From 14a56f861ebd65e238fec95d8c7b30373feaef0c Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 30 Jun 2026 17:22:30 +0200 Subject: [PATCH] feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(electron): trigger global shortcuts via superproductivity:// URLs On Wayland the compositor owns global hotkeys, so Electron's globalShortcut often does not register. Add three actions — toggle-visibility, new-note and new-task — to the existing protocol handler so a compositor keybind can call `xdg-open superproductivity://`. No extra CLI tool or runtime is needed: xdg-open (Linux), open (macOS) and start (Windows) already ship with the OS, and the running instance receives the URL via the existing single-instance / second-instance path. Extract the show/hide logic into a shared toggleWindowVisibility() used by both the globalShowHide shortcut and the new protocol action, and add a key-repeat debounce so one held key press no longer hides then immediately re-shows the window. Docs: add a Wayland keybind recipe (Niri/sway/Hyprland) to the keyboard shortcuts wiki page. Refs #7114 * fix(electron): correct toggle-visibility focus race and debounce On Linux/Windows the second-instance handler pre-focused the window before processProtocolUrl ran, so toggle-visibility always read 'visible' and hid the window the user asked to show. Skip that pre-focus for toggle-visibility only (new getProtocolAction helper); every other action keeps the bring-to-front behavior. Make the key-repeat debounce direction-agnostic with a sliding quiet-gap (1000->750ms): it now guards both show and hide, settles a held key on a single toggle, and fires on the xdg-open path where the old isHidden-only guard was always false. Stop logging the create-task title and URL path to the exportable log (CLAUDE.md rule 9 / privacy). Add coverage for the real second-instance path, held-key-from-hidden, gap expiry, the #7282 minimize fallback, the macOS hide path, unfocused-show, and the log redaction; document AppImage/Flatpak/Snap scheme registration. Refs #7114. * fix(electron): show window on cold-start toggle-visibility launch Cold start: when superproductivity://toggle-visibility launches the app (it wasn't running), the freshly-shown window was immediately hidden again because the toggle saw it focused. Flag the cold-start URL during the argv scan and SHOW — never toggle — the window once it's ready (via processPendingProtocolUrls), respecting start-minimized-to-tray. The already-running second-instance path keeps real toggle behavior. Rename the two interactive protocol actions new-task/new-note to add-task/add-note: aligns with the app's Add-Task vocabulary and the globalAddTask/globalAddNote keys, and avoids colliding with the programmatic create-task/. The action names are a frozen public contract once users bind them in compositor configs, so this is the pre-merge moment to settle the naming. Refs #7114. --- docs/wiki/3.03-Keyboard-Shortcuts.md | 50 +++++ electron/ipc-handlers/global-shortcuts.ts | 31 +-- electron/protocol-handler.test.cjs | 222 ++++++++++++++++++++++ electron/protocol-handler.ts | 79 +++++++- electron/various-shared.test.cjs | 220 +++++++++++++++++++++ electron/various-shared.ts | 67 ++++++- 6 files changed, 629 insertions(+), 40 deletions(-) create mode 100644 electron/protocol-handler.test.cjs create mode 100644 electron/various-shared.test.cjs diff --git a/docs/wiki/3.03-Keyboard-Shortcuts.md b/docs/wiki/3.03-Keyboard-Shortcuts.md index 503bb838b5..cb1c6b434f 100755 --- a/docs/wiki/3.03-Keyboard-Shortcuts.md +++ b/docs/wiki/3.03-Keyboard-Shortcuts.md @@ -103,10 +103,60 @@ These shortcuts are **unconfigurable** and always available on group headers. - Global shortcuts (`globalShowHide`, `globalToggleTaskStart`, `globalAddNote`, `globalAddTask`) work system-wide when the app is running. - Window zoom controls (`zoomIn`, `zoomOut`, `zoomDefault`) are desktop-only. +**Linux (Wayland):** + +- Electron's built-in global shortcuts often do not register, because Wayland deliberately leaves global hotkeys to the compositor. Bind your compositor's keys to the URL scheme instead (see below). + **Android:** - Keyboard settings are not shown on Android; the keyboard configuration UI is hidden. +## Global Shortcuts on Wayland (URL scheme) + +On Wayland the compositor — not the application — owns global hotkeys, so the built-in global shortcuts above may not fire. Super Productivity registers the `superproductivity://` URL scheme, and the already-running instance handles these URLs, so you can bind compositor keys to them with the standard `xdg-open` command (part of `xdg-utils`, already present on virtually every Linux desktop — no extra tool or runtime to install). + +Available fire-and-forget actions: + +| URL | Equivalent shortcut | Effect | +| ----------------------------------------- | ----------------------- | -------------------------------------------- | +| `superproductivity://toggle-visibility` | `globalShowHide` | Show/focus the window, or hide it | +| `superproductivity://add-task` | `globalAddTask` | Show the window and open the add-task bar | +| `superproductivity://add-note` | `globalAddNote` | Show the window and open the add-note dialog | +| `superproductivity://task-toggle-start` | `globalToggleTaskStart` | Start/pause tracking the current task | +| `superproductivity://create-task/<title>` | — | Create a task with the URL-encoded `<title>` | + +Run any of them manually to test: + +```bash +xdg-open "superproductivity://toggle-visibility" +``` + +Then bind them in your compositor. Examples: + +```kdl +// Niri (config.kdl) +binds { + Mod+Shift+S { spawn "xdg-open" "superproductivity://toggle-visibility"; } + Mod+Shift+A { spawn "xdg-open" "superproductivity://add-task"; } +} +``` + +```ini +# sway / i3 (~/.config/sway/config) +bindsym $mod+Shift+s exec xdg-open "superproductivity://toggle-visibility" +bindsym $mod+Shift+a exec xdg-open "superproductivity://add-task" +``` + +```ini +# Hyprland (~/.config/hypr/hyprland.conf) +bind = $mainMod SHIFT, S, exec, xdg-open "superproductivity://toggle-visibility" +bind = $mainMod SHIFT, A, exec, xdg-open "superproductivity://add-task" +``` + +If `xdg-open` does not reach the app, confirm the desktop entry registers the scheme with `xdg-mime query default x-scheme-handler/superproductivity` (it should print Super Productivity's `.desktop` file). The same mechanism works on X11; it is only required on Wayland. + +> **Packaging note:** the `.deb`/`.rpm` packages register the scheme at install time. With the **AppImage** you must integrate it into your desktop first (e.g. via AppImageLauncher), otherwise nothing registers the scheme. **Flatpak/Snap** register it via their own packaged desktop file, so `xdg-mime query` resolves to that id rather than a plain `superproductivity.desktop`. + ## Configurable Vs Reserved All shortcuts listed in [[3.02-Settings-and-Preferences]] are user-configurable. Users can: diff --git a/electron/ipc-handlers/global-shortcuts.ts b/electron/ipc-handlers/global-shortcuts.ts index e7a0decb94..66b27626c0 100644 --- a/electron/ipc-handlers/global-shortcuts.ts +++ b/electron/ipc-handlers/global-shortcuts.ts @@ -4,12 +4,9 @@ import { KeyboardConfig, GLOBAL_KEY_CFG_KEYS, } from '../shared-with-frontend/keyboard-config.model'; -import { getWin, setWasMaximizedBeforeHide } from '../main-window'; +import { getWin } from '../main-window'; import { toggleTaskWidgetVisibility } from '../task-widget/task-widget'; -import { showOrFocus } from '../various-shared'; -import { ensureIndicator } from '../indicator'; -import { getIsMinimizeToTray } from '../shared-state'; -import { IS_MAC } from '../common.const'; +import { showOrFocus, toggleWindowVisibility } from '../various-shared'; import { errorHandlerWithFrontendInform } from '../error-handler-with-frontend-inform'; export const initGlobalShortcutsIpc = (): void => { @@ -33,29 +30,7 @@ const registerShowAppShortCuts = (cfg: KeyboardConfig): void => { switch (key) { case 'globalShowHide': actionFn = () => { - if (!mainWin.isFocused()) { - showOrFocus(mainWin); - return; - } - // Hide strategy differs by platform: - // - macOS: the dock icon always remains after hide(), so the - // window stays reachable without any tray. Match the native - // Cmd+H gesture users expect from a "show/hide" shortcut. - // - Windows/Linux: hide() removes the taskbar entry. Without a - // visible tray icon the window becomes unreachable (see #7282). - // Only hide to tray when minimize-to-tray is enabled AND the - // tray was successfully (re)created; otherwise minimize so a - // taskbar handle remains as a safety net. blur() is a Windows - // focus workaround (electron#20464) and a no-op elsewhere. - setWasMaximizedBeforeHide(mainWin.isMaximized()); - if (IS_MAC) { - mainWin.hide(); - } else if (getIsMinimizeToTray() && ensureIndicator()) { - mainWin.blur(); - mainWin.hide(); - } else { - mainWin.minimize(); - } + toggleWindowVisibility(mainWin); }; break; diff --git a/electron/protocol-handler.test.cjs b/electron/protocol-handler.test.cjs new file mode 100644 index 0000000000..9dc7c6fd69 --- /dev/null +++ b/electron/protocol-handler.test.cjs @@ -0,0 +1,222 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); + +require('ts-node/register/transpile-only'); + +const protocolHandlerPath = path.resolve(__dirname, 'protocol-handler.ts'); + +const originalModuleLoad = Module._load; + +let showOrFocusCalls = []; +let toggleVisibilityCalls = []; +let logCalls = []; + +const installMocks = () => { + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + // Only used for types in protocol-handler; provide harmless stubs. + return { App: class {}, BrowserWindow: class {} }; + } + if (request === 'electron-log/main') { + return { log: (...args) => logCalls.push(args) }; + } + if (request === './various-shared') { + return { + showOrFocus: (win) => showOrFocusCalls.push(win), + toggleWindowVisibility: (win) => toggleVisibilityCalls.push(win), + }; + } + return originalModuleLoad.call(this, request, parent, isMain); + }; +}; + +const restoreMocks = () => { + Module._load = originalModuleLoad; +}; + +const loadModule = () => { + delete require.cache[protocolHandlerPath]; + installMocks(); + try { + return require(protocolHandlerPath); + } finally { + restoreMocks(); + } +}; + +const makeWin = () => { + const sent = []; + return { + sent, + webContents: { + send: (channel, payload) => sent.push({ channel, payload }), + }, + }; +}; + +test.beforeEach(() => { + showOrFocusCalls = []; + toggleVisibilityCalls = []; + logCalls = []; +}); + +test('add-task shows the window and opens the add-task bar', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + processProtocolUrl('superproductivity://add-task', win); + + assert.equal(showOrFocusCalls.length, 1); + assert.deepEqual(win.sent, [{ channel: 'SHOW_ADD_TASK_BAR', payload: undefined }]); +}); + +test('add-note shows the window and triggers add-note', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + processProtocolUrl('superproductivity://add-note', win); + + assert.equal(showOrFocusCalls.length, 1); + assert.deepEqual(win.sent, [{ channel: 'ADD_NOTE', payload: undefined }]); +}); + +test('toggle-visibility delegates to the shared toggle helper without sending IPC', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + processProtocolUrl('superproductivity://toggle-visibility', win); + + assert.equal(toggleVisibilityCalls.length, 1); + assert.equal(toggleVisibilityCalls[0], win); + assert.deepEqual(win.sent, []); +}); + +test('create-task forwards the decoded title', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + processProtocolUrl('superproductivity://create-task/Buy%20milk', win); + + assert.deepEqual(win.sent, [ + { channel: 'ADD_TASK_FROM_APP_URI', payload: { title: 'Buy milk' } }, + ]); +}); + +test('does not log user content (the task title) to the exportable log', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + processProtocolUrl('superproductivity://create-task/My%20Secret%20Title', win); + + // The task itself is still dispatched with the real title... + assert.deepEqual(win.sent, [ + { channel: 'ADD_TASK_FROM_APP_URI', payload: { title: 'My Secret Title' } }, + ]); + // ...but the title must never reach the (exportable) log. + assert.ok( + !JSON.stringify(logCalls).includes('Secret'), + 'task title must not appear in any log line', + ); +}); + +test('unknown actions are ignored and do not send IPC or throw', () => { + const { processProtocolUrl } = loadModule(); + const win = makeWin(); + + assert.doesNotThrow(() => + processProtocolUrl('superproductivity://does-not-exist', win), + ); + assert.deepEqual(win.sent, []); + assert.equal(showOrFocusCalls.length, 0); + assert.equal(toggleVisibilityCalls.length, 0); +}); + +test('getProtocolAction extracts the action host, null for missing/invalid', () => { + const { getProtocolAction } = loadModule(); + + assert.equal( + getProtocolAction('superproductivity://toggle-visibility'), + 'toggle-visibility', + ); + assert.equal( + getProtocolAction('superproductivity://create-task/Buy%20milk'), + 'create-task', + ); + assert.equal(getProtocolAction(undefined), null); + assert.equal(getProtocolAction('::: not a url :::'), null); +}); + +// Build a minimal Electron `app` double that captures the event listeners +// `initializeProtocolHandling` registers so we can drive the real second-instance path. +const makeFakeApp = () => { + const handlers = {}; + return { + handlers, + setAsDefaultProtocolClient: () => {}, + on: (evt, fn) => { + handlers[evt] = fn; + }, + whenReady: () => ({ then: () => {} }), + }; +}; + +test('second-instance does NOT pre-focus for toggle-visibility (reads pre-press state)', () => { + const { initializeProtocolHandling } = loadModule(); + const win = makeWin(); + const app = makeFakeApp(); + + initializeProtocolHandling(false, app, () => win); + app.handlers['second-instance']({}, [ + '/path/to/app', + 'superproductivity://toggle-visibility', + ]); + + // The generic pre-focus would show the window and make the toggle read "visible" and + // hide it again (#7114) — so it must be skipped for this action. + assert.equal(showOrFocusCalls.length, 0, 'must not pre-focus before toggling'); + assert.equal(toggleVisibilityCalls.length, 1, 'toggle still runs'); +}); + +test('second-instance pre-focuses for a plain launch and for non-toggle actions', () => { + const { initializeProtocolHandling } = loadModule(); + const win = makeWin(); + const app = makeFakeApp(); + + initializeProtocolHandling(false, app, () => win); + + // a) plain second launch (no protocol URL) -> bring our window to front. + app.handlers['second-instance']({}, ['/path/to/app']); + assert.equal(showOrFocusCalls.length, 1); + + // b) add-task still focuses the window and opens the add-task bar. + app.handlers['second-instance']({}, ['/path/to/app', 'superproductivity://add-task']); + assert.ok(showOrFocusCalls.length >= 2, 'non-toggle action still focuses the window'); + assert.deepEqual(win.sent, [{ channel: 'SHOW_ADD_TASK_BAR', payload: undefined }]); +}); + +test('cold-start toggle-visibility shows the launched window instead of toggling it (#7114)', () => { + const win = makeWin(); + const app = makeFakeApp(); + const originalArgv = process.argv; + // Simulate the app being COLD-LAUNCHED by the URL: it appears in argv at startup. + process.argv = ['/path/to/app', 'superproductivity://toggle-visibility']; + let mod; + try { + mod = loadModule(); + mod.initializeProtocolHandling(false, app, () => win); + } finally { + process.argv = originalArgv; + } + + // The window is created + shown by startup, then the ready-drain runs ~1s later. + mod.processPendingProtocolUrls(win); + + // Cold start must SHOW the window, never route it through the toggle (which, on a freshly + // shown+focused window, would immediately hide it again). + assert.equal(toggleVisibilityCalls.length, 0, 'cold-start must not toggle'); + assert.equal(showOrFocusCalls.length, 1); + assert.equal(showOrFocusCalls[0], win); + assert.deepEqual(win.sent, []); +}); diff --git a/electron/protocol-handler.ts b/electron/protocol-handler.ts index 963c6be4b1..feb0aa9dae 100644 --- a/electron/protocol-handler.ts +++ b/electron/protocol-handler.ts @@ -2,7 +2,7 @@ import { App, BrowserWindow } from 'electron'; import { log } from 'electron-log/main'; import * as path from 'path'; import { IPC } from './shared-with-frontend/ipc-events.const'; -import { showOrFocus } from './various-shared'; +import { showOrFocus, toggleWindowVisibility } from './various-shared'; export const PROTOCOL_NAME = 'superproductivity'; export const PROTOCOL_PREFIX = `${PROTOCOL_NAME}://`; @@ -10,10 +10,34 @@ export const PROTOCOL_PREFIX = `${PROTOCOL_NAME}://`; // Store pending URLs to process after window is ready let pendingUrls: string[] = []; +// When the app is COLD-LAUNCHED by `superproductivity://toggle-visibility` (it was not +// already running), the freshly-created window must just be SHOWN — never toggled, which +// would immediately hide the window the launch was meant to reveal (#7114). The cold-start +// argv scan sets this one-shot flag instead of routing that URL through the toggle, and the +// window-ready drain (processPendingProtocolUrls) consumes it with a single showOrFocus. +let coldStartShowPending = false; + +/** + * Parse the action (host) of a `superproductivity://` URL, or `null` if it is + * missing/unparseable. Used by the `second-instance` handler to special-case actions + * whose behavior the generic pre-focus would otherwise break. + */ +export const getProtocolAction = (url: string | undefined): string | null => { + if (!url) { + return null; + } + try { + return new URL(url).hostname; + } catch { + return null; + } +}; + export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): void => { - // Redact query params before logging — OAuth code/state are credentials - const redactedUrl = url.split('?')[0].split('#')[0]; - log('Processing protocol URL:', redactedUrl); + // Log only the scheme + action host. The query/fragment carry OAuth credentials and the + // path carries user content (e.g. a create-task title); the log is exportable, so neither + // may be written to it. + log('Processing protocol URL:', `${PROTOCOL_PREFIX}${getProtocolAction(url) ?? ''}`); // Only process after window is ready if (!mainWin || !mainWin.webContents) { @@ -33,7 +57,8 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): const pathParts = urlObj.pathname.split('/').filter(Boolean); log('Protocol action:', action); - log('Protocol path parts:', pathParts); + // Log the count only — path parts can hold user content (e.g. a create-task title). + log('Protocol path part count:', pathParts.length); switch (action) { case 'oauth-callback': @@ -46,7 +71,8 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): case 'create-task': if (pathParts.length > 0) { const taskTitle = decodeURIComponent(pathParts[0]); - log('Creating task with title:', taskTitle); + // Don't log the title — the log is exportable and must not contain user content. + log('Creating task from protocol URL'); // Send IPC message to create task if (mainWin && mainWin.webContents) { @@ -60,6 +86,20 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): mainWin.webContents.send(IPC.TASK_TOGGLE_START); } break; + // The following three mirror the `globalShowHide` / `globalAddNote` / `globalAddTask` + // global shortcuts. On Wayland the compositor owns global hotkeys, so users bind keys + // to `xdg-open superproductivity://<action>` instead (#7114). + case 'toggle-visibility': + toggleWindowVisibility(mainWin); + break; + case 'add-note': + showOrFocus(mainWin); + mainWin.webContents.send(IPC.ADD_NOTE); + break; + case 'add-task': + showOrFocus(mainWin); + mainWin.webContents.send(IPC.SHOW_ADD_TASK_BAR); + break; default: log('Unknown protocol action:', action); } @@ -69,6 +109,12 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): }; export const processPendingProtocolUrls = (mainWin: BrowserWindow): void => { + if (coldStartShowPending) { + coldStartShowPending = false; + // Cold-start toggle-visibility: show the window (works even if start-minimized-to-tray + // left it hidden) instead of toggling it back off. + showOrFocus(mainWin); + } if (pendingUrls.length > 0) { log(`Processing ${pendingUrls.length} pending protocol URLs`); const urls = [...pendingUrls]; @@ -104,14 +150,17 @@ export const initializeProtocolHandling = ( // Handle protocol on Windows/Linux via second instance appInstance.on('second-instance', (event, commandLine) => { const mainWin = getMainWindow(); + const url = commandLine.find((arg) => arg.startsWith(PROTOCOL_PREFIX)); - // Someone tried to run a second instance, we should focus our window instead. - if (mainWin) { + // A second launch should normally bring our window to front. But `toggle-visibility` + // must observe the *pre-press* window state — pre-focusing here would make the toggle + // always read "visible" and hide the window the user actually asked to show (#7114), + // so let that action manage visibility itself. + if (mainWin && getProtocolAction(url) !== 'toggle-visibility') { showOrFocus(mainWin); } // Handle protocol url from second instance - const url = commandLine.find((arg) => arg.startsWith(PROTOCOL_PREFIX)); if (url) { processProtocolUrl(url, mainWin); } @@ -128,7 +177,17 @@ export const initializeProtocolHandling = ( // Handle protocol URL passed as command line argument for testing process.argv.forEach((val) => { if (val && val.startsWith(PROTOCOL_PREFIX)) { - log('Protocol URL from command line:', val.split('?')[0].split('#')[0]); + log( + 'Protocol URL from command line:', + `${PROTOCOL_PREFIX}${getProtocolAction(val) ?? ''}`, + ); + // A toggle-visibility that cold-launched the app must SHOW the new window, not toggle + // it (see coldStartShowPending) — running the normal toggle would hide the window the + // user just asked to see (#7114). Flag it for the window-ready drain instead. + if (getProtocolAction(val) === 'toggle-visibility') { + coldStartShowPending = true; + return; + } // Process after app is ready appInstance.whenReady().then(() => { processProtocolUrl(val, getMainWindow()); diff --git a/electron/various-shared.test.cjs b/electron/various-shared.test.cjs new file mode 100644 index 0000000000..13e437d08b --- /dev/null +++ b/electron/various-shared.test.cjs @@ -0,0 +1,220 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); + +require('ts-node/register/transpile-only'); + +const variousSharedPath = path.resolve(__dirname, 'various-shared.ts'); + +const originalModuleLoad = Module._load; +const originalDateNow = Date.now; + +let mockNow = 0; +let mockIsMinimizeToTray = false; +let mockEnsureIndicator = false; +let mockIsMac = false; + +const installMocks = () => { + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + return { app: { quit: () => {} }, BrowserWindow: class {} }; + } + if (request === 'electron-log/main') { + return { info: () => {} }; + } + if (request === './main-window') { + return { + getWin: () => null, + getWasMaximizedBeforeHide: () => false, + setWasMaximizedBeforeHide: () => {}, + }; + } + if (request === './task-widget/task-widget') { + return { + getIsTaskWidgetAlwaysShow: () => true, + getIsTaskWidgetUserForcedVisible: () => false, + hideTaskWidget: () => {}, + }; + } + if (request === './shared-state') { + return { + getIsMinimizeToTray: () => mockIsMinimizeToTray, + setIsQuiting: () => {}, + }; + } + if (request === './indicator') { + return { ensureIndicator: () => mockEnsureIndicator }; + } + if (request === './common.const') { + return { IS_MAC: mockIsMac }; + } + return originalModuleLoad.call(this, request, parent, isMain); + }; +}; + +const restoreMocks = () => { + Module._load = originalModuleLoad; +}; + +const loadModule = () => { + delete require.cache[variousSharedPath]; + installMocks(); + try { + return require(variousSharedPath); + } finally { + restoreMocks(); + } +}; + +const makeWin = (state) => { + const calls = []; + const win = { + calls, + _state: { ...state }, + isVisible: () => win._state.visible, + isMinimized: () => win._state.minimized, + isFocused: () => win._state.focused, + isMaximized: () => false, + isDestroyed: () => false, + minimize: () => { + calls.push('minimize'); + win._state = { visible: false, minimized: true, focused: false }; + }, + hide: () => { + calls.push('hide'); + win._state = { visible: false, minimized: false, focused: false }; + }, + blur: () => calls.push('blur'), + restore: () => calls.push('restore'), + show: () => { + calls.push('show'); + win._state = { visible: true, minimized: false, focused: false }; + }, + focus: () => { + calls.push('focus'); + win._state = { ...win._state, focused: true }; + }, + maximize: () => calls.push('maximize'), + webContents: { isDestroyed: () => true, focus: () => {} }, + }; + return win; +}; + +test.beforeEach(() => { + mockNow = 100000; + mockIsMinimizeToTray = false; + mockEnsureIndicator = false; + mockIsMac = false; + Date.now = () => mockNow; +}); + +test.afterEach(() => { + Date.now = originalDateNow; +}); + +test('a held key-repeat does not hide then immediately re-show the window (#7114)', () => { + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: true }); + + // 1) First press of one physical key: visible+focused -> minimize. + toggleWindowVisibility(win); + assert.deepEqual(win.calls, ['minimize']); + assert.equal(win.isVisible(), false); + + // 2) Key-repeat 80ms later (same physical press): must be ignored, NOT re-shown. + mockNow += 80; + toggleWindowVisibility(win); + assert.deepEqual( + win.calls, + ['minimize'], + 'repeat within the quiet gap must be ignored', + ); + + // 3) Another repeat, still within the gap relative to the previous event. + mockNow += 80; + toggleWindowVisibility(win); + assert.deepEqual(win.calls, ['minimize'], 'consecutive repeats keep resetting the gap'); +}); + +test('a deliberate press after the quiet gap toggles again (gap actually expires)', () => { + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: true }); + + // 1) First press hides it and records the toggle timestamp. + toggleWindowVisibility(win); + assert.deepEqual(win.calls, ['minimize']); + + // 2) A repeat within the gap is swallowed (and still slides the gap forward). + mockNow += 200; + toggleWindowVisibility(win); + assert.deepEqual(win.calls, ['minimize'], 'within-gap repeat ignored'); + + // 3) After a real pause (> the quiet gap, measured from the LAST event) a deliberate + // press shows it again — proving the debounce releases rather than sticking. + mockNow += 1000; + toggleWindowVisibility(win); + assert.ok(win.calls.includes('show'), 'press after the gap re-shows the window'); +}); + +test('a held key starting HIDDEN settles shown, not hidden (#7114, both directions)', () => { + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: false, minimized: true, focused: false }); + + // 1) First event of the held key: hidden+unfocused -> show. + toggleWindowVisibility(win); + assert.ok(win.calls.includes('show'), 'first event shows the window'); + assert.equal(win.isVisible(), true); + const callsAfterShow = [...win.calls]; + + // 2-3) Repeats within the gap must NOT hide it again. The old isHidden-only guard let + // the now-visible window fall through to the hide branch -> ended HIDDEN. + mockNow += 80; + toggleWindowVisibility(win); + mockNow += 80; + toggleWindowVisibility(win); + assert.deepEqual(win.calls, callsAfterShow, 'repeats swallowed in both directions'); + assert.equal(win.isVisible(), true, 'window stays shown for the whole held press'); +}); + +test('a visible-but-unfocused window is brought to front, never hidden', () => { + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: false }); + + toggleWindowVisibility(win); + + assert.deepEqual(win.calls, ['focus'], 'should focus, not hide'); +}); + +test('macless minimize-to-tray hides to tray only when the indicator exists', () => { + mockIsMinimizeToTray = true; + mockEnsureIndicator = true; + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: true }); + + toggleWindowVisibility(win); + + assert.deepEqual(win.calls, ['blur', 'hide']); +}); + +test('minimize-to-tray falls back to minimize when the tray is unavailable (#7282)', () => { + mockIsMinimizeToTray = true; + mockEnsureIndicator = false; // tray failed to (re)create + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: true }); + + toggleWindowVisibility(win); + + // Must keep a taskbar handle (minimize), not hide() into an unreachable state. + assert.deepEqual(win.calls, ['minimize']); +}); + +test('on macOS the window hides (dock icon stays), never minimizes', () => { + mockIsMac = true; + const { toggleWindowVisibility } = loadModule(); + const win = makeWin({ visible: true, minimized: false, focused: true }); + + toggleWindowVisibility(win); + + assert.deepEqual(win.calls, ['hide']); +}); diff --git a/electron/various-shared.ts b/electron/various-shared.ts index c699106ee7..106acb5da5 100644 --- a/electron/various-shared.ts +++ b/electron/various-shared.ts @@ -1,12 +1,18 @@ import { app, BrowserWindow } from 'electron'; import { info } from 'electron-log/main'; -import { getWin, getWasMaximizedBeforeHide } from './main-window'; +import { + getWin, + getWasMaximizedBeforeHide, + setWasMaximizedBeforeHide, +} from './main-window'; import { getIsTaskWidgetAlwaysShow, getIsTaskWidgetUserForcedVisible, hideTaskWidget, } from './task-widget/task-widget'; -import { setIsQuiting } from './shared-state'; +import { getIsMinimizeToTray, setIsQuiting } from './shared-state'; +import { ensureIndicator } from './indicator'; +import { IS_MAC } from './common.const'; // eslint-disable-next-line prefer-arrow/prefer-arrow-functions export function quitApp(): void { @@ -56,3 +62,60 @@ export function showOrFocus(passedWin: BrowserWindow): void { } }, 60); } + +// One physical key press can fire this action several times in a row: Electron's +// globalShortcut auto-repeats while the key is held (X11 XGrabKey), and a held compositor +// key bound to `xdg-open superproductivity://toggle-visibility` spawns repeated launches. +// Without a guard the burst hides the window and immediately re-shows it — the #7114 +// flicker. We debounce in BOTH directions (a repeat must never undo the first event's +// toggle) with a sliding quiet-gap: every event — even a swallowed one — extends the +// window, so a held key settles on a single toggle instead of oscillating. The gap has to +// exceed the OS/compositor auto-repeat *initial* delay (commonly 250–660 ms) or the first +// repeat slips through and flickers again; 750 ms covers typical setups while keeping a +// deliberate later re-press responsive. +const TOGGLE_VISIBILITY_REPEAT_GAP_MS = 750; +let lastToggleVisibilityEvent = 0; + +/** + * Show the window if it is hidden/unfocused, otherwise hide it. Shared by the + * `globalShowHide` global shortcut and the `superproductivity://toggle-visibility` + * protocol action so both entry points behave identically. + */ +// eslint-disable-next-line prefer-arrow/prefer-arrow-functions +export function toggleWindowVisibility(passedWin: BrowserWindow): void { + const win = passedWin || getWin(); + if (!win) { + return; + } + + const now = Date.now(); + const sinceLastMs = now - lastToggleVisibilityEvent; + // Update on every event (even swallowed ones) so a held key keeps the gap alive. + lastToggleVisibilityEvent = now; + if (sinceLastMs < TOGGLE_VISIBILITY_REPEAT_GAP_MS) { + return; + } + + if (!win.isFocused()) { + showOrFocus(win); + return; + } + + // Hide strategy differs by platform: + // - macOS: the dock icon always remains after hide(), so the window stays reachable + // without any tray. Match the native Cmd+H gesture users expect from "show/hide". + // - Windows/Linux: hide() removes the taskbar entry. Without a visible tray icon the + // window becomes unreachable (#7282). Only hide to tray when minimize-to-tray is + // enabled AND the tray was successfully (re)created; otherwise minimize so a taskbar + // handle remains as a safety net. blur() is a Windows focus workaround (electron#20464) + // and a no-op elsewhere. + setWasMaximizedBeforeHide(win.isMaximized()); + if (IS_MAC) { + win.hide(); + } else if (getIsMinimizeToTray() && ensureIndicator()) { + win.blur(); + win.hide(); + } else { + win.minimize(); + } +}