add auto-updater
This commit is contained in:
+74
-22
@@ -3,12 +3,17 @@
|
||||
* Uses Ink for terminal rendering with React components.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useApp } from 'ink';
|
||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
||||
import type { AppConfig } from '../app.js';
|
||||
import type {
|
||||
GitUpdateService,
|
||||
UpdateCheck,
|
||||
UpdateLaunchRequest,
|
||||
} from '../updater/git-update-service.js';
|
||||
import { colors, logoSmall } from './theme.js';
|
||||
|
||||
// Screen imports
|
||||
@@ -18,13 +23,15 @@ import { TemplateListScreen } from './screens/TemplateList.js';
|
||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||
|
||||
import { MessageDialog } from './components/Dialog.js';
|
||||
import { ConfirmDialog, MessageDialog } from './components/Dialog.js';
|
||||
|
||||
/**
|
||||
* Props for the App component.
|
||||
*/
|
||||
interface AppProps {
|
||||
config: AppConfig;
|
||||
updater: GitUpdateService;
|
||||
onUpdateAndRestart: (request: UpdateLaunchRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +59,7 @@ function Router(): React.ReactElement {
|
||||
/**
|
||||
* Status bar component shown at the bottom of the screen.
|
||||
*/
|
||||
function StatusBar(): React.ReactElement {
|
||||
function StatusBar({ update }: { update: UpdateCheck | null }): React.ReactElement {
|
||||
const { status } = useStatus();
|
||||
const { screen, canGoBack } = useNavigation();
|
||||
|
||||
@@ -64,7 +71,9 @@ function StatusBar(): React.ReactElement {
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text color={colors.primary} bold>{logoSmall}</Text>
|
||||
<Text color={colors.textMuted}>{status}</Text>
|
||||
<Text color={update?.status === 'available' ? colors.warning : colors.textMuted}>
|
||||
{update?.status === 'available' ? 'Update available · press U' : status}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
|
||||
</Text>
|
||||
@@ -80,9 +89,23 @@ function DialogOverlay(): React.ReactElement | null {
|
||||
|
||||
if (!dialog?.visible) return null;
|
||||
|
||||
const borderColor = dialog.type === 'error' ? colors.error :
|
||||
dialog.type === 'confirm' ? colors.warning :
|
||||
colors.info;
|
||||
const contents = dialog.type === 'confirm' ? (
|
||||
<ConfirmDialog
|
||||
title={dialog.title ?? "Confirm"}
|
||||
message={dialog.message}
|
||||
onConfirm={dialog.onConfirm ?? (() => {})}
|
||||
onCancel={dialog.onCancel ?? (() => {})}
|
||||
confirmLabel={dialog.confirmLabel}
|
||||
cancelLabel={dialog.cancelLabel}
|
||||
/>
|
||||
) : (
|
||||
<MessageDialog
|
||||
title={dialog.type === 'error' ? 'Error' : 'Info'}
|
||||
message={dialog.message}
|
||||
onClose={dialog.onCancel ?? (() => {})}
|
||||
type={dialog.type}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -93,14 +116,7 @@ function DialogOverlay(): React.ReactElement | null {
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<MessageDialog
|
||||
title={dialog.type === 'error' ? '✗ Error' :
|
||||
dialog.type === 'confirm' ? '? Confirm' :
|
||||
'ℹ Info'}
|
||||
message={dialog.message}
|
||||
onClose={dialog.onCancel ?? (() => {})}
|
||||
type={dialog.type as 'error' | 'info' | 'success'}
|
||||
/>
|
||||
{contents}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -108,18 +124,54 @@ function DialogOverlay(): React.ReactElement | null {
|
||||
/**
|
||||
* Main content wrapper with global keybindings.
|
||||
*/
|
||||
function MainContent(): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
function MainContent({
|
||||
updater,
|
||||
onUpdateAndRestart,
|
||||
}: Pick<AppProps, 'updater' | 'onUpdateAndRestart'>): React.ReactElement {
|
||||
const { goBack, canGoBack } = useNavigation();
|
||||
const { screen } = useNavigation();
|
||||
const appContext = useAppContext();
|
||||
const [update, setUpdate] = useState<UpdateCheck | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
updater.check().then((result) => {
|
||||
if (active) setUpdate(result);
|
||||
}).catch(() => {
|
||||
// Update checks are advisory and must never prevent wallet use.
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [updater]);
|
||||
|
||||
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
|
||||
useBlockableInput((input, key) => {
|
||||
// Quit on Ctrl+C
|
||||
if (key.ctrl && input === 'c') {
|
||||
appContext.exit();
|
||||
exit();
|
||||
void appContext.exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((input === 'u' || input === 'U') && update?.status === 'available') {
|
||||
if (!update.canApply) {
|
||||
appContext.showError(update.blockReason ?? 'This update cannot be applied automatically.');
|
||||
return;
|
||||
}
|
||||
|
||||
void appContext.confirm(
|
||||
`Branch: ${update.branch}\nInstalled: ${update.currentCommit.slice(0, 7)}\nAvailable: ${update.targetCommit.slice(0, 7)}\n\n${update.summary}`,
|
||||
{
|
||||
title: 'XO update available',
|
||||
confirmLabel: 'Update and restart',
|
||||
cancelLabel: 'Later',
|
||||
},
|
||||
).then(async (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
onUpdateAndRestart(update);
|
||||
await appContext.exit();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Go back on Escape
|
||||
@@ -144,7 +196,7 @@ function MainContent(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
{/* Status bar */}
|
||||
<StatusBar />
|
||||
<StatusBar update={update} />
|
||||
|
||||
{/* Dialog overlay */}
|
||||
<DialogOverlay />
|
||||
@@ -156,7 +208,7 @@ function MainContent(): React.ReactElement {
|
||||
* Main App component.
|
||||
* Sets up providers and renders the main content.
|
||||
*/
|
||||
export function App({ config }: AppProps): React.ReactElement {
|
||||
export function App({ config, updater, onUpdateAndRestart }: AppProps): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
|
||||
// Cleanup will be handled by React when components unmount
|
||||
@@ -171,7 +223,7 @@ export function App({ config }: AppProps): React.ReactElement {
|
||||
>
|
||||
<InputLayerProvider>
|
||||
<NavigationProvider initialScreen="seed-input">
|
||||
<MainContent />
|
||||
<MainContent updater={updater} onUpdateAndRestart={onUpdateAndRestart} />
|
||||
</NavigationProvider>
|
||||
</InputLayerProvider>
|
||||
</AppProvider>
|
||||
|
||||
Reference in New Issue
Block a user