Skip to content

Migrate browser data from the legacy web origin to web.phcode.dev - #3139

Merged
abose merged 2 commits into
mainfrom
ai
Aug 27, 2026
Merged

Migrate browser data from the legacy web origin to web.phcode.dev#3139
abose merged 2 commits into
mainfrom
ai

Conversation

@abose

@abose abose commented Aug 27, 2026

Copy link
Copy Markdown
Member

Why

Browser storage is per-origin. A user landing on web.phcode.dev sees an empty editor while their projects, preferences, theme and extensions all sit on the old origin, invisible to the new one. This adds a one-time migration so the move does not cost them their data.

How it works

  • On the origin being retired, every boot shows a dialog announcing the move, with a live countdown to the sunset date (2026-09-10).
  • On web.phcode.dev, a hidden same-site iframe pointed at <legacy>/migrateAssist.html reports whether anything is worth moving. If there is nothing, absolutely nothing happens — no dialog, no interruption, normal boot. If there is, the user is asked once, the transfer runs in the background against a status-bar task while they keep working, and a dialog appears again at the end offering a reload.
  • Once it has run, the automatic path never runs again. Help → Migrate My Data From phcode.dev… re-runs it on demand.

Moving: everything under /fs/local/, the installed extensions and themes under /fs/app/extensions/, /fs/app/phcode.json, plus two PhStore values (extensions.disabled, STATE_recentProjects).

The load-bearing assumption

Browsers partition third-party storage by site (eTLD+1), not origin. phcode.dev, staging.phcode.dev and web.phcode.dev are all the same site, so the helper frame is same-site and reads real unpartitioned IndexedDB. Had the new home been on a different registrable domain this approach would have silently reported "nothing to migrate" everywhere rather than failing loudly.

Security

migrateAssist.html hands the user's entire browser filesystem to whoever embeds it, so the origin check is the only gate:

  • Exact-match allowlist, never a prefix test — https://web.phcode.dev.evil.example must not pass.
  • event.origin re-validated on every message, not just the handshake.
  • The parentOrigin query param validated before anything is posted.
  • Replies go to the validated origin, never "*".
  • Reads confined to three known roots; /mnt and .. traversal refused.

All of these are covered by tests.

Performance

Files stream individually rather than being zipped per folder. Measured on 300 files, the zip was not earning its place — it produced an archive larger than the input, because JSZip stores uncompressed, so it only added a read/encode/decode pass on top of the IndexedDB cost that dominates either way:

zip per folder per file
scan 375 ms 280 ms
transfer + write 7636 ms 5546 ms
total 8011 ms 5826 ms

Writing those same 300 files locally, with no migration involved at all, costs 3167 ms — so IndexedDB is the floor and this now runs at ~1.75× it. Streaming also gives an exact per-file progress count and caps memory at one file instead of one folder.

Scope

Out of scope by design: /mnt File System Access mounts (handles are origin-bound and unrecoverable — the files on disk are untouched, the user re-picks the folder), AI chat history (AI is not available in the browser app), and Safari/iOS, which is a product decision rather than a technical limit.

Android/ChromeOS TWA users are sent to the Play Store rather than the new site, since the shipped APK only trusts phcode.dev and navigating it elsewhere surfaces a browser URL bar inside what looks like an app. "Stay here" is a genuine option there — managed school fleets can have the Play Store blocked entirely.

Also in here

fix: correct setSucceded typo in the TaskManager task API — TaskManager exported its success method as task.setSucceded (missing an e) while its own JSDoc documented the correct spelling, so calling the documented name threw. Two callers already carried (sic) comments explaining the trap instead of fixing it. Renamed with all 17 call sites updated; no alias kept, since the extensions were audited and none use the task API.

Testing

  • New suite unit:MigrateAssist, 15 specs: sunset math, origin predicates, path-root validation, the security refusals above, and a byte-exact streaming round trip using a file containing NUL and 0xFF.
  • Full unit category 2730/2730, TaskManager integration 30/30, verified in a clean browser profile (empty IndexedDB, matching CI).
  • All three dialog states verified visually, and the transfer confirmed non-blocking.

Before this ships

  • LEGACY_ORIGIN is still https://staging.phcode.dev for validation. Flipping it to phcode.dev is a one-line change in constants.js.
  • phcode.dev must allow web.phcode.dev to frame /migrateAssist.html. If the host blocks framing the handshake times out and the migration silently never runs.
  • The web cache key is the x.y.z version, not the build number, so existing users only pick up a deploy when the version bumps.

abose added 2 commits August 27, 2026 18:13
TaskManager exported its success method as task.setSucceded, missing an e,
while the JSDoc for that same function documented the correct spelling. Calling
the documented name threw, and two callers already carried "(sic)" comments
explaining the trap rather than fixing it.

Renamed to setSucceeded and updated every call site. No alias is kept: the
extensions were audited and none use the task API, so there is nothing left to
break, and leaving the misspelling exported would only invite the next caller
to trip on the same mismatch.

The "(sic)" comments in the Python and PHP installers are removed too, since
they now describe something that is no longer true.
Three problems with the first cut.

The menu entry read "Migrate My Data…", which says nothing about where the data
comes from. It now reads "Migrate My Data From phcode.dev…", with the domain
derived from the configured legacy origin rather than written out separately so
the label cannot drift from the host actually in use. The same derivation
replaces the hardcoded domain in every other user facing string.

Progress was an undismissable modal that blocked the editor for the whole
transfer. At roughly 26ms per file that is minutes on a real project, which is
not something to hold the app hostage for. The user is asked once, before
anything is copied, the transfer then runs against a status bar task while they
keep working, and a dialog appears again only at the end offering a reload.

Files are streamed individually instead of zipped per folder. Measured on 300
files the zip was not earning its place: it produced an archive slightly larger
than the input because JSZip stores uncompressed, so it only added a
read/encode/decode pass on top of the IndexedDB cost that dominates either way.
Streaming takes that sample from 8.0s to 5.8s, against a 3.2s floor for writing
the same files locally at all, and it gives an exact per file progress count
while capping memory at one file rather than one folder. The helper page no
longer needs JSZip.

Sunset date moves to 2026-09-10.
@sonarqubecloud

Copy link
Copy Markdown

@abose
abose merged commit 004a9be into main Aug 27, 2026
12 of 21 checks passed
@abose
abose deleted the ai branch August 27, 2026 13:18
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