mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 08:56:41 +00:00
* feat(plainspace): add Collaborate action to project context menu Surface Plainspace sharing from the project context menu (active, non-Inbox, not-yet-shared projects) so it can be discovered at the moment of collaboration intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace selector hides the action once a project is shared to avoid provisioning a duplicate space on a repeat click. * docs(plainspace): document collaboration in wiki Add Plainspace to the issue-integration comparison (matrix + per-provider section) and a how-to for sharing a project via the project menu, including the network/privacy caveat. * feat(plainspace): place Collaborate action below the share-list item Group the two share/export actions and lift Collaborate higher for discoverability. Gated to active, non-inbox, not-already-shared projects. * fix(plainspace): smoother first-run connect and value-first dialog Pre-check connectivity and revalidate a stored token before the space picker, so a stale/foreign token routes to the connect dialog and an offline state shows a calm message instead of the raw 'check your token' picker error. Make the connect dialog value-first: lead with what you get, drop the 4-step how-to and email hint in favor of one short pointer (token-creation guidance moves to plainspace.org). * docs(plans): dedicated from-Super-Productivity flow on plainspace.org Open plan for a guided token/connect flow on plainspace.org when a user arrives from SP (Model A manual token, Model B OAuth-style handoff). * refactor(plainspace): drop redundant connect pre-check (multi-review) The space picker already detects a stale token and offers a reconnect (#8616), so the revalidate() pre-check duplicated that path, cost an extra GET /me, and forced re-auth on a valid token during a transient server blip. Keep only the one-line offline guard; drop revalidate(), the discriminated union, and the unnecessary _isOnline() seam (navigator .onLine is spyable in the runner). Fix stale doc comments and document the disabled-provider trade-off in the selector. * feat(plainspace): show brand icon in the connect dialog title * feat(plainspace): deep-link connect dialog to the from-SP onboarding flow Point the dialog's 'Open Plainspace' link at the dedicated /connect/super-productivity entrypoint (which guides token creation) instead of the bare marketing host, closing the connect-flow funnel leak. * feat(plainspace): bounce back to the app after connecting (desktop) Append a validated `?return=superproductivity://plainspace-connect` deep link to the connect URL, gated on IS_ELECTRON (only desktop registers the scheme — web/mobile would get dead buttons). Handle that action in the Electron protocol handler by surfacing the window, so the connect page's "Open Super Productivity" button re-focuses the app. Desktop-only; needs an on-device check.
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
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, toggleWindowVisibility } from './various-shared';
|
|
|
|
export const PROTOCOL_NAME = 'superproductivity';
|
|
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 => {
|
|
// 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) {
|
|
log('Window not ready, deferring protocol URL processing');
|
|
pendingUrls.push(url);
|
|
|
|
// Process any pending protocol URLs after window is created
|
|
setTimeout(() => {
|
|
processPendingProtocolUrls(mainWin);
|
|
}, 10000);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const action = urlObj.hostname;
|
|
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
|
|
log('Protocol action:', action);
|
|
// 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':
|
|
log('Received OAuth callback URL via app protocol');
|
|
if (mainWin && mainWin.webContents) {
|
|
mainWin.webContents.send(IPC.OAUTH_CALLBACK, { url });
|
|
showOrFocus(mainWin);
|
|
}
|
|
break;
|
|
case 'create-task':
|
|
if (pathParts.length > 0) {
|
|
const taskTitle = decodeURIComponent(pathParts[0]);
|
|
// 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) {
|
|
mainWin.webContents.send(IPC.ADD_TASK_FROM_APP_URI, { title: taskTitle });
|
|
}
|
|
}
|
|
break;
|
|
case 'task-toggle-start':
|
|
// Send IPC message to toggle task start
|
|
if (mainWin && mainWin.webContents) {
|
|
mainWin.webContents.send(IPC.TASK_TOGGLE_START);
|
|
}
|
|
break;
|
|
case 'plainspace-connect':
|
|
// Bounce-back from the plainspace.org connect flow ("Open Super
|
|
// Productivity"): just surface the window. The user pastes the token
|
|
// they copied there; no payload to forward.
|
|
if (mainWin && mainWin.webContents) {
|
|
showOrFocus(mainWin);
|
|
}
|
|
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);
|
|
}
|
|
} catch (error) {
|
|
log('Error processing protocol URL:', error);
|
|
}
|
|
};
|
|
|
|
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];
|
|
pendingUrls = [];
|
|
urls.forEach((url) => processProtocolUrl(url, mainWin));
|
|
}
|
|
};
|
|
|
|
export const initializeProtocolHandling = (
|
|
IS_DEV: boolean,
|
|
appInstance: App,
|
|
getMainWindow: () => BrowserWindow | null,
|
|
): void => {
|
|
// Register protocol handler
|
|
if (IS_DEV && process.defaultApp) {
|
|
if (process.argv.length >= 2) {
|
|
const launchArgsForProtocol = [path.resolve(process.argv[1])];
|
|
const userDataDirArg = process.argv.find((arg) =>
|
|
arg.startsWith('--user-data-dir='),
|
|
);
|
|
if (userDataDirArg) {
|
|
launchArgsForProtocol.push(userDataDirArg);
|
|
}
|
|
|
|
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [
|
|
...launchArgsForProtocol,
|
|
]);
|
|
}
|
|
} else {
|
|
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME);
|
|
}
|
|
|
|
// 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));
|
|
|
|
// 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
|
|
if (url) {
|
|
processProtocolUrl(url, mainWin);
|
|
}
|
|
});
|
|
|
|
// Handle protocol on macOS
|
|
appInstance.on('open-url', (event, url) => {
|
|
if (url.startsWith(PROTOCOL_PREFIX)) {
|
|
event.preventDefault();
|
|
processProtocolUrl(url, getMainWindow());
|
|
}
|
|
});
|
|
|
|
// 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:',
|
|
`${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());
|
|
});
|
|
}
|
|
});
|
|
};
|