mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
a86a5e6839
checkNow was calling bridge.checkForUpdate() which invokes updater.checkForUpdates() via IPC. startAutoCheck() in the main process already calls checkForUpdates() on a 5s timer, and if that network request is still pending, the concurrent IPC call from checkNow hangs indefinitely, causing the UI to be stuck in "checking" state forever. Per the original design spec, checkNow should use performCheck() (GitHub API) directly. This is completely independent of electron-updater's internal state machine, so it never conflicts with the background startAutoCheck(). performCheck handles isCheckingRef, isChecking, hasUpdate, and latestRelease; checkNow only manages manualCheckStatus.
381 lines
12 KiB
TypeScript
381 lines
12 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 } 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
|
|
const STARTUP_CHECK_DELAY_MS = 5000;
|
|
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
|
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
|
window.localStorage?.getItem('debug.updateDemo') === '1';
|
|
|
|
// Debug logging for update checks
|
|
const debugLog = (...args: unknown[]) => {
|
|
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
|
|
console.log('[UpdateCheck]', ...args);
|
|
}
|
|
};
|
|
|
|
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<null>;
|
|
dismissUpdate: () => void;
|
|
openReleasePage: () => void;
|
|
installUpdate: () => void;
|
|
}
|
|
|
|
/**
|
|
* 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(): UseUpdateCheckResult {
|
|
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);
|
|
|
|
// Keep currentVersionRef in sync so checkNow always reads the latest version
|
|
useEffect(() => {
|
|
currentVersionRef.current = updateState.currentVersion;
|
|
}, [updateState.currentVersion]);
|
|
|
|
// 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();
|
|
}, []);
|
|
|
|
// Subscribe to electron-updater auto-download IPC events.
|
|
// These fire automatically when autoDownload=true in the main process.
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
|
|
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'downloading',
|
|
downloadPercent: 0,
|
|
downloadError: null,
|
|
// Use electron-updater's version as fallback if GitHub API hasn't resolved yet
|
|
latestRelease: prev.latestRelease ?? {
|
|
version: info.version,
|
|
tagName: `v${info.version}`,
|
|
name: `v${info.version}`,
|
|
body: info.releaseNotes || '',
|
|
htmlUrl: '',
|
|
publishedAt: info.releaseDate || new Date().toISOString(),
|
|
assets: [],
|
|
},
|
|
}));
|
|
});
|
|
|
|
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'downloading',
|
|
downloadPercent: Math.round(p.percent),
|
|
}));
|
|
});
|
|
|
|
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'ready',
|
|
downloadPercent: 100,
|
|
}));
|
|
});
|
|
|
|
const cleanupError = bridge?.onUpdateError?.((payload) => {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
autoDownloadStatus: 'error',
|
|
downloadError: payload.error,
|
|
}));
|
|
});
|
|
|
|
return () => {
|
|
cleanupAvailable?.();
|
|
cleanupProgress?.();
|
|
cleanupDownloaded?.();
|
|
cleanupError?.();
|
|
};
|
|
}, []);
|
|
|
|
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();
|
|
|
|
// Save last check time
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
|
|
|
|
// 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<null> => {
|
|
// Prevent concurrent checks (performCheck owns isCheckingRef)
|
|
if (isCheckingRef.current) {
|
|
debugLog('checkNow: already checking, skipping');
|
|
return null;
|
|
}
|
|
|
|
// Immediately reflect 'checking' in the UI
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: 'checking',
|
|
error: null,
|
|
}));
|
|
|
|
// 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') {
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus: 'up-to-date',
|
|
}));
|
|
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);
|
|
|
|
setUpdateState((prev) => ({
|
|
...prev,
|
|
manualCheckStatus:
|
|
result === null ? 'error' : result.hasUpdate ? 'available' : 'up-to-date',
|
|
}));
|
|
|
|
return null;
|
|
}, [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?.();
|
|
}, []);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Check if we've checked recently
|
|
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
|
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(() => {
|
|
debugLog('=== Delayed check triggered ===');
|
|
void performCheck(updateState.currentVersion);
|
|
}, STARTUP_CHECK_DELAY_MS);
|
|
|
|
return () => {
|
|
if (startupCheckTimeoutRef.current) {
|
|
clearTimeout(startupCheckTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [updateState.currentVersion, performCheck]);
|
|
|
|
return {
|
|
updateState,
|
|
checkNow,
|
|
dismissUpdate,
|
|
openReleasePage,
|
|
installUpdate,
|
|
};
|
|
}
|