feat(web): cloud-only boot picker + build:web target (#1466)

This commit is contained in:
Steven Enamakel
2026-05-10 22:14:18 -07:00
committed by GitHub
parent 81d24b4d74
commit 83bc564838
6 changed files with 180 additions and 40 deletions
+1
View File
@@ -19,6 +19,7 @@ package-lock.json
node_modules
dist
dist-ssr
dist-web
*.local
# Environment variables
+2
View File
@@ -16,6 +16,7 @@
"tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh",
"build": "tsc && vite build",
"build:app": "tsc && vite build",
"build:web": "cross-env VITE_OPENHUMAN_TARGET=web tsc && cross-env VITE_OPENHUMAN_TARGET=web vite build",
"compile": "tsc --noEmit",
"preview": "vite preview",
"tauri": "tauri",
@@ -121,6 +122,7 @@
"@wdio/mocha-framework": "^9.24.0",
"@wdio/spec-reporter": "^9.24.0",
"autoprefixer": "^10.4.23",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
@@ -9,6 +9,7 @@
* Visual language follows ServiceBlockingGate.tsx (bg-stone-950/80 overlay,
* bg-stone-900 panel, ocean-500 / coral-500 semantics).
*/
import { isTauri } from '@tauri-apps/api/core';
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -74,8 +75,17 @@ type TestStatus =
| { kind: 'auth' }
| { kind: 'unreachable'; reason: string };
// Desktop release artifact URL surfaced on the web build's mode picker so
// users without a remote core have a clear path to install the app instead
// of being trapped on the cloud-only form.
const DESKTOP_DOWNLOAD_URL = 'https://github.com/tinyhumansai/openhuman/releases/latest';
function ModePicker({ onConfirm }: PickerProps) {
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
// Web build cannot spawn a local sidecar, so the only viable choice is
// cloud. Default the selection accordingly and hide the local option in
// the render path below.
const isDesktop = isTauri();
const [selected, setSelected] = useState<'local' | 'cloud'>(isDesktop ? 'local' : 'cloud');
const [cloudUrl, setCloudUrl] = useState('');
const [cloudToken, setCloudToken] = useState('');
const [urlError, setUrlError] = useState<string | null>(null);
@@ -180,41 +190,65 @@ function ModePicker({ onConfirm }: PickerProps) {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Choose core mode</h2>
<h2 className="text-xl font-semibold text-white">
{isDesktop ? 'Choose core mode' : 'Connect to your core'}
</h2>
<p className="mt-2 text-sm text-stone-300">
OpenHuman needs a running core to operate. Choose how you want to connect.
{isDesktop
? 'OpenHuman needs a running core to operate. Choose how you want to connect.'
: '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.'}
</p>
<div className="mt-5 flex flex-col gap-3">
{/* Local option */}
<button
type="button"
onClick={() => setSelected('local')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'local'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">Local (recommended)</div>
<div className="mt-0.5 text-xs text-stone-400">
Embedded core runs on this device fastest, no configuration required.
</div>
</button>
{!isDesktop && (
<div
className="mt-4 rounded-xl border border-stone-700 bg-stone-800/60 p-3 text-xs text-stone-300"
data-testid="web-download-cta">
Prefer to run everything on your own device?{' '}
<a
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noopener noreferrer"
className="text-ocean-400 underline hover:text-ocean-300">
Download the desktop app
</a>
.
</div>
)}
{/* Cloud option */}
<button
type="button"
onClick={() => setSelected('cloud')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'cloud'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">Cloud</div>
<div className="mt-0.5 text-xs text-stone-400">
Connect to a remote core at a custom URL.
</div>
</button>
<div className="mt-5 flex flex-col gap-3">
{/* Local option — desktop only; web builds cannot spawn a sidecar. */}
{isDesktop && (
<button
type="button"
onClick={() => setSelected('local')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'local'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">Local (recommended)</div>
<div className="mt-0.5 text-xs text-stone-400">
Embedded core runs on this device fastest, no configuration required.
</div>
</button>
)}
{/* Cloud option — always available; the only option on the web build. */}
{isDesktop && (
<button
type="button"
onClick={() => setSelected('cloud')}
className={`rounded-xl border p-4 text-left transition-colors ${
selected === 'cloud'
? 'border-ocean-500 bg-ocean-500/10 text-white'
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
}`}>
<div className="font-medium">Cloud</div>
<div className="mt-0.5 text-xs text-stone-400">
Connect to a remote core at a custom URL.
</div>
</button>
)}
{selected === 'cloud' && (
<div className="mt-1 flex flex-col gap-3">
@@ -8,6 +8,7 @@
* - Assert rendered text and dispatched actions for each meaningful state.
*/
import { configureStore } from '@reduxjs/toolkit';
import { isTauri } from '@tauri-apps/api/core';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -15,6 +16,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import coreModeReducer, { type CoreModeState } from '../../../store/coreModeSlice';
import BootCheckGate from '../BootCheckGate';
// The global test setup mocks isTauri()=>false (web). The existing picker
// behavior under test was written for desktop (local option visible,
// pre-selected). Force desktop runtime for those describes; the new web
// describe at the bottom flips it back to false.
const mockedIsTauri = vi.mocked(isTauri);
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
@@ -68,6 +75,11 @@ function renderGate(store = makeStore()) {
// Tests
// ---------------------------------------------------------------------------
// All describes below assume desktop unless they explicitly opt out.
beforeEach(() => {
mockedIsTauri.mockReturnValue(true);
});
describe('BootCheckGate — picker (unset mode)', () => {
it('shows the mode picker when coreMode is unset', () => {
renderGate();
@@ -472,3 +484,67 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => {
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
});
});
describe('BootCheckGate — picker (web build, !isTauri)', () => {
beforeEach(() => {
mockedIsTauri.mockReturnValue(false);
});
it('uses the web-friendly title and hides the Local option', () => {
renderGate();
expect(screen.getByText('Connect to your core')).toBeInTheDocument();
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
expect(screen.queryByText('Local (recommended)')).not.toBeInTheDocument();
// The selectable Cloud tile is also gone — cloud is implicit and the
// URL/token form is rendered directly.
expect(screen.queryByRole('button', { name: 'Cloud' })).not.toBeInTheDocument();
});
it('renders the cloud form fields immediately (cloud is the only option)', () => {
renderGate();
expect(screen.getByPlaceholderText(/https:\/\/core\.example\.com/)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Bearer token/i)).toBeInTheDocument();
});
it('shows a Download desktop app CTA linking to the release page', () => {
renderGate();
const cta = screen.getByTestId('web-download-cta');
expect(cta).toBeInTheDocument();
const link = cta.querySelector('a');
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toMatch(
/github\.com\/tinyhumansai\/openhuman\/releases\/latest/
);
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toMatch(/noopener/);
});
it('continues into a cloud boot check when URL + token are provided', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
renderGate();
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
target: { value: 'https://core.example.com/rpc' },
});
fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
target: { value: 'tok-web' },
});
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
expect(mockRunBootCheck).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'cloud',
url: 'https://core.example.com/rpc',
token: 'tok-web',
}),
expect.any(Object)
);
});
});
+10 -1
View File
@@ -84,6 +84,15 @@ function guardCefRelListSupportsPlugin(): PluginOption {
};
}
// `VITE_OPENHUMAN_TARGET=web` switches the build to the browser-hosted
// flavor: output lands in `dist-web/` so the desktop build artifact in
// `dist/` (consumed by `cargo tauri build`) is never clobbered, and the
// `import.meta.env.VITE_OPENHUMAN_TARGET` value is exposed to runtime code
// that wants a build-time signal in addition to the runtime `isTauri()`
// check. Default (`undefined` / `desktop`) keeps the historical behavior.
const buildTarget = (process.env.VITE_OPENHUMAN_TARGET ?? "desktop").trim();
const isWebTarget = buildTarget === "web";
// https://vite.dev/config/
export default defineConfig(async () => ({
root: "src",
@@ -98,7 +107,7 @@ export default defineConfig(async () => ({
// the shell exports staging URLs.
envDir: resolve(__dirname, ".."),
build: {
outDir: "../dist",
outDir: isWebTarget ? "../dist-web" : "../dist",
emptyOutDir: true,
// Desktop CEF has surfaced a runtime where `link.relList.supports` is
// truthy but not callable. Vite calls it both in the modulepreload
+25 -7
View File
@@ -208,6 +208,9 @@ importers:
autoprefixer:
specifier: ^10.4.23
version: 10.5.0(postcss@8.5.10)
cross-env:
specifier: ^10.1.0
version: 10.1.0
eslint:
specifier: ^9.39.2
version: 9.39.4(jiti@1.21.7)
@@ -231,7 +234,7 @@ importers:
version: 28.1.0(@noble/hashes@2.2.0)
knip:
specifier: ^6.3.1
version: 6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
version: 6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
postcss:
specifier: ^8.5.6
version: 8.5.10
@@ -413,6 +416,9 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -2626,6 +2632,11 @@ packages:
engines: {node: '>=12.0.0'}
hasBin: true
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
hasBin: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -5827,6 +5838,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -6337,9 +6350,9 @@ snapshots:
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
@@ -7996,6 +8009,11 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -9380,7 +9398,7 @@ snapshots:
dependencies:
json-buffer: 3.0.1
knip@6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
knip@6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
formatly: 0.3.0
@@ -9388,7 +9406,7 @@ snapshots:
jiti: 2.6.1
minimist: 1.2.8
oxc-parser: 0.127.0
oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
picomatch: 4.0.4
smol-toml: 1.6.1
strip-json-comments: 5.0.3
@@ -10037,7 +10055,7 @@ snapshots:
'@oxc-parser/binding-win32-ia32-msvc': 0.127.0
'@oxc-parser/binding-win32-x64-msvc': 0.127.0
oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
'@oxc-resolver/binding-android-arm64': 11.19.1
@@ -10055,7 +10073,7 @@ snapshots:
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1