diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4495b9f..cadadd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,7 @@ jobs: - run: bunx tsc --noEmit -p packages/webtau/tsconfig.json - run: bunx tsc --noEmit -p packages/webtau-vite/tsconfig.json - run: bunx tsc --noEmit -p packages/create-gametau/tsconfig.json + - run: bunx tsc --noEmit -p examples/electrobun-counter/tsconfig.json - run: bunx tsc --noEmit -p examples/battlestation/tsconfig.json lint: diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a55c1..c4405be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added - `webtau/adapters/electrobun`: `bootstrapElectrobunFromWindowBridge()`, `createElectrobunWindowBridgeProvider()`, `isElectrobun()`, and `getElectrobunCapabilities()` for first-class Electrobun runtime detection and bridge bootstrap. +- `webtau`: `getRuntimeInfo()` for runtime id and capability introspection across WASM, Tauri, and provider-backed runtimes. - `create-gametau`: `--desktop-shell electrobun` and `--electrobun-mode hybrid|native|dual`. - Electrobun counter example BrowserWindow and GPUWindow build lanes. diff --git a/README.md b/README.md index e02e3e3..cb95ae7 100644 --- a/README.md +++ b/README.md @@ -332,11 +332,24 @@ if (!isTauri()) { } ``` -#### `isTauri()` - -Returns `true` when running inside Tauri (checks `window.__TAURI_INTERNALS__`). - -#### `wasm_state!(Type)` (Rust crate) +#### `isTauri()` + +Returns `true` when running inside Tauri (checks `window.__TAURI_INTERNALS__`). + +#### `getRuntimeInfo()` + +Returns the active runtime id plus capability flags for capability-based branching. + +```typescript +import { getRuntimeInfo } from "webtau"; + +const runtime = getRuntimeInfo(); +// { id: "wasm" | "tauri" | "electrobun" | string, platform, capabilities } +``` + +Use this when a template or app needs to distinguish shell/render mode without relying on ad hoc globals. + +#### `wasm_state!(Type)` (Rust crate) Generates thread-local state management for WASM. Replaces Tauri's `State>` for the browser target. diff --git a/examples/electrobun-counter/src/index.ts b/examples/electrobun-counter/src/index.ts index a5edb58..0286dad 100644 --- a/examples/electrobun-counter/src/index.ts +++ b/examples/electrobun-counter/src/index.ts @@ -1,6 +1,5 @@ -import { configure, isTauri } from "webtau"; +import { configure, getRuntimeInfo, isTauri } from "webtau"; import { - getElectrobunCapabilities, bootstrapElectrobunFromWindowBridge, } from "webtau/adapters/electrobun"; import { setupElectrobunHybridWgpuWhenReady } from "./hybrid-wgpu"; @@ -12,9 +11,9 @@ async function main() { let hybridHandle: Awaited> = null; if (bootstrapElectrobunFromWindowBridge()) { - const capabilities = getElectrobunCapabilities(); hybridHandle = await setupElectrobunHybridWgpuWhenReady(); - const renderMode = hybridHandle?.renderMode ?? capabilities?.renderMode ?? "browser"; + const runtime = getRuntimeInfo(); + const renderMode = hybridHandle?.renderMode ?? runtime.capabilities.renderMode ?? "browser"; modeEl.textContent = `Electrobun bridge (${renderMode})`; } else if (!isTauri()) { modeEl.textContent = "WASM (web)"; diff --git a/packages/create-gametau/README.md b/packages/create-gametau/README.md index 00bdae9..f9b8754 100644 --- a/packages/create-gametau/README.md +++ b/packages/create-gametau/README.md @@ -63,6 +63,7 @@ Runtime bootstrap is explicit in scaffolded `src/index.ts`: - Electrobun path: `bootstrapElectrobunFromWindowBridge()` from `webtau/adapters/electrobun` - Tauri path: `bootstrapTauri()` from `webtau/adapters/tauri` - Web path: `configure({ loadWasm })` from `webtau` +- Runtime/capability seam: `getRuntimeInfo()` from `webtau` This keeps the generated project lightweight while giving contributors clear extension points for production features. diff --git a/packages/create-gametau/templates/base/src/index.ts b/packages/create-gametau/templates/base/src/index.ts index 5d3b26e..676f71a 100644 --- a/packages/create-gametau/templates/base/src/index.ts +++ b/packages/create-gametau/templates/base/src/index.ts @@ -1,4 +1,4 @@ -import { configure, isTauri } from "webtau"; +import { configure, getRuntimeInfo, isTauri } from "webtau"; import { bootstrapElectrobunFromWindowBridge } from "webtau/adapters/electrobun"; import { bootstrapTauri } from "webtau/adapters/tauri"; import { startGameLoop } from "./game/loop"; @@ -43,6 +43,12 @@ async function main() { }); } + const runtime = getRuntimeInfo(); + document.body.dataset.runtime = runtime.id; + if (runtime.capabilities.renderMode) { + document.body.dataset.renderMode = runtime.capabilities.renderMode; + } + // Set up the renderer const app = document.getElementById("app")!; await initScene(app); diff --git a/packages/webtau/src/adapters/electrobun.ts b/packages/webtau/src/adapters/electrobun.ts index 880e794..e6e1429 100644 --- a/packages/webtau/src/adapters/electrobun.ts +++ b/packages/webtau/src/adapters/electrobun.ts @@ -26,6 +26,7 @@ import type { DialogAdapter, EventAdapter, FsAdapter, + RuntimeCapabilities, WindowAdapter, } from "../provider.js"; import { setWindowAdapter } from "../window.js"; @@ -99,6 +100,23 @@ export function getElectrobunCapabilities(): ElectrobunCapabilities | null { }; } +function getElectrobunRuntimeCapabilities(): RuntimeCapabilities { + const capabilities = getElectrobunCapabilities(); + + return { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + renderMode: capabilities?.renderMode ?? "unknown", + hasGpuWindow: capabilities?.hasGpuWindow ?? false, + hasWgpuView: capabilities?.hasWgpuView ?? false, + hasWebGpu: capabilities?.hasWebGpu ?? false, + }; +} + export function createElectrobunWindowBridgeProvider( bridge: ElectrobunBridge = getElectrobunBridge() as ElectrobunBridge, ): CoreProvider { @@ -116,6 +134,11 @@ export function createElectrobunWindowBridgeProvider( ? bridge.convertFileSrc(filePath, protocol) : `electrobun://asset/${filePath.replace(/^\/+/, "")}` ), + runtimeInfo: { + id: "electrobun", + platform: "desktop", + capabilities: getElectrobunRuntimeCapabilities(), + }, }; } @@ -370,6 +393,11 @@ export function createElectrobunCoreProvider(): CoreProvider { convertFileSrc: (filePath: string): string => { return `electrobun://asset/${filePath.replace(/^\/+/, "")}`; }, + runtimeInfo: { + id: "electrobun", + platform: "desktop", + capabilities: getElectrobunRuntimeCapabilities(), + }, }; } diff --git a/packages/webtau/src/adapters/tauri.ts b/packages/webtau/src/adapters/tauri.ts index 691d63b..e3670ae 100644 --- a/packages/webtau/src/adapters/tauri.ts +++ b/packages/webtau/src/adapters/tauri.ts @@ -11,10 +11,10 @@ * ``` */ -import { registerProvider } from "../core.js"; -import type { EventCallback, UnlistenFn } from "../event.js"; -import { setEventAdapter } from "../event.js"; -import type { CoreProvider, EventAdapter } from "../provider.js"; +import { registerProvider } from "../core.js"; +import type { EventCallback, UnlistenFn } from "../event.js"; +import { setEventAdapter } from "../event.js"; +import type { CoreProvider, EventAdapter } from "../provider.js"; // ── Typed shapes for dynamically imported Tauri modules ───────────────────── // @tauri-apps/api is an optional peer dependency. These interfaces describe @@ -64,19 +64,31 @@ export function createTauriEventAdapter(): EventAdapter { // Prefer auto-detection (isTauri() path in invoke()) over explicit registration // unless you need to force Tauri mode or test with a mock provider. -export function createTauriCoreProvider(): CoreProvider { - return { - id: "tauri", - invoke: async (command: string, args?: Record): Promise => { - const mod = await import("@tauri-apps/api/core" as string) as TauriCoreModule; - return mod.invoke(command, args); - }, - convertFileSrc: (filePath: string, protocol?: string): string => { - const proto = protocol ?? "asset"; - return `${proto}://localhost${filePath.startsWith("/") ? filePath : `/${filePath}`}`; - }, - }; -} +export function createTauriCoreProvider(): CoreProvider { + return { + id: "tauri", + invoke: async (command: string, args?: Record): Promise => { + const mod = await import("@tauri-apps/api/core" as string) as TauriCoreModule; + return mod.invoke(command, args); + }, + convertFileSrc: (filePath: string, protocol?: string): string => { + const proto = protocol ?? "asset"; + return `${proto}://localhost${filePath.startsWith("/") ? filePath : `/${filePath}`}`; + }, + runtimeInfo: { + id: "tauri", + platform: "desktop", + capabilities: { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + }, + }, + }; +} // ── Bootstrap ───────────────────────────────────────────────────────────────── diff --git a/packages/webtau/src/core.test.ts b/packages/webtau/src/core.test.ts index ebaed13..44cc1c4 100644 --- a/packages/webtau/src/core.test.ts +++ b/packages/webtau/src/core.test.ts @@ -3,6 +3,7 @@ import { configure, convertFileSrc, getProvider, + getRuntimeInfo, invoke, isTauri, registerProvider, @@ -21,6 +22,162 @@ describe("isTauri", () => { }); }); +describe("getRuntimeInfo", () => { + const globalObj = globalThis as Record; + let hadWindow = false; + let previousWindow: unknown; + + beforeEach(() => { + hadWindow = Object.hasOwn(globalObj, "window"); + previousWindow = globalObj.window; + resetProvider(); + }); + + afterEach(() => { + resetProvider(); + if (hadWindow) { + globalObj.window = previousWindow; + } else { + delete globalObj.window; + } + }); + + test("returns wasm runtime info by default", () => { + expect(getRuntimeInfo()).toEqual({ + id: "wasm", + platform: "web", + capabilities: { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + }, + }); + }); + + test("returns tauri runtime info from environment auto-detection", () => { + globalObj.window = { __TAURI_INTERNALS__: {} }; + + expect(getRuntimeInfo()).toEqual({ + id: "tauri", + platform: "desktop", + capabilities: { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + }, + }); + }); + + test("returns explicit provider runtime info when supplied", () => { + registerProvider({ + id: "custom", + invoke: async () => null, + convertFileSrc: (path) => path, + runtimeInfo: { + id: "custom", + platform: "desktop", + capabilities: { + events: false, + fs: false, + dialog: false, + window: false, + task: true, + convertFileSrc: true, + }, + }, + }); + + expect(getRuntimeInfo()).toEqual({ + id: "custom", + platform: "desktop", + capabilities: { + events: false, + fs: false, + dialog: false, + window: false, + task: true, + convertFileSrc: true, + }, + }); + }); + + test("resolves function-form runtimeInfo lazily", () => { + registerProvider({ + id: "custom-lazy", + invoke: async () => null, + convertFileSrc: (path) => path, + runtimeInfo: () => ({ + id: "custom-lazy", + platform: "desktop", + capabilities: { + events: true, + fs: false, + dialog: false, + window: false, + task: true, + convertFileSrc: true, + }, + }), + }); + + expect(getRuntimeInfo()).toEqual({ + id: "custom-lazy", + platform: "desktop", + capabilities: { + events: true, + fs: false, + dialog: false, + window: false, + task: true, + convertFileSrc: true, + }, + }); + }); + + test("derives Electrobun render-mode info from the exposed bridge", () => { + globalObj.window = { + __ELECTROBUN__: { + invoke: async () => null, + renderMode: "hybrid", + capabilities: { + hasGpuWindow: false, + hasWgpuView: true, + hasWebGpu: true, + }, + }, + }; + + registerProvider({ + id: "electrobun", + invoke: async () => null, + convertFileSrc: (path) => path, + }); + + expect(getRuntimeInfo()).toEqual({ + id: "electrobun", + platform: "desktop", + capabilities: { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + renderMode: "hybrid", + hasGpuWindow: false, + hasWgpuView: true, + hasWebGpu: true, + }, + }); + }); +}); + // --------------------------------------------------------------------------- // invoke — web/WASM mode // --------------------------------------------------------------------------- diff --git a/packages/webtau/src/core.ts b/packages/webtau/src/core.ts index f85abac..0dc9516 100644 --- a/packages/webtau/src/core.ts +++ b/packages/webtau/src/core.ts @@ -1,272 +1,320 @@ -/** - * webtau — Tauri-to-web invoke router. - * - * Detects whether the app is running inside Tauri (desktop) or as a - * plain web app. In Tauri mode, delegates to `@tauri-apps/api/core`. - * In web mode, calls the corresponding function from a WASM module - * that was registered via `configure()`. - * - * A runtime provider can be registered via `registerProvider()` to - * route invoke() and convertFileSrc() through an arbitrary backend - * (e.g. Electrobun). When no provider is registered and `isTauri()` - * is true, Tauri auto-registers itself on first invoke(). - */ - -import { WebtauError } from "./diagnostics.js"; -import type { CoreProvider } from "./provider.js"; - -export type { CoreProvider }; -export type { DiagnosticCode, DiagnosticEnvelope } from "./diagnostics.js"; -export { WebtauError } from "./diagnostics.js"; - -// biome-ignore lint/suspicious/noExplicitAny: WASM modules have dynamic signatures that cannot be statically typed -type WasmModule = Record any>; - -let registeredProvider: CoreProvider | null = null; -let wasmModule: WasmModule | null = null; -let wasmLoader: (() => Promise) | null = null; -let wasmLoadPromise: Promise | null = null; - -export interface WebtauConfig { - /** - * A function that returns the WASM module (or a promise for it). - * This is typically `() => import("./wasm")` pointing at the - * wasm-pack output. - */ - loadWasm: () => Promise; - - /** - * Called if the WASM module fails to load. - * Defaults to `console.error`. - */ - onLoadError?: (error: unknown) => void; -} - -let onLoadError: (error: unknown) => void = (err) => { - console.error("[webtau] Failed to load WASM module:", err); -}; - -/** - * Configure webtau for web mode. Must be called before the first - * `invoke()` in a web build. In Tauri mode this is a no-op. - * - * ```ts - * import { configure } from "webtau"; - * - * configure({ - * loadWasm: () => import("./wasm"), - * }); - * ``` - */ -export function configure(config: WebtauConfig): void { - wasmLoader = config.loadWasm; - wasmModule = null; - wasmLoadPromise = null; - if (config.onLoadError) { - onLoadError = config.onLoadError; - } -} - -/** - * Register a runtime provider that replaces the default Tauri/WASM - * routing in `invoke()` and `convertFileSrc()`. - * - * ```ts - * import { registerProvider } from "webtau"; - * - * registerProvider({ - * id: "electrobun", - * invoke: (cmd, args) => electrobun.ipc.invoke(cmd, args), - * convertFileSrc: (path) => `electrobun://asset/${path}`, - * }); - * ``` - */ -export function registerProvider(provider: CoreProvider): void { - registeredProvider = provider; -} - -/** Returns the currently registered provider, or `null`. */ -export function getProvider(): CoreProvider | null { - return registeredProvider; -} - -/** Clears the registered provider (useful for testing). */ -export function resetProvider(): void { - registeredProvider = null; -} - -/** - * Returns `true` when running inside Tauri. - * Checks for `window.__TAURI_INTERNALS__` which Tauri injects. - */ -export function isTauri(): boolean { - return ( - typeof window !== "undefined" && - "__TAURI_INTERNALS__" in window - ); -} - -/** - * Lazily loads and caches the WASM module. - * - * Loading is deduplicated: concurrent `invoke()` calls while the module is - * still loading will share the same promise instead of triggering multiple - * loads. On failure the promise is cleared so the next `invoke()` retries - * the load (the user may have called `configure()` with a fixed loader). - */ -async function getWasmModule(): Promise { - // Fast path: module already loaded and cached. - if (wasmModule) return wasmModule; - - // Deduplicate: if a load is already in flight, piggyback on it. - if (wasmLoadPromise) return wasmLoadPromise; - - if (!wasmLoader) { - throw new WebtauError({ - code: "NO_WASM_CONFIGURED", - runtime: "wasm", - command: "", - message: "[webtau] No WASM module configured.", - hint: 'Call configure({ loadWasm: () => import("./wasm") }) before invoke().', - }); - } - - wasmLoadPromise = wasmLoader().then( - (mod) => { - wasmModule = mod; - return mod; - }, - (err) => { - // Clear promise so subsequent invoke() calls can retry after the - // user fixes the issue (e.g. reconfigures with a valid loader). - wasmLoadPromise = null; - onLoadError(err); - throw new WebtauError({ - code: "LOAD_FAILED", - runtime: "wasm", - command: "", - message: `[webtau] Failed to load WASM module: ${err instanceof Error ? err.message : String(err)}`, - hint: "Check that loadWasm returns a valid WASM module and network is available.", - }); - } - ); - - return wasmLoadPromise; -} - -/** - * Universal invoke — same API as `@tauri-apps/api/core`'s `invoke()`. - * - * In Tauri mode: delegates to Tauri IPC. - * In web mode: calls the matching WASM export, passing the args - * object as a single parameter. - * - * ```ts - * const view = await invoke("get_world_view"); - * const result = await invoke("tick_world"); - * ``` - */ -export async function invoke( - command: string, - args?: Record -): Promise { - // 1. Explicit provider — delegate immediately. - if (registeredProvider) { - try { - return await registeredProvider.invoke(command, args); - } catch (err) { - if (err instanceof WebtauError) throw err; - throw new WebtauError({ - code: "PROVIDER_ERROR", - runtime: registeredProvider.id, - command, - message: err instanceof Error ? err.message : String(err), - hint: `Provider "${registeredProvider.id}" threw while invoking "${command}". Check the provider implementation.`, - }); - } - } - - // 2. Auto-detect Tauri — lazily register a Tauri provider, then delegate. - if (isTauri()) { - // Dynamic import — @tauri-apps/api is an optional peer dependency. - // Only loaded at runtime inside Tauri, never in web builds. - const mod = await import("@tauri-apps/api/core" as string); - - const tauriProvider: CoreProvider = { - id: "tauri", - invoke: (cmd, a) => mod.invoke(cmd, a), - convertFileSrc: (path, protocol) => mod.convertFileSrc(path, protocol), - }; - registeredProvider = tauriProvider; - - return tauriProvider.invoke(command, args); - } - - // 3. WASM path — unchanged. - const wasm = await getWasmModule(); - const fn = wasm[command]; - - if (typeof fn !== "function") { - const available = Object.keys(wasm).filter((k) => typeof wasm[k] === "function").join(", "); - throw new WebtauError({ - code: "UNKNOWN_COMMAND", - runtime: "wasm", - command, - message: `[webtau] WASM module has no export named "${command}". Available: ${available}`, - hint: `Export "${command}" is not defined. Available exports: ${available}`, - }); - } - - try { - const result = args ? fn(args) : fn(); - - // wasm_bindgen can return plain values or promises - if (result instanceof Promise) { - try { - return await result; - } catch (asyncErr) { - if (asyncErr instanceof WebtauError) throw asyncErr; - throw new WebtauError({ - code: "PROVIDER_ERROR", - runtime: "wasm", - command, - message: asyncErr instanceof Error ? asyncErr.message : String(asyncErr), - hint: `WASM command "${command}" rejected. Check the Rust implementation for errors.`, - }); - } - } - - return result as T; - } catch (execErr) { - if (execErr instanceof WebtauError) throw execErr; - throw new WebtauError({ - code: "PROVIDER_ERROR", - runtime: "wasm", - command, - message: execErr instanceof Error ? execErr.message : String(execErr), - hint: `WASM command "${command}" threw an error. Check the Rust implementation.`, - }); - } -} - -/** - * Converts a file path to a URL that can be used to load assets. - * - * In Tauri mode: delegates to `@tauri-apps/api/core`'s `convertFileSrc()`, - * which returns an `asset://` protocol URL. - * In web mode: returns the path as-is — no protocol conversion is needed - * since web apps load assets via standard HTTP URLs. - * - * ```ts - * const url = convertFileSrc("/app/data/sprite.png"); - * // web: "/app/data/sprite.png" - * // Tauri: "asset://localhost/app/data/sprite.png" - * ``` - */ -export function convertFileSrc(filePath: string, protocol?: string): string { - if (registeredProvider) { - return registeredProvider.convertFileSrc(filePath, protocol); - } - // On web, just return the path as-is — no protocol conversion needed - return filePath; -} +/** + * webtau - Tauri-to-web invoke router. + * + * Detects whether the app is running inside Tauri (desktop) or as a plain web + * app. In Tauri mode, delegates to `@tauri-apps/api/core`. In web mode, calls + * the corresponding function from a configured WASM module. A runtime provider + * can be registered to route invoke and asset conversion through an arbitrary + * backend (for example Electrobun). + */ + +import { WebtauError } from "./diagnostics.js"; +import type { + CoreProvider, + RuntimeCapabilities, + RuntimeInfo, + RuntimeInfoResolver, +} from "./provider.js"; + +export type { CoreProvider, RuntimeCapabilities, RuntimeInfo, RuntimeInfoResolver }; +export type { DiagnosticCode, DiagnosticEnvelope } from "./diagnostics.js"; +export { WebtauError } from "./diagnostics.js"; + +// biome-ignore lint/suspicious/noExplicitAny: WASM modules have dynamic signatures that cannot be statically typed +type WasmModule = Record any>; + +type ElectrobunRuntimeBridge = { + capabilities?: Partial>; + renderMode?: string; +}; + +let registeredProvider: CoreProvider | null = null; +let wasmModule: WasmModule | null = null; +let wasmLoader: (() => Promise) | null = null; +let wasmLoadPromise: Promise | null = null; + +export interface WebtauConfig { + loadWasm: () => Promise; + onLoadError?: (error: unknown) => void; +} + +let onLoadError: (error: unknown) => void = (err) => { + console.error("[webtau] Failed to load WASM module:", err); +}; + +const wasmCapabilities: RuntimeCapabilities = { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, +}; + +const tauriCapabilities: RuntimeCapabilities = { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, +}; + +function cloneRuntimeInfo(info: RuntimeInfo): RuntimeInfo { + return { + ...info, + capabilities: { ...info.capabilities }, + }; +} + +function normalizeElectrobunRenderMode(mode: string | undefined): string { + switch (mode) { + case "browser": + case "hybrid": + case "gpu": + return mode; + default: + return "unknown"; + } +} + +function getElectrobunBridgeCapabilities(): RuntimeCapabilities { + // Fallback capability derivation for bare provider registrations. The + // Electrobun adapter remains the authoritative path when runtimeInfo is + // supplied explicitly by the provider itself. + const bridge = typeof window !== "undefined" + ? (window as typeof window & { __ELECTROBUN__?: ElectrobunRuntimeBridge }).__ELECTROBUN__ + : undefined; + const renderMode = normalizeElectrobunRenderMode( + bridge?.renderMode ?? bridge?.capabilities?.renderMode, + ); + const hasGpuWindow = bridge?.capabilities?.hasGpuWindow ?? renderMode === "gpu"; + const hasWgpuView = bridge?.capabilities?.hasWgpuView + ?? (renderMode === "hybrid" || renderMode === "gpu"); + const hasWebGpu = bridge?.capabilities?.hasWebGpu + ?? (hasWgpuView || renderMode === "gpu"); + + return { + events: true, + fs: true, + dialog: true, + window: true, + task: true, + convertFileSrc: true, + renderMode, + hasGpuWindow, + hasWgpuView, + hasWebGpu, + }; +} + +function deriveRuntimeInfo(provider: CoreProvider): RuntimeInfo { + const resolved = typeof provider.runtimeInfo === "function" + ? provider.runtimeInfo() + : provider.runtimeInfo; + if (resolved) { + return cloneRuntimeInfo(resolved); + } + + if (provider.id === "tauri") { + return { + id: "tauri", + platform: "desktop", + capabilities: { ...tauriCapabilities }, + }; + } + + if (provider.id === "electrobun") { + return { + id: "electrobun", + platform: "desktop", + capabilities: getElectrobunBridgeCapabilities(), + }; + } + + return { + id: provider.id, + platform: "desktop", + capabilities: { + events: false, + fs: false, + dialog: false, + window: false, + task: true, + convertFileSrc: true, + }, + }; +} + +export function configure(config: WebtauConfig): void { + wasmLoader = config.loadWasm; + wasmModule = null; + wasmLoadPromise = null; + if (config.onLoadError) { + onLoadError = config.onLoadError; + } +} + +export function registerProvider(provider: CoreProvider): void { + registeredProvider = provider; +} + +export function getProvider(): CoreProvider | null { + return registeredProvider; +} + +export function resetProvider(): void { + registeredProvider = null; +} + +export function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export function getRuntimeInfo(): RuntimeInfo { + if (registeredProvider) { + return deriveRuntimeInfo(registeredProvider); + } + + if (isTauri()) { + return { + id: "tauri", + platform: "desktop", + capabilities: { ...tauriCapabilities }, + }; + } + + return { + id: "wasm", + platform: "web", + capabilities: { ...wasmCapabilities }, + }; +} + +async function getWasmModule(): Promise { + if (wasmModule) return wasmModule; + if (wasmLoadPromise) return wasmLoadPromise; + + if (!wasmLoader) { + throw new WebtauError({ + code: "NO_WASM_CONFIGURED", + runtime: "wasm", + command: "", + message: "[webtau] No WASM module configured.", + hint: 'Call configure({ loadWasm: () => import("./wasm") }) before invoke().', + }); + } + + wasmLoadPromise = wasmLoader().then( + (mod) => { + wasmModule = mod; + return mod; + }, + (err) => { + wasmLoadPromise = null; + onLoadError(err); + throw new WebtauError({ + code: "LOAD_FAILED", + runtime: "wasm", + command: "", + message: `[webtau] Failed to load WASM module: ${err instanceof Error ? err.message : String(err)}`, + hint: "Check that loadWasm returns a valid WASM module and network is available.", + }); + }, + ); + + return wasmLoadPromise; +} + +export async function invoke( + command: string, + args?: Record, +): Promise { + if (registeredProvider) { + try { + return await registeredProvider.invoke(command, args); + } catch (err) { + if (err instanceof WebtauError) throw err; + throw new WebtauError({ + code: "PROVIDER_ERROR", + runtime: registeredProvider.id, + command, + message: err instanceof Error ? err.message : String(err), + hint: `Provider "${registeredProvider.id}" threw while invoking "${command}". Check the provider implementation.`, + }); + } + } + + if (isTauri()) { + const mod = await import("@tauri-apps/api/core" as string); + + const tauriProvider: CoreProvider = { + id: "tauri", + invoke: (cmd, a) => mod.invoke(cmd, a), + convertFileSrc: (path, protocol) => mod.convertFileSrc(path, protocol), + runtimeInfo: { + id: "tauri", + platform: "desktop", + capabilities: { ...tauriCapabilities }, + }, + }; + registeredProvider = tauriProvider; + + return tauriProvider.invoke(command, args); + } + + const wasm = await getWasmModule(); + const fn = wasm[command]; + + if (typeof fn !== "function") { + const available = Object.keys(wasm).filter((k) => typeof wasm[k] === "function").join(", "); + throw new WebtauError({ + code: "UNKNOWN_COMMAND", + runtime: "wasm", + command, + message: `[webtau] WASM module has no export named "${command}". Available: ${available}`, + hint: `Export "${command}" is not defined. Available exports: ${available}`, + }); + } + + try { + const result = args ? fn(args) : fn(); + + if (result instanceof Promise) { + try { + return await result; + } catch (asyncErr) { + if (asyncErr instanceof WebtauError) throw asyncErr; + throw new WebtauError({ + code: "PROVIDER_ERROR", + runtime: "wasm", + command, + message: asyncErr instanceof Error ? asyncErr.message : String(asyncErr), + hint: `WASM command "${command}" rejected. Check the Rust implementation for errors.`, + }); + } + } + + return result as T; + } catch (execErr) { + if (execErr instanceof WebtauError) throw execErr; + throw new WebtauError({ + code: "PROVIDER_ERROR", + runtime: "wasm", + command, + message: execErr instanceof Error ? execErr.message : String(execErr), + hint: `WASM command "${command}" threw an error. Check the Rust implementation.`, + }); + } +} + +export function convertFileSrc(filePath: string, protocol?: string): string { + if (registeredProvider) { + return registeredProvider.convertFileSrc(filePath, protocol); + } + + return filePath; +} diff --git a/packages/webtau/src/provider.ts b/packages/webtau/src/provider.ts index 598f91a..c56766f 100644 --- a/packages/webtau/src/provider.ts +++ b/packages/webtau/src/provider.ts @@ -1,92 +1,106 @@ -/** - * webtau/provider — Adapter interfaces for runtime providers. - * - * Types only — no runtime logic. Each interface defines the contract - * that a runtime provider (Tauri, Electrobun, etc.) must implement - * for the corresponding webtau module. - */ - -import type { - MessageDialogOptions, - OpenDialogOptions, - SaveDialogOptions, -} from "./dialog.js"; -import type { EventCallback, UnlistenFn } from "./event.js"; -import type { - CreateDirOptions, - FsEntry, - ReadDirOptions, - RemoveOptions, -} from "./fs.js"; - -// ── Core ── - -export interface CoreProvider { - /** Unique identifier for this runtime (e.g. "tauri", "electrobun"). */ - id: string; - - /** Invoke a command on the runtime backend. */ - invoke(command: string, args?: Record): Promise; - - /** Convert a file path to a URL suitable for loading assets. */ - convertFileSrc(filePath: string, protocol?: string): string; -} - -// ── Window ── - -export interface WindowAdapter { - isFullscreen(): Promise; - setFullscreen(fullscreen: boolean): Promise; - innerSize(): Promise<{ width: number; height: number; type: string }>; - outerSize(): Promise<{ width: number; height: number; type: string }>; - setSize(size: { width: number; height: number; type: string }): Promise; - maximize(): Promise; - isMaximized(): Promise; - title(): Promise; - setTitle(title: string): Promise; - close(): Promise; - minimize(): Promise; - unminimize(): Promise; - show(): Promise; - hide(): Promise; - setDecorations(decorations: boolean): Promise; - center(): Promise; - currentMonitor(): Promise<{ - name: string | null; - size: { width: number; height: number }; - position: { x: number; y: number }; - scaleFactor: number; - } | null>; - scaleFactor(): Promise; -} - -// ── Event ── - -export interface EventAdapter { - listen(event: string, handler: EventCallback): Promise; - emit(event: string, payload?: T): Promise; -} - -// ── Filesystem ── - -export interface FsAdapter { - writeTextFile(path: string, contents: string): Promise; - readTextFile(path: string): Promise; - writeFile(path: string, contents: Uint8Array | ArrayBuffer | number[] | string): Promise; - readFile(path: string): Promise; - exists(path: string): Promise; - mkdir(path: string, options?: CreateDirOptions): Promise; - readDir(path: string, options?: ReadDirOptions): Promise; - remove(path: string, options?: RemoveOptions): Promise; - copyFile(fromPath: string, toPath: string): Promise; - rename(oldPath: string, newPath: string): Promise; -} - -// ── Dialog ── - -export interface DialogAdapter { - message(text: string, options?: MessageDialogOptions): Promise; - ask(text: string, options?: MessageDialogOptions): Promise; - open(options?: OpenDialogOptions): Promise; - save(options?: SaveDialogOptions): Promise; -} +/** + * webtau/provider - Adapter interfaces for runtime providers. + * + * Types only - no runtime logic. Each interface defines the contract that a + * runtime provider (Tauri, Electrobun, etc.) must implement for the + * corresponding webtau module. + */ + +import type { + MessageDialogOptions, + OpenDialogOptions, + SaveDialogOptions, +} from "./dialog.js"; +import type { EventCallback, UnlistenFn } from "./event.js"; +import type { + CreateDirOptions, + FsEntry, + ReadDirOptions, + RemoveOptions, +} from "./fs.js"; + +export interface RuntimeCapabilities { + events: boolean; + fs: boolean; + dialog: boolean; + window: boolean; + task: boolean; + convertFileSrc: boolean; + renderMode?: string; + hasGpuWindow?: boolean; + hasWgpuView?: boolean; + hasWebGpu?: boolean; +} + +export interface RuntimeInfo { + id: string; + platform: "web" | "desktop"; + capabilities: RuntimeCapabilities; +} + +export type RuntimeInfoResolver = RuntimeInfo | (() => RuntimeInfo); + +export interface CoreProvider { + /** Unique identifier for this runtime (for example "tauri" or "electrobun"). */ + id: string; + + /** Invoke a command on the runtime backend. */ + invoke(command: string, args?: Record): Promise; + + /** Convert a file path to a URL suitable for loading assets. */ + convertFileSrc(filePath: string, protocol?: string): string; + + /** Optional runtime/capability metadata for capability-based branching. */ + runtimeInfo?: RuntimeInfoResolver; +} + +export interface WindowAdapter { + isFullscreen(): Promise; + setFullscreen(fullscreen: boolean): Promise; + innerSize(): Promise<{ width: number; height: number; type: string }>; + outerSize(): Promise<{ width: number; height: number; type: string }>; + setSize(size: { width: number; height: number; type: string }): Promise; + maximize(): Promise; + isMaximized(): Promise; + title(): Promise; + setTitle(title: string): Promise; + close(): Promise; + minimize(): Promise; + unminimize(): Promise; + show(): Promise; + hide(): Promise; + setDecorations(decorations: boolean): Promise; + center(): Promise; + currentMonitor(): Promise<{ + name: string | null; + size: { width: number; height: number }; + position: { x: number; y: number }; + scaleFactor: number; + } | null>; + scaleFactor(): Promise; +} + +export interface EventAdapter { + listen(event: string, handler: EventCallback): Promise; + emit(event: string, payload?: T): Promise; +} + +export interface FsAdapter { + writeTextFile(path: string, contents: string): Promise; + readTextFile(path: string): Promise; + writeFile(path: string, contents: Uint8Array | ArrayBuffer | number[] | string): Promise; + readFile(path: string): Promise; + exists(path: string): Promise; + mkdir(path: string, options?: CreateDirOptions): Promise; + readDir(path: string, options?: ReadDirOptions): Promise; + remove(path: string, options?: RemoveOptions): Promise; + copyFile(fromPath: string, toPath: string): Promise; + rename(oldPath: string, newPath: string): Promise; +} + +export interface DialogAdapter { + message(text: string, options?: MessageDialogOptions): Promise; + ask(text: string, options?: MessageDialogOptions): Promise; + open(options?: OpenDialogOptions): Promise; + save(options?: SaveDialogOptions): Promise; +}