diff --git a/app/src/components/ErrorFallbackScreen.tsx b/app/src/components/ErrorFallbackScreen.tsx index fdd42596c..1397f56c3 100644 --- a/app/src/components/ErrorFallbackScreen.tsx +++ b/app/src/components/ErrorFallbackScreen.tsx @@ -1,3 +1,6 @@ +import { LATEST_APP_DOWNLOAD_URL } from '../utils/config'; +import { openUrl } from '../utils/openUrl'; + /** * ErrorFallbackScreen * @@ -55,6 +58,9 @@ export default function ErrorFallbackScreen({

The application encountered an unexpected error and could not recover.

+

+ If this keeps happening after restart, install the latest version. +

{/* Error details */}
@@ -73,10 +79,10 @@ export default function ErrorFallbackScreen({
{/* Actions */} -
+
+
diff --git a/app/src/components/daemon/ServiceBlockingGate.tsx b/app/src/components/daemon/ServiceBlockingGate.tsx index 795696f4a..8714b24c5 100644 --- a/app/src/components/daemon/ServiceBlockingGate.tsx +++ b/app/src/components/daemon/ServiceBlockingGate.tsx @@ -1,9 +1,83 @@ +import { useState } from 'react'; + +import { useDaemonHealth } from '../../hooks/useDaemonHealth'; +import { useDaemonLifecycle } from '../../hooks/useDaemonLifecycle'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { LATEST_APP_DOWNLOAD_URL } from '../../utils/config'; +import { openUrl } from '../../utils/openUrl'; + interface ServiceBlockingGateProps { children: React.ReactNode; } const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => { - return <>{children}; + const { snapshot } = useCoreState(); + const daemonHealth = useDaemonHealth(); + const daemonLifecycle = useDaemonLifecycle(); + const [isRestarting, setIsRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + const hasSession = Boolean(snapshot.sessionToken); + const shouldShowRecoveryPrompt = + hasSession && daemonLifecycle.maxAttemptsReached && daemonHealth.status !== 'running'; + + const handleRetry = async () => { + setIsRestarting(true); + setRestartError(null); + try { + const restarted = await daemonHealth.restartDaemon(); + if (restarted) { + daemonLifecycle.resetRetries(); + return; + } + setRestartError('Retry failed. Download the latest app build and try again.'); + } catch { + setRestartError('Retry failed. Download the latest app build and try again.'); + } finally { + setIsRestarting(false); + } + }; + + const handleDownloadLatest = async () => { + await openUrl(LATEST_APP_DOWNLOAD_URL); + }; + + if (!shouldShowRecoveryPrompt) { + return <>{children}; + } + + return ( + <> + {children} +
+
+

OpenHuman core is unavailable

+

+ We could not recover the local core service after multiple attempts. This can happen + after a critical crash or binary mismatch. +

+

+ Download the latest app build to restore a healthy core runtime. +

+ {restartError &&

{restartError}

} +
+ + +
+
+
+ + ); }; export default ServiceBlockingGate; diff --git a/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx b/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx index 9962b5dec..091e2c045 100644 --- a/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx +++ b/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx @@ -1,33 +1,38 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, type Mock } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as tauriCommands from '../../../utils/tauriCommands'; import ServiceBlockingGate from '../ServiceBlockingGate'; +const mockOpenUrl = vi.fn(); +const mockUseCoreState = vi.fn(); +const mockUseDaemonHealth = vi.fn(); +const mockUseDaemonLifecycle = vi.fn(); + +vi.mock('../../../utils/openUrl', () => ({ + openUrl: (...args: unknown[]) => mockOpenUrl(...args), +})); + +vi.mock('../../../utils/config', () => ({ + LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest', +})); + +vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); + +vi.mock('../../../hooks/useDaemonHealth', () => ({ useDaemonHealth: () => mockUseDaemonHealth() })); + +vi.mock('../../../hooks/useDaemonLifecycle', () => ({ + useDaemonLifecycle: () => mockUseDaemonLifecycle(), +})); + describe('ServiceBlockingGate', () => { - const mockIsTauri = tauriCommands.isTauri as Mock; - const mockServiceStatus = tauriCommands.openhumanServiceStatus as Mock; - const mockAgentStatus = tauriCommands.openhumanAgentServerStatus as Mock; - const mockInstall = tauriCommands.openhumanServiceInstall as Mock; - const mockStart = tauriCommands.openhumanServiceStart as Mock; - - it('renders children directly outside Tauri', async () => { - mockIsTauri.mockReturnValue(false); - - render( - -
App Content
-
- ); - - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); + beforeEach(() => { + mockOpenUrl.mockReset(); + mockUseCoreState.mockReturnValue({ snapshot: { sessionToken: 'token' } }); + mockUseDaemonHealth.mockReturnValue({ status: 'running', restartDaemon: vi.fn() }); + mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: false, resetRetries: vi.fn() }); }); - it('renders children even when service is not installed', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }); - mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] }); - + it('renders children and does not show recovery prompt when daemon is healthy', async () => { render(
App Content
@@ -35,15 +40,12 @@ describe('ServiceBlockingGate', () => { ); await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument(); + expect(screen.queryByText('OpenHuman core is unavailable')).not.toBeInTheDocument(); }); - it('does not expose forced install actions from the app shell', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }); - mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] }); - mockInstall.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] }); - mockStart.mockResolvedValue({ result: { state: 'Running' }, logs: [] }); + it('shows recovery prompt when daemon retries are exhausted', async () => { + mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() }); + mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() }); render( @@ -51,16 +53,13 @@ describe('ServiceBlockingGate', () => { ); - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByRole('button', { name: 'Install Service' })).not.toBeInTheDocument(); - expect(mockInstall).not.toHaveBeenCalled(); - expect(mockStart).not.toHaveBeenCalled(); + expect(screen.getByText('OpenHuman core is unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download Latest Version' })).toBeInTheDocument(); }); - it('renders children when service is running even if agent is not running', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] }); - mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] }); + it('opens latest release page from recovery prompt', async () => { + mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() }); + mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() }); render( @@ -68,14 +67,23 @@ describe('ServiceBlockingGate', () => { ); - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Download Latest Version' })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://github.com/tinyhumansai/openhuman/releases/latest' + ); + }); }); - it('renders children when service is running and agent probe fails', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] }); - mockAgentStatus.mockRejectedValue(new Error('agent status unavailable')); + it('calls restartDaemon and resetRetries on successful retry', async () => { + const mockRestartDaemon = vi.fn().mockResolvedValue(true); + const mockResetRetries = vi.fn(); + mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon }); + mockUseDaemonLifecycle.mockReturnValue({ + maxAttemptsReached: true, + resetRetries: mockResetRetries, + }); render( @@ -83,14 +91,18 @@ describe('ServiceBlockingGate', () => { ); - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry Core' })); + + await waitFor(() => { + expect(mockRestartDaemon).toHaveBeenCalled(); + expect(mockResetRetries).toHaveBeenCalled(); + }); }); - it('renders children when service is stopped but agent server is running (soft pass)', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] }); - mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] }); + it('shows error message when restartDaemon returns false', async () => { + const mockRestartDaemon = vi.fn().mockResolvedValue(false); + mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon }); + mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() }); render( @@ -98,14 +110,17 @@ describe('ServiceBlockingGate', () => { ); - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry Core' })); + + await waitFor(() => { + expect(screen.getByText(/Retry failed/)).toBeInTheDocument(); + }); }); - it('renders children when service probe fails but agent server is running (soft pass)', async () => { - mockIsTauri.mockReturnValue(true); - mockServiceStatus.mockRejectedValue(new Error('service status unavailable')); - mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] }); + it('shows error message when restartDaemon throws', async () => { + const mockRestartDaemon = vi.fn().mockRejectedValue(new Error('daemon error')); + mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon }); + mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() }); render( @@ -113,7 +128,10 @@ describe('ServiceBlockingGate', () => { ); - await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument()); - expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry Core' })); + + await waitFor(() => { + expect(screen.getByText(/Retry failed/)).toBeInTheDocument(); + }); }); }); diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index d817daa44..2e0fe007d 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -96,7 +96,7 @@ export const SENTRY_RELEASE = BUILD_SHA export const MINIMUM_SUPPORTED_APP_VERSION = (import.meta.env.VITE_MINIMUM_SUPPORTED_APP_VERSION as string | undefined)?.trim() ?? ''; -/** Recovery link opened when OAuth is blocked due to an outdated app build (build-time default; override via CI). */ +/** URL for the latest app release download page. Used for OAuth version-gate recovery and crash-recovery prompts. Override via VITE_LATEST_APP_DOWNLOAD_URL for deployment-specific download pages. */ export const LATEST_APP_DOWNLOAD_URL = (import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() || 'https://github.com/tinyhumansai/openhuman/releases/latest'; diff --git a/scripts/install.sh b/scripts/install.sh index b50e4642f..4af42c455 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -255,7 +255,12 @@ PY ASSET_URL="$(echo "${parsed}" | sed -n '3p')" ASSET_SHA256="$(echo "${parsed}" | sed -n '4p')" - [ -n "${ASSET_URL}" ] + # Exit 0 on success, 2 when API responded but no compatible asset was found. + # Callers can distinguish "no asset" (2) from network/parse errors (1). + if [ -n "${ASSET_URL}" ]; then + return 0 + fi + return 2 } resolve_release_digest() { @@ -292,8 +297,10 @@ if resolve_from_latest_json; then log_ok "Resolved latest release via latest.json (${LATEST_VERSION})" else log_warn "latest.json lookup failed. Falling back to releases API." - if ! resolve_from_release_api; then - if [ "${DRY_RUN}" = true ]; then + resolve_from_release_api + resolve_rc=$? + if [ "${resolve_rc}" -ne 0 ]; then + if [ "${DRY_RUN}" = true ] && [ "${resolve_rc}" -eq 2 ]; then if [ "${OS}" = "linux" ]; then log_warn "No Linux release asset is currently published. Dry-run will skip install steps." echo "DRY RUN: no compatible asset available for ${OS}/${ARCH}"