diff --git a/cinny b/cinny index 8de528d..a4a0fa6 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 8de528da9a20c99a6c569b6ad95709705d00ef70 +Subproject commit a4a0fa675881f9fd6f678084fcb6a1e2decaf1a2 diff --git a/electron-builder.json5 b/electron-builder.json5 index 672e5bb..81c3a5b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -38,8 +38,8 @@ ], "protocols": [ { - "name": "Element URL", - "schemes": ["element"] + "name": "Paarrot URL", + "schemes": ["paarrot", "element"] } ], "linux": { @@ -57,7 +57,7 @@ "Name": "Paarrot", "GenericName": "Matrix Client", "Comment": "A Matrix client built with Cinny", - "MimeType": "x-scheme-handler/element;", + "MimeType": "x-scheme-handler/paarrot;x-scheme-handler/element;", "Categories": "Network;InstantMessaging;", "Keywords": "matrix;chat;messaging;", "StartupWMClass": "paarrot" diff --git a/electron/main.js b/electron/main.js index 4ec1ffc..9b8af2c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,7 +1,7 @@ const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron'); const path = require('path'); const fs = require('fs'); -const { exec } = require('child_process'); +const { exec, execFile } = require('child_process'); const { promisify } = require('util'); const Store = require('electron-store'); const open = require('open'); @@ -17,14 +17,26 @@ const VITE_DEV_SERVER = 'http://localhost:38347'; const PORT = 44548; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const store = new Store(); +const PAARROT_PROTOCOL = 'paarrot'; const ELEMENT_PROTOCOL = 'element'; +const PRIMARY_PROTOCOL = PAARROT_PROTOCOL; +const SUPPORTED_PROTOCOLS = [PAARROT_PROTOCOL, ELEMENT_PROTOCOL]; let mainWindow = null; let tray = null; let isQuitting = false; let apiServer = null; let pendingDeepLink = null; +let protocolRegistrationState = Object.fromEntries( + SUPPORTED_PROTOCOLS.map((scheme) => [scheme, { + before: false, + registerCall: false, + after: false, + error: null, + }]) +); // Allow audio playback without requiring a prior user gesture (needed for notification sounds) app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); @@ -139,7 +151,7 @@ if (!gotTheLock) { pendingDeepLink = extractDeepLinkFromArgv(process.argv); /** - * Extracts an element:// deep link URL from argv. + * Extracts a supported deep link URL from argv. * @param {string[]} argv - Process argument vector. * @returns {string|null} The deep link URL or null. */ @@ -148,17 +160,31 @@ function extractDeepLinkFromArgv(argv) { return null; } - const match = argv.find((arg) => typeof arg === 'string' && arg.startsWith(`${ELEMENT_PROTOCOL}://`)); + const match = argv.find((arg) => { + if (typeof arg !== 'string') { + return false; + } + + const lowerArg = arg.toLowerCase(); + return SUPPORTED_PROTOCOLS.some((scheme) => lowerArg.startsWith(`${scheme}://`)); + }); + return match || null; } /** - * Converts an element:// deep link into an in-app hash route. + * Converts a supported 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}://`)) { + if (!deepLink) { + return null; + } + + const lowerDeepLink = deepLink.toLowerCase(); + const isSupported = SUPPORTED_PROTOCOLS.some((scheme) => lowerDeepLink.startsWith(`${scheme}://`)); + if (!isSupported) { return null; } @@ -211,20 +237,228 @@ function applyDeepLink(deepLink) { } /** - * Registers the app as handler for element:// links. + * Registers the app as handler for a URL scheme. + * @param {string} scheme - URL protocol scheme name. + * @returns {{ before: boolean; registerCall: boolean; after: boolean; error: string | null }} */ -function registerElementProtocol() { - const before = app.isDefaultProtocolClient(ELEMENT_PROTOCOL); - +function registerProtocolScheme(scheme) { + const before = app.isDefaultProtocolClient(scheme); let registered = false; - if (process.defaultApp) { - registered = app.setAsDefaultProtocolClient(ELEMENT_PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); - } else { - registered = app.setAsDefaultProtocolClient(ELEMENT_PROTOCOL); + let error = null; + + try { + if (process.defaultApp) { + registered = app.setAsDefaultProtocolClient(scheme, process.execPath, [path.resolve(process.argv[1])]); + } else { + registered = app.setAsDefaultProtocolClient(scheme); + } + } catch (err) { + error = err instanceof Error ? err.message : String(err); } - const after = app.isDefaultProtocolClient(ELEMENT_PROTOCOL); - console.log(`[Protocol] element:// before=${before} registerCall=${registered} after=${after}`); + const after = app.isDefaultProtocolClient(scheme); + return { + before, + registerCall: registered, + after, + error, + }; +} + +/** + * Registers all supported URL protocol schemes. + */ +function registerAppProtocols() { + SUPPORTED_PROTOCOLS.forEach((scheme) => { + const schemeState = registerProtocolScheme(scheme); + protocolRegistrationState[scheme] = schemeState; + console.log(`[Protocol] ${scheme}:// before=${schemeState.before} registerCall=${schemeState.registerCall} after=${schemeState.after}${schemeState.error ? ` error=${schemeState.error}` : ''}`); + }); +} + +/** + * Reads a named Windows registry value. + * @param {string} keyPath - Registry key path. + * @param {string} valueName - Registry value name. + * @returns {Promise} Registry value content. + */ +async function queryWindowsRegistryValue(keyPath, valueName) { + try { + const { stdout } = await execAsync(`reg query "${keyPath}" /v "${valueName}"`); + const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const valueLine = lines.find((line) => line.toLowerCase().startsWith(valueName.toLowerCase())); + if (!valueLine) { + return null; + } + + const parsed = valueLine.split(/\s{2,}/).filter(Boolean); + return parsed.length >= 3 ? parsed.slice(2).join(' ') : null; + } catch { + return null; + } +} + +/** + * Reads the default Windows registry value. + * @param {string} keyPath - Registry key path. + * @returns {Promise} Default value content. + */ +async function queryWindowsRegistryDefault(keyPath) { + try { + const { stdout } = await execAsync(`reg query "${keyPath}" /ve`); + const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const valueLine = lines.find((line) => line.startsWith('(Default)')); + if (!valueLine) { + return null; + } + + const parsed = valueLine.split(/\s{2,}/).filter(Boolean); + return parsed.length >= 3 ? parsed.slice(2).join(' ') : null; + } catch { + return null; + } +} + +/** + * Provides Windows-specific protocol association diagnostics for one scheme. + * @param {string} scheme - URL protocol scheme. + * @returns {Promise<{userChoiceProgId: string | null; userChoiceCommand: string | null; hkcuCommand: string | null; hklmCommand: string | null}>} + */ +async function getWindowsProtocolDiagnosticsForScheme(scheme) { + const userChoiceProgId = await queryWindowsRegistryValue( + `HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`, + 'ProgId' + ); + + let userChoiceCommand = null; + if (userChoiceProgId) { + userChoiceCommand = + (await queryWindowsRegistryDefault(`HKCU\\Software\\Classes\\${userChoiceProgId}\\shell\\open\\command`)) || + (await queryWindowsRegistryDefault(`HKLM\\Software\\Classes\\${userChoiceProgId}\\shell\\open\\command`)); + } + + const hkcuCommand = await queryWindowsRegistryDefault(`HKCU\\Software\\Classes\\${scheme}\\shell\\open\\command`); + const hklmCommand = await queryWindowsRegistryDefault(`HKLM\\Software\\Classes\\${scheme}\\shell\\open\\command`); + + return { + userChoiceProgId, + userChoiceCommand, + hkcuCommand, + hklmCommand, + }; +} + +/** + * Ensures Windows has user-level URL protocol class entries for a scheme + * so the scheme appears in Default Apps link associations. + * @param {string} scheme - URL protocol scheme. + * @returns {Promise} + */ +async function ensureWindowsRegistryEntriesForScheme(scheme) { + if (process.platform !== 'win32') { + return; + } + + const openCommand = process.defaultApp && process.argv[1] + ? `"${process.execPath}" "${path.resolve(process.argv[1])}" "%1"` + : `"${process.execPath}" "%1"`; + const iconValue = `"${process.execPath}",0`; + const protocolDisplayName = `URL:${scheme} Protocol`; + + await execFileAsync('reg.exe', [ + 'add', + `HKCU\\Software\\Classes\\${scheme}`, + '/ve', + '/t', + 'REG_SZ', + '/d', + protocolDisplayName, + '/f', + ]); + + await execFileAsync('reg.exe', [ + 'add', + `HKCU\\Software\\Classes\\${scheme}`, + '/v', + 'URL Protocol', + '/t', + 'REG_SZ', + '/d', + '', + '/f', + ]); + + await execFileAsync('reg.exe', [ + 'add', + `HKCU\\Software\\Classes\\${scheme}\\DefaultIcon`, + '/ve', + '/t', + 'REG_SZ', + '/d', + iconValue, + '/f', + ]); + + await execFileAsync('reg.exe', [ + 'add', + `HKCU\\Software\\Classes\\${scheme}\\shell\\open\\command`, + '/ve', + '/t', + 'REG_SZ', + '/d', + openCommand, + '/f', + ]); +} + +/** + * Provides Windows diagnostics for all supported schemes. + * @returns {Promise>} + */ +async function getWindowsProtocolDiagnostics() { + if (process.platform !== 'win32') { + return null; + } + + const diagnostics = {}; + for (const scheme of SUPPORTED_PROTOCOLS) { + diagnostics[scheme] = await getWindowsProtocolDiagnosticsForScheme(scheme); + } + + return diagnostics; +} + +/** + * Returns renderer-friendly protocol diagnostics payload. + * @returns {Promise<{ success: boolean; data: { platform: string; primaryScheme: string; schemes: Record; windows: null | Record } }>} + */ +async function getProtocolStatusPayload() { + const schemes = {}; + for (const scheme of SUPPORTED_PROTOCOLS) { + const state = protocolRegistrationState[scheme] || { + before: false, + registerCall: false, + after: false, + error: null, + }; + + schemes[scheme] = { + isDefault: app.isDefaultProtocolClient(scheme), + before: state.before, + registerCall: state.registerCall, + error: state.error, + }; + } + + return { + success: true, + data: { + platform: process.platform, + primaryScheme: PRIMARY_PROTOCOL, + schemes, + windows: await getWindowsProtocolDiagnostics(), + }, + }; } // Helper to get correct icon path in dev vs packaged app @@ -592,7 +826,7 @@ function createTray() { // App lifecycle app.whenReady().then(() => { - registerElementProtocol(); + registerAppProtocols(); createWindow(); createTray(); @@ -704,6 +938,52 @@ ipcMain.handle('window:is-maximized', () => { return mainWindow ? mainWindow.isMaximized() : false; }); +/** + * Returns protocol registration health for the renderer settings page. + * @returns {Promise<{ success: boolean; data: { platform: string; primaryScheme: string; schemes: Record; windows: null | Record } }>} + */ +ipcMain.handle('protocol:get-status', () => { + return getProtocolStatusPayload(); +}); + +/** + * Re-runs protocol registration and opens OS default-apps page when manual selection is required. + * @returns {Promise<{ success: boolean; data: { openedSettings: boolean; platform: string; primaryScheme: string; schemes: Record; windows: null | Record } }>} + */ +ipcMain.handle('protocol:repair', async () => { + if (process.platform === 'win32') { + for (const scheme of SUPPORTED_PROTOCOLS) { + try { + await ensureWindowsRegistryEntriesForScheme(scheme); + } catch (error) { + console.warn(`[Protocol] Failed to write Windows URL protocol registry entries for ${scheme}:`, error); + } + } + } + + registerAppProtocols(); + + let openedSettings = false; + const isDefault = app.isDefaultProtocolClient(PRIMARY_PROTOCOL); + if (process.platform === 'win32' && !isDefault) { + try { + await shell.openExternal('ms-settings:defaultapps'); + openedSettings = true; + } catch (error) { + console.warn('[Protocol] Failed to open Windows default apps settings:', error); + } + } + + const status = await getProtocolStatusPayload(); + return { + success: true, + data: { + openedSettings, + ...status.data, + }, + }; +}); + ipcMain.handle('window:start-drag', () => { if (mainWindow) mainWindow.webContents.startDrag({ file: '', icon: nativeImage.createEmpty() }); }); diff --git a/electron/preload.js b/electron/preload.js index 7bc666b..274a2ba 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -18,7 +18,8 @@ const commandMap = { 'check_for_updates': 'check-for-updates', 'download_update': 'download-update', 'install_update': 'install-update', - 'get_desktop_sources': 'get-desktop-sources' + 'get_desktop_sources': 'get-desktop-sources', + 'get_protocol_status': 'protocol:get-status' }; // Shared invoke function for legacy API compatibility @@ -176,6 +177,12 @@ contextBridge.exposeInMainWorld('electron', { list: () => ipcRenderer.invoke('plugin:list'), uninstall: (pluginId) => ipcRenderer.invoke('plugin:uninstall', pluginId), readPluginCode: (pluginId) => ipcRenderer.invoke('plugin:read-code', pluginId) + }, + + // Desktop protocol diagnostics + protocol: { + getStatus: () => ipcRenderer.invoke('protocol:get-status'), + repair: () => ipcRenderer.invoke('protocol:repair') } });