diff --git a/dist-electron/win-unpacked/resources/app.asar b/dist-electron/win-unpacked/resources/app.asar index 0885f60..5d57fcc 100644 Binary files a/dist-electron/win-unpacked/resources/app.asar and b/dist-electron/win-unpacked/resources/app.asar differ diff --git a/electron-builder.json5 b/electron-builder.json5 index c07d8e1..672e5bb 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -36,6 +36,12 @@ "filter": ["**/*"] } ], + "protocols": [ + { + "name": "Element URL", + "schemes": ["element"] + } + ], "linux": { "target": [ { @@ -51,6 +57,7 @@ "Name": "Paarrot", "GenericName": "Matrix Client", "Comment": "A Matrix client built with Cinny", + "MimeType": "x-scheme-handler/element;", "Categories": "Network;InstantMessaging;", "Keywords": "matrix;chat;messaging;", "StartupWMClass": "paarrot" diff --git a/electron/main.js b/electron/main.js index 7f79737..4ec1ffc 100644 --- a/electron/main.js +++ b/electron/main.js @@ -18,11 +18,13 @@ const PORT = 44548; const execAsync = promisify(exec); const store = new Store(); +const ELEMENT_PROTOCOL = 'element'; let mainWindow = null; let tray = null; let isQuitting = false; let apiServer = null; +let pendingDeepLink = null; // Allow audio playback without requiring a prior user gesture (needed for notification sounds) app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); @@ -115,16 +117,116 @@ const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { - app.on('second-instance', () => { + app.on('second-instance', (event, argv) => { // Someone tried to run a second instance, focus our window + const deepLink = extractDeepLinkFromArgv(argv); + if (deepLink) { + pendingDeepLink = deepLink; + } + if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); + + if (pendingDeepLink) { + applyDeepLink(pendingDeepLink); + } } }); } +pendingDeepLink = extractDeepLinkFromArgv(process.argv); + +/** + * Extracts an element:// deep link URL from argv. + * @param {string[]} argv - Process argument vector. + * @returns {string|null} The deep link URL or null. + */ +function extractDeepLinkFromArgv(argv) { + if (!Array.isArray(argv)) { + return null; + } + + const match = argv.find((arg) => typeof arg === 'string' && arg.startsWith(`${ELEMENT_PROTOCOL}://`)); + return match || null; +} + +/** + * Converts an element:// deep link into an in-app hash route. + * @param {string} deepLink - The incoming protocol URL. + * @returns {string|null} Hash route value without leading #. + */ +function deepLinkToHashRoute(deepLink) { + if (!deepLink || !deepLink.startsWith(`${ELEMENT_PROTOCOL}://`)) { + return null; + } + + const hashIndex = deepLink.indexOf('#'); + if (hashIndex === -1) { + return null; + } + + const hashValue = deepLink.slice(hashIndex + 1); + if (!hashValue) { + return null; + } + + return hashValue.startsWith('/') ? hashValue : `/${hashValue}`; +} + +/** + * Applies a deep link to the current window and brings the app to foreground. + * @param {string} deepLink - The incoming protocol URL. + */ +function applyDeepLink(deepLink) { + if (!mainWindow) { + pendingDeepLink = deepLink; + return; + } + + const hashRoute = deepLinkToHashRoute(deepLink); + if (!hashRoute) { + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + mainWindow.show(); + mainWindow.focus(); + + if (isDev) { + mainWindow.loadURL(`${VITE_DEV_SERVER}/#${hashRoute}`).catch((error) => { + console.error('[Protocol] Failed to load deep link route in dev:', error); + }); + } else { + mainWindow.loadFile(path.join(__dirname, '../cinny/dist/index.html'), { hash: hashRoute }).catch((error) => { + console.error('[Protocol] Failed to load deep link route in production:', error); + }); + } + + pendingDeepLink = null; +} + +/** + * Registers the app as handler for element:// links. + */ +function registerElementProtocol() { + const before = app.isDefaultProtocolClient(ELEMENT_PROTOCOL); + + let registered = false; + if (process.defaultApp) { + registered = app.setAsDefaultProtocolClient(ELEMENT_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); + } else { + registered = app.setAsDefaultProtocolClient(ELEMENT_PROTOCOL); + } + + const after = app.isDefaultProtocolClient(ELEMENT_PROTOCOL); + console.log(`[Protocol] element:// before=${before} registerCall=${registered} after=${after}`); +} + // Helper to get correct icon path in dev vs packaged app function getIconPath(iconName) { if (isDev) { @@ -490,8 +592,14 @@ function createTray() { // App lifecycle app.whenReady().then(() => { + registerElementProtocol(); + createWindow(); createTray(); + + if (pendingDeepLink) { + applyDeepLink(pendingDeepLink); + } // Start API server apiServer = new PaarrotAPIServer(mainWindow); @@ -517,6 +625,11 @@ app.whenReady().then(() => { } }); +app.on('open-url', (event, url) => { + event.preventDefault(); + applyDeepLink(url); +}); + app.on('window-all-closed', () => { // On macOS, apps stay active until Cmd+Q if (process.platform !== 'darwin') {