* docs(plugins): add microsoft 365 calendar provider plan
* fix(jira): gate Electron requests behind one-shot capability
Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.
* fix(jira): enforce Electron request capability
Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.
* fix(electron): handle payload-only IPC lifecycle
Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.
* fix(electron): address Jira IPC capability review findings
- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
payload-only IPC refactor (was [1], now undefined -> TypeError on every
download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
that reuses the WebFrameMain object is not permanently locked out of
Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
_handleResponse instead of leaking a dangling request-log entry
* test(electron): cover ANY_FILE_DOWNLOADED payload parsing
Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.
* fix(electron): restore Node ambient globals for frontend build
* fix(electron): open file:// URLs with special characters (#8695)
Local file:// URLs with non-ASCII names or spaces (e.g. Grüne, "Another
One") failed to open on Windows: both open sinks handed the URL to
shell.openExternal, which percent-encodes the path (ü → %C3%BC, space →
%20) before ShellExecute, so the OS then searched for a literally-named
folder and failed. Pure-ASCII paths worked because there was nothing to
encode.
Route local file: URLs through shell.openPath with a decoded filesystem
path (fileURLToPath) instead. openPath takes a raw path, so Unicode names
and spaces open correctly. This also applies the executable-extension
guard to file: URLs, which previously only ran on the OPEN_PATH sink
(GHSA-hr87-735w-hfq3).
Shared openLocalPath()/isLocalFileUrl() cover both the OPEN_EXTERNAL IPC
handler and the navigation-interception path in main-window.
* test(electron): cover NTLM/exec vectors at the un-pre-gated openPath sink
Follow-up to the #8695 review. Add regression tests locking in that
openLocalPath alone (OPEN_PATH has no scheme pre-gate) blocks the
path-based UNC file: URL (file:////host/share, GHSA-hr87-735w-hfq3) and
an executable hidden behind a percent-encoded dot (evil%2Ebat). Document
that the decode tests run on Linux and therefore don't exercise the
Windows drive-letter/backslash conversion, and clarify why isLocalFileUrl
is intentionally broader than the renderer/scheme-allowlist checks.
* fix(electron): block executable launch via shell.openPath
window.ea.openPath is reachable by any plugin, same-origin iframe plugin, or
renderer XSS, and its guard only blocked UNC/remote paths (NTLM leak,
GHSA-hr87). shell.openPath hands a local file to the OS handler, and on
Windows ShellExecute runs .bat/.cmd/.vbs/.hta/... from a plain file with no
exec bit. Chained with a writable-dir drop (e.g. FILE_SYNC_SAVE into the
local-file sync folder) or a malicious synced FILE attachment clicked by the
user, that is renderer -> native code execution bypassing the nodeExecution
consent gate.
Add hasExecutableFileExtension() (normalizes Windows trailing dots/spaces and
NTFS alternate data streams) and refuse those paths at the openPath sink.
Applied at the sink, not in the shared isPathSafeToOpen, so link/image
rendering (where a remote https://.../x.exe is legitimate) is unaffected.
* fix(electron): close # filename bypass in openPath executable gate
The executable-extension check split every path on `?`/`#` to drop a URL
query/fragment, but `#` is a legal Windows/NTFS filename char (and both `#`
and `?` are legal on POSIX). For a bare filesystem path that split truncated
the real filename: `C:\sync\evil.txt#.bat` was read as `.txt` and allowed,
yet ShellExecute runs it as `.bat` — defeating the gate via the same
drop-a-file-then-openPath vector the fix targets. Gate the query/fragment
split behind an actual `file:` scheme so real filenames keep their extension.
Also add well-known ShellExecute/launcher vectors the curated denylist
missed: settingcontent-ms, appref-ms, library-ms, wsc, chm, hlp, diagcab,
msix/appx family (Windows) and pkg, terminal, fileloc, inetloc (macOS).
Adds regression tests for both.
* fix(electron): require file:// (not bare file:) before query/fragment split
The executable-extension gate stripped a URL query/fragment for any string
starting with `file:`. On POSIX `#`, `?` and `:` are all legal filename
chars, so a file whose name merely starts with the literal `file:` (e.g.
`file:notes.txt#.sh`) was mis-parsed as a URL and its real `.sh`/`.desktop`
extension hidden. Require the `//` of an actual `file://` URL so only genuine
URLs get the query/fragment split; bare paths keep their real extension.
* fix(electron): fail-safe exec confirmation dialog (GHSA-256q)
The EXEC confirmation was the only gate before an arbitrary shell command
runs with the user's privileges, but it did not fail safe:
- defaultId: 2 was out of range for a two-button dialog, leaving the
focused default per-platform-undefined, so an accidental Enter could
execute. Cancel is now defaultId + cancelId (Enter/Escape never runs).
- 'Remember my answer' defaulted to checked, so one careless click could
whitelist a command to the silent allow-list forever. It is now opt-in.
Add electron/ipc-handlers/exec.test.cjs as a regression guard.
* docs(plugins): correct misleading plugin sandboxing claims
Docs claimed JS plugins run in 'isolated VM contexts' and iframes run
'without allow-same-origin' — both are false. JS plugins run in the host
renderer via new Function, and iframes use allow-same-origin (required for
#8467), so both can reach the privileged window.ea bridge. Align the docs
with the code (plugin-iframe.util.ts) and stress the trust model.
* fix(electron): fail closed on corrupt exec allow-list, cover error paths
The EXEC handler is wired to ipcMain.on (fire-and-forget), so a throw on a
corrupt allow-list surfaced as an unhandled promise rejection with no user
feedback. Wrap the handler body in try/catch and route failures through
errorHandlerWithFrontendInform (the same channel exec errors already use),
failing closed so a corrupt store never falls through to executing.
Expand the regression tests: assert the corrupt-config path informs the error
and runs nothing, cover exec-error routing, and guard allow-list append (an
overwrite regression that wipes remembered commands previously passed green).
* docs(plugins): document window.ea.exec shell path in trust model
The trust-model section implied executeNodeScript() was the only process
path; on desktop window.ea.exec also runs arbitrary shell commands via
child_process.exec behind a separate, weaker gate (confirmation dialog +
persistent allow-list, not the nodeExecution consent).
* test(electron): run exec security regression tests in CI
The test:electron runner globs electron/*.test.cjs (non-recursive), so the
guard at electron/ipc-handlers/exec.test.cjs was never executed by CI —
verified: the suite went 160 -> 168 tests once discovered. Move it to
electron/exec.test.cjs (matching every sibling electron test) and point
execModulePath at ipc-handlers/exec.ts.
* refactor(electron): narrow exec allow-list without an unsafe cast
Drop the `as string[]` cast that asserted away the very corruption the
Array.isArray guard is meant to catch; narrow the unknown value honestly so
the guard is a real type-check. Behavior is unchanged (falsy -> empty list,
truthy non-array -> fail closed).
* fix(electron): stop logging exec command content
Per CLAUDE.md rule 9 (log history is exportable, never log user content), a
command can carry a secret in its arguments; log a content-free line instead.
Also fold the duplicated exec-spawn into a single runCommand() helper so the
allow-listed and just-confirmed paths share one audited call site.
* fix(electron): remove exec IPC to close GHSA-256q at the root
window.ea.exec exposed arbitrary shell (child_process.exec) to the whole
renderer: JS plugins (new Function in the host realm), same-origin iframe
plugins (window.parent.ea), and any renderer XSS — bypassing the per-plugin
nodeExecution consent gate entirely. Its only consumer, COMMAND task
attachments, is dormant: no UI creates them (the edit dialog offers only
LINK/IMG/FILE).
Rather than guard a dormant RCE primitive, remove it: delete the EXEC IPC
handler + preload.exec + the ElectronAPI method + the directive's COMMAND
branch. This closes the vector for plugins, iframes, AND host-realm XSS at
once (a bootstrap handoff would only hide it from plugins), and supersedes
the earlier dialog hardening (that primitive no longer exists).
Keep the 'COMMAND' literal in the synced TaskAttachment type (removing a
synced union member breaks typia validation on peers/legacy data); a click
on a legacy COMMAND attachment now shows an informational snack instead of
executing.
* docs(plugins): reflect exec IPC removal in the trust model
window.ea.exec no longer exists (removed to close GHSA-256q); the docs now
state executeNodeScript is the only sanctioned native-code path.
* test(electron): guard exec IPC stays removed (GHSA-256q)
The interim exec security tests were deleted together with the executor, so the
shipped fix had no regression coverage. Add electron/exec.test.cjs (picked up by
the electron/*.test.cjs CI glob) asserting the bare exec primitive stays gone:
no IPC.EXEC event, no exec.ts handler, no preload exec bridge, no ElectronAPI.exec
method, no initExecIpc wiring. Targets only the removed surface, not the
sanctioned PLUGIN_EXEC_NODE_SCRIPT/executeScript nodeExecution path.
* chore(electron): mark ALLOWED_COMMANDS store key as legacy
Its only reader/writer was the deleted exec handler (GHSA-256q). Document that it
is retained purely so older persisted stores keep loading.
* 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.
* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)
* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy
* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment
* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut
* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping
* test(keyboard-shortcut): add unit tests for layout shortcut translation logic
* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture
* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc
* refactor(config): extract global shortcut keys and mapping helpers
* test: add test helper capability for IS_ELECTRON and IS_MAC
* test: add comprehensive integration unit tests for global shortcut effects
* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix
* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization
* refactor: address non-blocking suggestions for layout detection and DI tokens
* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require
* fix(client-id): increase entropy to 6 chars and fix related test regressions
* fix(electron): validate URL scheme before shell.openExternal (GHSA-hr87-735w-hfq3)
Task-note Markdown links had their href passed verbatim to shell.openExternal
on click, letting note content (sync, imported backups, issue-provider data)
silently invoke any OS-registered protocol handler — ms-msdt: (Follina),
search-ms:, \\host\share (NTLM hash capture), etc. — with one click and no
prompt. Angular's sanitizer does not help: its SAFE_URL_PATTERN blocks only
javascript:, so these schemes pass.
Add a shared scheme allowlist (http/https/mailto/file) enforced at both layers:
- marked renderer (marked-options-factory): blocked-scheme links render as
inert <span> text instead of an anchor (covers all note render paths + web).
- Electron main process, both shell.openExternal sinks: the OPEN_EXTERNAL IPC
handler (system.ts) and openUrlInBrowser (main-window.ts, the
will-navigate/window-open path used by the fullscreen/archived dialogs).
The allowlist lives in electron/shared-with-frontend so renderer and main agree
on one policy. Blocked links are styled muted/non-interactive with an
explanatory tooltip.
* fix(electron): harden openExternal/openPath against SMB & attribute-injection (GHSA-hr87-735w-hfq3)
Follow-up hardening from multi-agent review of the initial fix:
- file:// SMB bypass: `file:` was allow-listed unconditionally, so
`file://host/share` / `file:////host` reached shell.openExternal — the same
NTLM-leak vector the raw `\\host\share` check blocked. Restrict `file:` to the
canonical local form `file:///<path>`. Gated on the raw string because
Chromium (renderer) and Node (main) parse file: hosts/paths differently.
- IPC.OPEN_PATH was unguarded: a synced FILE-attachment path of `\\host\share`
leaked NTLM via shell.openPath. Reject UNC paths (new isUncPath helper).
- Attribute-injection XSS: the marked link renderer interpolated href/title
raw; marked's angle-bracket link form could inject an event handler that runs
under disableSanitizer. HTML-escape href/title.
- Remove the now-dead/misleading explicit backslash check and redundant
toLowerCase (new URL already throws on raw UNC; protocol is spec-lowercase).
Adds cross-engine-verified tests for file:// SMB forms, isUncPath, and the
href quote-injection case.
* fix(electron): block remote file:// in OPEN_PATH too (GHSA-hr87-735w-hfq3)
Re-review found the OPEN_PATH guard was incomplete: isUncPath only rejects raw
slash-prefixed paths, but shell.openPath('file://host/share') resolves to
\\host\share on Windows — the same NTLM leak. A synced FILE-attachment path is
attacker-controllable.
Centralize the path policy in isPathSafeToOpen() (rejects UNC paths and remote
file:// URLs, allows local paths and file:///) so the two sinks can't drift.
* fix(electron): gate image src against remote file:// / UNC (GHSA-hr87-735w-hfq3)
Markdown image src auto-loads on render (no click), so a remote
file://host/share or UNC src silently triggers an SMB connection and
leaks the user's NTLM hash just by viewing a synced/imported note — the
same vector the link/openExternal gate already blocks, but with no user
interaction. CSP (img-src ... file:) does not block it.
Gate image href through isPathSafeToOpen: remote file:// and UNC srcs
render as inert (HTML-escaped) alt text instead of an <img>; local
file://, http(s), data: and blob: images are unaffected.
Also drop a dead eslint-disable directive on ALLOWED_EXTERNAL_URL_SCHEMES
(the name does not trip naming-convention).
* fix(tasks): gate IMG attachment src against remote file:// / UNC (GHSA-hr87-735w-hfq3)
An IMG-type task attachment renders <img [src]="resolvedOriginalPath">,
which auto-loads on render with no click. A synced/imported remote
file://host or UNC path there silently triggers an SMB fetch and leaks
the user's NTLM hash just by opening the task — the same no-click vector
the markdown-image gate already blocks, but via Angular template binding
(Angular's URL sanitizer does not help; SAFE_URL_PATTERN allows file:).
Drop unsafe srcs via isPathSafeToOpen so they never reach the binding;
local file://, http(s), data: and blob: images are unaffected.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* feat(electron): add global shortcut for task widget toggle
Adds a configurable global shortcut (`globalToggleTaskWidget`) that
shows/hides the task widget without changing the persisted
enabled/disabled preference. The shortcut is a no-op while the task
widget feature is disabled.
When the user reveals the widget via the shortcut while the main window
is visible, a sticky "user-forced visible" flag keeps it up (like
always-show) instead of letting the next focus/show event immediately
hide it. The flag clears when the user toggles the widget off, opens the
app from the widget, or the feature is disabled.
Rebased onto master to drop the now-superseded tray-indicator refactor;
the only main-window.ts change is the additive user-forced-visible gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(electron): handle task widget shortcut races
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address findings from code-reviewer + debugger-assistant +
refactor-specialist multi-review on 8e1c47ebc2:
- afterPack: read wrapper source before rename; on writeFile failure,
roll the rename back so a broken intermediate state can't ship a
package with no launcher. Strengthen idempotency check to require a
shebang at binPath (both files alone no longer counts as "installed").
- wrapper: gate the X11 injection on \$SNAP_NAME = "superproductivity",
not just \$SNAP set. An xdg-open from a sibling snap (e.g. Firefox)
leaks \$SNAP into the child env, which previously would have silently
forced XWayland on .deb/.rpm users. Derive BIN_DIR from \$SNAP
directly when we are our snap, avoiding fragile \$0 resolution
through snapd's wrapper chain. Stop argv scanning at -- so positional
args that resemble --ozone-platform aren't misread as a user override.
- app-control: point app.relaunch() execPath at the sibling shell
wrapper instead of the default process.execPath (which is the renamed
ELF and would bypass the flag injection on a relaunched Snap+Wayland
instance).
- Move build/snap-wrapper.sh -> build/linux/snap-wrapper.sh to match
the existing build/linux/ layout convention.
- Update research doc §18 to reflect the tightened predicate and the
relaunch fix.
The globalShowHide shortcut called mainWin.hide() unconditionally. On
Windows/Linux hide() removes the taskbar entry, so with no tray icon
the window became unreachable.
- macOS: use hide() (dock icon always remains).
- Windows/Linux: hide to tray only when minimize-to-tray is on AND the
tray was created; otherwise minimize() to keep a taskbar handle.
- Log whether Windows tray creation used the GUID path, to help diagnose
cases where the icon lands in the hidden overflow area.
Refs #7282
* feat(electron): add Local REST API for desktop automation
Add a local HTTP server (port 3876) that allows external scripts and
tools to interact with a running Super Productivity desktop app.
Features:
- Task CRUD operations (create, read, update, delete)
- Task control (start, stop, set current)
- Task archive/restore operations
- Query filters for tasks (by title, project, tag, done status)
- Read-only project and tag listing
The API is disabled by default and can be enabled in Settings > Misc.
Only accepts connections from localhost (127.0.0.1) for security.
* fix: resolve lint errors for local REST API
* fix: use lazy injection for LocalRestApiHandlerService
Fixes StartupService tests by using Injector.get() instead of direct
injection, preventing LocalRestApiHandlerService from being instantiated
when StartupService tests don't have NgRx providers.
* fix: resolve markdown lint errors in API docs
* fix: add security warning to Local REST API hint
Closes#5622
The 'Minimize to tray' setting was not updating the main process shared state when changed in the settings, requiring an app restart to take effect. This was because the 'UPDATE_SETTINGS' IPC event (sent on change) was not updating the 'isMinimizeToTray' flag. This commit ensures that 'UPDATE_SETTINGS' updates the relevant shared state.
* origin/master:
feat(i18n): add export task list string to localization files
feat(ipc): improve error handling and streamline IPC initialization
feat(startup): refactor startup logic and move initialization to StartupService
feat(ipc): implement IPC handlers for app control, data, exec, global shortcuts, system, and Jira
feat: add placeholder text support to daily summary and inline markdown components
feat: don't install api test plugin
chore(deps): bump the npm_and_yarn group across 4 directories with 1 update
# Conflicts:
# electron/ipc-handler.ts
# src/app/app.component.ts