super-productivity/electron/various-shared.test.cjs
Johannes Millan 14a56f861e
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.
2026-06-30 17:22:30 +02:00

220 lines
7 KiB
JavaScript

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