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/
` | — | Create a task with the URL-encoded `` |
+
+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://` 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();
+ }
+}