claude-code/src/components/NativeAutoUpdater.tsx

193 lines
26 KiB
TypeScript
Raw Normal View History

2026-03-31 19:22:47 +08:00
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { logForDebugging } from 'src/utils/debug.js';
import { logError } from 'src/utils/log.js';
import { useInterval } from 'usehooks-ts';
import { useUpdateNotification } from '../hooks/useUpdateNotification.js';
import { Box, Text } from '../ink.js';
import type { AutoUpdaterResult } from '../utils/autoUpdater.js';
import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js';
import { isAutoUpdaterDisabled } from '../utils/config.js';
import { installLatest } from '../utils/nativeInstaller/index.js';
import { gt } from '../utils/semver.js';
import { getInitialSettings } from '../utils/settings/settings.js';
/**
* Categorize error messages for analytics
*/
function getErrorType(errorMessage: string): string {
if (errorMessage.includes('timeout')) {
return 'timeout';
}
if (errorMessage.includes('Checksum mismatch')) {
return 'checksum_mismatch';
}
if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) {
return 'not_found';
}
if (errorMessage.includes('EACCES') || errorMessage.includes('permission')) {
return 'permission_denied';
}
if (errorMessage.includes('ENOSPC')) {
return 'disk_full';
}
if (errorMessage.includes('npm')) {
return 'npm_error';
}
if (errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND')) {
return 'network_error';
}
return 'unknown';
}
type Props = {
isUpdating: boolean;
onChangeIsUpdating: (isUpdating: boolean) => void;
onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void;
autoUpdaterResult: AutoUpdaterResult | null;
showSuccessMessage: boolean;
verbose: boolean;
};
export function NativeAutoUpdater({
isUpdating,
onChangeIsUpdating,
onAutoUpdaterResult,
autoUpdaterResult,
showSuccessMessage,
verbose
}: Props): React.ReactNode {
const [versions, setVersions] = useState<{
current?: string | null;
latest?: string | null;
}>({});
const [maxVersionIssue, setMaxVersionIssue] = useState<string | null>(null);
const updateSemver = useUpdateNotification(autoUpdaterResult?.version);
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest';
// Track latest isUpdating value in a ref so the memoized checkForUpdates
// callback always sees the current value without changing callback identity
// (which would re-trigger the initial-check useEffect below and cause
// repeated downloads on remount — the upstream trigger for #22413).
const isUpdatingRef = useRef(isUpdating);
isUpdatingRef.current = isUpdating;
const checkForUpdates = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
}
if ("production" === 'test' || "production" === 'development') {
logForDebugging('NativeAutoUpdater: Skipping update check in test/dev environment');
return;
}
if (isAutoUpdaterDisabled()) {
return;
}
onChangeIsUpdating(true);
const startTime = Date.now();
// Log the start of an auto-update check for funnel analysis
logEvent('tengu_native_auto_updater_start', {});
try {
// Check if current version is above the max allowed version
const maxVersion = await getMaxVersion();
if (maxVersion && gt(MACRO.VERSION, maxVersion)) {
const msg = await getMaxVersionMessage();
setMaxVersionIssue(msg ?? 'affects your version');
}
const result = await installLatest(channel);
const currentVersion = MACRO.VERSION;
const latencyMs = Date.now() - startTime;
// Handle lock contention gracefully - just return without treating as error
if (result.lockFailed) {
logEvent('tengu_native_auto_updater_lock_contention', {
latency_ms: latencyMs
});
return; // Silently skip this update check, will try again later
}
// Update versions for display
setVersions({
current: currentVersion,
latest: result.latestVersion
});
if (result.wasUpdated) {
logEvent('tengu_native_auto_updater_success', {
latency_ms: latencyMs
});
onAutoUpdaterResult({
version: result.latestVersion,
status: 'success'
});
} else {
// Already up to date
logEvent('tengu_native_auto_updater_up_to_date', {
latency_ms: latencyMs
});
}
} catch (error) {
const latencyMs = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
logError(error);
const errorType = getErrorType(errorMessage);
logEvent('tengu_native_auto_updater_fail', {
latency_ms: latencyMs,
error_timeout: errorType === 'timeout',
error_checksum: errorType === 'checksum_mismatch',
error_not_found: errorType === 'not_found',
error_permission: errorType === 'permission_denied',
error_disk_full: errorType === 'disk_full',
error_npm: errorType === 'npm_error',
error_network: errorType === 'network_error'
});
onAutoUpdaterResult({
version: null,
status: 'install_failed'
});
} finally {
onChangeIsUpdating(false);
}
// isUpdating intentionally omitted from deps; we read isUpdatingRef
// instead so the guard is always current without changing callback
// identity (which would re-trigger the initial-check useEffect below).
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
}, [onAutoUpdaterResult, channel]);
// Initial check
useEffect(() => {
void checkForUpdates();
}, [checkForUpdates]);
// Check every 30 minutes
useInterval(checkForUpdates, 30 * 60 * 1000);
const hasUpdateResult = !!autoUpdaterResult?.version;
const hasVersionInfo = !!versions.current && !!versions.latest;
// Show the component when:
// - warning banner needed (above max version), or
// - there's an update result to display (success/error), or
// - actively checking and we have version info to show
const shouldRender = !!maxVersionIssue || hasUpdateResult || isUpdating && hasVersionInfo;
if (!shouldRender) {
return null;
}
return <Box flexDirection="row" gap={1}>
{verbose && <Text dimColor wrap="truncate">
current: {versions.current} &middot; {channel}: {versions.latest}
</Text>}
{isUpdating ? <Box>
<Text dimColor wrap="truncate">
Checking for updates
</Text>
</Box> : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && <Text color="success" wrap="truncate">
Update installed · Restart to update
</Text>}
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
Auto-update failed &middot; Try <Text bold>/status</Text>
</Text>}
{maxVersionIssue && "external" === 'ant' && <Text color="warning">
Known issue: {maxVersionIssue} &middot; Run{' '}
<Text bold>claude rollback --safe</Text> to downgrade
</Text>}
</Box>;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVJlZiIsInVzZVN0YXRlIiwibG9nRXZlbnQiLCJsb2dGb3JEZWJ1Z2dpbmciLCJsb2dFcnJvciIsInVzZUludGVydmFsIiwidXNlVXBkYXRlTm90aWZpY2F0aW9uIiwiQm94IiwiVGV4dCIsIkF1dG9VcGRhdGVyUmVzdWx0IiwiZ2V0TWF4VmVyc2lvbiIsImdldE1heFZlcnNpb25NZXNzYWdlIiwiaXNBdXRvVXBkYXRlckRpc2FibGVkIiwiaW5zdGFsbExhdGVzdCIsImd0IiwiZ2V0SW5pdGlhbFNldHRpbmdzIiwiZ2V0RXJyb3JUeXBlIiwiZXJyb3JNZXNzYWdlIiwiaW5jbHVkZXMiLCJQcm9wcyIsImlzVXBkYXRpbmciLCJvbkNoYW5nZUlzVXBkYXRpbmciLCJvbkF1dG9VcGRhdGVyUmVzdWx0IiwiYXV0b1VwZGF0ZXJSZXN1bHQiLCJzaG93U3VjY2Vzc01lc3NhZ2UiLCJ2ZXJib3NlIiwiTmF0aXZlQXV0b1VwZGF0ZXIiLCJSZWFjdE5vZGUiLCJ2ZXJzaW9ucyIsInNldFZlcnNpb25zIiwiY3VycmVudCIsImxhdGVzdCIsIm1heFZlcnNpb25Jc3N1ZSIsInNldE1heFZlcnNpb25Jc3N1ZSIsInVwZGF0ZVNlbXZlciIsInZlcnNpb24iLCJjaGFubmVsIiwiYXV0b1VwZGF0ZXNDaGFubmVsIiwiaXNVcGRhdGluZ1JlZiIsImNoZWNrRm9yVXBkYXRlcyIsInVzZUNhbGxiYWNrIiwic3RhcnRUaW1lIiwiRGF0ZSIsIm5vdyIsIm1heFZlcnNpb24iLCJNQUNSTyIsIlZFUlNJT04iLCJtc2ciLCJyZXN1bHQiLCJjdXJyZW50VmVyc2lvbiIsImxhdGVuY3lNcyIsImxvY2tGYWlsZWQiLCJsYXRlbmN5X21zIiwibGF0ZXN0VmVyc2lvbiIsIndhc1VwZGF0ZWQiLCJzdGF0dXMiLCJlcnJvciIsIkVycm9yIiwibWVzc2FnZSIsIlN0cmluZyIsImVycm9yVHlwZSIsImVycm9yX3RpbWVvdXQiLCJlcnJvcl9jaGVja3N1bSIsImVycm9yX25vdF9mb3VuZCIsImVycm9yX3Blcm1pc3Npb24iLCJlcnJvcl9kaXNrX2Z1bGwiLCJlcnJvcl9ucG0iLCJlcnJvcl9uZXR3b3JrIiwiaGFzVXBkYXRlUmVzdWx0IiwiaGFzVmVyc2lvbkluZm8iLCJzaG91bGRSZW5kZXIiXSwic291cmNlcyI6WyJOYXRpdmVBdXRvVXBkYXRlci50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VFZmZlY3QsIHVzZVJlZiwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGxvZ0V2ZW50IH0gZnJvbSAnc3JjL3NlcnZpY2VzL2FuYWx5dGljcy9pbmRleC5qcydcbmltcG9ydCB7IGxvZ0ZvckRlYnVnZ2luZyB9IGZyb20gJ3NyYy91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IGxvZ0Vycm9yIH0gZnJvbSAnc3JjL3V0aWxzL2xvZy5qcydcbmltcG9ydCB7IHVzZUludGVydmFsIH0gZnJvbSAndXNlaG9va3MtdHMnXG5pbXBvcnQgeyB1c2VVcGRhdGVOb3RpZmljYXRpb24gfSBmcm9tICcuLi9ob29rcy91c2VVcGRhdGVOb3RpZmljYXRpb24uanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgdHlwZSB7IEF1dG9VcGRhdGVyUmVzdWx0IH0gZnJvbSAnLi4vdXRpbHMvYXV0b1VwZGF0ZXIuanMnXG5pbXBvcnQgeyBnZXRNYXhWZXJzaW9uLCBnZXRNYXhWZXJzaW9uTWVzc2FnZSB9IGZyb20gJy4uL3V0aWxzL2F1dG9VcGRhdGVyLmpzJ1xuaW1wb3J0IHsgaXNBdXRvVXBkYXRlckRpc2FibGVkIH0gZnJvbSAnLi4vdXRpbHMvY29uZmlnLmpzJ1xuaW1wb3J0IHsgaW5zdGFsbExhdGVzdCB9IGZyb20gJy4uL3V0aWxzL25hdGl2ZUluc3RhbGxlci9pbmRleC5qcydcbmltcG9ydCB7IGd0IH0gZnJvbSAnLi4vdXRpbHMvc2VtdmVyLmpzJ1xuaW1wb3J0IHsgZ2V0SW5pdGlhbFNldHRpbmdzIH0gZnJvbSAnLi4vdXRpbHMvc2V0dGluZ3Mvc2V0dGluZ3MuanMnXG5cbi8qKlxuICogQ2F0ZWdvcml6ZSBlcnJvciBtZXNzYWdlcyBmb3IgYW5hbHl0aWNzXG4gKi9cbmZ1bmN0aW9uIGdldEVycm9yVHlwZShlcnJvck1lc3NhZ2U6IHN0cmluZyk6IHN0cmluZyB7XG4gIGlmIChlcnJvck1lc3NhZ2UuaW5jbHVkZXMoJ3RpbWVvdXQnKSkge1xuICAgIHJldHVybiAndGltZW91dCdcbiAgfVxuICBpZiAoZXJyb3JNZXNzYWdlLmluY2x1ZGVzKCdDaGVja3N1bSBtaXNtYXRjaCcpKSB7XG4gICAgcmV0dXJuICdjaGVja3N1bV9taXNtYXRjaCdcbiAgfVxuICBpZiAoZXJyb3JNZXNzYWdlLmluY2x1ZGVzKCdFTk9FTlQnKSB8fCBlcnJvck1lc3NhZ2UuaW5jbHVkZXMoJ25vdCBmb3VuZCcpKSB7XG4gICAgcmV0dXJuICdub3RfZm91bmQnXG4gIH1cbiAgaWYgKGVycm9yTWVzc2FnZS5pbmNsdWRlcygnRUFDQ0VTJykgfHwgZXJyb3JNZXNzYWdlLmluY2x1ZGVzKCdwZXJtaXNzaW9uJykpIHtcbiAgICByZXR1cm4gJ3Blcm1pc3Npb25fZGVuaWVkJ1xuICB9XG4gIGlmIChlcnJvck1lc3NhZ2UuaW5jbHVkZXMoJ0VOT1NQQycpKSB7XG4gICAgcmV0dXJuICdkaXNrX2Z1bGwnXG4gIH1cbiAgaWYgKGVycm9yTWVzc2FnZS5pbmNsdWRlcygnbnBtJykpIHtcbiAgICByZXR1cm4gJ25wbV9lcnJvcidcbiAgfVxuICBpZiAoXG4gICAgZXJyb3JNZXNzYWdlLmluY2x1ZGVzKCduZXR3b3JrJykgfHxcbiAgICBlcnJvck1lc3NhZ2UuaW5jbHVkZXMoJ0VDT05OUkVGVVNFRCcpIHx8XG4gICAgZXJyb3JNZXNzYWdlLmluY2x1ZGVzKCdFTk9URk9VTkQnKVxuICApIHtcbiAgICByZXR1cm4gJ25ldHdvcmtfZXJyb3InXG4gIH1cbiAgcmV0dXJuICd1bmtub3duJ1xufVxuXG50eXBlIFByb3BzID0ge1xuICBpc1VwZGF0aW5nOiBib29sZWFuXG4gIG9uQ2hhbmdlSXNVcGRhdGluZzogKGlzVXBkYXRpbmc6IGJvb2xlYW4pID0+IHZvaWRcbiAgb25BdXRvVXBkYXRlclJlc3VsdDogKGF1dG9VcGRhdGVyUmVzdWx0OiBBdXRvVXBkYXRlclJlc3VsdCkgPT4gdm9pZFxuICBhdXRvVXBkYXRlclJlc3VsdDogQXV0b1VwZGF0ZXJSZXN1bHQgfCBudWxsXG4gIHNob3dTdWNjZXNzTWVzc2FnZTogYm9vbGVhblxuICB2ZXJib3NlOiBib29sZWFuXG59XG5cbmV4cG9ydCBmdW5jdGlvbiBOYXRpdmVBdXRvVXBkYXRlcih7XG4gIGlzVXBkYXRpbmcsXG4