mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
44030bb06e
commit
14a56f861e
6 changed files with 629 additions and 40 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
222
electron/protocol-handler.test.cjs
Normal file
222
electron/protocol-handler.test.cjs
Normal 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, []);
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
220
electron/various-shared.test.cjs
Normal file
220
electron/various-shared.test.cjs
Normal 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']);
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue