feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645)

* 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://<action>`. 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/<title>. 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.
This commit is contained in:
Johannes Millan 2026-06-30 17:22:30 +02:00 committed by GitHub
parent 44030bb06e
commit 14a56f861e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 629 additions and 40 deletions

View file

@ -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:

View file

@ -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;

View file

@ -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, []);
});

View file

@ -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());

View file

@ -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']);
});

View file

@ -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 250660 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();
}
}