Files
Netcatty/application/state/useUpdateCheck.ts
陈大猫 8376e35022 fix: macOS 自动更新装不上(进程退不掉) (#1224)
* 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>
2026-06-04 00:05:09 +08:00

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,
};
}