diff --git a/desktopBg.cjs b/desktopBg.cjs index 25d344ea..2c6b7973 100644 --- a/desktopBg.cjs +++ b/desktopBg.cjs @@ -11,8 +11,11 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const userPreferences = require('./userPreferences.cjs'); +const { createLoginAutostart } = require('./loginAutostart.cjs'); const SUPPRESS_SHOW_KEY = 'onlykeySuppressShow'; +const LOGIN_ITEM_EXPLAINED_KEY = 'onlykey.loginItemAccessExplained'; +const LOGIN_ITEM_UI_READY = 'onlykey-login-item-ui-ready'; function isLikelyAppRoot(dir) { try { @@ -190,6 +193,16 @@ const state = { stopped: false, pendingTimers: [], autoLaunch: null, + userRevealedWindow: false, + macLoginItemsDeferred: false, + macLoginItemCleanupStarted: false, + macLoginItemsReady: false, + macPreferenceBeforeLoginItems: null, + autostartUserSet: false, + loginItemPromptDismissed: false, + macAutostartChain: Promise.resolve(), + loginItemExplainPromise: null, + loginItemPoll: null, }; const osx = os.platform() === 'darwin'; @@ -199,6 +212,179 @@ function hasNw() { return typeof nw !== 'undefined' && !!nw?.Window; } +/** + * Hidden autostart must not call System Events. Login Item cleanup waits + * until the user has opened the window and seen why macOS will ask. + */ +function shouldDeferMacLoginItems(platform, hiddenLaunch, userRevealed) { + return platform === 'darwin' && !!hiddenLaunch && !userRevealed; +} + +/** + * After the note, delete leftover Login Items. Migrate them into the + * LaunchAgent only when autostart is still wanted and no agent exists yet. + * A stored "off", or a checkbox change during the note, must not be undone. + */ +function macLoginItemFollowUp(userSet, preferenceOn, agentEnabled) { + if (userSet || agentEnabled || !preferenceOn) return 'remove-items'; + return 'migrate'; +} + +function loginItemAccessExplained() { + try { + return localStorage.getItem(LOGIN_ITEM_EXPLAINED_KEY) === '1'; + } catch { + return false; + } +} + +function markLoginItemAccessExplained() { + try { + localStorage.setItem(LOGIN_ITEM_EXPLAINED_KEY, '1'); + } catch { + // ignore + } +} + +function loginItemAppleScriptDeferred() { + return osx && !state.macLoginItemsReady; +} + +function enqueueMacAutostart(task) { + const run = state.macAutostartChain.then(task, task); + state.macAutostartChain = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +/** + * document-start inject scripts do not share the page's JavaScript globals. + * nw.Window.get().window is the page where LoginItemAccessHost registers. + */ +function appPageWindow() { + const seen = []; + const push = (candidate) => { + if (candidate && !seen.includes(candidate)) seen.push(candidate); + }; + try { + if (typeof nw !== 'undefined' && nw.Window && typeof nw.Window.get === 'function') { + const nwWin = nw.Window.get(); + if (nwWin) push(nwWin.window); + } + } catch { + // ignore + } + if (typeof window !== 'undefined') push(window); + return seen; +} + +function stopLoginItemPoll() { + if (state.loginItemPoll) { + clearInterval(state.loginItemPoll); + state.loginItemPoll = null; + } +} + +/** The React confirm dialog owns the UI. This only waits for its answer. */ +function requestLoginItemDecision() { + return new Promise((resolve) => { + let finished = false; + let asked = false; + const finish = (value) => { + if (finished) return; + finished = true; + stopLoginItemPoll(); + resolve(value); + }; + const tryAsk = () => { + if (finished || asked) return asked; + for (const page of appPageWindow()) { + const ask = page && page.onlykeyAskLoginItemAccess; + if (typeof ask !== 'function') continue; + asked = true; + stopLoginItemPoll(); + Promise.resolve() + .then(() => ask()) + .then((answer) => finish(answer), () => finish(false)); + return true; + } + return false; + }; + const onReady = () => { + tryAsk(); + }; + for (const page of appPageWindow()) { + if (page && typeof page.addEventListener === 'function') { + page.addEventListener(LOGIN_ITEM_UI_READY, onReady); + } + } + if (tryAsk()) return; + state.loginItemPoll = setInterval(tryAsk, 50); + }); +} + +/** Resolves true when it is safe to call System Events. */ +function explainLoginItemAccess() { + if (!osx || isDesktopTestMode()) return Promise.resolve(false); + if (loginItemAccessExplained()) return Promise.resolve(true); + if (state.loginItemExplainPromise) return state.loginItemExplainPromise; + const pending = requestLoginItemDecision().then((shown) => { + if (!shown) state.loginItemExplainPromise = null; + return shown; + }); + state.loginItemExplainPromise = pending; + return pending; +} + +async function finishMacLoginItemAccess() { + let proceed = false; + try { + const answer = await explainLoginItemAccess(); + if (answer === 'later') { + state.loginItemPromptDismissed = true; + state.macLoginItemCleanupStarted = false; + return; + } + if (answer !== true) { + state.macLoginItemCleanupStarted = false; + return; + } + proceed = true; + if (state.stopped || !state.autoLaunch) return; + const followUp = macLoginItemFollowUp( + state.autostartUserSet, + state.macPreferenceBeforeLoginItems === true, + await state.autoLaunch.isEnabled(), + ); + if (followUp === 'remove-items') { + await state.autoLaunch.removeLegacyLoginItems(); + return; + } + const result = await state.autoLaunch.repair(); + if (state.autostartUserSet) return; + userPreferences.autoLaunch = !!result.enabled; + if (state.tray) assignTrayMenu(buildTrayMenu(state.autoLaunch)); + } catch (error) { + console.error('Login item cleanup failed:', error); + } finally { + if (proceed) { + markLoginItemAccessExplained(); + state.macLoginItemsReady = true; + } + } +} + +function scheduleMacLoginItemCleanup() { + if (!osx || isDesktopTestMode() || state.loginItemPromptDismissed) return; + if (state.macLoginItemCleanupStarted) return; + if (!state.autoLaunch) return; + if (state.macLoginItemsDeferred && !state.userRevealedWindow) return; + state.macLoginItemCleanupStarted = true; + enqueueMacAutostart(() => finishMacLoginItemAccess()); +} + function trayInitActive() { return !state.stopped && hasNw(); } @@ -447,6 +633,7 @@ function resolveTrayIconPath() { } function revealWindow(win) { + state.userRevealedWindow = true; setSuppressShow(false); try { if (win.isMinimized) win.restore(); @@ -460,12 +647,14 @@ function revealWindow(win) { } win.show(true); win.focus(); + scheduleMacLoginItemCleanup(); } function executeShowMainWindow() { const reveal = () => { if (state.quitting || state.stopped || !hasNw()) return; state.quitting = false; + state.userRevealedWindow = true; setSuppressShow(false); trayLog('executeShowMainWindow', { hasMainRef: !!state.mainAppWindow, @@ -674,14 +863,42 @@ function collectArgv() { return values; } -/** True when this process was started by the OS login entry (--hidden). */ +function hasHiddenLaunchArg(arg) { + return arg === '--hidden' || arg === '--onlykey-autostart' || arg.startsWith('--hidden='); +} + +/** True when this process was started by the OS login entry. */ function launchedHiddenToTray() { - return collectArgv().some((arg) => arg === '--hidden' || arg.startsWith('--hidden=')); + try { + if (process.env.ONLYKEY_LAUNCHED_HIDDEN === '1') return true; + } catch { + // ignore + } + return collectArgv().some(hasHiddenLaunchArg); } function isHiddenLaunchCommand(cmdline) { const line = Array.isArray(cmdline) ? cmdline.map(String).join(' ') : String(cmdline || ''); - return /(?:^|[\s"'=])--hidden(?:\s|"|'|=|$)/.test(line); + return /(?:^|[\s"'=])--(?:hidden|onlykey-autostart)(?:\s|"|'|=|$)/.test(line); +} + +function localSuppressShow() { + try { + return localStorage.getItem(SUPPRESS_SHOW_KEY) === '1'; + } catch { + return false; + } +} + +/** + * Autostart must not paint the window, including when a focus event arrives + * before the suppress flag is put back. After the user asks for the window, + * only an explicit hide-to-tray keeps it hidden. + */ +function shouldKeepWindowHidden() { + if (state.userRevealedWindow) return readSuppressShow() || localSuppressShow(); + if (launchedHiddenToTray()) return true; + return readSuppressShow() || localSuppressShow(); } /** @@ -690,6 +907,7 @@ function isHiddenLaunchCommand(cmdline) { */ function applyLaunchVisibility(win) { if (!launchedHiddenToTray()) return false; + state.userRevealedWindow = false; setSuppressShow(true); if (!win) return true; try { @@ -845,25 +1063,30 @@ function buildTrayMenu(autoLaunch) { checked: !!userPreferences.autoLaunch, click: function () { if (!autoLaunch) { - // Keep checkbox in sync with reality when auto-launch is unavailable. + // Keep checkbox in sync with reality when autostart is unavailable. autoLaunchMenuItem.checked = false; userPreferences.autoLaunch = false; return; } const next = !userPreferences.autoLaunch; - userPreferences.autoLaunch = next; - autoLaunch - .isEnabled() - .then((isEnabled) => { - if (isEnabled && !next) return autoLaunch.disable(); - if (!isEnabled && next) return autoLaunch.enable(); - return undefined; - }) - .catch(console.error) - .finally(() => { - // Rebuild so Linux tray picks up the new checked state. - assignTrayMenu(buildTrayMenu(autoLaunch)); - }); + state.autostartUserSet = true; + enqueueMacAutostart(() => { + const autostartOptions = loginItemAppleScriptDeferred() ? { skipLoginItems: true } : undefined; + const op = next ? autoLaunch.enable(autostartOptions) : autoLaunch.disable(autostartOptions); + return op + .catch(console.error) + .then(() => autoLaunch.isEnabled()) + .then((enabled) => { + userPreferences.autoLaunch = !!enabled; + }) + .catch(() => { + userPreferences.autoLaunch = next; + }) + .finally(() => { + // Rebuild so Linux tray picks up the new checked state. + assignTrayMenu(buildTrayMenu(autoLaunch)); + }); + }); }, }); @@ -927,81 +1150,14 @@ function buildTrayMenu(autoLaunch) { return settingsMenu; } -/** - * Login autostart used to open the window (and, on macOS, a visible login - * item). Rewrite an already-enabled entry once so the next login stays in - * the tray. Never call this from desktop unit tests — the dev execPath can - * resolve to a real HKCU Run value. - */ -async function adoptHiddenAutostart(autoLaunch, launchPath, appName) { - const marker = path.join(tmpDir(), 'autostart-hidden-synced'); - - if (osx && !fs.existsSync(marker)) { - try { - const AutoLaunch = require('auto-launch'); - const legacy = new AutoLaunch({ - name: appName, - path: launchPath, - isHidden: true, - mac: { useLaunchAgent: false }, - }); - if (await legacy.isEnabled()) { - await legacy.disable(); - await autoLaunch.enable(); - try { - fs.writeFileSync(marker, '1'); - } catch { - // ignore - } - return; - } - } catch (error) { - console.error('Legacy login-item migration failed:', error); - } - } - - let enabled = false; - try { - enabled = !!(await autoLaunch.isEnabled()); - } catch { - return; - } - - if (!enabled) { - try { - if (!fs.existsSync(marker)) fs.writeFileSync(marker, '1'); - } catch { - // ignore - } - return; - } - - if (fs.existsSync(marker)) return; - - try { - await autoLaunch.disable(); - await autoLaunch.enable(); - fs.writeFileSync(marker, '1'); - } catch (error) { - console.error('Could not update autostart to launch in the tray:', error); - try { - await autoLaunch.enable(); - } catch { - // leave the previous attempt's error - } - } -} - /** * macOS: the path a login entry must launch. * * process.execPath in NW.js's node context is a Chromium HELPER, not the app: * …/OnlyKey App.app/Contents/Frameworks/nwjs Framework.framework/Versions// * Helpers/nwjs Helper (Renderer).app/Contents/MacOS/nwjs Helper (Renderer). - * auto-launch only knows Electron's helper layout, so it kept that path - and - * named the LaunchAgent after it - and at login macOS started the renderer - * helper with --hidden instead of the app. Use the outer bundle's own - * executable (Contents/MacOS/). + * Login autostart does not launch that helper. It `open`s the outer bundle. + * This resolves the bundle's own executable (Contents/MacOS/). */ function macBundleRoot(execPath) { const match = /^(.+?\.app)(?:\/|$)/.exec(String(execPath || '')); @@ -1059,45 +1215,44 @@ async function initTray() { if (state.tray || !trayInitActive()) return; const appPath = process.execPath; - const appName = appPath.includes('node_modules') ? 'OnlyKey-dev' : 'OnlyKey'; - // Prefer the launcher wrapper when installed under /opt so login restart - // goes through the same path as the desktop entry. - let launchPath = fs.existsSync(path.join(path.dirname(appPath), 'onlykey-launch')) - ? path.join(path.dirname(appPath), 'onlykey-launch') - : appPath; - if (osx) launchPath = macLaunchPath(appPath); let autoLaunch = null; try { - const AutoLaunch = require('auto-launch'); - autoLaunch = new AutoLaunch({ - name: appName, - path: launchPath, - // --hidden on Windows/Linux; LaunchAgent ProgramArguments on macOS. - // The app honors --hidden by staying in the tray. - isHidden: true, - mac: { useLaunchAgent: true }, + autoLaunch = createLoginAutostart({ + execPath: appPath, + allowWrites: !isDesktopTestMode(), }); } catch (error) { - console.error('AutoLaunch init skipped:', error); + console.error('Autostart init skipped:', error); } - // Resolve OS auto-launch state BEFORE first menu paint. Doing it afterward and + // Resolve OS autostart state BEFORE first menu paint. Doing it afterward and // calling remove()/insert() emptied the Linux tray menu (Cinnamon/Mint). - if (autoLaunch) { + // Desktop unit tests must not read or write the developer machine's login items. + // On Mac the note and System Events call happen after the tray exists, so + // closing the window before Continue still leaves a tray icon. + const deferMacLoginItems = shouldDeferMacLoginItems( + os.platform(), + launchedHiddenToTray(), + state.userRevealedWindow, + ); + if (autoLaunch && !isDesktopTestMode()) { try { - if (!isDesktopTestMode()) { - if (osx && removeHelperLaunchAgents(appPath)) { - // Autostart was on but pointed at a helper: register the app itself. - await autoLaunch.enable(); + if (osx) { + state.macLoginItemsDeferred = deferMacLoginItems; + if (state.macPreferenceBeforeLoginItems == null && !state.autostartUserSet) { + state.macPreferenceBeforeLoginItems = !!userPreferences.autoLaunch; } - await adoptHiddenAutostart(autoLaunch, launchPath, appName); } + await autoLaunch.repair(osx ? { skipLoginItems: true } : undefined); if (!trayInitActive()) return; - const enabled = await autoLaunch.isEnabled(); + const enabled = !!(await autoLaunch.isEnabled()); if (!trayInitActive()) return; - userPreferences.autoLaunch = !!enabled; - } catch { - // auto-launch may not be supported on all platforms + // Keep a stored "on" until the visible-window pass can see Login Items. + // Persisting "off" here would skip that migration if the note is dismissed. + const mayMigrate = osx && !enabled && state.macPreferenceBeforeLoginItems === true && !state.autostartUserSet; + if (!mayMigrate) userPreferences.autoLaunch = enabled; + } catch (error) { + console.error('Autostart repair failed:', error); } } @@ -1123,6 +1278,7 @@ async function initTray() { assignTrayMenu(settingsMenu); trayLog('initTray complete', { iconPath, menuItems: state.menuLabels.length }); markTrayReady(true); + scheduleMacLoginItemCleanup(); // Re-assign once more on next tick — some Linux tray hosts register the icon // asynchronously and drop the first menu attachment. @@ -1254,6 +1410,7 @@ function startWindowPoll(tick, maxPolls) { function stop() { state.stopped = true; + stopLoginItemPoll(); if (state.commandPoll) { clearInterval(state.commandPoll); state.commandPoll = null; @@ -1310,6 +1467,10 @@ module.exports = { showMainWindow, quitApp, launchedHiddenToTray, + shouldDeferMacLoginItems, + macLoginItemFollowUp, + requestLoginItemDecision, + shouldKeepWindowHidden, applyLaunchVisibility, dispatchTrayCommand, processTrayCommand, diff --git a/desktopInject.js b/desktopInject.js index 34b7d8d2..3c7a389c 100644 --- a/desktopInject.js +++ b/desktopInject.js @@ -31,12 +31,11 @@ var showWin = function () { try { - if (localStorage.getItem('onlykeySuppressShow') === '1') return; - var fs = require('fs'); - var path = require('path'); - if (fs.existsSync(path.join(nw.App.startPath, 'tmp', 'suppress-show.json'))) return; + if (desktop && typeof desktop.shouldKeepWindowHidden === 'function' && desktop.shouldKeepWindowHidden()) { + return; + } } catch { - // suppress-show probe is best-effort + // show the window if the hidden-launch check itself fails } if (win.isMinimized && win.restore) win.restore(); if (!win.isVisible) win.show(true); diff --git a/docs/desktop-tray.md b/docs/desktop-tray.md index 9c0ca282..82f5b509 100644 --- a/docs/desktop-tray.md +++ b/docs/desktop-tray.md @@ -35,7 +35,9 @@ Do **not** pass `title: undefined` into `nw.Tray` — macOS draws that string ne 1. `package.json` → `window.inject_js_start: desktopInject.js` calls `desktopBg.cjs` → `start()`. 2. `window.show` is **false**. `desktopInject.js` shows the window at document-start, except an autostart launch (`--hidden`), which stays in the tray. Do not set `show` back to `true` — that flashes a window on login. 3. `start()` clears stale `tmp/tray-*`, binds close-to-tray, calls `initTray()` in the **main renderer**, polls `tmp/tray-command.json` (for tests / fallback). It also polls `~/.config/OnlyKey/app-tmp/tray-command.json`, which the Linux `onlykey-launch` wrapper writes. -4. Tray menu `click` handlers call `dispatchTrayCommand()` → `processTrayCommand()` in the same renderer. The autostart checkbox label is **Autostart to tray** (`isHidden: true`, macOS LaunchAgent). +4. Tray menu `click` handlers call `dispatchTrayCommand()` → `processTrayCommand()` in the same renderer. The autostart checkbox label is **Autostart to tray**. `loginAutostart.cjs` registers one login entry that passes `--hidden --onlykey-autostart`: Windows `HKCU\...\Run\OnlyKey` (and clears a disabled StartupApproved flag; the old value was named `nw` and Task Manager left it off), macOS LaunchAgent `to.crp.onlykey` via `open -gj` on the app bundle (legacy helper agents are removed so Dock icons do not accumulate; Login Items are removed later, while the window is visible), Linux `~/.config/autostart/OnlyKey.desktop` through `onlykey-launch`. + +The first time a visible window is about to ask System Events to list Login Items, `LoginItemAccessHost` shows the shared confirm dialog. The desktop shell waits for Continue or Not now by calling `onlykeyAskLoginItemAccess` on the page window (`nw.Window.get().window`). The document-start inject script does not share that page's JavaScript globals, so the shell keeps checking the page window until React registers the callback. Continue, then choose Allow on the macOS prompt. Not now leaves those Login Items until the next time the window is opened. The tray is already up, so the note does not block it. A `--hidden --onlykey-autostart` launch does not show that note and does not run the AppleScript; the LaunchAgent is still registered. A stored autostart "off" is not turned back on. `NSAppleEventsUsageDescription` in `resources/osx/Info.plist` is the purpose line macOS can show on the prompt. 5. A second launch while the window is hidden (Start menu, Dock, Applications, taskbar pin, CLI — anything that hits the same executable) is Chromium single-instance. `nw.App` `open` (Windows/Linux) and `reopen` (macOS) call `executeShowMainWindow()`. A second `--hidden` autostart does not. 6. `scripts/start-desktop.mjs` clears tray tmp files before launch and `taskkill`s stale `nw.exe` on exit. @@ -54,7 +56,7 @@ On Cinnamon/Mint/GNOME StatusNotifier, **never** mutate an already-assigned tray Correct pattern (see `buildTrayMenu` / `assignTrayMenu` in `desktopBg.cjs`): -1. Resolve auto-launch enabled state **before** first menu paint. +1. Resolve autostart enabled state **before** first menu paint. 2. Build a full `nw.Menu` with final checkbox values. 3. Assign `tray.menu` once (re-assign on Linux after ~250ms). 4. On preference toggles, **rebuild** a new menu and reassign — do not remove/insert in place. diff --git a/docs/login-autostart/allow-system-events-modal.jpg b/docs/login-autostart/allow-system-events-modal.jpg new file mode 100644 index 00000000..3247327a Binary files /dev/null and b/docs/login-autostart/allow-system-events-modal.jpg differ diff --git a/docs/login-autostart/automation-permission-enabled.jpg b/docs/login-autostart/automation-permission-enabled.jpg new file mode 100644 index 00000000..ae237928 Binary files /dev/null and b/docs/login-autostart/automation-permission-enabled.jpg differ diff --git a/docs/login-autostart/macos-system-events-prompt.jpg b/docs/login-autostart/macos-system-events-prompt.jpg new file mode 100644 index 00000000..5048feb5 Binary files /dev/null and b/docs/login-autostart/macos-system-events-prompt.jpg differ diff --git a/index.html b/index.html index ec6f2c7c..b4be3cd1 100644 --- a/index.html +++ b/index.html @@ -60,8 +60,7 @@ } var showWin = function () { try { - if (localStorage.getItem('onlykeySuppressShow') === '1') return; - if (fs.existsSync(path.join(nw.App.startPath, 'tmp', 'suppress-show.json'))) return; + if (desktopApi.shouldKeepWindowHidden && desktopApi.shouldKeepWindowHidden()) return; } catch (e) {} if (win.isMinimized && win.restore) win.restore(); if (!win.isVisible) win.show(true); diff --git a/loginAutostart.cjs b/loginAutostart.cjs new file mode 100644 index 00000000..e45131c4 --- /dev/null +++ b/loginAutostart.cjs @@ -0,0 +1,864 @@ +/** + * Login autostart for the packaged app. + * + * The `auto-launch` package is not used. It renamed the Windows Run value to + * the executable (`nw`), ignored Task Manager's StartupApproved flag, and on + * macOS it launched the binary inside the bundle (and, on older builds, added + * another Login Item). That left Windows logged-in-but-not-started, and Mac + * with a visible window plus a new Dock icon for every release candidate. + * + * One entry, named for the product: + * Windows HKCU\...\Run\OnlyKey → nw.exe "" --hidden --onlykey-autostart + * plus StartupApproved enabled (02 00 …). A disabled `nw` value that + * points at this install is migrated once; a disabled `OnlyKey` + * value is left off. + * macOS ~/Library/LaunchAgents/to.crp.onlykey.plist + * runs `open -gj --args --hidden --onlykey-autostart` + * so Launch Services owns a single Dock tile. Legacy agents are + * removed immediately. Login Items are removed with System Events + * only while the window is visible. Pass skipLoginItems to leave + * them in place (a hidden autostart launch does this). + * Linux ~/.config/autostart/OnlyKey.desktop via onlykey-launch --hidden. + */ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const WINDOWS_RUN = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +const WINDOWS_APPROVED = + 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run'; +const ENABLED_APPROVAL = new Set([0x02, 0x06, 0x08]); +const MAC_LABEL = 'to.crp.onlykey'; +const MAC_LABEL_DEV = 'to.crp.onlykey.dev'; + +const LINUX_LEGACY_DESKTOPS = new Set([ + 'nw.desktop', + 'nwjs.desktop', + 'onlykey-launch.desktop', + 'OnlyKey App.desktop', + 'OnlyKey-dev.desktop', +]); + +function registrationName(execPath) { + const normalized = String(execPath || '').replace(/\\/g, '/').toLowerCase(); + return normalized.includes('/node_modules/') ? 'OnlyKey-dev' : 'OnlyKey'; +} + +function quoteWindowsArg(value) { + return `"${String(value).replace(/"/g, '')}"`; +} + +/** Command stored in the Run key. The directory argument is the app root. */ +function windowsRunCommand(exePath) { + const exe = path.win32.normalize(String(exePath || '')); + const dir = path.win32.dirname(exe); + return `${quoteWindowsArg(exe)} ${quoteWindowsArg(dir)} --hidden --onlykey-autostart`; +} + +function normalizeWinPath(value) { + return String(value || '').replace(/\//g, '\\').toLowerCase(); +} + +function commandTargetsExe(command, exePath) { + if (!command || !exePath) return false; + return normalizeWinPath(command).includes(normalizeWinPath(exePath)); +} + +function parseApproved(value) { + if (value == null || value === '') return null; + const parts = String(value).trim().split(/[-\s]+/).filter(Boolean); + if (!parts.length || parts.some((part) => !/^[0-9a-fA-F]{2}$/.test(part))) return null; + return Buffer.from(parts.map((part) => Number.parseInt(part, 16))); +} + +/** Missing StartupApproved means Windows will run the Run value. */ +function approvalEnabled(bytes) { + if (bytes == null || !Buffer.isBuffer(bytes) || bytes.length === 0) return true; + return ENABLED_APPROVAL.has(bytes[0]); +} + +/** + * @param {{ name: string, exePath: string, runValues: object, approvals: object, force?: boolean }} opts + * force is the tray checkbox turning autostart on. Repair must not pass it: + * a StartupApproved disable of the OnlyKey value is the user (or Task Manager) + * turning that entry off, and the next launch has to leave it off. + */ +function planWindowsUpdate(opts) { + const name = opts.name; + const exePath = opts.exePath; + const run = opts.runValues || {}; + const approved = opts.approvals || {}; + const desired = windowsRunCommand(exePath); + const legacy = Object.keys(run).filter((key) => key !== name && commandTargetsExe(run[key], exePath)); + const hasCanonical = Object.prototype.hasOwnProperty.call(run, name); + const canonical = hasCanonical ? run[name] : null; + + if (opts.force) { + return { enabled: true, write: desired, enableApproved: true, deleteRun: legacy }; + } + + if (hasCanonical && !approvalEnabled(parseApproved(approved[name]))) { + return { enabled: false, write: null, enableApproved: false, deleteRun: legacy }; + } + + if (!hasCanonical && legacy.length === 0) { + return { enabled: false, write: null, enableApproved: false, deleteRun: [] }; + } + + const commandOk = canonical === desired; + const approvalOk = hasCanonical && approvalEnabled(parseApproved(approved[name])); + if (commandOk && approvalOk && legacy.length === 0) { + return { enabled: true, write: null, enableApproved: false, deleteRun: [] }; + } + + return { enabled: true, write: desired, enableApproved: true, deleteRun: legacy }; +} + +function planWindowsDisable(name, exePath, runValues) { + const run = runValues || {}; + const legacy = Object.keys(run).filter((key) => key !== name && commandTargetsExe(run[key], exePath)); + const deleteRun = Object.prototype.hasOwnProperty.call(run, name) ? [name, ...legacy] : legacy; + return { enabled: false, write: null, enableApproved: false, deleteRun }; +} + +function macBundleRoot(execPath) { + const match = /^(.+?\.app)(?:\/|$)/.exec(String(execPath || '').replace(/\\/g, '/')); + return match ? match[1] : null; +} + +function macLabelFor(execPath) { + return registrationName(execPath) === 'OnlyKey-dev' ? MAC_LABEL_DEV : MAC_LABEL; +} + +function xmlEscape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** `open -gj` keeps Launch Services on the bundle, so Dock tiles do not multiply. */ +function macLaunchAgentPlist(bundlePath, label) { + const args = ['/usr/bin/open', '-gj', bundlePath, '--args', '--hidden', '--onlykey-autostart']; + const body = args.map((arg) => ` ${xmlEscape(arg)}`).join('\n'); + return ` + + + + Label + ${xmlEscape(label)} + ProgramArguments + +${body} + + RunAtLoad + + LimitLoadToSessionType + Aqua + + +`; +} + +function macAgentIsCurrent(text, bundlePath) { + const body = String(text || ''); + if (!body.includes('--hidden') && !body.includes('--onlykey-autostart')) return false; + if (bundlePath && !body.includes(bundlePath)) return false; + return body.includes('/usr/bin/open'); +} + +function shouldRemoveMacAgent(filename, text, opts) { + if (!filename || !String(filename).endsWith('.plist')) return false; + if (filename === `${opts.label}.plist`) return false; + const body = String(text || ''); + if (opts.devMode) return !!(opts.bundlePath && body.includes(opts.bundlePath)); + if (opts.bundlePath && body.includes(opts.bundlePath)) return true; + if (body.includes('OnlyKey App.app')) return true; + if (body.includes(MAC_LABEL)) return true; + return false; +} + +function shouldRemoveLoginItem(item, opts) { + const itemPath = String((item && item.itemPath) || ''); + if (!itemPath) return false; + if (opts.devMode) return !!(opts.bundlePath && itemPath.includes(opts.bundlePath)); + if (opts.bundlePath && itemPath.includes(opts.bundlePath)) return true; + return itemPath.includes('OnlyKey App.app'); +} + +function planMac(opts) { + const want = !!opts.canonicalText || opts.legacyNames.length > 0 || opts.loginItems.length > 0; + if (!want) { + return { + enabled: false, + write: false, + deleteAgents: opts.legacyNames, + deleteLoginItems: opts.loginItems, + }; + } + return { + enabled: true, + write: opts.canonicalText !== opts.desiredText, + deleteAgents: opts.legacyNames, + deleteLoginItems: opts.loginItems, + }; +} + +function normalizeMacPath(value) { + let text = String(value || '').trim(); + if (text.startsWith('file://')) { + text = text.slice('file://'.length); + try { + text = decodeURI(text); + } catch { + // keep the raw path + } + } + if (text.length > 1 && text.endsWith('/')) text = text.slice(0, -1); + return text; +} + +function isOnlyKeyDockTile(tile) { + if (!tile) return false; + if (tile.bundleId === MAC_LABEL || tile.bundleId === MAC_LABEL_DEV) return true; + const url = normalizeMacPath(tile.url); + if (url.includes('/OnlyKey App.app')) return true; + const label = tile.label || ''; + return (label === 'OnlyKey App' || label === 'OnlyKey') && url.includes('OnlyKey'); +} + +/** + * Collapse extra OnlyKey Dock tiles. A single live tile is left alone so a + * normal launch does not restart the Dock. + */ +function dockTilesToDrop(tiles, bundlePath, exists) { + const current = normalizeMacPath(bundlePath); + const onlyKey = []; + (tiles || []).forEach((tile, index) => { + if (!isOnlyKeyDockTile(tile)) return; + onlyKey.push({ index, path: normalizeMacPath(tile.url) }); + }); + const currentOnes = onlyKey.filter((entry) => entry.path === current); + const drop = []; + currentOnes.slice(1).forEach((entry) => drop.push(entry.index)); + for (const entry of onlyKey) { + if (entry.path === current) continue; + const missing = !entry.path || (typeof exists === 'function' && !exists(entry.path)); + if (missing || currentOnes.length > 0) drop.push(entry.index); + } + return drop; +} + +function quoteDesktopExec(value) { + const text = String(value); + if (!/[\s"\\]/.test(text)) return text; + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function linuxExecCommand(exePath, existsSync) { + const dir = path.dirname(exePath); + const wrapper = path.join(dir, 'onlykey-launch'); + if (existsSync(wrapper)) { + return `${quoteDesktopExec(wrapper)} --hidden --onlykey-autostart`; + } + return `${quoteDesktopExec(exePath)} ${quoteDesktopExec(dir)} --hidden --onlykey-autostart`; +} + +function linuxDesktopContents(execLine) { + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=OnlyKey', + 'Comment=OnlyKey startup script', + `Exec=${execLine}`, + 'StartupNotify=false', + 'Terminal=false', + 'X-GNOME-Autostart-enabled=true', + '', + ].join('\n'); +} + +function linuxAutostartDisabled(text) { + const body = String(text || ''); + return /^Hidden=true\s*$/m.test(body) || /^X-GNOME-Autostart-enabled=false\s*$/m.test(body); +} + +function desktopExecLine(text) { + const match = String(text || '').match(/^Exec=(.*)$/m); + return match ? match[1].trim() : ''; +} + +function shouldRemoveLinuxAutostart(filename, text, opts) { + if (filename === `${opts.name}.desktop`) return false; + if (opts.devMode) return false; + if (!LINUX_LEGACY_DESKTOPS.has(filename)) return false; + const body = String(text || ''); + return body.includes('onlykey-launch') || body.includes('/opt/OnlyKey/') || body.includes('OnlyKey'); +} + +function planLinux(opts) { + const disabled = !!(opts.canonicalText && linuxAutostartDisabled(opts.canonicalText)); + if (disabled || (!opts.canonicalText && opts.legacyNames.length === 0)) { + return { enabled: false, write: false, deleteLegacy: opts.legacyNames }; + } + const execOk = opts.canonicalText && desktopExecLine(opts.canonicalText) === desktopExecLine(opts.desiredText); + return { enabled: true, write: !execOk, deleteLegacy: opts.legacyNames }; +} + +function parseLoginItems(output) { + const items = []; + const chunks = String(output || '').split(/\r?\n---\r?\n/); + for (const chunk of chunks) { + const lines = chunk.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0); + if (lines.length < 2) continue; + items.push({ name: lines[0], itemPath: lines[1] }); + } + return items; +} + +function appleString(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function defaultRun(file, args, options = {}) { + // Resolved at call time so tests can spy on child_process.spawnSync. + const runner = require('child_process').spawnSync; + return runner(file, args, { + encoding: 'utf8', + timeout: options.timeout || 20000, + windowsHide: true, + input: options.input, + env: options.env ? { ...process.env, ...options.env } : process.env, + }); +} + +function assertRan(result, label) { + if (!result || result.error) { + throw result?.error || new Error(`${label} failed`); + } + if (result.status !== 0) { + const detail = `${result.stderr || ''}\n${result.stdout || ''}`.trim(); + throw new Error(detail || `${label} exited ${result.status}`); + } + return result.stdout || ''; +} + +function powershellExe() { + const root = process.env.SystemRoot || process.env.windir || 'C:\\Windows'; + return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +} + +const READ_WINDOWS_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +function Read-Key([string]$path) { + $map = @{} + $item = Get-Item -LiteralPath $path -ErrorAction SilentlyContinue + if (-not $item) { return $map } + foreach ($n in $item.GetValueNames()) { + if ([string]::IsNullOrEmpty($n)) { continue } + $v = $item.GetValue($n) + if ($v -is [byte[]]) { $map[$n] = [System.BitConverter]::ToString($v) } + else { $map[$n] = [string]$v } + } + return $map +} +@{ + run = (Read-Key $env:ONLYKEY_RUN_KEY) + approved = (Read-Key $env:ONLYKEY_APPROVED_KEY) +} | ConvertTo-Json -Compress -Depth 4 +`; + +const APPLY_WINDOWS_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$run = $env:ONLYKEY_RUN_KEY +$approved = $env:ONLYKEY_APPROVED_KEY +if ($env:ONLYKEY_WRITE_VALUE) { + if (-not (Test-Path -LiteralPath $run)) { New-Item -Path $run -Force | Out-Null } + New-ItemProperty -LiteralPath $run -Name $env:ONLYKEY_WRITE_NAME -PropertyType String -Value $env:ONLYKEY_WRITE_VALUE -Force | Out-Null +} +if ($env:ONLYKEY_ENABLE_APPROVED -eq '1') { + if (-not (Test-Path -LiteralPath $approved)) { New-Item -Path $approved -Force | Out-Null } + $bytes = [byte[]](2,0,0,0,0,0,0,0,0,0,0,0) + New-ItemProperty -LiteralPath $approved -Name $env:ONLYKEY_WRITE_NAME -PropertyType Binary -Value $bytes -Force | Out-Null +} +if ($env:ONLYKEY_DELETE_RUN) { + foreach ($n in $env:ONLYKEY_DELETE_RUN.Split('|')) { + if (-not $n) { continue } + Remove-ItemProperty -LiteralPath $run -Name $n -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $approved) { + Remove-ItemProperty -LiteralPath $approved -Name $n -ErrorAction SilentlyContinue + } + } +} +`; + +function parseJsonObject(text) { + const cleaned = String(text || '').replace(/^\uFEFF/, '').trim(); + if (!cleaned) return {}; + const parsed = JSON.parse(cleaned); + return parsed && typeof parsed === 'object' ? parsed : {}; +} + +function createLoginAutostart(options = {}) { + const execPath = options.execPath || process.execPath; + const platform = options.platform || process.platform; + const fileSystem = options.fs || fs; + const home = options.homedir || os.homedir(); + const run = options.run || defaultRun; + const allowWrites = options.allowWrites !== false; + const name = registrationName(execPath); + const devMode = name === 'OnlyKey-dev'; + const keys = { + run: (options.registry && options.registry.run) || WINDOWS_RUN, + approved: (options.registry && options.registry.approved) || WINDOWS_APPROVED, + }; + + function refuse() { + return { changed: false, enabled: false, skipped: true }; + } + + function runPowershell(script, env) { + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + const stdout = assertRan( + run(powershellExe(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { env }), + 'powershell' + ); + return stdout; + } + + function readWindows() { + const stdout = runPowershell(READ_WINDOWS_SCRIPT, { + ONLYKEY_RUN_KEY: keys.run, + ONLYKEY_APPROVED_KEY: keys.approved, + }); + const parsed = parseJsonObject(stdout); + return { + run: parsed.run && typeof parsed.run === 'object' ? parsed.run : {}, + approved: parsed.approved && typeof parsed.approved === 'object' ? parsed.approved : {}, + }; + } + + function applyWindows(plan) { + const changed = !!(plan.write || plan.enableApproved || (plan.deleteRun && plan.deleteRun.length)); + if (!changed) return { changed: false, enabled: plan.enabled }; + runPowershell(APPLY_WINDOWS_SCRIPT, { + ONLYKEY_RUN_KEY: keys.run, + ONLYKEY_APPROVED_KEY: keys.approved, + ONLYKEY_WRITE_NAME: name, + ONLYKEY_WRITE_VALUE: plan.write || '', + ONLYKEY_ENABLE_APPROVED: plan.enableApproved ? '1' : '0', + ONLYKEY_DELETE_RUN: (plan.deleteRun || []).join('|'), + }); + return { changed: true, enabled: plan.enabled, removed: plan.deleteRun || [] }; + } + + function windowsEnabled(state) { + const command = state.run[name]; + if (!commandTargetsExe(command, execPath)) return false; + return approvalEnabled(parseApproved(state.approved[name])); + } + + function repairWindows() { + const state = readWindows(); + const plan = planWindowsUpdate({ + name, + exePath: execPath, + runValues: state.run, + approvals: state.approved, + }); + return applyWindows(plan); + } + + function enableWindows() { + const state = readWindows(); + return applyWindows(planWindowsUpdate({ + name, + exePath: execPath, + runValues: state.run, + approvals: state.approved, + force: true, + })); + } + + function disableWindows() { + const state = readWindows(); + return applyWindows(planWindowsDisable(name, execPath, state.run)); + } + + function readText(filePath) { + try { + if (!fileSystem.existsSync(filePath)) return ''; + return fileSystem.readFileSync(filePath, 'utf8'); + } catch { + return ''; + } + } + + function listFiles(dir) { + try { + return fileSystem.readdirSync(dir); + } catch { + return []; + } + } + + function macPaths() { + const bundlePath = macBundleRoot(execPath); + const label = macLabelFor(execPath); + const dir = path.join(home, 'Library', 'LaunchAgents'); + return { bundlePath, label, dir, canonical: path.join(dir, `${label}.plist`) }; + } + + function macLegacy(paths) { + return listFiles(paths.dir).filter((filename) => { + const text = readText(path.join(paths.dir, filename)); + return shouldRemoveMacAgent(filename, text, { + label: paths.label, + bundlePath: paths.bundlePath, + devMode, + }); + }); + } + + function listLoginItems() { + const script = [ + 'tell application "System Events"', + ' set output to ""', + ' set totalCount to count of login items', + ' repeat with i from 1 to totalCount', + ' set itemName to name of login item i', + ' set itemPath to path of login item i', + ' set output to output & itemName & linefeed & itemPath & linefeed & "---" & linefeed', + ' end repeat', + ' return output', + 'end tell', + ].join('\n'); + const stdout = assertRan(run('osascript', [], { input: script, timeout: 15000 }), 'osascript'); + const paths = macPaths(); + return parseLoginItems(stdout).filter((item) => shouldRemoveLoginItem(item, { + bundlePath: paths.bundlePath, + devMode, + })); + } + + function deleteLoginItems(items) { + for (const item of items) { + const script = `tell application "System Events" to delete login item ${appleString(item.name)}`; + try { + assertRan(run('osascript', [], { input: script, timeout: 15000 }), 'osascript'); + } catch (error) { + console.error('Could not remove login item', item.name, error); + } + } + } + + function writeCanonical(paths, text) { + fileSystem.mkdirSync(paths.dir, { recursive: true }); + fileSystem.writeFileSync(paths.canonical, text, { mode: 0o644 }); + } + + function deleteAgents(paths, names) { + for (const filename of names) { + try { + fileSystem.unlinkSync(path.join(paths.dir, filename)); + } catch (error) { + if (error && error.code !== 'ENOENT') console.error('Could not remove LaunchAgent', filename, error); + } + } + } + + function pruneDock(bundlePath) { + if (devMode || !bundlePath) return false; + const plistPath = path.join(home, 'Library', 'Preferences', 'com.apple.dock.plist'); + if (!fileSystem.existsSync(plistPath)) return false; + let raw; + try { + raw = assertRan(run('plutil', ['-convert', 'json', '-o', '-', plistPath], { timeout: 10000 }), 'plutil'); + } catch (error) { + console.error('Could not read the Dock layout:', error); + return false; + } + let data; + try { + data = JSON.parse(raw); + } catch { + return false; + } + const persistent = data['persistent-apps']; + if (!Array.isArray(persistent)) return false; + const tiles = persistent.map((entry) => { + const tile = entry && entry['tile-data'] ? entry['tile-data'] : {}; + const fileData = tile['file-data'] || {}; + return { + bundleId: tile['bundle-identifier'] || '', + label: tile['file-label'] || '', + url: fileData._CFURLString || '', + }; + }); + const drop = new Set(dockTilesToDrop(tiles, bundlePath, (target) => { + try { + return fileSystem.existsSync(target); + } catch { + return false; + } + })); + if (drop.size === 0) return false; + data['persistent-apps'] = persistent.filter((_, index) => !drop.has(index)); + const tmp = path.join(os.tmpdir(), `onlykey-dock-${process.pid}.json`); + fileSystem.writeFileSync(tmp, JSON.stringify(data)); + try { + assertRan(run('plutil', ['-convert', 'binary1', tmp], { timeout: 10000 }), 'plutil'); + fileSystem.copyFileSync(tmp, plistPath); + } catch (error) { + console.error('Could not update the Dock layout:', error); + return false; + } finally { + try { + fileSystem.unlinkSync(tmp); + } catch { + // ignore + } + } + try { + run('killall', ['Dock'], { timeout: 5000 }); + } catch (error) { + console.error('Could not restart the Dock:', error); + } + return true; + } + + function readLoginItems() { + try { + return listLoginItems(); + } catch (error) { + console.error('Could not list login items:', error); + return []; + } + } + + function repairMac(opts = {}) { + const paths = macPaths(); + if (!paths.bundlePath) return { changed: false, enabled: false }; + const desired = macLaunchAgentPlist(paths.bundlePath, paths.label); + const canonicalText = readText(paths.canonical); + const legacyNames = macLegacy(paths); + const loginItems = opts.skipLoginItems ? [] : readLoginItems(); + const plan = planMac({ + canonicalText, + desiredText: desired, + legacyNames, + loginItems, + }); + if (plan.write) writeCanonical(paths, desired); + deleteAgents(paths, plan.deleteAgents); + if (!opts.skipLoginItems) deleteLoginItems(plan.deleteLoginItems); + const dockChanged = pruneDock(paths.bundlePath); + const changed = !!(plan.write || plan.deleteAgents.length || plan.deleteLoginItems.length || dockChanged); + return { changed, enabled: plan.enabled, removed: plan.deleteAgents }; + } + + function enableMac(opts = {}) { + const paths = macPaths(); + if (!paths.bundlePath) throw new Error('macOS autostart needs the app bundle'); + const desired = macLaunchAgentPlist(paths.bundlePath, paths.label); + writeCanonical(paths, desired); + deleteAgents(paths, macLegacy(paths)); + if (!opts.skipLoginItems) { + try { + deleteLoginItems(listLoginItems()); + } catch (error) { + console.error('Could not remove login items:', error); + } + } + pruneDock(paths.bundlePath); + return { changed: true, enabled: true }; + } + + function disableMac(opts = {}) { + const paths = macPaths(); + deleteAgents(paths, [path.basename(paths.canonical), ...macLegacy(paths)]); + if (!opts.skipLoginItems) { + try { + deleteLoginItems(listLoginItems()); + } catch (error) { + console.error('Could not remove login items:', error); + } + } + return { changed: true, enabled: false }; + } + + function removeLegacyLoginItems() { + if (!allowWrites || platform !== 'darwin') return { removed: 0, skipped: true }; + const paths = macPaths(); + if (!paths.bundlePath) return { removed: 0, skipped: true }; + let loginItems = []; + try { + loginItems = listLoginItems(); + } catch (error) { + console.error('Could not list login items:', error); + return { removed: 0, failed: true }; + } + deleteLoginItems(loginItems); + return { removed: loginItems.length }; + } + + function macEnabled() { + const paths = macPaths(); + if (!paths.bundlePath) return false; + return macAgentIsCurrent(readText(paths.canonical), paths.bundlePath); + } + + function linuxDir() { + return path.join(home, '.config', 'autostart'); + } + + function linuxDesired() { + const execLine = linuxExecCommand(execPath, (target) => fileSystem.existsSync(target)); + return linuxDesktopContents(execLine); + } + + function linuxLegacy() { + return listFiles(linuxDir()).filter((filename) => { + const text = readText(path.join(linuxDir(), filename)); + return shouldRemoveLinuxAutostart(filename, text, { name, devMode }); + }); + } + + function repairLinux() { + const dir = linuxDir(); + const canonical = path.join(dir, `${name}.desktop`); + const desired = linuxDesired(); + const plan = planLinux({ + canonicalText: readText(canonical), + desiredText: desired, + legacyNames: linuxLegacy(), + }); + if (plan.write) { + fileSystem.mkdirSync(dir, { recursive: true }); + fileSystem.writeFileSync(canonical, desired, { mode: 0o644 }); + } + for (const filename of plan.deleteLegacy) { + try { + fileSystem.unlinkSync(path.join(dir, filename)); + } catch (error) { + if (error && error.code !== 'ENOENT') console.error('Could not remove autostart entry', filename, error); + } + } + return { changed: !!(plan.write || plan.deleteLegacy.length), enabled: plan.enabled }; + } + + function linuxEnabled() { + const text = readText(path.join(linuxDir(), `${name}.desktop`)); + if (!text || linuxAutostartDisabled(text)) return false; + return desktopExecLine(text).includes('--hidden'); + } + + function repair(options) { + if (!allowWrites) return Promise.resolve(refuse()); + const skipLoginItems = !!(options && options.skipLoginItems); + return Promise.resolve().then(() => { + if (platform === 'win32') return repairWindows(); + if (platform === 'darwin') return repairMac({ skipLoginItems }); + if (platform === 'linux') return repairLinux(); + return refuse(); + }); + } + + function enableLinux() { + const dir = linuxDir(); + fileSystem.mkdirSync(dir, { recursive: true }); + fileSystem.writeFileSync(path.join(dir, `${name}.desktop`), linuxDesired(), { mode: 0o644 }); + for (const filename of linuxLegacy()) { + try { + fileSystem.unlinkSync(path.join(dir, filename)); + } catch (error) { + if (error && error.code !== 'ENOENT') console.error('Could not remove autostart entry', filename, error); + } + } + return { changed: true, enabled: true }; + } + + function enable(options) { + if (!allowWrites) return Promise.resolve(refuse()); + const skipLoginItems = !!(options && options.skipLoginItems); + return Promise.resolve().then(() => { + if (platform === 'win32') return enableWindows(); + if (platform === 'darwin') return enableMac({ skipLoginItems }); + if (platform === 'linux') return enableLinux(); + return refuse(); + }); + } + + function disable(options) { + if (!allowWrites) return Promise.resolve(refuse()); + const skipLoginItems = !!(options && options.skipLoginItems); + return Promise.resolve().then(() => { + if (platform === 'win32') return disableWindows(); + if (platform === 'darwin') return disableMac({ skipLoginItems }); + if (platform === 'linux') { + const dir = linuxDir(); + const names = new Set([`${name}.desktop`, ...linuxLegacy()]); + for (const filename of names) { + try { + fileSystem.unlinkSync(path.join(dir, filename)); + } catch (error) { + if (error && error.code !== 'ENOENT') console.error('Could not remove autostart entry', filename, error); + } + } + return { changed: true, enabled: false }; + } + return refuse(); + }); + } + + function isEnabled() { + if (!allowWrites) return Promise.resolve(false); + return Promise.resolve().then(() => { + if (platform === 'win32') return windowsEnabled(readWindows()); + if (platform === 'darwin') return macEnabled(); + if (platform === 'linux') return linuxEnabled(); + return false; + }); + } + + function clearLegacyLoginItems() { + if (!allowWrites) return Promise.resolve({ removed: 0, skipped: true }); + return Promise.resolve().then(() => removeLegacyLoginItems()); + } + + return { repair, enable, disable, isEnabled, removeLegacyLoginItems: clearLegacyLoginItems, name }; +} + +module.exports = { + createLoginAutostart, + registrationName, + windowsRunCommand, + commandTargetsExe, + parseApproved, + approvalEnabled, + planWindowsUpdate, + planWindowsDisable, + macBundleRoot, + macLabelFor, + macLaunchAgentPlist, + macAgentIsCurrent, + shouldRemoveMacAgent, + shouldRemoveLoginItem, + planMac, + parseLoginItems, + normalizeMacPath, + isOnlyKeyDockTile, + dockTilesToDrop, + linuxExecCommand, + linuxDesktopContents, + linuxAutostartDisabled, + shouldRemoveLinuxAutostart, + planLinux, + desktopExecLine, +}; diff --git a/package-lock.json b/package-lock.json index 0cea046d..7a4e6687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "6.0.0", "license": "Apache-2.0", "dependencies": { - "auto-launch": "^5.0.5", "js-sha256": "0.9.0", "openpgp": "^6.3.1", "react": "^19.2.4", @@ -28,19 +27,19 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.1.11", - "@vitest/ui": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", + "@vitest/ui": "^4.1.12", "concurrently": "^9.2.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.11.0", - "happy-dom": "^20.10.6", + "happy-dom": "^21.0.4", "nw": "0.104.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.67.0", "vite": "^7.3.6", - "vitest": "^4.1.11", + "vitest": "^4.1.12", "wait-on": "^9.0.4" } }, @@ -1120,6 +1119,13 @@ "node": ">=18.0.0" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2354,9 +2360,9 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2576,16 +2582,17 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2681,15 +2688,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", + "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2703,8 +2710,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "4.1.12", + "vitest": "4.1.12" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2713,16 +2720,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2731,13 +2738,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2758,9 +2765,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -2771,13 +2778,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -2785,14 +2792,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2801,9 +2808,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -2811,14 +2818,14 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz", - "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.12.tgz", + "integrity": "sha512-eYUTHtsp52Fu6YGUw9T9173bba7YUr9j/Xj9CdhUaOb8AppLKcknHUaiZQeqJ8MiJ8C2VyKiZuUGSKdvPIKQKA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -2830,17 +2837,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.11" + "vitest": "4.1.12" } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2928,11 +2935,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/applescript": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz", - "integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ==" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3004,22 +3006,6 @@ "dev": true, "license": "MIT" }, - "node_modules/auto-launch": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz", - "integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==", - "license": "MIT", - "dependencies": { - "applescript": "^1.0.0", - "mkdirp": "^0.5.1", - "path-is-absolute": "^1.0.0", - "untildify": "^3.0.2", - "winreg": "1.2.4" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/axios": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", @@ -3932,9 +3918,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "dev": true, "funding": [ { @@ -4126,9 +4112,9 @@ "license": "ISC" }, "node_modules/happy-dom": { - "version": "20.11.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz", - "integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==", + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz", + "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==", "dev": true, "license": "MIT", "peer": true, @@ -4411,9 +4397,9 @@ } }, "node_modules/joi": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.0.tgz", - "integrity": "sha512-/RwAK4BAfzcdbdFg073tzq4/xhY6Q7lMd5AmjqTL6pu0QFJdEmtswgm8BvgwXvOKuPNYQ8XrdQrrW/gF65LiNw==", + "version": "18.3.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.2.tgz", + "integrity": "sha512-iUivApKYw5GpZmq2Sy6El99j7noo8QSUFZlyQtr3HCkosC/b5wKF6/Y3teiHIlaM8WI38sAT3P+51rB0lhAhJA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4964,6 +4950,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4992,18 +4979,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -5203,15 +5178,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5872,15 +5838,6 @@ "dev": true, "license": "MIT" }, - "node_modules/untildify": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz", - "integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", @@ -5999,20 +5956,20 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6040,12 +5997,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6152,12 +6109,6 @@ "node": ">=8" } }, - "node_modules/winreg": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz", - "integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==", - "license": "BSD-2-Clause" - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6187,9 +6138,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", "dev": true, "license": "MIT", "engines": { @@ -6283,9 +6234,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index e2f7cd1e..03e244ae 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "node-remote": "http://localhost:5173/*", "chromium-args": "--disable-features=CalculateNativeWinOcclusion --disable-renderer-backgrounding --disable-background-timer-throttling", "dependencies": { - "auto-launch": "^5.0.5", "js-sha256": "0.9.0", "openpgp": "^6.3.1", "react": "^19.2.4", @@ -78,25 +77,25 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.1.11", - "@vitest/ui": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", + "@vitest/ui": "^4.1.12", "concurrently": "^9.2.1", "eslint": "^9.39.5", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.11.0", - "happy-dom": "^20.10.6", + "happy-dom": "^21.0.4", "nw": "0.104.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.67.0", "vite": "^7.3.6", - "vitest": "^4.1.11", + "vitest": "^4.1.12", "wait-on": "^9.0.4" }, "overrides": { "axios": "^1.18.1", "form-data": "^4.0.6", - "follow-redirects": "^1.16.0", + "follow-redirects": "^1.16.2", "lodash": "^4.18.1", "tar": "^7.5.22", "shell-quote": "^1.10.0", @@ -105,7 +104,9 @@ "nanoid": "^3.3.18", "picomatch": "^4.0.5", "@babel/core": "^7.29.7", - "joi": "^18.2.3" + "joi": "^18.3.1", + "minimatch@>=7.0.0 <11": "^11.0.1", + "zod": "^4.6.6" }, "repository": { "type": "git", diff --git a/resources/osx/Info.plist b/resources/osx/Info.plist index 1a1979d3..f2dfe06b 100755 --- a/resources/osx/Info.plist +++ b/resources/osx/Info.plist @@ -30,5 +30,7 @@ NSApplication NSSupportsAutomaticGraphicsSwitching + NSAppleEventsUsageDescription + OnlyKey uses this once to remove login items left by older versions, so the app starts once in the tray. diff --git a/resources/windows/installer.nsi b/resources/windows/installer.nsi index a5646526..78d32714 100755 --- a/resources/windows/installer.nsi +++ b/resources/windows/installer.nsi @@ -103,6 +103,27 @@ Section "Install" ; Create start menu shortcut CreateShortCut "$SMPROGRAMS\${name}.lnk" "$INSTDIR\${exec}" "" "$INSTDIR\icon.ico" + + ; Migrate the old auto-launch value named "nw" (Task Manager shows that name, + ; and a disabled StartupApproved flag keeps it from running). A disabled + ; OnlyKey value is left alone. + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "OnlyKey" + ${If} $0 == "" + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "nw" + ${If} $0 != "" + StrCpy $1 "$INSTDIR\nw.exe" + Push $0 + Push $1 + Call StrContains + Pop $2 + ${If} $2 == "1" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "OnlyKey" "$\"$INSTDIR\nw.exe$\" $\"$INSTDIR$\" --hidden --onlykey-autostart" + WriteRegBin HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "OnlyKey" 020000000000000000000000 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "nw" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "nw" + ${EndIf} + ${EndIf} + ${EndIf} WriteUninstaller "${uninstaller}" @@ -117,6 +138,65 @@ SectionEnd ShowUninstDetails hide +; $0 = 1 when the string contains the substring. +; Push string, push substring, Call StrContains, Pop result. +Function StrContains + Exch $R0 + Exch + Exch $R1 + Push $R2 + Push $R3 + Push $R4 + StrLen $R2 $R0 + StrCpy $R3 0 + inst_str_loop: + StrCpy $R4 $R1 $R2 $R3 + StrCmp $R4 "" inst_str_notfound + StrCmp $R4 $R0 inst_str_found + IntOp $R3 $R3 + 1 + Goto inst_str_loop + inst_str_found: + StrCpy $R0 1 + Goto inst_str_done + inst_str_notfound: + StrCpy $R0 0 + inst_str_done: + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +FunctionEnd + +; Push string, push substring, Call un.StrContains, Pop result. +Function un.StrContains + Exch $R0 + Exch + Exch $R1 + Push $R2 + Push $R3 + Push $R4 + StrLen $R2 $R0 + StrCpy $R3 0 + un_str_loop: + StrCpy $R4 $R1 $R2 $R3 + StrCmp $R4 "" un_str_notfound + StrCmp $R4 $R0 un_str_found + IntOp $R3 $R3 + 1 + Goto un_str_loop + un_str_found: + StrCpy $R0 1 + Goto un_str_done + un_str_notfound: + StrCpy $R0 0 + un_str_done: + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +FunctionEnd + UninstallCaption "Uninstall ${productName}" UninstallText "Follow the prompts to remove ${productName} from this computer." UninstallIcon "${icon}" @@ -153,6 +233,23 @@ Section "Uninstall" DeleteRegKey HKLM "${uninstkey}" DeleteRegKey HKLM "${regkey}" + + ; Per-user login entry. Also drop the legacy value auto-launch named after nw.exe + ; when it points at this install. + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "OnlyKey" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "OnlyKey" + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "nw" + ${If} $0 != "" + StrCpy $1 "$INSTDIR\nw.exe" + Push $0 + Push $1 + Call un.StrContains + Pop $2 + ${If} $2 == "1" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "nw" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "nw" + ${EndIf} + ${EndIf} Delete "$SMPROGRAMS\${name}.lnk" diff --git a/scripts/release.mjs b/scripts/release.mjs index fc0bbe10..9201204d 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -180,6 +180,7 @@ export const DESKTOP_SHELL_FILES = [ 'userPreferences.cjs', 'desktopBg.cjs', 'desktopInject.js', + 'loginAutostart.cjs', ]; /** @@ -198,20 +199,20 @@ export const APP_NW_ALLOWLIST = new Set([ /** * Vite bundles react, react-dom, zustand, openpgp, and js-sha256 into dist/. - * Only these two are loaded via require() at runtime (sshpk is externalized - * in vite.config.ts; auto-launch is required by desktopBg.cjs). + * Only sshpk is loaded via require() at runtime (externalized in vite.config.ts). + * Login autostart is loginAutostart.cjs in the desktop shell; it does not use + * the auto-launch package (that package registered Windows startup as "nw" + * and piled up macOS login items). */ -export const RUNTIME_DEPS = ['sshpk', 'auto-launch']; +export const RUNTIME_DEPS = ['sshpk']; /** * Every package the RUNTIME_DEPS load, as package-lock.json `packages` keys * (`node_modules/x`, or a nested `node_modules/a/node_modules/x`). * - * Copying the two names alone shipped them WITHOUT their own dependencies: - * auto-launch needs applescript, untildify, mkdirp and winreg, so - * `require('auto-launch')` threw inside the packaged app, autostart was never - * registered, and the tray's "Autostart to tray" box could be unticked but - * never ticked again. sshpk was missing all of its own the same way. + * Copying the package name alone ships it WITHOUT its own dependencies. + * sshpk needs asn1 and the rest of its tree; a missing one throws inside the + * packaged app. * * Resolution follows node's: the nearest node_modules above the requiring * package first, then the top level. A dependency the lock does not have is diff --git a/src/App.tsx b/src/App.tsx index bc3e969d..c4eb0b5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import DeviceDialogs from './components/DeviceDialogs'; import WorkingDialog from './components/dialogs/WorkingDialog'; import AppUpdateHost from './components/AppUpdateHost'; import FirmwareUpdateHost from './components/FirmwareUpdateHost'; +import LoginItemAccessHost from './components/LoginItemAccessHost'; import ThemeToggle from './components/ThemeToggle'; import DeviceMessages from './components/DeviceMessages'; import { HelpTip } from './components/ui/HelpTip'; @@ -54,6 +55,7 @@ const App: React.FC = () => { {/* Outside sessionEpoch so a lock/unplug does not abort an in-flight download. */} +
diff --git a/src/components/LoginItemAccessHost.tsx b/src/components/LoginItemAccessHost.tsx new file mode 100644 index 00000000..dcfb3707 --- /dev/null +++ b/src/components/LoginItemAccessHost.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import ConfirmDialog from './dialogs/ConfirmDialog'; + +/** Same sentence as NSAppleEventsUsageDescription in resources/osx/Info.plist. */ +export const LOGIN_ITEM_ACCESS_PURPOSE = + 'OnlyKey uses this once to remove login items left by older versions, so the app starts once in the tray.'; + +const LOGIN_ITEM_ACCESS_MESSAGE = `macOS will ask OnlyKey to control System Events. Choose Allow. ${LOGIN_ITEM_ACCESS_PURPOSE}`; + +export const LOGIN_ITEM_UI_READY = 'onlykey-login-item-ui-ready'; + +export type LoginItemAccessAnswer = true | 'later'; + +declare global { + interface Window { + onlykeyAskLoginItemAccess?: () => Promise; + } +} + +/** + * The document-start inject script cannot see this page's globals. The pending + * request lives on the module so a remount still opens the note the shell asked for. + */ +let pendingResolve: ((answer: LoginItemAccessAnswer) => void) | null = null; +const subscribers = new Set<(open: boolean) => void>(); + +function publish(open: boolean) { + subscribers.forEach((notify) => notify(open)); +} + +function askLoginItemAccess(): Promise { + return new Promise((resolve) => { + pendingResolve = resolve; + publish(true); + }); +} + +function settleLoginItemAccess(answer: LoginItemAccessAnswer) { + const resolve = pendingResolve; + pendingResolve = null; + publish(false); + resolve?.(answer); +} + +export function resetLoginItemAccessPrompt() { + pendingResolve = null; + publish(false); +} + +/** + * macOS asks the desktop shell for this answer before it talks to System Events. + * The shell does not build UI; it calls window.onlykeyAskLoginItemAccess. + */ +const LoginItemAccessHost: React.FC = () => { + const [open, setOpen] = useState(false); + + useEffect(() => { + window.onlykeyAskLoginItemAccess = askLoginItemAccess; + const notify = (next: boolean) => setOpen(next); + subscribers.add(notify); + notify(pendingResolve != null); + window.dispatchEvent(new Event(LOGIN_ITEM_UI_READY)); + return () => { + subscribers.delete(notify); + }; + }, []); + + const dialog = ( + settleLoginItemAccess(true)} + onCancel={() => settleLoginItemAccess('later')} + /> + ); + + if (typeof document !== 'undefined' && document.body) { + return createPortal(dialog, document.body); + } + return dialog; +}; + +export default LoginItemAccessHost; diff --git a/src/components/__tests__/LoginItemAccessHost.ui.test.tsx b/src/components/__tests__/LoginItemAccessHost.ui.test.tsx new file mode 100644 index 00000000..ff96cfc0 --- /dev/null +++ b/src/components/__tests__/LoginItemAccessHost.ui.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LoginItemAccessHost, { resetLoginItemAccessPrompt } from '../LoginItemAccessHost'; +import { renderWithProviders } from '../../test/render'; + +describe('LoginItemAccessHost', () => { + afterEach(() => { + resetLoginItemAccessPrompt(); + delete window.onlykeyAskLoginItemAccess; + }); + + it('stays closed until the desktop shell asks', () => { + renderWithProviders(); + expect(screen.queryByRole('heading', { name: 'Allow System Events' })).not.toBeInTheDocument(); + expect(typeof window.onlykeyAskLoginItemAccess).toBe('function'); + }); + + it('resolves Continue and Not now for the desktop shell', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const ask = window.onlykeyAskLoginItemAccess; + expect(ask).toBeTypeOf('function'); + + const first = ask!(); + expect(await screen.findByRole('heading', { name: 'Allow System Events' })).toBeInTheDocument(); + expect(screen.getByText(/remove login items left by older versions/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Continue' })); + await expect(first).resolves.toBe(true); + expect(screen.queryByRole('heading', { name: 'Allow System Events' })).not.toBeInTheDocument(); + + const second = ask!(); + await screen.findByRole('button', { name: 'Not now' }); + await user.click(screen.getByRole('button', { name: 'Not now' })); + await expect(second).resolves.toBe('later'); + }); + + it('opens after a remount when the shell already asked', async () => { + const user = userEvent.setup(); + const first = renderWithProviders(); + const ask = window.onlykeyAskLoginItemAccess; + expect(ask).toBeTypeOf('function'); + first.unmount(); + + const pending = ask!(); + renderWithProviders(); + expect(await screen.findByRole('heading', { name: 'Allow System Events' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Not now' })); + await expect(pending).resolves.toBe('later'); + }); +}); diff --git a/tests/desktop/bootstrap.static.test.mjs b/tests/desktop/bootstrap.static.test.mjs index 43f85d20..dc42ce62 100644 --- a/tests/desktop/bootstrap.static.test.mjs +++ b/tests/desktop/bootstrap.static.test.mjs @@ -11,6 +11,7 @@ describe('desktop bootstrap static checks', () => { expect(html).toContain("require(require('path').join(appRoot, 'desktopBg.cjs'))"); expect(html).toContain('desktopApi.start()'); expect(html).toContain('launchedHiddenToTray'); + expect(html).toContain('shouldKeepWindowHidden'); expect(html).toContain('NW desktop bootstrap'); }); @@ -19,6 +20,7 @@ describe('desktop bootstrap static checks', () => { expect(inject).toContain('desktopBg.cjs'); expect(inject).toContain('desktop.start'); expect(inject).toContain('applyLaunchVisibility'); + expect(inject).toContain('shouldKeepWindowHidden'); }); it('desktopInject.js prefers a relative require so Finder launches still boot', () => { @@ -32,6 +34,22 @@ describe('desktop bootstrap static checks', () => { expect(plist).not.toContain('com.intel.nw'); }); + it('explains the System Events prompt in the Mac plist and the confirm dialog', () => { + const purpose = 'OnlyKey uses this once to remove login items left by older versions, so the app starts once in the tray.'; + const plist = fs.readFileSync(path.join(rootDir, 'resources', 'osx', 'Info.plist'), 'utf8'); + const dialog = fs.readFileSync(path.join(rootDir, 'src', 'components', 'LoginItemAccessHost.tsx'), 'utf8'); + const shell = fs.readFileSync(path.join(rootDir, 'desktopBg.cjs'), 'utf8'); + expect(plist).toContain('NSAppleEventsUsageDescription'); + expect(plist).toContain(purpose); + expect(dialog).toContain(purpose); + expect(dialog).toContain('ConfirmDialog'); + expect(shell).toContain('onlykeyAskLoginItemAccess'); + expect(shell).toContain('appPageWindow'); + expect(shell).not.toContain('document.createElement'); + expect(shell).toContain('skipLoginItems'); + expect(shell).toContain('shouldDeferMacLoginItems'); + }); + it('linux launcher asks a running instance to show unless autostart is hidden', () => { const launch = fs.readFileSync(path.join(rootDir, 'resources', 'linux', 'onlykey-launch'), 'utf8'); expect(launch).toContain('tray-command.json'); diff --git a/tests/desktop/desktopBg.unit.test.mjs b/tests/desktop/desktopBg.unit.test.mjs index 8b6638a2..8074bd8a 100644 --- a/tests/desktop/desktopBg.unit.test.mjs +++ b/tests/desktop/desktopBg.unit.test.mjs @@ -108,6 +108,24 @@ function loadDesktopModule() { } describe('desktopBg.cjs unit', () => { + it('defers macOS System Events only for a hidden launch before the window is shown', () => { + const desktop = loadDesktopModule(); + expect(desktop.shouldDeferMacLoginItems('darwin', true, false)).toBe(true); + expect(desktop.shouldDeferMacLoginItems('darwin', true, true)).toBe(false); + expect(desktop.shouldDeferMacLoginItems('darwin', false, false)).toBe(false); + expect(desktop.shouldDeferMacLoginItems('win32', true, false)).toBe(false); + expect(desktop.shouldDeferMacLoginItems('linux', true, false)).toBe(false); + }); + + it('does not migrate login items over an explicit autostart off', () => { + const desktop = loadDesktopModule(); + expect(desktop.macLoginItemFollowUp(false, true, false)).toBe('migrate'); + expect(desktop.macLoginItemFollowUp(false, true, true)).toBe('remove-items'); + expect(desktop.macLoginItemFollowUp(false, false, false)).toBe('remove-items'); + expect(desktop.macLoginItemFollowUp(true, true, false)).toBe('remove-items'); + expect(desktop.macLoginItemFollowUp(true, false, true)).toBe('remove-items'); + }); + beforeEach(() => { vi.useRealTimers(); vi.stubGlobal('localStorage', { @@ -132,6 +150,37 @@ describe('desktopBg.cjs unit', () => { vi.unstubAllGlobals(); }); + it('asks the page window when the inject global cannot see the dialog', async () => { + const { win } = createNwMock(); + const injectWindow = { addEventListener: vi.fn() }; + vi.stubGlobal('window', injectWindow); + const desktop = loadDesktopModule(); + win.window.onlykeyAskLoginItemAccess = () => Promise.resolve(true); + + await expect(desktop.requestLoginItemDecision()).resolves.toBe(true); + expect(injectWindow.onlykeyAskLoginItemAccess).toBeUndefined(); + }); + + it('waits for the page to register the System Events dialog', async () => { + vi.useFakeTimers(); + const { win } = createNwMock(); + vi.stubGlobal('window', { addEventListener: vi.fn() }); + const desktop = loadDesktopModule(); + const pending = desktop.requestLoginItemDecision(); + let settled = false; + pending.then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(50); + expect(settled).toBe(false); + + win.window.onlykeyAskLoginItemAccess = () => Promise.resolve('later'); + await vi.advanceTimersByTimeAsync(50); + await expect(pending).resolves.toBe('later'); + vi.useRealTimers(); + }); + it('binds close handler and creates tray on start() in main context', async () => { const { win } = createNwMock(); const desktop = loadDesktopModule(); @@ -375,38 +424,22 @@ describe('desktopBg.cjs unit', () => { expect(win.show).not.toHaveBeenCalled(); }); - it('registers autostart as a hidden to-tray launch', async () => { - const autoLaunchPath = require.resolve('auto-launch'); - const previous = require.cache[autoLaunchPath]; - function FakeAutoLaunch(opts) { - this.opts = opts; - FakeAutoLaunch.instances.push(this); - this.isEnabled = vi.fn(() => Promise.resolve(false)); - this.enable = vi.fn(() => Promise.resolve()); - this.disable = vi.fn(() => Promise.resolve()); - } - FakeAutoLaunch.instances = []; - require.cache[autoLaunchPath] = { - id: autoLaunchPath, - filename: autoLaunchPath, - loaded: true, - exports: FakeAutoLaunch, - }; - + it('registers autostart outside the test process and stays hidden for --onlykey-autostart', async () => { + const childProcess = require('child_process'); + const spy = vi.spyOn(childProcess, 'spawnSync'); try { - createNwMock(); + createNwMock({ argv: ['--onlykey-desktop-test', '--onlykey-autostart'] }); const desktop = loadDesktopModule(); + expect(desktop.launchedHiddenToTray()).toBe(true); + expect(desktop.shouldKeepWindowHidden()).toBe(true); desktop.start(); await vi.waitFor(() => expect(desktop.getTestState().ownsTray).toBe(true), { timeout: 2000 }); - - expect(FakeAutoLaunch.instances.length).toBeGreaterThan(0); - expect(FakeAutoLaunch.instances[0].opts.isHidden).toBe(true); - expect(FakeAutoLaunch.instances[0].opts.mac).toEqual({ useLaunchAgent: true }); - expect(FakeAutoLaunch.instances[0].enable).not.toHaveBeenCalled(); - expect(FakeAutoLaunch.instances[0].disable).not.toHaveBeenCalled(); + const loginCalls = spy.mock.calls.filter((args) => /powershell|osascript|plutil|reg.exe/i.test(String(args[0]))); + expect(loginCalls).toEqual([]); + desktop.dispatchTrayCommand('show'); + expect(desktop.shouldKeepWindowHidden()).toBe(false); } finally { - if (previous) require.cache[autoLaunchPath] = previous; - else delete require.cache[autoLaunchPath]; + spy.mockRestore(); } }); diff --git a/tests/desktop/loginAutostart.unit.test.mjs b/tests/desktop/loginAutostart.unit.test.mjs new file mode 100644 index 00000000..3f868015 --- /dev/null +++ b/tests/desktop/loginAutostart.unit.test.mjs @@ -0,0 +1,382 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const autostart = require(path.join(rootDir, 'loginAutostart.cjs')); + +const EXE = 'C:\\Program Files (x86)\\OnlyKey\\nw.exe'; +const BUNDLE = '/Applications/OnlyKey App.app'; +const HELPER = `${BUNDLE}/Contents/Frameworks/nwjs Framework.framework/Helpers/nwjs Helper (Renderer).app/Contents/MacOS/nwjs Helper (Renderer)`; + +function tempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ok-autostart-')); +} + +describe('login autostart plans', () => { + it('builds a Windows Run command that passes the app directory and stays hidden', () => { + expect(autostart.windowsRunCommand(EXE)).toBe( + '"C:\\Program Files (x86)\\OnlyKey\\nw.exe" "C:\\Program Files (x86)\\OnlyKey" --hidden --onlykey-autostart' + ); + expect(autostart.registrationName(EXE)).toBe('OnlyKey'); + expect(autostart.registrationName('C:\\src\\OnlyKey-App\\node_modules\\nw\\nwjs\\nw.exe')).toBe('OnlyKey-dev'); + }); + + it('treats StartupApproved 01 as off and 02/06 as on', () => { + expect(autostart.approvalEnabled(null)).toBe(true); + expect(autostart.approvalEnabled(autostart.parseApproved('02-00-00-00-00-00-00-00-00-00-00-00'))).toBe(true); + expect(autostart.approvalEnabled(autostart.parseApproved('06-00-00-00-00-00-00-00-00-00-00-00'))).toBe(true); + expect(autostart.approvalEnabled(autostart.parseApproved('01-00-00-00-C9-2C-7F-F8-B8-A3-DC-01'))).toBe(false); + expect(autostart.approvalEnabled(autostart.parseApproved('03-00-00-00-00-00-00-00-00-00-00-00'))).toBe(false); + }); + + it('migrates a disabled nw Run value that points at this install', () => { + const plan = autostart.planWindowsUpdate({ + name: 'OnlyKey', + exePath: EXE, + runValues: { + OneDrive: '"C:\\Users\\me\\OneDrive.exe" /background', + nw: '"C:\\Program Files (x86)\\OnlyKey\\nw.exe" --hidden', + }, + approvals: { nw: '01-00-00-00-C9-2C-7F-F8-B8-A3-DC-01' }, + }); + expect(plan.enabled).toBe(true); + expect(plan.write).toBe(autostart.windowsRunCommand(EXE)); + expect(plan.enableApproved).toBe(true); + expect(plan.deleteRun).toEqual(['nw']); + }); + + it('does not turn a disabled OnlyKey entry back on', () => { + const command = autostart.windowsRunCommand(EXE); + const plan = autostart.planWindowsUpdate({ + name: 'OnlyKey', + exePath: EXE, + runValues: { OnlyKey: command, nw: `"${EXE}" --hidden` }, + approvals: { OnlyKey: '03-00-00-00-11-22-33-44-55-66-77-88' }, + }); + expect(plan.enabled).toBe(false); + expect(plan.write).toBeNull(); + expect(plan.deleteRun).toEqual(['nw']); + }); + + it('leaves a correct enabled entry untouched', () => { + const command = autostart.windowsRunCommand(EXE); + const plan = autostart.planWindowsUpdate({ + name: 'OnlyKey', + exePath: EXE, + runValues: { OnlyKey: command }, + approvals: { OnlyKey: '02-00-00-00-00-00-00-00-00-00-00-00' }, + }); + expect(plan).toEqual({ enabled: true, write: null, enableApproved: false, deleteRun: [] }); + }); + + it('checking the box re-enables an entry Task Manager turned off', () => { + const plan = autostart.planWindowsUpdate({ + name: 'OnlyKey', + exePath: EXE, + runValues: { OnlyKey: autostart.windowsRunCommand(EXE) }, + approvals: { OnlyKey: '01-00-00-00-00-00-00-00-00-00-00-00' }, + force: true, + }); + expect(plan.enabled).toBe(true); + expect(plan.enableApproved).toBe(true); + }); + + it('does not treat another app\'s nw Run value as ours', () => { + const plan = autostart.planWindowsUpdate({ + name: 'OnlyKey', + exePath: EXE, + runValues: { nw: '"C:\\Tools\\Other\\nw.exe" --hidden' }, + approvals: {}, + }); + expect(plan.enabled).toBe(false); + expect(plan.deleteRun).toEqual([]); + }); + + it('launches the macOS bundle through open -gj, not the helper binary', () => { + expect(autostart.macBundleRoot(HELPER)).toBe(BUNDLE); + const plist = autostart.macLaunchAgentPlist(BUNDLE, 'to.crp.onlykey'); + expect(plist).toContain('to.crp.onlykey'); + expect(plist).toContain('/usr/bin/open'); + expect(plist).toContain('-gj'); + expect(plist).toContain(`${BUNDLE}`); + expect(plist).toContain('--hidden'); + expect(plist).toContain('--onlykey-autostart'); + expect(plist).not.toContain('Helper'); + expect(autostart.macAgentIsCurrent(plist, BUNDLE)).toBe(true); + }); + + it('removes legacy Mac agents and login items that start OnlyKey, and keeps other apps', () => { + const opts = { label: 'to.crp.onlykey', bundlePath: BUNDLE, devMode: false }; + expect(autostart.shouldRemoveMacAgent('to.crp.onlykey.plist', `${BUNDLE}`, opts)).toBe(false); + expect(autostart.shouldRemoveMacAgent( + 'nwjs Helper (Renderer).plist', + `${HELPER}--hidden`, + opts + )).toBe(true); + expect(autostart.shouldRemoveMacAgent( + 'OnlyKey App.plist', + `${BUNDLE}/Contents/MacOS/OnlyKey App`, + opts + )).toBe(true); + expect(autostart.shouldRemoveMacAgent('other.plist', '/Applications/Other.app', opts)).toBe(false); + expect(autostart.shouldRemoveLoginItem({ name: 'OnlyKey App', itemPath: BUNDLE }, opts)).toBe(true); + expect(autostart.shouldRemoveLoginItem({ name: 'Mail', itemPath: '/System/Applications/Mail.app' }, opts)).toBe(false); + expect(autostart.shouldRemoveMacAgent( + 'OnlyKey App.plist', + `${BUNDLE}`, + { ...opts, devMode: true } + )).toBe(true); + expect(autostart.shouldRemoveMacAgent( + 'OnlyKey App.plist', + '/Applications/OnlyKey App.app', + { label: 'to.crp.onlykey.dev', bundlePath: '/tmp/dev/nwjs.app', devMode: true } + )).toBe(false); + }); + + it('keeps one live Dock tile and drops stale OnlyKey icons', () => { + const tiles = [ + { bundleId: 'to.crp.onlykey', label: 'OnlyKey App', url: 'file:///Applications/OnlyKey%20App.app/' }, + { bundleId: 'to.crp.onlykey', label: 'OnlyKey App', url: 'file:///Applications/OnlyKey%20App.app/' }, + { bundleId: 'com.apple.mail', label: 'Mail', url: 'file:///System/Applications/Mail.app/' }, + { bundleId: 'to.crp.onlykey', label: 'OnlyKey App', url: 'file:///Users/me/Downloads/OnlyKey%20App.app/' }, + ]; + const exists = (target) => target === BUNDLE || target === '/Users/me/Downloads/OnlyKey App.app'; + expect(autostart.dockTilesToDrop(tiles, BUNDLE, exists).sort()).toEqual([1, 3]); + expect(autostart.dockTilesToDrop([tiles[0]], BUNDLE, exists)).toEqual([]); + expect(autostart.dockTilesToDrop( + [{ bundleId: 'to.crp.onlykey', label: 'OnlyKey App', url: 'file:///missing/OnlyKey%20App.app/' }], + BUNDLE, + () => false + )).toEqual([0]); + }); + + it('parses login item listings', () => { + const items = autostart.parseLoginItems('OnlyKey App\n/Applications/OnlyKey App.app\n---\nMail\n/System/Applications/Mail.app\n---\n'); + expect(items).toEqual([ + { name: 'OnlyKey App', itemPath: '/Applications/OnlyKey App.app' }, + { name: 'Mail', itemPath: '/System/Applications/Mail.app' }, + ]); + }); + + it('writes a Linux autostart file through onlykey-launch and honors a desktop disable', async () => { + const home = tempHome(); + try { + const wrapper = path.join(home, 'opt', 'onlykey-launch'); + fs.mkdirSync(path.dirname(wrapper), { recursive: true }); + fs.writeFileSync(wrapper, '#!/bin/sh\n'); + const execLine = autostart.linuxExecCommand(path.join(home, 'opt', 'nw'), (target) => target === wrapper); + expect(execLine).toContain('--hidden --onlykey-autostart'); + expect(execLine).toContain('onlykey-launch'); + const desired = autostart.linuxDesktopContents(execLine); + expect(autostart.linuxAutostartDisabled(desired)).toBe(false); + expect(autostart.linuxAutostartDisabled('Hidden=true\n')).toBe(true); + expect(autostart.linuxAutostartDisabled('X-GNOME-Autostart-enabled=false\n')).toBe(true); + + const legacy = path.join(home, '.config', 'autostart'); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, 'nw.desktop'), '[Desktop Entry]\nExec=/opt/OnlyKey/nw --hidden\n'); + fs.writeFileSync(path.join(legacy, 'keep.desktop'), '[Desktop Entry]\nExec=/usr/bin/keep\n'); + + const auto = autostart.createLoginAutostart({ + platform: 'linux', + execPath: path.join(home, 'opt', 'nw'), + homedir: home, + fs, + }); + const result = await auto.repair(); + expect(result.enabled).toBe(true); + const canonical = fs.readFileSync(path.join(legacy, 'OnlyKey.desktop'), 'utf8'); + expect(canonical).toContain(`Exec=${execLine}`); + expect(fs.existsSync(path.join(legacy, 'nw.desktop'))).toBe(false); + expect(fs.existsSync(path.join(legacy, 'keep.desktop'))).toBe(true); + expect(await auto.isEnabled()).toBe(true); + + fs.writeFileSync(path.join(legacy, 'OnlyKey.desktop'), 'Hidden=true\nExec=/opt/OnlyKey/nw\n'); + const held = await auto.repair(); + expect(held.enabled).toBe(false); + expect(fs.readFileSync(path.join(legacy, 'OnlyKey.desktop'), 'utf8')).toContain('Hidden=true'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe('macOS autostart files', () => { + const homes = []; + afterEach(() => { + for (const home of homes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); + }); + + it('replaces helper agents and login items with one open -gj agent', async () => { + const home = tempHome(); + homes.push(home); + const agents = path.join(home, 'Library', 'LaunchAgents'); + fs.mkdirSync(agents, { recursive: true }); + fs.writeFileSync( + path.join(agents, 'nwjs Helper (Renderer).plist'), + `${HELPER}--hidden` + ); + fs.writeFileSync(path.join(agents, 'other.plist'), '/Applications/Other.app'); + + const calls = []; + const auto = autostart.createLoginAutostart({ + platform: 'darwin', + execPath: HELPER, + homedir: home, + fs, + run(file, args, opts) { + calls.push({ file, args, input: opts && opts.input }); + if (file === 'osascript' && opts && opts.input && opts.input.includes('count of login items')) { + return { status: 0, stdout: `OnlyKey App\n${BUNDLE}\n---\n`, stderr: '' }; + } + if (file === 'osascript') return { status: 0, stdout: '', stderr: '' }; + if (file === 'plutil' && args.includes('-o')) { + return { status: 0, stdout: JSON.stringify({ 'persistent-apps': [] }), stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + }); + + const result = await auto.repair(); + expect(result.enabled).toBe(true); + expect(fs.existsSync(path.join(agents, 'nwjs Helper (Renderer).plist'))).toBe(false); + expect(fs.existsSync(path.join(agents, 'other.plist'))).toBe(true); + const plist = fs.readFileSync(path.join(agents, 'to.crp.onlykey.plist'), 'utf8'); + expect(plist).toContain('/usr/bin/open'); + expect(plist).toContain(BUNDLE); + expect(await auto.isEnabled()).toBe(true); + expect(calls.some((call) => call.file === 'osascript' && call.input && call.input.includes('delete login item'))).toBe(true); + expect(calls.some((call) => call.file === 'killall')).toBe(false); + }); + + it('skips System Events on a deferred repair and still replaces helper agents', async () => { + const home = tempHome(); + homes.push(home); + const agents = path.join(home, 'Library', 'LaunchAgents'); + fs.mkdirSync(agents, { recursive: true }); + fs.writeFileSync( + path.join(agents, 'nwjs Helper (Renderer).plist'), + `${HELPER}--hidden` + ); + + const calls = []; + const auto = autostart.createLoginAutostart({ + platform: 'darwin', + execPath: HELPER, + homedir: home, + fs, + run(file, args, opts) { + calls.push({ file, args, input: opts && opts.input }); + if (file === 'plutil' && args.includes('-o')) { + return { status: 0, stdout: JSON.stringify({ 'persistent-apps': [] }), stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + }); + + const result = await auto.repair({ skipLoginItems: true }); + expect(result.enabled).toBe(true); + expect(fs.existsSync(path.join(agents, 'nwjs Helper (Renderer).plist'))).toBe(false); + expect(fs.readFileSync(path.join(agents, 'to.crp.onlykey.plist'), 'utf8')).toContain('/usr/bin/open'); + expect(calls.some((call) => call.file === 'osascript')).toBe(false); + expect(await auto.isEnabled()).toBe(true); + + await auto.disable({ skipLoginItems: true }); + expect(fs.existsSync(path.join(agents, 'to.crp.onlykey.plist'))).toBe(false); + expect(calls.some((call) => call.file === 'osascript')).toBe(false); + }); + + it('removes legacy login items without rewriting the LaunchAgent', async () => { + const home = tempHome(); + homes.push(home); + const agents = path.join(home, 'Library', 'LaunchAgents'); + fs.mkdirSync(agents, { recursive: true }); + const plist = autostart.macLaunchAgentPlist(BUNDLE, 'to.crp.onlykey'); + fs.writeFileSync(path.join(agents, 'to.crp.onlykey.plist'), plist); + + const calls = []; + const auto = autostart.createLoginAutostart({ + platform: 'darwin', + execPath: HELPER, + homedir: home, + fs, + run(file, args, opts) { + calls.push({ file, input: opts && opts.input }); + if (file === 'osascript' && opts && opts.input && opts.input.includes('count of login items')) { + return { status: 0, stdout: `OnlyKey App\n${BUNDLE}\n---\n`, stderr: '' }; + } + return { status: 0, stdout: '', stderr: '' }; + }, + }); + + const removed = await auto.removeLegacyLoginItems(); + expect(removed.removed).toBe(1); + expect(fs.readFileSync(path.join(agents, 'to.crp.onlykey.plist'), 'utf8')).toBe(plist); + expect(calls.some((call) => call.file === 'osascript' && call.input && call.input.includes('delete login item'))).toBe(true); + expect(calls.some((call) => call.file === 'plutil')).toBe(false); + }); +}); + +describe('windows autostart registry', () => { + const keyRoot = 'HKCU:\\Software\\OnlyKeyAutostartTest'; + const registry = { + run: `${keyRoot}\\Run`, + approved: `${keyRoot}\\Approved`, + }; + + afterEach(() => { + if (process.platform !== 'win32') return; + const { spawnSync } = require('child_process'); + const exe = path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + const script = `Remove-Item -LiteralPath '${keyRoot}' -Recurse -Force -ErrorAction SilentlyContinue`; + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + spawnSync(exe, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { windowsHide: true }); + }); + + it.skipIf(process.platform !== 'win32')('migrates a disabled nw value into an enabled OnlyKey entry', async () => { + const auto = autostart.createLoginAutostart({ + platform: 'win32', + execPath: EXE, + registry, + }); + + const { spawnSync } = require('child_process'); + const exe = path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + const plant = ` + $ErrorActionPreference = 'Stop' + New-Item -Path '${registry.run}' -Force | Out-Null + New-ItemProperty -LiteralPath '${registry.run}' -Name 'nw' -PropertyType String -Value '"${EXE}" --hidden' -Force | Out-Null + New-Item -Path '${registry.approved}' -Force | Out-Null + New-ItemProperty -LiteralPath '${registry.approved}' -Name 'nw' -PropertyType Binary -Value ([byte[]](1,0,0,0,1,2,3,4,5,6,7,8)) -Force | Out-Null + `; + const planted = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(plant, 'utf16le').toString('base64')], { windowsHide: true, encoding: 'utf8' }); + expect(planted.status, planted.stderr).toBe(0); + + const repaired = await auto.repair(); + expect(repaired.enabled).toBe(true); + expect(await auto.isEnabled()).toBe(true); + + const read = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(` + $run = Get-Item -LiteralPath '${registry.run}' + $approved = Get-Item -LiteralPath '${registry.approved}' + @{ + command = [string]$run.GetValue('OnlyKey') + nw = [string]$run.GetValue('nw') + flag = [System.BitConverter]::ToString([byte[]]$approved.GetValue('OnlyKey')) + } | ConvertTo-Json -Compress + `, 'utf16le').toString('base64')], { windowsHide: true, encoding: 'utf8' }); + expect(read.status, read.stderr).toBe(0); + const state = JSON.parse(read.stdout); + expect(state.command).toBe(autostart.windowsRunCommand(EXE)); + expect(state.nw).toBe(''); + expect(state.flag.startsWith('02-00-00-00')).toBe(true); + + await auto.disable(); + expect(await auto.isEnabled()).toBe(false); + }, 30000); +}); diff --git a/tests/desktop/release-packaging.static.test.mjs b/tests/desktop/release-packaging.static.test.mjs index 7c1b0bfc..13a6e8e4 100644 --- a/tests/desktop/release-packaging.static.test.mjs +++ b/tests/desktop/release-packaging.static.test.mjs @@ -385,12 +385,12 @@ describe('ensureOfficialNwjsApp', () => { describe('runtime node_modules in the app payload', () => { const lock = JSON.parse(fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8')); - it('ships what auto-launch and sshpk require, not just the two packages', () => { + it('ships what sshpk requires, not just the package itself', () => { const closure = runtimeDependencyClosure(lock).map((k) => k.replace(/^.*node_modules\//, '')); - for (const dep of ['auto-launch', 'applescript', 'untildify', 'mkdirp', 'winreg', - 'sshpk', 'asn1', 'bcrypt-pbkdf', 'ecc-jsbn', 'jsbn', 'safer-buffer', 'tweetnacl']) { + for (const dep of ['sshpk', 'asn1', 'bcrypt-pbkdf', 'ecc-jsbn', 'jsbn', 'safer-buffer', 'tweetnacl']) { expect(closure).toContain(dep); } + expect(closure).not.toContain('auto-launch'); }); it('refuses a dependency the lock does not have', () => {