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 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.
220 lines
7 KiB
JavaScript
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']);
|
|
});
|