mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(web): cloud-only boot picker + build:web target (#1466)
This commit is contained in:
@@ -19,6 +19,7 @@ package-lock.json
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-web
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+25
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user