From 30010ddee3af6f72afc1ecd5ac8bf35db56957bf Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 27 Aug 2026 18:11:47 +0530 Subject: [PATCH 1/2] fix: correct setSucceded typo in the TaskManager task API 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. --- docs/API-Reference/features/TaskManager.md | 2 +- .../default/PHPSupport/ServerInstaller.js | 2 +- .../default/PythonSupport/ServerInstaller.js | 2 +- src/extensionsIntegrated/appUpdater/main.js | 2 +- .../appUpdater/update-electron.js | 4 ++-- src/features/TaskManager.js | 4 ++-- test/spec/TaskManager-integ-test.js | 16 ++++++++-------- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/API-Reference/features/TaskManager.md b/docs/API-Reference/features/TaskManager.md index fdb774bd3c..d07490a63a 100644 --- a/docs/API-Reference/features/TaskManager.md +++ b/docs/API-Reference/features/TaskManager.md @@ -87,7 +87,7 @@ Methods for managing the task's state and UI representation in the TaskManager. | getProgressPercent | function | Returns the task's current progress percentage. | | setFailed | function | Marks the task as failed. | | isFailed | function | Returns true if the task is marked as failed. | -| setSucceded | function | Marks the task as succeeded. | +| setSucceeded | function | Marks the task as succeeded. | | isSucceded | function | Returns true if the task is marked as succeeded. | | showStopIcon | function | Shows the stop icon with an optional tooltip message. | | hideStopIcon | function | Hides the stop icon. | diff --git a/src/extensions/default/PHPSupport/ServerInstaller.js b/src/extensions/default/PHPSupport/ServerInstaller.js index 83f20f001d..1414d1f707 100644 --- a/src/extensions/default/PHPSupport/ServerInstaller.js +++ b/src/extensions/default/PHPSupport/ServerInstaller.js @@ -241,7 +241,7 @@ define(function (require, exports, module) { } task.setProgressPercent(100); task.setMessage(Strings.PHP_INSTALL_DONE); - task.setSucceded(); // (sic - TaskManager's exported name) + task.setSucceeded(); setTimeout(task.close, 4000); Metrics.countEvent("lsp", "phpInst", upgrading ? "upOk" : "ok"); return { entryPath: getEntryPlatformPath(), upgraded: upgrading }; diff --git a/src/extensions/default/PythonSupport/ServerInstaller.js b/src/extensions/default/PythonSupport/ServerInstaller.js index 792ec6bfec..1c603a2edf 100644 --- a/src/extensions/default/PythonSupport/ServerInstaller.js +++ b/src/extensions/default/PythonSupport/ServerInstaller.js @@ -339,7 +339,7 @@ define(function (require, exports, module) { currentUnit = null; task.setProgressPercent(100); task.setMessage(Strings.PYTHON_INSTALL_DONE); - task.setSucceded(); // (sic - TaskManager's exported name) + task.setSucceeded(); setTimeout(task.close, 4000); Metrics.countEvent("lsp", "pyInst", upgrading ? "upOk" : "ok"); return { binaryPath: getBinaryPlatformPath(), upgraded: upgrading }; diff --git a/src/extensionsIntegrated/appUpdater/main.js b/src/extensionsIntegrated/appUpdater/main.js index f2ea906989..fbc45a686a 100644 --- a/src/extensionsIntegrated/appUpdater/main.js +++ b/src/extensionsIntegrated/appUpdater/main.js @@ -582,7 +582,7 @@ define(function (require, exports, module) { } else if(data === UPDATE_STATUS.INSTALLER_DOWNLOADED){ Metrics.countEvent(Metrics.EVENT_TYPE.UPDATES, 'downloaded', Phoenix.platform); updatePendingRestart = true; - updateTask.setSucceded(); + updateTask.setSucceeded(); updateTask.setTitle(Strings.UPDATE_DONE); updateTask.setMessage(Strings.UPDATE_RESTART_INSTALL); if(!updateInstalledDialogShown){ diff --git a/src/extensionsIntegrated/appUpdater/update-electron.js b/src/extensionsIntegrated/appUpdater/update-electron.js index 9629b88d2b..0c57fdf89c 100644 --- a/src/extensionsIntegrated/appUpdater/update-electron.js +++ b/src/extensionsIntegrated/appUpdater/update-electron.js @@ -194,7 +194,7 @@ define(function (require, exports, module) { await window.electronAPI.setUpdateScheduled(true); showOrHideUpdateIcon(); Metrics.countEvent(Metrics.EVENT_TYPE.UPDATES, 'scheduled', Phoenix.platform); - updateTask.setSucceded(); + updateTask.setSucceeded(); updateTask.setTitle(Strings.UPDATE_DONE); updateTask.setMessage(Strings.UPDATE_RESTART_INSTALL); NotificationUI.createToastFromTemplate(Strings.UPDATE_READY_RESTART_TITLE, @@ -406,7 +406,7 @@ define(function (require, exports, module) { Strings.UPDATE_READY_RESTART_INSTALL_MESSAGE); } }); - updateTask.setSucceded(); + updateTask.setSucceeded(); Phoenix.app.registerQuitTimeAppUpdateHandler(quitTimeAppUpdateHandler); console.log("Update was scheduled in another window, registering quit handler"); } diff --git a/src/features/TaskManager.js b/src/features/TaskManager.js index c1450e2c0c..31b558f8d5 100644 --- a/src/features/TaskManager.js +++ b/src/features/TaskManager.js @@ -318,7 +318,7 @@ define(function (require, exports, module) { * @property {function(): number} getProgressPercent - Returns the task's current progress percentage. * @property {function(): void} setFailed - Marks the task as failed. * @property {function(): boolean} isFailed - Returns true if the task is marked as failed. - * @property {function(): void} setSucceded - Marks the task as succeeded. + * @property {function(): void} setSucceeded - Marks the task as succeeded. * @property {function(): boolean} isSucceded - Returns true if the task is marked as succeeded. * @property {function(string): void} showStopIcon - Shows the stop icon with an optional tooltip message. * @property {function(): void} hideStopIcon - Hides the stop icon. @@ -521,7 +521,7 @@ define(function (require, exports, module) { task.getTitle = getTitle; task.setMessage = setMessage; task.getMessage = getMessage; - task.setSucceded = setSucceeded; + task.setSucceeded = setSucceeded; task.isSucceeded = isSucceeded; task.setFailed = setFailed; task.isFailed = isFailed; diff --git a/test/spec/TaskManager-integ-test.js b/test/spec/TaskManager-integ-test.js index f828bdebdd..cfe4aa107d 100644 --- a/test/spec/TaskManager-integ-test.js +++ b/test/spec/TaskManager-integ-test.js @@ -147,7 +147,7 @@ define(function (require, exports, module) { expect(testWindow.$(".dropdown-status-bar").is(":visible")).toBeFalse(); // now set task to success so that the green persistant spinner is visible. - task.setSucceded(); + task.setSucceeded(); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeFalse(); // now disable the hide spinner option @@ -157,7 +157,7 @@ define(function (require, exports, module) { expect(testWindow.$(".dropdown-status-bar").is(":visible")).toBeFalse(); // now lets see if the icon is shown when the task is marked ass success - task.setSucceded(); + task.setSucceeded(); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); task.close(); @@ -229,7 +229,7 @@ define(function (require, exports, module) { it("Should be able to set progress to success", async function () { const task = TaskManager.addNewTask("title", "message"); - task.setSucceded(); + task.setSucceeded(); testWindow.$("#status-tasks .btn-status-bar").click(); expect(testWindow.$(".dropdown-status-bar").is(":visible")).toBeTrue(); expectProgressPercentToBeAround(100); @@ -389,7 +389,7 @@ define(function (require, exports, module) { it(`Should success spinner not auto hide on timeout and hide on click`, async function(){ const task = TaskManager.addNewTask("title", "message"); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); - task.setSucceded(); + task.setSucceeded(); await awaits(TaskManager.SPINNER_HIDE_TIME*2); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); // clicking on tasks will hide spinner @@ -416,7 +416,7 @@ define(function (require, exports, module) { const task = TaskManager.addNewTask("title", "message"); const task1 = TaskManager.addNewTask("title", "message"); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); - task.setSucceded(); + task.setSucceeded(); task1.setFailed(); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-failure")).toBeTrue(); task1.close(); @@ -435,7 +435,7 @@ define(function (require, exports, module) { expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); task.setFailed(); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-failure")).toBeTrue(); - task.setSucceded(); + task.setSucceeded(); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-success")).toBeTrue(); task.setProgressPercent(10); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-failure")).toBeFalse(); @@ -451,7 +451,7 @@ define(function (require, exports, module) { task.setFailed(); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-failure")).toBeFalse(); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeFalse(); - task.setSucceded(); + task.setSucceeded(); expect(testWindow.$("#status-tasks .spinner").hasClass("spinner-success")).toBeFalse(); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeFalse(); task.setProgressPercent(10); @@ -467,7 +467,7 @@ define(function (require, exports, module) { expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeFalse(); task.flashSpinnerForAttention(); expect(testWindow.$("#status-tasks .spinner").is(":visible")).toBeTrue(); - task.setSucceded(); + task.setSucceeded(); // even though the task succeeded, since task has `noSpinnerNotification` specified, the success-spinner // will not be shown. Instead, the normal blue spinner displayed by `flashSpinnerForAttention` // is still visible till the spinner hide timer is hit. From d08b53a8d9c6086c3a2aae4c58d05634df9d8b2d Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 27 Aug 2026 18:12:09 +0530 Subject: [PATCH 2/2] refactor: stream migration per file and move progress out of a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../MigrateAssist/constants.js | 18 +- .../MigrateAssist/html/migrate-progress.html | 25 -- .../MigrateAssist/main.js | 5 +- .../MigrateAssist/migrator.js | 269 +++++++++++------- .../MigrateAssist/sunset-dialog.js | 6 +- src/migrateAssist.html | 125 ++++---- src/nls/root/strings.js | 13 +- test/spec/Extn-MigrateAssist-test.js | 110 +++---- 8 files changed, 287 insertions(+), 284 deletions(-) delete mode 100644 src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html diff --git a/src/extensionsIntegrated/MigrateAssist/constants.js b/src/extensionsIntegrated/MigrateAssist/constants.js index ff41ed689c..1db7f0e090 100644 --- a/src/extensionsIntegrated/MigrateAssist/constants.js +++ b/src/extensionsIntegrated/MigrateAssist/constants.js @@ -46,16 +46,16 @@ define(function (require, exports, module) { const NEW_ORIGIN = "https://web.phcode.dev"; /** - * Human readable form of the above, used inside translated sentences via StringUtils.format. + * Human readable form of the new origin, used inside translated sentences via StringUtils.format. + * The legacy equivalent is derived from the origin instead, see getLegacyDomainName. */ - const LEGACY_DOMAIN_NAME = "phcode.dev"; const NEW_DOMAIN_NAME = "web.phcode.dev"; /** * The day the legacy origin stops serving. Month is 0 based, so 8 is September. * @type {number} */ - const SUNSET_DATE = Date.UTC(2026, 8, 1); + const SUNSET_DATE = Date.UTC(2026, 8, 10); /** * Android/ChromeOS Trusted Web Activity that wraps the legacy origin. Users launched from this @@ -135,6 +135,16 @@ define(function (require, exports, module) { return !Phoenix.isNativeApp && location.origin === getNewOrigin(); } + /** + * Hostname of the origin being retired, for use inside user facing sentences. Derived from the + * origin rather than written out separately so the two can never disagree, which matters while + * the legacy origin still points at staging. + * @return {string} + */ + function getLegacyDomainName() { + return new URL(getLegacyOrigin()).hostname; + } + /** * URL of the helper page on the legacy origin. * @@ -189,13 +199,13 @@ define(function (require, exports, module) { return (typeof now === "number" ? now : Date.now()) >= SUNSET_DATE; } - exports.LEGACY_DOMAIN_NAME = LEGACY_DOMAIN_NAME; exports.NEW_DOMAIN_NAME = NEW_DOMAIN_NAME; exports.SUNSET_DATE = SUNSET_DATE; exports.TWA_STORE_URL = TWA_STORE_URL; exports.MIGRATION_DONE_KEY = MIGRATION_DONE_KEY; exports.getLegacyOrigin = getLegacyOrigin; exports.getMigrateAssistURL = getMigrateAssistURL; + exports.getLegacyDomainName = getLegacyDomainName; exports.getNewOrigin = getNewOrigin; exports.isLegacyOrigin = isLegacyOrigin; exports.isNewOrigin = isNewOrigin; diff --git a/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html b/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html deleted file mode 100644 index 1b3f051cc2..0000000000 --- a/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/src/extensionsIntegrated/MigrateAssist/main.js b/src/extensionsIntegrated/MigrateAssist/main.js index 7ff9caa436..76969e5363 100644 --- a/src/extensionsIntegrated/MigrateAssist/main.js +++ b/src/extensionsIntegrated/MigrateAssist/main.js @@ -38,6 +38,7 @@ define(function (require, exports, module) { Commands = require("command/Commands"), Menus = require("command/Menus"), Strings = require("strings"), + StringUtils = require("utils/StringUtils"), Constants = require("./constants"), SunsetDialog = require("./sunset-dialog"), Migrator = require("./migrator"); @@ -54,7 +55,9 @@ define(function (require, exports, module) { // desktop. It is also skipped on Safari/iOS, where the migration is deliberately not // implemented: offering an action we do not honour would be worse than not offering it. if (!Phoenix.isTestWindow && Constants.isMigrationSupportedBrowser()) { - CommandManager.register(Strings.CMD_MIGRATE_DATA, Commands.HELP_MIGRATE_DATA, function () { + CommandManager.register( + StringUtils.format(Strings.CMD_MIGRATE_DATA, Constants.getLegacyDomainName()), + Commands.HELP_MIGRATE_DATA, function () { Migrator.runManually(); }); // Anchored to About rather than to Check for Updates: the updater only registers its diff --git a/src/extensionsIntegrated/MigrateAssist/migrator.js b/src/extensionsIntegrated/MigrateAssist/migrator.js index 61d76dd1b0..b7276146bd 100644 --- a/src/extensionsIntegrated/MigrateAssist/migrator.js +++ b/src/extensionsIntegrated/MigrateAssist/migrator.js @@ -35,22 +35,22 @@ define(function (require, exports, module) { const Dialogs = require("widgets/Dialogs"), DefaultDialogs = require("widgets/DefaultDialogs"), - Mustache = require("thirdparty/mustache/mustache"), Strings = require("strings"), StringUtils = require("utils/StringUtils"), Metrics = require("utils/Metrics"), - ZipUtils = require("utils/ZipUtils"), + TaskManager = require("features/TaskManager"), PreferencesManager = require("preferences/PreferencesManager"), CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), - Constants = require("./constants"), - progressTemplate = require("text!./html/migrate-progress.html"); + Constants = require("./constants"); const HANDSHAKE_TIMEOUT_MS = 15000, - BUNDLE_TIMEOUT_MS = 120000, + FILE_TIMEOUT_MS = 60000, + CHUNK_SIZE = 8 * 1024 * 1024, IFRAME_ID = "migrate-assist-frame"; const RESULT_MIGRATED = "migrated", + RESULT_DECLINED = "declined", RESULT_NOTHING = "nothing", RESULT_UNREACHABLE = "unreachable"; @@ -68,7 +68,7 @@ define(function (require, exports, module) { iframe.style.display = "none"; let pendingScan = null, - bundleHandler = null, + fileHandler = null, destroyed = false; function _onMessage(event) { @@ -87,8 +87,8 @@ define(function (require, exports, module) { const resolve = pendingScan; pendingScan = null; resolve(data); - } else if (bundleHandler) { - bundleHandler(data); + } else if (fileHandler) { + fileHandler(data); } } @@ -126,100 +126,158 @@ define(function (require, exports, module) { } /** - * Requests one bundle and reassembles its chunks into a single ArrayBuffer. + * Requests one file, reassembling it from chunks if it is larger than the chunk size. */ - function fetchBundle(id) { + function fetchFile(path) { return new Promise((resolve, reject) => { - const chunks = []; - let expected = -1, - received = 0; + const parts = []; + let received = 0; const timer = setTimeout(() => { - bundleHandler = null; - reject(new Error("timed out receiving " + id)); - }, BUNDLE_TIMEOUT_MS); + fileHandler = null; + reject(new Error("timed out receiving " + path)); + }, FILE_TIMEOUT_MS); - bundleHandler = function (data) { - if (data.id !== id) { + function requestFrom(offset) { + iframe.contentWindow.postMessage( + { type: "MIGRATE_READ", path: path, offset: offset, length: CHUNK_SIZE }, + legacyOrigin); + } + + fileHandler = function (data) { + if (data.path !== path) { return; } if (data.type === "MIGRATE_ERROR") { clearTimeout(timer); - bundleHandler = null; + fileHandler = null; reject(new Error(data.message)); - } else if (data.type === "MIGRATE_BUNDLE_META") { - expected = data.chunkCount; - } else if (data.type === "MIGRATE_CHUNK") { - chunks[data.index] = data.chunk; - received = received + 1; - if (data.last || (expected > 0 && received === expected)) { - clearTimeout(timer); - bundleHandler = null; - const total = chunks.reduce((sum, c) => sum + c.byteLength, 0); - const merged = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - merged.set(new Uint8Array(chunk), offset); - offset = offset + chunk.byteLength; - } - resolve(merged.buffer); - } + return; + } + if (data.type !== "MIGRATE_DATA") { + return; } + parts.push(new Uint8Array(data.chunk)); + received = received + data.chunk.byteLength; + if (!data.eof) { + requestFrom(received); + return; + } + clearTimeout(timer); + fileHandler = null; + if (parts.length === 1) { + resolve(parts[0]); + return; + } + const merged = new Uint8Array(received); + let offset = 0; + for (const part of parts) { + merged.set(part, offset); + offset = offset + part.byteLength; + } + resolve(merged); }; - iframe.contentWindow.postMessage({ type: "MIGRATE_BUNDLE", id: id }, legacyOrigin); + requestFrom(0); }); } - return { scan, fetchBundle, destroy }; + return { scan, fetchFile, destroy }; } - function _showProgressDialog(bundleCount) { - const dialog = Dialogs.showModalDialogUsingTemplate( - Mustache.render(progressTemplate, { - Strings: Strings, - introMessage: StringUtils.format(Strings.MIGRATE_PROGRESS_INTRO, - Constants.LEGACY_DOMAIN_NAME) - }), - false // no auto dismiss, the transfer must not be interrupted half way - ); - const $dlg = dialog.getElement(); - let bundlesDone = 0; - + /** + * Progress lives in the status bar rather than a modal. The transfer is dominated by IndexedDB + * writes at roughly 10ms per file, so a few thousand files is minutes long, and blocking the + * whole editor behind an undismissable dialog for that is not acceptable. The user is told once + * up front, works normally while it runs, and gets a dialog again only when it is done. + */ + function _startProgressTask(totalFiles) { + const task = TaskManager.addNewTask( + Strings.MIGRATE_PROGRESS_TITLE, + StringUtils.format(Strings.MIGRATE_PROGRESS_STATUS, 0, totalFiles), + ``); return { - dialog: dialog, - setWaiting: function (index, name) { - // The remote side is zipping. Nothing to count yet, so pulse rather than sit at 0%. - $dlg.find(".migrate-assist-bar").addClass("migrate-assist-bar-indeterminate"); - $dlg.find(".migrate-assist-status") - .text(StringUtils.format(Strings.MIGRATE_PROGRESS_PREPARING, name, index + 1, bundleCount)); + update: function (done) { + task.setProgressPercent(Math.round((done / totalFiles) * 100)); + task.setMessage(StringUtils.format(Strings.MIGRATE_PROGRESS_STATUS, done, totalFiles)); }, - setBundleProgress: function (index, name, doneFiles, totalFiles) { - $dlg.find(".migrate-assist-bar").removeClass("migrate-assist-bar-indeterminate"); - bundlesDone = index; - const withinBundle = totalFiles ? (doneFiles / totalFiles) : 0; - const overall = Math.min(100, Math.round(((bundlesDone + withinBundle) / bundleCount) * 100)); - $dlg.find(".migrate-assist-bar").css("width", `${overall}%`); - $dlg.find(".migrate-assist-status") - .text(StringUtils.format(Strings.MIGRATE_PROGRESS_STATUS, name, index + 1, bundleCount)); + succeed: function (done) { + task.setProgressPercent(100); + task.setMessage(StringUtils.format(Strings.MIGRATE_PROGRESS_STATUS, done, totalFiles)); + task.setSucceeded(); + task.close(); }, - finish: function (summary) { - $dlg.find(".migrate-assist-bar") - .removeClass("migrate-assist-bar-indeterminate") - .css("width", "100%"); - $dlg.find(".migrate-assist-intro").text(Strings.MIGRATE_DONE_TITLE); - $dlg.find(".migrate-assist-status").text(summary.message); - if (summary.detail) { - $dlg.find(".migrate-assist-detail").removeClass("forced-hidden").text(summary.detail); - } - $dlg.find(".migrate-assist-reload").removeClass("forced-hidden").on("click", function () { - CommandManager.execute(Commands.APP_RELOAD); - }); - $dlg.find(".migrate-assist-close").removeClass("forced-hidden").on("click", function () { - dialog.close(); - }); + fail: function () { + task.setFailed(); + task.close(); } }; } + /** + * Shown once, before anything is copied, so the user knows why their machine is busy. + */ + function _confirmStart(fileCount) { + return Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + Strings.MIGRATE_PROGRESS_TITLE, + StringUtils.format(Strings.MIGRATE_START_MESSAGE, fileCount, Constants.getLegacyDomainName()), + [ + { + className: Dialogs.DIALOG_BTN_CLASS_NORMAL, + id: Dialogs.DIALOG_BTN_CANCEL, + text: Strings.CANCEL + }, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: Dialogs.DIALOG_BTN_OK, + text: Strings.MIGRATE_START_CONFIRM + } + ] + ).getPromise(); + } + + function _showCompletion(migratedFiles, failed) { + const message = failed.length + ? StringUtils.format(Strings.MIGRATE_DONE_MESSAGE, migratedFiles) + "

" + + StringUtils.format(Strings.MIGRATE_DONE_PARTIAL, failed.length) + : StringUtils.format(Strings.MIGRATE_DONE_MESSAGE, migratedFiles); + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + Strings.MIGRATE_DONE_TITLE, + message, + [ + { + className: Dialogs.DIALOG_BTN_CLASS_NORMAL, + id: Dialogs.DIALOG_BTN_CANCEL, + text: Strings.MIGRATE_RELOAD_LATER + }, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: Dialogs.DIALOG_BTN_OK, + text: Strings.MIGRATE_RELOAD_NOW + } + ] + ).done(function (buttonId) { + if (buttonId === Dialogs.DIALOG_BTN_OK) { + CommandManager.execute(Commands.APP_RELOAD); + } + }); + } + + /** + * Filer needs the parent directory to exist before a write, and mkdirs is recursive. + */ + function _ensureParentDir(filePath) { + return new Promise((resolve, reject) => { + window.fs.mkdirs(window.path.dirname(filePath), 0o755, true, (err) => { + if (err && err.code !== "EEXIST") { + reject(err); + return; + } + resolve(); + }); + }); + } + async function _applyPreferences(prefsText) { // Flush first. The in memory user scope is authoritative, so overwriting the file underneath // it would just get clobbered by the next save. @@ -250,35 +308,39 @@ define(function (require, exports, module) { let progress = null; try { const scan = await bridge.scan(); - if (!scan.hasData || !scan.bundles.length) { + if (!scan.hasData || !scan.files.length) { Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", manual ? "manualNothing" : "autoNothing"); return RESULT_NOTHING; } + // Asked once, before anything is copied. After this the user is left alone. + const choice = await _confirmStart(scan.files.length); + if (choice !== Dialogs.DIALOG_BTN_OK) { + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", "declined"); + return RESULT_DECLINED; + } + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", manual ? "manualStart" : "autoStart"); - progress = _showProgressDialog(scan.bundles.length); + progress = _startProgressTask(scan.files.length); const failed = []; let migratedFiles = 0; - for (let i = 0; i < scan.bundles.length; i++) { - const bundle = scan.bundles[i]; - const name = bundle.dest.substring(bundle.dest.lastIndexOf("/") + 1); - progress.setWaiting(i, name); + for (const file of scan.files) { try { - const buffer = await bridge.fetchBundle(bundle.id); - await ZipUtils.unzipBinDataToLocation(buffer, bundle.dest, false, - function (doneCount, totalCount) { - progress.setBundleProgress(i, name, doneCount, totalCount); - return true; // must be explicit, see unzipBinDataToLocation - }); - migratedFiles = migratedFiles + bundle.fileCount; + const bytes = await bridge.fetchFile(file.path); + // Paths are identical on both origins, so the destination is the source path. + await _ensureParentDir(file.path); + await Phoenix.VFS.writeFileAsync(file.path, window.Filer.Buffer.from(bytes), + window.fs.BYTE_ARRAY_ENCODING); + migratedFiles = migratedFiles + 1; } catch (err) { - // One bad folder should not cost the user everything else. - console.error("MigrateAssist: bundle failed", bundle.id, err); - failed.push(name); + // One unreadable file should not cost the user everything else. + console.error("MigrateAssist: could not copy", file.path, err); + failed.push(file.path); } + progress.update(migratedFiles + failed.length); } if (scan.prefs) { @@ -299,19 +361,15 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", failed.length ? "completedWithErrors" : "completed"); - progress.finish({ - message: StringUtils.format(Strings.MIGRATE_DONE_MESSAGE, migratedFiles), - detail: failed.length - ? StringUtils.format(Strings.MIGRATE_DONE_PARTIAL, failed.join(", ")) - : null - }); + progress.succeed(migratedFiles); + _showCompletion(migratedFiles, failed); return RESULT_MIGRATED; } catch (err) { console.error("MigrateAssist: migration could not run", err); Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", manual ? "manualUnreachable" : "autoUnreachable"); if (progress) { - progress.dialog.close(); + progress.fail(); } return RESULT_UNREACHABLE; } finally { @@ -348,14 +406,17 @@ define(function (require, exports, module) { */ async function runManually() { const result = await run(true); + if (result === RESULT_DECLINED) { + return; // the user said no, they do not need to be told what they just chose + } if (result === RESULT_NOTHING) { Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, Strings.MIGRATE_NOTHING_TITLE, - StringUtils.format(Strings.MIGRATE_NOTHING_MESSAGE, Constants.LEGACY_DOMAIN_NAME)); + StringUtils.format(Strings.MIGRATE_NOTHING_MESSAGE, Constants.getLegacyDomainName())); } else if (result === RESULT_UNREACHABLE) { Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_ERROR, Strings.MIGRATE_UNREACHABLE_TITLE, - StringUtils.format(Strings.MIGRATE_UNREACHABLE_MESSAGE, Constants.LEGACY_DOMAIN_NAME)); + StringUtils.format(Strings.MIGRATE_UNREACHABLE_MESSAGE, Constants.getLegacyDomainName())); } } diff --git a/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js b/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js index 85ec1ff579..7baf156d7a 100644 --- a/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js +++ b/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js @@ -51,13 +51,13 @@ define(function (require, exports, module) { const paragraphs = []; paragraphs.push(StringUtils.format(Strings.MIGRATE_MOVING_MESSAGE, - Constants.LEGACY_DOMAIN_NAME, Constants.NEW_DOMAIN_NAME)); + Constants.getLegacyDomainName(), Constants.NEW_DOMAIN_NAME)); if (!Constants.isPastSunset()) { const days = Constants.daysToSunset(); paragraphs.push(StringUtils.format( days === 1 ? Strings.MIGRATE_SUNSET_COUNTDOWN_ONE : Strings.MIGRATE_SUNSET_COUNTDOWN, - days, Constants.LEGACY_DOMAIN_NAME)); + days, Constants.getLegacyDomainName())); } if (!Constants.isMigrationSupportedBrowser()) { @@ -112,7 +112,7 @@ define(function (require, exports, module) { Dialogs.showModalDialog( DefaultDialogs.DIALOG_ID_INFO, - StringUtils.format(Strings.MIGRATE_MOVING_TITLE, Constants.LEGACY_DOMAIN_NAME), + StringUtils.format(Strings.MIGRATE_MOVING_TITLE, Constants.getLegacyDomainName()), _buildMessage(), _buildButtons() ).done(function (buttonId) { diff --git a/src/migrateAssist.html b/src/migrateAssist.html index be36c09ca0..ffb4bb9b24 100644 --- a/src/migrateAssist.html +++ b/src/migrateAssist.html @@ -38,9 +38,14 @@ It deliberately does not boot Phoenix. virtualfs.js is a self contained IIFE that gives us window.fs over the same Filer IndexedDB, which is all we need. + + Files are streamed one at a time rather than zipped per folder. IndexedDB costs roughly 10ms per + file whatever we do, so an intermediate zip only adds a read/encode/decode pass on top of that + floor, and measured on 300 files it produced an archive slightly LARGER than the input because + JSZip stores uncompressed. Streaming also gives the receiver an exact per file progress count and + keeps peak memory at one file instead of one whole folder. --> -