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 16:11:40 +02:00
parent b2876624b1
commit aa74a08a34
3 changed files with 58 additions and 13 deletions

View file

@ -120,8 +120,8 @@ Available fire-and-forget actions:
| URL | Equivalent shortcut | Effect |
| ----------------------------------------- | ----------------------- | -------------------------------------------- |
| `superproductivity://toggle-visibility` | `globalShowHide` | Show/focus the window, or hide it |
| `superproductivity://new-task` | `globalAddTask` | Show the window and open the add-task bar |
| `superproductivity://new-note` | `globalAddNote` | Show the window and open the add-note dialog |
| `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>` |
@ -137,20 +137,20 @@ Then bind them in your compositor. Examples:
// Niri (config.kdl)
binds {
Mod+Shift+S { spawn "xdg-open" "superproductivity://toggle-visibility"; }
Mod+Shift+A { spawn "xdg-open" "superproductivity://new-task"; }
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://new-task"
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://new-task"
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.

View file

@ -62,21 +62,21 @@ test.beforeEach(() => {
logCalls = [];
});
test('new-task shows the window and opens the add-task bar', () => {
test('add-task shows the window and opens the add-task bar', () => {
const { processProtocolUrl } = loadModule();
const win = makeWin();
processProtocolUrl('superproductivity://new-task', win);
processProtocolUrl('superproductivity://add-task', win);
assert.equal(showOrFocusCalls.length, 1);
assert.deepEqual(win.sent, [{ channel: 'SHOW_ADD_TASK_BAR', payload: undefined }]);
});
test('new-note shows the window and triggers add-note', () => {
test('add-note shows the window and triggers add-note', () => {
const { processProtocolUrl } = loadModule();
const win = makeWin();
processProtocolUrl('superproductivity://new-note', win);
processProtocolUrl('superproductivity://add-note', win);
assert.equal(showOrFocusCalls.length, 1);
assert.deepEqual(win.sent, [{ channel: 'ADD_NOTE', payload: undefined }]);
@ -190,8 +190,33 @@ test('second-instance pre-focuses for a plain launch and for non-toggle actions'
app.handlers['second-instance']({}, ['/path/to/app']);
assert.equal(showOrFocusCalls.length, 1);
// b) new-task still focuses the window and opens the add-task bar.
app.handlers['second-instance']({}, ['/path/to/app', 'superproductivity://new-task']);
// 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

@ -10,6 +10,13 @@ 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
@ -85,11 +92,11 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null):
case 'toggle-visibility':
toggleWindowVisibility(mainWin);
break;
case 'new-note':
case 'add-note':
showOrFocus(mainWin);
mainWin.webContents.send(IPC.ADD_NOTE);
break;
case 'new-task':
case 'add-task':
showOrFocus(mainWin);
mainWin.webContents.send(IPC.SHOW_ADD_TASK_BAR);
break;
@ -102,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];
@ -168,6 +181,13 @@ export const initializeProtocolHandling = (
'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());