* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215) macOS in-place auto-update downloaded and unpacked the new version but never installed it: the app appeared to close, ShipIt never ran, and no restart happened. A full uninstall + reinstall did not help; only a manual DMG replace worked. Root cause is a code-level coordination bug, not the release pipeline. The published mac zips are correctly Developer-ID signed, notarized, and stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and latest-mac.yml is well-formed — so Squirrel.Mac signature validation passes. The failure is that quitAndInstall() drives app.quit() while two normal-quit behaviors keep the process alive: 1. the main-window close handler hides to tray when close-to-tray is enabled (it only closes when isQuitting is true), and 2. the before-quit dirty-editor guard preventDefault()s the quit for a 5s renderer round-trip. Either keeps the parent process running, so Squirrel.Mac's ShipIt helper — which waits on the parent PID to die before swapping the bundle — lands in launchd "pending spawn / on-demand-only" limbo and the service is removed without installing. This matches the reporter's diagnosis exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only). Fix: before quitAndInstall fires app.quit(), mark the app as quitting for an update via windowManager.setQuittingForUpdate(true). That sets isQuitting (bypassing close-to-tray) and the before-quit handler now returns early when isQuittingForUpdate() is true (skipping the dirty-editor round-trip), so the process exits cleanly and ShipIt can run. The same fix also covers the latent Windows NSIS case where close-to-tray would block an in-place update. If the install never actually quits the app (quitAndInstall throws, or returns without app.quit() on a Squirrel follow-up error / stale download), the quitting-for-update flags are rolled back — synchronously on throw, and via a short unref'd watchdog otherwise — so the app does not get stuck permanently bypassing close-to-tray and the quit guard. Tests: unit tests for the new windowManager flags and for the install handler — ordering (setQuittingForUpdate before quitAndInstall), tray cleanup still runs, no-op when the updater fails to load, rollback on synchronous throw, and watchdog rollback when the app never quits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auto-update): keep dirty-editor guard during update install setQuittingForUpdate only bypasses close-to-tray (so the window actually closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would silently lose edits. If the user cancels to save, the quit aborts and autoUpdateBridge's watchdog clears the quitting-for-update flags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auto-update): clear update-quit state when the quit is cancelled When the user clicks "Restart Now" with a dirty editor open, the before-quit guard cancels the quit (settle "stay"). The update path had already called setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray for the install), so without clearing it the app stays in a quitting state — close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auto-update): check for unsaved editors before quitAndInstall (#1215) The previous fix bypassed close-to-tray so the app process actually exits and Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit dirty-editor guard as the unsaved-work safety net. But on macOS that net has a hole: quitAndInstall() closes the window FIRST and only then fires before-quit. Once setQuittingForUpdate(true) lets the main window truly close (instead of hiding to tray), the before-quit guard can run after the window is already gone — isReachableByUser is false, so it commits the quit and silently drops unsaved SFTP edits. Fix: move the dirty-editor check to the moment the user clicks "Restart Now", in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the window and renderer are still alive: - dirty -> abort the install (don't set the quitting flags, don't quitAndInstall) and broadcast netcatty:update:needs-save so the renderer prompts the user to save and retry. - clean -> proceed with the existing flow (commit-to-quit, tray cleanup, quitAndInstall, watchdog). - no reachable main window / crashed renderer -> install directly (no user to ask), matching the before-quit fail-open path. The before-quit dirty guard is kept as defense-in-depth: if the window is still reachable it re-checks (clean, since we just verified), and if it's already gone it lets the quit through — which is now safe because the install handler already confirmed there were no unsaved editors. The request/reply/timeout round-trip is extracted into a shared helper, electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install handler and main.cjs's before-quit guard use one implementation. main.cjs's before-quit is refactored onto it (behavior preserved: sender-filtered reply, fail-open timeout, and the setQuittingForUpdate(false) rollback when the user cancels to save). The needs-save notice is BROADCAST to every window, not just the queried main window: "Restart to Update" can be clicked from the Settings window, which would otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx (main window) and SettingsPage (settings window) — pass an onNeedsSave callback that shows an actionable toast ("save your editors, then click Restart Now again") in en / zh-CN / ru. Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On macOS quitAndInstall() can return while Squirrel is still pulling the downloaded ZIP from the local update server before it closes the windows; on a large/slow update that can exceed 10s. Clearing isQuitting that early would let the eventual native quit hit a non-quitting close-to-tray handler and strand the install again — the exact #1215 failure. The longer window only fires when the app is realistically stuck, at the cost of close-to-tray staying bypassed a little longer in the rare genuine-failure case. Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or crashed webContents / send-throws / no-ipcMain paths); install handler pre-check (dirty -> no quitAndInstall + needs-save broadcast to all windows; clean -> quitAndInstall runs; no main window -> installs without asking). Existing install-handler tests updated for the now-async handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
718 lines
29 KiB
TypeScript
718 lines
29 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
|
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
|
|
// Check for updates at most once per hour
|
|
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
// Delay startup check to avoid slowing down app launch.
|
|
// 8s gives electron-updater's startAutoCheck(5000) time to emit
|
|
// 'update-available' first. The `onUpdateAvailable` handler also cancels
|
|
// any pending startup timeout, so even on slow networks where the event
|
|
// arrives after 8s the duplicate check is avoided.
|
|
const STARTUP_CHECK_DELAY_MS = 8000;
|
|
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
|
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
|
|
|
|
// Debug logging for update checks (no-op in production)
|
|
const debugLog = (..._args: unknown[]) => {};
|
|
|
|
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
|
|
|
|
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
|
|
|
|
export interface UpdateState {
|
|
isChecking: boolean;
|
|
hasUpdate: boolean;
|
|
currentVersion: string;
|
|
latestRelease: ReleaseInfo | null;
|
|
error: string | null;
|
|
lastCheckedAt: number | null;
|
|
// Auto-download state — driven by electron-updater IPC events
|
|
autoDownloadStatus: AutoDownloadStatus;
|
|
downloadPercent: number;
|
|
downloadError: string | null;
|
|
/** Manual check state — driven by user clicking "Check for Updates" */
|
|
manualCheckStatus: ManualCheckStatus;
|
|
}
|
|
|
|
export interface UseUpdateCheckResult {
|
|
updateState: UpdateState;
|
|
checkNow: () => Promise<UpdateCheckResult | null>;
|
|
dismissUpdate: () => void;
|
|
openReleasePage: () => void;
|
|
installUpdate: () => void;
|
|
startDownload: () => void;
|
|
isUpdateDemoMode: boolean;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing update checks
|
|
* - Automatically checks for updates on startup (with delay)
|
|
* - Respects dismissed version to avoid nagging
|
|
* - Provides manual check capability
|
|
*/
|
|
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; onNeedsSave?: () => void }): UseUpdateCheckResult {
|
|
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
|
// reacts immediately in the same window. Falls back to reading localStorage
|
|
// when no caller provides the value (e.g. in non-settings contexts).
|
|
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
|
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
|
|
|
// Latest "install blocked by unsaved editors" callback (#1215). Kept in a ref
|
|
// so the listener effect (empty deps) always calls the current one without
|
|
// re-subscribing on every render. The consuming component shows the toast;
|
|
// this hook only owns the bridge subscription (toasts live in the view layer).
|
|
const onNeedsSaveRef = useRef(options?.onNeedsSave);
|
|
onNeedsSaveRef.current = options?.onNeedsSave;
|
|
|
|
const [updateState, setUpdateState] = useState<UpdateState>({
|
|
isChecking: false,
|
|
hasUpdate: false,
|
|
currentVersion: '',
|
|
latestRelease: null,
|
|
error: null,
|
|
lastCheckedAt: null,
|
|
autoDownloadStatus: 'idle',
|
|
downloadPercent: 0,
|
|
downloadError: null,
|
|
manualCheckStatus: 'idle',
|
|
});
|
|
|
|
const hasCheckedOnStartupRef = useRef(false);
|
|
const isCheckingRef = useRef(false);
|
|
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Track current version in a ref to avoid stale closure in checkNow
|
|
const currentVersionRef = useRef(updateState.currentVersion);
|
|
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
|
|
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
|
|
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
|
|
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Flag: true when we suppressed auto-download because the version was dismissed.
|
|
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
|
|
// in the progress/downloaded/error callbacks.
|
|
const dismissedAutoDownloadRef = useRef(false);
|
|
|
|
// Keep currentVersionRef in sync so checkNow always reads the latest version
|
|
useEffect(() => {
|
|
currentVersionRef.current = updateState.currentVersion;
|
|
}, [updateState.currentVersion]);
|
|
|
|
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
|
|
useEffect(() => {
|
|
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
|
|
}, [updateState.autoDownloadStatus]);
|
|
|
|
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (manualCheckResetTimeoutRef.current) {
|
|
clearTimeout(manualCheckResetTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Get current app version
|
|
useEffect(() => {
|
|
const loadVersion = async () => {
|
|
try {
|
|
const bridge = netcattyBridge.get();
|
|
const info = await bridge?.getAppInfo?.();
|
|
if (info?.version) {
|
|
setUpdateState((prev) => ({ ...prev, currentVersion: info.version }));
|
|
}
|
|
} catch {
|
|
// Ignore - running without Electron bridge
|
|
}
|
|
};
|
|
void loadVersion();
|
|
}, []);
|
|
|
|
// Hydrate auto-download status from the main process so windows opened
|
|
// after the download started (e.g. Settings) immediately reflect the
|
|
// current state instead of showing stale 'idle'.
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
void bridge?.getUpdateStatus?.().then((snapshot) => {
|
|
if (!snapshot || snapshot.status === 'idle') return;
|
|
|
|
// Respect dismissed versions: if the user dismissed this release,
|
|
// don't surface download progress/ready state in late-opening windows.
|
|
// Also set the dismissed ref so subsequent IPC events are suppressed.
|
|
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
if (snapshot.version && snapshot.version === dismissedVersion) {
|
|
dismissedAutoDownloadRef.current = true;
|
|
return;
|
|
}
|
|
|
|
// 'available' means an update was found but auto-download is disabled.
|
|
// Surface the version info (hasUpdate + latestRelease) but keep
|
|
// autoDownloadStatus at 'idle' so the manual download path shows.
|
|
const isAvailableOnly = snapshot.status === 'available';
|
|
|
|
setUpdateState((prev) => {
|
|
// Don't overwrite if the renderer already has a newer state
|
|
if (prev.autoDownloadStatus !== 'idle') return prev;
|
|
return {
|
|
...prev,
|
|
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
|
|
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
|
|
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
|
|
downloadError: isAvailableOnly ? null : snapshot.error,
|
|
// Use snapshot version if no release data or if versions differ
|
|
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
|
version: snapshot.version,
|
|
tagName: `v${snapshot.version}`,
|
|
name: `v${snapshot.version}`,
|
|
body: '',
|
|
htmlUrl: '',
|
|
publishedAt: new Date().toISOString(),
|
|
assets: [],
|
|
} : prev.latestRelease) : prev.latestRelease,
|
|
};
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
// Subscribe to electron-updater auto-download IPC events.
|
|
// These fire automatically when autoDownload=true in the main process.
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
|
|
// When electron-updater confirms no update in its feed, don't write
|
|
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
|
|
// fallback for an hour. Let performCheck write it on success so the
|
|
// GitHub check can still discover releases not yet in the updater feed.
|
|
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
|
|
// No-op for now — the GitHub fallback will handle lastCheckedAt.
|
|
});
|
|
|
|
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
|
|
// Cancel any pending startup GitHub API check — electron-updater is
|
|
// now authoritative and we don't want a duplicate toast.
|
|
if (startupCheckTimeoutRef.current) {
|
|
clearTimeout(startupCheckTimeoutRef.current);
|
|
startupCheckTimeoutRef.current = null;
|
|
}
|
|
|
|
// Check if this version was dismissed by the user
|
|
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
const isDismissed = dismissedVersion === info.version;
|
|
if (isDismissed) {
|
|
dismissedAutoDownloadRef.current = true;
|
|
}
|
|
// When auto-update is disabled, autoDownload=false in the main process
|
|
// so no download will start. Don't transition to 'downloading' or the
|
|
// UI will be stuck at 0%. Keep status idle and let the manual download
|
|
// link surface instead.
|
|
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
|
|
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
hasUpdate: !isDismissed,
|
|
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
|
|
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
|
|
downloadError: shouldTrackDownload ? null : prev.downloadError,
|
|
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
|
// if the updater reports a different version than the cached release.
|
|
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
|
version: info.version,
|
|
tagName: `v${info.version}`,
|
|
name: `v${info.version}`,
|
|
body: info.releaseNotes || '',
|
|
htmlUrl: '',
|
|
publishedAt: info.releaseDate || new Date().toISOString(),
|
|
assets: [],
|
|
} : prev.latestRelease,
|
|
}));
|
|
});
|
|
|
|
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
|
|
// If we suppressed the download for a dismissed version, ignore progress.
|
|
if (dismissedAutoDownloadRef.current) return;
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'downloading',
|
|
downloadPercent: Math.round(p.percent),
|
|
}));
|
|
});
|
|
|
|
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
|
|
// If the download was for a dismissed version, don't transition to
|
|
// 'ready' — that would trigger the "Update ready" toast.
|
|
if (dismissedAutoDownloadRef.current) return;
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'ready',
|
|
downloadPercent: 100,
|
|
}));
|
|
});
|
|
|
|
const cleanupError = bridge?.onUpdateError?.((payload) => {
|
|
// If we suppressed the download for a dismissed version, ignore errors.
|
|
if (dismissedAutoDownloadRef.current) return;
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'error',
|
|
downloadError: payload.error,
|
|
}));
|
|
});
|
|
|
|
// Install was requested but blocked by unsaved editors (#1215). The main
|
|
// process broadcasts this to every window so whichever one the user clicked
|
|
// "Restart Now" from gets feedback. Delegate to the caller's handler (which
|
|
// shows the toast) — registered here because bridge subscriptions belong in
|
|
// the state layer, not in components.
|
|
const cleanupNeedsSave = bridge?.onUpdateNeedsSave?.(() => {
|
|
onNeedsSaveRef.current?.();
|
|
});
|
|
|
|
return () => {
|
|
cleanupNotAvailable?.();
|
|
cleanupAvailable?.();
|
|
cleanupProgress?.();
|
|
cleanupDownloaded?.();
|
|
cleanupError?.();
|
|
cleanupNeedsSave?.();
|
|
};
|
|
}, []);
|
|
|
|
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
|
|
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
|
|
|
|
// In demo mode, use a fake version to allow checking
|
|
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersion;
|
|
|
|
if (!effectiveVersion || effectiveVersion === '0.0.0') {
|
|
debugLog('Skipping check - invalid version:', effectiveVersion);
|
|
// Skip check for dev builds
|
|
return null;
|
|
}
|
|
|
|
if (isCheckingRef.current) {
|
|
debugLog('Already checking, skipping');
|
|
return null;
|
|
}
|
|
|
|
isCheckingRef.current = true;
|
|
setUpdateState((prev) => ({ ...prev, isChecking: true, error: null }));
|
|
|
|
try {
|
|
let result: UpdateCheckResult;
|
|
|
|
if (IS_UPDATE_DEMO_MODE) {
|
|
debugLog('Demo mode: creating fake update result');
|
|
// Simulate a short delay like a real API call
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
// In demo mode, create a fake update result
|
|
result = {
|
|
hasUpdate: true,
|
|
currentVersion: '0.0.1',
|
|
latestRelease: {
|
|
version: '1.0.0',
|
|
tagName: 'v1.0.0',
|
|
name: 'Netcatty v1.0.0',
|
|
body: 'Demo release for testing update notification',
|
|
htmlUrl: 'https://github.com/binaricat/Netcatty/releases',
|
|
publishedAt: new Date().toISOString(),
|
|
assets: [],
|
|
},
|
|
};
|
|
} else {
|
|
result = await checkForUpdates(currentVersion);
|
|
}
|
|
debugLog('Check result:', result);
|
|
debugLog('Latest release version:', result.latestRelease?.version);
|
|
const now = Date.now();
|
|
|
|
// Only advance last-check time and cache release on successful checks.
|
|
// Failed checks (result.error set, no latestRelease) must not update
|
|
// the timestamp — otherwise stale cached release data persists for an
|
|
// hour while the throttle prevents re-checking.
|
|
if (!result.error) {
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
|
if (result.latestRelease) {
|
|
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
|
|
}
|
|
}
|
|
|
|
// Check if this version was dismissed
|
|
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
const showUpdate = result.hasUpdate &&
|
|
result.latestRelease?.version !== dismissedVersion;
|
|
|
|
debugLog('Show update:', showUpdate, 'dismissed version:', dismissedVersion);
|
|
debugLog('Setting state with hasUpdate:', showUpdate);
|
|
|
|
setUpdateState((prev) => {
|
|
debugLog('State updated:', { ...prev, hasUpdate: showUpdate, latestRelease: result.latestRelease });
|
|
return {
|
|
...prev,
|
|
isChecking: false,
|
|
hasUpdate: showUpdate,
|
|
latestRelease: result.latestRelease,
|
|
error: result.error || null,
|
|
lastCheckedAt: now,
|
|
};
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
isChecking: false,
|
|
error: errorMsg,
|
|
}));
|
|
return null;
|
|
} finally {
|
|
isCheckingRef.current = false;
|
|
}
|
|
}, []);
|
|
|
|
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
|
|
// Prevent concurrent checks (performCheck owns isCheckingRef)
|
|
if (isCheckingRef.current) {
|
|
debugLog('checkNow: already checking, skipping');
|
|
return null;
|
|
}
|
|
|
|
// Cancel any pending startup auto-check to avoid racing with
|
|
// electron-updater's startAutoCheck — concurrent checkForUpdates()
|
|
// calls are rejected by electron-updater and would surface a false error.
|
|
if (startupCheckTimeoutRef.current) {
|
|
clearTimeout(startupCheckTimeoutRef.current);
|
|
startupCheckTimeoutRef.current = null;
|
|
}
|
|
|
|
// Clear any pending "up-to-date" auto-reset timer
|
|
if (manualCheckResetTimeoutRef.current) {
|
|
clearTimeout(manualCheckResetTimeoutRef.current);
|
|
manualCheckResetTimeoutRef.current = null;
|
|
}
|
|
|
|
// Reset dismissed flag so a manual retry can surface download events again
|
|
dismissedAutoDownloadRef.current = false;
|
|
|
|
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
|
|
setUpdateState((prev) => {
|
|
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
|
|
if (prev.autoDownloadStatus === 'error') {
|
|
autoDownloadStatusRef.current = 'idle';
|
|
}
|
|
return {
|
|
...prev,
|
|
manualCheckStatus: 'checking',
|
|
error: null,
|
|
// P2: reset download error state so auto-download can retry on next available update
|
|
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
|
|
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
|
|
};
|
|
});
|
|
|
|
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
|
|
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
|
|
if (!effectiveVersion || effectiveVersion === '0.0.0') {
|
|
// Dev/invalid build — can't determine update status, reset to idle
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: 'idle',
|
|
}));
|
|
return null;
|
|
}
|
|
|
|
// Delegate to performCheck (GitHub API) — completely independent of
|
|
// electron-updater's startAutoCheck() in the main process.
|
|
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
|
|
const result = await performCheck(effectiveVersion);
|
|
|
|
// Determine manual check status. performCheck already suppressed dismissed
|
|
// versions in state (hasUpdate=false), so we must respect that here too —
|
|
// otherwise a dismissed release would be reported as 'available' and could
|
|
// trigger a background download via checkForUpdate below.
|
|
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
const isAvailable = result !== null && !result.error && result.hasUpdate &&
|
|
result.latestRelease?.version !== dismissedVersion;
|
|
const nextStatus: ManualCheckStatus =
|
|
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
|
|
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: nextStatus,
|
|
}));
|
|
|
|
if (nextStatus === 'up-to-date') {
|
|
// Auto-reset "up-to-date" badge back to idle after 5s
|
|
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
|
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
|
}, 5000);
|
|
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
|
|
// Trigger electron-updater as a fallback. This covers two cases:
|
|
// 1. 'available': GitHub found an update but electron-updater hasn't
|
|
// started a download yet — kick it off.
|
|
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
|
|
// electron-updater feed may still be reachable. Without this,
|
|
// environments where api.github.com is blocked would never attempt
|
|
// the auto-download path.
|
|
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
|
|
if (res?.error && res?.supported !== false) {
|
|
// Surface actual download-feed errors; unsupported platforms
|
|
// (res.supported === false) should keep autoDownloadStatus at
|
|
// 'idle' so the manual download link shows.
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'error',
|
|
downloadError: res.error,
|
|
}));
|
|
} else if (res?.checking) {
|
|
// Another check is already in flight — don't change status; the
|
|
// in-flight check will resolve via IPC events.
|
|
} else if (nextStatus === 'error' && res?.available) {
|
|
// GitHub API failed but electron-updater found an update.
|
|
// Respect dismissed versions before surfacing.
|
|
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
if (res.version && res.version === dismissed) {
|
|
// User dismissed this version — don't re-surface
|
|
} else {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: 'available',
|
|
hasUpdate: true,
|
|
error: null,
|
|
}));
|
|
}
|
|
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
|
// GitHub API failed but electron-updater says no update available.
|
|
// Clear the error status so Settings doesn't stay stuck in error state.
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: 'up-to-date',
|
|
}));
|
|
manualCheckResetTimeoutRef.current = setTimeout(() => {
|
|
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
|
|
}, 5000);
|
|
}
|
|
}).catch(() => {
|
|
// Bridge unavailable — ignore; the manual download link remains visible
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [performCheck]);
|
|
|
|
const dismissUpdate = useCallback(() => {
|
|
if (updateState.latestRelease?.version) {
|
|
localStorageAdapter.writeString(
|
|
STORAGE_KEY_UPDATE_DISMISSED_VERSION,
|
|
updateState.latestRelease.version
|
|
);
|
|
}
|
|
setUpdateState((prev) => ({ ...prev, hasUpdate: false }));
|
|
}, [updateState.latestRelease?.version]);
|
|
|
|
const openReleasePage = useCallback(async () => {
|
|
const url = updateState.latestRelease
|
|
? getReleaseUrl(updateState.latestRelease.version)
|
|
: getReleaseUrl();
|
|
|
|
try {
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.openExternal) {
|
|
await bridge.openExternal(url);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fallback to window.open
|
|
}
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
}, [updateState.latestRelease]);
|
|
|
|
const installUpdate = useCallback(() => {
|
|
netcattyBridge.get()?.installUpdate?.();
|
|
}, []);
|
|
|
|
const startDownload = useCallback(async () => {
|
|
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
|
|
const bridge = netcattyBridge.get();
|
|
try {
|
|
const checkResult = await bridge?.checkForUpdate?.();
|
|
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
|
|
if (checkResult.supported === false) {
|
|
openReleasePage();
|
|
return;
|
|
}
|
|
if (checkResult.available === false) {
|
|
openReleasePage();
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'downloading',
|
|
downloadPercent: 0,
|
|
downloadError: null,
|
|
}));
|
|
void bridge?.downloadUpdate?.().then((res) => {
|
|
if (res && !res.success) {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'error',
|
|
downloadError: res.error || 'Download failed',
|
|
}));
|
|
}
|
|
}).catch(() => {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'error',
|
|
downloadError: 'Download failed',
|
|
}));
|
|
});
|
|
}, [openReleasePage]);
|
|
|
|
// Startup check with delay - runs once on mount
|
|
useEffect(() => {
|
|
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
|
|
|
// In demo mode, trigger check immediately after a short delay
|
|
if (IS_UPDATE_DEMO_MODE) {
|
|
debugLog('Demo mode: scheduling update check in', STARTUP_CHECK_DELAY_MS, 'ms');
|
|
|
|
startupCheckTimeoutRef.current = setTimeout(() => {
|
|
debugLog('=== Demo mode: Triggering update check ===');
|
|
void performCheck('0.0.1');
|
|
}, STARTUP_CHECK_DELAY_MS);
|
|
|
|
return () => {
|
|
if (startupCheckTimeoutRef.current) {
|
|
clearTimeout(startupCheckTimeoutRef.current);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Normal mode: wait for version to be loaded, then check
|
|
// This is handled by the version-dependent effect below
|
|
}, [performCheck]);
|
|
|
|
// Normal mode startup check - depends on currentVersion
|
|
useEffect(() => {
|
|
// Skip in demo mode (handled above)
|
|
if (IS_UPDATE_DEMO_MODE) {
|
|
return;
|
|
}
|
|
|
|
debugLog('Version check effect', {
|
|
hasChecked: hasCheckedOnStartupRef.current,
|
|
currentVersion: updateState.currentVersion
|
|
});
|
|
|
|
if (hasCheckedOnStartupRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (!updateState.currentVersion || updateState.currentVersion === '0.0.0') {
|
|
return;
|
|
}
|
|
|
|
// Hydrate cached release info so update status is visible across windows.
|
|
// When auto-update is disabled, hydrate release data (for the Settings UI)
|
|
// but don't set hasUpdate (which would trigger the toast in App.tsx).
|
|
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
|
if (lastCheck) {
|
|
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
|
if (cachedRelease) {
|
|
try {
|
|
const release = JSON.parse(cachedRelease) as ReleaseInfo;
|
|
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
|
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
|
|
const showUpdate = isNewer && release.version !== dismissedVersion;
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
latestRelease: prev.latestRelease ?? release,
|
|
hasUpdate: prev.hasUpdate || showUpdate,
|
|
lastCheckedAt: lastCheck,
|
|
}));
|
|
} catch {
|
|
// Ignore corrupted cache
|
|
}
|
|
}
|
|
}
|
|
|
|
// Respect auto-update toggle — skip automatic check when disabled.
|
|
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
|
|
// autoUpdateEnabled dependency) can re-trigger this effect.
|
|
if (!autoUpdateEnabled) {
|
|
return;
|
|
}
|
|
|
|
// Check if we've checked recently
|
|
const now = Date.now();
|
|
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
|
hasCheckedOnStartupRef.current = true;
|
|
return;
|
|
}
|
|
|
|
hasCheckedOnStartupRef.current = true;
|
|
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
|
|
|
startupCheckTimeoutRef.current = setTimeout(async () => {
|
|
// Re-check the toggle at fire time — the user may have toggled it
|
|
// after the timer was scheduled.
|
|
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
|
if (stillEnabled === 'false') {
|
|
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
|
|
return;
|
|
}
|
|
// If electron-updater's auto-check already started a download, skip the
|
|
// redundant GitHub API check to avoid duplicate toast notifications.
|
|
if (autoDownloadStatusRef.current !== 'idle') {
|
|
debugLog('Skipping startup check — auto-download already active');
|
|
return;
|
|
}
|
|
// If the main process check is still in flight, reschedule the
|
|
// fallback instead of permanently skipping it — the auto-check may
|
|
// fail silently (check-phase errors aren't broadcast to the renderer).
|
|
try {
|
|
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
|
|
if (snapshot?.isChecking) {
|
|
debugLog('Main process check still in flight — rescheduling fallback');
|
|
startupCheckTimeoutRef.current = setTimeout(async () => {
|
|
if (autoDownloadStatusRef.current !== 'idle') return;
|
|
// Re-check if the main process check is still running to avoid
|
|
// duplicate notifications on very slow networks.
|
|
try {
|
|
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
|
|
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
|
|
} catch { /* fall through */ }
|
|
debugLog('=== Rescheduled fallback check triggered ===');
|
|
void performCheck(updateState.currentVersion);
|
|
}, 5000);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Bridge unavailable — fall through to GitHub check
|
|
}
|
|
debugLog('=== Delayed check triggered ===');
|
|
void performCheck(updateState.currentVersion);
|
|
}, STARTUP_CHECK_DELAY_MS);
|
|
|
|
return () => {
|
|
if (startupCheckTimeoutRef.current) {
|
|
clearTimeout(startupCheckTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
|
|
|
return {
|
|
updateState,
|
|
checkNow,
|
|
dismissUpdate,
|
|
openReleasePage,
|
|
installUpdate,
|
|
startDownload,
|
|
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
|
};
|
|
}
|