diff --git a/src-node/package-lock.json b/src-node/package-lock.json index ec84f2d7c..edb8b114d 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@phcode/node-core", - "version": "5.1.1-0", + "version": "5.1.2-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.1-0", + "version": "5.1.2-0", "license": "GNU-AGPL3.0", "dependencies": { "@expo/sudo-prompt": "^9.3.2", diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index e9e64ca51..7d468458e 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -1731,10 +1731,18 @@ define(function (require, exports, module) { .finally(()=>{ raceAgainstTime(_safeNodeTerminate()) .finally(()=>{ - // In Electron, use allowClose() to bypass the close handler - // (which would otherwise trigger another cleanup cycle). + // In Electron multi-window case, use allowClose() to bypass + // the close handler (which would otherwise trigger another + // cleanup cycle). But for last window, closeWindow() calls + // quitApp() (no loop) and runs quitTimeAppUpdateHandler. if(window.__ELECTRON__) { - window.electronAPI.allowClose(); + Phoenix.app.getPhoenixInstanceCount().then(count => { + if(count === 1) { + Phoenix.app.closeWindow(); + } else { + window.electronAPI.allowClose(); + } + }); } else { Phoenix.app.closeWindow(); } @@ -2276,11 +2284,19 @@ define(function (require, exports, module) { raceAgainstTime(_safeNodeTerminate()) .finally(()=>{ closeInProgress = false; - // In Electron, we must call allowClose() to complete the original - // close request (sets forceClose=true). Calling closeWindow() would + // In Electron multi-window case, we must call allowClose() to + // complete the original close request. Calling closeWindow() would // trigger a new close sequence and cause an infinite loop. + // But for last window, closeWindow() calls quitApp() (no loop), + // and we need it to run quitTimeAppUpdateHandler. if(window.__ELECTRON__) { - window.electronAPI.allowClose(); + Phoenix.app.getPhoenixInstanceCount().then(count => { + if(count === 1) { + Phoenix.app.closeWindow(); + } else { + window.electronAPI.allowClose(); + } + }); } else { Phoenix.app.closeWindow(); } diff --git a/src/extensionsIntegrated/appUpdater/main.js b/src/extensionsIntegrated/appUpdater/main.js index cb477df8a..72bcfdb26 100644 --- a/src/extensionsIntegrated/appUpdater/main.js +++ b/src/extensionsIntegrated/appUpdater/main.js @@ -24,6 +24,7 @@ // shell.js file. This is app updates are pretty core level even though we do it as an extension here. define(function (require, exports, module) { + require("./update-electron"); const AppInit = require("utils/AppInit"), Metrics = require("utils/Metrics"), FileSystem = require("filesystem/FileSystem"), @@ -517,11 +518,17 @@ define(function (require, exports, module) { let updateInstalledDialogShown = false, updateFailedDialogShown = false; AppInit.appReady(function () { - if(!window.__TAURI__ || Phoenix.isTestWindow) { + if(Phoenix.isTestWindow) { + return; + } + if(window.__ELECTRON__) { + // Electron updates handled by update-electron.js + return; + } + if(!window.__TAURI__) { // app updates are only for desktop builds return; } - // todo electron edge for app updater if (brackets.platform === "mac") { // in mac, the `update.app.tar.gz` is downloaded, and only extracted on app quit. // we do this only in mac as the `.app` file is extracted only at app quit and deleted diff --git a/src/extensionsIntegrated/appUpdater/update-electron.js b/src/extensionsIntegrated/appUpdater/update-electron.js new file mode 100644 index 000000000..db351b040 --- /dev/null +++ b/src/extensionsIntegrated/appUpdater/update-electron.js @@ -0,0 +1,515 @@ +/* + * 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 logger*/ + +// Electron-specific app updater for Linux +// Windows/Mac are not supported in Electron edge builds + +define(function (require, exports, module) { + const AppInit = require("utils/AppInit"), + Metrics = require("utils/Metrics"), + Commands = require("command/Commands"), + CommandManager = require("command/CommandManager"), + Menus = require("command/Menus"), + Dialogs = require("widgets/Dialogs"), + DefaultDialogs = require("widgets/DefaultDialogs"), + Strings = require("strings"), + marked = require('thirdparty/marked.min'), + semver = require("thirdparty/semver.browser"), + NotificationUI = require("widgets/NotificationUI"), + TaskManager = require("features/TaskManager"), + NativeApp = require("utils/NativeApp"), + PreferencesManager = require("preferences/PreferencesManager"); + + let updateTask, updatePendingRestart, updateFailed; + + const KEY_LAST_UPDATE_CHECK_TIME = "PH_LAST_UPDATE_CHECK_TIME", + KEY_LAST_UPDATE_DESCRIPTION = "PH_LAST_UPDATE_DESCRIPTION", + KEY_UPDATE_AVAILABLE = "PH_UPDATE_AVAILABLE"; + + const PREFS_AUTO_UPDATE = "autoUpdate"; + const MAX_LOG_LINES = 500; + let isAutoUpdateFlow = true; + let updateScheduled = false; + let cachedUpdateDetails = null; + + function showOrHideUpdateIcon() { + if(updateScheduled && !updateTask) { + updateTask = TaskManager.addNewTask(Strings.UPDATING_APP, Strings.UPDATING_APP_MESSAGE, + ``, { + noSpinnerNotification: isAutoUpdateFlow, + onSelect: function () { + if(updatePendingRestart){ + Dialogs.showInfoDialog(Strings.UPDATE_READY_RESTART_TITLE, + Strings.UPDATE_READY_RESTART_INSTALL_MESSAGE); + } else if(updateFailed){ + Dialogs.showInfoDialog(Strings.UPDATE_FAILED_TITLE, Strings.UPDATE_FAILED_MESSAGE); + } else { + Dialogs.showInfoDialog(Strings.UPDATING_APP, Strings.UPDATING_APP_DIALOG_MESSAGE); + } + } + }); + if(!isAutoUpdateFlow) { + updateTask.show(); + } else { + updateTask.flashSpinnerForAttention(); + } + } + let updateAvailable = PreferencesManager.getViewState(KEY_UPDATE_AVAILABLE); + if(updateAvailable){ + $("#update-notification").removeClass("forced-hidden"); + } else { + $("#update-notification").addClass("forced-hidden"); + } + } + + function fetchJSON(url) { + return fetch(url) + .then(response => { + if (!response.ok) { + return null; + } + return response.json(); + }); + } + + async function getUpdatePlatformKey() { + const platformArch = await Phoenix.app.getPlatformArch(); + let os = 'windows'; + if (brackets.platform === "mac") { + os = "darwin"; + } else if (brackets.platform === "linux") { + os = "linux"; + } + return `${os}-${platformArch}`; + } + + async function getUpdateDetails() { + const updatePlatformKey = await getUpdatePlatformKey(); + const updateDetails = { + shouldUpdate: false, + updatePendingRestart: false, + downloadURL: null, + currentVersion: Phoenix.metadata.apiVersion, + updateVersion: null, + releaseNotesMarkdown: null, + updatePlatform: updatePlatformKey + }; + try{ + const updateMetadata = await fetchJSON(brackets.config.app_update_url); + // In Electron, binary version and loaded app version are always the same + // since both are loaded at app start and only change after full restart + const currentVersion = await window.electronAPI.getAppVersion(); + if(semver.gt(updateMetadata.version, currentVersion)){ + console.log("Update available: ", updateMetadata, "Detected platform: ", updatePlatformKey); + PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, true); + updateDetails.shouldUpdate = true; + updateDetails.updateVersion = updateMetadata.version; + updateDetails.releaseNotesMarkdown = updateMetadata.notes; + if(updateMetadata.platforms && updateMetadata.platforms[updatePlatformKey]){ + updateDetails.downloadURL = updateMetadata.platforms[updatePlatformKey].url; + } + } else { + console.log("no updates available for platform: ", updateDetails.updatePlatform); + PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false); + } + showOrHideUpdateIcon(); + } catch (e) { + console.error("Error getting update metadata", e); + logger.reportError(e, `Error getting app update metadata`); + updateFailed = true; + Metrics.countEvent(Metrics.EVENT_TYPE.UPDATES, 'fail', "Unknown"+Phoenix.platform); + } + return updateDetails; + } + + /** + * Check if we're at an upgradable location. + * For Electron on Linux, we require the AppImage to be in ~/.phoenix-code/ + */ + async function isUpgradableLocation() { + try { + const isPackaged = await window.electronAPI.isPackaged(); + if (!isPackaged) { + return false; + } + const homeDir = await window.electronFSAPI.homeDir(); + const phoenixInstallDir = `${homeDir}.phoenix-code/`; + const execPath = await window.electronAPI.getExecutablePath(); + return execPath.startsWith(phoenixInstallDir); + } catch (e) { + console.error(e); + return false; + } + } + + function _getButtons(isUpgradableLoc) { + const updateLater = + {className: Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.UPDATE_LATER }; + const getItNow = + { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.GET_IT_NOW }; + const updateOnExit = + { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.UPDATE_ON_EXIT }; + if(!isUpgradableLoc) { + return [updateLater, getItNow]; + } + return [updateLater, updateOnExit]; + } + + async function scheduleUpdate(updateDetails) { + updateScheduled = true; + updatePendingRestart = true; + cachedUpdateDetails = updateDetails; + // Store in shared state so other windows know update is scheduled + await window.electronAPI.setUpdateScheduled(true); + showOrHideUpdateIcon(); + Metrics.countEvent(Metrics.EVENT_TYPE.UPDATES, 'scheduled', Phoenix.platform); + updateTask.setSucceded(); + updateTask.setTitle(Strings.UPDATE_DONE); + updateTask.setMessage(Strings.UPDATE_RESTART_INSTALL); + NotificationUI.createToastFromTemplate(Strings.UPDATE_READY_RESTART_TITLE, + `
${Strings.UPDATE_FAILED_VISIT_SITE_MESSAGE}
+${fullLogText}
+
+ ${Strings.UPDATE_INSTALLING_MESSAGE}
+ + `; + + dialog = Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + Strings.UPDATE_INSTALLING, + dialogContent, + [ + { + className: "forced-hidden", + id: Dialogs.DIALOG_BTN_OK, + text: Strings.OK + } + ], + false + ); + + launchLinuxUpdater((type, text) => { + appendLogLine(text); + }) + .then(resolve) + .catch(failUpdateDialogAndExit); + }); + } + + AppInit.appReady(async function () { + if(!window.__ELECTRON__ || Phoenix.isTestWindow) { + return; + } + // Electron updates only supported on Linux currently + if (brackets.platform !== "linux") { + console.error("App updates not yet implemented on this platform in Electron builds!"); + return; + } + // Check if another window already scheduled an update (multi-window state persistence) + // This ensures the quit handler is registered in this window too + try { + const isUpdateScheduled = await window.electronAPI.getUpdateScheduled(); + if (isUpdateScheduled) { + updateScheduled = true; + updatePendingRestart = true; + // Create task in success state (update ready, waiting for restart) + updateTask = TaskManager.addNewTask(Strings.UPDATE_DONE, Strings.UPDATE_RESTART_INSTALL, + ``, { + noSpinnerNotification: true, + onSelect: function () { + Dialogs.showInfoDialog(Strings.UPDATE_READY_RESTART_TITLE, + Strings.UPDATE_READY_RESTART_INSTALL_MESSAGE); + } + }); + updateTask.setSucceded(); + Phoenix.app.registerQuitTimeAppUpdateHandler(quitTimeAppUpdateHandler); + console.log("Update was scheduled in another window, registering quit handler"); + } + } catch (e) { + console.error("Error checking shared state for update state:", e); + } + $("#update-notification").click(()=>{ + checkForUpdates(); + }); + CommandManager.register(Strings.CMD_CHECK_FOR_UPDATE, Commands.HELP_CHECK_UPDATES, ()=>{ + checkForUpdates(); + }); + CommandManager.register(Strings.CMD_AUTO_UPDATE, Commands.HELP_AUTO_UPDATE, ()=>{ + PreferencesManager.set(PREFS_AUTO_UPDATE, !PreferencesManager.get(PREFS_AUTO_UPDATE)); + }); + const helpMenu = Menus.getMenu(Menus.AppMenuBar.HELP_MENU); + helpMenu.addMenuItem(Commands.HELP_CHECK_UPDATES, "", Menus.AFTER, Commands.HELP_GET_INVOLVED); + PreferencesManager.definePreference(PREFS_AUTO_UPDATE, "boolean", true, { + description: Strings.DESCRIPTION_AUTO_UPDATE + }); + showOrHideUpdateIcon(); + const lastUpdateDetails = PreferencesManager.getViewState(KEY_LAST_UPDATE_DESCRIPTION); + if(lastUpdateDetails && (lastUpdateDetails.updateVersion === Phoenix.metadata.apiVersion)) { + let markdownHtml = marked.parse(lastUpdateDetails.releaseNotesMarkdown || ""); + Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml); + PreferencesManager.setViewState(KEY_LAST_UPDATE_DESCRIPTION, null); + PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false); + $("#update-notification").addClass("forced-hidden"); + } + // check for updates at boot + let lastUpdateCheckTime = PreferencesManager.getViewState(KEY_LAST_UPDATE_CHECK_TIME); + const currentTime = Date.now(); + const oneDayInMilliseconds = 24 * 60 * 60 * 1000; + if(lastUpdateCheckTime && ((currentTime - lastUpdateCheckTime) < oneDayInMilliseconds)){ + console.log("Skipping update check: last update check was within one day"); + return; + } + PreferencesManager.setViewState(KEY_LAST_UPDATE_CHECK_TIME, currentTime); + checkForUpdates(true); + }); +});