Commit graph

513 commits

Author SHA1 Message Date
Johannes Millan
eb80bfc707 fix(plugins): harden uploaded nodeExecution consent gate (review follow-ups)
Addresses multi-agent review findings on the uploaded-plugin nodeExecution
consent gate:

- id validation: use an allowlist (/^[A-Za-z0-9][A-Za-z0-9._-]*$/) instead of a
  Unicode denylist, closing bidi/zero-width/homoglyph dialog-anchor spoofing the
  range list missed (U+061C, U+2060, U+3164, fullwidth chars); strip all Unicode
  control+format chars from the self-declared display name/version.
- never upgrade trust: describeVerifiedBuiltInDialog returns null on any imperfect
  on-disk verification (id mismatch, missing permission, unreadable manifest) and
  the grant handler falls back to the unverified dialog, so a colliding uploaded id
  can never borrow a built-in's verified dialog.
- reserve the gitea/linear/trello/azure-devops issue-provider bundled ids (they had
  drifted out of BUNDLED_PLUGIN_IDS, leaving an impersonation gap) and guard the
  BUNDLED_PLUGIN_PATHS subset-of BUNDLED_PLUGIN_IDS invariant with a node test.
- key the revoke and exec IPC handlers through the same assertSafePluginId as the
  grant handler so the "revoke by id on teardown/re-upload" guarantee can't drift.
- clear the session nodeExecution denial when a plugin is uninstalled, so a fresh
  re-upload of the same id is prompted again rather than silently failing closed.
- de-duplicate the PluginNodeExecutionElectronApi interface into a single
  electron/shared-with-frontend model (was copied byte-identically in two files).
2026-06-25 15:50:41 +02:00
Johannes Millan
a6489e17a8 fix(plugins): reject path-segment ids in nodeExecution consent gate
Multi-agent review of the Phase 1 gate found the uploaded plugin id —
used as a path component in the bundled-manifest existsSync probe — was
not rejecting path separators or dot-segments. Impact was bounded (the
verified-builtin branch re-validates with the strict kebab regex before
any read, so no code exec / file read / dialog spoof), but `..` / `/`
left a filesystem-existence oracle and rendered misleadingly as the
dialog "Plugin ID". assertSafePluginId now rejects `/`, `\`, `.`, `..`.

Also from review: factor the shared Allow/Deny dialog shell, and correct
the denied-cache comment (the existing token short-circuit handles the
multi-call-site case; the cache only makes a denial sticky across a later
non-interactive grant re-entry). Documents the uploaded-id constraints.

Refs #8512
2026-06-24 11:56:43 +02:00
Johannes Millan
839deea1e9 feat(plugins): allow uploaded nodeExecution behind consent gate
Re-open the nodeExecution permission for uploaded/community plugins
(previously built-in only, #8205) behind the existing main-process
consent dialog. Phase 1 of #8512; unblocks the Super Productivity MCP
plugin (discussion #8385).

- Main process sanitizes the attacker-controlled plugin id and the
  self-declared name/version before they reach the consent dialog or
  the grant map (control/bidi/whitespace rejected, length-capped).
- Bundled vs uploaded is decided by the on-disk manifest, never a
  renderer-supplied flag, so uploaded code can't borrow a built-in
  plugin's verified name.
- Uploaded-plugin dialog anchors on the validated id, flags the plugin
  as unverified third-party with full machine access / no sandbox, and
  defaults to Deny.
- Revoke is main-authoritative by (pluginId, webContents) so a re-upload
  reusing an id can't inherit a live session grant.
- Consent stays session-scoped: an in-memory, never-synced denied set
  prevents re-prompt storms; deny keeps the plugin enabled but fails
  node calls closed until re-enable or restart.

Refs #8512 #8385
2026-06-24 11:56:43 +02:00
aakhter
677d4aa57f
Auto-link raw webexteams URIs (#8560)
* feat: auto-link webexteams URIs

* test: expect escaped webexteams URIs

---------

Co-authored-by: Aamer Akhter <aamer_akhter@users.noreply.bitbucket.org>
2026-06-24 11:07:56 +02:00
Harbinger
8171bb05d0
refactor(sync): remove unused error classes, dialog, and constructor-time logging (phase 1, #8325) (#8510)
* fix(op-log): lock snapshot save to prevent lost-update window

saveCurrentStateAsSnapshot() read NgRx state then lastSeq without
holding OPERATION_LOG lock. An op appended between the two reads would
get seq <= lastAppliedOpSeq but its effect would be absent from the
snapshot. On next hydration the tail replay would start after that seq,
silently skipping the op forever.

Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and
read lastSeq BEFORE state snapshot so the worst interleaving degrades
to harmless re-replay (idempotent) rather than a missed op.

Fixes #8308

* fix(op-log): address review feedback on snapshot lock PR

- Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay
- Add inline note about compaction's opposite read order and worse failure mode
- Add lock regression tests (#8308): lock acquired, read order, error handling

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(sync): remove unused error classes, dialog, and constructor-time logging

Phase 1 of #8325: clean up orphaned sync error types and their UI.

Removed error classes that are no longer thrown anywhere:
- NoEtagAPIError, FileExistsAPIError (unused API errors)
- RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow)
- RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile
- LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError

Removed DialogSyncErrorComponent and all references in SyncWrapperService
(_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog,
_openSyncErrorDialog, _extractModelIdFromError).

Removed constructor-time logging from JsonParseError, ModelValidationError,
DataValidationFailedError — these errors are logged at the catch site; redundant
construction-time logs risk leaking user data.

Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR,
ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts.

Updated file-based-sync-flowchart.md to reflect the removed error types.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 13:36:56 +02:00
Johannes Millan
f7278a43ec
fix(electron): crisp, flicker-free Linux tray icon (#4905) (#8484)
Linux StatusNotifierItem hosts redraw the whole tray item on every
Tray.setImage(), so cycling the 16 progress-animation frames made the
icon flicker between a full and a partial ring. Show a single static
running icon on Linux; Windows/macOS keep the animation.

Also re-render the static stopped/running icons (light+dark, @2x) from
vector sources at 24/48px so GNOME no longer upscales a 16px source.
2026-06-19 12:43:00 +02:00
Johannes Millan
b2a68eddee
fix(electron): restore custom title bar on GNOME-X11 (#8483) (#8485)
#7097 force-disabled the custom title bar on all GNOME and hid the
toggle, even though only GNOME+Wayland actually fails to render the
Window-Controls-Overlay. This stranded GNOME-X11 users (where the
feature worked) with no way to re-enable it.

- Narrow the kill-switch from IS_GNOME_DESKTOP to a new IS_GNOME_WAYLAND
  (XDG_SESSION_TYPE/WAYLAND_DISPLAY, mirroring idle-time-handler). GNOME
  on X11 keeps the feature and the settings toggle.
- Unify GNOME detection: preload imports it from common.const instead of
  re-implementing, so main and renderer can't drift.
- Align global-theme.service with main-window: force native decorations
  on GNOME+Wayland in both, so a persisted/synced `true` no longer lays
  the custom header over native controls (doubled header).
- Add unit tests for the detection matrix.
2026-06-19 12:42:44 +02:00
Johannes Millan
31b7c3aac7
fix(electron): show tray icon on versioned Windows builds (#7282) (#8470)
A Windows tray-icon GUID binds to the executable's full path unless the
binary is code-signed with an organization in the signature subject. The
Store (MSIX) and portable/scoop builds run from versioned directories
whose path changes on every update, so the GUID goes stale and
Shell_NotifyIcon(NIM_ADD) fails silently (Electron raises no JS error).
new Tray(image, guid) still returns a live Tray, so the try/catch and
ensureIndicator() truthiness guards never detected the failure — the
window hid to an invisible, unreachable tray.

Use the path-bound GUID only for the stable-path NSIS build; create the
tray without a GUID on Store and portable, so Windows identifies the icon
by window handle and it stays visible across updates. The NSIS path is
untouched, preserving its taskbar-pin persistence.

Refs: https://www.electronjs.org/docs/latest/api/tray (guid)
2026-06-18 13:23:13 +02:00
Maikel Hajiabadi
ec16757c82
fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381)
* 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
2026-06-17 12:57:47 +02:00
Johannes Millan
a4bde69ece fix(electron): allow obsidian:// and other app deep-link schemes (#8429)
The GHSA-hr87 scheme allowlist (#8210, v18.10.0) permitted only
http/https/mailto/file, which silently broke custom app deep-links such
as obsidian:// in task-attachment links and markdown notes.

Add low-risk app-launch + standard schemes (obsidian, vscode,
vscode-insiders, zotero, logseq, tel, sms) to the shared allowlist. This
restores the links across all sinks (OPEN_EXTERNAL, OPEN_PATH, the
navigation fallback, and the markdown link renderer) while still blocking
the OS-level handlers (ms-msdt:, search-ms:) the allowlist exists to stop.
2026-06-16 11:52:44 +02:00
Johannes Millan
b3f7be0afc fix(electron): guard sync-folder cache against stale-load clobber
If a folder pick (setSyncFolderPath) completes while the very first
getSyncFolderPath disk read is still in flight, the load's resolution
callback would overwrite the freshly-set cache with the now-stale disk
value (null), leaving sync wrongly not-ready until restart. Only seed
the cache from disk when it's still unset; the freshly-picked value wins.
2026-06-10 20:35:00 +02:00
Johannes Millan
ecd5ec7077 Merge master and address PR review findings
Harden LocalFile saves, keep legacy file image IPC removed, avoid pre-save cached-image deletion, and add user-facing reselect warnings for LocalFile/background images.
2026-06-10 19:17:54 +02:00
Johannes Millan
0286cd3b5d feat(electron): restore bmp/avif background image support 2026-06-10 17:04:26 +02:00
Johannes Millan
9b58b883af refactor(electron): pick-time userData rejection + shared store key 2026-06-10 17:04:07 +02:00
Johannes Millan
86bb66b3b4 fix(electron): surface sync-folder pick persist failures to renderer 2026-06-10 17:02:48 +02:00
Johannes Millan
789214640d Merge origin/master into task/issue-8228-68b7fe
Resolve conflict in electron/local-file-sync.ts by keeping the PR side:
master's assertPathOutside guard was added inside the TO_FILE_URL handler,
which this PR deletes entirely (deletion supersedes the guard). Also drop
master's two new TO_FILE_URL tests (the handler no longer exists).
2026-06-10 16:12:07 +02:00
Johannes Millan
36dedbd8c4 Merge remote-tracking branch 'origin/master' into fix/security-followups-ntlm-iframe
* origin/master: (24 commits)
  fix(sync): keep own sync settings on hydration replay (#8245)
  Allow writable plugin calendar events to be rescheduled from Schedule (#7965)
  build(lint): enforce Log helpers over console.* in src/app (#8239)
  Fix minor cal design (#8227)
  feat(search): add completed task filter (#8168)
  fix(redmine): support non-latin issue search #4149 (#8150)
  fix(tasks): show schedule context for deadline reminders #7308 (#8165)
  test(task-repeat): derive today assertions from mocked DateService date
  test(task-repeat-cfg): fix date-dependent today assertion
  fix(android): bias torn bridge-thread reads safe, fix stale timer docs
  feat(i18n): update German translations (#8234)
  fix(android): render timer notifications via chronometer, drop 1Hz loops
  refactor(sync): derive local-only sync settings from a single key list #8233 (#8235)
  fix(planner): don't double-count timed recurring tasks on Today (#8236)
  fix(sync): keep schedule settings local (#8077)
  fix(planner): don't double-count recurring tasks that already have an instance (#8229)
  fix(electron): lock in-window navigation to the app's loaded origin (#8230)
  feat(shortcuts): add optional shortcuts for scheduling #8093 (#8189)
  refactor(planner): replace tomorrow task toPromise usage (#8202)
  fix: harden local file URL guard
  ...

# Conflicts:
#	src/app/plugins/ui/plugin-index/plugin-index.component.ts
2026-06-10 15:52:24 +02:00
johannesjo
2b628a0a50 refactor(electron): consistency + UX nits from pre-merge review
Pre-merge review of #8228 flagged four real items in the otherwise
solid security boundary. All non-blocking; rolled into one pass:

1) IMAGE_PICK_AND_IMPORT was the only renderer-callable handler that
   threw raw Errors back across IPC. The FS handlers all funnel
   through createSafeIpcError, which strips e.stack so main-bundle
   paths can't leak via the renderer's error-handling. Switch
   IMAGE_PICK_AND_IMPORT to the same shape: returns Error on
   validation failure, null on user cancel, value on success.
   Renderer adapter uses `instanceof Error` (parity with fileSyncLoad
   et al). New test asserts the returned error has no path content
   and no stack.

2) The image picker button was not disabled while the IPC was in
   flight. A second click would queue a second IPC, opening a second
   dialog after the first resolves; because `replacesId` is computed
   from the form value at click time, the first import's id would
   never be GC'd. Add an `isPickerBusy` signal and bind the button's
   `disabled` to it. New spec asserts the second concurrent click is a
   no-op and the busy flag clears in the `finally`.

3) Stale comment in `local-file.model.ts` pointed at
   `electron/sync-folder-store.ts`, which doesn't exist (the storage
   was inlined into local-file-sync.ts in Phase 2.5). Update to
   describe the actual current shape and clarify that the field is
   read once on upgrade for the migration breadcrumb and never written
   from new code paths.

4) Image input placeholder advertised `file://` URLs as a valid
   input. Phase 4 removed the IPC that resolved them, so the
   placeholder was a footgun. Drop the `file://` half.

68/68 electron security tests; 8/8 formly-image-input karma; lint +
tsc clean.
2026-06-10 00:17:48 +02:00
johannesjo
38d2b16a82 refactor(electron): close IMAGE_CACHE_IMPORT trigger gap + review nits
End-to-end review of the #8228 sweep flagged one real residual risk
and several smaller items. This commit addresses them in one pass.

The IMAGE_CACHE_IMPORT IPC accepted a renderer-supplied absolute path
with no link to a recent SHOW_OPEN_DIALOG signal. A compromised
renderer could call importImage() with any image-extension path under
5 MB outside userData — same information-disclosure shape the legacy
READ_LOCAL_IMAGE_AS_DATA_URL had, just one-shot instead of repeatable.

Fix: merge the dialog and the import into a single atomic IPC,
IMAGE_PICK_AND_IMPORT. Main opens the picker inline and only calls
importImage() with the file the user actually clicked. The renderer
no longer holds a path at any point in the flow; an attacker who
controls the renderer cannot trigger an image read without a real
user dialog interaction.

Shape change:
- Return null on user-cancel; throw on validation failure. This
  preserves the existing UX where cancel is silent and an unreadable
  image shows a snack. Renderer-side openFileExplorer is a try/catch.
- Optional { replacesId } arg garbage-collects the previously
  cached image when the user picks a new one. Closes the disk-fills
  follow-up the review raised (#3) without an extra IPC round-trip.

Smaller items from the review folded in:
- Extract _resolveRelative helper in local-file-sync.ts; the five
  FS handlers had the same three-line incantation copy-pasted. The
  Phase 2.5 simplification over-corrected by inlining everything.
- Drop the dead __resetSyncFolderCacheForTests export; no caller, and
  the tests reset via require.cache surgery anyway.
- image-cache.ts getExt now uses path.extname so Windows backslash
  paths are handled correctly (the old slash-only split worked by
  accident).
- resolveBgImageToDataUrl: short-circuit on empty id, and log a
  console.warn when a legacy file:// is detected (no resolvable IPC
  remains; user must re-pick). i18n snack is a documented follow-up.
- Package LocalFileSyncElectron.isReady now logs a critical line via
  the injected sync logger when isReady=false but the renderer's
  privateCfg still has the legacy syncFolderPath — dev console
  surfaces the "needs re-pick" state without dragging i18n into the
  security PR.
- Stale comment in electron-file-adapter.ts referenced a deleted file
  (sync-folder-store.ts is inlined into local-file-sync.ts now).

Tests:
- electron/local-file-sync.test.cjs gains four IPC-level cases:
  legacy IMAGE_CACHE_IMPORT not registered, pick+import round-trip,
  cancel returns null, validation failure throws, replacesId GCs the
  prior cached file.
- formly-image-input.component.spec rewritten for the new flow:
  asserts imagePickAndImport is called with replacesId when one is in
  state, snack on rejection but not on null, no setValue on either.

68/68 electron security tests pass; 7/7 formly-image-input karma;
5/5 resolve-bg-image karma; 17/17 sync-providers package
local-file specs. Lint + tsc clean.

What this commit does NOT do (called out so it isn't forgotten):
- CLIPBOARD_COPY_IMAGE_FILE has the same renderer-supplied-path shape
  for clipboard images. Parallel issue; out of scope for #8228.
- PICK_DIRECTORY has no defaultPath — could nudge users toward a
  dedicated sync subfolder vs picking ~/Documents. Cosmetic.
- Symlink TOCTOU between resolveSyncPath and writeFileSync stands;
  O_NOFOLLOW-on-open is the proper fix and needs cross-platform care.
- Migration UX is console-only; a user-visible snack needs i18n
  infrastructure not appropriate for the security commit.
2026-06-09 23:57:45 +02:00
johannesjo
ecb72fbc4a feat(electron): drop legacy renderer-supplied-path image IPCs (Phase 4)
With Phase 3's image cache in place, the picker no longer hands the
renderer an absolute path or produces file:// URLs. The two legacy
IPCs that still accepted renderer-supplied absolute paths are now
dead surface area:

  - READ_LOCAL_IMAGE_AS_DATA_URL — read any image-extension file
    outside userData and inline it as base64. The renderer could
    swap the stored value for any matching path between renders.
  - TO_FILE_URL — convert any path outside userData into a file://
    URL the renderer could embed. Was the precondition for the
    above.

Both are removed entirely (handlers, preload bindings, type
signatures, IPC enum entries). The remaining image flow is:

  showOpenDialog (proven user intent)
    → imageCacheImport(path) — main copies into private cache
    → renderer stores `image:<id>`
    → imageCacheGetDataUrl(id) per render

Migration: existing configs that hold `file://` values from older
builds render no background until the user re-picks. The resolver
util explicitly detects the legacy shape and returns null with a
comment pointing to this commit. Same migration model we used for
the sync folder (Phase 2): a one-time re-pick is the cost of
closing the security gap, and the picker UI is already in place.

Also drops the now-stale `IS_ELECTRON` early-return in the resolver
util — `window.ea?.imageCacheGetDataUrl` is undefined on web,
short-circuits naturally, and the implicit check made the spec
unable to exercise the Electron branch.

Tests updated: assertion that the legacy handlers are no longer
registered (a compromised renderer that still tries the IPC must
see no handler), and a positive `image:<id>` round-trip in the
karma util spec. 65 electron security tests + 5 util spec all
green; lint + tsc clean.
2026-06-09 23:40:47 +02:00
johannesjo
601a26c1f7 feat(electron): image picker copy-to-cache (Phase 3 of #8228)
The background-image picker previously gave the renderer an absolute
path to an arbitrary image-extension file and stored it as a file://
URL in user config. The render path then asked main to inline that
file:// on every theme apply. Main only refused paths inside userData,
so a compromised renderer could swap the stored value to any png/jpg/
gif/webp/svg path outside userData and re-read it for as long as it
stayed in config.

Switch the picker to copy-to-cache, serve by opaque id:

- electron/image-cache.ts (new): main-owned directory at
  `userData/bg-images/<id>.<ext>`. importImage(absolutePath) validates
  the source (outside userData, allowed extension, 5 MB cap, non-empty,
  is-file), copies it in, and returns a `randomBytes(16)` hex id. The
  id is unguessable so a renderer cannot iterate the cache directory
  for files it didn't import. getImageDataUrl(id) reads the cached
  file and returns a `data:<mime>;base64,…` URL, or null.

- electron/local-file-sync.ts: two new IPCs, IMAGE_CACHE_IMPORT and
  IMAGE_CACHE_GET_DATA_URL, registered next to the existing image
  handlers.

- electronAPI.d.ts + preload.ts: imageCacheImport / imageCacheGetDataUrl
  bindings.

- formly-image-input.component: after showOpenDialog returns a path,
  call imageCacheImport(path) and store `image:<id>` in the form
  control. Surface the existing "couldn't read image" snack on
  rejection. Removes the toFileUrl round-trip and removes the
  absolute-path leak into user config.

- resolve-bg-image-to-data-url.util: handle the `image:<id>` prefix by
  calling imageCacheGetDataUrl. Legacy `file://` values are still
  accepted for back-compat (the existing userData-guarded
  READ_LOCAL_IMAGE_AS_DATA_URL handles them); since the picker no
  longer produces new file:// values, the long-tail drains as users
  re-pick. A future commit can remove the legacy code path.

Tests:
- electron/image-cache.test.cjs: 12 cases — happy path, in-userData
  rejection, bad extension, too-large / empty / missing source,
  non-string input, unknown / malformed id, cross-restart persistence,
  removeCachedImage.
- electron/local-file-sync.test.cjs: 3 new IPC-level cases for
  IMAGE_CACHE_IMPORT (round-trip, userData refusal) and
  IMAGE_CACHE_GET_DATA_URL (unknown id).
- formly-image-input.component.spec: rewritten to assert
  imageCacheImport is called and `image:<id>` is what lands in the
  form, plus a failure-path snack assertion.

67 of 67 electron security tests pass; 6 of 6 formly-image-input
karma tests pass; lint and tsc clean.
2026-06-09 23:28:14 +02:00
johannesjo
00e71b407f refactor(electron): simplify sync folder ownership + stop renderer writes
Post-review of Phase 2 (#8228). The reviewer flagged one real bug and
several pieces of premature scaffolding.

Real bug — finding #1: the sync form was still writing the sync folder
path back into the renderer credential store. Phase 2 made the IPCs
take a relative path and main own the canonical root, but the Formly
button at sync-form.const.ts:200 had `key: 'syncFolderPath'`, which
caused FormlyBtn.onClick to setValue(picker-return-string) on the
form model. That flowed into privateCfg.syncFolderPath on save. The
"renderer no longer holds the authoritative copy" claim was true in
the IPC contract but untrue at runtime. A compromised renderer could
keep mutating its own privateCfg.syncFolderPath; nothing on master
reads it after Phase 2, but any future code path that does would
re-open the hole. Fix:
- drop `syncFolderPath` from PROVIDER_FIELD_DEFAULTS[LocalFile] so it
  is not in the LocalFile credential-store defaults at all
- drop `key: 'syncFolderPath'` from the LocalFile picker button so the
  picker's return value is not bound to the form model

KISS simplifications (a/b/d from review):
- delete electron/sync-folder-store.ts and its test; inline the
  load/cache/persist helpers at the top of local-file-sync.ts as
  ~25 LOC. The dedicated module was carrying an init/get/set ceremony
  and a Promise singleton for a single string that is read at most a
  few times per second in the worst case. simple-store is the durable
  layer either way.
- drop the `_resolveOrThrow` typeof precheck; resolveSyncPath already
  type-checks `relativePath` and throws the same opaque error
- inline `resolveSyncPath(...).absolutePath` into each handler; the
  helper was the third layer doing the same thing

Other findings addressed:
- finding #2 (PICK_DIRECTORY silently undefined on persist failure):
  PICK_DIRECTORY now returns a safe Error via createSafeIpcError when
  realpath/persist fails, distinct from `undefined` for user-cancel.
  New tests cover both branches plus the "cache must not be poisoned
  with a non-existent path" invariant.
- finding #4 (set-without-canonicalize): the inlined
  setSyncFolderPath now `realpathSync.native`s the picked path before
  persisting, so a relative or symlinked picker result is rejected
  early and the on-disk form matches what resolveSyncPath compares
  against on read.
- finding #5 (fire-and-forget init that was also being awaited per
  handler): dropped the fire-and-forget; handlers just `await
  getSyncFolderPath()`, which lazy-loads from disk on first call and
  caches.

All 52 electron security tests still pass; lint clean; tsc clean.
2026-06-09 23:15:53 +02:00
johannesjo
0c21649fde feat(electron): own sync folder main-side and take only relative paths
Phase 2 of #8228. Closes the renderer-supplied-absolute-path hole: the
file-sync IPCs now take a relative path; main resolves it against a
sync folder it owns and never trusts the renderer's copy of.

Main side
- sync-folder-store: persists the path in simple-store (still inside
  userData, still renderer-unreachable via assertPathOutside) with an
  in-memory cache so per-IPC lookup doesn't stat the file every call.
  initSyncFolderStore() is fire-and-forget at adapter init; each handler
  awaits it defensively because it's idempotent via _loadOnce.
- FILE_SYNC_SAVE/LOAD/REMOVE: accept { relativePath, ... }, pass through
  resolveSyncPath, write/read/delete the vetted absolute path.
- CHECK_DIR_EXISTS and FILE_SYNC_LIST_FILES: accept an optional
  relativePath; default to the sync root itself so the existing
  "is the sync folder reachable?" check still works.
- PICK_DIRECTORY: now persists the chosen folder via setSyncFolderPath
  before returning the display string to the renderer. The renderer
  receiving the string is no longer load-bearing — it's display only.
- New GET_SYNC_FOLDER_PATH IPC returns the current value for display
  (settings form, "sync folder: …" labels).
- TO_FILE_URL: pulls in the assertPathOutside(userData) backstop that
  was missing on master, so a userData path cannot be laundered into a
  file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL or
  the navigation guard.

Package (@sp/sync-providers/local-file)
- LocalFileSyncElectron.isReady now asks main (getMainSyncFolderPath
  dep) instead of reading the renderer's privateCfg. This drives the
  migration UX: an existing install with a legacy privateCfg value but
  no main-side path lands on "not configured" and gets re-prompted by
  the picker. A one-cycle "please pick again" is the migration cost; we
  decided against silently promoting the renderer's persisted value
  because it could be tampered with.
- getFilePath returns the *relative* path (no absolute folder prefix).
  The leading-slash strip is preserved so existing call sites that
  prepend '/' still work.
- pickDirectory no longer writes to renderer privateCfg — main owns
  persistence. The dep contract still returns the display string.
- The privateCfg.syncFolderPath field is marked @deprecated; the
  field is kept for migration of older configs and may be removed in a
  later pass once the migration has rolled out.

Renderer
- ElectronFileAdapter forwards the relative path verbatim to the IPC.
- The renderer's LocalFileSyncElectron deps wire pickDirectory and the
  new getMainSyncFolderPath to the bridge.

Tests
- electron/local-file-sync.test.cjs rewritten for the new signatures:
  happy path, traversal escapes, absolute-relativePath rejection,
  sync-folder-equals-userData rejection (grant-file forgery defense),
  PICK persists main-side, GET returns the configured path, TO_FILE_URL
  refuses userData.
- electron/sync-folder-store.test.cjs covers persistence,
  init-before-get throw, null/empty handling, idempotent set.
- The package's local-file-sync-electron.spec.ts is updated for the new
  deps shape and asserts the package no longer writes to the renderer
  credential store.

All 56 electron-side tests pass; 374 sync-providers tests pass.
2026-06-09 22:40:50 +02:00
Johannes Millan
880cfdcaf7
fix(electron): lock in-window navigation to the app's loaded origin (#8230)
* fix(electron): lock in-window navigation to the app's loaded origin

The previous will-navigate handler accepted any URL whose hostname was
'localhost' or '127.0.0.1', which left the privileged main window
reachable by any local web server (e.g. http://127.0.0.1:1337/) — the
preload bridge (window.ea) would have been exposed to whatever page that
server returned. In production the legitimate origin is file:// only, so
there is no excuse for ever navigating to http://localhost in-window.

Compare against the URL the app actually loaded (captured at loadURL
time) instead of a hostname allowlist. http/https requires exact
host+port match; file:// requires the same html pathname; data:/blob:/
javascript: are rejected outright. The same guard is also applied to
will-redirect so a same-origin start cannot be redirected onto an
attacker page mid-navigation, and a defensive did-create-window handler
destroys any unexpected child window (the deny-all setWindowOpenHandler
should make that path unreachable).

Also tightens TO_FILE_URL with the same userData deny used by the other
file-sync IPCs, so a plugin/XSS cannot launder a userData path into a
file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL.

* fix(electron): reject UNC/remote-host file:// in navigation guard

`new URL('file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html').pathname`
equals the local app's pathname, so the previous pathname-only check
considered an attacker-controlled UNC host same-origin and let it load
in the privileged main window. The app's loaded file:// URL is always
local (empty host), so require `target.host === ''` explicitly.

* test(electron): add userinfo-@-trick navigation guard regression

`http://localhost:4200@evil.com/` parses with host=evil.com and
username=localhost — a naive substring or startsWith check would be
fooled. The existing host-equality check already rejects it; lock that
in with a test so a future refactor cannot regress.
2026-06-09 22:34:15 +02:00
johannesjo
c12982f1f9 feat(electron): add sync-path-resolver for renderer-supplied paths
Phase 1 of the fix for #8228, simplified after KISS review.

The earlier draft (commit 5cb83b41a, since reverted) introduced a
multi-grant capability store with opaque grantIds and a feature
discriminator. Review showed the abstraction was overhead: this app
has exactly one user-pickable filesystem location (the sync folder).
Backgrounds will go through copy-to-cache, not folder grants;
plugins do not get folder grants; backups use a main-derived path.
And the opaque grantId added zero marginal security over a featureless
{relativePath} API since an XSS in the renderer would just replay the
id from memory. The security boundary is "main owns the canonical root
and the resolver logic", not the id.

So: drop the store, keep the resolver. resolveSyncPath takes
(syncFolderPath, relativePath, userDataDir) and returns the vetted
absolute path. Layered defenses:

  - syncFolderPath canonicalized every call (folder may have moved
    since startup); missing/inaccessible root denies
  - canonical root must not be userData or live inside it (a user who
    picks userData as their sync folder would otherwise hand the
    renderer authority over settings/grants/db)
  - relativePath may be '' or '.' for LIST/CHECK ops on the root;
    anything else must not be absolute and must not escape
  - leaf symlinks refused (v1 policy; O_NOFOLLOW alternative is a
    follow-up that needs cross-platform care)
  - canonical resolved path (or deepest existing ancestor when the
    leaf doesn't exist yet) must still live inside the canonical
    root — catches symlinks/junctions in intermediate directories
    and case-fold / 8.3-shortname aliases
  - ancestor walk fails CLOSED on EACCES; only ENOENT lets us walk
    up. A permission-restricted ancestor must not silently allow.

Errors are opaque (PathNotAllowedError, no path in message) and the
stack is stripped — IPC handlers further sanitize before returning to
the renderer.

Phase 2 will wire this into FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES and
CHECK_DIR_EXISTS, move syncFolderPath ownership from the renderer's
privateCfg to a main-side simple-store key, and add a re-confirm
migration on first launch.

16 unit tests cover the rejection cases from #8228's test plan plus
the EACCES-fail-closed branches that came out of the KISS review.
2026-06-09 22:23:47 +02:00
johannesjo
7b61d34c6e Revert "feat(electron): add capability grant store for filesystem IPC"
This reverts commit 5cb83b41a0.
2026-06-09 22:21:24 +02:00
johannesjo
5cb83b41a0 feat(electron): add capability grant store for filesystem IPC
Lays the groundwork for issue #8228: today the FILE_SYNC_* IPCs accept
any renderer-supplied absolute path outside userData, which means any
plugin or XSS in the renderer can read/write arbitrary user files
(~/.ssh, /etc/passwd, etc.). The goal is to replace string-path
authorization with capability-based grants: the renderer holds only an
opaque grantId, the main process holds the canonical root, and FS ops
take (grantId, relativePath).

This commit adds the main-side primitives but does not wire them into
any IPC yet:

- grant-store: persisted, main-only, feature-scoped grants backed by
  simple-store under a new 'fileGrants' key. Opaque hex ids from
  crypto.randomBytes. Roots are canonicalized at create time, and
  revalidated at startup so a folder that was moved, deleted, or
  swapped for a symlink-elsewhere invalidates the grant cleanly.

- grant-resolver: resolveGrantPath(grantId, relativePath, feature)
  returns the absolute path the IPC handler may touch. Enforces the
  feature scope match, rejects absolute paths and '..' traversal, and
  refuses symlinks. For not-yet-existing leaves it canonicalizes the
  deepest existing ancestor so a directory symlink mid-path cannot
  sneak a write outside the root. Errors are opaque
  (PathNotAllowedError) and never embed the offending path.

Notes on tests: ts-node's transpile-only loader does not downlevel
'for...of map' iteration, so the store uses Array.from(map.entries())
where it iterates the cache. The existing electron *.test.cjs use the
same loader and the same workaround was already implicit in their
patterns.

Phase 2 (switching FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES + CHECK_DIR_EXISTS
to grant-based, with re-confirm migration of the existing syncFolderPath)
and Phase 3 (image picker copy-to-cache) follow in separate commits.
2026-06-09 22:13:54 +02:00
Johannes Millan
33111e46ff fix: harden local file URL guard 2026-06-09 19:32:09 +02:00
Johannes Millan
4bf699735a fix(electron): compare host exactly before in-window navigation
will-navigate gated on url.includes('localhost'), so hosts like https://localhost.evil.com (or a 'localhost' substring in the query) were treated as the app's own origin and navigated the main window directly, bypassing the openUrlInBrowser scheme guard. Parse the URL and match the hostname exactly (localhost/127.0.0.1).

No regression for prod: it serves from file:// and routes via hash (withHashLocation), so will-navigate never fires for the app's own origin; only real external links reach this handler.
2026-06-09 19:32:09 +02:00
Johannes Millan
b361f86a91 fix: close remaining no-click NTLM image-load sinks (GHSA-hr87-735w-hfq3)
A sweep for auto-loading subresource sinks fed attacker-controllable
(synced) content found two more no-click NTLM vectors beyond the
markdown/attachment images already gated:

- READ_LOCAL_IMAGE_AS_DATA_URL (electron/local-file-sync.ts): a synced
  theme backgroundImage 'file://host/share/x.png' reaches this main-process
  handler, where fileURLToPath -> fs.stat/readFile on the resulting UNC path
  opens an SMB connection and leaks the NTLM hash — no click, just the theme
  being active. This is the most reliable variant (main-process fs, not a
  Chromium subresource). Guard with isPathSafeToOpen before any fs access.

- note.imgUrl (note.component): a synced note image URL is bound directly
  to <img [src]>/[enlargeImg] and auto-loads on render. Gate at the setter
  so only a safe URL reaches the bindings.

Both reuse the existing isPathSafeToOpen helper; local file://, http(s),
data: and blob: are unaffected. New electron test proves stat/readFile are
never reached for UNC / remote file:// input.

(cherry picked from commit 80a3c20b3f5a3f535f5d04d7fd215ba569493ef8)
2026-06-09 19:07:15 +02:00
Johannes Millan
7f029b1798
fix(plugins): harden nodeExecution grants (#8205)
* fix(plugins): harden node execution grants

* fix(plugins): harden iframe bridge boundaries (#8208)

* fix(plugins): harden iframe bridge boundaries

* fix(plugins): tighten iframe bridge follow-up

* test(plugins): make node-executor electron test hermetic

The new plugin-node-executor test read the real built-in plugin manifest
(src/assets/bundled-plugins/sync-md/manifest.json), which is a build
artifact absent when 'npm run test:electron' runs in CI (before the
frontend/plugin build). Stub the manifest read, scoped to the executor
module via Module._load, so the grant/token/webContents assertions no
longer depend on built plugin assets.

* fix(plugins): allow iframe formatDate & getCurrentLanguage i18n methods

The iframe API allow-list gate added in #8208 only listed a subset of
the i18n methods that master's #8146 exposes to iframe plugins. Without
this, plugin calls to formatDate/getCurrentLanguage (and translate) are
rejected with 'Unknown API method'. Add all three so the merged gate
matches the methods createBoundMethods/createPluginApiScript expose.

* docs(plugins): document node-exec handoff bootstrap-ordering invariant

The one-shot consumePluginNodeExecutionApi() handoff is defended by
construction ordering, not structural isolation: PluginBridgeService must
consume it before any plugin 'new Function' code runs (both share window.ea
in one renderer realm). Document the invariant at the consumption site so a
future lazy-service/early-plugin-load refactor can't silently regress it.
Surfaced by the post-merge security review (latent finding; not currently
exploitable).

* fix(plugins): use static iframe sandbox attribute to avoid NG0910

Binding a security-sensitive iframe attribute (sandbox) via [attr.sandbox]
makes Angular throw RuntimeError NG0910 and tear down the iframe, crashing
the plugin view to the global error screen. This broke the plugin-iframe,
plugin-loading and plugin-lifecycle e2e tests.

Restore the static sandbox attribute (still without allow-same-origin, so
the opaque-origin isolation from #8208 is preserved) and drop the now-unused
iframeSandbox binding/import.
2026-06-09 18:16:41 +02:00
Johannes Millan
c86e369369
fix(security): restrict renderer-supplied filesystem IPC paths (#8223)
* fix(security): canonicalize file-path-guard and restrict FILE_SYNC_* to outside userData

file-path-guard now canonicalizes via fs.realpathSync.native (deepest existing
ancestor) before the path.relative check, so symlinks and case-insensitive / 8.3
filesystem aliases can't bypass containment — this also hardens the existing
backup-dir (GHSA-x937) check, which was purely lexical and bypassable on macOS.

Adds assertPathOutside and applies it to every FILE_SYNC_* handler and
READ_LOCAL_IMAGE_AS_DATA_URL in local-file-sync.ts, so the renderer (untrusted
plugin/XSS) can no longer read/write/delete/list/inline files inside the app's
private dir (userData: settings/grants/db) while user-chosen sync folders still
work.

* fix(security): validate clipboard image basePath, fileName and copy source

ipc-handler-wrapper now uses isPathInsideDir containment (not a bare startsWith,
which accepted a sibling like <userData>-evil) and rejects any fileName that is
not a plain image basename or imageId containing separators/.. — closing the
clipboard grant-file forgery (fileName='simpleSettings'). CLIPBOARD_COPY_IMAGE_FILE
now requires an image-extension source. The supported-image-extension allowlist
is consolidated into a single SUPPORTED_IMAGE_EXTENSIONS in mime-type-mapping.
2026-06-09 18:16:22 +02:00
Johannes Millan
cd86ba2c0d
fix(electron): validate URL scheme before shell.openExternal/openPath (GHSA-hr87-735w-hfq3) (#8210)
* 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>
2026-06-09 17:29:18 +02:00
Symon Baikov
e0be3a1a16
Feat/task widget global shortcut (#7099)
* 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>
2026-06-09 16:11:16 +02:00
Johannes Millan
b729fd941c
fix(electron): restrict loadBackupData IPC to the backup directory (#8206)
* test(e2e): retry supersync time-estimate dialog until input binds

The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.

* fix(electron): restrict loadBackupData IPC to the backup directory

The BACKUP_LOAD_DATA handler passed the renderer-supplied path straight
to readFileSync with no validation. Because plugin background scripts run
via `new Function` in the renderer, any installed plugin (or an XSS
payload in task content) could read arbitrary files through
window.ea.loadBackupData('/etc/shadow').

Constrain the path to BACKUP_DIR / BACKUP_DIR_WINSTORE via a new
isPathInsideDir guard. It uses path.relative (not a startsWith string
compare) so `..` traversal is collapsed and a name-prefixed sibling
directory (backups vs backups-evil) is not mistaken for a child. The only
legitimate caller already passes paths built from BACKUP_DIR, and
getBackupPath() is display-only, so backup restore is unaffected.

Adds electron/file-path-guard.test.cjs (node --test) covering traversal
escape, prefix-sibling, absolute-outside, and normalize-stays-inside.

Refs GHSA-x937-wf3j-88q3

* docs(electron): note lexical-only containment in file-path-guard

Clarify that isPathInsideDir does string-based containment (no fs.realpath),
so a symlink planted inside the dir is out of scope — it requires local
filesystem write access, outside this guard's renderer-input threat model.
Surfaced during multi-agent review of the GHSA-x937-wf3j-88q3 fix.

* test(electron): load guard via computed path for asar require check

The electron-smoke job runs tools/verify-electron-requires.js over the
packaged app.asar, which flags literal relative require() calls that can't
resolve in the package. The new guard test had require('./file-path-guard.ts'),
but .ts source is excluded from app.asar, so the check failed. Load the module
via a computed path (require(path.resolve(__dirname, ...))) — the same pattern
the other electron *.test.cjs files use; the scanner skips computed requires.
Also reworded the comment, which had reintroduced the literal pattern (the
scanner matches raw text, comments included).
2026-06-09 15:29:06 +02:00
felix bear
1c53656889
fix(jira): honor no_proxy for electron requests #6324 (#8177)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 15:00:43 +02:00
Johannes Millan
4a99e7e505
fix(security): stored XSS → RCE chain — note image, plugin nodeExecution, CSP (GHSA-78rv-m663-4fph) (#8178)
* fix(ui): prevent stored XSS in enlarge-img directive

The enlarged-image element was built by interpolating the image URL into an innerHTML string, so a crafted synced/imported note.imgUrl could break out of the src attribute and inject an event handler (stored DOM-XSS). Build the <img> with createElement and property assignment instead, so the URL is never parsed as HTML. Adds a regression spec.

Refs: GHSA-78rv-m663-4fph

* fix(plugins): authorize nodeExecution from main-process state

The Electron main process authorized Node execution from the manifest the renderer passes on each IPC call, and window.ea.pluginExecNodeScript is callable by any renderer code — so injected renderer JS could forge {permissions:['nodeExecution']} and run arbitrary Node (CWE-501).

The main process now keeps its own grantedPlugins set, populated via a dedicated PLUGIN_SET_NODE_CONSENT channel. The renderer registers a grant in _fireOnReady (before the first node call, covering every load path including zip upload) and revokes it on teardown. The executor requires set membership.

Defense in depth, not a hard boundary: the registration channel is itself renderer-reachable, so a fully-compromised renderer could register a grant itself. It blocks the disclosed PoC (forged manifest for a never-loaded plugin) and removes per-call manifest trust. A hard boundary needs a main-process consent dialog.

Refs: GHSA-78rv-m663-4fph

* fix(security): add object-src 'none' and document CSP constraints

Adds object-src 'none' (the app embeds no <object>/<embed>) and replaces the stale TODO with an accurate note on why 'unsafe-eval'/'unsafe-inline' cannot be dropped yet: the plugin runtime and the inline bootstrap script use new Function, and the packaged app loads the renderer over file:// (opaque origin), so tightening script-src to 'self' is unreliable.

Refs: GHSA-78rv-m663-4fph
2026-06-08 20:20:17 +02:00
felix bear
354e600ab8
fix(electron): avoid logging local file sync paths (#8127)
* fix(electron): avoid logging local file sync paths #7870

* fix(electron): return safe local sync ipc errors #7870

* chore(ci): rerun electron download checks for #8127

* chore(ci): rerun checks for #8127

---------

Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:46:13 +02:00
felix bear
00cd665e1a
feat(backup): configure automatic backup file limit #6690 (#8094)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:09:49 +02:00
felix bear
376e068412
fix(electron): respect tray title settings #7823 (#8097)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:07:09 +02:00
Stefan Hynst
18472e86b9
fix(macOS): gracefully quit app (#7733)
* fix(macOS): gracefully quit app

When macOS App is closed via menu item "Quit", make sure that IPC flow completes first.

- close main window instead of directly invoking `quitApp()`

* fix(macOS): use quit-intent flag

- do not touch "minimizeToTray" setting
- instead set "quitRequested" flag to indicate quit intent
- set timeout to cancel quit request, if app fails to respond within 5 seconds

* refactor(macOS): split quit-intent state and add timeout rationale

- Separate the dual-purpose timer handle into an explicit boolean +
  named timer to make the semantics obvious.
- Extract the 5s window as a named constant with a comment on why this
  particular trade-off (long enough for sync/finish-day, short enough
  to not block a subsequent tray-hide click after cancel).
- Fix the misleading "set flag" comment in the no-window branch
  (no flag is set there; none is needed).
- Note that the `closed` reset clears the pending timer so it doesn't
  keep the event loop alive.

Pure cleanup, no behavior change.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-27 17:13:43 +02:00
Harbinger
9910d30fca
feat(sync): add OneDrive sync provider with PKCE auth (#7523)
* feat(sync): integrate onedrive with pkce and operation log sync

* fix(sync): add oauth state validation and token refresh concurrency control

* fix(sync): refactor onedrive oauth cleanup and reduce download API calls

- Replace setInterval-based OAuth state pruning with on-demand
  _pruneExpiredOAuthStates() to avoid background timer overhead
- Optimize downloadFile to read ETag from content response headers
  first, falling back to a metadata request only when needed (reduces
  API calls from 2 to 1 in the common case)
- Narrow OneDrive class interface from SyncProviderServiceInterface to
  FileSyncProvider for stronger type guarantees
- Accept optional devPath constructor parameter for non-production
  folder namespacing
- Extract isOneDriveClientIdRequired to a helper function in
  sync-form.const.ts for readability
- Change default OneDrive sync folder name to 'Super Productivity'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 review feedback for OneDrive sync

Critical fixes:
- Detect invalid_grant in token refresh responses and clear credentials
- Reset _tokenRefreshInFlightPromise in clearAuthCredentials to prevent
  stale refresh results from overwriting cleared state
- Re-read config before persisting refreshed tokens to avoid race with
  concurrent clearAuthCredentials calls

Important fixes:
- Extract OAuth state management to shared oauth-state.util.ts to fix
  circular dependency between OneDrive provider and callback handler
- Add 401 retry pattern (_is401Retry) matching Dropbox's approach:
  on 401, proactively refresh token and retry, only clear credentials
  if retry fails
- Remove unused auth status translation keys (AUTH_SUCCESS,
  BTN_AUTHENTICATE, BTN_REAUTHENTICATE, REAUTH_SUCCESS,
  STATUS_CONFIGURED, STATUS_NOT_CONFIGURED) and form props
- Remove unused requireAuth parameter from _cfgOrError

Minor fixes:
- Replace path-exposing log messages with structured logging
- Remove unused _formatHttpErrorDetails helper
- Return empty ETag fallback in getFileRev instead of throwing
- Add folder cache invalidation comment for path changes
- Add afterEach to restore global fetch in tests
- Add PKCE defence-in-depth comment in auth code dialog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): use eslint-disable-next-line for @microsoft.graph.conflictBehavior

Replace spread+computed-property workaround with a plain property plus
an explicit eslint-disable-next-line comment. Clearer intent: the
naming violation is a Graph API requirement, not accidental.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): pre-save OneDrive form config in reauth() to prevent MissingCredentialsSPError

Re-auth was failing silently on Linux because getAuthHelper() reads
clientId from IndexedDB (privateCfg), but the form values were never
written before calling configuredAuthForSyncProviderIfNecessary().

- Extract shared BTN_REAUTHENTICATE and REAUTH_SUCCESS translation keys
- Add OneDrive to OAUTH_SYNC_PROVIDERS
- Pre-save form config in reauth() matching the existing save() flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): adapt TooManyRequestsAPIError to new constructor signature after rebase

* refactor(sync): migrate OneDrive provider to packages/sync-providers

Move OneDrive core logic into the shared sync-providers package,
matching the pattern used by Dropbox, WebDAV, and other providers.
The app-side code is now a thin adapter with a createOneDriveProvider()
factory function.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): re-throw MissingRefreshTokenAPIError from _requestOAuthToken catch block

The catch block in _requestOAuthToken was swallowing the
MissingRefreshTokenAPIError thrown after clearing credentials on
invalid_grant, causing it to fall through to a generic HttpNotOkAPIError
instead. Now re-throws MissingRefreshTokenAPIError explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 second review critical and important items

- Replace _is401Retry instance field with per-call parameter to prevent
  concurrent 401 races (Critical #1)
- Fix _requestOAuthToken catch block to re-throw MissingRefreshTokenAPIError
  instead of swallowing it (Critical #2)
- Fix _parseOAuthCallback defaulting unknown provider to 'unknown' instead
  of 'dropbox' to prevent misrouting (Critical #3 + Important #6)
- Change conflictBehavior from 'replace' to 'fail' to prevent data loss
  if a file exists with the same name as the folder (Critical #4)
- Await in-flight token refresh promise in clearAuthCredentials before
  clearing, to close the refresh-during-clear race (Important #5)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 review important and minor items

- Extract duplicate OneDrive pre-auth save block into
  _persistOneDriveFormCfgBeforeAuth() method (Important #7)
- Add GET probe in _ensureSyncFolderExists to skip per-segment
  creation when folder already exists (Important #9)
- Redact sensitive params (code, code_verifier, refresh_token,
  access_token) from token endpoint and Graph error bodies
  (Important #11)
- Remove targetPath from _mapAndThrow error messages to prevent
  logging user content (Important #12)
- Add OAuth state validation in manual paste flow for OneDrive
  (Important #13)
- Fix unknown provider routing in _parseOAuthCallback (Important #14)
- Guard Electron IPC listener against post-destroy emissions
  (Important #15)
- Remove dead authStatus Formly field from sync-form.const.ts (Minor)
- Simplify Headers builder in _ensureSyncFolderExists (Minor)
- Fix spec afterEach to restore original fetch instead of undefined
  (Minor)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): fix clearAuthCredentials race and add unit tests for OneDrive

Fix a race condition in clearAuthCredentials where _tokenRefreshInFlightPromise
was nulled AFTER awaiting, causing the invalid_grant handler's clearAuthCredentials
to wait on itself. Now captures the promise and nulls the field before awaiting.

Add 5 new unit tests covering critical code paths:
- 401 token refresh and retry
- 401 retry failure with credential clearing
- 400 invalid_grant credential clearing
- 412 precondition failed mapping
- Concurrent token refresh deduplication

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 third review items

- Simplify clearAuthCredentials: null _tokenRefreshInFlightPromise without
  awaiting to avoid deadlock when called from inside the refresh IIFE
- Add superproductivity:// scheme validation in Electron OAuth listener
- Fix invalid_grant test to assert MissingRefreshTokenAPIError
- Fix folder cache test to match GET-probe optimization
- Set setComplete default in beforeEach

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sync): fix OAuthCallbackHandler spec to expect 'unknown' provider

The _parseOAuthCallback now returns 'unknown' instead of 'dropbox' for
URLs without an explicit provider path segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 fourth review items

- Handle generic 401 from token endpoint (clear creds + MissingRefreshTokenAPIError)
- Remove user-supplied paths from error constructors (rule 9 compliance)
- Soften clearAuthCredentials comment to reflect actual TOCTOU guarantee
- Tighten Electron scheme gate to match native path prefix
- Add scheme info to Electron rejection log
- Fix folder-cache test to assert GET probe count
- Remove redundant per-test setComplete stub

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 fifth review items

- Scope invalid_grant/401 credential clearing to refresh_token grant only
  (bad auth code exchange no longer wipes existing credentials)
- Redact OAuth code/state from Electron protocol handler logs
- Add @sp/sync-providers/onedrive TS path alias to tsconfig files
- Default undefined sync config fields instead of overwriting with undefined
- Handle OneDrive in provider-switch branch (preserve encryptKey)
- Update sync-config test expectations to match default values

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 sixth review items

- Use If-None-Match: * for null-rev uploads to prevent concurrent overwrite
- Guard in-flight refresh against stale credentials (match refreshToken/clientId/tenantId)
- Gate OneDrive provider option behind Electron/Native (web CORS unsupported)
- Don't clear credentials on transient token endpoint errors (429/5xx)
- Preserve null syncProvider instead of defaulting to WebDAV
- Hydrate full OneDrive privateCfg on provider switch
- Remove duplicate syncInterval/isManualSyncOnly Formly controls
- Revert non-English locale changes (zh.json, zh-tw.json)
- Redact URL fragments (#) in Electron protocol handler logs
- listFiles rethrows non-404 errors instead of swallowing all
- Update 401 test expectations for new rethrow behavior

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): guard credential clearing against stale concurrent refresh

Prevent a stale in-flight refresh from wiping newer credentials after
a concurrent re-auth. The refresh IIFE now throws a plain Error (not
MissingRefreshTokenAPIError) when it detects config drift, and all
clearAuthCredentials() call sites now verify the stored config still
matches before clearing. Also fix listFiles() to handle 404 from
_requestJson (which throws HttpNotOkAPIError, not RemoteFileNotFoundAPIError).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 eighth review items

- Replace If-None-Match:* with @microsoft.graph.conflictBehavior=fail query param for upload create-only semantics
- Add identity change detection in _persistOneDriveFormCfgBeforeAuth to clear tokens when useCustomApp/clientId/tenantId change
- Extract _clearIfConfigMatches helper to guard credential clearing against stale concurrent refresh
- Restrict folder probe fallthrough to 404 only, propagate other errors
- Restore locale files from upstream master
- Centralize IS_ONEDRIVE_SUPPORTED in onedrive-auth-mode.const.ts, use in factory and form config

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): address PR #7523 ninth review items

- Paginate listFiles via @odata.nextLink to avoid silent data loss
- Fix logger misuse (log -> normal with proper string message)
- Replace as any with OneDrivePrivateCfg in dialog-sync-cfg
- Throw NoRevAPIError when uploadFile response lacks eTag
- Omit undefined optional booleans from global config to prevent overwrite
- Move OneDrive dynamic import inside IS_ONEDRIVE_SUPPORTED gate
- Require state param for OneDrive full-URL paste (CSRF)
- Add id_token to sensitive keys for body redaction

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sync): add OneDrive type alias to electron tsconfig paths

The @sp/sync-providers/onedrive path alias was missing from
electron/tsconfig.electron.json, causing the Electron build to fail
with "Cannot find module '@sp/sync-providers/onedrive'". The alias was
already present in tsconfig.base.json but electron uses its own paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:31:06 +02:00
Johannes Millan
c1d14d501c
Experiment right side action bar (#7594)
* feat(layout): experimental vertical action strip on right edge

Teleports main-header's action-nav-right to document.body on desktop so
it escapes any ancestor containing-block, then pins it as a vertical strip
at the viewport's right edge via fixed positioning. Reserves the column
with padding-right on .app-container so the right-panel ends to its left.

* feat(layout): right-panel spans full viewport height

Restructure app shell so right-panel is a direct flex child of
.app-container (wrapping main-content via ng-content), letting its
.side extend over the full height of the viewport — including the
header area — rather than starting below the header.

* style(layout): give vertical action strip an elevated surface bg

* style(layout): match vertical action strip styling to left side nav

* fix(layout): stack play/focus buttons vertically in action strip

* style(layout): use solid surface bg for vertical action strip

* fix(layout): keep current-task title visible beside the action strip

In the teleported vertical action strip the current-task title pill was
positioned right:100% of the play button and clipped by the strip's
overflow, and the 48px mini-fab overflowed the 48px rail and got
side-clipped.

Render the title as a position:fixed flyout to the left of the strip
(the strip has no transform/filter so it is not a containing block for
fixed descendants), aligned to the play button across the web /
mac-titlebar / obsidian-header / RTL variants, and drop the play button
wrapper's horizontal margin so it fits the rail.

* fix(layout): stop tooltip overlay from blocking action strip clicks

A tooltip shown 'below' a button in the vertical action strip lands
directly over the next button. The tooltip's cdk-overlay-pane wrapper
(unlike the inner mat-tooltip-component) had no pointer-events:none, so
it intercepted clicks for real users and for Playwright actionability —
breaking ~20 focus-mode/break e2e tests.

* test(focus-mode): use teleport-robust focus-button locator

The action nav is teleported out of <main-header> into a body-level
strip, so 'main-header focus-button button' no longer matches. Drop the
main-header ancestor; 'focus-button button' is unique either way.

* fix(layout): polish vertical action strip (flyout, spacing, bg)

- Current-task title is now a hover-reveal flyout: hidden until the play
  button (or the flyout itself) is hovered. Mobile (< 1080px) unchanged
  (component still display:none's it there).
- Drop the position:fixed + magic top calc; keep the component's own
  absolute + translateY(-50%) so the flyout is pixel-perfectly centred
  on the play button regardless of theme/Material density.
- Strip now overflow: visible so the flyout can extend past the 48px
  rail (overflow-x can't be visible while overflow-y is auto).
- Re-declare --header-nav-button-gap on the strip: it is scoped to
  <main-header>'s :host but the strip is teleported to <body>, so the
  var was undefined => every strip button had gap: 0 (cramped).
- Extra margin between the play button and the focus button.
- Strip background now matches the left sidenav (--sidenav-bg).

* fix(layout): uniform vertical spacing between strip buttons

Drop the extra play-button and counters-group bottom margins; one
--header-nav-button-gap (var(--s)) now drives the spacing between every
button in the strip. Measured: all consecutive gaps = 8px.

* feat(layout): make vertical action bar a configurable opt-in

Adds misc.isVerticalActionBar (default off). The right-edge vertical
action strip was previously an unconditional experiment; it is now an
opt-in toggle in Settings > Misc that switches the layout live without
reload:

- app.component: @if branches the DOM between classic (horizontal
  header) and vertical (right-panel hoisted to full viewport height);
  .app-container gets .has-vertical-action-bar to gate CSS.
- main-header: one-shot ngAfterViewInit teleport replaced with an
  effect() that teleports/restores the action nav reactively to the
  config flag and the desktop/mobile breakpoint.
- app/right-panel SCSS: strip padding and full-height right-panel are
  now scoped to .has-vertical-action-bar; classic layout restored as
  the default.

* fix(layout): even vertical-strip spacing for panel-button wrappers

plugin-header-btns, plugin-side-panel-btns, desktop-panel-buttons and
user-profile-button are zero-height wrapper custom-elements. As direct
flex children of the vertical action strip the column gap landed on the
collapsed wrapper instead of the button(s) inside, so their icons were
unevenly spaced next to real buttons like the add button. Make each
wrapper a centered column flex item that stacks its button(s) with the
same gap, and hide truly-empty wrappers so they don't reserve a phantom
gap slot. Verified: all 7 visible strip buttons now 8px apart.

* fix(right-panel): clip transient content overflow during slide animation

.side animates width 0<->* while .side-inner keeps its min-width, so
content briefly spills past the (intentionally overflow:visible) .side
during open/close. Add an isPanelAnimating host class driven by the
@slideRightPanel start/done callbacks and clip :host for that window
only (overflow: clip — no scroll container). Mirrors the existing
resizing/windowResizing transient-state pattern. Verified: class +
clip present only during the ~200ms animation, visible when idle.

* refactor(layout): drop dual DOM for vertical action bar

Single classic layout for both modes; strip is teleported and offset by
--bar-height so the horizontal header keeps owning the title-bar zone
(native drag region + WCO + Mac traffic lights). Drops the 40px
title-bar-collision padding and the right-panel full-height override.

* feat(layout): right-panel side spans full viewport height

Move the header into right-panel's projected .content slot and make
right-panel host full-height. The panel column now starts at viewport
top with the header band only spanning the .content width, similar to
Obsidian / Linear / VS Code.

* fix(layout): only offset vertical strip below WCO band on Win/Linux

Default top inset is now 0 (web, Mac hiddenInset, native-frame Electron
all let the strip start at viewport top — none of them have a window-
control overlay clashing with the right edge). Push the strip down by
--bar-height only on Win/Linux Electron with custom title bar, where
the WCO buttons would otherwise sit on top of the strip's first row.

* style(electron): use compact 32px WCO band on Win/Linux

Shrinks the Windows Controls Overlay height from 44px to 32px so the
native min/max/close buttons sit in a slimmer band — matches VS Code /
Edge slim title bar conventions and frees more of the header zone for
app content. Width stays OS-controlled (~138px); only height is
configurable.

* style(layout): tighten WCO band to 24px and pull strip up to match

Drops WCO_HEIGHT to 24px on Win/Linux and introduces a --wco-height CSS
var mirroring it. The vertical action strip now clears the WCO band by
exactly --wco-height instead of --bar-height (48px), reclaiming the
newly-freed top zone on the right edge.

* style(layout): add breathing room above strip below WCO band

Strip top inset moves from --wco-height to --wco-height + --s so the
first action button isn't flush against the bottom of the window
controls overlay on Win/Linux.

* fix(styles): scope vertical-action-bar tooltip rule to the experiment

The pane-level `pointer-events: none` was needed only because tooltips
in the teleported vertical strip can land over the next button. Apply
it via a new `body.isVerticalActionBar` body class (toggled from
`misc.isVerticalActionBar` in GlobalThemeService, alongside the existing
`isObsidianStyleHeader` effect) so unrelated app tooltips keep their
default pane behaviour.

* refactor(styles): extract vertical-action-bar CSS to its own partial

Move the 130-line experimental strip block out of `src/styles.scss` and
into `src/styles/components/_vertical-action-bar.scss`. Wired via
`_components.scss`. No CSS rules change — pure code organisation.

* merge(master): resolve _components.scss conflict in right action bar branch

Agent-Logs-Url: https://github.com/super-productivity/super-productivity/sessions/1941dd45-72e1-4a3e-92da-912fcfcb9bed

Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
2026-05-23 16:11:37 +02:00
Johannes Millan
f32c56b933 fix(settings): raise background image size caps
The previous 200 KB limit silently rejected legitimately-sized
wallpapers, leaving users with a blank background and no UI feedback
(only a terminal error). Raise the limits per ingestion path:

- Electron file:// picker: 5 MB. Only the path is persisted; image
  bytes are read locally on demand and never sync.
- Browser/Android file input: 256 KB. Bytes embed as a data URL and
  sync via op-log, so the cap stays tight to protect SuperSync
  snapshot size against per-entity bloat.

Closes #7716
2026-05-21 22:56:03 +02:00
Stefan Hynst
450e0b840c
fix(macOS): quit app on system shutdown (#7704)
* fix(maxOS): quit app on shutdown

- closing all windows alone will not quit the app on macOS
- add call to quitApp() for macOS
- this will fix the issue with the app interrupting macOS shutdown
- works now, too: Dock > Super Productivity > Quit

* fix(macOS): unset "minimize to tray" to actually close window on quit

- if "minimize to tray" is on, main window is always hidden instead of closed
- this prevents app from being quit

* Update electron/start-app.ts

---------

Co-authored-by: Johannes Millan <johannesjo@users.noreply.github.com>
2026-05-21 12:25:52 +02:00
Johannes Millan
79133f9046 feat(electron): suffix version string per distribution target
Append a short channel marker to the (display-only) version string so bug
reports and the config footer reveal which build a user runs, e.g.:

  win nsis W   portable P   MS Store MS
  mac dmg  D   App Store MAS
  linux AppImage AI   snap SN   flatpak FP   deb/rpm L
  android Play A   F-Droid AF   ios I   web WB

Desktop channels are detected in the Electron process (process.platform,
process.mas/windowsStore, APPIMAGE/SNAP/FLATPAK_ID) via a shared helper
reused by the tray-GUID logic and exposed sync over the preload bridge
(no IPC, getAppVersionStr stays synchronous). Mobile/web are detected in
the frontend. deb vs rpm is not distinguishable at runtime, so both
report L. No caller semver-compares this string.
2026-05-19 16:42:45 +02:00
Miklos
6f5e174b54
fix(electron): use template tray icons on macOS (#7609)
The macOS tray icon was loaded as a static black or white PNG chosen
once at startup from `nativeTheme.shouldUseDarkColors`, so it stayed
black on a dark menu bar (or white on a light one) after the appearance
changed.

Use the `-l` (black-on-transparent) icons on macOS and mark them as
template images. macOS then auto-inverts them for the current menu bar
appearance, including the highlight state when the menu is open and
accessibility inverted-colors. Linux and Windows behavior is unchanged;
`--force-dark-tray` is now a no-op on macOS because template images make
manual forcing unnecessary.
2026-05-14 22:59:09 +02:00
Benjamin
07ec51b1a5
feat(plugins): add onReady() API with IPC ping + fix consent write delay (#7578)
* feat(plugins): add onReady() API with IPC ping + fix consent write delay #7326

- Remove setTimeout(5000) from _getNodeExecutionConsent; write consent immediately
- Add plugin.onReady(fn) to PluginAPI — fires after plugin.js evaluation and IPC bridge confirmation
- Add _pingNodeBridge() in plugin.service.ts with 3-attempt retry (1s, 2s delays)
- Add triggerReady() and pingNodeBridge() to PluginRunner
- Show snack + set error state if IPC bridge unavailable after retries
- Add NODE_EXECUTION_BRIDGE_UNAVAILABLE translation key
- Add focused tests for onReady, triggerReady, pingNodeBridge, consent persistence
- Update plugin-development.md with onReady usage and nodeExecution guidance

* test(plugins): fix unused variable lint errors in spec files

* fix(plugins): guard triggerReady on instance.loaded; fix doc numbering

* fix(plugins): remove _triggerReady from public API, route ping via bridge, add retry tests

* fix(electron): add paths for @sp/sync-providers subpath exports (node moduleResolution compat)

* fix(plugins): centralize onReady, tear down runtime on activation error, add iframe onReady

Address review on #7578:
- All plugin load paths (startup, upload, reload, lazy) now go through _fireOnReady,
  ensuring the IPC ping + onReady fire on every successful load — not just lazy.
- activatePlugin error path now unloads the plugin runtime (hooks, buttons, side
  effects) before setting status='error', preventing partially-running plugins.
- Iframe PluginAPI now exposes onReady (fires on next microtask after plugin.js
  evaluates), matching the host-side contract for typed iframe plugins.

* fix(plugins): clean up half-loaded plugins on onReady error, test real retry util

Self-review followups:
- _fireOnReadyWithCleanup wraps the 3 non-activatePlugin load paths and tears
  down the plugin (unloadPlugin + remove from list + status='error' + snack)
  if the IPC ping or onReady callback throws. Previously, those paths only
  logged and rethrew, leaving partially-running plugins.
- Extracted retry loop into pure pingWithRetry utility; spec now exercises
  production code instead of an inline-replicated stub. Removed the old
  plugin-ping-node-bridge.spec.ts which was just testing its own copy of
  the logic.
- Documented iframe onReady semantic (fires on microtask, no ping) in both
  the source comment and docs/plugin-development.md, since cold-boot is not
  a concern for iframe plugins (rendered on demand).

* ci(plugins): use npm i for root install to tolerate override drift

The root lockfile pins app-builder-lib's transitive minimatch via the
`overrides` field. npm 10.9.7 (bundled with Node 22 in setup-node@v6)
flags this as drift and fails `npm ci`, while npm 11 accepts it.
ci.yml's main test job uses `npm i`, which tolerates the drift without
mutating the lockfile on disk.

Plugin-Tests has been red on every PR since 2026-05-08 for this reason.
The inner `npm ci` for plugin-specific deps stays strict.

* fix(plugins): make onReady optional, assert callback isolation in spec

- packages/plugin-api/src/types.ts: mark onReady? optional on the public
  PluginAPI interface so existing plugin TypeScript typings (and any
  third-party PluginAPI implementations) remain assignable after upgrade.
  The host runtime already treats onReady as optional (no-op if no
  registration callback is provided), so this aligns the type with the
  actual contract.

- src/app/plugins/plugin-runner.spec.ts: the previous isolation test only
  asserted that triggerReady() resolved for both plugins; it would still
  pass if triggerReady fired every registered callback. The updated test
  wires per-plugin Jasmine spies through globalThis (the same context the
  plugin code's `new Function` runs in) and asserts call counts before
  and after each triggerReady, actually proving isolation.

* refactor(plugins): test real consent logic; scope startup snacks; tighten ping timeout

Address review feedback on PR #7578:

- Extract consent decision into pure `decideNodeExecutionConsent` util so the
  spec exercises real code instead of a reimplemented stub. Delete the
  stub-based plugin-consent.spec.ts and plugin-fire-on-ready.spec.ts (the
  latter was orchestration glue already covered by plugin-runner.spec.ts and
  ping-with-retry.util.spec.ts).
- Reduce per-ping timeout 5000ms -> 1500ms. Worst-case cold-boot bridge-down
  detection drops from ~17s to ~7.5s; in-process vm script returning true
  doesn't need 5s.
- Add PLUGIN_LOAD_FAILED translation wrapping plugin name + error. Strip the
  now-redundant pluginName from NODE_EXECUTION_BRIDGE_UNAVAILABLE.
- Scope activation-failure snack to manual activations only — startup
  auto-activation failures stay silent (plugin tile shows error state).
  _handleReadyFailure still snacks unconditionally since onReady failure
  leaves a partially-loaded runtime that the user needs to see.

---------

Co-authored-by: Benjamin <1159333+benjaminburzan@users.noreply.github.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-14 13:41:01 +02:00
Johannes Millan
087b9dd43f
refactor(sync): post-extraction review cleanup of @sp/sync-core and @sp/sync-providers (#7595)
* refactor(sync): tighten extracted package surfaces

Combined polish from the post-extraction review:

- sync-core: strip NgRx-shaped types from EntityConfig/EntityRegistry;
  expose host extensions via generic param. Move StateSelector,
  PropsStateSelector, SelectByIdFactory, SelectById, EntityUpdateLike,
  EntityAdapterLike to a new app-side entity-registry-host.types.ts.
- sync-core: mark OpType.SyncImport/BackupImport/Repair as @deprecated;
  hosts should use createFullStateOpTypeHelpers().
- sync-providers: resolve provider.types.ts vs provider-types.ts
  duplication; inline implementation into the dashed canonical name.
- sync-providers: drop unused root barrel and "." export; consumers
  already use focused subpath barrels (/dropbox, /webdav, etc.).
- sync-providers: replace wildcard "@sp/sync-providers/*" tsconfig path
  alias with 11 explicit subpath entries matching package.json exports;
  deep-internal imports now fail at typecheck.
- sync-providers: move @sp/sync-core from dependencies to
  peerDependencies (kept in devDependencies for tests).
- both packages: add composite: true to enable project references;
  introduce tsconfig.build.json overlay so tsup DTS bundler still works.
- gitignore: ignore **/*.tsbuildinfo composite outputs.

* refactor(sync-core): prune 47 unused barrel exports

Removes exports with zero consumers outside the package. Source files
are unchanged; only the public barrel is trimmed. Covers compression
helper classes, sync-file-prefix error/config types, replay coordinator
internals, remote-apply result types, upload/download planning option
and plan types, ports misc, conflict-resolution helper types, and
sync-import-filter decision types.

* refactor(sync-core): drop unused encryption migration path

decryptWithMigration and DecryptResult had no host consumer; they
exposed a structural-migration entry point ("here is your ciphertext
re-encrypted under Argon2id") that nothing in the codebase reads. The
side-channel setLegacyKdfWarningHandler — which IS used — stays.

encryptWithDerivedKey/decryptWithDerivedKey lose their export keyword
and remain as module-internal helpers; encrypt/decrypt/encryptBatch/
decryptBatch still call them. Wire format and legacy-fallback semantics
are unchanged, so existing ciphertext continues to decrypt.

Test imports for compression and sync-file-prefix specs now go via
their source files instead of the trimmed barrel.

* fix(sync-providers): bound dropbox token refresh to single retry; share md5 rev helper

The five hand-rolled token-refresh blocks in Dropbox.{getFileRev,
downloadFile, uploadFile, removeFile, listFiles} recursed on themselves
after refresh. If the post-refresh call still saw a token error (real
case: the refresh token itself was revoked), the recursion would not
terminate. Consolidated into a single _withTokenRefresh helper that
attempts the call, refreshes once on a token error, retries once, then
lets the outer 401 classifier surface AuthFailSPError.

Same log message, same _isTokenError discriminator, same refresh call.
Same five sites still apply their post-call non-token error mapping
(NoRev, InvalidData, RemoteFileNotFound, path-not-found swallow, etc.).

Also extracts md5 content-rev computation duplicated between
LocalFileSyncBase._getLocalRev and WebdavApi._computeContentHash into a
shared file-based/content-rev.ts; both call sites preserve their own
error wrapping at the boundary.

* refactor(sync): split oversized super-sync and conflict-resolution

sync-providers: extract request-ID hashing from super-sync.ts (1017 ->
918 lines) into a new request-id.ts. The helpers were free functions
already in disguise (none referenced this), so the move is mechanical.
HTTP plumbing (_doWebFetch/_doNativeFetch/_fetchApi*) stays as private
methods — it transitively touches 12 instance members and would need
either a wide context object or a separate http-client collaborator
class to extract cleanly. Left as a follow-up.

sync-core: split conflict-resolution.ts into three cohesive files:
- entity-frontier.ts now owns buildEntityFrontier and
  adjustForClockCorruption (per-entity vector-clock domain).
- extractEntityFromPayload and extractUpdateChanges move to
  operation.types.ts next to the existing extractActionPayload.
- conflict-resolution.ts keeps deep-equality, LWW planning,
  partitioning, and identical-conflict detection.

Public barrel exports unchanged; tests now import the moved symbols
from their new homes.

* refactor(sync-core): drop redundant OperationStorePort

OperationStorePort overlapped with RemoteOperationApplyStorePort on the
two state-transition methods (markSynced/markApplied,
markRejected/markFailed) and had zero non-structural consumers — the
only implementer was OperationLogStoreService, which already exposes
the three methods as its own public surface. Removing the port leaves
the service contract intact and removes the verb-pair confusion noted
in the post-extraction review.

Spec contract test still drives the same state transitions; only the
local typing of the test fixture changes from the deleted interface to
Pick<OperationLogStoreService, ...>.

* refactor(sync-providers): decouple SuperSync provider from SP-specific host

Two coupling leaks the package shouldn't carry:

1. SUPER_SYNC_DEFAULT_BASE_URL was an implicit fallback inside
   SuperSyncProvider — an SP-specific URL baked into a "framework-
   agnostic" package. Make defaultBaseUrl a required SuperSyncDeps
   field; the host factory supplies the SP default. The constant stays
   exported as a suggested default for hosts targeting the SP-hosted
   server.

2. Consumers that wanted the WebSocket path had to do
   `provider as unknown as SuperSyncProvider` to call
   getWebSocketParams. Introduce SuperSyncWebSocketAccess interface +
   isSuperSyncWebSocketAccess structural guard; SuperSyncProvider
   implements it. sync-wrapper.service drops its cast in favor of the
   guard.

super-sync-restore.service still casts to SuperSyncProvider for the
restore path — same pattern would solve it, but out of scope here.

* test(sync-providers): extract shared test helpers and prefer barrels

Adds tests/helpers/sync-logger.ts and tests/helpers/credential-store.ts
to centralize the noopLogger and CredentialStore mocks that were copy-
pasted across 8 spec files. createStatefulCredentialStore covers the
"load/upsert/clear with state" cases; createMockCredentialStore covers
bare vi.fn() ports. Spec sites that needed a unique mockResolvedValue
chain it after the helper, preserving behavior 1:1.

Also migrates 5 spec files from deep ../src/<file> paths to the
matching sub-barrel (../src/webdav, /http, /super-sync, /platform) for
symbols already exported there. No new barrel exports added — internal
types (WebDavHttpAdapter, WebdavApi, DropboxApi, etc.) stay on deep
paths because they are intentionally not part of the public surface.

super-sync.spec.ts keeps its own credential/logger mocks (special
__asPort wrapper and vi.spyOn against the live NOOP_SYNC_LOGGER) that
the generic helpers cannot reproduce without bloat.

* test(sync): pin vector-clock pruning, error-meta privacy, and sync-import edges

Fills three test gaps surfaced by the post-extraction review:

- vector-clock pruning correctness across clocks: 4 cases pinning that
  pruning legitimately flips GREATER_THAN to CONCURRENT/LESS_THAN when
  the dropped keys are still present in the comparison clock. This is
  the documented behavior (compareVectorClocks is intentionally not
  pruning-aware); the protocol handles flips server-side via the
  rejected-ops retry loop. preserveClientIds case also covered.

- error-meta privacy boundary: 22 new cases covering urlPathOnly (strip
  query/fragment/userinfo, preserve host+path+port, leave non-URLs
  intact) and errorMeta (no leakage of headers, response bodies, OAuth
  tokens, signed-URL params, user emails, or attached error fields).
  Real negative assertions (.not.toContain), not shape checks.

- sync-import-filter edge cases: 8 cases covering empty clocks on
  either side, op clock listing the import client at 0, same-client
  with equal counter (pinning the strict-greater-than boundary), and
  different-client knowledge above the import counter.

sync-core 195 -> 207 tests, sync-providers 319 -> 341 tests; no
production code changed.

* style(sync-core): format sync-file-prefix.spec import line

* fix(sync): address package review feedback
2026-05-14 13:06:08 +02:00
Johannes Millan
001e9847b4 build(electron): scope tsc path aliases to package dist types
tsc -p electron/tsconfig.electron.json was resolving @sp/* aliases to
packages/*/src/*.ts, then transitively compiling those sources and
emitting .js next to each .ts (no outDir is set). Every electron-bearing
task (electron:build, npm start, build, dist) re-littered
packages/{sync-core,sync-providers,shared-schema}/src with stale .js
shadows; gitignore hid them from git but they still shadowed the .ts
sources for Vitest, silently breaking module mocks.

Point the aliases at dist/index.d.ts (and dist/* for subpaths) so tsc
consumes declarations only and never touches package source. Runtime
resolution is unaffected (Node uses the package.json exports).
2026-05-13 22:01:20 +02:00