Skip to content

Commit 83bc564

Browse files
authored
feat(web): cloud-only boot picker + build:web target (tinyhumansai#1466)
1 parent 81d24b4 commit 83bc564

6 files changed

Lines changed: 179 additions & 39 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package-lock.json
1919
node_modules
2020
dist
2121
dist-ssr
22+
dist-web
2223
*.local
2324

2425
# Environment variables

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh",
1717
"build": "tsc && vite build",
1818
"build:app": "tsc && vite build",
19+
"build:web": "cross-env VITE_OPENHUMAN_TARGET=web tsc && cross-env VITE_OPENHUMAN_TARGET=web vite build",
1920
"compile": "tsc --noEmit",
2021
"preview": "vite preview",
2122
"tauri": "tauri",
@@ -121,6 +122,7 @@
121122
"@wdio/mocha-framework": "^9.24.0",
122123
"@wdio/spec-reporter": "^9.24.0",
123124
"autoprefixer": "^10.4.23",
125+
"cross-env": "^10.1.0",
124126
"eslint": "^9.39.2",
125127
"eslint-config-prettier": "^10.1.8",
126128
"eslint-plugin-import": "^2.32.0",

app/src/components/BootCheckGate/BootCheckGate.tsx

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* Visual language follows ServiceBlockingGate.tsx (bg-stone-950/80 overlay,
1010
* bg-stone-900 panel, ocean-500 / coral-500 semantics).
1111
*/
12+
import { isTauri } from '@tauri-apps/api/core';
1213
import debug from 'debug';
1314
import { useCallback, useEffect, useRef, useState } from 'react';
1415

@@ -74,8 +75,17 @@ type TestStatus =
7475
| { kind: 'auth' }
7576
| { kind: 'unreachable'; reason: string };
7677

78+
// Desktop release artifact URL surfaced on the web build's mode picker so
79+
// users without a remote core have a clear path to install the app instead
80+
// of being trapped on the cloud-only form.
81+
const DESKTOP_DOWNLOAD_URL = 'https://github.com/tinyhumansai/openhuman/releases/latest';
82+
7783
function ModePicker({ onConfirm }: PickerProps) {
78-
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
84+
// Web build cannot spawn a local sidecar, so the only viable choice is
85+
// cloud. Default the selection accordingly and hide the local option in
86+
// the render path below.
87+
const isDesktop = isTauri();
88+
const [selected, setSelected] = useState<'local' | 'cloud'>(isDesktop ? 'local' : 'cloud');
7989
const [cloudUrl, setCloudUrl] = useState('');
8090
const [cloudToken, setCloudToken] = useState('');
8191
const [urlError, setUrlError] = useState<string | null>(null);
@@ -180,41 +190,65 @@ function ModePicker({ onConfirm }: PickerProps) {
180190

181191
return (
182192
<Panel>
183-
<h2 className="text-xl font-semibold text-white">Choose core mode</h2>
193+
<h2 className="text-xl font-semibold text-white">
194+
{isDesktop ? 'Choose core mode' : 'Connect to your core'}
195+
</h2>
184196
<p className="mt-2 text-sm text-stone-300">
185-
OpenHuman needs a running core to operate. Choose how you want to connect.
197+
{isDesktop
198+
? 'OpenHuman needs a running core to operate. Choose how you want to connect.'
199+
: 'OpenHuman on the web connects to a remote core you control. Enter its URL and auth token, or install the desktop app to run one locally.'}
186200
</p>
187201

202+
{!isDesktop && (
203+
<div
204+
className="mt-4 rounded-xl border border-stone-700 bg-stone-800/60 p-3 text-xs text-stone-300"
205+
data-testid="web-download-cta">
206+
Prefer to run everything on your own device?{' '}
207+
<a
208+
href={DESKTOP_DOWNLOAD_URL}
209+
target="_blank"
210+
rel="noopener noreferrer"
211+
className="text-ocean-400 underline hover:text-ocean-300">
212+
Download the desktop app
213+
</a>
214+
.
215+
</div>
216+
)}
217+
188218
<div className="mt-5 flex flex-col gap-3">
189-
{/* Local option */}
190-
<button
191-
type="button"
192-
onClick={() => setSelected('local')}
193-
className={`rounded-xl border p-4 text-left transition-colors ${
194-
selected === 'local'
195-
? 'border-ocean-500 bg-ocean-500/10 text-white'
196-
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
197-
}`}>
198-
<div className="font-medium">Local (recommended)</div>
199-
<div className="mt-0.5 text-xs text-stone-400">
200-
Embedded core runs on this device — fastest, no configuration required.
201-
</div>
202-
</button>
219+
{/* Local option — desktop only; web builds cannot spawn a sidecar. */}
220+
{isDesktop && (
221+
<button
222+
type="button"
223+
onClick={() => setSelected('local')}
224+
className={`rounded-xl border p-4 text-left transition-colors ${
225+
selected === 'local'
226+
? 'border-ocean-500 bg-ocean-500/10 text-white'
227+
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
228+
}`}>
229+
<div className="font-medium">Local (recommended)</div>
230+
<div className="mt-0.5 text-xs text-stone-400">
231+
Embedded core runs on this device — fastest, no configuration required.
232+
</div>
233+
</button>
234+
)}
203235

204-
{/* Cloud option */}
205-
<button
206-
type="button"
207-
onClick={() => setSelected('cloud')}
208-
className={`rounded-xl border p-4 text-left transition-colors ${
209-
selected === 'cloud'
210-
? 'border-ocean-500 bg-ocean-500/10 text-white'
211-
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
212-
}`}>
213-
<div className="font-medium">Cloud</div>
214-
<div className="mt-0.5 text-xs text-stone-400">
215-
Connect to a remote core at a custom URL.
216-
</div>
217-
</button>
236+
{/* Cloud option — always available; the only option on the web build. */}
237+
{isDesktop && (
238+
<button
239+
type="button"
240+
onClick={() => setSelected('cloud')}
241+
className={`rounded-xl border p-4 text-left transition-colors ${
242+
selected === 'cloud'
243+
? 'border-ocean-500 bg-ocean-500/10 text-white'
244+
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
245+
}`}>
246+
<div className="font-medium">Cloud</div>
247+
<div className="mt-0.5 text-xs text-stone-400">
248+
Connect to a remote core at a custom URL.
249+
</div>
250+
</button>
251+
)}
218252

219253
{selected === 'cloud' && (
220254
<div className="mt-1 flex flex-col gap-3">

app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@
88
* - Assert rendered text and dispatched actions for each meaningful state.
99
*/
1010
import { configureStore } from '@reduxjs/toolkit';
11+
import { isTauri } from '@tauri-apps/api/core';
1112
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
1213
import { Provider } from 'react-redux';
1314
import { beforeEach, describe, expect, it, vi } from 'vitest';
1415

1516
import coreModeReducer, { type CoreModeState } from '../../../store/coreModeSlice';
1617
import BootCheckGate from '../BootCheckGate';
1718

19+
// The global test setup mocks isTauri()=>false (web). The existing picker
20+
// behavior under test was written for desktop (local option visible,
21+
// pre-selected). Force desktop runtime for those describes; the new web
22+
// describe at the bottom flips it back to false.
23+
const mockedIsTauri = vi.mocked(isTauri);
24+
1825
// ---------------------------------------------------------------------------
1926
// Mocks
2027
// ---------------------------------------------------------------------------
@@ -68,6 +75,11 @@ function renderGate(store = makeStore()) {
6875
// Tests
6976
// ---------------------------------------------------------------------------
7077

78+
// All describes below assume desktop unless they explicitly opt out.
79+
beforeEach(() => {
80+
mockedIsTauri.mockReturnValue(true);
81+
});
82+
7183
describe('BootCheckGate — picker (unset mode)', () => {
7284
it('shows the mode picker when coreMode is unset', () => {
7385
renderGate();
@@ -472,3 +484,67 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => {
472484
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
473485
});
474486
});
487+
488+
describe('BootCheckGate — picker (web build, !isTauri)', () => {
489+
beforeEach(() => {
490+
mockedIsTauri.mockReturnValue(false);
491+
});
492+
493+
it('uses the web-friendly title and hides the Local option', () => {
494+
renderGate();
495+
496+
expect(screen.getByText('Connect to your core')).toBeInTheDocument();
497+
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
498+
expect(screen.queryByText('Local (recommended)')).not.toBeInTheDocument();
499+
// The selectable Cloud tile is also gone — cloud is implicit and the
500+
// URL/token form is rendered directly.
501+
expect(screen.queryByRole('button', { name: 'Cloud' })).not.toBeInTheDocument();
502+
});
503+
504+
it('renders the cloud form fields immediately (cloud is the only option)', () => {
505+
renderGate();
506+
507+
expect(screen.getByPlaceholderText(/https:\/\/core\.example\.com/)).toBeInTheDocument();
508+
expect(screen.getByPlaceholderText(/Bearer token/i)).toBeInTheDocument();
509+
});
510+
511+
it('shows a Download desktop app CTA linking to the release page', () => {
512+
renderGate();
513+
514+
const cta = screen.getByTestId('web-download-cta');
515+
expect(cta).toBeInTheDocument();
516+
const link = cta.querySelector('a');
517+
expect(link).not.toBeNull();
518+
expect(link?.getAttribute('href')).toMatch(
519+
/github\.com\/tinyhumansai\/openhuman\/releases\/latest/
520+
);
521+
expect(link?.getAttribute('target')).toBe('_blank');
522+
expect(link?.getAttribute('rel')).toMatch(/noopener/);
523+
});
524+
525+
it('continues into a cloud boot check when URL + token are provided', async () => {
526+
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
527+
528+
renderGate();
529+
530+
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
531+
target: { value: 'https://core.example.com/rpc' },
532+
});
533+
fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
534+
target: { value: 'tok-web' },
535+
});
536+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
537+
538+
await waitFor(() => {
539+
expect(screen.getByTestId('app-content')).toBeInTheDocument();
540+
});
541+
expect(mockRunBootCheck).toHaveBeenCalledWith(
542+
expect.objectContaining({
543+
kind: 'cloud',
544+
url: 'https://core.example.com/rpc',
545+
token: 'tok-web',
546+
}),
547+
expect.any(Object)
548+
);
549+
});
550+
});

app/vite.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ function guardCefRelListSupportsPlugin(): PluginOption {
8484
};
8585
}
8686

87+
// `VITE_OPENHUMAN_TARGET=web` switches the build to the browser-hosted
88+
// flavor: output lands in `dist-web/` so the desktop build artifact in
89+
// `dist/` (consumed by `cargo tauri build`) is never clobbered, and the
90+
// `import.meta.env.VITE_OPENHUMAN_TARGET` value is exposed to runtime code
91+
// that wants a build-time signal in addition to the runtime `isTauri()`
92+
// check. Default (`undefined` / `desktop`) keeps the historical behavior.
93+
const buildTarget = (process.env.VITE_OPENHUMAN_TARGET ?? "desktop").trim();
94+
const isWebTarget = buildTarget === "web";
95+
8796
// https://vite.dev/config/
8897
export default defineConfig(async () => ({
8998
root: "src",
@@ -98,7 +107,7 @@ export default defineConfig(async () => ({
98107
// the shell exports staging URLs.
99108
envDir: resolve(__dirname, ".."),
100109
build: {
101-
outDir: "../dist",
110+
outDir: isWebTarget ? "../dist-web" : "../dist",
102111
emptyOutDir: true,
103112
// Desktop CEF has surfaced a runtime where `link.relList.supports` is
104113
// truthy but not callable. Vite calls it both in the modulepreload

pnpm-lock.yaml

Lines changed: 25 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)