652 lines
25 KiB
JavaScript
652 lines
25 KiB
JavaScript
|
|
/**
|
||
|
|
* Auto-Update Bridge
|
||
|
|
*
|
||
|
|
* Wraps electron-updater to provide IPC-driven update checks, downloads, and
|
||
|
|
* install-on-quit. Designed around a "prompt" model: the renderer asks to
|
||
|
|
* check, then explicitly triggers download and install.
|
||
|
|
*
|
||
|
|
* Linux packages use electron-updater's package-manager path when the
|
||
|
|
* electron-builder package-type marker is present. Unmarked Linux builds
|
||
|
|
* (including snap and development runs) get a graceful fallback so the
|
||
|
|
* renderer can offer a manual "open GitHub releases" link.
|
||
|
|
*/
|
||
|
|
|
||
|
|
let _deps = null;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read the persisted auto-update preference from a JSON file in userData.
|
||
|
|
* Returns true (default) if the file doesn't exist or is unreadable.
|
||
|
|
*/
|
||
|
|
function readAutoUpdatePreference() {
|
||
|
|
try {
|
||
|
|
const { app } = _deps?.electronModule || {};
|
||
|
|
if (!app) return true;
|
||
|
|
const path = require('path');
|
||
|
|
const fs = require('fs');
|
||
|
|
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||
|
|
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
|
||
|
|
return data.enabled !== false;
|
||
|
|
} catch {
|
||
|
|
return true; // default to enabled
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Persist the auto-update preference to a JSON file in userData.
|
||
|
|
*/
|
||
|
|
function writeAutoUpdatePreference(enabled) {
|
||
|
|
try {
|
||
|
|
const { app } = _deps?.electronModule || {};
|
||
|
|
if (!app) return;
|
||
|
|
const path = require('path');
|
||
|
|
const fs = require('fs');
|
||
|
|
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||
|
|
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
|
||
|
|
} catch (err) {
|
||
|
|
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const SUPPORTED_LINUX_PACKAGE_TYPES = new Set(["deb", "rpm", "pacman"]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Identify the Linux package format written by electron-builder into the
|
||
|
|
* packaged resources directory. AppImage does not have this marker; its
|
||
|
|
* runtime exposes the APPIMAGE environment variable instead.
|
||
|
|
*/
|
||
|
|
function getLinuxPackageType() {
|
||
|
|
if (process.env.APPIMAGE) return "AppImage";
|
||
|
|
if (typeof process.resourcesPath !== "string") return null;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
const packageType = fs.readFileSync(path.join(process.resourcesPath, "package-type"), "utf8").trim();
|
||
|
|
return SUPPORTED_LINUX_PACKAGE_TYPES.has(packageType) ? packageType : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns true when the current packaging format supports electron-updater
|
||
|
|
* (macOS zip/dmg, Windows NSIS, Linux AppImage/deb/rpm/pacman).
|
||
|
|
*/
|
||
|
|
function isAutoUpdateSupported() {
|
||
|
|
if (process.platform === "darwin" || process.platform === "win32") {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return process.platform === "linux" && getLinuxPackageType() !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Lazily resolved autoUpdater — avoids importing electron-updater in
|
||
|
|
* contexts where native modules might not be available. */
|
||
|
|
let _autoUpdater = null;
|
||
|
|
|
||
|
|
/** Guard against duplicate listener registration */
|
||
|
|
let _listenersRegistered = false;
|
||
|
|
|
||
|
|
/** Track whether a download is in progress to distinguish download errors from check errors */
|
||
|
|
let _isDownloading = false;
|
||
|
|
|
||
|
|
/** Track whether quitAndInstall has entered the package installation phase */
|
||
|
|
let _isInstalling = false;
|
||
|
|
|
||
|
|
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
|
||
|
|
let _isChecking = false;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Snapshot of the last known update status so newly opened windows can hydrate
|
||
|
|
* without waiting for the next IPC event.
|
||
|
|
* @type {{ status: 'idle' | 'downloading' | 'ready' | 'error', percent: number, error: string | null, version: string | null, isChecking: boolean }}
|
||
|
|
*/
|
||
|
|
let _lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
|
||
|
|
function getAutoUpdater() {
|
||
|
|
if (_autoUpdater) return _autoUpdater;
|
||
|
|
try {
|
||
|
|
const { autoUpdater } = require("electron-updater");
|
||
|
|
autoUpdater.autoDownload = readAutoUpdatePreference();
|
||
|
|
autoUpdater.autoInstallOnAppQuit = false;
|
||
|
|
// Silence the default electron-log transport (we log ourselves).
|
||
|
|
autoUpdater.logger = null;
|
||
|
|
_autoUpdater = autoUpdater;
|
||
|
|
return autoUpdater;
|
||
|
|
} catch (err) {
|
||
|
|
console.error("[AutoUpdate] Failed to load electron-updater:", err?.message || err);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Register persistent global IPC event listeners for auto-download flow.
|
||
|
|
* Called once in init(). Forwards electron-updater events to the renderer
|
||
|
|
* even when no manual download was initiated.
|
||
|
|
*/
|
||
|
|
function setupGlobalListeners() {
|
||
|
|
if (_listenersRegistered) return;
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (!updater) return;
|
||
|
|
_listenersRegistered = true;
|
||
|
|
|
||
|
|
updater.on("update-not-available", () => {
|
||
|
|
_isChecking = false;
|
||
|
|
// Reset stale status so late-opening windows don't hydrate from a
|
||
|
|
// previous 'error' or 'ready' snapshot after a "no update" check.
|
||
|
|
_lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
|
||
|
|
broadcastToAllWindows("netcatty:update:update-not-available", {});
|
||
|
|
});
|
||
|
|
|
||
|
|
updater.on("update-available", (info) => {
|
||
|
|
_isChecking = false;
|
||
|
|
// Only track as downloading when autoDownload is enabled — otherwise no
|
||
|
|
// download will actually start and the status would be stuck at 0%.
|
||
|
|
// Use 'available' so late-opening windows can still hydrate the version.
|
||
|
|
const willDownload = updater.autoDownload !== false;
|
||
|
|
_isDownloading = willDownload;
|
||
|
|
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||
|
|
broadcastToAllWindows("netcatty:update:update-available", {
|
||
|
|
version: info.version || "",
|
||
|
|
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
|
||
|
|
releaseDate: info.releaseDate || null,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
updater.on("download-progress", (info) => {
|
||
|
|
_lastStatus.percent = Math.round(info.percent ?? 0);
|
||
|
|
broadcastToAllWindows("netcatty:update:download-progress", {
|
||
|
|
percent: info.percent ?? 0,
|
||
|
|
bytesPerSecond: info.bytesPerSecond ?? 0,
|
||
|
|
transferred: info.transferred ?? 0,
|
||
|
|
total: info.total ?? 0,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
updater.on("update-downloaded", () => {
|
||
|
|
_isDownloading = false;
|
||
|
|
_lastStatus = { ..._lastStatus, status: 'ready', percent: 100 };
|
||
|
|
broadcastToAllWindows("netcatty:update:downloaded");
|
||
|
|
});
|
||
|
|
|
||
|
|
updater.on("error", (err) => {
|
||
|
|
_isChecking = false;
|
||
|
|
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
|
||
|
|
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
|
||
|
|
// Install errors are also broadcast: Linux package managers report a cancelled
|
||
|
|
// elevation prompt or a failed command after update-downloaded has already
|
||
|
|
// cleared _isDownloading.
|
||
|
|
if (!_isDownloading && !_isInstalling) {
|
||
|
|
_lastStatus = { ..._lastStatus, isChecking: false };
|
||
|
|
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
_isDownloading = false;
|
||
|
|
const errorMsg = err?.message || "Unknown update error";
|
||
|
|
if (_isInstalling) {
|
||
|
|
_isInstalling = false;
|
||
|
|
cancelQuittingForUpdateWatchdog();
|
||
|
|
setQuittingForUpdate(false);
|
||
|
|
}
|
||
|
|
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
|
||
|
|
broadcastToAllWindows("netcatty:update:error", {
|
||
|
|
error: errorMsg,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log("[AutoUpdate] Global listeners registered");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Trigger an automatic update check after a delay.
|
||
|
|
* No-op on platforms that don't support auto-update (for example Linux snap
|
||
|
|
* or an unmarked development build).
|
||
|
|
* Called from main process after the main window is created.
|
||
|
|
*
|
||
|
|
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
|
||
|
|
*/
|
||
|
|
let _autoCheckTimer = null;
|
||
|
|
|
||
|
|
function startAutoCheck(delayMs = 5000) {
|
||
|
|
if (!isAutoUpdateSupported()) {
|
||
|
|
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Cancel any existing timer to avoid duplicate concurrent checks
|
||
|
|
// (e.g. from multiple windows initializing or re-enable toggle).
|
||
|
|
cancelAutoCheck();
|
||
|
|
_autoCheckTimer = setTimeout(async () => {
|
||
|
|
_autoCheckTimer = null;
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (!updater) {
|
||
|
|
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Respect autoDownload flag — the renderer may have disabled it via IPC
|
||
|
|
// before this timer fires.
|
||
|
|
if (updater.autoDownload === false) {
|
||
|
|
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
_isChecking = true;
|
||
|
|
_lastStatus = { ..._lastStatus, isChecking: true };
|
||
|
|
try {
|
||
|
|
console.log("[AutoUpdate] Starting automatic update check...");
|
||
|
|
await updater.checkForUpdates();
|
||
|
|
} catch (err) {
|
||
|
|
_isChecking = false;
|
||
|
|
_lastStatus = { ..._lastStatus, isChecking: false };
|
||
|
|
console.warn("[AutoUpdate] Auto-check failed:", err?.message || err);
|
||
|
|
}
|
||
|
|
}, delayMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cancel a pending startAutoCheck timer. Called when the renderer triggers
|
||
|
|
* a manual check to avoid racing with the queued auto-check.
|
||
|
|
*/
|
||
|
|
function cancelAutoCheck() {
|
||
|
|
if (_autoCheckTimer) {
|
||
|
|
clearTimeout(_autoCheckTimer);
|
||
|
|
_autoCheckTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Flip the windowManager "quitting for update" flag, swallowing the case where
|
||
|
|
* the window manager module isn't available. Used by the install handler to
|
||
|
|
* commit the app to a clean quit before quitAndInstall, and to roll back if the
|
||
|
|
* install never actually quits (#1215).
|
||
|
|
*/
|
||
|
|
function setQuittingForUpdate(enabled) {
|
||
|
|
try {
|
||
|
|
const windowManager = require("./windowManager.cjs");
|
||
|
|
windowManager.setQuittingForUpdate(!!enabled);
|
||
|
|
} catch {
|
||
|
|
// ignore — window manager may not be available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The webContents for usable dirty-editor windows. Used by the install handler
|
||
|
|
* to ask every renderer that can own editor tabs about unsaved work before
|
||
|
|
* committing to a quit. Targets registered editor owners specifically (not
|
||
|
|
* getAllWindows()[0]) so we never query tray/settings windows, whose renderers
|
||
|
|
* don't participate in the dirty-editor protocol.
|
||
|
|
*/
|
||
|
|
function getDirtyEditorWebContentsList() {
|
||
|
|
try {
|
||
|
|
const windowManager = require("./windowManager.cjs");
|
||
|
|
const windows = typeof windowManager.getDirtyEditorWindows === "function"
|
||
|
|
? windowManager.getDirtyEditorWindows()
|
||
|
|
: typeof windowManager.getMainWindows === "function"
|
||
|
|
? windowManager.getMainWindows()
|
||
|
|
: [windowManager.getMainWindow?.()].filter(Boolean);
|
||
|
|
return windows
|
||
|
|
.filter((win) => win && !win.isDestroyed?.())
|
||
|
|
.map((win) => win.webContents)
|
||
|
|
.filter((wc) => wc && !wc.isDestroyed?.() && !wc.isCrashed?.());
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tell the renderer that the update can't install yet because there are unsaved
|
||
|
|
* editors. The renderer surfaces a toast asking the user to save, then click
|
||
|
|
* "Restart Now" again.
|
||
|
|
*
|
||
|
|
* Broadcast to ALL windows, not just the main one: the install can be triggered
|
||
|
|
* from the Settings window's "Restart to Update" button, and that's the focused
|
||
|
|
* window the user is looking at. Sending only to the (possibly hidden/behind)
|
||
|
|
* main window would make the click appear to do nothing (#1215 review). The
|
||
|
|
* unsaved editors live in the main window, but every window surfaces the same
|
||
|
|
* "save first" notice so it lands wherever the user is.
|
||
|
|
*/
|
||
|
|
function notifyNeedsSave() {
|
||
|
|
broadcastToAllWindows("netcatty:update:needs-save");
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Max time to wait for the renderer's unsaved-editors reply before the install
|
||
|
|
* fails open and proceeds (matches the before-quit guard timeout). */
|
||
|
|
const INSTALL_DIRTY_CHECK_TIMEOUT_MS = 5000;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ask the main-window renderer whether it has unsaved editor changes, reusing
|
||
|
|
* the shared dirty-editor round-trip. Resolves false (fail open) if the helper
|
||
|
|
* is unavailable, so a missing module can never block an install. `ipcMain` is
|
||
|
|
* the instance passed to registerHandlers; the helper needs it to listen for
|
||
|
|
* the reply.
|
||
|
|
*/
|
||
|
|
function queryDirtyEditorsSafe(webContents, ipcMain) {
|
||
|
|
try {
|
||
|
|
const { queryDirtyEditors } = require("./dirtyEditorGuard.cjs");
|
||
|
|
return queryDirtyEditors(webContents, INSTALL_DIRTY_CHECK_TIMEOUT_MS, { ipcMain });
|
||
|
|
} catch (err) {
|
||
|
|
console.warn("[AutoUpdate] dirty-editor guard unavailable:", err?.message || err);
|
||
|
|
return Promise.resolve(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* If quitAndInstall doesn't lead to the app actually quitting (it returns
|
||
|
|
* without app.quit(), e.g. on a Squirrel.Mac follow-up error or a stale
|
||
|
|
* downloaded file), the quitting-for-update flags would stay set and
|
||
|
|
* permanently bypass close-to-tray + the dirty-editor quit guard. This
|
||
|
|
* watchdog clears them if we're still running after a grace period.
|
||
|
|
*
|
||
|
|
* The grace period is deliberately long. On macOS quitAndInstall() can return
|
||
|
|
* while Squirrel.Mac is still pulling the already-downloaded ZIP from the local
|
||
|
|
* update server before it actually closes the windows; for a large/slow update
|
||
|
|
* that second stage can take well over 10s. If the watchdog cleared isQuitting
|
||
|
|
* during that window, the eventual native quit would hit a *non*-quitting
|
||
|
|
* close-to-tray handler and get stranded again — the exact #1215 failure. So we
|
||
|
|
* only roll back after a window long enough that the app is realistically stuck,
|
||
|
|
* not merely slow. The cost of waiting longer is just that close-to-tray stays
|
||
|
|
* bypassed a bit longer in the rare genuine-failure case (#1215 review).
|
||
|
|
*/
|
||
|
|
let _quittingForUpdateWatchdog = null;
|
||
|
|
const QUITTING_FOR_UPDATE_WATCHDOG_MS = 60000;
|
||
|
|
|
||
|
|
function cancelQuittingForUpdateWatchdog() {
|
||
|
|
if (_quittingForUpdateWatchdog) {
|
||
|
|
clearTimeout(_quittingForUpdateWatchdog);
|
||
|
|
_quittingForUpdateWatchdog = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function scheduleQuittingForUpdateWatchdog() {
|
||
|
|
if (_quittingForUpdateWatchdog) {
|
||
|
|
clearTimeout(_quittingForUpdateWatchdog);
|
||
|
|
}
|
||
|
|
_quittingForUpdateWatchdog = setTimeout(() => {
|
||
|
|
_quittingForUpdateWatchdog = null;
|
||
|
|
_isInstalling = false;
|
||
|
|
// Still alive after the grace period — the install did not quit the app.
|
||
|
|
console.warn("[AutoUpdate] App still running after quitAndInstall; clearing quitting-for-update state");
|
||
|
|
setQuittingForUpdate(false);
|
||
|
|
}, QUITTING_FOR_UPDATE_WATCHDOG_MS);
|
||
|
|
// Don't let the watchdog keep the event loop (and thus the process) alive —
|
||
|
|
// if the app is otherwise ready to quit, the timer must not block it.
|
||
|
|
if (typeof _quittingForUpdateWatchdog.unref === "function") {
|
||
|
|
_quittingForUpdateWatchdog.unref();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cancel an install after the main quit guard finds unsaved editor changes.
|
||
|
|
* The main process owns the dirty-editor decision, but the install lifecycle
|
||
|
|
* state lives here and must be released together with the window-manager flag.
|
||
|
|
*/
|
||
|
|
function cancelPendingInstall() {
|
||
|
|
if (!_isInstalling) return;
|
||
|
|
_isInstalling = false;
|
||
|
|
cancelQuittingForUpdateWatchdog();
|
||
|
|
setQuittingForUpdate(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
function init(deps) {
|
||
|
|
_deps = deps;
|
||
|
|
setupGlobalListeners();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Broadcast an IPC event to all non-destroyed BrowserWindows.
|
||
|
|
* Ensures both the main window and settings window always receive
|
||
|
|
* auto-update events.
|
||
|
|
* @param {string} channel
|
||
|
|
* @param {unknown} [payload]
|
||
|
|
*/
|
||
|
|
function broadcastToAllWindows(channel, payload) {
|
||
|
|
try {
|
||
|
|
const { BrowserWindow } = _deps?.electronModule || {};
|
||
|
|
if (!BrowserWindow) return;
|
||
|
|
const windows = BrowserWindow.getAllWindows();
|
||
|
|
for (const win of windows) {
|
||
|
|
if (!win.isDestroyed()) {
|
||
|
|
if (payload !== undefined) {
|
||
|
|
win.webContents.send(channel, payload);
|
||
|
|
} else {
|
||
|
|
win.webContents.send(channel);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.warn("[AutoUpdate] broadcastToAllWindows failed:", err?.message || err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function registerHandlers(ipcMain) {
|
||
|
|
// ---- Check for updates ------------------------------------------------
|
||
|
|
ipcMain.handle("netcatty:update:check", async () => {
|
||
|
|
// Cancel any pending auto-check to prevent concurrent checkForUpdates()
|
||
|
|
// calls — electron-updater rejects them and surfaces false errors.
|
||
|
|
cancelAutoCheck();
|
||
|
|
|
||
|
|
if (!isAutoUpdateSupported()) {
|
||
|
|
return {
|
||
|
|
available: false,
|
||
|
|
supported: false,
|
||
|
|
error: "Auto-update is not supported on this platform/package format.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (!updater) {
|
||
|
|
return {
|
||
|
|
available: false,
|
||
|
|
supported: false,
|
||
|
|
error: "Update module failed to load.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// If a check is already in flight (e.g. from startAutoCheck), don't
|
||
|
|
// start a concurrent one — electron-updater rejects it and surfaces a
|
||
|
|
// confusing error. Return a sentinel so the renderer knows to wait.
|
||
|
|
if (_isChecking) {
|
||
|
|
return { available: false, supported: true, checking: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
// If a download is already in progress or the update is ready to install,
|
||
|
|
// skip the check entirely — calling checkForUpdates() while downloading
|
||
|
|
// can cause electron-updater to error, which corrupts the download state
|
||
|
|
// and forces the user to download manually (GitHub issue #522).
|
||
|
|
if (_isDownloading) {
|
||
|
|
return { available: true, supported: true, downloading: true, version: _lastStatus.version };
|
||
|
|
}
|
||
|
|
if (_lastStatus.status === 'ready') {
|
||
|
|
return { available: true, supported: true, ready: true, version: _lastStatus.version };
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
_isChecking = true;
|
||
|
|
_lastStatus = { ..._lastStatus, isChecking: true };
|
||
|
|
const result = await updater.checkForUpdates();
|
||
|
|
if (!result || !result.updateInfo) {
|
||
|
|
return { available: false, supported: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
const { version, releaseNotes, releaseDate } = result.updateInfo;
|
||
|
|
|
||
|
|
// Compare with current version using semver ordering.
|
||
|
|
// Only report an update when the feed version is strictly newer,
|
||
|
|
// avoiding false positives for pre-release or nightly builds.
|
||
|
|
const { app } = _deps?.electronModule || {};
|
||
|
|
const currentVersion = app?.getVersion?.() || "0.0.0";
|
||
|
|
const isNewer = currentVersion.localeCompare(version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
|
||
|
|
if (!isNewer) {
|
||
|
|
return { available: false, supported: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
available: true,
|
||
|
|
supported: true,
|
||
|
|
version,
|
||
|
|
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : "",
|
||
|
|
releaseDate: releaseDate || null,
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
_isChecking = false;
|
||
|
|
_lastStatus = { ..._lastStatus, isChecking: false };
|
||
|
|
console.warn("[AutoUpdate] Check failed:", err?.message || err);
|
||
|
|
return {
|
||
|
|
available: false,
|
||
|
|
supported: true,
|
||
|
|
error: err?.message || "Unknown update check error",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- Download update ---------------------------------------------------
|
||
|
|
ipcMain.handle("netcatty:update:download", async () => {
|
||
|
|
if (_isDownloading) {
|
||
|
|
return { success: true };
|
||
|
|
}
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (!updater) {
|
||
|
|
return { success: false, error: "Update module not available." };
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
_isDownloading = true;
|
||
|
|
_lastStatus = { ..._lastStatus, status: 'downloading', percent: 0, error: null };
|
||
|
|
await updater.downloadUpdate();
|
||
|
|
return { success: true };
|
||
|
|
} catch (err) {
|
||
|
|
_isDownloading = false;
|
||
|
|
_lastStatus = { ..._lastStatus, status: 'error', error: err?.message || "Download failed", percent: 0 };
|
||
|
|
// Don't broadcast here — the global updater "error" listener already handles it
|
||
|
|
console.error("[AutoUpdate] Download failed:", err?.message || err);
|
||
|
|
return { success: false, error: err?.message || "Download failed" };
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- Get current update status (for late-opening windows) ---------------
|
||
|
|
ipcMain.handle("netcatty:update:getStatus", () => {
|
||
|
|
return { ..._lastStatus };
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- Install (quit & install) ------------------------------------------
|
||
|
|
ipcMain.handle("netcatty:update:install", async () => {
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (!updater) return;
|
||
|
|
if (_isInstalling) return;
|
||
|
|
|
||
|
|
// Check for unsaved editors BEFORE committing to a quit (#1215 review).
|
||
|
|
//
|
||
|
|
// On macOS quitAndInstall() closes windows first and only then fires
|
||
|
|
// before-quit. Once setQuittingForUpdate(true) lets the main window
|
||
|
|
// actually close (instead of hiding to tray), the before-quit dirty-editor
|
||
|
|
// guard can run after the window is already gone — isReachableByUser is
|
||
|
|
// false, so it commits the quit and silently drops unsaved SFTP edits.
|
||
|
|
//
|
||
|
|
// So we ask the renderer here, while the window and renderer are still
|
||
|
|
// alive. If there's unsaved work in any main window, abort the install
|
||
|
|
// (don't touch the quitting flags, don't quitAndInstall) and tell the
|
||
|
|
// renderer to prompt the user to save; they can click "Restart Now" again
|
||
|
|
// afterwards. If no main window is reachable (no window / crashed
|
||
|
|
// renderer) there's no user to ask, so we install directly — matching the
|
||
|
|
// before-quit fail-open path.
|
||
|
|
const editorWebContents = getDirtyEditorWebContentsList();
|
||
|
|
if (editorWebContents.length > 0) {
|
||
|
|
const dirtyResults = await Promise.all(
|
||
|
|
editorWebContents.map((webContents) => queryDirtyEditorsSafe(webContents, ipcMain)),
|
||
|
|
);
|
||
|
|
if (dirtyResults.some(Boolean)) {
|
||
|
|
// Broadcast so the notice reaches whichever window the user clicked
|
||
|
|
// from (main or Settings), not just the main window we queried.
|
||
|
|
notifyNeedsSave();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Another request may have completed its dirty-editor check while this
|
||
|
|
// request was waiting. Only one request may commit the app to a quit.
|
||
|
|
if (_isInstalling) return;
|
||
|
|
|
||
|
|
// Commit the app to a real quit BEFORE quitAndInstall fires app.quit().
|
||
|
|
// Without this the in-place install silently fails (#1215): the main-window
|
||
|
|
// close handler hides to tray when close-to-tray is on, so the process
|
||
|
|
// stays alive and Squirrel.Mac's ShipIt helper — which waits on the parent
|
||
|
|
// PID to die before swapping the bundle — ends up in launchd "pending
|
||
|
|
// spawn" limbo and never installs. setQuittingForUpdate(true) sets
|
||
|
|
// isQuitting so close-to-tray is bypassed and the window actually closes.
|
||
|
|
_isInstalling = true;
|
||
|
|
setQuittingForUpdate(true);
|
||
|
|
|
||
|
|
// On macOS, the system tray keeps the app process alive even after all
|
||
|
|
// windows are closed, which prevents quitAndInstall from completing.
|
||
|
|
// Destroy the tray (and its panel window) before quitting so the app
|
||
|
|
// can exit cleanly and the installer can proceed.
|
||
|
|
if (process.platform === "darwin") {
|
||
|
|
try {
|
||
|
|
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||
|
|
globalShortcutBridge.cleanup();
|
||
|
|
} catch {
|
||
|
|
// ignore — bridge may not be available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
updater.quitAndInstall(false, true);
|
||
|
|
} catch (err) {
|
||
|
|
// quitAndInstall threw synchronously — the app will NOT quit. Roll back
|
||
|
|
// the quitting-for-update flags so later closes/quits behave normally
|
||
|
|
// instead of permanently bypassing close-to-tray + the dirty-editor
|
||
|
|
// guard (#1215 review).
|
||
|
|
console.error("[AutoUpdate] quitAndInstall failed:", err?.message || err);
|
||
|
|
_isInstalling = false;
|
||
|
|
cancelQuittingForUpdateWatchdog();
|
||
|
|
setQuittingForUpdate(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Linux package-manager failures emit the updater error synchronously from
|
||
|
|
// quitAndInstall(). The error listener clears _isInstalling in that case,
|
||
|
|
// so do not install a watchdog after the failure has already been handled.
|
||
|
|
if (!_isInstalling) return;
|
||
|
|
|
||
|
|
// quitAndInstall can also fail to quit asynchronously (e.g. Squirrel.Mac's
|
||
|
|
// follow-up check errors, or a stale/missing downloaded file) — it returns
|
||
|
|
// without app.quit() ever firing. A watchdog clears the flags if we're
|
||
|
|
// still alive shortly after, so the app doesn't get stuck in a state where
|
||
|
|
// every window close bypasses close-to-tray and the dirty-editor guard.
|
||
|
|
scheduleQuittingForUpdateWatchdog();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- Get auto-update preference -----------------------------------------
|
||
|
|
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
|
||
|
|
return { enabled: readAutoUpdatePreference() };
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---- Enable/disable auto-update ----------------------------------------
|
||
|
|
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
|
||
|
|
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
|
||
|
|
const wasEnabled = _prevAutoDownloadEnabled;
|
||
|
|
_prevAutoDownloadEnabled = !!enabled;
|
||
|
|
const updater = getAutoUpdater();
|
||
|
|
if (updater) {
|
||
|
|
updater.autoDownload = !!enabled;
|
||
|
|
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
|
||
|
|
}
|
||
|
|
// Persist so the preference survives app restarts
|
||
|
|
writeAutoUpdatePreference(!!enabled);
|
||
|
|
if (!enabled) {
|
||
|
|
cancelAutoCheck();
|
||
|
|
} else if (!wasEnabled && !_isChecking) {
|
||
|
|
// Only re-schedule when actually re-enabling (not on every mount sync),
|
||
|
|
// to avoid duplicate checks from multiple windows initializing.
|
||
|
|
// Skip if a check is already in flight to prevent concurrent calls.
|
||
|
|
startAutoCheck(2000);
|
||
|
|
}
|
||
|
|
return { success: true };
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log("[AutoUpdate] Handlers registered");
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
init,
|
||
|
|
registerHandlers,
|
||
|
|
isAutoUpdateSupported,
|
||
|
|
startAutoCheck,
|
||
|
|
cancelPendingInstall,
|
||
|
|
};
|