Commit graph

21306 commits

Author SHA1 Message Date
Johannes Millan
b3e3722425 test(e2e): re-issue finish-day click until daily-summary nav starts
The finish-day button is a routerLink inside an @if (hasDoneTasks()) /
@else swap, so marking a task done re-creates the element. A one-shot
click can land before Angular wires the new element's routerLink and
silently no-ops, hanging archiveDoneTasks for the full 30s waitForURL.
Retry the click via expect().toPass() until navigation actually starts.

Recurrence of the race first addressed in b8d1dbe261.
2026-07-06 17:04:20 +02:00
Johannes Millan
245330bd68
fix(tasks): shorten reminder snooze button label to fit small screens (#8743)
The reminder dialog footer could not fit all actions in one row on small
screens. Show only the snooze duration (e.g. "10m") on the split-button's
main action instead of "Snooze 10m" — the snooze icon already conveys the
action — freeing horizontal space so the row fits.

Add a `mainLabel` input to the shared split-button so the abbreviated main
action keeps a full tooltip and aria-label for discoverability/accessibility.


Claude-Session: https://claude.ai/code/session_015797BREShEaTkBsA3tRBuX

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 23:36:05 +02:00
Johannes Millan
cb586f87e9
fix(sync): report honest status when SuperSync encryption key is missing (#8741)
* fix(work-context): use project icon for header title icon

The header title icon read the active work context's icon, but
selectActiveWorkContext forced icon: null for projects (a leftover
from when projects had no icon field). Pass the project's own icon
through so the header matches the side nav.

* fix(sync): report honest status when SuperSync encryption key is missing

When a provider mandates E2E encryption (SuperSync) but no key is
configured, the GHSA-9v8x guard skips the upload while pending ops stay
unsynced. The low-level result was byte-identical to "nothing to upload",
so the wrapper claimed IN_SYNC — silently one-way syncing (downloads
apply, local edits never leave; device loss = data loss).

Thread an `encryptionRequiredKeyMissing` flag from the upload guard
through UploadResult and the orchestrator's UploadOutcome so the wrapper
reports UNKNOWN_OR_CHANGED (UpdateRemote) instead of IN_SYNC, and
short-circuits the LWW re-upload loop that would otherwise spin against
the same missing key.

Also stop reverting to a keyless config when enable-encryption fails
after the server was already wiped: that left a mandatory-encryption
provider with no key, so the upload guard blocked every future upload and
the wiped server could never be repopulated (the advertised "Sync Now"
recovery was a no-op — account stranded). Keep the new key so the next
sync re-uploads the data encrypted, mirroring the password-change flow.

Refs #8731
2026-07-03 18:35:33 +02:00
Johannes Millan
bf710637d2
feat(android): add home screen widget for today's tasks (#8737)
* feat(android): add home screen widget for today's tasks (#3818)

Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:

- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
  selector, debounced, hydration-guarded, re-pushed on sync-window end
  and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
  taps are overlaid natively at render time (no native blob write, no
  race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
  PendingIntent template); exported receiver no longer lists the custom
  actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
  by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
  translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
  connection; access stays @Synchronized via the App singleton)

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>

* feat(android): polish widget UI and support toggling done state

Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
  (#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
  brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
  brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
  project-less tasks; whole row and empty state open the app

Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
  done task queues setUnDone, and a done->undone round trip before
  the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
  overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
  tasks already in the target state

---------

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
2026-07-03 18:32:34 +02:00
Johannes Millan
1fde5cf664 docs(security): revise secure-storage plan after multi-agent review 2026-07-03 18:21:16 +02:00
Johannes Millan
08d13fd7d5
fix(local-rest-api): validate PATCH/create field value types (#8738)
PATCH /tasks/:id picked allowed keys but never checked value types, so a
wrong-typed value (e.g. tagIds:123, timeEstimate:"abc") was written into
the store and the synced op-log, corrupting state locally and tripping
typia-as-corrupt on other devices when the op replays. Both PATCH and
create now typia.validate the values and return 400 before dispatching.

Also harden the Electron server: reset isListening eagerly and call
closeAllConnections() on stop so a keep-alive socket can't leave the
server stuck and no-op a later re-enable, reject requests with 503 while
disabled, and log an actionable message on EADDRINUSE.

Closes #8732
Refs #7484
2026-07-03 18:20:33 +02:00
Johannes Millan
25d7d6a379
ci(lighthouse): raise script-count budget to 220, align warn to 210 (#8740)
* fix(work-context): use project icon for header title icon

The header title icon read the active work context's icon, but
selectActiveWorkContext forced icon: null for projects (a leftover
from when projects had no icon field). Pass the project's own icon
through so the header matches the side nav.

* ci(lighthouse): raise script-count budget 200->220, align warn to 210

The Lighthouse `resource-summary.script.count` budget in budget.json was
an exact hard cap of 200 that the app has crept up against as it grows
(master passed at <=200; a recent build hit 201 and failed CI on an
unrelated PR). Bump the hard budget to 220 to restore headroom.

The parallel warn threshold in .lighthouserc.json was 170 - already
exceeded by ~30, so it fired on every run as permanent, meaningless
noise. Raise it to 210 so it sits just below the 220 hard cap and again
functions as an early warning before the budget is breached.
2026-07-03 18:13:23 +02:00
Johannes Millan
421dc353e8 docs(sync): add sync-engine extraction plan 2026-07-03 16:14:22 +02:00
Johannes Millan
911f39402d ci(android): publish per-push dev builds to the Play internal track
Fold per-push Android dev distribution into build-android.yml (which
already builds a signed APK on every master push) instead of a second
workflow that duplicated the whole build. On non-tag master pushes it
stamps a dev versionCode into the working copy of build.gradle and, after
the existing build, ships the play APK to the Play 'internal' track so a
fresh build auto-updates on the phone. Internal testing skips review, so
builds land in minutes; dev builds (code base+1..999) and releases
(base+9000) share the track but stay distinct by versionCode. All new
steps are gated to refs/heads/master (non-tag); the tag/release path is
untouched, and every dev step is continue-on-error so it can never break
the release-critical build.

versionCode = last stable release's code + commits-since-that-tag
(first-parent), staying in the 999-slot band above the release and below
the next version's base. The base is derived from the last stable tag
(not the working tree), so a version bump landing on master before its
tag is pushed can't make dev codes jump ahead and then regress. Logic
lives in tools/android-dev-version-code.js (reusing getAndroidVersionInfo)
with unit tests; it degrades to skip on any anomaly.

Reuses existing keystore/Play/Unsplash secrets; no new secrets.
2026-07-03 15:45:33 +02:00
Johannes Millan
b60845b42c
test(sync): reliably dismiss setup-time E2EE dialog in webdav e2e helper (#8727)
#8709 added a mandatory disableClose "Encrypt before first upload?" modal on
every fresh file-based sync setup. The setupWebdavSync helper dismissed it with
locator.isVisible({ timeout }), but isVisible() returns the CURRENT state
immediately and never polls — the timeout is effectively ignored. The modal
opens only after save()'s awaited provider-auth check and a lazy import() of the
dialog chunk, so it appears a beat after the Save click; isVisible() raced it,
returned false, and the Skip click was never issued. The leftover backdrop then
blocked every following interaction, failing ~11-12 @webdav specs per run with
"cdk-overlay-backdrop intercepts pointer events" and conflict dialogs that never
appeared. It passed only when the modal happened to already be up at the instant
of the check, which is why the failures looked flaky.

Use waitFor({ state: 'visible' }), which actually polls until the modal appears,
then click Skip and confirm it closes. Apply the same wait to the encrypt-at-
setup fill path. Verified green via a full @webdav CI run.
2026-07-03 15:27:48 +02:00
Johannes Millan
a927b3347b feat(work-context): show active project/tag icon in header title 2026-07-03 14:52:47 +02:00
Johannes Millan
2ddc16053c
fix(task-repeat-cfg): don't crash on a deleted repeat config (#8715) (#8726)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(task-repeat-cfg): don't crash when completing a task with a deleted repeat config

selectTaskRepeatCfgById throws on a missing config; completing a recurring
task (or editing its subtasks) whose repeatCfgId dangles — e.g. after
cross-client sync deleted the config — crashed the whole app. Route those
task-derived lookups through the non-throwing selector and skip instead. (#8715)

* fix(task-repeat-cfg): guard remaining deleted-repeat-config crash paths

Extend the #8715 fix to the three task-derived UI lookups that still used
the throwing selectTaskRepeatCfgById: move-to-project (task + context menu)
and the edit-repeat dialog. A task whose repeatCfgId dangles (e.g. the config
was deleted via cross-client sync) no longer crashes the app on these actions
— move falls back to a plain task move, the edit dialog aborts and closes. (#8715)
2026-07-03 14:49:24 +02:00
Johannes Millan
98ce93eb0e
fix(boards): keep Eisenhower "Not Completed" filter across restarts (#8723) (#8725)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(boards): keep Eisenhower "Not Completed" filter across restarts (#8723)

The fixBuggyDefaultBoardFilters normalizer ran on every loadAllData and
reset any Eisenhower quadrant with taskDoneState UnDone back to All. With
stable panel IDs it could not tell the old buggy default apart from a
user's intentional "Not Completed" choice, so that choice was clobbered
on every restart.

Drop the Eisenhower taskDoneState branch; new installs already get the
relaxed All default from DEFAULT_BOARDS. The Kanban DONE branch is kept
(separate, unreported concern).
2026-07-03 14:48:50 +02:00
Johannes Millan
4d94be258d feat(plainspace): use Plainspace brand icon for header open link 2026-07-03 14:36:18 +02:00
Johannes Millan
cbeb7c704f
fix(sync): name the discarded title in LWW conflict banner + fix fr dismiss label (#8694) (#8724)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(sync): name discarded title in LWW conflict banner; fix fr dismiss label (#8694)

* fix(sync): surface the last discarded title in LWW conflict banner

Multi-review follow-up to the #8694 banner:
- Show the LAST non-empty discarded title (the user's final offline rename),
  not the first — a stale intermediate rename is misleading. Also simpler
  (drops the first-wins guard).
- Document why the kept==discarded equality guard is correct: when a title
  edit loses to a concurrent other-field remote win, the current state still
  shows the rejected local title, so an annotation would only repeat it —
  silencing it is right, not a hidden divergence.

fr.json G.DISMISS "Rejeter"->"Ignorer" (prior commit) is an intentional,
owner-approved deviation from the en-only rule: it is a mistranslation fix
for the shared banner dismiss label, not a new string. Other locales may
carry the same "Reject"-flavored label — left as a follow-up.
2026-07-03 14:13:43 +02:00
Myk
8003a9e125
fix(tasks): treat a missing title as blank instead of crashing isBlankTask #8713 (#8714) 2026-07-03 11:13:26 +02:00
Myk
108820695e
fix(issuePanel): allow removing an issue provider whose plugin is uninstalled #8711 (#8712) 2026-07-03 11:12:04 +02:00
Johannes Millan
10a47888a6
feat(sync): offer E2EE before first upload for file-based providers (#8709)
* feat(sync): offer E2EE at setup for file-based providers

File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file)
support optional client-side encryption but, unlike SuperSync, have no
mandatory-encryption upload guard — so their first setup sync would ship
plaintext before a user could enable E2EE.

Offer to set an encryption password during first-time setup and persist it
as part of the SAME config save: the key goes to the provider's privateCfg
and isEncryptionEnabled to the global config, atomically with isEnabled.
The normal first sync then encrypts from the first op via the standard
download-first flow — no separate snapshot-overwrite and no plaintext-upload
race. Skipping keeps the existing unencrypted behavior; works offline too.

Reuses DialogEnableEncryptionComponent in a new side-effect-free
collectPasswordOnly mode (returns the password, writes nothing), with a
provider-aware Skip action for the disableClose setup modal.

* test(sync): skip file-based setup encryption prompt in webdav e2e helper

Fresh file-based setup now opens the optional "Encrypt before first upload?"
dialog before the config is persisted, so setupWebdavSync would stall on the
disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec
proceeds unencrypted, exactly as before; encryption specs still enable it
afterwards via enableEncryption().

* test(sync): add e2e for file-based setup-time encryption

Adds a @webdav @encryption spec that configures WebDAV with the encryption
password set in the setup dialog, then asserts the first upload's remote
sync-data.json does NOT contain the task title in plaintext (compression is
off by default, so a plaintext upload would) — proving the first sync is
encrypted — and that a second client with the same password decrypts and
receives the task without overwriting the remote.

setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead
of skipping it); adds e2e selectors on the setup dialog password/confirm/
submit controls.

* ci(e2e): allow targeting the webdav job via workflow_dispatch grep

The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch
input (default @webdav, mirroring the supersync job) so a specific WebDAV
test can be run on demand.

* test(sync): fix remote sync-file path in setup-encryption e2e

Non-production builds nest the file under a /DEV segment
(sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd;
the CI trace confirmed the app uploaded to <folder>/DEV/sync-data.json and
that the first upload was encrypted.

* test(sync): cover joining an unencrypted remote with setup-time encryption

Adds the data-safety edge case for file-based setup-time E2EE: a client that
sets a password at setup while joining a remote that already holds UNENCRYPTED
data. Asserts the join reads and preserves the existing data (does not overwrite
the remote with the joining client's empty state) and that a subsequent write
upgrades the remote to encrypted (no plaintext titles remain).
2026-07-02 17:17:01 +02:00
Johannes Millan
5c9c0c2131 18.13.1 2026-07-02 16:41:38 +02:00
Johannes Millan
b9fa3bdfc1 test(focus-mode): stub matchMedia so inline-launch tests are hermetic
macOS/Windows CI runners report prefers-reduced-motion: reduce, making the
component skip the rocket animation and dispatch startFocusSession
immediately. The spec read the host setting directly, so the three
inline-launch assertions failed on those platforms (release build 18.13.0)
while passing on Linux. Stub matchMedia in both startSession describe blocks
and add coverage for the reduced-motion shortcut.
2026-07-02 16:38:43 +02:00
Johannes Millan
429dc4c7e9 ci(release): tolerate concurrent App Store review-submission race
The iOS and macOS release lanes both run on a v* tag push and can race
on App Store Connect's per-app review-submission state. When they
collide, the lane that submits second fails with 'A review submission is
already in progress' -- but only after its binary has already uploaded
and processed successfully, so the build is safe.

Route both lanes through a shared submit_to_app_store helper that treats
that one collision as a soft success and emits a GitHub Actions warning
annotation so the required manual step (add the build to the open
submission) stays visible on a green run. Every other failure, and any
failure before upload (e.g. the version-creation race), still aborts the
lane loudly.
2026-07-02 16:38:43 +02:00
Johannes Millan
2261160436
fix(sync): scan all tags when archiving to avoid dangling tag refs (#8710)
* fix(sync): scan all tags when archiving to avoid dangling tag refs

The archive path only cleaned tags named in each task's own tagIds, so a
one-sided tag->task reference (tag.taskIds holds an id the task omits) left
behind by a sync replay was never removed. That dangling reference later
tripped cross-model validation and forced a reconciliation/REPAIR.

Lift removeTasksFromAllTags into the shared helpers and reuse it in the
archive path so cleanup is symmetric with the delete path (scans every tag).
Add regression coverage.

* refactor(store): route project-delete tag cleanup through shared helper

Replace the hand-rolled all-tags scan in handleDeleteProject with the shared
removeTasksFromAllTags helper, matching the delete and archive paths (and
gaining its no-op skip for tags that don't reference the deleted tasks).

Move the helper into the STATE UPDATE HELPERS section (it is a state->state
transform, not a list helper) and let its JSDoc own the one-sided-tag-ref
rationale, trimming the duplicated archive call-site comment to a pointer.

No behavior change: final tag taskIds are identical; only unaffected tags now
keep their reference identity instead of being rewritten to equal arrays.
2026-07-02 16:37:39 +02:00
Johannes Millan
78e546ff6a 18.13.0 2026-07-02 15:25:39 +02:00
Johannes Millan
a740618d50 docs: prefer GitHub Actions for E2E suites 2026-07-02 15:15:52 +02:00
Myk
310e8cf2db
fix(plugins): spawn nodeExecution scripts via process.execPath so packaged apps work #8707 (#8708) 2026-07-02 15:05:57 +02:00
Johannes Millan
d00e2df354
refactor(ui): unify primary/secondary action button treatment across dialogs (#8706)
Standardize dialog actions: primary=mat-flat-button color=primary,
secondary/cancel=mat-button, destructive=color=warn. Drop dead Bootstrap
'btn btn-primary' classes and decorative check/close icons. Document the
convention in docs/styling-guide.md and update e2e selectors that keyed
on the old stroked variant.

Closes #8683
2026-07-02 14:42:59 +02:00
Johannes Millan
e7d98439a8
feat(update-check): notify desktop users about new releases (#5463) (#8705)
* feat(update-check): notify desktop users about new releases (#5463)

* fix(update-check): build release URL locally, use HttpClient with timeout

* feat(update-check): add translations for all locales
2026-07-02 14:13:09 +02:00
Johannes Millan
b692eb72ce
feat(rate-dialog): calm, recurring, win-timed store rating prompt (#8704)
* feat(rate-dialog): native store review + prompt after a productive win

Baseline of PR #8680 (squashed) so review improvements land as a separate,
cherry-pickable commit on top.

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

* feat(rate-dialog): calm banner, feedback cooldown, delayed win prompt

Review + UX follow-ups on top of PR #8680:
- Fix (correctness): re-check full eligibility at prompt fire-time, not just
  opt-out, so a crash/data-damage recorded after arming still suppresses it.
- Fix: iOS advances the rating cadence only once the native request resolves;
  a reject leaves eligibility intact instead of burning a lifetime prompt.
- Fix: Android review-flow failure now logs and abandons instead of opening the
  Play Store unprompted (the trigger is an automatic win, not a user tap).
- UX: web/electron/F-Droid now show a calm, non-modal banner that opens the full
  rate/feedback dialog on request, rather than a modal shoved in mid-flow.
- UX: 'give feedback' no longer permanently opts out — it starts a long cooldown
  (~90 app-start days) so an engaged user can still be asked once more later.
- UX: the win prompt fires a few seconds after the completion, not on the tap.
- UX: dedicated 'Send feedback' entry in the Help menu (GitHub Discussions).
- UX: show the maintainer email as selectable text (mailto: dead-end fallback).
- Refactor: move selectTodayProgress into work-context.selectors (colocation);
  idempotency guard on the win subscription.

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

* test(rate-dialog): import WIN_PROMPT_DELAY_MS in spec instead of mirroring it

Export the delay constant from the service and reference it in the spec so the
test can't silently drift from the real value. Found by a review pass.

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

* feat(rate-dialog): recurring cadence, version-age gate, GitHub star CTA

Growth-focused follow-ups (review found the lifetime cap starves review
recency/velocity, which is what stores rank on):

- Recurring cadence: after the fixed onboarding tiers (32/96 app-start days) the
  prompt no longer stops forever — it recurs every ~180 app-start days (~6+
  months of real use), well inside Apple's ~3/365 allowance and Play's own
  quota. Still calm: win-timed, opt-out/crash/feedback-gated, OS-throttled.
- Version-age gate: hold the prompt for 7 days after the app version changes so a
  fresh (possibly regressed) release isn't asked to be rated immediately. Tracked
  via two device-local LS keys, checked at arm time and fire time.
- Play tier-burn is now only a deferral, not a lifetime loss (recurring cadence).
- Web/Electron CTA: 'Star us on GitHub' (the desktop-distribution equivalent of
  store ranking) instead of the near-zero-conversion how-to-rate doc.

Tests cover recurrence at + beyond the last tier, the version-age gate, and the
updated cadence expectations. 60/60 green; tsc clean.

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

* fix(rate-dialog): register iOS plugin, drop version-age gate, cut per-tick churn

Second multi-review pass (7 reviewers):

- CRITICAL (Codex, verified): the iOS StoreReview Capacitor plugin was never
  registered — CustomViewController registers WebDavHttpPlugin but not
  StoreReviewPlugin, so requestReview() rejected and the native App Store review
  card never showed. Register it via registerPluginInstance.
- Remove the version-age gate (4 reviewers): getAppVersionStr() changes every
  release and the app ships ~weekly, so the 7-day window kept re-arming on every
  update (web SW reload / Electron auto-update) and near-permanently suppressed
  the prompt on desktop — the platform where the GitHub-star intent matters most.
  It also duplicated the 30-day crash gate, which already covers crashing
  regressions. Drops 2 LS keys, a constant, and ~40 LOC.
- Perf: add distinctUntilChanged on the armed win stream so the 1s time-tracking
  tick (new {done,total} with identical numbers) no longer re-runs scan/filter
  every second for the whole session.

59/59 specs green; tsc clean. Kotlin/Swift can't be compiled here — the iOS
registration needs an on-device/simulator smoke test.

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

* chore(i18n): translate new rate-prompt strings into all locales

Add BANNER_ACTION, BTN_STAR_GITHUB (F.D_RATE) and SEND_FEEDBACK (MH.HM) to all
27 non-en locale files — previously they existed only in en.json and fell back
to English for everyone else. SEND_FEEDBACK reuses each locale's existing
feedback wording for consistency; GitHub is kept untranslated.

Deliberately edits locale files beyond the usual en-only workflow, per request.
Best-effort translations — a native check on the less-common languages is
welcome.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 13:52:10 +02:00
jibin jose
393f686e4b
feat(theme): add global wallpaper with per-context override (#8643) (#8663)
Fixes the Today background leaking onto non-context pages (Planner,
Schedule, Boards, Config) and the startup flash reported in #8643.

The active work context stays "Today" (the reducer default) on pages
that aren't a tag/project, so its background was shown there wrongly.
Resolve the background per-route instead: per-context image -> global
wallpaper -> none, with overlay-opacity and blur following whichever
image is actually shown (never the sticky context's on global pages).

- add a global wallpaper (image dark/light + overlay opacity + blur) to
  MiscConfig, surfaced via a "Set wallpaper..." dialog under Theme
- resolveBackground() replaces the URL image helper and returns the
  styling source; a cleared/empty image falls back to the global one
- app.component derives opacity/blur from the resolved source

Builds on the URL-aware background stream contributed in the PR.

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: jibin7jose <jibin7jose@users.noreply.github.com>
2026-07-02 12:53:38 +02:00
Johannes Millan
db99a4c345
fix(tasks): refocus add-task input after clicking + button (#8703)
Clicking the + button moved focus onto the button, which unmounts once
the input clears, dropping focus to <body>. Route the click through a
new onSubmitBtnClick() that refocuses the input after addTask() settles
so the next task can be typed right away. The Enter-key path already
retained input focus, so it is unchanged.
2026-07-02 12:53:30 +02:00
Johannes Millan
6c1117b7b0
fix(gitlab): reject bare project slugs in config validation (#8701)
The #8667 regex only rejected pasted display names (values with spaces),
so a bare single-segment slug like `test_config` still passed and produced
a confusing 404 at poll time (#8665). A GitLab project reference must be a
numeric ID or a namespace-qualified path (`group/project`, subgroups, or
the `%2F`-encoded form) — the REST API cannot resolve a bare slug (verified
live: `/projects/gitlab/issues` → 404, `/projects/gitlab-org%2Fgitlab/issues`
→ 200). Require a path separator for the non-numeric branch so the mistake
gets inline feedback (Save/Test are gated on form validity) instead. The
separator lookahead keeps the char class a single unnested quantifier.
2026-07-02 12:53:12 +02:00
Johannes Millan
222fa4c8aa
feat(sync): show banner when LWW sync discards a user content edit (#8694) (#8702)
* feat(sync): show banner when LWW discards a user content edit

Auto-resolved LWW conflicts were only surfaced as a generic "N local/
remote wins" count snack, so a field-level edit silently dropped by
last-write-wins (e.g. a title edit lost to a concurrent notes edit) was
invisible to the user (#8694).

Split the resolution outcome: routine self-healing (reschedule/repeat/
archive/done churn) keeps the quiet count snack, while a resolution that
discarded a genuine content edit (task title/notes/subtasks/attachments)
shows a dismissible banner naming the affected task(s).

- New pure summarizeLwwResolutions() classifier in @sp/sync-core (inspects
  the losing side's changed fields; UPDATE only, so create/delete/move and
  scheduling churn stay routine).
- Titles are HTML-escaped before the innerHTML banner (they come from
  synced remote data) and never logged.

* fix(sync): correct + simplify LWW content-conflict notice (multi-review)

Address multi-agent review of the previous commit:

- CRITICAL: the classifier's multi-entity branch made the feature a no-op
  in production. Captured ops are always wrapped as
  { actionPayload, entityChanges: [] } and task edits never populate
  entityChanges (only time-tracking does), so a real title/notes edit was
  read as having no changed fields → classified routine → banner never
  fired. Fixed by dropping the branch and relying on extractUpdateChanges
  (which unwraps actionPayload), gated to UPDATE ops. Spec now uses the
  real wrapped payload shape so this can't regress.
- Move the classifier out of the framework-agnostic @sp/sync-core package
  into the app (findLwwContentConflicts); it held app-specific TASK field
  names and was exported but unused. TASK-only, no generics/callback.
- De-duplicate content conflicts per task (one task can yield several
  concurrent conflicts) so the banner never lists a title twice.
- Direction-neutral wording ("Older edits may have been discarded") since
  the discarded side depends on which client won.
- Use the banner's built-in dismiss button instead of a no-op action.
- Consolidate escapeHtml: escapeHtmlAttr now re-exports the shared util.
- Guard against a non-string title before trim().

* fix(sync): drop dead 'attachments' from LWW content fields (review)

Second-review WARNING: attachments are edited via dedicated
[TaskAttachment] actions with payload { taskId, taskAttachment }, never
updateTask({ task: { changes: { attachments } } }), so extractUpdateChanges
never surfaces an 'attachments' key — the entry could never match and
falsely claimed coverage. Removed it (title/notes/subTaskIds are live) and
added a guard test asserting a real attachment-action-shaped op is not
flagged, so it isn't naively re-added.
2026-07-02 12:53:02 +02:00
Johannes Millan
c4f1a57fa9
feat(plainspace): add "Open in Plainspace" header button for shared projects (#8700)
* feat(plainspace): add "Open in Plainspace" header button for shared projects

Surfaces a one-click link to the bound Plainspace space in the project
header (page-title actions), shown only for projects shared on Plainspace.

- getSpaceUrl$ resolves the space slug via /me and builds {host}/{slug}
- openProjectOnPlainspace opens it externally (Electron) / new tab (web),
  with an OPEN_FAILED snack when the slug can't be resolved (offline/token)
- selectPlainspaceProviderForProject selector (selectIsProjectShared\* DRY'd)

* fix(plainspace): harden Open-in-Plainspace URL resolution + a11y

Multi-review follow-ups:
- getSpaceUrl$ guards a malformed /me body (non-array projects) instead of
  throwing an unhandled rejection, and requires a non-empty slug (blank slug
  would open the host root) — both now return null → OPEN_FAILED snack
- add aria-label to the header icon button
- correct stale comments referencing the removed context-menu entry

* docs(plainspace): note web popup-blocker limitation on external open
2026-07-02 12:52:53 +02:00
Johannes Millan
e283973e36 feat(plainspace): update icon to new two-figure group mark 2026-07-02 12:19:52 +02:00
Johannes Millan
6b375db007
fix(sync): calm vector-clock pruning notification (#8696) (#8699)
Users with several past installs/reinstalls accumulate >20 client IDs in
the vector clock, so pruning fires on nearly every download-merge. The old
notice was a sticky WARNING recommending a destructive "sync reset" for a
benign, self-healing cleanup, and it recurred constantly.

Split the signal by purpose without touching pruning correctness:
- Log: OpLog.info -> OpLog.warn, plus prunedIds/survivingIds so the churn
  is diagnosable from the exported log history users share in reports.
- Snack: throttle to once per app session (take(1)); WARNING -> CUSTOM
  with a neutral icon and auto-dismiss instead of a sticky alert.
- Reword the message: drop "exceeding the limit"/"sync reset", keep the
  numbers as a breadcrumb.
2026-07-02 12:18:14 +02:00
Johannes Millan
4e0e2d9ffd
fix(electron): open file:// URLs with special characters on Windows (#8695) (#8698)
* fix(electron): open file:// URLs with special characters (#8695)

Local file:// URLs with non-ASCII names or spaces (e.g. Grüne, "Another
One") failed to open on Windows: both open sinks handed the URL to
shell.openExternal, which percent-encodes the path (ü → %C3%BC, space →
%20) before ShellExecute, so the OS then searched for a literally-named
folder and failed. Pure-ASCII paths worked because there was nothing to
encode.

Route local file: URLs through shell.openPath with a decoded filesystem
path (fileURLToPath) instead. openPath takes a raw path, so Unicode names
and spaces open correctly. This also applies the executable-extension
guard to file: URLs, which previously only ran on the OPEN_PATH sink
(GHSA-hr87-735w-hfq3).

Shared openLocalPath()/isLocalFileUrl() cover both the OPEN_EXTERNAL IPC
handler and the navigation-interception path in main-window.

* test(electron): cover NTLM/exec vectors at the un-pre-gated openPath sink

Follow-up to the #8695 review. Add regression tests locking in that
openLocalPath alone (OPEN_PATH has no scheme pre-gate) blocks the
path-based UNC file: URL (file:////host/share, GHSA-hr87-735w-hfq3) and
an executable hidden behind a percent-encoded dot (evil%2Ebat). Document
that the decode tests run on Linux and therefore don't exercise the
Windows drive-letter/backslash conversion, and clarify why isLocalFileUrl
is intentionally broader than the renderer/scheme-allowlist checks.
2026-07-02 11:47:51 +02:00
Johannes Millan
3d952fb157
fix(gitlab): reject invalid project identifiers in config validation (#8667)
The project-field validation regex was only end-anchored, so display
names containing spaces (e.g. "My Group/My Gitlab") passed validation
and produced a confusing 404 at poll time instead of inline feedback
(#8665). Anchor the regex at both ends and simplify the path branch to a
plain character class so it stays a permissive "did you paste a display
name?" guard (still accepts consecutive hyphens and other GitLab-valid
paths). Clarify the field hint to ask for the path slug or numeric ID,
and add regression tests plus a field-wiring check.
2026-07-02 10:43:09 +02:00
Johannes Millan
785911c232
test(sync): arm setup-sync flag so encryption prompt spec exercises dialog path (#8681)
* test(sync): arm setup-sync flag so encryption prompt spec exercises dialog path

The two _promptSuperSyncEncryptionIfNeeded() specs added in #8679 predated the
_shouldPromptEncryptionAfterSetupSync one-shot guard introduced by #8678. After
both merged, the method early-returns unless the flag is armed, so MatDialog.open
was never called and the "opens immediately" / "defers then opens (#8670)" specs
failed. Arm the flag via the public markPromptEncryptionAfterSetupSync() in the
describe beforeEach; this also makes the "does not prompt" specs exercise their
real code paths instead of passing on the early return.

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

* test(encryption): stop crypto.subtle stub leaking across specs

encryption.browser.spec stubbed window.crypto.subtle to simulate an insecure
context, capturing Object.getOwnPropertyDescriptor(window.crypto, 'subtle') to
restore it. But subtle is an accessor inherited from Crypto.prototype, not an
own property, so that descriptor is undefined and the stub defines a shadowing
OWN property. The finally block only restored `if (originalDescriptor)` — always
falsy — so the `subtle: undefined` stub was never removed and leaked into every
later spec in the karma run. Any spec relying on isCryptoSubtleAvailable() then
saw false suite-wide; under Jasmine's randomized order this intermittently broke
the SuperSyncEncryptionMigrationBannerService specs (banner never opened → 7
failures), surfacing only when this spec happened to run first.

Restore via a helper that reinstates the original descriptor when present and
otherwise deletes the shadowing own property, re-exposing the prototype getter.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 00:30:39 +02:00
Johannes Millan
7861d26235
fix(sync): defer SuperSync encryption prompt instead of dropping it when a dialog is open (#8679)
After the #8670 E2EE-mandatory upload guard, first-time SuperSync setup skips
the initial upload (no key yet), so the first sync reaches IN_SYNC almost
instantly — faster than the sync-config dialog's close animation. The post-sync
_promptSuperSyncEncryptionIfNeeded then ran while the config dialog was still in
openDialogs and hit the one-shot 'dialog already open -> skip' guard, so the
mandatory-encryption prompt never opened. Setup silently finished with sync
enabled, no encryption configured, and nothing uploaded — and the whole
@supersync e2e suite went red (all 6 shards, ~72 failures with 'Unable to
determine Client A vs B').

Make the guard DEFER instead of DROP: wait (bounded) for open dialogs to clear,
then re-validate the active provider and re-check encryption state before
prompting. Add a fast-path so we never wait on our own prompt dialog. Re-checking
the provider after the wait prevents opening the disableClose setup dialog for a
provider that was switched/disabled while we waited. Nothing is uploaded before a
key exists, so the security invariant of #8670 is preserved.

Regression tests cover: immediate open when no dialog is open, defer-then-open
once the dialog closes (#8670), skip when encryption is configured while waiting,
skip when the provider is no longer SuperSync after the wait, and skip when
already enabled.

NOTE: this branch predates #8670; rebase onto current master (which contains the
isEncryptionMandatory guard) before merging so this actually turns the red
@supersync CI green.
2026-07-01 21:57:08 +02:00
Johannes Millan
e016b334d3
feat(sync): calm E2EE migration banner for existing unencrypted SuperSync (#8672) (#8678)
* feat(sync): calm E2EE migration banner for existing unencrypted SuperSync

Nudge established, unencrypted SuperSync accounts to enable end-to-end
encryption via a calm, dismissible banner (once per app start, device-local
snooze) instead of the dead-end per-sync setup modal.

- New SuperSyncEncryptionMigrationBannerService, shown at
  afterInitialSyncDoneAndDataLoadedInitially$ (mirrors SyncSafetyBannerService).
- Detect: SuperSync + isReady() + getLastServerSeq() > 0 + getEncryptKey()
  undefined + WebCrypto available. isReady() being false for the
  half-configured state auto-excludes the multi-device needs-password cohort.
- Data safety: the action runs a fresh sync (download+merge) and re-checks the
  server is still unencrypted immediately before the destructive
  delete-and-reupload; aborts (snack, no re-encrypt) if the server turned out
  encrypted elsewhere. Opens the escapable dialog (initialSetup: false).
- Snooze (14d) via localStorage, no telemetry; recurs calmly rather than
  dismiss-forever.
- Avoid double-prompt: gate the legacy setup modal behind a one-shot flag set by
  the config dialog only on fresh SuperSync enable, so established users are
  owned by the banner and fresh setups still get the one-time modal.

Refs #8672, #8671

* fix(sync): harden SuperSync E2EE migration banner per multi-review

Address findings from the multi-agent review:

- Arm the fresh-setup encryption flag regardless of connectivity. Previously it
  was set only inside `if (isOnline())`, so an offline SuperSync setup never
  flagged and the later online sync silently skipped the prompt (and the banner,
  since seq was 0), risking an unencrypted upload. (Codex CRITICAL)
- Snooze only once the migration dialog is actually reached, not on the Enable
  click. A transient pre-sync failure (HANDLED_ERROR: offline/network/lock) no
  longer hides the nudge for the full 14-day window. (consensus)
- Consume the one-shot setup flag at the top of the post-sync prompt (once
  SuperSync is confirmed) instead of only when the modal opens, so it can't leak
  to a later, unrelated sync and pop the dead-end modal; also short-circuits the
  common path before loading config.
- Reuse `_isMigrationNeeded()` for the post-sync re-check and delete the
  duplicated `_isServerStillUnencrypted` (DRY); add a dialog-stacking guard so
  the escapable dialog never stacks on an enter-password prompt.
- Correct the detection comment: `isReady()` excludes only the half-configured
  state; the never-encrypted-locally cohort is caught by the HANDLED_ERROR defer.
- Document why the dialog is opened directly (the shared opener forces
  disableClose, which would break the calm banner's escapability), and add a
  TODO(#8670) to retire the legacy modal + flag once the upload guard lands.

Refs #8672, #8671

* test(sync): cover offline arming of the SuperSync setup encryption flag

Follow-up to the multi-review verification:

- Add a dialog-sync-cfg regression test asserting the fresh-setup encryption
  flag is armed even when offline (and that no sync is kicked off), locking in
  the fix that moved arming outside the isOnline() gate.
- Note the one-shot flag consume-at-top tradeoff in _promptSuperSyncEncryptionIfNeeded
  (a rare blocked modal-open won't retry; the migration banner catches it next
  app start).

Refs #8672
2026-07-01 21:09:42 +02:00
Johannes Millan
d813481547
fix(issue): don't stamp ambient tag on automatic issue imports (#8677)
Background auto-imports fire regardless of what the user is viewing, but
getTaskDefaults inherited the active non-Today tag into the imported task,
stamping an unrelated tag that then synced to all devices (#8673).

Reachable via two paths, both fixed:
- issue-provider backlog polls (pollingMode 'always', now the Plainspace
  default) — and the classic 'whenProjectOpen' poll too, whose in-flight
  import can resolve after a context switch and read the live tag.
- calendar auto-import for the current day.

Every import through checkAndImportNewIssuesToBacklogForProject is an
automatic backlog poll targeting the provider's default project, so mark
them (and the calendar auto-import) with a new isAutoImport flag on
addTaskFromIssue and skip the contextTagIds inheritance for those. Naming
follows the existing isAutoImportForCurrentDay / isAutoAddToBacklog.
Foreground (user-initiated) imports are unchanged.

Closes #8673
2026-07-01 21:09:17 +02:00
Johannes Millan
e89306a630
feat(plainspace): create tasks directly in a Plainspace-backed project (#8676)
* feat(plainspace): create tasks directly in a Plainspace-backed project

Adding a top-level task to an SP project that has a bound PLAINSPACE issue
provider now creates the task in the Plainspace space so the team sees it,
symmetric with the existing auto-import. Wires into the generic
autoCreateIssueOnTaskAdd$ pipeline:

- PlainspaceApiService.createTask$ -> POST /api/integration/tasks { spaceId,
  title }; errors propagate so a failed add surfaces a snack.
- PlainspaceSyncAdapterService.createIssue links the returned SPTask id and
  seeds the two-way-sync baseline (no issueNumber, so no '#123' title prefix).
- _hasAutoCreateEnabled recognises the native PLAINSPACE key (no opt-in flag;
  the bound provider is the opt-in).

Requires a new PAT-authed server route POST /api/integration/tasks (documented
in docs/plainspace-api-extension-plan.md 4c); inert until that ships.

* fix(plainspace): only auto-create tasks once the provider is configured

Multi-review follow-ups to the create-task feature:

- _hasAutoCreateEnabled now requires a bound Plainspace provider (spaceId +
  token), not just isEnabled. selectEnabledIssueProviders filters on the flag
  only, so a mid-connect provider (spaceId/token still null) previously POSTed an
  invalid create and error-snacked on every task add; now it skips silently until
  configured. Adds a covering spec.
- Drop the unnecessary 'as unknown as' double cast in createIssue.
- Drop the unnecessary 'as any' on the valid 'PLAINSPACE' key in the effect spec.
2026-07-01 19:36:52 +02:00
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