Skip to content

feat: cloud sync via GitHub Gist + group-scoped pinned tabs - #1362

Open
Eger37 wants to merge 214 commits into
Drive4ik:masterfrom
Eger37:master
Open

feat: cloud sync via GitHub Gist + group-scoped pinned tabs#1362
Eger37 wants to merge 214 commits into
Drive4ik:masterfrom
Eger37:master

Conversation

@Eger37

@Eger37 Eger37 commented Jun 27, 2026

Copy link
Copy Markdown

Reworks cloud sync to a delta model. The existing single-file Gist sync is
replaced with a compacted snapshot + per-device delta logs, for reliable
multi-device convergence. Per-device watermark makes replay idempotent;
deferred truncation means a clobbered snapshot is re-derivable (no data loss).
ETag conditional GET (304 fast path), advisory server-clock lock, user-priority
lock so user actions beat sync. Data still lives only in the user's own Gist.

Adds group-scoped pinned tabs. Pin a tab within a group (browser-pinned
only while its group is active) — session flag, ordering, native + context-menu
toggles, thumbtack badge in popup/Manage.

Danyil added 30 commits June 28, 2026 00:52
… Gist

Adds an opt-in cloud sync that keeps tab groups consistent across devices via
a private GitHub Gist, using a compacted base snapshot plus per-device delta
logs (disjoint keys, never clobbered):
- pure engine: delta-log, delta-capture, replay/merge with conflict rules,
  plan-sync, lazy compaction, url-sync (incl. about: stub), container identity
  mapping, group-relative indexing, tab-sleep, applied-navigation echo guard;
- transport: multi-file Gist API with ETag conditional GET (304 fast path),
  XHR upload progress, and an advisory server-clock lock to serialize cycles;
- safety: per-device watermark dedup, deferred self-truncation (a clobbered
  snapshot never loses data), user-priority lock so user actions beat sync;
- wiring: background/constants/utils + sync settings UI (options).
Pin a tab within an active group (browser-pinned only while its group is
shown). Adds the groupPinned session flag and ordering, native tab-menu and
context-menu toggles, group-pin-aware moves (tab-move-split), and supporting
cache/menu/migration changes. Includes the thumbtack asset and locale strings.
Render the group-pin thumbtack badge in every tab list (popup main + focused
views, normal + archived; mirrored in Manage). Plus popup resilience for the
sync apply path: null-guard stale group lookups against cross-channel races
and contain async broadcast-handler rejections locally.
Pure-unit coverage for the sync engine: replay/merge conflict rules, planSync
no-data-loss invariants, compaction + deferred truncation, advisory lock,
conditional fetch (304), container map, group-relative index, url-sync, tab
sleep, user-priority lock, delta-log hydration, favicon-bloat bound, and
end-to-end convergence.
Keep *.test.mjs out of the webpack build so the delta-sync test suite is not
shipped in the extension package.
Route the sync trigger to deltaSynchronization() when USE_DELTA_SYNC is set,
falling back to the legacy synchronization() only for explicit-revision pulls.
Without this the delta pipeline was never invoked and every sync ran the legacy
URL-based path.
Separate "Enable sync to the cloud" (manual sync + gist setup) from a new autoSyncEnable ("Enable auto sync to the cloud") that gates the periodic sync alarm and the "Cloud sync every…" interval picker. The sync alarm now requires both flags.
…4ik#7)

The manual Sync button and the auto/retry alarm always route through the
delta pipeline. Remove the USE_DELTA_SYNC entry-point switch and the
trust/revision arguments from the sync trigger path; the legacy URL-based
synchronization() stays reachable only via explicit Cloud backup entries.
Add background entries cloudBackupPush (TRUST_LOCAL push of the current
local full state) and cloudBackupRestore (TRUST_CLOUD restore of a chosen
gist revision), both wrapping the legacy Cloud.synchronization. Extract the
browser-action progress wiring shared with cloudSync into
withCloudActionProgress so backup and sync drive the toolbar progress
identically; the legacy synchronization() itself is left untouched.
Add the local-only options syncBackupFilePath (template, default keeps the
bounded {slot} round-robin) and syncBackupLocation (downloads/host, default
downloads). delta-sync maybeBackupBeforeApply reads both and createBackup
honors a location override on the file-path-override path, so the sync
pre-apply backup uses its own name and destination, independent of the
auto-backup settings, with the same placeholder system.
Replace the single github-gist.vue panel with three focused blocks wired into
Options.vue in order access -> sync -> backup:

- github-gist-access.vue: GitHub Gist token + file plus the local/FF-Sync
  storage-location selector, reading the existing githubGistToken/
  githubGistFileName keys (no migration).
- github-gist-sync.vue: delta-only cloud sync controls (sync button without
  the trust dropdown, reset-sync-state, sleep/favicon/pre-apply toggles).
- github-gist-backup.vue: Create backup (BG.cloudBackupPush) and Restore
  revision dropdown (BG.cloudBackupRestore) with no trust dropdown.

Shared token/area resolution and gist-history loading move into
sync-area.mixin.js. The sync trigger now calls BG.cloudSync with no
trust/revision args. The pre-apply sync backup location/path settings live in
the backup block, reusing the autoBackup location widgets via a new pathKey
prop bound to syncBackupLocation/syncBackupFilePath.
Add keys for the GitHub Gist access / cloud sync / cloud backup block titles,
the storage-location selector label, the Create backup button, and the
pre-apply sync backup settings (en authoritative; ru/uk translated). Remove the
now-unused trust-dropdown keys (syncStartTrustLocal, syncStartTrustCloud,
syncDataInCloudCanBeDifferent).
Rename the delta-sync gist layout files so they share a clear STG-sync-* namespace, distinct from the legacy/backup gist files:

- STG-snapshot.json -> STG-sync-snapshot.json

- STG-delta-<id>.json -> STG-sync-delta-<id>.json

- STG-lock.json -> STG-sync-lock.json

The gist-discovery predicate in githubgist.js matches via the layout constants, so it follows the new names automatically. Comments and test fixtures naming the old files are updated to match.
…seed

Delta sync no longer reads the legacy STG-backup.json to seed its initial snapshot. resolveBaseSnapshot now returns an empty snapshot when no STG-sync-snapshot.json exists; the first sync's bootstrap step (gatherLocalPending/computeBootstrapEvents) then emits synthetic add events for every local group/tab/pinned/option, so a brand-new gist initializes entirely from the CURRENT LOCAL app state.

Removes the now-dead seed module and its test, the LEGACY_BACKUP_FILE_NAME constant, and its gist-discovery match. STG-backup.json no longer plays any role in sync.
Add a githubGistBackupFileName sync option (default STG-cloud-backup.json) so the legacy full-state Cloud backup writes/reads its own gist file instead of sharing githubGistFileName with the delta-sync engine.

cloudBackupPush/cloudBackupRestore route through Cloud.synchronization with useBackupFile, which builds the GithubGist provider from githubGistBackupFileName (resolved from the same option bag, honoring syncOptionsLocation). The delta-sync path is unaffected; it uses the fixed STG-sync-* layout names.
Switch gist discovery from matching a file name to matching the user's gist
name (the gist description), with a per-name cache of the resolved gist id for
a fast GET /gists/:id path. The one named gist holds both the delta layout
files (STG-sync-*) and the Cloud backup file; reads/writes still target explicit
file names, only discovery changed.

Renaming the gist keys a different (empty) cache slot, so a name change cleanly
re-discovers or creates a fresh gist and forgets the old one.
The sync pre-apply backup location radio was bound but never saved: the key is
a string (not in ONLY_BOOL_OPTION_KEYS) and had no watcher. Register an
optionsWatch mirroring autoBackupLocation so the selection persists.
autoSyncEnable lacks a sync/autoBackup prefix, so isRoaming treated it as a
synced option and it roamed across devices, inconsistent with syncEnable. Add
it to the explicit LOCAL_ONLY_OPTION_KEYS set so it stays per-device.
The settings page mounts three components (gist access / sync / backup) that
each fetch gist info on created(), causing ~3x getInfo() calls for the same
gist. Cache the in-flight getInfo() promise at module scope keyed by token+name
so the concurrent reads share one round-trip; a reload after a user action
passes useCache=false to force a fresh fetch.
The cloud-backup error notification deep-linked to #backup/sync, scrolling to
the sync block instead of the backup block. Point it at #backup/backup and map
the regrouped cloud sub-block ids (cloud/access/sync/backup) in goToBlock so the
settings page scrolls to the right section.
Move the Cloud sync title to head the entire sync sub-block in Options.vue, with Enable sync to the cloud as the first control inside it. The auto-sync, interval, inner sync controls, sync button and the pre-apply backup unit now all render inside the same Cloud sync box. github-gist-sync renders only the inner controls.
Danyil added 30 commits July 11, 2026 01:25
…on hidden

Route the pinned-group branch of apply() through the same
windowsWithLoadingGroups lock as the normal group path so overlapping
show/hide invocations can no longer interleave. Guard the context-menu
togglePinnedGroup() path with the same someGroupAreLoading loading guard
applyGroup uses and await the message so rapid clicks cannot re-fire
mid-flight. Carry 'hidden' on UI tab objects and decide the menu label
on !tab.hidden, matching the background's getPinnedGroupShownWindowId.
clearAddon wipes localStorage sync marks (deviceId, lastPushedSeq) but
leaves the IndexedDB delta log intact. After a clear-before-restore with
sync enabled, the surviving log is re-read on the post-reload init with
lastPushedSeq reset to 0, and startup reconcile synthesizes tab/pinned
removals for every stale uid absent from the restored set, deleting
cloud and other-device data.

Call resetSyncState() before the reload on the clear path to clear the
delta log and local marks (cloud untouched), matching the state produced
by the manual sync reset.
…eaf modules

Move Groups.getTitle/getIconUrl/tabsCountMessage/createTitle/getDefaults/
create/extractUId/getEmojiIcon, Tabs.getTitle/extractId/isLoading/
normalizeFavIcon, Windows.get and the cloud broadcast helpers verbatim into
groups-helpers.js, tabs-helpers.js, windows-helpers.js and
sync/cloud/cloud-helpers.js. The original modules re-export them, so
background callers are unaffected, while pages can import the helpers
without pulling the full background module graph.
…ckground modules

Shared mixins and popup components now use groups-helpers/tabs-helpers/
windows-helpers/cloud-helpers plus broadcast channel() subscriptions, so
opening the popup no longer parses groups.js, tabs.js, windows.js,
cloud.js and their transitive graph (migration, githubgist, menus-*,
delta capture) or runs their listener side effects. The single-tab mute
toggle goes through sendMessageModule like the group-level one already
did. Manage and options pages share the same mixins and get the same
diet; their own direct imports are untouched.
The color picker component is only needed when the group edit dialog
renders, so load it with a dynamic import; webpack emits it as a shared
async chunk instead of inlining it into every page bundle. Its css stays
in the static entry css.
loadTabUid no longer mints on the pure read path used by sync; it reads
in-memory then the browser.sessions value and returns empty when neither
is set. A new ensureTabUid reads the session first and mints (via
setTabUid) only when truly empty. setTabUid mirrors every persisted uid
into a compact storage.local tabUidBackup map; loadTabUid reconstructs a
lost session value from that backup by re-persisting it instead of
minting a fresh uid, so sync identity is never spuriously re-minted.
Route every mint-if-missing site through Cache.ensureTabUid so the
persisted session/backup value is read before a new uid is minted:
tabs.js onCreated (pinned/normal), onUpdated pin-absorb and onAttached;
groups.js absorbNativePinnedTabs; delta-capture resolveUid;
local-state getLivePinnedTabs. Drop the durable backup entry on real
tab close in tabs.js onRemoved.
Enable webpack minification with keep_fnames/keep_classnames so logger output that reads Function.prototype.name stays intact. The minimizer plugin processes every emitted JS asset, including files copied verbatim by copy-webpack-plugin, so the externals /js tree and background.js are minified too. Import specifiers, including query-string module parameters, are preserved.
Favicons (including data: blobs) now travel in a separate per-device gist
file (STG-sync-favicons-<deviceId>.json) mapping tab uid to favIconUrl,
never in the append-only delta log or the snapshot. This removes the
O(events) favicon surface that previously drove a multi-GB syncDeltaLog.

- layout: add the favicon file name + device-id helpers (reserved prefix)
- url-sync: add sanitizeFavIconUrlForFile (keep data: within a size cap),
  drop the now-unused sanitizeFavIconUrl, and strip all tab favicons from
  synced group records so events stay favicon-free
- favicon-map: build the uid->favicon map from the full model (live group
  tabs, archived group tabs, pinned tabs) with a per-favicon + per-file
  budget; serialize/merge helpers
- favicon-file: apply a merged map on pull via Cache.setTabFavIcon for
  live/pinned tabs and into archived-group tab records
- delta-capture/local-state: stop carrying favicons into events/snapshot
- delta-sync: read peer favicon files on pull and apply them; rebuild the
  self map each push and write only when it changed vs the last written map
  (wholesale overwrite drops dead uids); wired into the fast path too
Records/snapshot no longer carry favicons; the per-device favicon file
carries data: favicons keyed by uid. Rework favicon-bloat + url-sync +
delta-sync-helpers tests to assert the new invariants (no favicon in
events, data: favicons kept only in the file within caps).
New events never carry favicons, so the historical favicon-stripping
insurance (stripHistoricalTabFavicon and the favicon part of
stripEventBloat) is no longer needed; the owner clears delta logs on
update. Thumbnail and oversized-icon (iconUrl) stripping stay intact as
separate still-relevant blob classes. Update hydration tests to exercise
the surviving thumbnail/icon strip and assert favicons are now preserved
through migration.
get-startup-data stripped favIconUrl from every windows-payload tab because grouped tabs get icons from the groups payload; unsynced tabs are in neither, so they rendered a placeholder on first open. Strip only tabs with a truthy groupId, matching the loadUnsyncedTabs criterion (!tab.groupId).
The favicon-sync feature only admitted data: blobs into the per-device
favicon map, silently dropping the http(s)/moz-extension favIconUrl that
nearly every live tab carries. Favicons stopped propagating between
devices.

Admit http(s)/moz-extension (and data:, size-capped) into
sanitizeFavIconUrlForFile so the map carries real favicons. On the
receive side, add Cache.setSyncedTabFavIcon to persist a synced favicon
of any scheme to session storage so unloaded/discarded/hidden tabs still
render an icon; the apply path uses it and skips writes when the stored
value already matches. Keep favicons out of the delta log and snapshot.

Relax prepareForSaveTab to persist non-data favIconUrls so archived
group tab records carry http favicons for the map build to offer.
buildFavIconMap dropped entries by lexical uid order when over the file
budget, so a few huge data: blobs could evict many cheap http URLs and
the truncation was silent. Sort candidates by cost ascending so the
cheapest favicons are kept first, and surface an overflow warning
(mirroring the delta-log cap warn) via an injected callback that keeps
the module pure.
…tent hash

Real data: favicons routinely exceed the old 30000-char per-favicon cap and
the arbitrary 512000-byte file cap, so they were silently dropped and never
synced. Both limits were set out of fear of the delta-LOG blowup, but the
favicon file is O(tabs) and overwritten in place, not O(events) append.

- Derive caps from the real GitHub gist per-file API ceiling (GIST_FILE_MAX_BYTES
  = 10 MiB; larger needs a git clone). File budget = half that; per-favicon cap =
  a sixteenth. Ordinary embedded favicons now pass; only pathological blobs drop.
- sanitizeFavIconUrlForFile validates scheme only; all size gating moved into
  buildFavIconMap where every drop is reported via onOverflow (never silent).
- Content-hash dedup: favicon file is now {tabs:{uid->hash}, blobs:{hash->url}}
  so tabs sharing an icon store the blob once. mergeFavIconMaps resolves back to
  a flat uid->url map (and tolerates the legacy flat format).

Favicons still never ride the delta log/snapshot; http(s)/moz-extension icons
remain admitted alongside data:.
…s and dedup the favicon file by content hash
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant