* feat(plugin): add generic request capability to PluginAPI
* feat(plugin): gate PluginAPI.request behind manifest allowedHosts
PluginAPI.request is otherwise reachable to any host the shared SSRF
filter does not block. Add a manifest-declared, host-enforced allowlist
so a plugin's outbound reach is explicit and reviewable at install.
- PluginManifest.allowedHosts: exact hostnames the plugin may reach.
- PluginBridgeService enforces it before the shared HTTP/SSRF layer:
exact host, case-insensitive, trailing-dot tolerant, port-agnostic;
userinfo tricks resolved via URL parsing; fail-closed (empty/omitted
allowedHosts disables request entirely).
- validatePluginManifest rejects malformed allowedHosts at install.
Companion tests: bridge enforcement (reject non-declared host,
fail-closed on undefined/empty, case-insensitive + trailing-dot,
userinfo-trick blocked) and manifest validation.
* style(plugin): fix prettier formatting flagged by CI in request API
The generic-request commit slipped past checkFile on three files; CI
prettier flagged the multi-line request() signature and a long spec
assertion string. Formatting only, no behavior change.
* feat(plugin): require "http" permission for PluginAPI.request
Network egress becomes an explicit, opt-in capability (like nodeExecution):
request now requires "http" in the manifest permissions in addition to a
matching allowedHosts entry. Missing either is fail-closed. Enforced
host-side (before the shared SSRF layer) on both the iframe and
Function-sandbox paths; the "http" permission gets a human-readable line in
the plugin security info.
Tests: fail-closed without the "http" permission.
* feat(plugin): surface plugin network reach in the plugin-management UI
Render the manifest allowedHosts as a chip-set beside permissions/hooks
(with a count in the collapsible title), so a plugin's outbound network
reach is reviewable in-app instead of only in the raw manifest.
Tests: allowedHosts shown in the collapsible title, omitted when none.
* feat(plugin): block redirects on PluginAPI.request (SSRF-via-redirect)
Only the initial request URL is allowlist/SSRF-checked, but HttpClient/XHR
auto-follows 3xx — so a declared host could 302 to a private/metadata IP and
return internal content to the plugin. Execute the request path via fetch with
redirect:"error" so any redirect is refused instead of chased.
- New PluginHttpHelperOpts.blockRedirects (default false); PluginBridge sets it
for request. Issue-provider HTTP is untouched (still HttpClient).
- fetch executor preserves the HttpClient contract callers depend on: query
params, timeout via AbortController (covering the body read, not just headers),
text/json parsing, and non-2xx rejecting with .status + .error.
- Only plain objects/arrays are JSON-serialized; FormData/Blob/URLSearchParams/
ArrayBuffer(View) pass through, and Content-Type: application/json is added
only for the JSON case (HttpClient parity).
- Web/desktop only: on native, Capacitor patches fetch and ignores
redirect/signal, so native falls back to HttpClient (documented limitation).
- Blocks all redirects incl. benign same-host ones; manual per-hop re-validation
is not possible on the web (cross-origin Location is opaque). Documented.
Tests: redirect refused, .status/.error parity, SSRF still pre-checked,
body pass-through, params + responseType:text, real-timer timeout, and native
falls back to HttpClient.
* fix(plugin): gate allowedHosts UI on the "http" capability
The plugin-management panel showed a "Network access" section (and title count)
whenever allowedHosts was non-empty, regardless of permissions — advertising
reach for a capability the bridge rejects without the "http" permission. Gate the
section, the chip loop, and the title count on a getNetworkReachHosts() helper
(hosts only when permissions includes "http"). Addresses review on #8721.
* docs(plugin): note _requestNoRedirect bypasses NetworkRetryInterceptorService
The fetch path skips Angular HTTP_INTERCEPTORS, so PluginAPI.request GETs lose the
single status-0 retry the HttpClient paths keep. Inherent to redirect:"error"
(XHR/HttpClient cannot block redirects). Flagged in review on #8721.
* feat(plugins): enable OAuth-based issue-provider plugins
Generic, provider-agnostic plugin-framework hooks so an issue-provider plugin
that needs an exact OAuth redirect can work without any built-in code:
- OAuthFlowConfig.redirectUri: a plugin may declare an exact pre-registered
callback; the host uses it for both the authorize request and token exchange.
- The Electron loopback honors a plugin-requested fixed port and rejects with a
clear message when that port is already in use.
- Apply user-supplied clientId/clientSecret/redirectUri overrides onto a
plugin's oauthConfig (bring-your-own OAuth app).
- Fix: merging a partial pluginConfig update no longer drops omitted keys and
deep-merges nested objects (e.g. twoWaySync).
Split out of the Basecamp community-plugin work per PR #8507 feedback; contains
no provider-specific code.
* fix(plugins): address #8546 review
- validate OAuthFlowConfig.redirectUri per platform (loopback / same-origin /
app scheme) and fail fast instead of hanging; restrict desktop loopback to 127.0.0.1
- warn when a client secret is dropped on web/native (bring-your-own credentials)
- validate the IPC loopback port to [1024,65535], register the error handler before
listen(), and close the failed server
- merge pluginConfig via generic recursion instead of a hardcoded twoWaySync case
- nits: named token-store imports; fix stale prepareRedirectUri comment
- tests: redirectUri validation, the web client-secret warning, and generic merge
* fix(plugins): address #8546 round-2 review
- loopback error handler calls cleanupServer() so a post-listen runtime error
doesn't leave the server ref / 5-min timer dangling
- share OAUTH_LOOPBACK_PORT_{MIN,MAX} between the renderer and Electron main so
the bounds never drift; reject out-of-range (incl. 0/80/443) redirect ports early
- drop _getElectronLoopbackPort and parse the already-validated redirectUri once
- document that a bring-your-own clientSecret syncs via pluginConfig (override boundary)
- skip __proto__/constructor/prototype keys in the pluginConfig merge (defense-in-depth)
- test: prototype-pollution guard
* fix(plugins): address #8546 round-3 review
- reject native redirectUri overrides outright (closes CodeQL
js/incomplete-url-scheme-check) via a pure, per-platform validateOAuthRedirectUri
util (electron loopback / native reject / web same-origin)
- gate bring-your-own OAuth credentials to the desktop loopback flow and warn
(instead of silently dropping clientId) when set on web/native
- namespace BYO under pluginConfig.oauthOverrides (was flat keys); document the
convention on OAuthFlowConfig (public plugin API)
- shallow top-level pluginConfig merge: drop the deep recursion + proto guard;
nested objects (e.g. twoWaySync) are replaced wholesale, matching callers
- pin the web redirect to /assets/oauth-callback.html via a shared constant so a
same-origin wrong-path URI fails fast; note the desktop 127.0.0.1-only rule
- companion tests for each
* fix(plugins): strip desktop redirectUri on web/native OAuth flows
A plugin-declared redirectUri is the desktop loopback override; keeping it on
the web/native branches made a web/native-capable plugin throw at connect time
(the loopback URI fails web/native redirectUri validation). Strip it on those
branches so prepareRedirectUri falls through to the platform default. Document
redirectUri as desktop-only in the plugin API and fix a misleading test name.
* refactor(plugins): extract resolveEffectiveOAuthConfig and harden native fallthrough
Move the platform client/secret/redirectUri selection out of the bridge into a
pure, parameterized util so every branch is unit-testable (the IS_* platform
consts are module-level and cannot be mocked in karma). Also strip clientSecret
and redirectUri on the native fall-through — a native platform where the plugin
ships no matching client id — keeping both strictly desktop-only.
Optional hardening on top of the redirectUri fix; safe to drop independently.
* 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>
* feat(sync): show actionable error on persistent WebDAV 409
When a WebDAV PUT keeps returning 409 Conflict even after the parent
collection is created, the Base URL / Sync Folder Path is misconfigured
(the classic Synology / raw-WebDAV setup mistake). Previously the raw
"HTTP 409 Conflict" (or a bare "Unknown error") reached the user with no
hint at the cause.
Throw a dedicated WebDavSyncFolderUnusableSPError with a privacy-safe,
actionable message (no path, no response body) at the spot that already
detects this condition. Mirrors NetworkUnavailableSPError: a fixed
user-facing message matched by instanceof.
* ci(release): revive contributors section in release notes
* fix(plugins): stop leaked timers on plugin disable via onUnload hook
* fix(plugins): harden onUnload teardown hook after review
* refactor(plugins): group lifecycle registers into options object
* fix(plugins): close onReady stale-guard gap and timer interleave race
* fix(plugins): harden node execution grants
* fix(plugins): harden iframe bridge boundaries (#8208)
* fix(plugins): harden iframe bridge boundaries
* fix(plugins): tighten iframe bridge follow-up
* test(plugins): make node-executor electron test hermetic
The new plugin-node-executor test read the real built-in plugin manifest
(src/assets/bundled-plugins/sync-md/manifest.json), which is a build
artifact absent when 'npm run test:electron' runs in CI (before the
frontend/plugin build). Stub the manifest read, scoped to the executor
module via Module._load, so the grant/token/webContents assertions no
longer depend on built plugin assets.
* fix(plugins): allow iframe formatDate & getCurrentLanguage i18n methods
The iframe API allow-list gate added in #8208 only listed a subset of
the i18n methods that master's #8146 exposes to iframe plugins. Without
this, plugin calls to formatDate/getCurrentLanguage (and translate) are
rejected with 'Unknown API method'. Add all three so the merged gate
matches the methods createBoundMethods/createPluginApiScript expose.
* docs(plugins): document node-exec handoff bootstrap-ordering invariant
The one-shot consumePluginNodeExecutionApi() handoff is defended by
construction ordering, not structural isolation: PluginBridgeService must
consume it before any plugin 'new Function' code runs (both share window.ea
in one renderer realm). Document the invariant at the consumption site so a
future lazy-service/early-plugin-load refactor can't silently regress it.
Surfaced by the post-merge security review (latent finding; not currently
exploitable).
* fix(plugins): use static iframe sandbox attribute to avoid NG0910
Binding a security-sensitive iframe attribute (sandbox) via [attr.sandbox]
makes Angular throw RuntimeError NG0910 and tear down the iframe, crashing
the plugin view to the global error screen. This broke the plugin-iframe,
plugin-loading and plugin-lifecycle e2e tests.
Restore the static sandbox attribute (still without allow-same-origin, so
the opaque-origin isolation from #8208 is preserved) and drop the now-unused
iframeSandbox binding/import.
* feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes
Wires the dead PluginHooks.PERSISTED_DATA_UPDATE enum into a fired hook,
renamed PERSISTED_DATA_CHANGED. Plugins are notified when their persisted
data changes for any reason after the host's initial boot load — local
writes, remote incremental sync via bulkApplyOperations, and post-boot
wholesale loadAllData paths (SYNC_IMPORT / BACKUP_IMPORT / validation
repair / recovery).
Selector-based effect on selectPluginUserDataFeatureState, gated on
SyncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$ so the
boot-time state seeds the pairwise baseline. Differ compares prev/next
by === on the encoded data blob (never decoded). Stage A composite
entityIds (pluginId:key) are normalized to owner pluginId and deduped
so a plugin with N keyed entries changing in one emission fires exactly
once. Per-pluginId dispatch via new PluginHooksService.dispatchHookToPlugin.
Effect is { dispatch: false } and creates no ops, so no sync-window
guard is needed — and adding one (skipDuringSyncWindow) would silently
suppress the very remote-sync deliveries the hook is designed to catch.
Closes#7754. Follow-up: doc-mode adoption tracked in #7752.
* test(plugins): tighten PERSISTED_DATA_CHANGED spec + docs
Multi-review pass 2 surfaced that the 5s timeout spec asserted only that
the dispatcher resolved — it would have silently passed if the timeout
race were removed entirely. Spy on PluginLog.err and assert the timeout
message actually reached the catch branch.
Also:
- Add `:` guard to PluginHooksService.registerHookHandler so the
persistence-key grammar is enforced at both the persistence and
hooks-registry endpoints (defense-in-depth; composeId already throws
at the bridge).
- Add async-rejected-promise spec to cover the Promise.race branch that
sync-throw didn't exercise.
- Carry the PERSISTED_DATA_CHANGED contract paragraph into
docs/plugin-development.md and docs/wiki/3.01-API.md (previously only
in packages/plugin-api/README.md).
- Clarify loadSyncedData(key?) in the README for keyed plugins.
* feat(plugins): keyed persistence API for per-context LWW (Stage A Phase 1+3)
Add an optional `key` argument to `persistDataSynced` / `loadSyncedData`,
composed at the bridge transport boundary into `pluginId:key` entity ids.
Distinct keys now produce distinct ops that LWW-resolve per-entity,
enabling document-mode-style plugins to avoid cross-context blob
overwrites without changing existing keyless callers.
Phase 3: `removePluginUserData(pluginId)` now sweeps the full prefix
(legacy entry + every keyed entry), dispatching one delete per match
with the rule-6 setTimeout(0) trailer so remote replicas don't keep
keyed entries after uninstall. The reducer-only "smart prefix match"
shortcut is wrong (one op for the prefix only, remote keyed entries
leak) — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md
Phase 3.
Phase 4 (document-mode plugin-side migration of the legacy single-blob
entry) is left as a separate follow-up so the host change can be
reviewed in isolation.
Issue #7749
* feat(document-mode): migrate to keyed persistence (Stage A Phase 4)
Move from one synced blob under the bare plugin id to per-entity keyed
entries:
- meta — { enabledCtxIds: string[] }, owned by background.ts
- doc:${ctxId} — one entry per context, owned by the editor iframe
- __meta__ — migration stamp
Each entry has its own LWW timestamp on the host, so a concurrent edit
in project A on Device 1 and project B on Device 2 no longer
whole-blob-collide.
The migration runs idempotently from both background.ts and editor.ts
(stamp-guarded), splits the legacy single blob into keyed entries, then
tombstones the legacy entry with an empty payload — giving LWW a
winning side against any offline device that still writes the old
shape.
flushSave / flushSaveSync no longer need to read+merge sibling state,
since each context's entry stands alone. The future-version blob guard
(isStorageUnreadable) is dropped — it referenced the wrapping blob's
version, which no longer exists; per-doc corruption still falls back
via isDocCorrupt.
Issue #7749
* fix(plugins): tighten Stage A keyspace at the boundaries
Multi-review surfaced three small gaps in the keyed-persistence rollout:
- The synchronous composeId throw covers the bridge's iframe and
direct-API entry points, but three in-process callers
(plugin-config.service, plugin.service, plugin-config-dialog) bypass
the bridge and route directly into the persistence service. A
user-installed plugin with `id: "evil:plugin"` passed manifest
validation and would have collided with the legitimate `evil`
plugin's keyed namespace — `removePluginUserData('evil')` would have
over-matched the sweep. Reject the colon at install time in
`validatePluginManifest`; keep the bridge throw as defense-in-depth.
- The new `key` arg at the bridge was typia-asserted on `data` but
unchecked itself. A compromised iframe could pass a multi-megabyte
string or a non-string value via postMessage. `data` is capped at
1 MB, but the entity id composed from `key` would be stored verbatim
in NgRx state, IndexedDB, the op-log, and on the sync wire — bypassing
the data cap. Add `assertPluginPersistenceKey` with a 256-char cap.
- `_loadPersistedData` silently returned `null` when composeId threw,
while `_persistDataSynced` rethrew. The asymmetry made a malformed
pluginId look like "no data yet" on the load side, indistinguishable
from a fresh install. Hoist composeId + key validation out of the
load try/catch so it throws symmetrically.
Issue #7749
* fix(plugins): lower per-write cap to 256 KB
The pre-Stage-A 1 MB cap was sized for the old single-blob shape, where
one entry held every context's data. With the keyed split, each entity
gets its own write budget — 1 MB per write is wildly over-provisioned
for the realistic upper bound of plugin payloads (heavy document-mode
docs ~30–100 KB, configs and automations KB-scale).
256 KB keeps 2–5× headroom over realistic payloads while bounding the
per-plugin storage growth more tightly.
Document-mode's migration loop now skips oversized legacy docs instead
of aborting the whole run: a user whose legacy blob holds one ~500 KB
doc (legal under the old cap) keeps the other contexts migrated and
the original bytes preserved in the legacy entry. The success stamp
stays at migrated:0 in that case so a future build (or pruning of the
doc) can complete the migration without data loss.
Issue #7749
* test(plugins): e2e migration of legacy single-blob to keyed entries
The migration logic in document-mode is unit-tested against a mock
PluginAPI, which can't catch real-iframe quirks (postMessage handling
of undefined second args, commit-chain timing under the host's
per-entity rate limiter, hydration ordering against the op-log). Add
two end-to-end scenarios:
- Fresh install: enable the plugin, verify the __meta__ stamp lands
at migrated:1 (the migration's final write — observing it implies
every earlier step completed).
- Legacy blob: seed a pre-Stage-A single-blob entry via the e2e helper
store, enable the plugin, verify the legacy entry is tombstoned,
meta carries the enabledCtxIds, and each doc landed under its own
doc:${ctxId} key.
Issue #7749
* chore(plugins): drop dead code and review-driven polish
Four small follow-ups from the multi-review pass:
- Don't log the plugin-supplied `key` value. Plugins may use user
content (search queries, doc titles) as keys; the log history is
exportable. Log `keyLen` instead, per CLAUDE.md rule 9.
- Delete `detectStaleLegacyWrite` and its 3 specs. Exported and
fully tested, but zero non-test callers — banner UI is forbidden
by project convention for transient-only messaging. If the need
resurfaces, the implementation is four lines.
- Drop the `attemptedAt` field from `MigrationStamp`. It was written
but never read; the success stamp is the only re-entry gate, and
the resume path is just "re-run the loop" — re-writes are content-
idempotent. Saves one rate-limited write per fresh migration.
- Update `docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md`
with an implementation-status table referencing the shipping
commits, so future readers don't have to dig through git.
Issue #7749
Add the host-side plugin API for work-context-scoped UI:
- registerWorkContextHeaderButton — context-filtered (PROJECT/TAG/TODAY)
header buttons, with toggled-state support on the active button
- a work-view embed slot so a plugin can replace the task list with its
own UI for the active context (showInWorkContext / closeWorkContextView)
- the WORK_CONTEXT_CHANGE hook and getActiveWorkContext() pull API,
sharing one toActiveWorkContext() projection helper as the single
source of truth
- selectTask API plus the plugin-bridge / plugin-hooks wiring and specs
Squashed from the feat/doc-mode-v4 work. The full per-step history —
including the earlier in-tree (non-plugin) Angular document-mode feature
that was prototyped and then removed on the same branch — is preserved
in the archive/doc-mode-v4-full-history branch and the
doc-mode-v4-pre-squash tag.
* feat(plugin-api): add tag ids to plugin field mapping
* feat(plugin-sync): handle tag ids in plugin sync adapter
* feat(plugin-sync): handle tag ids in plugin issue provider adapter
* feat(plugin-sync): add tags to two way sync
* fix(plugin-sync): add deep equality comparison for arrays
* refactor(plugin-sync): extract sortTagLabels utility and refactor sync adapters
* fix(two-way-sync): trigger sync on addTagToTask action
* fix(plugin-sync): harden tag synchronization
* fix(plugin-sync): preserve provider-owned baselines
---------
Co-authored-by: johannesjo <johannes.millan@gmail.com>
* feat(plugins): add onReady() API with IPC ping + fix consent write delay #7326
- Remove setTimeout(5000) from _getNodeExecutionConsent; write consent immediately
- Add plugin.onReady(fn) to PluginAPI — fires after plugin.js evaluation and IPC bridge confirmation
- Add _pingNodeBridge() in plugin.service.ts with 3-attempt retry (1s, 2s delays)
- Add triggerReady() and pingNodeBridge() to PluginRunner
- Show snack + set error state if IPC bridge unavailable after retries
- Add NODE_EXECUTION_BRIDGE_UNAVAILABLE translation key
- Add focused tests for onReady, triggerReady, pingNodeBridge, consent persistence
- Update plugin-development.md with onReady usage and nodeExecution guidance
* test(plugins): fix unused variable lint errors in spec files
* fix(plugins): guard triggerReady on instance.loaded; fix doc numbering
* fix(plugins): remove _triggerReady from public API, route ping via bridge, add retry tests
* fix(electron): add paths for @sp/sync-providers subpath exports (node moduleResolution compat)
* fix(plugins): centralize onReady, tear down runtime on activation error, add iframe onReady
Address review on #7578:
- All plugin load paths (startup, upload, reload, lazy) now go through _fireOnReady,
ensuring the IPC ping + onReady fire on every successful load — not just lazy.
- activatePlugin error path now unloads the plugin runtime (hooks, buttons, side
effects) before setting status='error', preventing partially-running plugins.
- Iframe PluginAPI now exposes onReady (fires on next microtask after plugin.js
evaluates), matching the host-side contract for typed iframe plugins.
* fix(plugins): clean up half-loaded plugins on onReady error, test real retry util
Self-review followups:
- _fireOnReadyWithCleanup wraps the 3 non-activatePlugin load paths and tears
down the plugin (unloadPlugin + remove from list + status='error' + snack)
if the IPC ping or onReady callback throws. Previously, those paths only
logged and rethrew, leaving partially-running plugins.
- Extracted retry loop into pure pingWithRetry utility; spec now exercises
production code instead of an inline-replicated stub. Removed the old
plugin-ping-node-bridge.spec.ts which was just testing its own copy of
the logic.
- Documented iframe onReady semantic (fires on microtask, no ping) in both
the source comment and docs/plugin-development.md, since cold-boot is not
a concern for iframe plugins (rendered on demand).
* ci(plugins): use npm i for root install to tolerate override drift
The root lockfile pins app-builder-lib's transitive minimatch via the
`overrides` field. npm 10.9.7 (bundled with Node 22 in setup-node@v6)
flags this as drift and fails `npm ci`, while npm 11 accepts it.
ci.yml's main test job uses `npm i`, which tolerates the drift without
mutating the lockfile on disk.
Plugin-Tests has been red on every PR since 2026-05-08 for this reason.
The inner `npm ci` for plugin-specific deps stays strict.
* fix(plugins): make onReady optional, assert callback isolation in spec
- packages/plugin-api/src/types.ts: mark onReady? optional on the public
PluginAPI interface so existing plugin TypeScript typings (and any
third-party PluginAPI implementations) remain assignable after upgrade.
The host runtime already treats onReady as optional (no-op if no
registration callback is provided), so this aligns the type with the
actual contract.
- src/app/plugins/plugin-runner.spec.ts: the previous isolation test only
asserted that triggerReady() resolved for both plugins; it would still
pass if triggerReady fired every registered callback. The updated test
wires per-plugin Jasmine spies through globalThis (the same context the
plugin code's `new Function` runs in) and asserts call counts before
and after each triggerReady, actually proving isolation.
* refactor(plugins): test real consent logic; scope startup snacks; tighten ping timeout
Address review feedback on PR #7578:
- Extract consent decision into pure `decideNodeExecutionConsent` util so the
spec exercises real code instead of a reimplemented stub. Delete the
stub-based plugin-consent.spec.ts and plugin-fire-on-ready.spec.ts (the
latter was orchestration glue already covered by plugin-runner.spec.ts and
ping-with-retry.util.spec.ts).
- Reduce per-ping timeout 5000ms -> 1500ms. Worst-case cold-boot bridge-down
detection drops from ~17s to ~7.5s; in-process vm script returning true
doesn't need 5s.
- Add PLUGIN_LOAD_FAILED translation wrapping plugin name + error. Strip the
now-redundant pluginName from NODE_EXECUTION_BRIDGE_UNAVAILABLE.
- Scope activation-failure snack to manual activations only — startup
auto-activation failures stay silent (plugin tile shows error state).
_handleReadyFailure still snacks unconditionally since onReady failure
leaves a partially-loaded runtime that the user needs to see.
---------
Co-authored-by: Benjamin <1159333+benjaminburzan@users.noreply.github.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
The web build at app.super-productivity.com was inheriting the desktop
OAuth client whose accepted redirect URIs are loopback-only, so Google
rejected the https callback with redirect_uri_mismatch.
Add webClientId / webClientSecret to OAuthFlowConfig and route the
browser branch through them. Google's "Web application" client type is
provisioned as confidential, so PKCE alone is not enough — the secret
ships in client JS like the desktop one. Electron and native mobile
flows are unchanged.
* feat: add plugin data reinit api
* test(plugins): stub data init in bridge spec
* test(plugins): stub data init in counter bridge spec
---------
Co-authored-by: Aamer Akhter <aamer_akhter@users.noreply.bitbucket.org>
Add a CalDAV Calendar plugin that syncs tasks with calendar events via
the CalDAV/WebDAV protocol, supporting self-hosted servers like Nextcloud.
Plugin features:
- CalDAV PROPFIND/REPORT for calendar discovery and event fetching
- iCal parsing and serialization with RFC 5545 compliance
- Two-way sync with field mappings (title, notes, dates, duration)
- Time-block integration for auto-creating calendar events
- Multi-calendar support with compound IDs
Host-side changes:
- Add generic request() method to PluginHttp for WebDAV methods
- Add allowPrivateNetwork manifest flag (bundled plugins only)
- Add dynamic loadOptions support for config field dropdowns
- Add backfill effect for existing scheduled tasks
- Generify translation keys (LOAD_OPTIONS instead of LOAD_CALENDARS)
Security:
- SSRF protection via origin validation in resolveHref
- allowPrivateNetwork gated by _isPluginBundled check
- XML parse error detection in CalDAV response parsing
- Description rendered as plain text (not markdown)
Correctness:
- TZID conversion uses formatToParts (no local timezone contamination)
- modifyICalEvent scoped to VEVENT block (preserves VTIMEZONE)
- responseType: 'text' on all CalDAV PUT/DELETE calls
- CR characters handled in iCal text escaping
- Trailing CRLF added to modifyICalEvent output per RFC 5545
- Fix taskIdToGcalEventId collision by hex-encoding nanoid bytes
- Add timeBlock API to plugin interface, move Google Calendar-specific
logic into the plugin, make TimeBlockSyncEffects provider-agnostic
- Use isPluginIssueProvider() in task selectors
- Replace console.error with Log.err, add i18n for time-block errors
- Use 1-hour default when rescheduling all-day event to timed slot
- Apply config migration in dialog for legacy single-calendar configs
- Remove planTaskForDay/moveBeforeTask from deleteOnUnschedule$
- Rename icalEvents$ to calendarEvents$
- Make issueProviderKey required on CalendarIntegrationEvent
- Parameterize loadAllCalendars/loadWritableCalendars
- Replace deprecated unescape() with TextEncoder in iCal util
- Fix btoa() non-ASCII throw in getIssueLink
- Fix timezone bug in all-day reschedule date parsing
- Deep-clone pluginConfig before mutating in migration dialog
- Add showIf property to PluginFormField so fields can depend on
another config value being truthy
- Show timeBlockCalendarId only when isAutoTimeBlock is enabled
- Improve wording for auto time blocking and calendar field descriptions
Add Google Calendar as a plugin-based calendar provider with OAuth 2.0
authentication, multi-calendar support, and event management.
- Plugin fetches events from selected read calendars, merged into the
existing calendar integration pipeline
- Context menus on planner and schedule views for calendar events:
open link, reschedule, create as task, hide forever, delete
- Reschedule opens date/time picker and updates event via plugin API,
handling both timed and all-day events correctly
- Delete with confirmation dialog
- CalendarEventActionsService extracts shared event action logic
- HiddenCalendarEventsService for permanent event hiding
- triggerRefresh() for immediate UI updates after mutations
- Multi-select config fields for choosing calendars to display
- Fix change detection for plugin select fields in provider dialog
- Electron-safe URL opening with scheme validation
Google rejects custom scheme redirect URIs that don't match the
platform's app identifier. Use the Android applicationId
(com.superproductivity.superproductivity) on Android and the iOS
bundle ID (com.super-productivity.app) on iOS, with single-slash
URI format per Google's documentation.
- Add iosClientId to OAuthFlowConfig and plugin API types
- Add iOS OAuth client ID to Google Calendar plugin
- Select client ID per platform (Android/iOS/Desktop) in bridge service
- Add Android package name intent filter in AndroidManifest
- Accept both URI schemes in OAuth callback handler
- Fix redirect handler to use IS_NATIVE_PLATFORM consistently
Google rejects Desktop OAuth client IDs when the redirect URI is a
custom scheme (as used on mobile via Capacitor), returning a 400 error.
Add mobileClientId to OAuthFlowConfig so plugins can specify a separate
Android/iOS client ID that authenticates via app signing instead of a
client secret. On native platforms, the bridge service automatically
uses the mobile client ID with PKCE only.
Also fix getRedirectUri() to return the custom scheme for all native
platforms (not just Android WebView), and align inline types in the
dialog component and plugin declaration with OAuthFlowConfig.
- Replace drag handle with SVG done-toggle circle with checkmark draw-on animation
- Improve mobile UX: cdkDragStartDelay for touch, bottom-sheet context menu
- Move issue/repeat indicators from drag handle to tag-list as chips
- Add isFinishDayEnabled config toggle
- Reorder context menu: add sub task before duplicate, deadline as own entry
- Fix context menu not opening on swipe left
- Fix race condition in done toggle animation timeout
Add OAuth support for plugins with PKCE, token persistence in local-only
IndexedDB, and Electron/web redirect handling. Extend two-way sync with
dueDay, dueWithTime, and timeEstimate field mappings including mutually
exclusive field clearing. Support remote issue deletion on task delete
and remote deletion detection during polling.
Add Google Calendar plugin (disabled from bundled builds pending legal
review) as a development reference for the plugin OAuth and two-way
sync APIs.
Additional fixes:
- Restore accidentally deleted focus-mode translation keys
- Remove full Task[] from deleteTasks action to prevent op-log bloat
- Add dueDay/deadlineDay format validation guards (#6908)
- Fix Android reminder alarm cancel intent matching
- Validate dueDay format to prevent false overdue from corrupted data
- Fix PKCE test expectation after utility consolidation
Move the voice reminder (domina mode) feature from a built-in Angular
feature into a self-contained plugin with its own manifest, i18n, and
config dialog. Add registerConfigHandler plugin API for settings button
on plugin cards. Include migration logic to auto-enable the plugin and
transfer config for users who had domina mode enabled.
- Fix currentTaskChange hook to dispatch full Task object instead of ID
- Extract shared BUNDLED_PLUGIN_PATHS constant to eliminate duplication
- Use firstValueFrom instead of deprecated .toPromise()
- Add voice name fallback matching for migrated configs
- Mark DominaModeConfig and selector as @deprecated
- Add unit tests for onCurrentTaskChange and config handler
Add a bundled plugin that opens a dialog with a textarea where each
line becomes a task. Includes project selector (defaults to inbox),
due date picker (defaults to today), draft auto-save via
persistDataSynced, and project theme color indicator.
Also extends the plugin API with dueDay support on PluginCreateTaskData,
raised button option on DialogButtonCfg, and default form element
theming in the plugin dialog component.
Add a plugin-based issue provider architecture that allows plugins to
register as issue providers alongside the existing built-in providers.
- Plugin API: types for search, display, comments, two-way sync, and
field mappings in @super-productivity/plugin-api
- Registry service to manage plugin provider lifecycle
- HTTP proxy with SSRF protection for plugin network requests
- Adapter service implementing IssueServiceInterface for plugins
- Sync adapter for plugin-based two-way sync
- Plugin config UI in the issue provider edit dialog
- Plugin providers shown in setup overview and issue panel
Migrate GitHub from built-in to plugin as first proof:
- Bundled github-issue-provider plugin with full feature parity
- Reducer migration converts old GitHub data to plugin format while
preserving legacy fields for cross-version compatibility
- Auto-enable plugin when existing GitHub providers are detected
- Plugin registers under 'GITHUB' key via MigratedIssueProviderKey
- GitHub translations moved to plugin i18n bundles
Mark Two-Way Sync as experimental in the UI.
* fix(e2e): stabilize undo task delete sync test
Two flakiness sources fixed:
- Click on task element could activate title inline editor, causing
Backspace to edit text instead of triggering delete. Now clicks the
drag handle which calls focusSelf() without entering edit mode.
- Replaced deleteTask helper with inline sequence to avoid wasting
2s of the 5s undo snackbar window on dialog-detection timeout.
* refactor: address code review findings from 2026-02-03
- Extract getBreakCycle helper to replace error-prone `cycle - 1 || 1`
pattern at 3 call sites
- Add clarifying comment on intentionally broad 'timed out' match
- Reduce Pomodoro E2E test from 9 to 5 sessions (sufficient coverage)
- Remove dead _isTransientNetworkError wrapper from DropboxApi
- Extract stubWindowConfirm helper in task reducer tests
* fix(sync): prevent Formly from clearing provider config on show (#6345)
resetOnHide: true caused Formly to reset field values when provider
fieldGroups transitioned from hidden to visible, discarding user input
if sync was enabled before selecting a provider.
* fix(tasks): fix huge space between emoji and text in tag/project menus
Use matMenuItemIcon attribute on emoji spans so they project into the
icon slot of mat-menu-item instead of the text slot. Update emoji icon
sizing to 24x24px to match mat-icon and add overflow: hidden.
Closes#5977
* fix(tasks): guard against undefined task entities in selectors and archive (#6359)
Prevent TypeError crashes (reading 'dueWithTime', 'dueDay', 'issueProviderId') caused
by orphaned IDs in NgRx state. Fix archive merge to deduplicate IDs, filter orphans,
and use correct entity precedence (young over old). Add defensive null guards to
selectors and archive/task service methods.
* fix(tasks): guard against undefined task in mainListTasksInProject$ (#6360)
* fix(tasks): detect and sanitize orphaned task IDs to prevent startup crashes (#6359, #6360)
Orphaned task IDs (entries in task.ids without matching entities) caused
TypeError on app startup. Fix addresses three layers: validation now
flags orphaned IDs instead of silently skipping them, loadAllData
sanitizes IDs on load as a safety net, and data repair no longer crashes
when encountering orphaned IDs it's trying to fix.
* fix(sync): prevent recurring task duplication across clients
Remove SuperSync special-case that bypassed initial sync wait, causing
repeatable task effects to fire before sync completed. Add post-sync
cleanup effect that detects and removes stale duplicate repeat instances
when multiple active instances exist for the same repeat config.
* fix(sync): restore WebDAV provider compatibility warning text
* feat(sync): mark WebDAV and LocalFile sync options as experimental
* feat(plugins): add UI Kit with inject-first CSS strategy for iframe plugins
Introduce a lightweight CSS reset (UI Kit) that auto-styles basic HTML
elements in plugin iframes to match the host app theme. Injected after
<head> so plugin styles always win by source order.
UI Kit provides: element resets (body, headings, buttons, inputs, tables,
links, code, lists, hr), .btn-primary/.btn-outline button variants, and
.card/.card-clickable components.
All bundled plugins updated to use UI Kit classes, removing redundant
custom CSS (-542 lines net). Pico CSS removed from automations plugin.
sync-md converted from hardcoded colors to host theme variables.
* feat(plugins): extract shared CSS utilities into UI Kit
Move .text-muted, .text-primary, .page-fade and @keyframes fadeIn from
plugin CSS into the UI Kit so all iframe plugins get them automatically.
Add box-shadow focus ring to input:focus for better accessibility.
Remove per-plugin focus overrides now covered by the UI Kit.
- Add i18n field to PluginManifest type in plugin-api package
- Create PluginI18nService for translation management
- Load translations from file paths or cached content
- Nested key lookup with dot notation (e.g., BUTTONS.SAVE)
- Smart fallback: current language → English → key
- Parameter interpolation with {{param}} syntax
- Language switching via signals
- Memory cleanup for unloaded plugins
- Add type validation for cached translation content
- Document language sync integration points
Part of plugin internationalization system implementation.
Tests will be added in Phase 8.
The reminderId field was deprecated in favor of remindAt. After migration
4.6, tasks store reminder timestamps directly in remindAt instead of
referencing a separate Reminder entity.
Changes:
- Remove reminderId from TaskCopy interface and plugin-api Task type
- Update all code checking reminderId to use remindAt instead
- Update data repair to clear legacy reminderId values
- Remove obsolete reminderId validation (reminders array is now empty)
- Update migration files to handle legacy types
- Update tests to reflect new behavior
- Add unit tests for isPersistentAction type guard.
- Fix compilation errors in task scheduling components caused by removed reminderId/removeReminderFromTask.
- Fix type error in create-sorted-blocker-blocks.spec.ts.
- Fix lint errors in various files.
Upgrade Electron from 37.7.0 to 39.2.5. Since Electron 38+ defaults to
Wayland via --ozone-platform=auto, force X11 on Linux to ensure reliable
idle detection (#1443) and global shortcuts. Users can opt-in to Wayland
with --ozone-platform=wayland or --force-wayland flags.