Commit graph

21264 commits

Author SHA1 Message Date
Johannes Millan
305f89ab32
feat(plainspace): poll for new tasks in the background by default (#8674)
* feat(plainspace): poll for new tasks in the background by default

Default Plainspace providers to pollingMode 'always' so tasks assigned to
me auto-import into the bound project's backlog without navigating to that
project. Covers both creation paths (share auto-provision and the generic
add-provider dialog) via DEFAULT_PLAINSPACE_CFG.

Keep background ('always'-mode) polls silent: skip the per-poll 'Polling
backlog…' spinner snack and only notify when a task is actually imported,
so a 5-min background poll no longer flashes UI every tick.

Affects new connections only; existing providers keep their stored
pollingMode.

* fix(plainspace): deterministic task id to avoid cross-device import dupes

Multi-review (sync-correctness) caught a CRITICAL surfaced by defaulting
Plainspace to background 'always' polling: imported tasks used a random
nanoid() id and dedup is purely local, so two open clients polling within a
sync round-trip would each create their own task for the same issue and the
op-log would keep both. Every other built-in provider defaults to
whenProjectOpen, so Plainspace is the first to hit this at scale.

Give Plainspace imports a deterministic natural-key id
(ps_<providerId>_<issueId>), mirroring the existing calendar
generateCalendarTaskId pattern, so concurrent addTask ops converge on one
entity id instead of duplicating. Also scope the config comment to what is
actually suppressed (the backlog-poll spinner; the update-poll progress bar
still ticks).
2026-07-01 19:17:15 +02:00
Johannes Millan
63253f8e0c
fix(sync): never transmit plaintext operations for E2EE-mandatory providers (#8670)
* fix(sync): never upload plaintext ops for E2EE-mandatory providers

SuperSync's first-time setup ran an initial sync (dialog-sync-cfg save ->
sync(true)) BEFORE the user chose an encryption password, so all local ops
(incl. issue-provider credentials) were uploaded to the server in cleartext.
Completing setup deleted them; aborting left them stored indefinitely, breaking
the E2EE promise (GHSA-9v8x-68pf-p5x7).

Add an optional `isEncryptionMandatory` capability to OperationSyncCapable
(true for SuperSync) and refuse to upload in the op-log upload path while no
usable key is configured. Downloads still run (merge-first) and the encryption-
enable flow performs the first, encrypted upload, so the setup flow and prompt
are unchanged. File-based providers, where unencrypted sync is a legitimate
user choice, leave the flag unset.

* fix(sync): fail closed on plaintext snapshot for E2EE-mandatory providers

Multi-review hardening for GHSA-9v8x-68pf-p5x7. The op-upload guard closes the
reported leak, but SnapshotUploadService.deleteAndReuploadWithNewEncryption could
still push a plaintext snapshot for SuperSync on two adjacent paths: the (today
UI-unreachable) disable-encryption flow, and a keyless import declaring
isEncryptionEnabled:true. Reject an unencrypted snapshot for an encryption-
mandatory provider before any destructive deleteAllData, so the "never transmit
plaintext" invariant holds regardless of caller.

Also lower the mandatory-encryption upload-skip log from warn to normal: it is an
expected by-design skip during the pre-encryption setup window and would
otherwise fire on every auto-sync cycle.

* fix(sync): consolidate op-log into the encryption-enable snapshot

Follow-up to the GHSA-9v8x-68pf-p5x7 upload guard. With the guard, first-time
SuperSync setup leaves the whole local history unsynced until encryption is
enabled; the enable-snapshot then represents that full state, but the ops were
re-uploaded incrementally on top of it on the next sync (redundant server op-log
bloat, and previously untested at whole-history volume).

deleteAndReuploadWithNewEncryption now captures the pending ops the snapshot
subsumes (before the destructive delete, under runWithSyncBlocked + a modal
dialog so the set is stable) and marks them synced after a successful upload —
mirroring planRegularOpsAfterFullStateUpload in the op-log upload path, which
this direct snapshot upload bypasses. Also fixes the same latent redundancy in
the enable-from-settings-with-pending-ops flow.

Adds a multi-client e2e: local task history exists before first-time encrypted
setup, then a second client with the same password receives exactly those tasks
with no duplicates, conflicts, or errors.

* ci(e2e): add optional grep input to manual SuperSync e2e dispatch

* fix(sync): capture subsumed ops before state snapshot to prevent mark-synced data loss

deleteAndReuploadWithNewEncryption captured getUnsynced() AFTER the full-state
snapshot, so an op created in that window was marked synced yet absent from the
snapshot — silently lost. Capture the unsynced set first: every marked-synced op
is then guaranteed present in the snapshot, and a concurrent op arriving after
the capture is left unsynced and re-uploaded next sync (idempotent by op id).

* test(sync): mock WebCrypto so mandatory-encryption guard test reaches the guard

The 'enabling without a usable key' case passed isEncryptionEnabled: true but did
not mock WebCrypto, so the availability check threw WebCryptoNotAvailableError in
non-secure CI before the /unencrypted snapshot/ guard under test.
2026-07-01 17:56:39 +02:00
Johannes Millan
ad7564bad2
fix(electron): block executable launch via shell.openPath (#8668)
* 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.
2026-07-01 17:56:24 +02:00
Johannes Millan
97e97042cd
fix(electron): remove exec IPC to close GHSA-256q (#8669)
* 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.
2026-07-01 17:55:35 +02:00
Johannes Millan
c7d6131db8
feat(plainspace): Collaborate-on-Plainspace discovery + smoother connect (#8649)
* feat(plainspace): add Collaborate action to project context menu

Surface Plainspace sharing from the project context menu (active, non-Inbox,
not-yet-shared projects) so it can be discovered at the moment of collaboration
intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace
selector hides the action once a project is shared to avoid provisioning a
duplicate space on a repeat click.

* docs(plainspace): document collaboration in wiki

Add Plainspace to the issue-integration comparison (matrix + per-provider
section) and a how-to for sharing a project via the project menu, including the
network/privacy caveat.

* feat(plainspace): place Collaborate action below the share-list item

Group the two share/export actions and lift Collaborate higher for
discoverability. Gated to active, non-inbox, not-already-shared projects.

* fix(plainspace): smoother first-run connect and value-first dialog

Pre-check connectivity and revalidate a stored token before the space
picker, so a stale/foreign token routes to the connect dialog and an
offline state shows a calm message instead of the raw 'check your token'
picker error. Make the connect dialog value-first: lead with what you
get, drop the 4-step how-to and email hint in favor of one short pointer
(token-creation guidance moves to plainspace.org).

* docs(plans): dedicated from-Super-Productivity flow on plainspace.org

Open plan for a guided token/connect flow on plainspace.org when a user
arrives from SP (Model A manual token, Model B OAuth-style handoff).

* refactor(plainspace): drop redundant connect pre-check (multi-review)

The space picker already detects a stale token and offers a reconnect
(#8616), so the revalidate() pre-check duplicated that path, cost an
extra GET /me, and forced re-auth on a valid token during a transient
server blip. Keep only the one-line offline guard; drop revalidate(),
the discriminated union, and the unnecessary _isOnline() seam (navigator
.onLine is spyable in the runner). Fix stale doc comments and document
the disabled-provider trade-off in the selector.

* feat(plainspace): show brand icon in the connect dialog title

* feat(plainspace): deep-link connect dialog to the from-SP onboarding flow

Point the dialog's 'Open Plainspace' link at the dedicated
/connect/super-productivity entrypoint (which guides token creation)
instead of the bare marketing host, closing the connect-flow funnel leak.

* feat(plainspace): bounce back to the app after connecting (desktop)

Append a validated `?return=superproductivity://plainspace-connect` deep
link to the connect URL, gated on IS_ELECTRON (only desktop registers the
scheme — web/mobile would get dead buttons). Handle that action in the
Electron protocol handler by surfacing the window, so the connect page's
"Open Super Productivity" button re-focuses the app.

Desktop-only; needs an on-device check.
2026-07-01 16:39:34 +02:00
Daniel Nylander
6b76cb0f0e
i18n: update Swedish translations (#8652)
Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
2026-07-01 14:55:15 +02:00
Johannes Millan
4dab744be1
chore(deps): ngrx + capacitor updates, dependabot grouping, safe tooling refresh (#8664)
* chore(deps): bump @ngrx/* to 21.1.1 and @capacitor/* to latest 8.x

- @ngrx/{store,effects,entity,store-devtools,schematics} 21.1.0 -> 21.1.1
  (moved as a set; the family pins each other's peers exactly)
- @capacitor/{core,cli,android,ios} -> 8.4.1, @capacitor/keyboard 8.0.1 -> 8.0.5

Angular left at 21.2.x: it is already the latest stable 21.x, and Angular 22
is blocked by @ngrx (no Angular-22-compatible release exists yet).

* ci(dependabot): group coupled npm families into single PRs

npm updates had no groups, so lockstep families (ngrx pins its own peers
exactly, Angular/Capacitor move by major together) arrived as separate
single-package PRs that can never resolve npm ci alone. Group @ngrx,
@angular, @angular-eslint, @capacitor, and typescript-eslint so each
family updates as one coherent PR.

* chore(deps): refresh safe in-range tooling + tier-2 bumps

Tier 2 (package.json):
- @material-symbols/font-400 ^0.44.10 -> ^0.45.5 (icon font, 0.x minor)
- eslint-plugin-jsdoc 62.9.0 -> 63.0.10 (its only breaking change is
  'drop Node 20'; we run 22; plugin is not wired into eslint.config.js
  so there is no lint-rule impact)

In-range leaf refresh (lockfile only; already permitted by caret ranges):
- @playwright/test + playwright 1.60.0 -> 1.61.1
- jasmine-core 6.2.0 -> 6.3.0
- nanoid 5.1.11 -> 5.1.16, fs-extra 11.3.5 -> 11.3.6
- baseline-browser-mapping 2.10.32 -> 2.10.40
- eslint-plugin-prettier 5.5.5 -> 5.5.6

Angular 21.2.x patches, electron-builder, @typescript-eslint and
prettier/stylelint are intentionally left for 'npm update' in a real
terminal: their build-tooling dep trees can't be regenerated cleanly
here (the sandbox prunes cross-platform binaries).

* style(habit-tracker): remove empty .header-spacer rule

Dead empty block left over from 394e554bd0 (compact-view refactor);
triggered stylelint block-no-empty. The .header-spacer div stays in the
template — it's positioned by the parent grid and needs no own styles.
2026-07-01 14:54:55 +02:00
Voronchikhin Ivan
89e08670bf
fix(i18n): translate schedule placeholder labels (#8653) 2026-07-01 13:48:54 +02:00
dependabot[bot]
cb94528b65
chore(deps): bump marked from 17.0.6 to 18.0.5 (#8660)
Bumps [marked](https://github.com/markedjs/marked) from 17.0.6 to 18.0.5.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v17.0.6...v18.0.5)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.5
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 12:32:02 +02:00
dependabot[bot]
45786ec91a
chore(deps): bump @electric-sql/pglite from 0.5.2 to 0.5.3 (#8654)
Bumps [@electric-sql/pglite](https://github.com/electric-sql/pglite/tree/HEAD/packages/pglite) from 0.5.2 to 0.5.3.
- [Release notes](https://github.com/electric-sql/pglite/releases)
- [Changelog](https://github.com/electric-sql/pglite/blob/main/packages/pglite/CHANGELOG.md)
- [Commits](https://github.com/electric-sql/pglite/commits/@electric-sql/pglite@0.5.3/packages/pglite)

---
updated-dependencies:
- dependency-name: "@electric-sql/pglite"
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 12:31:42 +02:00
dependabot[bot]
1e543bfbe7
chore(deps): bump @capacitor/core from 8.3.4 to 8.4.1 (#8662)
Bumps [@capacitor/core](https://github.com/ionic-team/capacitor) from 8.3.4 to 8.4.1.
- [Release notes](https://github.com/ionic-team/capacitor/releases)
- [Changelog](https://github.com/ionic-team/capacitor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ionic-team/capacitor/compare/8.3.4...8.4.1)

---
updated-dependencies:
- dependency-name: "@capacitor/core"
  dependency-version: 8.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 12:31:08 +02:00
Johannes Millan
14a56f861e
feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645)
* 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.
2026-06-30 17:22:30 +02:00
Johannes Millan
44030bb06e
fix(op-log): backfill SIMPLE_COUNTER fields on LWW recreate (#7330) (#8646)
Issue #7330 ("Data damage detected ... Repair attempted but failed")
recurred on SIMPLE_COUNTER for users already on >= v18.6.0, where the
original TASK-only fix didn't reach.

Root cause is the same partial-LWW-recreate path: a concurrent
delete-vs-update across devices resurrects a counter (LWW resolves
local-delete + remote-update to 'remote'), and lwwUpdateMetaReducer
recreated it from the {id}-only delete payload. Because SIMPLE_COUNTER
had no RECREATE_FALLBACK entry, the recreated counter was missing
required fields - most often `type`, an enum typia rejects and that
dataRepair/autoFixTypiaErrors had no rule for - so post-sync validation
dead-ended on the repair dialog every sync.

Fix mirrors the TASK fix, two layers kept in lockstep by requiredKeys:
- Register SIMPLE_COUNTER in RECREATE_FALLBACK (defaults from
  EMPTY_SIMPLE_COUNTER, type=ClickCounter) so the meta-reducer recreate
  path backfills required fields and the bad state is never created.
- Add a simpleCounter.<id>.<field>===undefined branch to
  autoFixTypiaErrors to heal copies already corrupt on disk.

Tests: auto-fix + meta-reducer unit specs (incl. a real-typia
appDataValidators.simpleCounter proof), a full validate->repair->validate
integration repro, and a deterministic SuperSync delete-vs-update e2e
asserting no native repair dialog fires.
2026-06-30 15:40:25 +02:00
Johannes Millan
a9df055bc1 chore(deps): resolve Dependabot security alerts (build/dev tooling)
Bump the Angular build toolchain to ^21.2.17 (which itself pins the
patched esbuild 0.28.1, vite 7.3.5, @babel/core 7.29.7, piscina 5.2.0,
webpack-dev-server 5.2.5) and add scoped npm overrides for the
transitive deps Angular still pins to vulnerable versions:

- uuid 8.3.2 -> 11.1.1            (sockjs; CVE-2026-41907)
- minimatch 3.1.2 -> 3.1.5        (@electron/asar; CVE-2026-27903)
- @babel/core 7.29.0 -> 7.29.7    (compiler-cli pins exact; CVE-2026-49356)
- http-proxy-middleware 3.0.5 -> 3.0.7  (build-angular; CVE-2026-55603/2)
- http-proxy-middleware 2.0.9 -> 2.0.10 (webpack-dev-server; GHSA-64mm-vxmg-q3vj)
- undici 7.24.4 -> 7.28.0         (@angular/build)
- undici 6.25.0 -> 6.27.0         (node-gyp; GHSA-p88m/vxpw/35p6/g8m3)
- ws 8.20.1 -> 8.21.0             (karma socket.io chain; GHSA-96hv-2xvq-fx4p)

Remove the now-redundant webpack-dev-server: 5.2.4 override (Angular
21.2.17 pins the patched 5.2.5 natively).

esbuild keeps a residual top-level 0.27.3 (GHSA-g7r4-m6w7-qqqr, low):
vite 7.3.5 and tsup 8.5.1 both cap esbuild at ^0.27.0 and only 0.28.1
is patched. The production Angular build already runs 0.28.1; the
residual feeds dev/build tooling only and the dev-server vuln is not
reachable here.
2026-06-30 14:29:39 +02:00
Johannes Millan
d65aba183c fix(right-panel): render task detail panel on immediate to avoid blank panel
The task-detail-panel was lazily rendered via @defer (prefetch on idle).
Under load the on-idle main trigger can be starved, leaving the right-panel
shell open but its content empty — intermittently failing E2E tests that
open the detail panel (e.g. add-subtask-with-detail-panel-open and
planner-add-subtask-from-detail timing out on the panel becoming visible).

Switch the trigger to 'on immediate' so the panel renders as soon as the
block is created. The component stays code-split and idle-prefetched, so
the initial-bundle win is preserved (verified: eager bundle size and lazy
chunk count unchanged before/after).
2026-06-30 14:26:13 +02:00
Johannes Millan
ad756c8bb2
docs(plugins): correct misleading secret-purge orphan comment (#8642)
The uninstall purge comment claimed orphaned credentials are cleared
'until a later purge', but no reconcile exists: once the plugin is gone
from the registry, removeSecretsForPlugin/clearOAuthTokens are never
re-triggered for it. On an IndexedDB failure the secret/token orphans on
disk until the same plugin id is reinstalled and removed again. Reword
the comment to state this honestly.

Reported by @sbomsdorf in review of #8633.
2026-06-30 14:11:32 +02:00
oon arfiandwi
5ea0570f39
feat(plugins): generic OAuth hooks for issue-provider plugins (#8546)
* feat(plugins): enable OAuth-based issue-provider plugins

Generic, provider-agnostic plugin-framework hooks so an issue-provider plugin
that needs an exact OAuth redirect can work without any built-in code:

- OAuthFlowConfig.redirectUri: a plugin may declare an exact pre-registered
  callback; the host uses it for both the authorize request and token exchange.
- The Electron loopback honors a plugin-requested fixed port and rejects with a
  clear message when that port is already in use.
- Apply user-supplied clientId/clientSecret/redirectUri overrides onto a
  plugin's oauthConfig (bring-your-own OAuth app).
- Fix: merging a partial pluginConfig update no longer drops omitted keys and
  deep-merges nested objects (e.g. twoWaySync).

Split out of the Basecamp community-plugin work per PR #8507 feedback; contains
no provider-specific code.

* fix(plugins): address #8546 review

- validate OAuthFlowConfig.redirectUri per platform (loopback / same-origin /
  app scheme) and fail fast instead of hanging; restrict desktop loopback to 127.0.0.1
- warn when a client secret is dropped on web/native (bring-your-own credentials)
- validate the IPC loopback port to [1024,65535], register the error handler before
  listen(), and close the failed server
- merge pluginConfig via generic recursion instead of a hardcoded twoWaySync case
- nits: named token-store imports; fix stale prepareRedirectUri comment
- tests: redirectUri validation, the web client-secret warning, and generic merge

* fix(plugins): address #8546 round-2 review

- loopback error handler calls cleanupServer() so a post-listen runtime error
  doesn't leave the server ref / 5-min timer dangling
- share OAUTH_LOOPBACK_PORT_{MIN,MAX} between the renderer and Electron main so
  the bounds never drift; reject out-of-range (incl. 0/80/443) redirect ports early
- drop _getElectronLoopbackPort and parse the already-validated redirectUri once
- document that a bring-your-own clientSecret syncs via pluginConfig (override boundary)
- skip __proto__/constructor/prototype keys in the pluginConfig merge (defense-in-depth)
- test: prototype-pollution guard

* fix(plugins): address #8546 round-3 review

- reject native redirectUri overrides outright (closes CodeQL
  js/incomplete-url-scheme-check) via a pure, per-platform validateOAuthRedirectUri
  util (electron loopback / native reject / web same-origin)
- gate bring-your-own OAuth credentials to the desktop loopback flow and warn
  (instead of silently dropping clientId) when set on web/native
- namespace BYO under pluginConfig.oauthOverrides (was flat keys); document the
  convention on OAuthFlowConfig (public plugin API)
- shallow top-level pluginConfig merge: drop the deep recursion + proto guard;
  nested objects (e.g. twoWaySync) are replaced wholesale, matching callers
- pin the web redirect to /assets/oauth-callback.html via a shared constant so a
  same-origin wrong-path URI fails fast; note the desktop 127.0.0.1-only rule
- companion tests for each

* fix(plugins): strip desktop redirectUri on web/native OAuth flows

A plugin-declared redirectUri is the desktop loopback override; keeping it on
the web/native branches made a web/native-capable plugin throw at connect time
(the loopback URI fails web/native redirectUri validation). Strip it on those
branches so prepareRedirectUri falls through to the platform default. Document
redirectUri as desktop-only in the plugin API and fix a misleading test name.

* refactor(plugins): extract resolveEffectiveOAuthConfig and harden native fallthrough

Move the platform client/secret/redirectUri selection out of the bridge into a
pure, parameterized util so every branch is unit-testable (the IS_* platform
consts are module-level and cannot be mocked in karma). Also strip clientSecret
and redirectUri on the native fall-through — a native platform where the plugin
ships no matching client id — keeping both strictly desktop-only.

Optional hardening on top of the redirectUri fix; safe to drop independently.
2026-06-30 14:11:00 +02:00
Voronchikhin Ivan
c5fa02561e
fix(electron): use display name for Linux notifications (#8640) 2026-06-30 14:01:01 +02:00
John Costa
1a13bef5e7
feat(focus-mode): loop the break-end sound until the break is dismissed (#8608)
Adds an opt-in, default-off setting that keeps the focus-mode break-end sound
looping until the break is dismissed, instead of firing once. Useful when you
step away from your desk and want a persistent cue that the break is over.

Scope and decisions (per the discussion on #8593):
- One toggle only. Reuses the existing break-end sound (positive.mp3) and the
  global sound volume. No new sound picker or per-feature volume.
- Stored per-device in localStorage, not the synced config, mirroring
  TaskWidgetSettingsService. Looping audio behaves differently across platforms
  (desktop keeps the AudioContext running while the window is unfocused; mobile
  suspends it on app-background, #8243), so a single synced value would behave
  differently per device. The setting is labelled as local to the device.
- Limited to the focus-mode break timer reaching zero (detectBreakTimeUp$).
- A single selector-based effect owns the loop lifecycle (mirrors
  whiteNoiseSound$), so the loop starts once and stops on any leave-break
  transition. A hard 10-minute ceiling stops it regardless if the user truly
  walked away. The audio primitive stops any previous source before starting
  and uses a monotonic start-token, so a restart can never leak a second loop.

Closes #8593
2026-06-30 13:52:19 +02:00
Johannes Millan
ba2f77804b
feat(tasks): reassign tags when dragging between tag groups (#8638)
* refactor(boards): extract doesTaskMatchPanel membership predicate

Pull the board-panel column-membership filter out of the component's inline
.filter() into a pure, exported doesTaskMatchPanel() in boards.util, the
companion to rewriteTagIdsForPanel (the two encode the same tag rules). The
component now delegates to it, hoisting the backlog predicate so it's allocated
once per recompute rather than once per task. The predicate is a required
argument since backlog membership derives from project state.

* feat(tasks): reassign tags when dragging between tag groups

In the grouped-by-tag work view, dropping a task into a different tag group now
reassigns its tags instead of only reordering:
- onto another tag group → move (drop the source group's tag, add the target's);
- onto the "No tag" bucket → clear all of the task's tags;
- onto the "Unknown tag" / ambiguous (duplicate-title) buckets, or reordering
  within a group → unchanged (falls through to the existing reorder).

The customizer emits a group-title -> tagId map (the NO_TAG_GROUP_ID sentinel
for the No-tag bucket, null for un-retaggable buckets), derived from a single
per-title metadata source shared with the group-ordering map so the two can't
drift. task-list intercepts a cross-group drop and dispatches updateTags.

* test(tasks): de-flake planner add-subtask-from-detail e2e

The detail panel's deferred open-time _focusFirst() (~delay(50) + a 150ms
guarded timeout) can land after the inline add-subtask draft opens, steal
focus from it, and trip its blur-to-close — so the draft input renders then
vanishes and '.e2e-add-subtask-input' is never seen (a load-dependent flake,
~1/30 under contention). Wait for the panel's open-time auto-focus to settle
before driving the draft, mirroring add-subtask-with-detail-panel-open.spec.ts.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-30 12:59:35 +02:00
Johannes Millan
a1c059855e
feat(sync): nudge long-time users without sync to set it up (#8637)
* feat(sync): nudge long-time users without sync to set it up

Offline-first means a user who never configures sync has no backup at
all; clearing browser data or losing the device wipes everything. Once
the app has clearly been used for a while AND holds real data, show a
calm, low-priority banner once encouraging sync setup.

Trigger combines wall-clock age (>= 7 days since a lazily-seeded
FIRST_USE_TIMESTAMP) with a task-count gate (>= 20 tasks), so it is
robust both to users who restart many times a day and to those who
leave the app running for weeks (where app-start count fails), and
never nags an empty/dormant install. Shown at most once: both "Set up
sync" and "Not now" persist a dismissed flag; a configured provider
suppresses it entirely.

Reuses the existing banner system and mirrors NoteStartupBannerService.

* refactor(sync): apply review feedback to data-safety nudge

- Rename LS.FIRST_USE_TIMESTAMP -> SYNC_SAFETY_FIRST_SEEN and document that
  it is not a true install date (seeded at upgrade time for existing
  installs), so no other feature reuses it as one.
- Tests: add an unhydrated-config (undefined) skip case and assert the
  task-count selector, guarding the earlier race fix and a selector swap.
- i18n: match each locale's existing register for the nudge message
  (pt/cs/sk/tr -> formal, id -> informal) per translation review.

* test(planner): stabilize add-subtask-from-detail focus race

Wait for the detail panel's deferred open-time auto-focus to settle before
opening the inline subtask draft. The auto-focus lands ~200ms after the panel
opens and, if it fires after the draft input is focused, steals focus and
blur-closes the input — making toBeFocused() flake with 'element(s) not found'.
Mirrors the guard already used in add-subtask-with-detail-panel-open.spec.ts.
2026-06-29 19:45:42 +02:00
Johannes Millan
06a7425698
feat(focus-mode): make preparation opt-in, smooth start transition (#8639)
* feat(focus-mode): make preparation opt-in, smooth start transition

The full-screen preparation countdown is now opt-in (off by default) via a
new isShowPreparation config flag; the deprecated isSkipPreparation is kept
for synced-config back-compat. By default, starting a session now plays a
brief inline rocket launch from the play button, then begins.

Smooth the prep->running swap: the clock/controls cross-fade sequentially
(old fades out, then new fades in) via a new fadeSwap animation.

Fix the focus task-selector panel that rendered transparent (undefined
--c-bg-raised) -> opaque highest-elevation surface on the standard scrim.

* fix(focus-mode): guard re-entrant start, reduced-motion, clock cross-fade

- Ignore a re-entrant startSession() while the inline launch is playing
  (keyboard Enter / double-click on the still-focused FAB) and disable the
  play button during launch, so a second timer can't reset the new session.
- Skip the inline rocket launch + its 800ms delay under prefers-reduced-motion
  and start immediately (no invisible dead delay for motion-sensitive users).
- Cross-fade the clock digits with the duration slider (fade out before fade
  in) instead of a hard visibility toggle.
- Fix stale e2e launch-duration comment (~600ms -> ~800ms).
2026-06-29 19:44:53 +02:00
Johannes Millan
2d7fcb1659
fix(tasks): render notes markdown on open instead of flashing raw text (#8636)
* fix(tasks): render notes markdown on first paint instead of flashing plain text

The inline-markdown model setter deferred the rendered preview behind an
async resolveMarkdownImages() call, which is async even when there is
nothing to resolve. This briefly showed the raw notes as plain text before
the parsed markdown appeared when opening the task detail panel.

Render synchronously when the notes contain no clipboard-image URLs (the
common case) and only keep the deferred path when there are images to
resolve to blob: URLs, so we still avoid flashing a broken image.

The hasResolvableImages gate uses a coarse substring check (a safe superset
of what resolveMarkdownImages rewrites) rather than duplicating the
resolver's URL regex: it can't drift out of sync, and it avoids that
regex's O(n^2) backtracking on adversarial notes.

* fix(tasks): stop checklist notes flashing raw markdown on panel open

Opening a task's notes via its checklist progress badge routes through
TaskDetailTargetPanel.Notes, whose handler both set isFocusNotes=true (which
opened the inline-markdown textarea showing the raw '- [ ] ' source) and
called focusItem() on the notes wrapper. focusItem (150ms) won the focus race
and blurred the editor back to preview, leaving a ~150ms flash of raw
markdown before the rendered checklist reappeared. Visible for checklists;
invisible for plain notes where raw and rendered text look alike.

Drop the spurious isFocusNotes=true on auto-open: preview was always the
settled end state, and explicit edits still work via click/Enter.

* fix(tasks): stop late panel auto-focus from closing add-subtask draft

The detail panel's on-open auto-focus runs behind delay(50) + 150ms timers
(_focusFirst / focusItem via _scheduleTaskGuardedFocus). Under load those can
fire *after* the user already opened the inline "add subtask" draft. Focusing
a panel item then blurs the draft input, whose blur handler closes the draft —
so the input vanishes and "Add subtask" silently does nothing.

This surfaced as the flaky e2e "Planner: add subtask from detail panel"
(#8617/#8630): the input was visible, then went "element not found" while
waiting for toBeFocused, because the late auto-focus blurred and closed it.

Guard the single deferred-focus choke point so it never steals focus from an
open draft. Task-change already resets isAddSubtaskInputVisible before
re-focusing, so legitimate panel-open auto-focus is unaffected.
2026-06-29 19:05:06 +02:00
Johannes Millan
b7f94d519c
fix(focus-mode): show notes as single view, not parsed + unparsed at once (#8634)
* fix(focus-mode): show notes as single view, not parsed + unparsed at once

The focus-mode notes panel bound [isFocus] to its open/close signal, so
opening it force-entered edit mode and always showed the raw textarea and
the dimmed live preview together.

Open the panel in read (rendered) mode instead; tapping enters edit mode.
Add an opt-in isHidePreviewWhileEditing input to inline-markdown so the
compact panel shows a plain textarea while editing (no live preview). The
task detail panel keeps its live-preview-while-editing behavior.

* fix(tasks): reliably focus the add-subtask draft input on open

The detail panel focused the just-opened draft via a post-render
setTimeout. On a slow CI runner that macrotask can fire before this
view's change detection commits the inputEl viewChild, so focus()
no-ops and never retries — the draft opens unfocused, then is torn
down. Have the input own its initial focus via afterNextRender, which
is tied to the render lifecycle. Fixes the flaky planner add-subtask
e2e and the underlying slow-device UX fragility (#8617).
2026-06-29 18:01:57 +02:00
Johannes Millan
ceefb5000c
feat(plugins): add local-only secret storage API for plugins (#8633)
* feat(plugins): add local-only secret storage API for plugins

Add setSecret/getSecret/deleteSecret to the plugin API, backed by a
dedicated 'sup-plugin-secrets' IndexedDB that is never part of Super
Productivity's sync, exports, or backups (mirrors the existing OAuth
token store). Secrets are namespaced per plugin and purged on uninstall.

Unblocks credential-using plugins (e.g. IMAP mailbox -> task) that must
not put passwords in persistDataSynced or synced issue-provider config.

Also purge plugin OAuth tokens on uninstall (best-effort, alongside the
secret purge) — they previously leaked past uninstall.

Refs #7511

* fix(plugins): purge plugin secrets and OAuth tokens on cache clear

clearUploadedPluginsFromMemory (the 'Clear plugin cache' action) wiped
plugin code and persisted nodeExecution consent (#8512 Phase 2) but left
secrets and OAuth tokens in their dedicated stores. A same-id re-upload
after a cache clear has no existingState, so the re-upload purge never
fires and the new plugin could inherit the previous plugin's credentials
— the same id-reuse gap #8512 closed for consent. Purge both here too,
best-effort and idempotent, mirroring the per-plugin uninstall purge.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-29 17:14:36 +02:00
Johannes Millan
7182ff4fba
feat(plugins): persist nodeExecution consent per plugin (#8512) (#8600)
* feat(plugins): persist nodeExecution consent per plugin (#8512)

Phase 2 of #8512: remember an uploaded plugin's nodeExecution consent so
it is asked once, not every app session, while keeping the trust decision
local to the device.

- Main-owned, local-only store (electron/plugin-node-consent-store.ts,
  wrapping simple-store under key 'pluginNodeExecutionConsent'); never
  pfapi-synced, so a grant on one device never auto-grants on another.
- Ask-once is scoped to UPLOADED plugins; built-in plugins (sync-md) keep
  the per-session verified prompt unchanged (regression-safe).
- Consent is written only after a native Allow in main; the renderer has a
  delete-only clearConsent IPC (fail-safe) and no way to self-grant.
- Cleared on disable / uninstall / re-upload (never in generic teardown),
  so revoke = the existing disable toggle and changed code re-consents.
- No code hash: re-ask-on-change is structural (re-upload clears consent);
  a renderer hash would be forgeable and security-worthless. version:1 is
  the migration anchor if main-owned hashing is ever added.

Tests: electron 154 pass (consent-store + executor ask-once/deny/clear/
built-in-never-persisted); 474 plugin specs pass incl. the sync-exclusion
guard. Docs updated.

* fix(plugins): harden persisted consent against prototype-pollution ids

Multi-review (security) found a CRITICAL in the Phase 2 consent store: the
consents map was a plain object keyed on an attacker-controlled pluginId, so
an uploaded plugin with id 'constructor' / 'toString' / 'valueOf' /
'hasOwnProperty' resolved consents[id] to the inherited Object.prototype
member (a truthy function). The executor's ask-once check treated that as a
prior grant and minted a nodeExecution token with NO consent dialog on a fresh
install — full code execution with zero user approval. ('__proto__' was already
blocked by the id allowlist; these names pass it.) Unit tests missed it because
the executor test stub used a Map, which is immune to the footgun.

Fix:
- Store: null-prototype consents map (Object.create(null)) + own-property
  (hasOwnProperty) guarded reads + reject non-object entries. Closes the class
  for any prototype-member id, including across a disk round-trip.
- Executor: reject __proto__/prototype/constructor in assertSafePluginId as
  defense-in-depth at the boundary.
- Regression tests: consent store returns null for prototype-member ids (fresh,
  after a real set, after clear); grant request for these ids is rejected with
  no dialog and no mint.

Also from review: ask-once path now re-checks the sender URL after the consent
read (parity with the dialog path); clarified why the consent mutation queue is
not redundant with simple-store's save queue.

Electron suite 156/156 pass.

* refactor(plugins): log consent persist-failure via electron-log

Multi-review follow-ups (non-blocking):
- Route the best-effort consent persist-failure to electron-log/main (the
  user-exportable host log) instead of console; console in the executor is
  otherwise the sandboxed plugin's own output. Only the validated id is logged.
- Clarify the disable-path comment: clearing revokes the live session grant
  always, and the persisted consent only for uploaded plugins (built-ins have
  none).

* fix(plugins): clear persisted consent on cache-clear and disclose persistence in dialog

Two gaps found in multi-agent review of the Phase 2 persisted-consent feature:

- clearUploadedPluginsFromMemory() (the 'Clear plugin cache' button) wiped the
  plugin code from IndexedDB but left the main-owned persisted nodeExecution
  consent behind. A later re-upload of the same id has no existingState, so the
  re-upload consent-clear in loadPluginFromZip never fired and the (possibly
  different) code was silently granted node execution with no prompt — defeating
  the 'replacing code under an id always re-asks' invariant. Now clears consent
  for every evicted uploaded id, mirroring removeUploadedPlugin.

- The uploaded-plugin native consent dialog still said access was valid 'for this
  app session', but Allow is now persisted across sessions. The prompt now
  discloses that the choice is remembered on the device until disable / remove /
  re-upload, so the user consents to the actual scope.

Regression tests added on both sides.

* refactor(plugins): key persisted consent on a Map, not a null-prototype object

Multi-review simplification. The consent store keyed an attacker-controlled
pluginId into a plain object, defended against `Object.prototype` member names
(constructor/toString/…) with a null-prototype object + hasOwn guards + a
typeof-object read check. A `Map` makes that safety structural and self-evident —
an unstored key is simply `undefined` — and matches the sibling `grants` Map in
the executor. The on-disk format is unchanged (a plain {version, consents} object);
the Map is serialized via Object.fromEntries (define-semantics, no prototype write)
and rebuilt via Object.entries with the well-formedness guard moved to load time.

Also corrects the stale 'never downgrade-corrupt it' comment with the accurate
downgrade behavior, and adds a round-trip test proving a hand-edited on-disk
__proto__ data key loads inertly without polluting Object.prototype.

* refactor(plugins): funnel disable through PluginService.disablePlugin and de-dup dialog display

Two more multi-review items, now that we own the PR:

- 'Disabling a node plugin revokes its consent' previously lived only in the
  plugin-management UI handler, so a future programmatic disable path could unload
  the plugin yet leave persisted consent behind — re-enabling would then silently
  re-grant node execution. Added PluginService.disablePlugin(setEnabled=false +
  unload + clearNodeExecutionConsent) and routed the UI through it, making the
  revoke a structural invariant. The consent clear is a safe no-op for non-node
  plugins, so the previous requiresNodeExecution gate is dropped.

- The uploaded-plugin name/version were sanitized once for the dialog and again for
  persistence (same lengths/fallbacks, duplicated). Extracted sanitizedUploadedDisplay
  as the single source of truth so the persisted record always matches what the user
  saw in the prompt.

Tests added for the disablePlugin invariant.

* fix(plugins): harden persisted nodeExecution consent (multi-review)

Follow-ups from a multi-agent review of #8600:

- Re-ask structurally on every upload: clear consent unconditionally in
  loadPluginFromZip (outside the `existingState` branch) so a same-id
  re-upload always re-prompts even if consent was orphaned (crash
  mid-uninstall, IndexedDB eviction, external/partial wipe).
- Fail closed on upload: clearNodeExecutionConsent reports a persist failure
  via its return value; loadPluginFromZip aborts the upload if the prior
  consent could not be revoked, so replacement code can't inherit a stale
  grant. Lifecycle edges (disable/uninstall/cache-clear) ignore the result so
  a rare disk failure can't abort their bookkeeping.
- Mint the grant before the best-effort consent persist in the executor so a
  navigation/destroy during the write drops it via cleanup and a persist
  failure can't lose an approved grant.
- Validate the full consent record shape on load so a corrupt {}/array entry
  can't read as a grant.
- Log only the validated id + error code on persist failure (no userData path).
- Fix a stale comment (the store keys consent in a Map, not null-proto objects).

Adds regression tests: mint-before-persist ordering, best-effort persist,
malformed-entry rejection, and the consent-clear fail-closed return contract.

* test(plugins): add clearNodeExecutionConsent to PluginBridgeService spy

loadPluginFromZip now clears prior persisted nodeExecution consent
before loading replacement code (#8512 Phase 2). The spy in this spec
lacked the method, so the call threw, was caught, returned false, and
aborted the upload, failing both load-from-zip tests.
2026-06-29 16:12:00 +02:00
Johannes Millan
3ae9d968a4
fix(tasks): make "Add subtask" work in the Planner detail panel (#8617) (#8630)
* fix(tasks): make detail-panel add-subtask work in the Planner (#8617)

The detail panel and context menu delegated "Add subtask" to
AddSubtaskInputService.requestOpen(), a signal consumed only by the
<task> row that renders the parent. The Planner renders tasks as
<planner-task>, so the request was dropped and nothing happened
(regression from #8423; Today view still worked).

- task-detail-panel now hosts its own inline <add-subtask-input>
  (works in every view; also fixes the input opening behind the
  bottom panel on mobile). It controls the sub-task section's
  expansion via a signal and focuses the input after the expand
  animation completes; animates in/out with [@expandFade].
- task-detail-item gains expandedChange/afterExpand outputs so the
  panel can control/observe the Material expansion panel.
- context menu addSubTask() reverts to direct addSubTaskTo() (a
  transient menu has no place to host the inline draft).

Adds a Planner e2e repro and updates the affected unit specs.

* fix(tasks): address multi-review findings for #8617 add-subtask

- Focus the inline input via a deferred timeout in onSubTasksAfterExpand:
  with animations disabled Material fires afterExpand synchronously within
  the same CD pass, before the addSubtaskInput viewChild is committed, so
  the first collapsed→expand "Add subtask" click left the input unfocused.
- Reset isSubTasksExpanded on task switch so the sub-task section doesn't
  stay sticky-expanded across tasks (the panel instance is reused).
- Return focus to the "Add subtask" button when the draft is closed via
  Escape, instead of dropping focus to <body>.
- Refresh the now-stale AddSubtaskInputService doc comment.
- Assert the draft input is focused in the Planner e2e (the focus path was
  previously uncovered).

* fix(tasks): keep context-menu add-subtask on the inline-draft bus

The earlier context-menu change to addSubTaskTo() was unnecessary: the
context menu's "Add sub-task" entry is gated behind isAdvancedControls,
which only the <task> row enables. planner-task and schedule-event leave
it false, so the entry is hidden there — meaning the menu action is only
ever reachable from a rendered <task> row, where requestOpen() works.
Reverting restores the v18.12 inline-draft UX for that path and shrinks
the diff. (#8617 was only ever reachable via the detail panel, which the
self-hosted input fix already covers.)

* test(tasks): cover add-subtask focus with animations disabled

Guards the deferred-focus fix: with animations disabled Material fires
the expansion panel's afterExpand synchronously, before the panel's
add-subtask-input viewChild is committed. Verified this test fails
without the setTimeout deferral and passes with it.
2026-06-29 15:57:44 +02:00
Johannes Millan
cb13912fad
fix(plugins): keep toggle re-enableable after nodeExecution consent denial (#8632)
Denying a plugin's native nodeExecution consent prompt landed it in a sticky
`error` state, which grayed out the management toggle (`canEnablePlugin` is
`\!plugin.error`). The only way to re-prompt was restarting the app — there was
no in-session recovery (reported on #8385 against #8576/#8512 Phase 1).

A denial is a deliberate user choice, not a load failure. Throw a dedicated
NodeExecutionConsentDeniedError from `_fireOnReady` and, in both onReady-failure
chokepoints (`activatePlugin` catch and `_handleReadyFailure`), normalise the
plugin to a clean disabled state instead of an error tile: clear `error`, set
status `not-loaded`, isEnabled off, and skip the ERROR snack. Flipping the
toggle back on clears the session-denied marker and re-opens the prompt — no
restart needed.

Decisions from multi-agent review:
- Device-local, no sync write: the denial normalisation is in-memory only and
  deliberately does NOT persist isEnabled=false. nodeExecution consent is a
  per-device, session-scoped decision; persisting would write the synced
  pluginMetadata entity and disable the plugin on every other device from a
  local "not now". Next start on this device re-prompts (matches the existing
  session-scoped model).
- Only a genuine denial becomes the recoverable clean-disable: a technical
  grant-request failure (IPC/bridge error) still surfaces as a normal error,
  distinguished via the session-denied marker.

Covers startup re-activation/reload (activatePlugin catch) and the ZIP
re-upload path (_handleReadyFailure).
2026-06-29 15:57:24 +02:00
Johannes Millan
d63cea218c
feat(i18n): add ISO 8601 date format locale option (#6484) (#8631)
Add an "ISO 8601: 24-hour, YYYY-MM-DD" entry to the date format locale
dropdown. It maps to the already-shipped Swedish (sv) locale, which
formats as y-MM-dd + HH:mm, rather than introducing a new en-se value.

Reusing an existing DateTimeLocale member avoids a cross-client sync
footgun: a brand-new locale value would be rejected by older clients'
typia validation and would have no registered locale data on those
builds (blank dates via LocaleDatePipe). The tradeoff is Swedish
spelled-out month/weekday abbreviations in a few headers; numeric
dates and times are unaffected.
2026-06-29 15:56:49 +02:00
Johannes Millan
ae4d9d7104
fix(sync): verify file-based upload size to catch truncated writes (#8604) (#8628)
* fix(sync): verify file-based upload size to catch truncated writes (#8604)

Dropbox and OneDrive enforce no end-to-end integrity, so a truncated upload
(a cut-short body silently accepted) lands a partial gzip/JSON that fails to
decode on every later download until the file is deleted. The #7300 post-upload
verification was WebDAV-only.

Add a shared assertUploadedSizeMatches() helper: compare the stored byte size
(already in the upload response, no extra request) against the bytes sent, but
only for pure-ASCII payloads so the comparison is transport-encoding independent
— avoiding false positives on the native CapacitorHttp path, where a wrong
byte-count assumption would silently re-upload every cycle. Compressed/encrypted
payloads are base64 (ASCII), covering the reported case. On mismatch raise
UploadRevToMatchMismatchAPIError, the error the upload path already surfaces.
This detects and fails the sync loudly; it does not repair the remote.

Wired into Dropbox and OneDrive uploads. WebDAV keeps its stronger content-hash
re-read check.

* perf(sync): detect ASCII without encoding in upload-size check (#8604)

The upload-size guard called TextEncoder.encode(data).length purely to detect
whether the payload is pure-ASCII (byte count === char count), allocating a
full multi-MB Uint8Array on every Dropbox/OneDrive upload — including the
non-ASCII skip path, which is the default-config common case. Detect non-ASCII
with a regex instead and use data.length directly; behaviour is identical
(byteLen === data.length iff pure ASCII) with no allocation and an early exit on
the first non-ASCII unit.

Also add OneDrive upload-size wiring tests (match + ASCII truncation), which the
app-side spec previously exercised only on the skip path.

Surfaced by a second multi-agent review.
2026-06-29 15:38:08 +02:00
Johannes Millan
df491086c9
feat(plugins): link enabled issue-provider plugins to the issue panel (#8627)
Enabling an issueProvider-type plugin from Settings/Plugins only registered
the provider with no visible next step — connection setup lives in the issue
panel's '+' tab, which users couldn't find (#8624). Add a 'Set up in Issue
Provider Panel' action on every enabled issue-provider plugin card that
navigates to the work view and reveals the panel, the hub for managing the
(possibly multiple) connections per provider.
2026-06-29 15:04:38 +02:00
Johannes Millan
36ce092e6b
fix(plainspace): allow reconnecting a stale token from the space picker (#8616) (#8626)
* fix(plainspace): allow reconnecting a stale token from the space picker (#8616)

* refactor(plainspace): drop dead host fallback, self-contained _loadSpaces, stronger reconnect test
2026-06-29 14:19:09 +02:00
John Costa
f83021ab2a
feat(markdown): continue dated bullets with today's date on Enter (#8610)
When Enter is pressed on a dated bullet like `- 18.06.: Phone call`, the next
line is now started with today's date in the same format (`- 26.06.: `),
extending the existing markdown list continuation. Pressing Enter on an empty
dated entry exits the list in one keystroke, same as a plain bullet.

The date format is mirrored from the line being continued, so no locale config
is needed: dotted `dd.MM.` / `dd.MM.yyyy` and ISO `yyyy-MM-dd` are supported;
anything else (slashed, textual, 2-digit year, out-of-range) falls back to the
normal bullet continuation. Detection requires a `<date>: ` prefix (colon +
space) and validates day/month ranges to avoid mis-firing on version bullets or
time stamps.

Date parsing/formatting lives in a small pure util (date-prefix.util.ts);
`today` is threaded in from DateService.getLogicalTodayDate() at the call sites
so the inserted date respects the configured start-of-day and the util stays
clock-free and deterministic. Plain bullets only in v1 (checkbox/numbered lists
continue unchanged).

Closes #8602
2026-06-29 13:20:26 +02:00
dependabot[bot]
090eb38774
chore(deps): bump faraday in the bundler group across 1 directory (#8625)
Bumps the bundler group with 1 update in the / directory: [faraday](https://github.com/lostisland/faraday).


Updates `faraday` from 1.10.5 to 1.10.6
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v1.10.5...v1.10.6)

---
updated-dependencies:
- dependency-name: faraday
  dependency-version: 1.10.6
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 13:09:27 +02:00
dependabot[bot]
72e1f06f98
chore(deps)(deps): bump the github-actions-minor group with 3 updates (#8622)
Bumps the github-actions-minor group with 3 updates: [actions/setup-java](https://github.com/actions/setup-java), [ruby/setup-ruby](https://github.com/ruby/setup-ruby) and [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `actions/setup-java` from 5.3.0 to 5.4.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](ad2b38190b...1bcf9fb12c)

Updates `ruby/setup-ruby` from 1.313.0 to 1.314.0
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](89f90524b8...9eb537ca03)

Updates `anthropics/claude-code-action` from 1.0.152 to 1.0.159
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](51705da45e...a92e7c70a4)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: ruby/setup-ruby
  dependency-version: 1.314.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.159
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 12:46:38 +02:00
dependabot[bot]
6ee1765882
chore(deps)(deps): bump actions/cache from 5.0.5 to 6.1.0 (#8623)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](27d5ce7f10...55cc834586)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 12:45:07 +02:00
Johannes Millan
314e352fe9 feat(plainspace): auto-remove local task when it leaves my Plainspace list
When a Plainspace task is no longer mine — deleted on the server, or reassigned
away from me — and the local task has no local content (time tracking, notes,
sub-tasks, attachments, repeat config, or a done state), the regular issue poll
removes the orphaned local task.

Detection is a single list-diff: getMyTasks$ already returns every task assigned
to me (including done ones), so a local task whose issueId is missing from that
list is no longer mine. Done tasks stay in the list, so completing one never
removes it. Plainspace has no deleteIssue adapter, so removing a reassigned task
never deletes the still-living remote item; a single orphan keeps the deleteTask
UNDO snackbar, while a bulk vanish collapses to one deleteTasks op (sync rule #3).
The delete op is idempotent across synced devices.

Safety gate against fleet-wide data loss: skip when NONE of my polled tasks are
still in the fetched list. getMyTasks$ fails soft to [] (offline / bad token /
wholesale 404), the server returns [] when membership scope is empty (removed
from the space), and a wrong/garbage list shares no ids with mine — treating any
of those as "everything removed" would wipe every task on every synced device.
(Trusts GET /tasks to be complete; it is unpaginated today.)

Adds optional getRemovedRemoteTasks to IssueServiceInterface; the host applies
the local-content guard and the removal.
2026-06-26 15:04:22 +02:00
Johannes Millan
932e2f8ec9 docs: add product principles / anti-feature-creep guidance to CLAUDE.md 2026-06-26 13:12:02 +02:00
Symon Baikov
4f94d73757
fix(sync): invalidate unsynced cache on failed rejection (#8596) 2026-06-26 12:19:36 +02:00
Symon Baikov
3c82693cc3
fix(sync): exclude duplicate ops from quota gate (#8597) 2026-06-26 12:18:50 +02:00
John Costa
9e23832879
fix(tasks): preserve schedule when moving between backlog and regular list (#8603)
Moving a task between the backlog and the regular list now only changes its
list position. Previously, moving to the regular list force-scheduled the task
for today (planTasksForToday) and moving to the backlog cleared a schedule that
fell on today, so the task's due date silently changed.

Fixes #8592
2026-06-26 12:16:07 +02:00
Mohamed Abdeltawab
13b23c09dc
refactor(sync): remove dead getOpsSince method, migrate tests to getOpsSinceWithSeq (#8601)
* refactor(sync): remove dead getOpsSince method, migrate tests to getOpsSinceWithSeq

getOpsSince (non-WithSeq) was dead code in production — routes only called
getOpsSinceWithSeq. Removed the method and its direct unit tests; migrated
all remaining test callers to getOpsSinceWithSeq with .ops accessor.

* fix(sync): add missing lastSeq to userSyncState in cleanup test

getOpsSinceWithSeq reads userSyncState.lastSeq to determine the latest
sequence. The test overwrote userSyncState without lastSeq, causing the
new method to return empty ops.
2026-06-26 12:14:58 +02:00
Johannes Millan
2f2bf55c1a
feat(tasks): show overdue as red icon and time conflict as a "\!" icon (#8599)
* feat(work-view): show start time on Later Today calendar events

Calendar placeholder events under Later Today now display their clock
start time at the right of the row, like a scheduled task's time badge.
Gated behind a new showStartTime input (default off) so planner-day
usages are unchanged.

* test(work-view): cover start-time rendering on Later Today calendar events

* ci(android): enable manual Google Play promotion without a GitHub release

Promoting the Android build from internal to production was only triggered
by 'release: published', which also fans out to AUR, Docker, snap, and web.
An Android-only release thus forced publishing a full GitHub release; undoing
it by deleting the release left the AUR PKGBUILD pointing at a removed .deb
(404). Add a workflow_dispatch trigger so the promotion can run on its own.

* feat(tasks): show overdue as red icon and time conflict as a "!" icon

Overdue tasks now color the schedule icon with the danger token, driven
by a semantic :host.isOverdue state instead of Material's color="warn"
(which no longer reliably applied). Time conflicts are surfaced as a red
"!" icon next to the schedule control, replacing the task-box border/bar
and the schedule-icon dimming.

* refactor(tasks): remove redundant overdue/conflict indicator bindings

Follow-up cleanup after multi-review of the overdue/conflict indicators:

- Schedule buttons: drop [color]="isOverdue() ? 'warn' : ''". The icon
  color now comes from the :host.isOverdue rule; this binding only tinted
  the warn ripple, so it was dead for its purpose. Also trims three
  hot-path template bindings.
- Conflict "!" icon: drop the [attr.aria-label]. MatIcon force-sets
  aria-hidden when the attribute is absent, so the label was never
  announced; the adjacent (focusable) schedule button already exposes the
  conflict to assistive tech. The icon is now honestly decorative, with
  the tooltip kept as a mouse-only nicety.
2026-06-25 17:53:48 +02:00
Johannes Millan
465ea5b034
feat(tasks): add quick "done" icon button to reminder dialog footer (#8595)
* feat(tasks): add quick "done" icon button to reminder dialog footer

Promote marking a reminder's task as done from the overflow menu to a
one-click icon button beside the snooze split-button. Uses done_all for
the multi-task view and check for the single-task view, with a tooltip
and aria-label sourced from existing translation keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV

* style(tasks): match reminder done button to outlined snooze control

The quick done button rendered as a borderless mat-icon-button, clashing
with the outlined snooze split-button and filled primary CTA beside it.
Switch it to a compact, outlined square (mat-stroked-button) so it reads
as a peer of the snooze control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV

* refactor(tasks): equalize reminder done button spacing, drop menu duplicate

- Cancel Material's adjacent dialog-action button margin in the footer row
  so the done button has equal flex-gap spacing on both sides (the split-
  button host isn't a button-base, so the margin only landed on one side).
- Remove the now-redundant done/complete item from the overflow menu since
  it is a visible button, and drop the orphaned DONE translation key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV

* style(tasks): widen reminder footer button gap to var(--s)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV

* fix(tasks): show prominent reminder done button only for single task

For multiple reminders, demote complete-all back into the overflow menu
and rely on the existing per-row check buttons. Bulk-complete is the most
destructive footer action with no undo, and 'I finished all of these' is
the least likely bulk intent — so it should not occupy a prominent,
single-click slot. The visible done button now only appears for a single
reminder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYst27M7X994sRwdz8Q6XV

* test(reminders): dismiss deadline reminder via new done button

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 17:15:58 +02:00
Johannes Millan
a67323b2b5
feat(plugins): allow uploaded nodeExecution behind consent gate (#8576)
* 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

* 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

* 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 16:45:54 +02:00
Johannes Millan
762fd3baab fix(release-notes): strip non-Apple platform references from App Store What's New
App Review guideline 2.3.10 rejected the macOS 18.12.0 build because its
What's New text mentioned Android (carried over from the shared GitHub
changelog, which also lists Linux/GNOME desktop fixes). The Apple deliver
prep stripped markdown but never filtered platform-specific lines, so the
same rejection would recur on every release with an Android/Linux commit.

Add stripOtherPlatformLines() to the App Store prep step: drop any changelog
line naming a non-Apple platform (bullets, headings, and intro prose, so the
AI generation path can't leak a name either) and remove any section heading
left empty, logging each dropped line. The GitHub and Play Store changelogs
are unaffected. Refactor the script to export its logic and add tests.
2026-06-25 16:10:33 +02:00
Johannes Millan
cfd84ff738 ci(android): enable manual Google Play promotion without a GitHub release
Promoting the Android build from internal to production was only triggered
by 'release: published', which also fans out to AUR, Docker, snap, and web.
An Android-only release thus forced publishing a full GitHub release; undoing
it by deleting the release left the AUR PKGBUILD pointing at a removed .deb
(404). Add a workflow_dispatch trigger so the promotion can run on its own.
2026-06-25 16:10:33 +02:00
Mihai Vasiliu
1b5e9104ff
feat(i18n): update ro translations (#8586) 2026-06-25 11:45:11 +02:00
Johannes Millan
a444da28b7
fix(localization): correct time-format note + 24h schedule folding (#8565) (#8585)
* fix(schedule): call is24HourFormat signal so 12h events fold

scheduledClockStr/hoverTitle negated the is24HourFormat *signal* (a
function reference, always truthy) instead of its value, so is12Hour was
always false and schedule events never folded to 12h (rendered 14:00
instead of 2:00 for en-US users). Call the signal, matching
schedule-week.component. Adds folding coverage for both 12h and 24h.

* fix(localization): correct misleading time-format setting note

After #8574 the dateTimeLocale dropdown does control the 12/24-hour time
input, and the fallback follows the browser language, not the OS. The old
note ("cannot change... controlled by your OS") is now false and was the
source of confusion in #8565.

* test(localization): guard dateTimeLocale override against UI-language clobber

Boots the real LanguageService alongside DateTimeFormatService so a
regression that routes _set() back at the adapter (re-introducing the
#8565 startup race) is caught — the existing specs only call
setUiLanguage() directly.
2026-06-25 11:31:06 +02:00
Johannes Millan
faa9434a6a
fix(sync): surface OneDrive OAuth token errors and document Entra setup (#8580)
A OneDrive token-exchange 400 (issue #8572) surfaced only as a generic
"HTTP 400 Bad Request" / "copy the code exactly" message, hiding the
real cause: the authorize step succeeds, then token redemption fails
because the custom Microsoft Entra app isn't registered as a public
client ("Allow public client flows" disabled -> AADSTS7000218).

- onedrive provider: parse the OAuth error body once, log the safe
  short `error` code on every token failure, and route the verbose
  AADSTSxxxxx `error_description` to HttpNotOkAPIError.detail (UI only,
  per the existing privacy split).
- sync-wrapper: show a OneDrive-specific snack pointing at the Entra
  registration fix and interpolating the AADSTS detail; other providers
  keep INVALID_AUTH_CODE.
- in-app info text now shows the desktop redirect URI and links to the
  setup guide; add a full OneDrive section to the configure-sync wiki
  note (it was entirely missing).
- test: auth-code 400 surfaces the detail to the UI, logs the error
  code, and keeps the description out of the structured log.
2026-06-24 16:18:46 +02:00