From 83bc5648388c5cc839b224cc27678aed11e9f1ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 10 May 2026 22:14:18 -0700 Subject: [PATCH] feat(web): cloud-only boot picker + build:web target (#1466) --- .gitignore | 1 + app/package.json | 2 + .../BootCheckGate/BootCheckGate.tsx | 98 +++++++++++++------ .../__tests__/BootCheckGate.test.tsx | 76 ++++++++++++++ app/vite.config.ts | 11 ++- pnpm-lock.yaml | 32 ++++-- 6 files changed, 180 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 1e509fbd7..bf65cebff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ package-lock.json node_modules dist dist-ssr +dist-web *.local # Environment variables diff --git a/app/package.json b/app/package.json index 42572c369..053f8a4bf 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 15ed3c06a..4f2eebc05 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -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(null); @@ -180,41 +190,65 @@ function ModePicker({ onConfirm }: PickerProps) { return ( -

Choose core mode

+

+ {isDesktop ? 'Choose core mode' : 'Connect to your core'} +

- 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.'}

-
- {/* Local option */} - + {!isDesktop && ( +
+ Prefer to run everything on your own device?{' '} + + Download the desktop app + + . +
+ )} - {/* Cloud option */} - +
+ {/* Local option — desktop only; web builds cannot spawn a sidecar. */} + {isDesktop && ( + + )} + + {/* Cloud option — always available; the only option on the web build. */} + {isDesktop && ( + + )} {selected === 'cloud' && (
diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 477aea46a..bc1dda7f1 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -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) + ); + }); +}); diff --git a/app/vite.config.ts b/app/vite.config.ts index 4f1f4aea8..21e5be8bb 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1e2607e..139aca34b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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