mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
533 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
962c5bbeb1
|
feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
* feat(sync): conflict-journal foundation (observe-only)
Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.
- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
op-log SUP_OPS schema) + ConflictJournalService with record/query API
(unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
suspected; disjoint-merge reserved for the next subtask), capturing the
loser's field values verbatim. NOISE_FIELDS is limited to metadata
timestamps (modified/lastModified/created): the list-ordering arrays
carry membership as well as order, so an overlap on them is surfaced as
a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
built, wrapped in try/catch (record() swallows its own errors); clock-
corruption attribution uses a WeakSet side-channel tagged at detection.
The existing conflict-resolution suite stays 138/138 green, proving LWW
picks are unchanged. One-sided / sequential / EQUAL updates never become
conflicts and produce zero journal entries.
SPAP-13
* feat(sync): disjoint-field auto-merge for concurrent edits
When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.
In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.
Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.
No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.
SPAP-14
* feat(sync): sync conflicts review UI (banner, badge, page, flip)
Builds the conflict-review UI on top of the device-local conflict journal.
- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
RESOLVED snack at its emission sites; a persistent badge on the sync
icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
entity type with winner + reason chips, expandable per-field diff
(LOCAL vs REMOTE, device name + wall-clock time, winner marked),
per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
values (syncs like a user edit, no history rewind) and marks the entry
flipped; a stale-flip confirm appears (with the current value shown)
when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.
Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.
SPAP-15
* fix(sync): count disjoint-merge ops in localWinOpsCreated
autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.
Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).
Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.
Addresses review feedback on PR #8874.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(sync): address second-pass review (delete-lost, archive guard test, polish)
Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in
|
||
|
|
7733c4a7e5
|
feat(work-view): show break time today (#8909) | ||
|
|
b52087338e
|
feat(tasks): navigate from empty add-subtask input (#8916) | ||
|
|
ae158f8cd5
|
docs(development): add instructions for setting up local tests with Chromium (#8887)
* docs(wiki): document CHROME_BIN setup for unit tests The pre-push hook runs the Karma unit tests, which need a resolvable Chrome binary. Without a system Chrome or CHROME_BIN set, git push fails with "No binary for Chrome browser". Add a section covering both a system Chrome install and installing Chrome for Testing via @puppeteer/browsers with CHROME_BIN wired into the shell profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(wiki): note snap/flatpak caveats for the test browser Snap Chromium works but isn't auto-detected (set CHROME_BIN=/snap/bin/chromium); Flatpak is not recommended because karma-chrome-launcher execs the binary directly and the sandbox blocks Karma's temp profile dir. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Improve documentation around Chromium * add bash to codeblock * docs(wiki): fix Chrome-for-Testing install path and Chromium claims Address review feedback: - Option A: only real Google Chrome is auto-detected on Linux (karma-chrome-launcher probes google-chrome/-stable, not chromium); drop the overstated "finds it automatically" for Chromium. - Option B: pin `--path "$HOME/.cache/puppeteer"` — the standalone @puppeteer/browsers CLI defaults to the cwd, so the bare command downloaded chrome into ./chrome and left CHROME_BIN empty. - Note macOS differs (binary is "Google Chrome for Testing" in a .app, so `find -name chrome` is Linux-only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Human polish of instructions * final PR feedback --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c437dfc35a
|
chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
* chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] * update readme and remove-unused-log-imports.ts * restore benchmark test and make runnable |
||
|
|
5220684b7e
|
fix(app): hide donation page on macOS (#8915)
* fix(app): hide donation page on macOS Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds. * fix(app): harden donation platform gating Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation. |
||
|
|
6f1cacc553
|
chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
* chore(scripts): remove one-off codemod scripts [#8260 - Tier A] * Address PR feedback |
||
|
|
c6480d1cae
|
fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
* fix(sync): defense-in-depth vs entityId retarget on encrypted ops SuperSync E2EE (AES-256-GCM) covers only op.payload; metadata fields (entityId, opType, actionType, vectorClock, isPayloadEncrypted, ...) travel as plaintext and are not bound by the auth tag. A malicious/ compromised sync server or MITM could retag an encrypted LWW-update op with a different entityId, redirecting the authenticated changes onto an attacker-chosen entity — convertOpToAction() previously trusted the tampered entityId over the authenticated payload.id (coercing even a missing payload.id) and only warned. At the decrypt boundary (where encryption origin is known) verify that an in-scope LWW op's authenticated payload carries a string id equal to op.entityId; otherwise fail closed via a new OperationIntegrityError, distinct from DecryptError so it does not trigger the enter-password dialog. The gate mirrors convertOpToAction's predicate (alias resolution + singleton exclusion) so the two boundaries cannot drift. Scoped defense-in-depth for GHSA-8pxh-mgc7-gp3g, NOT full integrity. Still open (durable AAD-envelope fix): plaintext-injection downgrade via isPayloadEncrypted=false (needs a download-side mandatory-encryption guard), opType promotion, entityType swap, vectorClock replay. Correct the overstated integrity claim in the encryption architecture doc. * fix(sync): reject plaintext ops when SuperSync encryption is mandatory The isPayloadEncrypted flag is unauthenticated plaintext metadata, so a compromised SuperSync server or MITM could set it to false and inject a fully attacker-authored plaintext op — it would skip decryption AND the payload/metadata integrity check and be applied verbatim (arbitrary op forgery). This is a strictly more powerful bypass than the ciphertext entityId retarget closed previously. assertOpsEncryptedWhenExpected rejects any inbound plaintext op (download + piggyback paths) when encryption is enabled. It gates on config INTENT (isEncryptionMandatory && isEncryptionEnabled()), not key presence, so it also fails closed in the dropped-credential state (a !!encryptKey gate would fail open there). Safe with no legacy-data loss: enabling encryption deletes the server copy and re-uploads everything encrypted, so no legitimate plaintext op remains; a never-encrypted account (isEncryptionEnabled()===false) still accepts plaintext. The SuperSync op-level twin of the file-based GHSA-vrc7 download guard and the GHSA-9544 upload guard. Also give OperationIntegrityError a dedicated sync-wrapper branch: fail closed with a calm translated message instead of the raw GHSA/technical string. Follows up the review of GHSA-8pxh-mgc7-gp3g. |
||
|
|
0aa2941947
|
docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
Record the LocalFile check-then-write TOCTOU race as an accepted limitation and correct stale references in the reliability doc: - §5 documents the non-atomic rev-check + write as an accepted limitation, honestly scoping each mitigation: the upload lock only serializes a single client's own uploads (not across machines); the mismatch-fallback catches only a concurrent write visible at check time (never force-overwriting), so a write landing inside the check→write window can still be lost; .bak recovery is best-effort and only covers a corrupt/interrupted primary, not a valid concurrent overwrite or a fully-missing one. - Distinguishes torn writes (prevented on Electron via temp-file + renameSync; still in-place on Android SAF) from the CAS race (not closed by atomic rename; needs OS-level CAS, left accepted). - §1 corrected: _uploadWithRetry()/:474/"retries once" → current _uploadWithMismatchFallback with _MAX_UPLOAD_RETRIES=2 (3 attempts), and clarify that genuine concurrency throws immediately rather than exhausting retries. |
||
|
|
d5db11f329
|
fix(tasks): remove postal-mime dep and harden eml import (#8901)
* style(tasks): elevate add-subtask input * fix(tasks): parse eml files without external dependency Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy. * fix(tasks): isolate and harden eml import Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage. * fix(tasks): decode eml headers and harden import parsing - Decode RFC 2047 encoded-words in the subject and sender name so international titles show "Grüße" instead of raw "=?UTF-8?...?=". - Replace the charset regex with a quote-aware Content-Type parser so a charset inside another quoted parameter can't be mistaken for the real one, and ignore RFC 822 header comments (e.g. "7bit (comment)"). - Cap the synced title (300) and body (100k) so a crafted .eml can't amplify into an oversized op (the literal fence can double the body). - Document parseEml's intentional MIME omissions to prevent a later "fix" that reopens the untrusted-body attack surface. |
||
|
|
c68929d1fb
|
fix(task-repeat): allow selecting day-of-month recurrence (#8896)
* fix(task-repeat): allow selecting day-of-month mode * fix(task-repeat): clarify monthly recurrence pattern * test(task-repeat): cover day-of-month persistence * docs(task-repeat): explain custom monthly patterns |
||
|
|
762b3c5d8d
|
feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
* feat(plugins): add Todoist import plugin with Import/Export launcher * fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening * fix(plugins): clamp non-finite Todoist durations + review polish * feat(plugins): add Eisenhower priority mapping to Todoist import Replace the single "map priorities to p1-p3 tags" checkbox with one mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix). The new Eisenhower option reuses SP's built-in urgent/important tags by title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none), so imported tasks populate the Eisenhower Matrix board with no new tag and no new plugin API. Collapsing Todoist's single priority axis onto the 2-D matrix is opinionated, so it stays opt-in and off by default. Requested in the review of #8882. * fix(plugins): harden Todoist import data integrity * fix(plugins): improve Todoist import feedback |
||
|
|
18c48d5ea8
|
feat(tasks): add task through eml file hover (#8282)
* feat(eml): add EML file parsing and task creation on drop Currently a simple skeleton, needs more work, but should be a good POC before refining. Fixes #518 * fix(main-header): correct email message formatting and handle EML upload errors while this commit looks bad, its a first commit before refining this solution and sending the final PR. Fixes #518 * refactor(eml): move eml-parser to util and tighten isFileEml check * feat(eml): on eml hover error parse error to log and show a snack message * bugfix(eml-typecheck): tighten type for 'sender' to not have bugs * refactor(eml-drop): updated file locations for each file drop * bugfix(eml-drop): use stable npm package for parsing eml files * bugfix(eml-drop): remove relevant "dev" flag * refactor(eml-drop): drop empty emails with a snack instead of throwing * test(eml-drop): add isFileEml and parseEml unit tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * rafactor(eml-service): refactor code to be consistent as written in issue number 3 * bugfix(eml-drop): remove file name when geting an error as written in 1. inside the pr * docs: update task creation instructions to include .eml file support * refactor(eml-parser): update import statement for PostalMime to dynamic import * refactor(eml-drop): enhance file drop handling and improve error logging * test(eml-drop): add unit tests for EmlDropDirective and EmlDropService * feat(tasks): save eml body to task notes and harden eml parsing - capture the parsed plain-text email body into task notes (text/plain only, never HTML, to avoid injecting untrusted markup into markdown notes) - pass the File/Blob straight to postal-mime so it applies the message's own charset/transfer-encoding instead of forcing UTF-8 via file.text() - use relative import paths to match repo convention - document why the drop handler relies on the global dragover preventDefault - update wiki wording + tests for the new add() signature and notes behavior * fix(tasks): harden eml-to-task against untrusted subject/size Follow-up to the eml-drop feature from a multi-agent review: - suppress short-syntax on imported email subjects: email content is untrusted external input, but TaskService.add() dispatches addTask which ShortSyntaxEffects parses for #tag/@date/+project/URL tokens — stripping them from the intended literal "sender: subject" title and mutating tags/scheduling/attachments. Thread isIgnoreShortSyntax through add() (matching the existing plugin-bridge pattern) and set it here. - guard against oversized .eml files (postal-mime parses synchronously on the main thread and the body syncs to every device) with a 10MB cap + EML_TOO_LARGE snack. - trim sender/subject so whitespace-only headers don't bypass the empty check or block the address fallback. - drop the stale "add attachment" TODO. * fix(tasks): log bounded eml parse reason, not the raw error The source is untrusted email content and the log history is exportable (rule #9). Log only e.message (postal-mime's throw messages are structural, so no email content leaks) instead of the raw error object + stack. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Johannes Millan <johannes.millan@gmail.com> |
||
|
|
7f604f68f7
|
feat(task-repeat): default skip-overdue for new everyday recurring tasks (#8861)
* feat(task-repeat): default skip-overdue for new everyday recurring tasks
New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).
Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.
The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.
Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.
* docs(task-repeat): clarify custom every-day default; signpost baseline
Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
ON (it is the same schedule as the Daily preset); "off" now reads "custom
cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
creation default, so the model's `false` baseline is not mistaken for the
effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
gap applies to any new non-Daily config whose Advanced panel is opened
untouched, not only a Daily→switch.
* refactor(task-repeat): use type-only import in skip-overdue predicate
TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
|
||
|
|
05f6bd27d1
|
fix(op-log): serialize SQLite adapter transactions on the shared connection (#8849)
* fix(op-log): serialize SQLite adapter transactions on shared connection The SQLite op-log adapter issued raw BEGIN/COMMIT on the shared connection with no serialization. Once both stores share one SqliteDb (the staged native rollout), concurrent operations (capture append, archive write, compaction) would interleave BEGINs: SQLite has no nested transactions, so a second BEGIN throws and a bare statement issued mid-transaction silently joins — and rolls back with — the foreign transaction, corrupting op-log state. Funnel every adapter entry point through an internal FIFO queue so a transaction is exclusive on the connection for its whole BEGIN/COMMIT and no bare operation interleaves. Transaction-internal work runs directly on the connection (already holding the slot), so there is no re-entrancy. Document the mutual-exclusion invariant in the port contract and add a concurrent-transactions contract test that runs on both the in-memory fake and real sql.js. * docs: add complete architecture review report (2026-07-07) Whole-app architecture review synthesized from eight parallel subsystem reviews and an adversarial verification pass; findings filed as issues #8832-#8843, with duplicates cross-referenced rather than re-filed. * fix(op-log): key the SQLite transaction serializer to the connection Multi-agent review found the serializer was keyed to the adapter instance. The native rollout hands the op-log store and archive store two separate SqliteOpLogAdapter instances over one shared SqliteDb, so per-instance queues left an op-log BEGIN free to interleave with a concurrent archive BEGIN on the shared connection — the exact hazard the serializer targets. Key the FIFO queue to the connection (WeakMap<SqliteDb>) so every adapter over one SqliteDb shares one queue. Add a contract test that drives two adapters over one connection concurrently (verified red with per-instance keying, green with per-connection). Also: document the re-entrancy precondition as unenforced (a lint rule, not a runtime flag, is the right guard — a flag cannot distinguish a re-entrant call from a legal concurrent one) and correct init/getLastSeq/port-contract doc drift. |
||
|
|
3e56836a23 | docs(plans): add iOS home screen widget port plan | ||
|
|
a0fb90d0a8
|
docs: update styling breakpoint table (#8846) | ||
|
|
32e6a2a66d
|
docs: fix breakpoint names and values in styling guide table #8829 (#8845)
The breakpoint table in docs/styling-guide.md had four wrong values (xxs at 450px, s at 800px, m at 1000px, l at 1200px) and three wrong names (s instead of sm, m instead of md, l instead of lg) compared to the actual SCSS variables in _media-queries.scss and CSS custom properties in _css-variables.scss. Co-authored-by: Debian <x@zak.linux> |
||
|
|
994f0522ff
|
feat(tasks): redesign add-task-bar layout, toggles, note field & a11y (#8812)
* feat(tasks): move add-task-bar toggles into the actions row The add-to-top/bottom and search toggles used to overlay the input's top-right corner. They now sit at the start of the actions row (create mode) and the search info row (search mode), prepended so they scroll together with the rest of the row, separated by a dashed divider and sized to match the neighbouring action chips. A new ng-content slot on add-task-bar-actions lets the parent project the toggles into the scrollable row; they are still rendered in the search row when the action bar is not, so search stays toggleable. * feat(tasks): wrap long add-task-bar titles onto multiple lines The title field is now an auto-growing textarea (cdkTextareaAutosize) so a long title wraps into view instead of scrolling off the right edge, which is especially helpful on narrow screens. It stays single-line in meaning: Enter still submits and pasted newlines are collapsed to spaces so no line break ever reaches the task title. cdkTextareaAutosize sizes the textarea to rows*line-height and ignores its own padding, so the vertical breathing room lives on a wrapper element and the field grows a line at a time. matInput is dropped (there is no mat-form-field here and it broke the autosize height calc). E2E selectors that targeted the title by the `input` tag now use the tag-agnostic `.main-input` class. * feat(tasks): refine add-task-bar toggles, note field and input autosize - Move the note control from a labeled chip into an icon toggle in the left group (search · note · add-to-top/bottom); hidden in search mode. - Renumber the input shortcuts: Ctrl+1 add-to-bottom, Ctrl+2 search, Ctrl+3 note, Ctrl+4-9 the action chips. - Keep the submit (+) button's space reserved while empty via opacity (visibility is re-asserted by mat-icon's anti-FOUC rule, so it stayed visible). - Animate the note field expand/collapse with the shared @expandFade. - Fix a cdkTextareaAutosize height lag: the directive measures the textarea with its horizontal padding stripped but its width unchanged, so padding on the textarea made the measured wrap width wider than the rendered one and the field grew a few chars after the text had already wrapped (clipped line + scrollbar). Keep the title and note textareas padding-free and move the gutters to their wrappers so measured and rendered wrap widths match. * feat(tasks): improve add-task-bar a11y, shortcut order and toggle emphasis - Renumber the input shortcuts to match the on-screen left-to-right order: Ctrl+1 search, Ctrl+2 note, Ctrl+3 add-to-top/bottom (chips stay Ctrl+4-9); update the shortcut hints embedded in the tooltips accordingly. - Add aria-label to every icon-only button and aria-pressed to the search and note toggles so screen readers announce name and on/off state. - Take the invisible submit (+) out of the tab order (disabled while empty). - Guard the note toggle (and its Ctrl+2) to create mode; it is a no-op while searching, where the note field does not render. - Escape now dismisses an open task-suggestion list before closing the bar. - Un-dim the toggles (0.6 + :focus-visible so keyboard focus isn't faint) and give the submit + full-strength primary tint so the mobile CTA reads as the primary action. - Use the edit_note glyph for the note toggle (clearer than a chat bubble). * refactor(tasks): apply add-task-bar multi-review cleanups - De-duplicate the tooltip/aria-label expressions with @let (search, backlog, add-to-bottom) so the two attributes can never drift. - Split the Ctrl-shortcut map into local toggles (1-3) and action shortcuts (4-9) so stopPropagation derives from structure instead of a parallel key array that had to be kept in sync. - Restore the "note attached" cue: the note toggle carries .has-value (undimmed + tinted) when a collapsed note still holds text, distinct from the .active pill shown while the note field is open. - Remove dead code: the vestigial switch-add-to-btn class on the submit button, the unused .search-input rule, a stale duplicated SCSS comment, and the orphaned NOTE_BUTTON translation key. - Document that the projected .inline-action-controls styles must stay in the parent stylesheet (ng-content keeps the parent's encapsulation). * feat(tasks): use notes bubble icon and restore expanded note draft Match the notes icon used across the task feature (task list item, detail panel, archived-task dialog) instead of the one-off edit_note glyph. The glyph flips outline -> filled once the note field has text, mirroring the detail panel's empty / has-notes states. Also start the note field expanded when reopening the bar with a persisted draft note, so it is visible rather than hidden behind the collapsed toggle. * fix(tasks): repair startup-overlay selector for add-task-bar textarea The add-task-bar title field became a <textarea class="main-input">, but StartupOverlayService still queried 'add-task-bar.global input'. A CSS input type selector does not match a textarea, so on Android cold-start the partial- text handoff (cursor position + focus) never ran and the overlay cleared only via the 3s safety timeout. Target .main-input and retype to HTMLTextAreaElement. Also address multi-review nits on the same branch: - guard expandNote() in search mode so Ctrl+Enter cannot strand isNoteExpanded (mirrors toggleNote()), with a spec covering it - drop the unused fadeAnimation import and animations entry - type the inputEl viewChild as ElementRef<HTMLTextAreaElement> (matches noteEl) - repoint WorkViewPage.addBtn to .e2e-add-task-submit (.switch-add-to-btn now belongs to the add-to-backlog toggle) * test(tasks): clear add-task-bar sessionStorage between component specs * docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping |
||
|
|
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> |
||
|
|
1fde5cf664 | docs(security): revise secure-storage plan after multi-agent review | ||
|
|
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 |
||
|
|
421dc353e8 | docs(sync): add sync-engine extraction plan | ||
|
|
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). |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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> |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
280c2c1c52
|
fix(caldav): route WebDAV verbs through native HTTP on Android (#8558) (#8577)
CalDAV calendar discovery (PROPFIND) and event search (REPORT) failed on the Android (Capacitor) and web apps while working on Mac and iPad. Root causes: - Android: Capacitor's CapacitorHttp routes non-GET/HEAD/OPTIONS/TRACE requests through Java's HttpURLConnection, which throws ProtocolException for WebDAV/CalDAV verbs (PROPFIND, REPORT, ...). Affects every CalDAV calendar user on Android, not just iCloud. - Web: pure CORS — iCloud's CalDAV server sends no Access-Control-Allow-Origin header, so the browser blocks the request. Unfixable client-side. Fix (Android/native): on native platforms, route the WebDAV/CalDAV verbs through the existing OkHttp/URLSession-backed native HTTP executor (the same WebDavHttp plugin the WebDAV sync feature already uses), which accepts arbitrary method strings. Standard verbs (GET/POST/PUT/DELETE/...) keep the HttpClient path unchanged. The native path preserves SSRF validation, auth headers, and HttpClient's non-2xx error shape (.status) so plugin retry/404 checks behave identically. The rerouted set is scoped to the verbs the CalDAV plugin uses (PROPFIND, REPORT) and stays within what the native WebDavHttp plugin implements. Web is unchanged and still limited by CORS for servers (like iCloud) that don't send CORS headers; documented in 3.05-Web-App-vs-Desktop.md. The reroute is gated by injectable tokens (PLUGIN_HTTP_IS_NATIVE, PLUGIN_HTTP_NATIVE_EXECUTOR) for testability. |
||
|
|
87846ad83d
|
fix(android): keyboard + status-bar follow-ups for edge-to-edge (#8508) (#8548)
* fix(android): size fullscreen markdown/notes dialog above the keyboard (#8508) The fullscreen markdown editor (project & task notes) is position:fixed; height:100% but its keyboard rule subtracted --keyboard-overlay-offset, which is set only on iOS. On Android the rule was a no-op, so with the IME open the dialog kept full height (content behind the keyboard) or inherited the squashed sliver, leaving the toolbar + textarea + Close/Save mashed to the top. Use the resize-detecting --keyboard-height for the Android/mobile-web case (0 once the window resized, the obscured amount otherwise), mirroring the add-task bar and .app-container; keep the iOS --keyboard-overlay-offset path as a second, source-order-later rule (equal specificity, wins on iOS). On a device that resizes (--keyboard-height == 0) it is identical to the old rule, so it is never worse than before; it fixes the API >= 30 no-resize/VisualViewport-shrink case and composes with the SDK < 30 native fix (#8528). * fix(android): inset the header below the status bar on API < 30 (#8508) Under enforced edge-to-edge (targetSdk 36) the WebView extends under the status bar, but on the API < 30 WebView `env(safe-area-inset-top)` resolves to 0 (old WebViews map only display cutouts into safe-area insets, not the status bar), so `--safe-area-top` was 0 and the web header overlapped the status bar on Android 9. The web side cannot tell "edge-to-edge under the status bar" from "already natively inset" (env() is 0 in both), so a web-only fallback would double-count. Measure the overlap natively instead: in the existing keyboard layout listener (gated SDK < 30) compute max(0, rect.top - webViewTopOnScreen) — visible-frame top (status-bar height, reliable on API 28) minus the WebView's on-screen top (0 when edge-to-edge, == status-bar height once inset) — and publish it as the `--android-status-bar-overlap` CSS var (physical px -> CSS px, deduped). The web folds it in with max(env(safe-area-inset-top, 0px), var(--android-status-bar- overlap, 0px)): never a sum so it can't double-count, and on API >= 30 the var is never set so `max(env, 0) = env` keeps the verified behavior. JS readers still parse the token to 0, preserving the #8283 overlay scoping. Needs on-device validation on API < 30 and a no-op check on API >= 30. * fix(android): stop double-counting safe-area-top in note dialog height (#8508) The fullscreen note/markdown dialog subtracted --safe-area-top from its keyboard-open height while ALSO applying padding-top: --safe-area-top. Since :host is border-box (global * { box-sizing: border-box }), the padding is already inside height: 100%, so subtracting --safe-area-top again double-removed the top inset and left a --safe-area-top-sized gap between the Close/Save controls and the keyboard. Invisible while --safe-area-top was 0 on API < 30; it surfaced once the status-bar fix made it non-zero (and was latent on API >= 30 where env() already gave a non-zero value). Drop the - --safe-area-top term from the Android rule so the controls sit flush above the keyboard while the toolbar stays below the status bar. iOS keeps its own override (different keyboard runtime, unverified on device) and is flagged for separate checking. * fix(android): re-publish status-bar overlap after web reload (#8508) Review follow-ups to the #8508 keyboard/status-bar fixes: - The native --android-status-bar-overlap lives only as an inline style on the document, so a web-side window.location.reload() (language change, PWA update, sync-conflict recovery) wipes it while the dedupe field survives on the Activity -> the unchanged value is skipped and the header overlaps the status bar again on the WebView < 140 / API < 30 tail. Reset lastStatusBarOverlapCssPx in flushPendingShareIntent() (runs on every frontend (re)load) so the next layout pass re-publishes it. - Dialog keyboard rule: scope the Android rule with :not(.isIOS). iOS carries both isNativeMobile and isIOS and sets --keyboard-height non-zero, so the two equal-specificity rules both matched and iOS correctness depended on source order; they are now mutually exclusive and order-independent. - Declare --android-status-bar-overlap: 0px in :root for discoverability, alongside the other dynamic vars. |
||
|
|
431bc50da9
|
fix(sync): help Nextcloud users find their user ID + auto-detect it (#7617) (#8547)
* docs(sync): point Nextcloud user-ID lookup at the WebDAV URL (#7617) The field hint, the 404 test-connection message, and the wiki all told users to find their user ID under 'Settings -> Personal info' / 'your Files URL'. Both are unreliable: Personal info does not clearly surface the uid, and a folder's address bar shows a folder ID, not the user ID (reporter followed it and got a folder ID). Redirect all three to the authoritative source: Files -> settings gear (bottom-left) -> the WebDAV URL '.../remote.php/dav/files/<user-id>/'. * feat(sync): auto-detect Nextcloud user ID via OCS (#7617) The Nextcloud WebDAV files path needs the account's internal user ID, which differs from the email/login name people enter and is awkward to find by hand — the root cause of the recurring '404 / connection test failed' reports. Add a 'Detect user ID' button to the Nextcloud sync config that asks the server for it. - packages/sync-providers: discoverNextcloudUserId() calls the OCS endpoint /ocs/v2.php/cloud/user (OCS-APIRequest header) authenticated with login + app password and returns ocs.data.id. A 401 reports bad credentials (cleanly distinct from the 404 wrong-user-id path); a 200 that isn't an OCS payload reports 'not a Nextcloud/OCS server'. A missing/wrong URL scheme is refused up front (matches the provider's own _cfgOrError check) so credentials never hit a schemeless host. - Dialog: button fills the Username field with the detected ID. To keep auth working, if 'Login name' was empty and the user had typed their login into 'Username', that login is preserved into 'Login name' before Username is overwritten with the ID. Result handling split into _applyDetectedUserIdResult for unit-testability. - Additive and optional: generic WebDAV and the save/sync path are untouched, no synced data shapes change. - Tests: 7 package specs (success, trailing-slash, login fallback, 401, non-OCS 200, missing id, scheme guard) + 6 dialog specs (login guard, fill+confirm, 3 login-preservation cases, failure). |
||
|
|
0a0723521b
|
feat(azure-devops): add optional WIQL override for auto backlog import (#8516)
* feat(azure-devops): add optional WIQL override for auto backlog import Adds an optional autoImportWiql config field to the Azure DevOps issue provider plugin. When set, it fully replaces the generated auto-import backlog query, so the user controls scope, state filtering and ordering (e.g. to filter by iteration path, area path or work item type, or to match custom done-state names). When empty, behavior is unchanged from today's scope-based query, so it is fully backward compatible. This mirrors the Jira provider's autoAddBacklogJqlQuery (a full query the user owns) rather than appending a fragment, which can only narrow the hard-coded English done-state exclusion and cannot accept a real exported WIQL Select..From..Where statement. Adds a vitest suite covering the default scopes, quote escaping, the verbatim override and the blank-fallback, wires the plugin into the plugin-tests CI matrix, and updates the issue integration comparison wiki. Closes #7674 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DY1ESWymmU9x9ykLNaRHGH * test(azure-devops): use node test env, drop unneeded jsdom dependency The plugin tests exercise no DOM API, so vitest's default node environment is sufficient (matching the clickup and google-calendar provider plugins). Removing jsdom prunes ~530 lines of transitive devDependencies from the lockfile. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Johannes Millan <johannes.millan@gmail.com> |
||
|
|
bb3ed74480
|
feat(android): migrate edge-to-edge to built-in SystemBars (#8543)
* feat(android): migrate edge-to-edge to built-in SystemBars Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35 tail is covered by env() fallback plus a native keyboard shim (adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does not fight SystemBars' own IME padding. - config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the plugin from the generated gradle registration - theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them; the SCSS env() fallback preserves the #8283 top fix); bars are now transparent with the theme color painted behind them via setWebViewBackgroundColor, since SystemBars has no bar-color API - StartupOverlayManager: derive the overlay inset from the system-bar insets instead of the removed plugin's WebView margin Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device behavior NOT yet validated (gradle unavailable in this environment). Requires `npm install` + `npx cap sync android` and the device matrix in docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge. * build(deps): drop @capawesome edge-to-edge from lockfile Reconcile package-lock.json with the package.json change in the edge-to-edge -> SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge- support entry). Generated by `npm install`. * docs(android): clarify SystemBars band model from implementation review Comment-only + plan-doc follow-up to the multi-agent review of the migration: - Correct the band model in the inset comments: SystemBars *injects* --safe-area-inset-* only on API >= 35; WebView >= 140 is native env() passthrough (no injection below API 35). The SCSS var(..., env()) fallback covers every band. (capacitor.config.ts, global-theme.service.ts) - Fix a stale comment in StartupOverlayManager.show() that still described the removed @capawesome plugin insetting the WebView. - Record the device-validation-only findings in the plan doc (API>=35/WebView<140 double-count corner, env vs var consumer split, API 30-34/WebView<140 IME owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed (a blind shim extension would risk re-creating #8508). No behavior change. |
||
|
|
20e85a2a19
|
refactor(tasks): restructure reminder dialog footer into primary + overflow (#8517)
* refactor(tasks): restructure reminder dialog footer into primary + overflow The footer showed up to four visually identical stroked buttons (Snooze, Done, Add to Today, Start) with no hierarchy. Reduce to a single filled primary action plus a Snooze menu and an overflow (kebab) menu: - Primary: Start (single) / Schedule for today (multiple) - Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow) - New overflow menu holds the rest (Done/Complete, Add to Today, edit, unschedule, dismiss-keep-today) All existing actions remain available; only their grouping changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): add dropdown caret to reminder snooze button The Snooze button opens a menu but had no visible affordance signalling that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd slot) so the menu trigger reads as such, surfaced by the review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * test(reminders): open overflow menu to reach Done in deadline specs The reminder dialog's "Done" action moved from a footer button into the new overflow ("More actions") menu, so the two deadline e2e specs must open that menu before clicking Done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): refine reminder footer hierarchy after UX review Follow-up to the footer restructure, addressing review findings: - Surface a one-tap "Done" (check) icon for single reminders, so the most common reminder response isn't two taps deep in the overflow menu. Left out for the multi-task case, where a one-tap complete-all is a footgun and per-row checks already exist. - Make the primary action deadline-aware: deadline reminders now default to the safe "Add to Today" instead of "Start" (which sets the current task and reorders Today as a side effect). Start moves into the overflow for deadlines; scheduled reminders keep Start as primary. - Move "Edit (reschedule)" into the Snooze ("when") menu so all time-deferral actions are grouped and the overflow holds pure dispositions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * test(reminders): click one-tap Done icon in deadline specs The single-task reminder footer now surfaces "Done" as a one-tap icon button (aria-label "Mark as done"), so the deadline specs click it directly instead of opening the overflow menu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * refactor(tasks): put primary reminder action on the right, Done in menu Per UX feedback: - Reverse the footer button order so the filled primary action is always on the right (overflow on the left, Snooze in the middle). - Move "Done" back into the overflow menu instead of surfacing it as a standalone icon button. Update the deadline e2e specs to open the overflow menu before clicking Done accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * feat(tasks): allow dismissing reminder dialog + snooze split-button Three reminder-dialog improvements: - Click-away to dismiss: backdrop click / Escape now close the dialog and dismiss the reminders (clearing the reminder while keeping the task and its schedule). disableClose is kept and the events are handled in the dialog, so the worker does not immediately re-open it in a loop. - Snooze split-button: a single click snoozes 10 min (the common default, previously a hidden double-click) and a joined dropdown caret opens the full options menu. Uses a new shared .g-split-btn style. - Overflow button: replace the lone vertical "three dots" icon with a low-emphasis labeled "More" button, giving a clean emphasis ramp (More < Snooze < primary) with the primary kept on the right. Updates the deadline e2e specs and wiki for the new dismiss behavior, and adds unit tests for the backdrop/Escape dismissal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): make reminder dialog close on backdrop/Escape Per product decision, clicking the backdrop or pressing Escape now simply closes the reminder dialog (drop disableClose) rather than dismissing the reminders. Deadline reminders are still cleared on close (avoiding the 10s re-fire loop); scheduled reminders stay active and the worker re-shows them until the user acts on them. Reverts the earlier intercept-and-dismiss approach (and its unit tests); updates the wiki accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * feat(tasks): add split-button UI component, restructure reminder footer Add a reusable <split-button> UI component (src/app/ui/split-button): a primary default action joined flush to a compact, centered overflow trigger that opens a passed-in menu. The joined treatment lives in the shared style layer (styles/components/split-button.scss) and now renders with no gap between the two halves and a properly centered trigger icon. Use it in the reminder dialog footer: limit the footer to two actions — "Snooze 10m" (showing the snooze duration) and the primary CTA (Start / Add to Today) — and fold the former "More actions" and snooze-options menus into a single overflow menu opened by the icon button right of snooze. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): fuse split-button halves and update reminder dialog test The split-button trigger was pushed 8px away from the default action by Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base + .mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the joining `margin-left: -1px`. Match Material's selector hooks so the negative margin wins and the two halves render flush inside dialogs. Also drop the stale `disableClose: true` assertion from the reminder module spec to match the now-dismissable dialog (fixes failing CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): clarify reminder dialog "add to today" button label The multi-task primary button said "Schedule for today", which is both long and misleading: those tasks are already scheduled — the action drops their time and keeps them on Today (all day), or for deadlines adds them to Today while keeping the deadline date. Key the label on deadline vs schedule instead of single vs multiple: - all deadlines -> "Add to Today" (added to today, deadline preserved) - scheduled/mixed -> "Today" (already scheduled; kept on today, all day) Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for the per-row tooltips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * fix(tasks): distinguish deadline vs scheduled in reminder today-button After weighing options, label the reminder dialog's primary today-action by what it actually does in each case: - all deadlines -> "Add to Today" (task is added to Today; deadline kept) - scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all day, with the specific time dropped) This replaces the bare, verb-less "Today" with accurate wording consistent with the dialog's existing "keep in Today" vocabulary. Adds the KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE * chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS, which was a dead key from an earlier iteration. Remove it and regenerate t.const.ts through prettier so the file is back to the formatted form (the prior commit had committed raw generator output, churning ~760 lines). * refactor(ui): trim unused split-button inputs, add unit spec The split-button's only consumer never overrides color or triggerIcon, so drop both inputs (and the deprecated ThemePalette type) and inline the defaults. Add a unit spec covering content projection, mainClick, menu wiring, disabled state, and the trigger aria-label. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
ad149e138c
|
docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause (#8528)
* docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause After #8508 (patch removed in 18.12.0) a user on Android 9 / API 28 reports the global add-task bar sitting behind the soft keyboard — the device class open item #4 predicted. Capture the precise root cause (edge-to-edge forces no system resize; Type.ime() unreliable < API 30; old WebView VisualViewport doesn't shrink, so --keyboard-height stays 0) and why a web-side height fallback is the reverted #8295 trap (obscured ≈ 0 in both the resized and non-resized cases). Record the correct resize-detecting native fix recipe and the device-matrix gate it needs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs * fix(android): lift add-task bar above keyboard on API < 30 (#8508 follow-up) On Android 9 / API 28 under enforced edge-to-edge (targetSdk 36) the system does not resize the window for the IME and WindowInsetsCompat.Type.ime() is unreliable, so the edge-to-edge plugin never insets the WebView and neither the window nor VisualViewport shrinks. --keyboard-height stays 0 and the position:fixed add-task bar sits behind the keyboard. Add a resize-detecting native inset in CapacitorMainActivity, driven from the existing keyboard OnGlobalLayoutListener: measure the keyboard via getWindowVisibleDisplayFrame (reliable on every API level) and, only when the WebView actually extends behind the keyboard, lift it by the exact overlap via bottomMargin (matching the plugin), self-correcting as the keyboard height changes and restoring the captured margin on hide. Gated Build.VERSION.SDK_INT < 30 so it is a strict no-op on every device #8508/18.12.0 verified. Web-side height fallback was rejected: obscured = innerHeight - vvHeight is ~0 in both the resized and non-resized cases, so disambiguating requires the baseline-innerHeight + max(obscured, nativeKb - layoutShrink) math that was reverted as #8295. Native has the unambiguous geometry. Needs on-device validation across the matrix in docs/android-edge-to-edge-keyboard.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs * fix(android): harden SDK < 30 keyboard inset after review Address findings from a multi-agent review of adjustWebViewForKeyboardBelowApi30: - Bound the feedback loop: clamp appliedKeyboardInsetPx to [0, keypadHeight] (the lift never needs to exceed the keyboard height) and skip redundant layoutParams writes, so a WebView height that doesn't respond to the margin (e.g. the edge-to-edge plugin rewriting it) can't drive an endless requestLayout loop. - Ignore stale/pre-layout geometry while the keyboard is open (webView.height == 0) so an already-applied inset isn't collapsed to 0 for a frame; only the genuine keyboard-closed path restores the margin. - Make the dead-band density-relative (8dp) instead of a fixed 16px so it's visually constant across densities. The reviews confirmed the central risk is absent: shrinking the WebView via bottomMargin shrinks both innerHeight and visualViewport.height, so the web side's obscured stays under its 100px floor and does not add a second lift (no #8295 double-count). Still pending on-device validation per the docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs * fix(android): resize WebView via height for SDK<30 keyboard inset On API < 30 under enforced edge-to-edge the system does not resize the window for the IME, so the add-task bar sat behind the keyboard (#8508 follow-up, Android 9 / API 28). The edge-to-edge plugin owns webView.bottomMargin and rewrites it to 0 on every inset dispatch while the IME is visible, so correcting the margin from a second writer flickers; WebView padding does not move the web layout viewport; and fully replacing the plugin's inset listener stops it re-painting its color overlays (white navbar gap). Instead set an explicit WebView layout height to the keyboard top while the IME is up and restore the resting height on hide. Height is a different property than the margin the plugin manages and, for an explicit-height view, the margin does not change the view size — so the two never fight and the plugin keeps doing everything else (insets + color overlays). Gated SDK_INT < 30, so API >= 30 is untouched. Also documents the root cause, the approaches that failed, and the upstream status, plus a handover/plan for migrating to Capacitor's built-in SystemBars. * fix(android): guard zero-height + reuse loc buffer in keyboard inset Multi-review follow-ups for the SDK<30 keyboard WebView-height workaround: - Skip the write when the computed keyboard-top height is <= 0 instead of coercing to 0. A 0 would collapse the WebView, and the existing height==0 guard would then latch and stop recomputing until the keyboard hides. - Reuse a single IntArray for getLocationOnScreen instead of allocating one on every layout pass while the IME is up (hot path). - Document the implicit coupling with StartupOverlayManager: both read/ mutate webView.height from layout listeners and only coexist because the overlay early-returns once its input bar is visible. Cross-referenced in both files so the guard isn't removed later. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
ee47891f67 |
Merge branch 'feat/issue-8508-e897da'
* feat/issue-8508-e897da: fix(android): remove edge-to-edge WebView IME-inset patch (#8508) fix(android): correct edge-to-edge IME inset (gate API36+, latch, guard) (#8508) fix(plainspace): don't tint claim-list link icon with accent color |
||
|
|
c247bc541a |
fix(android): remove edge-to-edge WebView IME-inset patch (#8508)
The v18.11.0 patch (
|
||
|
|
2a0cc73507 |
fix(android): correct edge-to-edge IME inset (gate API36+, latch, guard) (#8508)
The v18.11.0 patch always inset the WebView for the IME, which shipped two bugs: 1. double-count on API <=35 (the system still resizes for the IME there - pre- enforcement, and we opt out on API 35 via values-v35) -> a keyboard-height blank gap above the keyboard; 2. the inset is reapplied on every OnApplyWindowInsetsListener callback via an unconditional setLayoutParams(); the IME inset fluctuates during typing (suggestion strip, layout switches), so the WebView relayouts mid-composition -> the IME composing region resets -> reversed/invisible characters. Corrected patch (regenerated via patch-package): - gate the inset to API 36+ where edge-to-edge is actually enforced (no system resize) -> no double-count, no gap on API <=35; - latch the keyboard inset while the keyboard stays visible -> no relayout on inset fluctuations during composition; - skip setLayoutParams when no margin changed. Verified: white gap gone on API 34 emulator. API 36+ typing-reversal half still needs real-device confirmation (not reproducible headless / on old emulators). Doc updated; do-not-naively-reapply guidance retained. |
||
|
|
9faf0a50d5
|
fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617) (#8513)
* fix(plainspace): don't tint claim-list link icon with accent color * fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617) A base-root 404 in the WebDAV connection test means auth succeeded but the DAV path /remote.php/dav/files/<userName>/ does not exist — i.e. the "Username" holds an email/display name instead of the account's user ID (Nextcloud accepts email/login for auth but the files path needs the uid). Previously this surfaced as a bare scrubbed hostname in the snack, which users misread as a stripped URL. Now: - WebdavApi.testConnection maps thrown errors to a readable, privacy-safe message plus an HTTP errorCode discriminator (404/401/status). - The Nextcloud "Test Connection" path shows a specific hint on 404 explaining that "Username" must be the user ID, not email/display name. - Wiki + field guidance clarified accordingly. |
||
|
|
8171bb05d0
|
refactor(sync): remove unused error classes, dialog, and constructor-time logging (phase 1, #8325) (#8510)
* fix(op-log): lock snapshot save to prevent lost-update window saveCurrentStateAsSnapshot() read NgRx state then lastSeq without holding OPERATION_LOG lock. An op appended between the two reads would get seq <= lastAppliedOpSeq but its effect would be absent from the snapshot. On next hydration the tail replay would start after that seq, silently skipping the op forever. Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and read lastSeq BEFORE state snapshot so the worst interleaving degrades to harmless re-replay (idempotent) rather than a missed op. Fixes #8308 * fix(op-log): address review feedback on snapshot lock PR - Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay - Add inline note about compaction's opposite read order and worse failure mode - Add lock regression tests (#8308): lock acquired, read order, error handling Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sync): remove unused error classes, dialog, and constructor-time logging Phase 1 of #8325: clean up orphaned sync error types and their UI. Removed error classes that are no longer thrown anywhere: - NoEtagAPIError, FileExistsAPIError (unused API errors) - RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow) - RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile - LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError Removed DialogSyncErrorComponent and all references in SyncWrapperService (_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog, _openSyncErrorDialog, _extractModelIdFromError). Removed constructor-time logging from JsonParseError, ModelValidationError, DataValidationFailedError — these errors are logged at the catch site; redundant construction-time logs risk leaking user data. Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR, ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts. Updated file-based-sync-flowchart.md to reflect the removed error types. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0b4bc79354
|
refactor: retire GET /api/sync/snapshot, re-scope GET /api/sync/status as diagnostic (#8496)
Retire GET /api/sync/snapshot (attack-surface reduction): - Remove route handler from sync.routes.ts (no production client caller) - Keep internal generateSnapshot() and all downstream code - Update tests: delete GET /snapshot describe block, rework isolation test (sync.routes.spec.ts), remove GET assertion (sync-fixes.spec.ts), remove SimulatedClient.getSnapshot() helper + rework 2 integration tests to use GET /api/sync/ops instead (multi-client-sync.integration.spec.ts) Re-scope GET /api/sync/status as diagnostic: - Add doc comment to route handler marking it diagnostic - Update all documentation (README, API wiki, architecture diagrams) Documentation updates across 5 files remove GET /snapshot references and label GET /status as diagnostic. Breaking: self-hosters with external tooling relying on GET /snapshot must migrate — no shipped client version ever called this endpoint. |
||
|
|
cbe3f1527f
|
fix(caldav): support Vikunja project discovery (#8481) |