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}"