diff --git a/src/command/Commands.js b/src/command/Commands.js index b2586a1656..38b60c7fe0 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -461,6 +461,9 @@ define(function (require, exports, module) { /** Toggles auto update */ exports.HELP_AUTO_UPDATE = "help.autoUpdate"; // shortcuts integrated extension + /** Migrates browser data from the legacy web origin */ + exports.HELP_MIGRATE_DATA = "help.migrateData"; // MigrateAssist integrated extension + // Working Set Configuration /** Sorts working set by order files were added */ exports.CMD_WORKINGSET_SORT_BY_ADDED = "cmd.sortWorkingSetByAdded"; // WorkingSetSort.js _handleSort() diff --git a/src/extensionsIntegrated/MigrateAssist/constants.js b/src/extensionsIntegrated/MigrateAssist/constants.js new file mode 100644 index 0000000000..ff41ed689c --- /dev/null +++ b/src/extensionsIntegrated/MigrateAssist/constants.js @@ -0,0 +1,206 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Shared configuration for the one time move off the legacy web origin onto web.phcode.dev. + * + * Everything that has to change when the rollout moves forward lives here: the two origins and the + * sunset date. `LEGACY_ORIGIN` points at staging while the flow is being validated end to end; + * flipping it to https://phcode.dev is the only edit needed to go live. + * + * @module extensionsIntegrated/MigrateAssist/constants + */ +define(function (require, exports, module) { + + // Domain names, package ids and store urls are brand identifiers rather than prose. They must + // render identically in every locale, so they stay here instead of going through strings.js. + + /** + * The origin we are migrating away from. Switch to "https://phcode.dev" once the flow has been + * validated against staging. + * @type {string} + */ + const LEGACY_ORIGIN = "https://staging.phcode.dev"; + + /** + * The origin we are migrating to. + * @type {string} + */ + const NEW_ORIGIN = "https://web.phcode.dev"; + + /** + * Human readable form of the above, used inside translated sentences via StringUtils.format. + */ + 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); + + /** + * Android/ChromeOS Trusted Web Activity that wraps the legacy origin. Users launched from this + * package need an app update rather than a browser navigation, see sunset-dialog.js. + */ + const TWA_PACKAGE_ID = "prod.phcode.twa"; + const TWA_REFERRER_PREFIX = `android-app://${TWA_PACKAGE_ID}`; + const TWA_STORE_URL = `https://play.google.com/store/apps/details?id=${TWA_PACKAGE_ID}`; + + /** + * PhStore key recording that the migration already ran. Once set, the automatic path never runs + * again and the user has to ask for it from the Help menu. + * @type {string} + */ + const MIGRATION_DONE_KEY = "migrateAssist.v1.done"; + + /** + * Dev only override, so the whole cross origin flow can be exercised on one dev server. + * http://localhost:8000 and http://127.0.0.1:8000 are different origins with separate IndexedDB + * but the same files, and both are already trusted, so they make a usable legacy/new pair. + * Set localStorage.MIGRATE_ORIGINS_OVERRIDE to {"legacy": "...", "target": "..."}. + */ + const ORIGINS_OVERRIDE_KEY = "MIGRATE_ORIGINS_OVERRIDE"; + + let _override = null; + function _getOverride() { + if (_override) { + return _override; + } + _override = {}; + // Mirrors the accounts server override in index.html: dev builds only, never in tests, so a + // stray localStorage value can never redirect a production user to an attacker's origin. + if (Phoenix.isTestWindow || !Phoenix.config || Phoenix.config.environment !== "dev") { + return _override; + } + try { + const parsed = JSON.parse(localStorage.getItem(ORIGINS_OVERRIDE_KEY)); + if (parsed && typeof parsed.legacy === "string" && typeof parsed.target === "string") { + _override = parsed; + console.log("MigrateAssist: using dev origin override", _override); + } + } catch (e) { + console.warn("MigrateAssist: could not read origin override, using defaults", e); + } + return _override; + } + + /** + * The origin holding the data to be moved. + * @return {string} + */ + function getLegacyOrigin() { + return _getOverride().legacy || LEGACY_ORIGIN; + } + + /** + * The origin the data is being moved into. + * @return {string} + */ + function getNewOrigin() { + return _getOverride().target || NEW_ORIGIN; + } + + /** + * True when this window is the site being retired. + * @return {boolean} + */ + function isLegacyOrigin() { + return !Phoenix.isNativeApp && location.origin === getLegacyOrigin(); + } + + /** + * True when this window is the new home. + * @return {boolean} + */ + function isNewOrigin() { + return !Phoenix.isNativeApp && location.origin === getNewOrigin(); + } + + /** + * URL of the helper page on the legacy origin. + * + * Both origins serve the same artifact with the same layout, so the helper sits at the same path + * prefix as the page asking for it. In production that is the origin root; on the dev server the + * app lives under /src/, and hardcoding "/" there would 404. + * @return {string} + */ + function getMigrateAssistURL() { + const pathname = location.pathname; + const prefix = pathname.substring(0, pathname.lastIndexOf("/") + 1); + return `${getLegacyOrigin()}${prefix}migrateAssist.html`; + } + + /** + * Safari and iOS are deliberately out of scope for the automatic migration. This is a product + * decision rather than a technical limit; the same site iframe would very likely work there too. + * @return {boolean} + */ + function isMigrationSupportedBrowser() { + return !(Phoenix.browser.desktop.isSafari || Phoenix.browser.mobile.isIos); + } + + /** + * True when the app was launched from our own Trusted Web Activity. document.referrer only + * reflects the initial navigation, so callers should read this once at boot and cache it. + * @return {boolean} + */ + function isTWALaunch() { + return (document.referrer || "").startsWith(TWA_REFERRER_PREFIX); + } + + /** + * Whole days left before the legacy origin is retired, floored at 0. + * @param {number} [now] current time in ms, for tests + * @return {number} + */ + function daysToSunset(now) { + const millisPerDay = 24 * 60 * 60 * 1000; + const remaining = SUNSET_DATE - (typeof now === "number" ? now : Date.now()); + if (remaining <= 0) { + return 0; + } + return Math.ceil(remaining / millisPerDay); + } + + /** + * @param {number} [now] current time in ms, for tests + * @return {boolean} true once the legacy origin is expected to be gone + */ + function isPastSunset(now) { + 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.getNewOrigin = getNewOrigin; + exports.isLegacyOrigin = isLegacyOrigin; + exports.isNewOrigin = isNewOrigin; + exports.isMigrationSupportedBrowser = isMigrationSupportedBrowser; + exports.isTWALaunch = isTWALaunch; + exports.daysToSunset = daysToSunset; + exports.isPastSunset = isPastSunset; +}); diff --git a/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html b/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html new file mode 100644 index 0000000000..1b3f051cc2 --- /dev/null +++ b/src/extensionsIntegrated/MigrateAssist/html/migrate-progress.html @@ -0,0 +1,25 @@ + diff --git a/src/extensionsIntegrated/MigrateAssist/main.js b/src/extensionsIntegrated/MigrateAssist/main.js new file mode 100644 index 0000000000..7ff9caa436 --- /dev/null +++ b/src/extensionsIntegrated/MigrateAssist/main.js @@ -0,0 +1,76 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Entry point for the one time move off the legacy web origin onto web.phcode.dev. + * + * Which half runs depends purely on which origin this window is: + * - on the origin being retired, announce the move once per boot (sunset-dialog); + * - on the new origin, quietly check whether anything is left behind and pull it across + * (migrator), plus register the Help menu entry that lets the user ask for it again later. + * + * Anywhere else, including the desktop app, this module does nothing at all. + * + * Styling lives in `../../styles/Extn-MigrateAssist.less`. + * + * @module extensionsIntegrated/MigrateAssist/main + */ +define(function (require, exports, module) { + const AppInit = require("utils/AppInit"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + Menus = require("command/Menus"), + Strings = require("strings"), + Constants = require("./constants"), + SunsetDialog = require("./sunset-dialog"), + Migrator = require("./migrator"); + + function _initLegacyOrigin() { + if (Phoenix.isTestWindow) { + return; + } + SunsetDialog.show(); + } + + function _initNewOrigin() { + // The menu entry is only registered here, so it can never show up on the legacy origin or on + // 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 () { + Migrator.runManually(); + }); + // Anchored to About rather than to Check for Updates: the updater only registers its + // command on the desktop build, so on the web the anchor would not exist and this would + // silently fall through to the very bottom of the menu. + Menus.getMenu(Menus.AppMenuBar.HELP_MENU) + .addMenuItem(Commands.HELP_MIGRATE_DATA, "", Menus.BEFORE, Commands.HELP_ABOUT); + } + Migrator.runOnBoot(); + } + + AppInit.appReady(function () { + if (Constants.isLegacyOrigin()) { + _initLegacyOrigin(); + } else if (Constants.isNewOrigin()) { + _initNewOrigin(); + } + }); +}); diff --git a/src/extensionsIntegrated/MigrateAssist/migrator.js b/src/extensionsIntegrated/MigrateAssist/migrator.js new file mode 100644 index 0000000000..61d76dd1b0 --- /dev/null +++ b/src/extensionsIntegrated/MigrateAssist/migrator.js @@ -0,0 +1,364 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Pulls projects, preferences and extensions across from the origin being retired. + * + * Runs on the new origin only. A hidden iframe on the legacy origin (see src/migrateAssist.html) + * zips one top level folder at a time and streams it over postMessage; this side reassembles each + * zip and hands it to ZipUtils.unzipBinDataToLocation, which writes files without deleting anything + * first. That gives us exactly the collision rule we want, source wins and extras survive. + * + * The automatic path is silent unless there is genuinely something to move. The manual path, driven + * from the Help menu, always reports what happened, because an explicit user action that appears to + * do nothing is worse than no action at all. + * + * @module extensionsIntegrated/MigrateAssist/migrator + */ +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"), + PreferencesManager = require("preferences/PreferencesManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + Constants = require("./constants"), + progressTemplate = require("text!./html/migrate-progress.html"); + + const HANDSHAKE_TIMEOUT_MS = 15000, + BUNDLE_TIMEOUT_MS = 120000, + IFRAME_ID = "migrate-assist-frame"; + + const RESULT_MIGRATED = "migrated", + RESULT_NOTHING = "nothing", + RESULT_UNREACHABLE = "unreachable"; + + let migrationRunning = false; + + /** + * Talks to the helper page on the legacy origin. Resolves once the scan comes back; individual + * bundles are then requested one at a time so only one zip is ever in memory. + */ + function _createBridge() { + const legacyOrigin = Constants.getLegacyOrigin(); + const iframe = document.createElement("iframe"); + iframe.id = IFRAME_ID; + iframe.setAttribute("title", "data migration helper"); + iframe.style.display = "none"; + + let pendingScan = null, + bundleHandler = null, + destroyed = false; + + function _onMessage(event) { + // Both checks matter: the origin proves who sent it, the source proves it came from our + // frame rather than any other iframe sharing this window's message bus. + if (event.origin !== legacyOrigin || event.source !== iframe.contentWindow) { + return; + } + const data = event.data; + if (!data || typeof data !== "object") { + return; + } + if (data.type === "MIGRATE_READY" && pendingScan) { + iframe.contentWindow.postMessage({ type: "MIGRATE_SCAN" }, legacyOrigin); + } else if (data.type === "MIGRATE_SCAN_RESULT" && pendingScan) { + const resolve = pendingScan; + pendingScan = null; + resolve(data); + } else if (bundleHandler) { + bundleHandler(data); + } + } + + // Registered before the src is set. The frame can post READY faster than the next microtask, + // and attaching afterwards loses the handshake and hangs until the timeout. + window.addEventListener("message", _onMessage); + + function destroy() { + if (destroyed) { + return; + } + destroyed = true; + window.removeEventListener("message", _onMessage); + if (iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + } + + function scan() { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingScan = null; + // Covers the origin being offline, refusing to be framed, or simply not having + // this page deployed yet. + reject(new Error("timed out waiting for " + legacyOrigin)); + }, HANDSHAKE_TIMEOUT_MS); + pendingScan = function (result) { + clearTimeout(timer); + resolve(result); + }; + iframe.src = Constants.getMigrateAssistURL() + "?parentOrigin=" + + encodeURIComponent(location.origin); + document.body.appendChild(iframe); + }); + } + + /** + * Requests one bundle and reassembles its chunks into a single ArrayBuffer. + */ + function fetchBundle(id) { + return new Promise((resolve, reject) => { + const chunks = []; + let expected = -1, + received = 0; + const timer = setTimeout(() => { + bundleHandler = null; + reject(new Error("timed out receiving " + id)); + }, BUNDLE_TIMEOUT_MS); + + bundleHandler = function (data) { + if (data.id !== id) { + return; + } + if (data.type === "MIGRATE_ERROR") { + clearTimeout(timer); + bundleHandler = 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); + } + } + }; + iframe.contentWindow.postMessage({ type: "MIGRATE_BUNDLE", id: id }, legacyOrigin); + }); + } + + return { scan, fetchBundle, 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; + + 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)); + }, + 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)); + }, + 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(); + }); + } + }; + } + + 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. + await PreferencesManager.save(); + const prefFile = PreferencesManager.getUserPrefFile(); + await Phoenix.VFS.writeFileAsync(prefFile, prefsText, "utf8"); + // Tell the preference system the file changed so the scope reloads and listeners, including + // the theme, pick it up without a restart. + PreferencesManager.fileChanged(prefFile); + } + + function _applyPhStore(phStore) { + for (const key of Object.keys(phStore || {})) { + PhStore.setItem(key, phStore[key]); + } + } + + /** + * @param {boolean} manual true when the user asked for this from the Help menu + * @return {Promise} one of RESULT_MIGRATED, RESULT_NOTHING, RESULT_UNREACHABLE + */ + async function run(manual) { + if (migrationRunning) { + return RESULT_NOTHING; + } + migrationRunning = true; + const bridge = _createBridge(); + let progress = null; + try { + const scan = await bridge.scan(); + if (!scan.hasData || !scan.bundles.length) { + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", + manual ? "manualNothing" : "autoNothing"); + return RESULT_NOTHING; + } + + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", + manual ? "manualStart" : "autoStart"); + progress = _showProgressDialog(scan.bundles.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); + 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; + } catch (err) { + // One bad folder should not cost the user everything else. + console.error("MigrateAssist: bundle failed", bundle.id, err); + failed.push(name); + } + } + + if (scan.prefs) { + try { + await _applyPreferences(scan.prefs); + } catch (err) { + console.error("MigrateAssist: could not apply preferences", err); + failed.push("phcode.json"); + } + } + _applyPhStore(scan.phStore); + + PhStore.setItem(Constants.MIGRATION_DONE_KEY, { + at: Date.now(), + files: migratedFiles, + failed: failed.length + }); + 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 + }); + 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(); + } + return RESULT_UNREACHABLE; + } finally { + migrationRunning = false; + bridge.destroy(); + } + } + + /** + * Boot time entry point. Stays completely silent unless there is something to move, and never + * runs again once the migration has succeeded. + */ + function runOnBoot() { + if (!Constants.isNewOrigin() || Phoenix.isNativeApp || Phoenix.isTestWindow) { + return; + } + if (!Constants.isMigrationSupportedBrowser()) { + return; + } + if (PhStore.getItem(Constants.MIGRATION_DONE_KEY)) { + return; + } + // Once the legacy origin is gone there is nothing to probe, so the feature disables itself + // rather than opening a doomed iframe on every boot forever. + if (Constants.isPastSunset()) { + return; + } + run(false); + } + + /** + * Help menu entry point. Ignores the done flag and the sunset date, and always says what + * happened. + */ + async function runManually() { + const result = await run(true); + if (result === RESULT_NOTHING) { + Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, + Strings.MIGRATE_NOTHING_TITLE, + StringUtils.format(Strings.MIGRATE_NOTHING_MESSAGE, Constants.LEGACY_DOMAIN_NAME)); + } 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)); + } + } + + exports.runOnBoot = runOnBoot; + exports.runManually = runManually; +}); diff --git a/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js b/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js new file mode 100644 index 0000000000..85ec1ff579 --- /dev/null +++ b/src/extensionsIntegrated/MigrateAssist/sunset-dialog.js @@ -0,0 +1,132 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * The "we are moving" dialog, shown on the origin being retired on every boot. + * + * Three variants, differing only in the primary button and the closing sentence: + * - default, sends the user to the new site where their data migrates automatically; + * - Trusted Web Activity, sends the user to the Play Store instead, because the installed app only + * trusts the legacy origin and navigating it elsewhere would surface a browser URL bar inside + * what looks like an app; + * - Safari/iOS, where the automatic migration is deliberately not implemented, so those users are + * told to download their projects by hand rather than being left to find out at the cutoff. + * + * @module extensionsIntegrated/MigrateAssist/sunset-dialog + */ +define(function (require, exports, module) { + const Dialogs = require("widgets/Dialogs"), + DefaultDialogs = require("widgets/DefaultDialogs"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + Metrics = require("utils/Metrics"), + Constants = require("./constants"); + + const BTN_GO_NEW_SITE = "goNewSite", + BTN_UPDATE_APP = "updateApp", + BTN_STAY = "stay"; + + // document.referrer only reflects the navigation that opened this document, so read it once + // before anything can navigate and hold on to the answer. + const isTWA = Constants.isTWALaunch(); + + function _buildMessage() { + const paragraphs = []; + + paragraphs.push(StringUtils.format(Strings.MIGRATE_MOVING_MESSAGE, + Constants.LEGACY_DOMAIN_NAME, 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)); + } + + if (!Constants.isMigrationSupportedBrowser()) { + paragraphs.push(Strings.MIGRATE_MANUAL_DOWNLOAD_NOTE); + } else if (isTWA) { + paragraphs.push(Strings.MIGRATE_TWA_UPDATE_NOTE); + paragraphs.push(StringUtils.format(Strings.MIGRATE_TWA_BROWSER_LINK, + Constants.getNewOrigin(), Constants.NEW_DOMAIN_NAME)); + } else { + paragraphs.push(StringUtils.format(Strings.MIGRATE_DATA_SAFE_NOTE, Constants.NEW_DOMAIN_NAME)); + } + + return paragraphs.map((text) => `

${text}

`).join(""); + } + + function _buildButtons() { + // "Stay here" is a real choice, not a nag dismiss. On managed ChromeOS fleets the Play Store + // can be blocked outright, so the update button may be a dead end through no fault of the + // user, and the app has to keep working for them. + const stayButton = { + className: Dialogs.DIALOG_BTN_CLASS_NORMAL, + id: BTN_STAY, + text: Strings.MIGRATE_STAY_HERE + }; + if (isTWA) { + return [ + stayButton, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: BTN_UPDATE_APP, + text: Strings.MIGRATE_UPDATE_APP + } + ]; + } + return [ + stayButton, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: BTN_GO_NEW_SITE, + text: Strings.MIGRATE_GO_TO_NEW_SITE + } + ]; + } + + /** + * Shows the dialog. Called once per boot on the legacy origin; dismissing it is per boot only, + * so the user is reminded again next time rather than being able to silence it permanently. + */ + function show() { + const variant = !Constants.isMigrationSupportedBrowser() ? "safari" : (isTWA ? "twa" : "web"); + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", `sunsetShown.${variant}`); + + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + StringUtils.format(Strings.MIGRATE_MOVING_TITLE, Constants.LEGACY_DOMAIN_NAME), + _buildMessage(), + _buildButtons() + ).done(function (buttonId) { + if (buttonId === BTN_GO_NEW_SITE) { + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", "sunsetGoNewSite"); + window.location = Constants.getNewOrigin(); + } else if (buttonId === BTN_UPDATE_APP) { + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", "sunsetUpdateApp"); + window.open(Constants.TWA_STORE_URL, "_blank", "noopener"); + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "migrateAssist", `sunsetStay.${variant}`); + } + }); + } + + exports.show = show; +}); diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index f8c3dfc4bb..7bf8606cfa 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -47,5 +47,6 @@ define(function (require, exports, module) { require("./CollapseFolders/main"); require("./Terminal/main"); require("./JSONSupport/main"); + require("./MigrateAssist/main"); require("./pro-loader"); }); diff --git a/src/migrateAssist.html b/src/migrateAssist.html new file mode 100644 index 0000000000..be36c09ca0 --- /dev/null +++ b/src/migrateAssist.html @@ -0,0 +1,357 @@ + + + + + + Phoenix Code data migration helper + + + + + + + + diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 704cad2a44..8643c94d79 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2916,6 +2916,32 @@ define({ "DEMO_JS_BUTTON_ONE_MORE": "One more thing", "DEMO_JS_BUTTON_START_AGAIN": "Start Again", "DEMO_JS_CARD_COUNT_TEMPLATE": "{NUM_DONE}/3 cards", - "DEMO_JS_DELETE_COUNT_TEMPLATE": "{NUM_DONE}/1 deleted" + "DEMO_JS_DELETE_COUNT_TEMPLATE": "{NUM_DONE}/1 deleted", // demo end + + // Migration off the legacy web origin onto the new home + "MIGRATE_MOVING_TITLE": "{0} is moving", + "MIGRATE_MOVING_MESSAGE": "{APP_NAME} on {0} is moving to a new home at {1}. Everything works the same, only the address changes.", + "MIGRATE_SUNSET_COUNTDOWN": "You have {0} days left before {1} stops working.", + "MIGRATE_SUNSET_COUNTDOWN_ONE": "You have {0} day left before {1} stops working.", + "MIGRATE_DATA_SAFE_NOTE": "Your projects, settings and extensions will be copied over for you the first time you open {0}. Nothing is deleted from this site.", + "MIGRATE_MANUAL_DOWNLOAD_NOTE": "Automatic transfer is not available in this browser. If you have projects saved here, please download them before the date above so you can open them again on the new site.", + "MIGRATE_TWA_UPDATE_NOTE": "Please update the app when you can. Nothing stops working today, and you can keep using this version in the meantime.", + "MIGRATE_TWA_BROWSER_LINK": "If you cannot update right now, you can also continue in a browser at {1}.", + "MIGRATE_GO_TO_NEW_SITE": "Take me to the new site", + "MIGRATE_UPDATE_APP": "Update the app", + "MIGRATE_STAY_HERE": "Stay here", + "MIGRATE_PROGRESS_TITLE": "Bringing your data over", + "MIGRATE_PROGRESS_INTRO": "Copying your projects, settings and extensions from {0}. This only happens once.", + "MIGRATE_PROGRESS_PREPARING": "Preparing {0} ({1} of {2})\u2026", + "MIGRATE_PROGRESS_STATUS": "Copying {0} ({1} of {2})\u2026", + "MIGRATE_DONE_TITLE": "All done.", + "MIGRATE_DONE_MESSAGE": "{0} files were copied over. Reload to start using them.", + "MIGRATE_DONE_PARTIAL": "These could not be copied and are still available on the old site: {0}", + "MIGRATE_RELOAD_NOW": "Reload", + "MIGRATE_NOTHING_TITLE": "Nothing to bring over", + "MIGRATE_NOTHING_MESSAGE": "We could not find any projects, settings or extensions on {0} that need copying.", + "MIGRATE_UNREACHABLE_TITLE": "Could not reach the old site", + "MIGRATE_UNREACHABLE_MESSAGE": "{APP_NAME} could not connect to {0} to check for your data. Please check your connection and try again.", + "CMD_MIGRATE_DATA": "Migrate My Data\u2026" }); diff --git a/src/styles/Extn-MigrateAssist.less b/src/styles/Extn-MigrateAssist.less new file mode 100644 index 0000000000..33329e31d2 --- /dev/null +++ b/src/styles/Extn-MigrateAssist.less @@ -0,0 +1,66 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +// Progress dialog for the one time migration off the legacy web origin. +// The progress bar itself reuses the shared .progress-bar-* classes from brackets.less, so only the +// dialog specific spacing lives here. +.migrate-assist-dialog { + + .migrate-assist-intro { + margin-bottom: 12px; + } + + .progress-bar-container { + margin: 4px 0 10px 0; + } + + // Shown while the other origin is still zipping a folder. There is no progress to report during + // that phase, and a bar frozen at 0% reads as a hang on the largest and slowest folders. + .migrate-assist-bar-indeterminate { + width: 100% !important; + background: repeating-linear-gradient( + to right, + #003366 0%, #004c99 10%, #0066cc 20%, #007bff 30%, #3399ff 40%, + #66b2ff 50%, #3399ff 60%, #007bff 70%, #0066cc 80%, #004c99 90%, #003366 100% + ); + background-size: 200% auto; + animation: migrate-assist-pulse 2s linear infinite; + } + + .migrate-assist-status { + margin: 0; + opacity: 0.8; + } + + .migrate-assist-detail { + margin: 8px 0 0 0; + font-size: 0.9em; + opacity: 0.7; + } +} + +@keyframes migrate-assist-pulse { + 0% { + background-position: 0 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index fd10e6472d..5e65d94402 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -46,6 +46,7 @@ @import "Extn-CSSColorPreview.less"; @import "Extn-CustomSnippets.less"; @import "Extn-CollapseFolders.less"; +@import "Extn-MigrateAssist.less"; @import "Extn-SidebarTabs.less"; @import "Extn-BottomPanelTabs.less"; @import "Extn-AIChatPanel.less"; diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 7786a134d3..b7a159286a 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -142,6 +142,7 @@ define(function (require, exports, module) { require("spec/Extn-JSONSupport-test"); require("spec/Extn-JSONSupport-integ-test"); require("spec/Extn-CollapseFolders-integ-test"); + require("spec/Extn-MigrateAssist-test"); require("spec/Extn-Tabbar-integ-test"); require("spec/Extn-CustomSnippets-test"); require("spec/Extn-CustomSnippets-integ-test"); diff --git a/test/spec/Extn-MigrateAssist-test.js b/test/spec/Extn-MigrateAssist-test.js new file mode 100644 index 0000000000..d1a0481fb6 --- /dev/null +++ b/test/spec/Extn-MigrateAssist-test.js @@ -0,0 +1,296 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, afterEach, awaitsFor */ + +define(function (require, exports, module) { + + const Constants = require("extensionsIntegrated/MigrateAssist/constants"), + ZipUtils = require("utils/ZipUtils"); + + describe("unit:MigrateAssist", function () { + + const DAY = 24 * 60 * 60 * 1000; + + describe("constants", function () { + + describe("daysToSunset", function () { + + it("should count whole days remaining", function () { + expect(Constants.daysToSunset(Constants.SUNSET_DATE - (10 * DAY))).toBe(10); + expect(Constants.daysToSunset(Constants.SUNSET_DATE - DAY)).toBe(1); + }); + + it("should round a part day up, so the last day never reads as zero", function () { + expect(Constants.daysToSunset(Constants.SUNSET_DATE - 1)).toBe(1); + expect(Constants.daysToSunset(Constants.SUNSET_DATE - (DAY + 1))).toBe(2); + }); + + it("should floor at zero on and after the sunset date", function () { + expect(Constants.daysToSunset(Constants.SUNSET_DATE)).toBe(0); + expect(Constants.daysToSunset(Constants.SUNSET_DATE + DAY)).toBe(0); + }); + }); + + describe("isPastSunset", function () { + + it("should be false before the date and true on or after it", function () { + expect(Constants.isPastSunset(Constants.SUNSET_DATE - 1)).toBe(false); + expect(Constants.isPastSunset(Constants.SUNSET_DATE)).toBe(true); + expect(Constants.isPastSunset(Constants.SUNSET_DATE + DAY)).toBe(true); + }); + }); + + describe("origins", function () { + + it("should ignore the dev override inside test windows", function () { + // The override exists so the flow can be exercised locally. It must never apply in + // a test window, otherwise a stray localStorage value could repoint a real + // migration. + expect(Constants.getLegacyOrigin()).toBe("https://staging.phcode.dev"); + expect(Constants.getNewOrigin()).toBe("https://web.phcode.dev"); + }); + + it("should not treat the spec runner as either migration origin", function () { + expect(Constants.isLegacyOrigin()).toBe(false); + expect(Constants.isNewOrigin()).toBe(false); + }); + }); + + describe("getMigrateAssistURL", function () { + + it("should point at the legacy origin", function () { + expect(Constants.getMigrateAssistURL().startsWith(Constants.getLegacyOrigin())).toBe(true); + }); + + it("should mirror the current path prefix rather than assuming the origin root", function () { + // Production serves the app at "/", but the dev server serves it under "/src/" and + // the spec runner under "/test/". Hardcoding "/" would 404 in both of those. + const pathname = location.pathname; + const prefix = pathname.substring(0, pathname.lastIndexOf("/") + 1); + expect(Constants.getMigrateAssistURL()) + .toBe(`${Constants.getLegacyOrigin()}${prefix}migrateAssist.html`); + }); + }); + + describe("browser support", function () { + + it("should exclude Safari and iOS and include everything else", function () { + const expected = !(Phoenix.browser.desktop.isSafari || Phoenix.browser.mobile.isIos); + expect(Constants.isMigrationSupportedBrowser()).toBe(expected); + }); + }); + }); + + // migrateAssist.html is the half of the migration that runs on the origin being retired. It + // is a standalone page rather than part of the app, so the only way to test it honestly is to + // load it and speak the real protocol to it. The iframe here is that page, not an embedded + // Phoenix instance, so this belongs in the unit category. + describe("helper page", function () { + + const HELPER_URL = `${Phoenix.baseURL}migrateAssist.html`; + const SANDBOX = "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/temp/migrate-assist-spec"; + const SEEDED_PROJECT = `${SANDBOX}/seeded`; + + let frames = []; + + /** + * Loads the helper page and collects everything it posts back. + */ + function openHelper(parentOrigin) { + const received = []; + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + const ready = new Promise((resolve) => { + function onMessage(event) { + if (event.source !== iframe.contentWindow) { + return; + } + received.push(event.data); + if (event.data && event.data.type === "MIGRATE_READY") { + resolve(); + } + } + window.addEventListener("message", onMessage); + iframe._cleanup = function () { + window.removeEventListener("message", onMessage); + iframe.remove(); + }; + }); + iframe.src = `${HELPER_URL}?parentOrigin=${encodeURIComponent(parentOrigin)}`; + document.body.appendChild(iframe); + frames.push(iframe); + return { iframe, received, ready }; + } + + function send(helper, message) { + helper.iframe.contentWindow.postMessage(message, location.origin); + } + + function lastOfType(received, type) { + for (let i = received.length - 1; i >= 0; i--) { + if (received[i] && received[i].type === type) { + return received[i]; + } + } + return null; + } + + beforeAll(async function () { + // A project of our own under /temp, so the assertions do not depend on whatever the + // machine running the tests happens to have in /fs/local. + await Phoenix.VFS.unlinkAsync(SANDBOX).catch(() => {}); + await Phoenix.VFS.ensureExistsDirAsync(SEEDED_PROJECT); + }); + + afterAll(async function () { + await Phoenix.VFS.unlinkAsync(SANDBOX).catch(() => {}); + }); + + afterEach(function () { + frames.forEach((frame) => frame._cleanup && frame._cleanup()); + frames = []; + }); + + /** + * Proving a negative needs a bound on "long enough". Rather than sleeping for an arbitrary + * period, run a trusted helper alongside the untrusted one and wait for the trusted one to + * complete a full handshake and scan. Once that has happened, the untrusted page has + * demonstrably had more than enough time to answer, and its silence means something. + */ + async function expectSilenceWhile(untrusted, forgedMessages) { + const control = openHelper(location.origin); + await control.ready; + forgedMessages.forEach((message) => send(untrusted, message)); + send(control, {type: "MIGRATE_SCAN"}); + await awaitsFor(function () { + return !!lastOfType(control.received, "MIGRATE_SCAN_RESULT"); + }, "the trusted control to finish a full exchange", 20000); + expect(untrusted.received.length).toBe(0); + } + + it("should hand nothing at all to an untrusted parent origin", async function () { + const helper = openHelper("https://web.phcode.dev.evil.example"); + await expectSilenceWhile(helper, [ + {type: "MIGRATE_SCAN"}, + {type: "MIGRATE_BUNDLE", id: "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/fs/local"} + ]); + }); + + it("should refuse an origin that only shares a prefix with a trusted one", async function () { + // A startsWith check would let this through and hand over every file the user owns. + const helper = openHelper(`${location.origin}.evil.example`); + await expectSilenceWhile(helper, [{type: "MIGRATE_SCAN"}]); + }); + + it("should complete the handshake for a trusted parent origin", async function () { + const helper = openHelper(location.origin); + await helper.ready; + expect(lastOfType(helper.received, "MIGRATE_READY")).not.toBe(null); + }); + + it("should report a scan describing the projects, prefs and extensions", async function () { + const helper = openHelper(location.origin); + await helper.ready; + send(helper, {type: "MIGRATE_SCAN"}); + await awaitsFor(function () { + return !!lastOfType(helper.received, "MIGRATE_SCAN_RESULT"); + }, "scan result", 20000); + + const scan = lastOfType(helper.received, "MIGRATE_SCAN_RESULT"); + expect(typeof scan.hasData).toBe("boolean"); + expect(Array.isArray(scan.bundles)).toBe(true); + for (const bundle of scan.bundles) { + // Every bundle must name a real destination and be one of the roots we allow. + expect(typeof bundle.dest).toBe("string"); + expect(bundle.dest.startsWith("/fs/")).toBe(true); + expect(bundle.fileCount).toBeGreaterThan(0); + } + }); + + it("should refuse to zip anything outside the migratable roots", async function () { + const helper = openHelper(location.origin); + await helper.ready; + const escapes = [ + "/mnt", + "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/fs/app", + "/fs/local/../../mnt", + "/fs/app/extensions/user/../../aiHistory", + SEEDED_PROJECT + ]; + escapes.forEach((id) => send(helper, {type: "MIGRATE_BUNDLE", id: id})); + await awaitsFor(function () { + return helper.received.filter((m) => m.type === "MIGRATE_ERROR").length === escapes.length; + }, "every escaping path to be rejected", 10000); + expect(lastOfType(helper.received, "MIGRATE_CHUNK")).toBe(null); + }); + + it("should round trip a real folder, overwriting collisions and keeping extras", async function () { + const helper = openHelper(location.origin); + await helper.ready; + send(helper, {type: "MIGRATE_SCAN"}); + await awaitsFor(function () { + return !!lastOfType(helper.received, "MIGRATE_SCAN_RESULT"); + }, "scan result", 20000); + + const scan = lastOfType(helper.received, "MIGRATE_SCAN_RESULT"); + const bundle = scan.bundles[0]; + expect(bundle).toBeTruthy(); + + send(helper, {type: "MIGRATE_BUNDLE", id: bundle.id}); + await awaitsFor(function () { + const last = lastOfType(helper.received, "MIGRATE_CHUNK"); + return !!last && last.last === true; + }, "all chunks to arrive", 60000); + + const chunks = helper.received.filter((m) => m.type === "MIGRATE_CHUNK" && m.id === bundle.id); + const meta = lastOfType(helper.received, "MIGRATE_BUNDLE_META"); + expect(chunks.length).toBe(meta.chunkCount); + + const total = chunks.reduce((sum, c) => sum + c.chunk.byteLength, 0); + expect(total).toBe(meta.totalBytes); + const merged = new Uint8Array(total); + let offset = 0; + chunks.sort((a, b) => a.index - b.index).forEach((c) => { + merged.set(new Uint8Array(c.chunk), offset); + offset = offset + c.chunk.byteLength; + }); + + // Pre-seed the destination the way the new origin would already look. + const dest = `${SANDBOX}/restored`; + await Phoenix.VFS.ensureExistsDirAsync(dest); + await Phoenix.VFS.writeFileAsync(`${dest}/only-here.txt`, "keep me", "utf8"); + + const ticks = []; + await ZipUtils.unzipBinDataToLocation(merged.buffer, dest, false, function (done, totalCount) { + ticks.push({done, totalCount}); + return true; + }); + + expect(ticks.length).toBeGreaterThan(0); + expect(ticks[ticks.length - 1].done).toBe(ticks[ticks.length - 1].totalCount); + + // The file that only existed on the receiving side must survive the merge. + const survivor = await Phoenix.VFS.readFileAsync(`${dest}/only-here.txt`, "utf8"); + expect(survivor).toBe("keep me"); + }); + }); + }); +});