From fc8442bec487a9974b24f87b61e274b3682dccc4 Mon Sep 17 00:00:00 2001 From: Rohan Pandula Date: Sun, 9 Aug 2026 16:57:13 -0700 Subject: [PATCH 01/26] feat: add simulator-only web runtime --- .github/workflows/ci.yml | 33 + README.md | 15 + app/ScanStudio/README.md | 22 + .../Sources/ScanStudio/ScanStudioApp.swift | 36 +- .../ScanStudio/UpdateSettingsView.swift | 105 ++- .../ScanStudioKit/WebServerModel.swift | 593 +++++++++++++++++ .../WebServerModelTests.swift | 334 ++++++++++ docs/WEB-HEADLESS.md | 84 +++ docs/adr/0001-web-headless-runtime.md | 132 ++++ ports/tauri/app/index.html | 7 +- ports/tauri/app/package-lock.json | 6 +- ports/tauri/app/package.json | 2 + ports/tauri/app/src/App.module.css | 14 +- ports/tauri/app/src/App.tsx | 24 +- ports/tauri/app/src/WebRuntimeGate.module.css | 205 ++++++ ports/tauri/app/src/WebRuntimeGate.tsx | 405 ++++++++++++ ports/tauri/app/src/__tests__/App.test.tsx | 4 + .../tauri/app/src/__tests__/App.web.test.tsx | 146 +++++ .../app/src/__tests__/WebRuntimeGate.test.tsx | 341 ++++++++++ ports/tauri/app/src/controlLease.ts | 97 +++ .../src/engine/__tests__/client.web.test.ts | 470 ++++++++++++++ ports/tauri/app/src/engine/client.ts | 270 +++++++- ports/tauri/app/src/global.css | 81 +++ ports/tauri/app/src/main.tsx | 6 +- ports/tauri/app/src/runtime.ts | 11 + ports/tauri/app/src/scannerControl.tsx | 22 + .../session/store/__tests__/selection.test.ts | 120 +++- ports/tauri/app/src/session/store/session.ts | 94 ++- ports/tauri/app/src/shell/AppShell.module.css | 75 ++- .../views/Capture/CaptureWorkflow.module.css | 13 +- .../app/src/views/ContactSheet.module.css | 59 +- ports/tauri/app/src/views/ContactSheet.tsx | 22 +- .../app/src/views/DefectOverlay.module.css | 32 +- .../tauri/app/src/views/DeviceBar.module.css | 24 +- ports/tauri/app/src/views/DeviceBar.tsx | 32 +- .../views/FrameDetail/FrameDetail.module.css | 71 +- .../app/src/views/HardwareStatus.module.css | 40 +- ports/tauri/app/src/views/Metadata.module.css | 61 +- .../app/src/views/ProjectPanel.module.css | 33 +- .../app/src/views/ScanRun/ScanRun.module.css | 71 +- .../src/views/ScanSetup/ScanSetup.module.css | 78 +-- .../app/src/views/SetupChecker.module.css | 49 +- ports/tauri/app/vite.config.ts | 11 +- ports/web/.dockerignore | 10 + ports/web/.gitignore | 4 + ports/web/Dockerfile | 50 ++ ports/web/Dockerfile.dockerignore | 10 + ports/web/README.md | 123 ++++ ports/web/compose.yaml | 33 + ports/web/pyproject.toml | 34 + ports/web/src/scanstudio_web/__init__.py | 6 + ports/web/src/scanstudio_web/app.py | 464 +++++++++++++ ports/web/src/scanstudio_web/cli.py | 64 ++ .../src/scanstudio_web/controller_lease.py | 138 ++++ .../web/src/scanstudio_web/engine_process.py | 509 +++++++++++++++ ports/web/src/scanstudio_web/relay.py | 85 +++ ports/web/src/scanstudio_web/security.py | 95 +++ ports/web/src/scanstudio_web/settings.py | 226 +++++++ ports/web/tests/conftest.py | 78 +++ ports/web/tests/fake_engine.py | 277 ++++++++ ports/web/tests/test_app.py | 326 ++++++++++ ports/web/tests/test_cli.py | 92 +++ ports/web/tests/test_controller_lease.py | 42 ++ ports/web/tests/test_engine_process.py | 132 ++++ ports/web/tests/test_settings.py | 24 + ports/web/uv.lock | 610 ++++++++++++++++++ 66 files changed, 7462 insertions(+), 320 deletions(-) create mode 100644 app/ScanStudio/Sources/ScanStudioKit/WebServerModel.swift create mode 100644 app/ScanStudio/Tests/ScanStudioKitTests/WebServerModelTests.swift create mode 100644 docs/WEB-HEADLESS.md create mode 100644 docs/adr/0001-web-headless-runtime.md create mode 100644 ports/tauri/app/src/WebRuntimeGate.module.css create mode 100644 ports/tauri/app/src/WebRuntimeGate.tsx create mode 100644 ports/tauri/app/src/__tests__/App.web.test.tsx create mode 100644 ports/tauri/app/src/__tests__/WebRuntimeGate.test.tsx create mode 100644 ports/tauri/app/src/controlLease.ts create mode 100644 ports/tauri/app/src/engine/__tests__/client.web.test.ts create mode 100644 ports/tauri/app/src/global.css create mode 100644 ports/tauri/app/src/runtime.ts create mode 100644 ports/tauri/app/src/scannerControl.tsx create mode 100644 ports/web/.dockerignore create mode 100644 ports/web/.gitignore create mode 100644 ports/web/Dockerfile create mode 100644 ports/web/Dockerfile.dockerignore create mode 100644 ports/web/README.md create mode 100644 ports/web/compose.yaml create mode 100644 ports/web/pyproject.toml create mode 100644 ports/web/src/scanstudio_web/__init__.py create mode 100644 ports/web/src/scanstudio_web/app.py create mode 100644 ports/web/src/scanstudio_web/cli.py create mode 100644 ports/web/src/scanstudio_web/controller_lease.py create mode 100644 ports/web/src/scanstudio_web/engine_process.py create mode 100644 ports/web/src/scanstudio_web/relay.py create mode 100644 ports/web/src/scanstudio_web/security.py create mode 100644 ports/web/src/scanstudio_web/settings.py create mode 100644 ports/web/tests/conftest.py create mode 100644 ports/web/tests/fake_engine.py create mode 100644 ports/web/tests/test_app.py create mode 100644 ports/web/tests/test_cli.py create mode 100644 ports/web/tests/test_controller_lease.py create mode 100644 ports/web/tests/test_engine_process.py create mode 100644 ports/web/tests/test_settings.py create mode 100644 ports/web/uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1458784..7fa9650 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,39 @@ jobs: - name: Check ports/tauri/vendor mirrors for drift beyond the known baseline run: scripts/check_ports_vendor_sync.sh + web-preview: + name: Browser gateway and container + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: ports/tauri/app/package-lock.json + - uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Test and build the shared browser client + working-directory: ports/tauri/app + run: | + npm ci + npm test + npm run build:web + - name: Test the locked simulator-only gateway + working-directory: ports/web + run: | + uv sync --locked --extra test + uv run ruff check . + uv run pytest + - name: Validate the hardened Compose contract + env: + SCANSTUDIO_WEB_TOKEN: ci-only-token + SCANSTUDIO_WEB_ALLOWED_ORIGINS: http://127.0.0.1:8787 + run: docker compose -f ports/web/compose.yaml config --quiet + - name: Build the simulator appliance image + run: docker build -f ports/web/Dockerfile -t scanstudio-web:ci . + package: name: Self-contained package build (${{ matrix.arch }}) runs-on: ${{ matrix.runs-on }} diff --git a/README.md b/README.md index 96bc3b7..d4d6223 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,21 @@ Gatekeeper globally. There is no support or release-schedule promise. The cross-platform source, setup instructions, and live-validation runbooks are in [`ports/tauri`](ports/tauri). +## Browser and headless preview + +The first browser/headless slice reuses the React client and the existing Rust +engine behind a small authenticated Python gateway. It runs locally or in a +hardened Docker container, supports one controller plus read-only observers, +and adapts down to a phone-sized browser. The macOS Settings pane includes an +off-by-default switch for this local browser preview. + +This milestone is intentionally **simulator-only**. It launches a separate +simulator engine, does not share the native app's scanner session, strips the +bridge and motion environment, and exposes no project, capture, USB, or output +paths. See the [gateway guide](ports/web/README.md) and +[hardware-capable roadmap](docs/WEB-HEADLESS.md) for the Docker/Unraid boundary +and the gates required before real scanning is enabled. + ## Download All prerelease packages are published together on the diff --git a/app/ScanStudio/README.md b/app/ScanStudio/README.md index 33630db..9526715 100644 --- a/app/ScanStudio/README.md +++ b/app/ScanStudio/README.md @@ -100,6 +100,28 @@ SCANSTUDIO_ENGINE_PATH="$(pwd)/engine/target/release/scanstudio-engine" swift ru Set `SCANSTUDIO_TIMESCALE` (default `1.0`) to multiply simulated delays. For example, `SCANSTUDIO_TIMESCALE=0.05 make run` provides a fast walkthrough. +### Browser preview toggle (development) + +Scan Studio's Settings window can start a loopback-only, simulator-only +browser preview for the current app session. Prepare the existing web runtime +and frontend first: + +```sh +cd ../../ports/web && uv sync --locked --extra test +cd ../tauri/app && npm ci && npm run build:web +``` + +When the engine comes from this checkout, the app discovers +`ports/web/.venv/bin/scanstudio-web` and `ports/tauri/app/dist`. A custom +development layout can set the exact paths with +`SCANSTUDIO_WEB_COMMAND_PATH` and `SCANSTUDIO_WEB_STATIC_DIR`. + +The release packaging scripts do not bundle these Python/frontend artifacts +yet. The app-side packaged-resource contract reserves +`Contents/Resources/WebRuntime/bin/scanstudio-web` and +`Contents/Resources/WebFrontend` for that later step; until then a packaged +build reports the missing runtime honestly and leaves the toggle off. + ## Real hardware: LS-5000 through the CoolscanPy bridge The real-device backend speaks the NDJSON contract in diff --git a/app/ScanStudio/Sources/ScanStudio/ScanStudioApp.swift b/app/ScanStudio/Sources/ScanStudio/ScanStudioApp.swift index 3e8da44..e48ae04 100644 --- a/app/ScanStudio/Sources/ScanStudio/ScanStudioApp.swift +++ b/app/ScanStudio/Sources/ScanStudio/ScanStudioApp.swift @@ -50,7 +50,10 @@ struct ScanStudioApp: App { } Settings { - UpdateSettingsView(model: appDelegate.updateFlowModel) + UpdateSettingsView( + model: appDelegate.updateFlowModel, + webServerModel: appDelegate.webServerModel + ) } } } @@ -167,12 +170,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Shared in-app update flow (01-05): one instance per app run, handed to /// the Settings scene and the launch + 24 h background check. let updateFlowModel: UpdateFlowModel + /// Optional, session-only browser preview. It always starts off and owns a + /// separate simulator engine; it never shares the native scanner session. + let webServerModel: WebServerModel /// Cancellable handle for the rolling 24 h background check task. private var backgroundUpdateTask: Task? override init() { + var browserEngineURL: URL? do { let engineURL = try EngineLocator.locate() + browserEngineURL = engineURL let client = try EngineClient(engineURL: engineURL) let diagnosticsDirectory = FileManager.default .homeDirectoryForCurrentUser @@ -187,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } updateFlowModel = Self.makeUpdateFlowModel() + webServerModel = WebServerModel(engineURL: browserEngineURL) super.init() // AUT-05-GUARD: mirror the real job-active signal into the update flow @@ -221,13 +230,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { backgroundUpdateTask?.cancel() - guard case .ready(let client, _) = launchState else { return } + let client: EngineClient? + if case .ready(let readyClient, _) = launchState { + client = readyClient + } else { + client = nil + } let finished = DispatchSemaphore(value: 0) + let webServerModel = webServerModel Task.detached { - await client.terminate() + // AppKit is synchronously waiting on the main thread here, so use + // the model's nonisolated process hook. The bounded process + // controller escalates after its graceful-shutdown window, which + // prevents a gateway from outliving Scan Studio. + await withTaskGroup(of: Void.self) { group in + group.addTask { + await webServerModel.stopProcessForApplicationTermination() + } + if let client { + group.addTask { + await client.terminate() + } + } + } finished.signal() } - _ = finished.wait(timeout: .now() + 2) + _ = finished.wait(timeout: .now() + 6) } private static func describe(_ error: Error) -> String { diff --git a/app/ScanStudio/Sources/ScanStudio/UpdateSettingsView.swift b/app/ScanStudio/Sources/ScanStudio/UpdateSettingsView.swift index 7b6bae3..3207391 100644 --- a/app/ScanStudio/Sources/ScanStudio/UpdateSettingsView.swift +++ b/app/ScanStudio/Sources/ScanStudio/UpdateSettingsView.swift @@ -1,8 +1,6 @@ -// Settings scene for the in-app update flow (01-05). Thin SwiftUI: renders -// `UpdateFlowModel` state and forwards button taps to its async actions. All -// policy lives in the model (install gated on `jobActive`, up-to-date vs error -// are distinct states, no auto-relaunch). The scene lives in the executable -// target so network/app concerns stay out of the ScanStudioKit library. +// Settings scene for the in-app update flow (01-05) and the optional local +// browser preview. Thin SwiftUI renders the two host-owned models and forwards +// actions; update policy and web-process lifecycle stay out of this view. import AppKit import ScanStudioKit @@ -10,6 +8,8 @@ import SwiftUI struct UpdateSettingsView: View { @Bindable var model: UpdateFlowModel + @Bindable var webServerModel: WebServerModel + @State private var tokenWasCopied = false var body: some View { Form { @@ -17,6 +17,64 @@ struct UpdateSettingsView: View { header } + Section("Browser preview") { + Toggle( + "Run browser preview (simulator only)", + isOn: Binding( + get: { webServerModel.isEnabled }, + set: { enabled in + Task { await webServerModel.setEnabled(enabled) } + } + ) + ) + .disabled(webServerModel.state == .stopping) + + Text("Starts a local browser UI with its own simulator-only engine. It does not share or control the scanner connected to the native app.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + browserStatus + + VStack(alignment: .leading, spacing: 6) { + Text("Local address") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Text(webServerModel.browserURL.absoluteString) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 8) + Button("Open in Browser") { + NSWorkspace.shared.open(webServerModel.browserURL) + } + .disabled(webServerModel.state != .running) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Access token") + .font(.caption) + .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(webServerModel.accessToken) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("Browser preview access token") + Spacer(minLength: 8) + Button(tokenWasCopied ? "Copied" : "Copy Token") { + copyAccessToken() + } + } + } + + Text("Enter the token in the browser. Only browsers on this Mac can connect, and a new token is created each time Scan Studio launches.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Section("Release channel") { Picker("Channel", selection: $model.channel) { Text("Prerelease").tag(UpdateChannel.alpha) @@ -67,7 +125,7 @@ struct UpdateSettingsView: View { } } .formStyle(.grouped) - .frame(width: 440) + .frame(width: 560) } private var header: some View { @@ -97,6 +155,41 @@ struct UpdateSettingsView: View { return "Development build" } + @ViewBuilder + private var browserStatus: some View { + switch webServerModel.state { + case .off: + Label("Off", systemImage: "stop.circle") + .foregroundStyle(.secondary) + case .starting: + ProgressView("Starting browser preview…") + case .running: + Label("Running locally — simulator only", systemImage: "checkmark.circle.fill") + .foregroundStyle(Color.scanStudioGreen) + case .stopping: + ProgressView("Stopping browser preview…") + case .failed(let message): + VStack(alignment: .leading, spacing: 4) { + Label("Browser preview unavailable", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(Color.scanStudioRed) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func copyAccessToken() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(webServerModel.accessToken, forType: .string) + tokenWasCopied = true + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + tokenWasCopied = false + } + } + @ViewBuilder private var stateContent: some View { switch model.checkState { diff --git a/app/ScanStudio/Sources/ScanStudioKit/WebServerModel.swift b/app/ScanStudio/Sources/ScanStudioKit/WebServerModel.swift new file mode 100644 index 0000000..2f89188 --- /dev/null +++ b/app/ScanStudio/Sources/ScanStudioKit/WebServerModel.swift @@ -0,0 +1,593 @@ +// Host-owned lifecycle for the optional browser preview. The browser gateway +// is deliberately a second, simulator-only engine session: it never inherits +// the desktop app's hardware bridge or motion authorization. This model owns +// only process/readiness state; the gateway in ports/web remains the protocol +// and security authority. + +import Darwin +import Foundation +import Observation + +public enum WebServerState: Equatable, Sendable { + case off + case starting + case running + case stopping + case failed(String) +} + +public struct WebServerRuntime: Equatable, Sendable { + public let executableURL: URL + public let staticDirectoryURL: URL + public let workingDirectoryURL: URL? + + public init( + executableURL: URL, + staticDirectoryURL: URL, + workingDirectoryURL: URL? = nil + ) { + self.executableURL = executableURL + self.staticDirectoryURL = staticDirectoryURL + self.workingDirectoryURL = workingDirectoryURL + } +} + +public struct WebServerLaunchConfiguration: Equatable, Sendable { + public let identifier: UUID + public let executableURL: URL + public let arguments: [String] + public let environment: [String: String] + public let workingDirectoryURL: URL? + + public init( + identifier: UUID, + executableURL: URL, + arguments: [String] = [], + environment: [String: String], + workingDirectoryURL: URL? = nil + ) { + self.identifier = identifier + self.executableURL = executableURL + self.arguments = arguments + self.environment = environment + self.workingDirectoryURL = workingDirectoryURL + } +} + +public struct WebServerProcessExit: Equatable, Sendable { + public let identifier: UUID + public let status: Int32 + public let reason: Process.TerminationReason + + public init( + identifier: UUID, + status: Int32, + reason: Process.TerminationReason + ) { + self.identifier = identifier + self.status = status + self.reason = reason + } +} + +/// Injectable process seam. The production actor wraps Foundation.Process; +/// tests use an in-memory actor and never spawn Python, Rust, or a scanner. +public protocol WebServerProcessControlling: Sendable { + var terminationEvents: AsyncStream { get } + + func start(configuration: WebServerLaunchConfiguration) async throws + /// A non-nil identifier stops only that run; nil stops whichever process + /// is current. Matching prevents stale startup work from stopping a newer + /// retry after a rapid toggle sequence. + func stop(identifier: UUID?) async +} + +/// Injectable readiness seam so `running` means the gateway and its simulator +/// engine completed startup, not merely that Process.run() returned. +public protocol WebServerReadinessChecking: Sendable { + func waitUntilReady(at startupURL: URL, timeout: Duration) async throws +} + +public enum WebServerRuntimeLocateError: Error, LocalizedError, Equatable { + case missingCommandOverride(String) + case missingStaticDirectoryOverride(String) + case runtimeUnavailable(commandPaths: [String], staticPaths: [String]) + case engineUnavailable + + public var errorDescription: String? { + switch self { + case .missingCommandOverride(let path): + return "SCANSTUDIO_WEB_COMMAND_PATH points to a missing executable: \(path)" + case .missingStaticDirectoryOverride(let path): + return "SCANSTUDIO_WEB_STATIC_DIR points to a missing directory: \(path)" + case .runtimeUnavailable(let commandPaths, let staticPaths): + return "The browser preview runtime is not installed. Looked for the gateway at \(commandPaths.joined(separator: ", ")) and the web app at \(staticPaths.joined(separator: ", ")). For development, set SCANSTUDIO_WEB_COMMAND_PATH and SCANSTUDIO_WEB_STATIC_DIR." + case .engineUnavailable: + return "The browser preview cannot start because the Scan Studio engine is unavailable." + } + } +} + +/// Resolves an eventual packaged runtime first, then the current source-tree +/// development layout. Packaging is intentionally not changed in this slice: +/// a future release may place an executable gateway at +/// `Contents/Resources/WebRuntime/bin/scanstudio-web` and Vite output at +/// `Contents/Resources/WebFrontend` without changing the app-side contract. +public struct WebServerRuntimeLocator: Sendable { + public static let commandOverrideKey = "SCANSTUDIO_WEB_COMMAND_PATH" + public static let staticDirectoryOverrideKey = "SCANSTUDIO_WEB_STATIC_DIR" + + private let environment: [String: String] + private let bundleResourceURL: URL? + private let developmentRepositoryURL: URL? + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleResourceURL: URL? = Bundle.main.resourceURL, + engineURL: URL? + ) { + self.environment = environment + self.bundleResourceURL = bundleResourceURL + self.developmentRepositoryURL = Self.inferRepositoryRoot(from: engineURL) + } + + public func locate() throws -> WebServerRuntime { + try Self.locate( + environment: environment, + bundleResourceURL: bundleResourceURL, + developmentRepositoryURL: developmentRepositoryURL, + fileExists: FileManager.default.fileExists(atPath:), + isDirectory: Self.isDirectory(atPath:) + ) + } + + static func locate( + environment: [String: String], + bundleResourceURL: URL?, + developmentRepositoryURL: URL?, + fileExists: (String) -> Bool, + isDirectory: (String) -> Bool + ) throws -> WebServerRuntime { + let packagedCommand = bundleResourceURL? + .appendingPathComponent("WebRuntime", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent("scanstudio-web", isDirectory: false) + let developmentCommand = developmentRepositoryURL? + .appendingPathComponent("ports/web/.venv/bin/scanstudio-web", isDirectory: false) + let commandCandidates = [packagedCommand, developmentCommand].compactMap { $0 } + + let commandURL: URL + if let override = nonempty(environment[commandOverrideKey]) { + commandURL = URL(fileURLWithPath: override) + guard fileExists(commandURL.path) else { + throw WebServerRuntimeLocateError.missingCommandOverride(commandURL.path) + } + } else if let candidate = commandCandidates.first(where: { fileExists($0.path) }) { + commandURL = candidate + } else { + let staticCandidates = staticCandidates( + bundleResourceURL: bundleResourceURL, + developmentRepositoryURL: developmentRepositoryURL + ) + throw WebServerRuntimeLocateError.runtimeUnavailable( + commandPaths: commandCandidates.map(\.path), + staticPaths: staticCandidates.map(\.path) + ) + } + + let packagedStatic = bundleResourceURL? + .appendingPathComponent("WebFrontend", isDirectory: true) + let developmentStatic = developmentRepositoryURL? + .appendingPathComponent("ports/tauri/app/dist", isDirectory: true) + let staticCandidates = [packagedStatic, developmentStatic].compactMap { $0 } + + let staticURL: URL + if let override = nonempty(environment[staticDirectoryOverrideKey]) { + staticURL = URL(fileURLWithPath: override, isDirectory: true) + guard isDirectory(staticURL.path) else { + throw WebServerRuntimeLocateError.missingStaticDirectoryOverride(staticURL.path) + } + } else if let candidate = staticCandidates.first(where: { isDirectory($0.path) }) { + staticURL = candidate + } else { + throw WebServerRuntimeLocateError.runtimeUnavailable( + commandPaths: commandCandidates.map(\.path), + staticPaths: staticCandidates.map(\.path) + ) + } + + return WebServerRuntime( + executableURL: commandURL, + staticDirectoryURL: staticURL, + workingDirectoryURL: commandURL.deletingLastPathComponent() + ) + } + + private static func staticCandidates( + bundleResourceURL: URL?, + developmentRepositoryURL: URL? + ) -> [URL] { + [ + bundleResourceURL?.appendingPathComponent("WebFrontend", isDirectory: true), + developmentRepositoryURL?.appendingPathComponent( + "ports/tauri/app/dist", + isDirectory: true + ), + ].compactMap { $0 } + } + + private static func nonempty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func isDirectory(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + + /// Source builds already resolve an engine under + /// `/app/ScanStudio/engine/target/{release,debug}`. Recognize that + /// exact shape instead of embedding a developer's absolute checkout path. + private static func inferRepositoryRoot(from engineURL: URL?) -> URL? { + guard let engineURL else { return nil } + let components = engineURL.standardizedFileURL.pathComponents + guard components.count >= 7 else { return nil } + let suffix = Array(components.suffix(6)) + guard suffix[0] == "app", + suffix[1] == "ScanStudio", + suffix[2] == "engine", + suffix[3] == "target", + ["release", "debug"].contains(suffix[4]), + suffix[5] == "scanstudio-engine" else { + return nil + } + return (0..<6).reduce(engineURL) { partial, _ in + partial.deletingLastPathComponent() + } + } +} + +/// Production process controller. The gateway opts into a dedicated process +/// group before spawning its simulator engine. Stop gives the gateway a short +/// graceful window, then targets that whole group so neither process can +/// survive app termination. +public actor FoundationWebServerProcess: WebServerProcessControlling { + public nonisolated let terminationEvents: AsyncStream + + private let terminationContinuation: AsyncStream.Continuation + private var process: (identifier: UUID, value: Process)? + + public init() { + var continuation: AsyncStream.Continuation! + terminationEvents = AsyncStream { continuation = $0 } + terminationContinuation = continuation + } + + public func start(configuration: WebServerLaunchConfiguration) throws { + if let process, process.value.isRunning { + throw CocoaError(.executableLoad) + } + + let process = Process() + process.executableURL = configuration.executableURL + process.arguments = configuration.arguments + process.environment = configuration.environment + process.currentDirectoryURL = configuration.workingDirectoryURL + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + + let continuation = terminationContinuation + let identifier = configuration.identifier + process.terminationHandler = { terminated in + continuation.yield( + WebServerProcessExit( + identifier: identifier, + status: terminated.terminationStatus, + reason: terminated.terminationReason + ) + ) + } + + try process.run() + self.process = (configuration.identifier, process) + } + + public func stop(identifier: UUID?) { + guard let current = process, + identifier == nil || identifier == current.identifier else { + return + } + defer { self.process = nil } + let process = current.value + guard process.isRunning else { return } + + let processIdentifier = process.processIdentifier + process.terminate() + // The app-hosted gateway uses a 0.75 second engine timeout. Its three + // bounded shutdown stages therefore fit inside this grace window. + let deadline = Date().addingTimeInterval(3.5) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.025) + } + if process.isRunning { + // `scanstudio-web` calls setsid() before it spawns the engine, so + // the negative PID targets the isolated gateway process group. + // The direct signal is a safe fallback for a rapid stop that lands + // before Python has completed that setup. + Darwin.kill(-processIdentifier, SIGKILL) + Darwin.kill(processIdentifier, SIGKILL) + } + process.waitUntilExit() + // A process group can outlive its leader. Sweep it once more after the + // gateway has exited so a stuck engine child cannot become an orphan. + Darwin.kill(-processIdentifier, SIGKILL) + } +} + +public struct URLSessionWebServerReadinessChecker: WebServerReadinessChecking { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func waitUntilReady(at startupURL: URL, timeout: Duration) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + var lastError: Error? + + while clock.now < deadline { + try Task.checkCancellation() + do { + var request = URLRequest(url: startupURL) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 1 + let (_, response) = try await session.data(for: request) + if let response = response as? HTTPURLResponse, + response.statusCode == 200 { + return + } + } catch is CancellationError { + throw CancellationError() + } catch { + lastError = error + } + try await Task.sleep(for: .milliseconds(100)) + } + + if let lastError { + throw WebServerReadinessError.timedOut(lastError.localizedDescription) + } + throw WebServerReadinessError.timedOut("the startup check never became ready") + } +} + +public enum WebServerReadinessError: Error, LocalizedError, Equatable { + case timedOut(String) + + public var errorDescription: String? { + switch self { + case .timedOut(let detail): + return "The browser preview did not become ready: \(detail)" + } + } +} + +@MainActor +@Observable +public final class WebServerModel { + public static let loopbackURL = URL(string: "http://127.0.0.1:8787/")! + + public private(set) var isEnabled = false + public private(set) var state: WebServerState = .off + + public let browserURL: URL + public let accessToken: String + + private let engineURL: URL? + private let process: any WebServerProcessControlling + private let readinessChecker: any WebServerReadinessChecking + private let inheritedEnvironment: [String: String] + private let runtimeResolver: () throws -> WebServerRuntime + private var generation: UInt64 = 0 + private var activeProcessIdentifier: UUID? + private var terminationObserver: Task? + + public convenience init(engineURL: URL?) { + let process = FoundationWebServerProcess() + let locator = WebServerRuntimeLocator(engineURL: engineURL) + self.init( + engineURL: engineURL, + process: process, + readinessChecker: URLSessionWebServerReadinessChecker(), + inheritedEnvironment: ProcessInfo.processInfo.environment, + runtimeResolver: { try locator.locate() }, + tokenGenerator: Self.makeAccessToken + ) + } + + init( + engineURL: URL?, + process: any WebServerProcessControlling, + readinessChecker: any WebServerReadinessChecking, + inheritedEnvironment: [String: String], + browserURL: URL = WebServerModel.loopbackURL, + runtimeResolver: @escaping () throws -> WebServerRuntime, + tokenGenerator: () -> String + ) { + self.engineURL = engineURL + self.process = process + self.readinessChecker = readinessChecker + self.inheritedEnvironment = inheritedEnvironment + self.browserURL = browserURL + self.runtimeResolver = runtimeResolver + self.accessToken = tokenGenerator() + + let events = process.terminationEvents + terminationObserver = Task { @MainActor [weak self] in + for await exit in events { + guard let self else { return } + self.handleProcessExit(exit) + } + } + } + + /// The SwiftUI Toggle calls this asynchronously. It is generation-gated + /// so a quick on/off/on sequence cannot let stale readiness or shutdown + /// completion overwrite the newest user choice. + public func setEnabled(_ enabled: Bool) async { + if enabled == isEnabled { + guard case .failed = state else { return } + } + + generation &+= 1 + let operationGeneration = generation + isEnabled = enabled + + if !enabled { + state = .stopping + let processIdentifier = activeProcessIdentifier + activeProcessIdentifier = nil + await process.stop(identifier: processIdentifier) + guard generation == operationGeneration else { return } + state = .off + return + } + + state = .starting + // Clear any process left behind by a failed/rapid previous attempt. + await process.stop(identifier: nil) + guard generation == operationGeneration, isEnabled else { return } + + var launchedIdentifier: UUID? + do { + guard let engineURL else { + throw WebServerRuntimeLocateError.engineUnavailable + } + let runtime = try runtimeResolver() + let processIdentifier = UUID() + launchedIdentifier = processIdentifier + activeProcessIdentifier = processIdentifier + try await process.start( + configuration: launchConfiguration( + identifier: processIdentifier, + runtime: runtime, + engineURL: engineURL + ) + ) + try await readinessChecker.waitUntilReady( + at: browserURL.appendingPathComponent("startupz"), + timeout: .seconds(10) + ) + guard generation == operationGeneration, isEnabled else { + await process.stop(identifier: processIdentifier) + return + } + state = .running + } catch is CancellationError { + if activeProcessIdentifier == launchedIdentifier { + activeProcessIdentifier = nil + } + await process.stop(identifier: launchedIdentifier) + guard generation == operationGeneration else { return } + isEnabled = false + state = .off + } catch { + if activeProcessIdentifier == launchedIdentifier { + activeProcessIdentifier = nil + } + await process.stop(identifier: launchedIdentifier) + guard generation == operationGeneration else { return } + isEnabled = false + state = .failed(Self.describe(error)) + } + } + + /// Used by tests and hosts that can await shutdown. The macOS app delegate + /// also owns the same process controller directly so it can stop it from a + /// detached task while the main run loop is terminating. + public func shutDown() async { + generation &+= 1 + isEnabled = false + state = .stopping + activeProcessIdentifier = nil + await process.stop(identifier: nil) + state = .off + } + + /// AppKit calls `applicationWillTerminate` on the main thread and then + /// waits briefly for cleanup. This nonisolated hook lets its detached + /// cleanup task stop the shared process without waiting for MainActor, + /// which is already occupied by the termination callback. + public nonisolated func stopProcessForApplicationTermination() async { + await process.stop(identifier: nil) + } + + public var visibleErrorMessage: String { + if case .failed(let message) = state { return message } + return "" + } + + private func launchConfiguration( + identifier: UUID, + runtime: WebServerRuntime, + engineURL: URL + ) -> WebServerLaunchConfiguration { + var environment = inheritedEnvironment + // Defense in depth in addition to the gateway's own child-environment + // scrub: the desktop bridge and motion latch never enter this process. + environment.removeValue(forKey: "SCANSTUDIO_BRIDGE_CMD") + environment.removeValue(forKey: "SCANSTUDIO_HW_MOTION") + environment["SCANSTUDIO_ENGINE_PATH"] = engineURL.path + environment["SCANSTUDIO_WEB_STATIC_DIR"] = runtime.staticDirectoryURL.path + environment["SCANSTUDIO_WEB_BIND"] = "127.0.0.1" + environment["SCANSTUDIO_WEB_PORT"] = "8787" + environment["SCANSTUDIO_WEB_TOKEN"] = accessToken + environment["SCANSTUDIO_WEB_ALLOWED_ORIGINS"] = "http://127.0.0.1:8787" + environment["SCANSTUDIO_WEB_COOKIE_SECURE"] = "false" + environment["SCANSTUDIO_WEB_ISOLATE_PROCESS_GROUP"] = "1" + environment["SCANSTUDIO_WEB_ENGINE_SHUTDOWN_TIMEOUT_SECONDS"] = "0.75" + environment["PYTHONUNBUFFERED"] = "1" + + return WebServerLaunchConfiguration( + identifier: identifier, + executableURL: runtime.executableURL, + environment: environment, + workingDirectoryURL: runtime.workingDirectoryURL + ) + } + + private func handleProcessExit(_ exit: WebServerProcessExit) { + guard exit.identifier == activeProcessIdentifier else { return } + activeProcessIdentifier = nil + guard isEnabled else { + if state == .stopping { state = .off } + return + } + generation &+= 1 + isEnabled = false + let reason = exit.reason == .uncaughtSignal + ? "signal \(exit.status)" + : "exit code \(exit.status)" + state = .failed("The browser preview stopped unexpectedly (\(reason)). Turn it on to try again.") + } + + private static func makeAccessToken() -> String { + var generator = SystemRandomNumberGenerator() + return (0..<32).map { _ in + String(format: "%02x", UInt8.random(in: .min ... .max, using: &generator)) + }.joined() + } + + private static func describe(_ error: Error) -> String { + if let localized = error as? LocalizedError, + let message = localized.errorDescription, + !message.isEmpty { + return message + } + return "The browser preview could not start: \(error.localizedDescription)" + } +} diff --git a/app/ScanStudio/Tests/ScanStudioKitTests/WebServerModelTests.swift b/app/ScanStudio/Tests/ScanStudioKitTests/WebServerModelTests.swift new file mode 100644 index 0000000..43bc7f2 --- /dev/null +++ b/app/ScanStudio/Tests/ScanStudioKitTests/WebServerModelTests.swift @@ -0,0 +1,334 @@ +import Foundation +import Testing + +@testable import ScanStudioKit + +@Suite("Browser preview runtime locator") +struct WebServerRuntimeLocatorTests { + @Test("explicit development paths win and are validated") + func overridesWin() throws { + let command = "/checkout/ports/web/.venv/bin/scanstudio-web" + let staticDirectory = "/checkout/ports/tauri/app/dist" + + let runtime = try WebServerRuntimeLocator.locate( + environment: [ + WebServerRuntimeLocator.commandOverrideKey: command, + WebServerRuntimeLocator.staticDirectoryOverrideKey: staticDirectory, + ], + bundleResourceURL: URL(fileURLWithPath: "/Applications/ScanStudio.app/Contents/Resources"), + developmentRepositoryURL: URL(fileURLWithPath: "/somewhere-else"), + fileExists: { $0 == command }, + isDirectory: { $0 == staticDirectory } + ) + + #expect(runtime.executableURL.path == command) + #expect(runtime.staticDirectoryURL.path == staticDirectory) + #expect(runtime.workingDirectoryURL?.path == "/checkout/ports/web/.venv/bin") + } + + @Test("packaged resources are preferred over a source checkout") + func packagedResourcesWin() throws { + let resources = URL(fileURLWithPath: "/Applications/ScanStudio.app/Contents/Resources") + let repository = URL(fileURLWithPath: "/checkout") + let packagedCommand = "/Applications/ScanStudio.app/Contents/Resources/WebRuntime/bin/scanstudio-web" + let packagedStatic = "/Applications/ScanStudio.app/Contents/Resources/WebFrontend" + + let runtime = try WebServerRuntimeLocator.locate( + environment: [:], + bundleResourceURL: resources, + developmentRepositoryURL: repository, + fileExists: { path in + path == packagedCommand + || path == "/checkout/ports/web/.venv/bin/scanstudio-web" + }, + isDirectory: { path in + path == packagedStatic || path == "/checkout/ports/tauri/app/dist" + } + ) + + #expect(runtime.executableURL.path == packagedCommand) + #expect(runtime.staticDirectoryURL.path == packagedStatic) + } + + @Test("source checkout is a fallback when packaged resources are absent") + func developmentFallback() throws { + let repository = URL(fileURLWithPath: "/checkout") + let command = "/checkout/ports/web/.venv/bin/scanstudio-web" + let staticDirectory = "/checkout/ports/tauri/app/dist" + + let runtime = try WebServerRuntimeLocator.locate( + environment: [:], + bundleResourceURL: nil, + developmentRepositoryURL: repository, + fileExists: { $0 == command }, + isDirectory: { $0 == staticDirectory } + ) + + #expect(runtime.executableURL.path == command) + #expect(runtime.staticDirectoryURL.path == staticDirectory) + } + + @Test("a missing command override fails closed") + func missingCommandOverrideFailsClosed() { + #expect(throws: WebServerRuntimeLocateError.missingCommandOverride("/missing/gateway")) { + try WebServerRuntimeLocator.locate( + environment: [WebServerRuntimeLocator.commandOverrideKey: "/missing/gateway"], + bundleResourceURL: nil, + developmentRepositoryURL: nil, + fileExists: { _ in false }, + isDirectory: { _ in false } + ) + } + } + + @Test("a missing static-directory override fails closed") + func missingStaticOverrideFailsClosed() { + let command = "/working/gateway" + #expect(throws: WebServerRuntimeLocateError.missingStaticDirectoryOverride("/missing/dist")) { + try WebServerRuntimeLocator.locate( + environment: [ + WebServerRuntimeLocator.commandOverrideKey: command, + WebServerRuntimeLocator.staticDirectoryOverrideKey: "/missing/dist", + ], + bundleResourceURL: nil, + developmentRepositoryURL: nil, + fileExists: { $0 == command }, + isDirectory: { _ in false } + ) + } + } +} + +@Suite("Browser preview server model") +@MainActor +struct WebServerModelTests { + private let engineURL = URL(fileURLWithPath: "/checkout/scanstudio-engine") + private let runtime = WebServerRuntime( + executableURL: URL(fileURLWithPath: "/checkout/scanstudio-web"), + staticDirectoryURL: URL(fileURLWithPath: "/checkout/dist", isDirectory: true), + workingDirectoryURL: URL(fileURLWithPath: "/checkout", isDirectory: true) + ) + + @Test("preview is off by default and a ready process becomes running") + func startsWithSafeSimulatorOnlyEnvironment() async throws { + let process = FakeWebServerProcess() + let readiness = FakeWebServerReadiness() + let model = makeModel(process: process, readiness: readiness) + + #expect(model.state == .off) + #expect(!model.isEnabled) + #expect(model.accessToken == "unit-test-access-token") + + await model.setEnabled(true) + + #expect(model.state == .running) + #expect(model.isEnabled) + let snapshot = await process.snapshot() + let launch = try #require(snapshot.configurations.last) + #expect(snapshot.stopCount == 1, "startup first clears any stale process") + #expect(launch.executableURL == runtime.executableURL) + #expect(launch.workingDirectoryURL == runtime.workingDirectoryURL) + #expect(launch.environment["SCANSTUDIO_ENGINE_PATH"] == engineURL.path) + #expect(launch.environment["SCANSTUDIO_WEB_STATIC_DIR"] == runtime.staticDirectoryURL.path) + #expect(launch.environment["SCANSTUDIO_WEB_BIND"] == "127.0.0.1") + #expect(launch.environment["SCANSTUDIO_WEB_PORT"] == "8787") + #expect(launch.environment["SCANSTUDIO_WEB_TOKEN"] == "unit-test-access-token") + #expect(launch.environment["SCANSTUDIO_WEB_ALLOWED_ORIGINS"] == "http://127.0.0.1:8787") + #expect(launch.environment["SCANSTUDIO_WEB_ISOLATE_PROCESS_GROUP"] == "1") + #expect(launch.environment["SCANSTUDIO_WEB_ENGINE_SHUTDOWN_TIMEOUT_SECONDS"] == "0.75") + #expect(launch.environment["SCANSTUDIO_BRIDGE_CMD"] == nil) + #expect(launch.environment["SCANSTUDIO_HW_MOTION"] == nil) + #expect(launch.environment["PRESERVED"] == "yes") + #expect(await readiness.urls == [URL(string: "http://127.0.0.1:8787/startupz")!]) + } + + @Test("turning the toggle off stops the process") + func toggleOffStopsProcess() async { + let process = FakeWebServerProcess() + let model = makeModel(process: process) + await model.setEnabled(true) + + await model.setEnabled(false) + + #expect(model.state == .off) + #expect(!model.isEnabled) + #expect(await process.snapshot().stopCount == 2) + } + + @Test("readiness failure is visible and returns the toggle to off") + func readinessFailureIsVisible() async { + let process = FakeWebServerProcess() + let readiness = FakeWebServerReadiness(failure: .readiness) + let model = makeModel(process: process, readiness: readiness) + + await model.setEnabled(true) + + #expect(!model.isEnabled) + guard case .failed(let message) = model.state else { + Issue.record("Expected a visible failure state") + return + } + #expect(message.contains("test readiness failure")) + #expect(await process.snapshot().stopCount == 2) + } + + @Test("process launch failure is visible and returns the toggle to off") + func processLaunchFailureIsVisible() async { + let process = FakeWebServerProcess(startFailure: .processStart) + let model = makeModel(process: process) + + await model.setEnabled(true) + + #expect(!model.isEnabled) + #expect(model.state == .failed("test process start failure")) + #expect(await process.snapshot().configurations.isEmpty) + } + + @Test("a missing engine fails without launching a gateway") + func missingEngineFailsClosed() async { + let process = FakeWebServerProcess() + let model = WebServerModel( + engineURL: nil, + process: process, + readinessChecker: FakeWebServerReadiness(), + inheritedEnvironment: [:], + runtimeResolver: { self.runtime }, + tokenGenerator: { "token" } + ) + + await model.setEnabled(true) + + #expect(!model.isEnabled) + #expect(model.visibleErrorMessage.contains("engine is unavailable")) + #expect(await process.snapshot().configurations.isEmpty) + } + + @Test("an unexpected matching process exit becomes a visible failure") + func unexpectedExitIsVisible() async throws { + let process = FakeWebServerProcess() + let model = makeModel(process: process) + await model.setEnabled(true) + let identifier = try #require(await process.snapshot().configurations.last?.identifier) + + await process.emitExit(identifier: identifier, status: 7) + await waitForObserver() + + #expect(!model.isEnabled) + #expect(model.state == .failed("The browser preview stopped unexpectedly (exit code 7). Turn it on to try again.")) + } + + @Test("a delayed exit from an old process cannot fail a new run") + func staleExitIsIgnored() async throws { + let process = FakeWebServerProcess() + let model = makeModel(process: process) + await model.setEnabled(true) + let firstIdentifier = try #require(await process.snapshot().configurations.last?.identifier) + await model.setEnabled(false) + await model.setEnabled(true) + + await process.emitExit(identifier: firstIdentifier, status: 0) + await waitForObserver() + + #expect(model.isEnabled) + #expect(model.state == .running) + } + + @Test("explicit and app-termination shutdown hooks both stop the process") + func shutdownAlwaysStopsProcess() async { + let process = FakeWebServerProcess() + let model = makeModel(process: process) + await model.setEnabled(true) + + await model.shutDown() + #expect(model.state == .off) + #expect(!model.isEnabled) + #expect(await process.snapshot().stopCount == 2) + + await model.stopProcessForApplicationTermination() + #expect(await process.snapshot().stopCount == 3) + } + + private func makeModel( + process: FakeWebServerProcess, + readiness: FakeWebServerReadiness = FakeWebServerReadiness() + ) -> WebServerModel { + WebServerModel( + engineURL: engineURL, + process: process, + readinessChecker: readiness, + inheritedEnvironment: [ + "SCANSTUDIO_BRIDGE_CMD": "/hardware/bridge", + "SCANSTUDIO_HW_MOTION": "I_UNDERSTAND", + "PRESERVED": "yes", + ], + runtimeResolver: { self.runtime }, + tokenGenerator: { "unit-test-access-token" } + ) + } + + private func waitForObserver() async { + for _ in 0..<20 { + await Task.yield() + } + } +} + +private enum FakeWebServerFailure: Error, LocalizedError, Sendable { + case processStart + case readiness + + var errorDescription: String? { + switch self { + case .processStart: "test process start failure" + case .readiness: "test readiness failure" + } + } +} + +private actor FakeWebServerProcess: WebServerProcessControlling { + nonisolated let terminationEvents: AsyncStream + private let continuation: AsyncStream.Continuation + private let startFailure: FakeWebServerFailure? + private var configurations: [WebServerLaunchConfiguration] = [] + private var stopCount = 0 + + init(startFailure: FakeWebServerFailure? = nil) { + self.startFailure = startFailure + var continuation: AsyncStream.Continuation! + terminationEvents = AsyncStream { continuation = $0 } + self.continuation = continuation + } + + func start(configuration: WebServerLaunchConfiguration) throws { + if let startFailure { throw startFailure } + configurations.append(configuration) + } + + func stop(identifier: UUID?) { + stopCount += 1 + } + + func emitExit(identifier: UUID, status: Int32) { + continuation.yield( + WebServerProcessExit(identifier: identifier, status: status, reason: .exit) + ) + } + + func snapshot() -> (configurations: [WebServerLaunchConfiguration], stopCount: Int) { + (configurations, stopCount) + } +} + +private actor FakeWebServerReadiness: WebServerReadinessChecking { + private let failure: FakeWebServerFailure? + private(set) var urls: [URL] = [] + + init(failure: FakeWebServerFailure? = nil) { + self.failure = failure + } + + func waitUntilReady(at startupURL: URL, timeout: Duration) throws { + urls.append(startupURL) + if let failure { throw failure } + } +} diff --git a/docs/WEB-HEADLESS.md b/docs/WEB-HEADLESS.md new file mode 100644 index 0000000..6f8e300 --- /dev/null +++ b/docs/WEB-HEADLESS.md @@ -0,0 +1,84 @@ +# ScanStudio web and headless roadmap + +ScanStudio's browser edition is a new host for the existing application, not a +new scanning implementation. The browser uses the same React session model as +the Tauri desktop port. A small Python service supervises the same Rust engine, +which continues to use the same Python bridge and CoolScanPy hardware path. + +See [ADR 0001](adr/0001-web-headless-runtime.md) for the decision and safety +boundaries. + +## Milestone 1: simulator appliance + +Goal: prove the complete browser transport without filesystem writes or scanner +motion. + +- authenticated browser session; +- one renewable controller lease and read-only observers; +- one supervised engine child and mandatory protocol handshake; +- HTTP request/response relay and ordered WebSocket events; +- existing Device Bar and Contact Sheet running in a browser; +- simulated six-frame strip load and preview; +- multi-stage Docker image with no bridge configured; +- unit tests plus a browser-to-engine simulator smoke test. + +The gateway deliberately rejects every engine method outside the milestone's +allowlist. A build that renders more controls does not make those operations +available server-side. + +The macOS app exposes this local preview as a session-only Settings toggle. It +starts off, binds to loopback, generates a fresh access token per app launch, +and stops the gateway during app termination. In this milestone the toggled +service owns a separate simulator engine; it does not attach to or control the +native app's scanner session. Docker lifecycle remains controlled by the +container runtime rather than a desktop process. + +## Milestone 2: server storage and reconnect + +- Replace local file dialogs with server-defined project and output roots. +- Accept opaque project/storage IDs, never arbitrary absolute browser input. +- Map real preview artifacts to short-lived authenticated IDs. +- Add a state snapshot and bounded event replay so a refreshed browser can + rehydrate without restarting the engine or scanner session. +- Correct the engine's documented stale project-mutation risk before allowing + mutations concurrent with active receipt persistence. +- Add graceful drain behavior and an explicit update-safe/idle signal. + +## Milestone 3: owner-attended container validation + +- Package the Python bridge, CoolScanPy, system libusb/SANE runtime, ExifTool, + licenses, notices, and corresponding source. +- Persist `HOME`/bridge state under `/config` and projects under + `/data/projects`. +- Pass only the required USB device where practical. For hotplug support, + document the broader `/dev/bus/usb` plus cgroup-rule and host-udev tradeoff. +- Preserve the existing two-part motion arm. Container startup must never + create or silently modify the latch. +- Validate preview, approval, one short real capture, safe stop, restart, and + recovery through the repository's live-operation runbook. + +## Milestone 4: Unraid release + +- Publish a pinned multi-architecture policy (x86-64 first) and image digest. +- Provide an Unraid Community Applications template for port, `/config`, + `/data/projects`, PUID/PGID, scanner group, and USB mapping. +- Recommend Tailscale or an authenticated TLS reverse proxy; never direct + Internet exposure. +- Disable unattended restarts while a job or held preview registration is + active. +- Add capacity and retention guidance. A 4000 dpi, 16-bit RGBI frame can consume + hundreds of megabytes across archive, positive, IR, meter, and evidence data. + +## Mobile direction + +There is no separate mobile app in this plan. The shared browser UI adapts in +place: + +- desktop retains the two/three-pane scanning cockpit; +- tablet uses a narrower navigation rail and workspace; +- phone stacks device controls above one primary workspace, uses 44 px touch + targets, safe-area insets, and `100dvh`; +- motion-capable actions remain explicit and never depend on hover. + +This keeps a future native mobile shell possible without making it a dependency +of the headless scanner service. diff --git a/docs/adr/0001-web-headless-runtime.md b/docs/adr/0001-web-headless-runtime.md new file mode 100644 index 0000000..31693ea --- /dev/null +++ b/docs/adr/0001-web-headless-runtime.md @@ -0,0 +1,132 @@ +# ADR 0001: Web and headless runtime + +- Status: Accepted for an incremental implementation +- Date: 2026-08-09 +- Branch: `feature/scanstudio-web` + +## Context + +ScanStudio has three mature boundaries already: + +1. SwiftUI and React/Tauri clients project session state and send commands. +2. `scanstudio-engine` owns projects, scan jobs, rendering, manifests, receipts, + evidence, and the public NDJSON protocol. +3. The Python `scanstudio-bridge` and CoolScanPy own hardware sessions, + registration, motion safety, and USB/SANE access. + +The React client is already mostly platform-neutral. Its `SessionStore` depends +on a two-method `EngineTransport`; only the current transport and a small set of +host services are Tauri-specific. + +The target is a browser-accessible, headless ScanStudio appliance that can run +on an x86-64 Linux/Unraid host while preserving native macOS, Windows, and Linux +clients. It must remain safe when a browser disconnects, when multiple tabs are +open, and when the physical scanner has exclusive state that cannot be replayed. + +## Decision + +Add a Python 3.13 FastAPI gateway that owns exactly one long-lived +`scanstudio-engine` subprocess. It performs the mandatory `engine.hello` +handshake, correlates protocol responses, and relays engine events over a +same-origin WebSocket. + +Reuse the React 19 + TypeScript + Vite interface in `ports/tauri/app`. Select a +Tauri or web transport at runtime; do not create a second browser UI or a second +client-side state model. + +The initial vertical slice is simulator-only and permits only: + +- `scanner.list` +- `scanner.connect` for `sim-ls5000-0` +- `scanner.status` +- `sim.loadMedia` +- `scanner.acquireThumbnails` +- `scanner.disconnect` + +It proves authentication, container delivery, request/response correlation, +event streaming, reconnect behavior, and the shared interface without touching +hardware or writing scan output. + +Real capture remains behind later acceptance gates. The production topology is: + +```text +browser + -> HTTPS reverse proxy or private VPN + -> FastAPI gateway (one controller lease, observers allowed) + -> scanstudio-engine (one long-lived process) + -> scanstudio-bridge (one long-lived Python process) + -> CoolScanPy / libusb + -> Nikon LS-5000 +``` + +## Options considered + +| Option | Three-year engineering TCO (assumption) | Risk | Decision | +| --- | ---: | --- | --- | +| Reuse React; Python gateway relays the existing engine protocol | 5–9 engineer-weeks | Medium | Chosen | +| Add HTTP/WebSocket directly to the Rust engine | 6–11 engineer-weeks | Medium | Rejected for now; it expands the engine's security and lifecycle surface | +| Rewrite engine workflow in Python | 30–60+ engineer-weeks | Very high | Rejected; duplicates tested policy, rendering, and receipt logic | +| Remote-control the Tauri desktop app | 10–18 engineer-weeks | High | Rejected; keeps a hidden GUI dependency and poor server lifecycle semantics | + +The estimates are directional for a single maintainer and include maintenance, +not calendar commitments. The chosen approach has the smallest new authority: +the gateway supervises and transports; it does not decide scan policy. + +## Consequences + +### Easier + +- Improvements to the engine or Python hardware layer reach every frontend. +- The existing React UI and its tests become both the Windows/Linux desktop UI + and browser UI. +- Browser disconnects do not own or cancel scanner jobs. +- Docker can package one stateful scanner appliance without a desktop session. +- A future native mobile client can use the same gateway protocol without + changing scanner logic. + +### Harder + +- The gateway must preserve process ordering and fail closed if the engine dies. +- A browser cannot choose server directories with its local file picker. + Storage selection needs an allowlisted server-side model. +- Engine paths cannot be exposed as arbitrary file URLs. Real previews require + opaque, authenticated artifact identifiers. +- One active project and one scanner mean the appliance cannot be horizontally + scaled. One browser gets a renewable controller lease; others are observers. +- Browser reconnect requires an authoritative state-hydration endpoint before + real capture is enabled. + +## Real-hardware release gates + +The simulator milestone does not enable the bridge. Real USB capture is enabled +only after all of the following are implemented and verified: + +1. Authentication, exact WebSocket Origin checks, request limits, and HTTPS or + a trusted private network are documented and tested. +2. Project and output paths are canonicalized beneath configured persistent + roots; symlinks and traversal cannot escape them. +3. Preview files are served through opaque authenticated IDs, never caller- + supplied filesystem paths. +4. Reconnect hydrates device, media, project, preview registration, approvals, + and active-job state without replaying a motion command. +5. SIGTERM stops accepting new motion, requests an after-current-frame stop, + waits for terminal evidence, then closes the engine and bridge. +6. Docker runs as a non-root user, without `--privileged`, with only the needed + USB device access and persistent `/config` and `/data/projects` mounts. +7. Existing SAFE-02 motion arming, hardware-lane locking, evidence retention, + and GPL corresponding-source distribution remain intact. +8. A container-specific, owner-attended LS-5000 run passes the Nikon live + operation runbook and records before/after state, hashes, logs, receipts, + rollback, and final media state. + +## Deployment boundary + +This milestone's supported image is simulator-only and contains neither USB +access nor the Python bridge/CoolScanPy. The future hardware-capable container +target is Linux x86-64 with a USB LS-5000. It will not contain the Swift app, +Nikon Scan/noVNC VM, Windows WSL2 path, or macOS FireWire driver. The scanner +must be owned by one host at a time; a VM and container cannot safely share it. + +That future hardware bundle will include GPL-3.0-only bridge/CoolScanPy +components and must ship their licenses, notices, and corresponding source. It +must not be labeled as MIT-only. diff --git a/ports/tauri/app/index.html b/ports/tauri/app/index.html index ff93803..8c135a0 100644 --- a/ports/tauri/app/index.html +++ b/ports/tauri/app/index.html @@ -2,9 +2,10 @@ - - - Tauri + React + Typescript + + + + ScanStudio diff --git a/ports/tauri/app/package-lock.json b/ports/tauri/app/package-lock.json index 4c85f60..baae94b 100644 --- a/ports/tauri/app/package-lock.json +++ b/ports/tauri/app/package-lock.json @@ -1840,9 +1840,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/ports/tauri/app/package.json b/ports/tauri/app/package.json index 7cbea10..c00af66 100644 --- a/ports/tauri/app/package.json +++ b/ports/tauri/app/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite --mode web", "build": "tsc && vite build", + "build:web": "tsc && vite build --mode web", "preview": "vite preview", "tauri": "tauri", "test": "vitest run", diff --git a/ports/tauri/app/src/App.module.css b/ports/tauri/app/src/App.module.css index 0c8ba42..0361785 100644 --- a/ports/tauri/app/src/App.module.css +++ b/ports/tauri/app/src/App.module.css @@ -8,24 +8,24 @@ .windowsSetupHeading { margin: 0; - color: #111827; + color: var(--scan-primary-text); font-size: 1rem; font-weight: 650; } .windowsSetupCopy { margin: 0; - color: #4b5563; + color: var(--scan-secondary-text); font-size: 0.875rem; line-height: 1.5; } .windowsSetupButton { padding: 0.45rem 0.75rem; - border: 1px solid #9ca3af; + border: 1px solid var(--scan-divider); border-radius: 4px; - background: #ffffff; - color: #111827; + background: var(--scan-raised); + color: var(--scan-primary-text); font: inherit; font-size: 0.875rem; font-weight: 600; @@ -33,10 +33,10 @@ } .windowsSetupButton:hover { - background: #f9fafb; + background: linear-gradient(var(--scan-control-hover), var(--scan-control-hover)), var(--scan-raised); } .windowsSetupButton:focus-visible { - outline: 2px solid #2563eb; + outline: 2px solid var(--scan-cyan); outline-offset: 2px; } diff --git a/ports/tauri/app/src/App.tsx b/ports/tauri/app/src/App.tsx index 7e55dd5..da36f72 100644 --- a/ports/tauri/app/src/App.tsx +++ b/ports/tauri/app/src/App.tsx @@ -8,6 +8,8 @@ import FrameDetailView from "./views/FrameDetail/FrameDetailView"; import ProjectPanel from "./views/ProjectPanel"; import SetupChecker from "./views/SetupChecker"; import { sessionStore, type SessionState } from "./session"; +import { isTauriRuntime, isWebSimulatorPreview } from "./runtime"; +import { useScannerControl } from "./scannerControl"; import styles from "./App.module.css"; let cachedStore: unknown = null; @@ -46,14 +48,19 @@ function App() { const state = useSyncExternalStore(stableSubscribe, stableGetSnapshot); const [workspace, setWorkspace] = useState({ kind: "contact" }); const selectedFrames = state.selectedFrameIndices; - const windows = isWindows(); + const windows = isTauriRuntime() && isWindows(); + const simulatorPreview = isWebSimulatorPreview(); + const canControlScanner = useScannerControl(); return ( - - + + {!simulatorPreview && } } workspace={ @@ -74,8 +81,15 @@ function App() { {workspace.kind === "windows-setup" && } {workspace.kind === "contact" && ( setWorkspace({ kind: "frame-detail", frameIndex })} - onCapture={() => setWorkspace({ kind: "capture" })} + canControl={canControlScanner} + onInspectFrame={ + simulatorPreview + ? undefined + : (frameIndex) => setWorkspace({ kind: "frame-detail", frameIndex }) + } + onCapture={ + simulatorPreview ? undefined : () => setWorkspace({ kind: "capture" }) + } /> )} diff --git a/ports/tauri/app/src/WebRuntimeGate.module.css b/ports/tauri/app/src/WebRuntimeGate.module.css new file mode 100644 index 0000000..74af3c0 --- /dev/null +++ b/ports/tauri/app/src/WebRuntimeGate.module.css @@ -0,0 +1,205 @@ +.loginSurface { + min-height: 100dvh; + display: grid; + place-items: center; + padding: max(2rem, env(safe-area-inset-top)) max(1.25rem, env(safe-area-inset-right)) + max(2rem, env(safe-area-inset-bottom)) max(1.25rem, env(safe-area-inset-left)); + background: var(--scan-workspace); + color: var(--scan-primary-text); +} + +.loginContent { + width: min(100%, 26rem); + display: flex; + flex-direction: column; + align-items: stretch; +} + +.brandMark { + width: 2.25rem; + height: 0.35rem; + margin-bottom: 1.5rem; + border-radius: 999px; + background: var(--scan-amber); +} + +.title { + margin: 0 0 0.65rem; + font-size: clamp(2.25rem, 8vw, 4.25rem); + line-height: 0.98; + letter-spacing: -0.035em; +} + +.statusCopy { + max-width: 36ch; + margin: 0 0 2rem; + color: var(--scan-secondary-text); + font-size: 1rem; + line-height: 1.55; +} + +.label { + margin-bottom: 0.5rem; + color: var(--scan-row-label); + font-size: 0.875rem; + font-weight: 650; +} + +.tokenInput { + min-height: 3rem; + padding: 0 0.85rem; + border: 1px solid var(--scan-divider); + border-radius: 0.55rem; + background: var(--scan-raised); + color: var(--scan-primary-text); + font: inherit; +} + +.tokenInput:focus-visible { + border-color: var(--scan-cyan); + outline: 3px solid rgb(79 201 217 / 18%); + outline-offset: 1px; +} + +.error { + margin: 0.75rem 0 0; + color: var(--scan-primary-text); + font-size: 0.9rem; + line-height: 1.45; +} + +.primaryButton { + min-height: 3rem; + margin-top: 1rem; + padding: 0 1rem; + border: 1px solid var(--scan-amber); + border-radius: 0.55rem; + background: var(--scan-amber); + color: rgb(0 0 0 / 86%); + font: inherit; + font-weight: 680; + cursor: pointer; +} + +.primaryButton:hover:not(:disabled) { + background: color-mix(in srgb, var(--scan-amber) 88%, white); +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.primaryButton:focus-visible, +.claimButton:focus-visible { + outline: 3px solid rgb(79 201 217 / 38%); + outline-offset: 2px; +} + +.authenticatedShell { + height: 100dvh; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; + background: var(--scan-workspace); +} + +.runtimeBar { + min-height: 2.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: max(0.55rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) + 0.55rem max(1rem, env(safe-area-inset-left)); + border-bottom: 1px solid var(--scan-divider); + background: var(--scan-sidebar); + color: var(--scan-primary-text); + font-size: 0.82rem; +} + +.runtimeIdentity, +.observerControls { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.runtimeIdentity { + font-weight: 700; +} + +.liveDot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--scan-green); +} + +.runtimeBar[data-control="observer"] .liveDot { + background: var(--scan-amber); +} + +.runtimeBar[data-control="offline"] .liveDot { + background: var(--scan-secondary-text); + box-shadow: none; +} + +.controlCopy { + color: var(--scan-secondary-text); +} + +.claimButton { + min-height: 2rem; + padding: 0 0.65rem; + border: 1px solid var(--scan-divider); + border-radius: 0.4rem; + background: var(--scan-raised); + color: var(--scan-primary-text); + font: inherit; + font-weight: 650; + cursor: pointer; +} + +.appFrame { + grid-row: 3; + min-height: 0; + overflow: hidden; +} + +.runtimeError { + grid-row: 2; + margin: 0; + padding: 0.55rem max(1rem, env(safe-area-inset-right)) 0.55rem + max(1rem, env(safe-area-inset-left)); + border-bottom: 1px solid var(--scan-red-border); + background: var(--scan-red-fill); + color: var(--scan-primary-text); + font-size: 0.82rem; + line-height: 1.4; +} + +@media (max-width: 42rem) { + .runtimeBar { + align-items: center; + } + + .runtimeBar[data-control="observer"] .controlCopy { + display: none; + } + + .runtimeBar[data-control="owned"] .controlCopy, + .runtimeBar[data-control="offline"] .controlCopy { + display: block; + max-width: 12rem; + text-align: right; + line-height: 1.25; + } +} + +@media (prefers-reduced-motion: reduce) { + .primaryButton, + .claimButton { + transition: none; + } +} diff --git a/ports/tauri/app/src/WebRuntimeGate.tsx b/ports/tauri/app/src/WebRuntimeGate.tsx new file mode 100644 index 0000000..974266e --- /dev/null +++ b/ports/tauri/app/src/WebRuntimeGate.tsx @@ -0,0 +1,405 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, + type ReactNode, +} from "react"; +import { + acquireControlTabLock, + clearControlLeaseToken, + controlLeaseHeaders, + setControlLeaseToken, + type HeldControlTabLock, +} from "./controlLease"; +import { isTauriRuntime } from "./runtime"; +import { + notifyWebSessionReady, + WEB_EVENT_STREAM_STATE_EVENT, + type WebEventStreamState, +} from "./engine/client"; +import { ScannerControlProvider } from "./scannerControl"; +import styles from "./WebRuntimeGate.module.css"; + +type ControlState = "available" | "owned" | "observer"; + +interface WebSession { + authenticated: boolean; + control: ControlState; +} + +interface WebRuntimeGateProps { + children: ReactNode; +} + +const CONTROL_TAB_UNVERIFIED_MESSAGE = + "Scanner control could not be verified for this tab. Reclaim control in this tab."; + +async function readSession(): Promise { + const response = await fetch("/api/v1/session", { + credentials: "same-origin", + headers: controlLeaseHeaders(), + }); + if (response.status === 401) return { authenticated: false, control: "available" }; + if (!response.ok) throw new Error(`Session check failed (${response.status}).`); + const payload = (await response.json()) as Partial; + const control = + payload.control === "owned" || payload.control === "observer" + ? payload.control + : "available"; + return { + authenticated: payload.authenticated === true, + control, + }; +} + +async function post(path: string, body?: unknown, includeLease = false): Promise { + return fetch(path, { + method: "POST", + credentials: "same-origin", + headers: { + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...(includeLease ? controlLeaseHeaders() : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +export default function WebRuntimeGate({ children }: WebRuntimeGateProps) { + const tauri = isTauriRuntime(); + const [session, setSession] = useState(null); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [eventStream, setEventStream] = useState({ + ready: tauri, + message: tauri ? null : "Connecting to the scanner event stream…", + }); + const claimInFlight = useRef | null>(null); + const refreshGeneration = useRef(0); + const controlTabLock = useRef(null); + + const releaseLocalControl = useCallback((): void => { + clearControlLeaseToken(); + controlTabLock.current?.release(); + controlTabLock.current = null; + }, []); + + const commitSession = useCallback((next: WebSession): void => { + if (next.authenticated && next.control === "owned" && controlTabLock.current === null) { + releaseLocalControl(); + setSession({ ...next, control: "observer" }); + setError(CONTROL_TAB_UNVERIFIED_MESSAGE); + return; + } + if (!next.authenticated || next.control !== "owned") releaseLocalControl(); + setSession(next); + }, [releaseLocalControl]); + + const refresh = useCallback(async (): Promise => { + const generation = ++refreshGeneration.current; + setError(null); + try { + const next = await readSession(); + if (refreshGeneration.current === generation) { + commitSession(next); + } + } catch (caught) { + if (refreshGeneration.current === generation) { + setError(caught instanceof Error ? caught.message : "The ScanStudio server is unavailable."); + } + } + }, [commitSession]); + + const claimControl = useCallback((): Promise => { + if (claimInFlight.current !== null) return claimInFlight.current; + const claim = (async (): Promise => { + const generation = ++refreshGeneration.current; + setError(null); + if (controlTabLock.current === null) { + const localGuard = await acquireControlTabLock(); + if (refreshGeneration.current !== generation) { + localGuard.release(); + return; + } + controlTabLock.current = localGuard; + } + let response: Response; + try { + response = await post("/api/v1/control/claim"); + } catch { + releaseLocalControl(); + if (refreshGeneration.current === generation) { + throw new Error("The scanner server could not be reached."); + } + return; + } + if (refreshGeneration.current !== generation) { + releaseLocalControl(); + return; + } + if (response.status === 401) { + releaseLocalControl(); + setSession({ authenticated: false, control: "available" }); + return; + } + if (response.status === 409 || response.status === 423) { + releaseLocalControl(); + setSession((current) => + current === null ? current : { ...current, control: "observer" }, + ); + return; + } + if (!response.ok) { + releaseLocalControl(); + throw new Error(`Control request failed (${response.status}).`); + } + let payload: { leaseToken?: unknown }; + try { + payload = (await response.json()) as { leaseToken?: unknown }; + } catch { + releaseLocalControl(); + throw new Error("The scanner server returned an unreadable control lease."); + } + if (typeof payload.leaseToken !== "string" || payload.leaseToken.length === 0) { + releaseLocalControl(); + throw new Error("The scanner server did not return a control lease."); + } + setControlLeaseToken(payload.leaseToken); + setError(null); + setSession((current) => + current === null ? current : { ...current, control: "owned" }, + ); + })(); + claimInFlight.current = claim; + const clearClaim = (): void => { + if (claimInFlight.current === claim) claimInFlight.current = null; + }; + void claim.then(clearClaim, clearClaim); + return claim; + }, [releaseLocalControl]); + + useEffect(() => { + if (tauri) return; + // A duplicated tab inherits sessionStorage. Clear that untrusted legacy + // copy before the first session read. Active leases only live in this + // page's module memory and are never restored from browser storage. + releaseLocalControl(); + void refresh(); + return () => { + refreshGeneration.current += 1; + releaseLocalControl(); + }; + }, [refresh, releaseLocalControl, tauri]); + + useEffect(() => { + if (tauri) return; + const update = (event: Event): void => { + const detail = (event as CustomEvent).detail; + if ( + typeof detail === "object" && + detail !== null && + typeof detail.ready === "boolean" + ) { + setEventStream({ + ready: detail.ready, + message: typeof detail.message === "string" ? detail.message : null, + }); + } + }; + window.addEventListener(WEB_EVENT_STREAM_STATE_EVENT, update); + return () => window.removeEventListener(WEB_EVENT_STREAM_STATE_EVENT, update); + }, [tauri]); + + useEffect(() => { + if (tauri || session?.authenticated !== true || session.control !== "available") return; + void claimControl().catch((caught) => { + setError(caught instanceof Error ? caught.message : "Scanner control could not be claimed."); + }); + }, [claimControl, session, tauri]); + + useEffect(() => { + if (!tauri && session?.authenticated === true) notifyWebSessionReady(); + }, [session?.authenticated, tauri]); + + useEffect(() => { + if (tauri || session?.authenticated !== true) return; + const interval = window.setInterval(() => void refresh(), 60_000); + return () => window.clearInterval(interval); + }, [refresh, session?.authenticated, tauri]); + + useEffect(() => { + if (tauri || session?.control !== "owned") return; + const heartbeat = window.setInterval(() => { + const generation = ++refreshGeneration.current; + void post("/api/v1/control/heartbeat", undefined, true) + .then((response) => { + if (refreshGeneration.current !== generation) return; + if (response.status === 401) { + releaseLocalControl(); + setSession({ authenticated: false, control: "available" }); + } else if (response.status === 409 || response.status === 423) { + releaseLocalControl(); + setSession((current) => + current === null ? current : { ...current, control: "observer" }, + ); + } else if (!response.ok) { + releaseLocalControl(); + setSession((current) => + current === null ? current : { ...current, control: "observer" }, + ); + setError(`Scanner control heartbeat failed (${response.status}).`); + } else { + setError(null); + } + }) + .catch(() => { + if (refreshGeneration.current !== generation) return; + releaseLocalControl(); + setSession((current) => + current === null ? current : { ...current, control: "observer" }, + ); + setError("The scanner server could not be reached; control was released locally."); + }); + }, 10_000); + const release = (): void => { + const headers = controlLeaseHeaders(); + void fetch("/api/v1/control/release", { + method: "POST", + credentials: "same-origin", + headers, + keepalive: true, + }); + releaseLocalControl(); + }; + window.addEventListener("pagehide", release); + return () => { + window.clearInterval(heartbeat); + window.removeEventListener("pagehide", release); + releaseLocalControl(); + }; + }, [releaseLocalControl, session?.control, tauri]); + + if (tauri) return children; + + const logIn = async (event: FormEvent): Promise => { + event.preventDefault(); + if (token.length === 0 || busy) return; + setBusy(true); + setError(null); + try { + const generation = ++refreshGeneration.current; + releaseLocalControl(); + const response = await post("/api/v1/session/login", { token }); + if (!response.ok) { + setError(response.status === 401 ? "That access token was not accepted." : `Login failed (${response.status}).`); + return; + } + setToken(""); + const next = await readSession(); + if (refreshGeneration.current === generation) { + commitSession(next); + } + } catch { + setError("The ScanStudio server could not be reached."); + } finally { + setBusy(false); + } + }; + + if (session === null) { + return ( +
+
+ +
+ ); + } + + if (!session.authenticated) { + return ( +
+
void logIn(event)}> +
+ ); + } + + return ( + +
+
+
+
+ {!eventStream.ready ? ( + + {eventStream.message ?? "Reconnecting to scanner events…"} + + ) : session.control === "owned" ? ( + This browser has scanner control + ) : ( +
+ Viewing only — another browser has control + +
+ )} +
+ {error !== null && ( +

+ {error} +

+ )} +
{children}
+
+
+ ); +} diff --git a/ports/tauri/app/src/__tests__/App.test.tsx b/ports/tauri/app/src/__tests__/App.test.tsx index ea30777..114a4c8 100644 --- a/ports/tauri/app/src/__tests__/App.test.tsx +++ b/ports/tauri/app/src/__tests__/App.test.tsx @@ -17,6 +17,10 @@ afterEach(() => { const mocks = vi.hoisted(() => ({ sessionStore: null as unknown, invoke: vi.fn() })); vi.mock("../session", () => mocks); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("../runtime", () => ({ + isTauriRuntime: () => true, + isWebSimulatorPreview: () => false, +})); const PROJECT: ScanProject = { schemaVersion: 4, diff --git a/ports/tauri/app/src/__tests__/App.web.test.tsx b/ports/tauri/app/src/__tests__/App.web.test.tsx new file mode 100644 index 0000000..67e6e19 --- /dev/null +++ b/ports/tauri/app/src/__tests__/App.web.test.tsx @@ -0,0 +1,146 @@ +/** @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import App from "../App"; +import { ScannerControlProvider } from "../scannerControl"; +import { SessionStore } from "../session/store/session"; +import { createScriptedTransport } from "../session/testing/harness"; +import type { DeviceInfo, ScannerStatus } from "../session/wire/types"; + +const mocks = vi.hoisted(() => ({ sessionStore: null as unknown, invoke: vi.fn() })); +vi.mock("../session", () => mocks); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("../runtime", () => ({ + isTauriRuntime: () => false, + isWebSimulatorPreview: () => true, +})); + +const SIMULATOR: DeviceInfo = { + deviceId: "sim-ls5000-0", + model: "LS-5000 (simulated)", + kind: "simulated", + firmware: "sim-fw-1", + connection: "virtual", +}; + +const EMPTY_STATUS: ScannerStatus = { + connected: true, + adapter: null, + mediaLoaded: false, + carrier: null, + frameCount: null, + lamp: "stable", + transport: "idle", + activeJobId: null, +}; + +const LOADED_STATUS: ScannerStatus = { + ...EMPTY_STATUS, + mediaLoaded: true, + carrier: "strip6", + frameCount: 6, +}; + +function webFixture() { + const calls: string[] = []; + const handle = createScriptedTransport({ + onRequest: (method) => { + calls.push(method); + if (method === "scanner.list") return { result: { devices: [SIMULATOR] } }; + if (method === "scanner.connect") { + return { result: { device: SIMULATOR, status: EMPTY_STATUS } }; + } + if (method === "sim.loadMedia") return { result: LOADED_STATUS }; + return { result: undefined }; + }, + }); + return { store: new SessionStore(handle.transport), handle, calls }; +} + +afterEach(() => { + cleanup(); + mocks.invoke.mockReset(); + vi.restoreAllMocks(); +}); + +describe("App simulator web controls", () => { + it("keeps observer-safe device discovery visible while disabling Connect", async () => { + const fixture = webFixture(); + mocks.sessionStore = fixture.store; + + render( + + + , + ); + + expect(await screen.findByText(SIMULATOR.model)).toBeVisible(); + expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + }); + + it("disables lease-protected simulator actions and omits unsupported routes", async () => { + const fixture = webFixture(); + await fixture.store.connect(SIMULATOR.deviceId); + mocks.sessionStore = fixture.store; + + render( + + + , + ); + + expect(await screen.findByRole("button", { name: "Disconnect" })).toBeDisabled(); + for (const carrier of ["roll36", "strip6", "mounted"]) { + expect(screen.getByRole("button", { name: carrier })).toBeDisabled(); + } + + await act(async () => { + await fixture.store.loadMedia("strip6"); + }); + act(() => fixture.store.toggleFrameSelection(1, false)); + + expect(screen.getByTestId("preview-button")).toBeDisabled(); + expect(screen.queryByTestId("capture-action")).toBeNull(); + expect(screen.queryByTestId("inspect-action")).toBeNull(); + expect( + fixture.calls.some((method) => + [ + "exiftool.detect", + "project.previewMetadataCommand", + "project.analyzeFrameDefects", + "roll.approve", + "roll.setSpacingOffset", + ].includes(method), + ), + ).toBe(false); + }); + + it("does not mount Tauri-only diagnostic report actions for web errors", async () => { + const fixture = webFixture(); + await fixture.store.connect(SIMULATOR.deviceId); + await fixture.store.acquireThumbnails(); + const operationId = fixture.store.getState().activeOperationId; + expect(operationId).not.toBeNull(); + fixture.handle.emitEvent({ + event: "scanner.thumbnailsFailed", + payload: { + code: "BRIDGE_STREAM_STALLED", + message: "preview stream stalled", + operationId, + }, + }); + mocks.sessionStore = fixture.store; + + render( + + + , + ); + + expect(await screen.findByTestId("preview-failed-message")).toHaveTextContent( + "preview stream stalled", + ); + expect(screen.queryByTestId("diagnostic-report-actions")).toBeNull(); + }); +}); diff --git a/ports/tauri/app/src/__tests__/WebRuntimeGate.test.tsx b/ports/tauri/app/src/__tests__/WebRuntimeGate.test.tsx new file mode 100644 index 0000000..b73a7b7 --- /dev/null +++ b/ports/tauri/app/src/__tests__/WebRuntimeGate.test.tsx @@ -0,0 +1,341 @@ +/** @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import WebRuntimeGate from "../WebRuntimeGate"; +import { clearControlLeaseToken, getControlLeaseToken } from "../controlLease"; +import { WEB_EVENT_STREAM_STATE_EVENT } from "../engine/client"; +import { useScannerControl } from "../scannerControl"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function ControlProbe() { + return
Control: {useScannerControl() ? "owned" : "observer"}
; +} + +function markEventStreamReady(): void { + act(() => { + window.dispatchEvent( + new CustomEvent(WEB_EVENT_STREAM_STATE_EVENT, { + detail: { ready: true, message: null }, + }), + ); + }); +} + +function installFakeLocks(initiallyHeld = false): { + request: ReturnType; + isHeld: () => boolean; +} { + let held = initiallyHeld; + const request = vi.fn( + async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => Promise | unknown, + ): Promise => { + if (options.ifAvailable === true && held) return callback(null); + held = true; + try { + return await callback({ name, mode: "exclusive" } as Lock); + } finally { + held = false; + } + }, + ); + Object.defineProperty(navigator, "locks", { + configurable: true, + value: { request } as unknown as LockManager, + }); + return { request, isHeld: () => held }; +} + +afterEach(() => { + cleanup(); + clearControlLeaseToken(); + window.sessionStorage.clear(); + Reflect.deleteProperty(navigator, "locks"); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("WebRuntimeGate", () => { + it("logs in, claims a tab-scoped control lease, and opens the app", async () => { + const locks = installFakeLocks(); + let authenticated = false; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path === "/api/v1/session" && init?.method !== "POST") { + return jsonResponse({ authenticated, control: "available" }); + } + if (path === "/api/v1/session/login") { + authenticated = true; + return jsonResponse({ authenticated: true }); + } + if (path === "/api/v1/control/claim") { + return jsonResponse({ leaseToken: "tab-lease", expiresInSeconds: 30 }); + } + throw new Error(`unexpected request ${path}`); + }); + vi.stubGlobal("fetch", fetchMock); + const user = userEvent.setup(); + + const { unmount } = render( + +
+ Scanner workspace + +
+
, + ); + + const token = await screen.findByLabelText("Access token"); + await user.type(token, "local-secret"); + await user.click(screen.getByRole("button", { name: "Open ScanStudio" })); + + expect(await screen.findByText("Scanner workspace")).toBeVisible(); + markEventStreamReady(); + expect(await screen.findByText("This browser has scanner control")).toBeVisible(); + expect(screen.getByText("Control: owned")).toBeVisible(); + expect(getControlLeaseToken()).toBe("tab-lease"); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/session/login", + expect.objectContaining({ body: JSON.stringify({ token: "local-secret" }) }), + ); + expect(locks.isHeld()).toBe(true); + unmount(); + await waitFor(() => expect(locks.isHeld()).toBe(false)); + expect(getControlLeaseToken()).toBeNull(); + }); + + it("keeps a second no-Locks page observing when the server lease is already owned", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/v1/session") { + return jsonResponse({ authenticated: true, control: "available" }); + } + if (path === "/api/v1/control/claim") { + return jsonResponse({ error: { code: "CONTROL_LOCKED" } }, 409); + } + throw new Error(`unexpected request ${path}`); + }), + ); + + render( + +
+ Scanner workspace + +
+
, + ); + + expect(await screen.findByText("Scanner workspace")).toBeVisible(); + markEventStreamReady(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Try to take control" })).toBeVisible(); + }); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + expect(getControlLeaseToken()).toBeNull(); + expect(screen.getByText("Control: observer")).toBeVisible(); + }); + + it("does not let a stale session refresh erase a newly claimed lease", async () => { + installFakeLocks(); + let sessionReads = 0; + let resolveStaleRefresh: ((response: Response) => void) | null = null; + let runPeriodicRefresh: (() => void) | null = null; + vi.spyOn(window, "setInterval").mockImplementation((handler, timeout) => { + if (timeout === 60_000 && typeof handler === "function") { + runPeriodicRefresh = handler as () => void; + } + return setTimeout(() => undefined, 0); + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/v1/session") { + sessionReads += 1; + if (sessionReads === 1) { + return jsonResponse({ authenticated: true, control: "observer" }); + } + return new Promise((resolve) => { + resolveStaleRefresh = resolve; + }); + } + if (path === "/api/v1/control/claim") { + return jsonResponse({ leaseToken: "new-tab-lease", expiresInSeconds: 30 }); + } + throw new Error(`unexpected request ${path}`); + }), + ); + + render( + + + , + ); + markEventStreamReady(); + expect(await screen.findByRole("button", { name: "Try to take control" })).toBeVisible(); + + act(() => runPeriodicRefresh?.()); + await waitFor(() => expect(sessionReads).toBe(2)); + fireEvent.click(screen.getByRole("button", { name: "Try to take control" })); + expect(await screen.findByText("Control: owned")).toBeVisible(); + expect(getControlLeaseToken()).toBe("new-tab-lease"); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + + await act(async () => { + resolveStaleRefresh?.(jsonResponse({ authenticated: true, control: "observer" })); + await Promise.resolve(); + }); + expect(screen.getByText("Control: owned")).toBeVisible(); + expect(getControlLeaseToken()).toBe("new-tab-lease"); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + }); + + it("omits a duplicated tab's copied lease and lets the server reject its claim", async () => { + window.sessionStorage.setItem("scanstudio.control-lease", "copied-tab-lease"); + const locks = installFakeLocks(true); + let sessionHeaders: HeadersInit | undefined; + let claimHeaders: HeadersInit | undefined; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path === "/api/v1/session") { + sessionHeaders = init?.headers; + return jsonResponse({ authenticated: true, control: "observer" }); + } + if (path === "/api/v1/control/claim") { + claimHeaders = init?.headers; + return jsonResponse({ error: { code: "CONTROL_LOCKED" } }, 409); + } + throw new Error(`unexpected request ${path}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + markEventStreamReady(); + expect(await screen.findByRole("button", { name: "Try to take control" })).toBeVisible(); + expect(sessionHeaders).toEqual({}); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + expect(getControlLeaseToken()).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Try to take control" })); + await waitFor(() => { + expect( + fetchMock.mock.calls.some(([input]) => String(input) === "/api/v1/control/claim"), + ).toBe(true); + }); + expect(locks.request).toHaveBeenCalledWith( + "scanstudio-controller-tab", + expect.objectContaining({ ifAvailable: true, mode: "exclusive" }), + expect.any(Function), + ); + expect(claimHeaders).toEqual({}); + expect(screen.getByText("Control: observer")).toBeVisible(); + }); + + it("can reclaim an expired server lease while another page still holds the advisory lock", async () => { + const locks = installFakeLocks(true); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/v1/session") { + return jsonResponse({ authenticated: true, control: "observer" }); + } + if (path === "/api/v1/control/claim") { + return jsonResponse({ leaseToken: "replacement-lease", expiresInSeconds: 30 }); + } + throw new Error(`unexpected request ${path}`); + }), + ); + + render( + + + , + ); + markEventStreamReady(); + fireEvent.click(await screen.findByRole("button", { name: "Try to take control" })); + + expect(await screen.findByText("Control: owned")).toBeVisible(); + expect(locks.request).toHaveBeenCalled(); + expect(locks.isHeld()).toBe(true); + expect(getControlLeaseToken()).toBe("replacement-lease"); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + }); + + it("claims control with a page-scoped in-memory lease when Web Locks is unavailable", async () => { + Reflect.deleteProperty(navigator, "locks"); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/v1/session") { + return jsonResponse({ authenticated: true, control: "available" }); + } + if (path === "/api/v1/control/claim") { + return jsonResponse({ leaseToken: "insecure-context-lease", expiresInSeconds: 30 }); + } + throw new Error(`unexpected request ${path}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + markEventStreamReady(); + + expect(await screen.findByText("Control: owned")).toBeVisible(); + expect( + fetchMock.mock.calls.some(([input]) => String(input) === "/api/v1/control/claim"), + ).toBe(true); + expect(window.sessionStorage.getItem("scanstudio.control-lease")).toBeNull(); + expect(getControlLeaseToken()).toBe("insecure-context-lease"); + }); + + it("releases its browser lock when a successful claim has malformed JSON", async () => { + const locks = installFakeLocks(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/v1/session") { + return jsonResponse({ authenticated: true, control: "available" }); + } + if (path === "/api/v1/control/claim") { + return new Response("not JSON", { status: 200 }); + } + throw new Error(`unexpected request ${path}`); + }), + ); + + render( + + + , + ); + + expect( + await screen.findByText("The scanner server returned an unreadable control lease."), + ).toBeVisible(); + await waitFor(() => expect(locks.isHeld()).toBe(false)); + expect(getControlLeaseToken()).toBeNull(); + expect(screen.getByText("Control: observer")).toBeVisible(); + }); +}); diff --git a/ports/tauri/app/src/controlLease.ts b/ports/tauri/app/src/controlLease.ts new file mode 100644 index 0000000..cddbcb7 --- /dev/null +++ b/ports/tauri/app/src/controlLease.ts @@ -0,0 +1,97 @@ +const CONTROL_LEASE_KEY = "scanstudio.control-lease"; +export const CONTROL_LEASE_HEADER = "X-ScanStudio-Control-Lease"; +export const CONTROL_TAB_LOCK_NAME = "scanstudio-controller-tab"; +let activeControlLeaseToken: string | null = null; + +export interface HeldControlTabLock { + mechanism: "web-lock" | "page"; + release(): void; +} + +export function getControlLeaseToken(): string | null { + return activeControlLeaseToken; +} + +export function setControlLeaseToken(token: string): void { + activeControlLeaseToken = token; +} + +export function clearControlLeaseToken(): void { + activeControlLeaseToken = null; + if (typeof window === "undefined") return; + try { + // Purge tokens written by older builds. sessionStorage is copied when a + // tab is duplicated and is therefore never an ownership authority. + window.sessionStorage.removeItem(CONTROL_LEASE_KEY); + } catch { + // The authoritative module-memory token was already cleared. + } +} + +export function controlLeaseHeaders(): Record { + const token = getControlLeaseToken(); + return token === null ? {} : { [CONTROL_LEASE_HEADER]: token }; +} + +/** + * Adds an advisory browser-local ownership guard. Web Locks are scoped to the + * current origin and are not copied when a tab is duplicated, but a busy or + * unavailable lock falls back to a page guard so it cannot wedge takeover after + * the server lease expires. The server's atomic lease remains authoritative. + */ +export async function acquireControlTabLock(): Promise { + const pageGuard = (): HeldControlTabLock => ({ + mechanism: "page", + release(): void { + // Module memory dies with this page; the server lease remains atomic. + }, + }); + + if (typeof navigator === "undefined") return pageGuard(); + const lockManager = Reflect.get(navigator, "locks") as LockManager | undefined; + if (lockManager === undefined || typeof lockManager.request !== "function") { + return pageGuard(); + } + + return new Promise((resolve) => { + let resultSettled = false; + let releaseHold = (): void => undefined; + const hold = new Promise((release) => { + releaseHold = release; + }); + const settle = (result: HeldControlTabLock): void => { + if (resultSettled) return; + resultSettled = true; + resolve(result); + }; + + try { + void lockManager + .request( + CONTROL_TAB_LOCK_NAME, + { ifAvailable: true, mode: "exclusive" }, + async (lock) => { + if (lock === null) { + // Advisory only: a frozen old tab may outlive the server lease. + // Let the gateway's atomic claim decide whether takeover is live. + settle(pageGuard()); + return; + } + let released = false; + settle({ + mechanism: "web-lock", + release(): void { + if (released) return; + released = true; + releaseHold(); + }, + }); + await hold; + }, + ) + .catch(() => settle(pageGuard())); + } catch { + settle(pageGuard()); + } + }); +} diff --git a/ports/tauri/app/src/engine/__tests__/client.web.test.ts b/ports/tauri/app/src/engine/__tests__/client.web.test.ts new file mode 100644 index 0000000..dbe64ce --- /dev/null +++ b/ports/tauri/app/src/engine/__tests__/client.web.test.ts @@ -0,0 +1,470 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { engineRequest, notifyWebSessionReady, onEngineEvent } from "../client"; +import { clearControlLeaseToken, setControlLeaseToken } from "../../controlLease"; +import { SessionStore } from "../../session/store/session"; +import type { EngineTransport } from "../../session/wire/codec"; + +afterEach(() => { + clearControlLeaseToken(); + window.sessionStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("browser engine client", () => { + it("forwards a request through the same-origin gateway and unwraps its result", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { devices: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(engineRequest("scanner.list", {})).resolves.toEqual({ devices: [] }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/engine/request", + expect.objectContaining({ + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "scanner.list", params: {} }), + }), + ); + }); + + it("preserves a typed engine error from the gateway", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "SCANNER_BUSY", + message: "a preview is active", + recoverable: false, + }, + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + await expect(engineRequest("scanner.disconnect", {})).rejects.toEqual({ + code: "SCANNER_BUSY", + message: "a preview is active", + recoverable: false, + }); + }); + + it("sends the tab-scoped controller lease with engine requests", async () => { + setControlLeaseToken("lease-for-this-tab"); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await engineRequest("scanner.connect", { deviceId: "sim-ls5000-0" }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/engine/request", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + "X-ScanStudio-Control-Lease": "lease-for-this-tab", + }, + }), + ); + }); + + it("never presents a controller lease copied through sessionStorage", async () => { + window.sessionStorage.setItem("scanstudio.control-lease", "copied-tab-lease"); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await engineRequest("scanner.connect", { deviceId: "sim-ls5000-0" }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/engine/request", + expect.objectContaining({ + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + it("delivers WebSocket event envelopes and closes cleanly", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "NOT_CONNECTED", + message: "no scanner is connected", + recoverable: true, + }, + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + class FakeWebSocket { + static instance: FakeWebSocket | null = null; + listeners = new Map void>>(); + close = vi.fn(); + + constructor(readonly url: string) { + FakeWebSocket.instance = this; + } + + addEventListener(name: string, listener: (event: { data?: unknown }) => void): void { + const current = this.listeners.get(name) ?? []; + current.push(listener); + this.listeners.set(name, current); + } + + emit(name: string, event: { data?: unknown } = {}): void { + for (const listener of this.listeners.get(name) ?? []) listener(event); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + const handler = vi.fn(); + + const unlisten = await onEngineEvent(handler); + notifyWebSessionReady(); + FakeWebSocket.instance?.emit("open"); + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith({ + event: "scanstudio.webEventStream", + payload: { state: "ready", engineConnected: false }, + }); + }); + FakeWebSocket.instance?.emit("message", { + data: JSON.stringify({ event: "scanner.thumbnailsComplete", payload: { count: 6 } }), + }); + + expect(handler).toHaveBeenCalledWith({ + event: "scanner.thumbnailsComplete", + payload: { count: 6 }, + }); + unlisten(); + expect(FakeWebSocket.instance?.close).toHaveBeenCalledWith(1000, "client closed"); + }); + + it("reconciles scanner status on open and reports a dropped event stream", async () => { + const device = { + deviceId: "sim-ls5000-0", + model: "SUPER COOLSCAN 5000 ED", + kind: "simulated", + firmware: "1.03-sim", + connection: "USB (simulated)", + supported: true, + }; + const status = { + connected: true, + adapter: null, + mediaLoaded: true, + carrier: "strip6", + frameCount: 6, + lamp: "stable", + transport: "idle", + activeJobId: null, + }; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as { method: string }; + const result = request.method === "scanner.list" ? { devices: [device] } : status; + return new Response(JSON.stringify({ result }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + })); + + class FakeWebSocket { + static instance: FakeWebSocket | null = null; + listeners = new Map void>>(); + close = vi.fn(); + + constructor(readonly url: string) { + FakeWebSocket.instance = this; + } + + addEventListener( + name: string, + listener: (event: { code?: number; data?: unknown }) => void, + ): void { + const current = this.listeners.get(name) ?? []; + current.push(listener); + this.listeners.set(name, current); + } + + emit(name: string, event: { code?: number; data?: unknown } = {}): void { + for (const listener of this.listeners.get(name) ?? []) listener(event); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + const handler = vi.fn(); + + const unlisten = await onEngineEvent(handler); + notifyWebSessionReady(); + FakeWebSocket.instance?.emit("open"); + + await vi.waitFor(() => { + expect(handler).toHaveBeenCalledWith({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device, + status, + }, + }); + }); + + FakeWebSocket.instance?.emit("close", { code: 1006 }); + expect(handler).toHaveBeenLastCalledWith({ + event: "scanstudio.webEventStream", + payload: { state: "disconnected" }, + }); + unlisten(); + }); + + it("commits the reconnect snapshot before replaying live events received during hydration", async () => { + const device = { + deviceId: "sim-ls5000-0", + model: "SUPER COOLSCAN 5000 ED", + kind: "simulated", + firmware: "1.03-sim", + connection: "USB (simulated)", + supported: true, + }; + const snapshotStatus = { + connected: true, + adapter: null, + mediaLoaded: true, + carrier: "roll36", + frameCount: 36, + lamp: "stable", + transport: "idle", + activeJobId: null, + }; + const liveStatus = { + ...snapshotStatus, + carrier: "strip6", + frameCount: 6, + }; + let resolveStatus!: (response: Response) => void; + const pendingStatus = new Promise((resolve) => { + resolveStatus = resolve; + }); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as { method: string }; + if (request.method === "scanner.list") { + return new Response(JSON.stringify({ result: { devices: [device] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return pendingStatus; + }); + vi.stubGlobal("fetch", fetchMock); + + class FakeWebSocket { + static instance: FakeWebSocket | null = null; + listeners = new Map void>>(); + close = vi.fn(); + + constructor(readonly url: string) { + FakeWebSocket.instance = this; + } + + addEventListener( + name: string, + listener: (event: { code?: number; data?: unknown }) => void, + ): void { + const current = this.listeners.get(name) ?? []; + current.push(listener); + this.listeners.set(name, current); + } + + emit(name: string, event: { code?: number; data?: unknown } = {}): void { + for (const listener of this.listeners.get(name) ?? []) listener(event); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + const handler = vi.fn(); + + const unlisten = await onEngineEvent(handler); + notifyWebSessionReady(); + FakeWebSocket.instance?.emit("open"); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + const liveEvent = { event: "scanner.status", payload: { status: liveStatus } }; + FakeWebSocket.instance?.emit("message", { data: JSON.stringify(liveEvent) }); + expect(handler).not.toHaveBeenCalledWith(liveEvent); + + resolveStatus( + new Response(JSON.stringify({ result: snapshotStatus }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(4)); + expect(handler).toHaveBeenNthCalledWith(2, { + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device, + status: snapshotStatus, + }, + }); + expect(handler).toHaveBeenNthCalledWith(3, liveEvent); + expect(handler).toHaveBeenNthCalledWith(4, { + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device, + status: liveStatus, + }, + }); + unlisten(); + }); + + it("reconciles singleton observer stores after another tab connects and disconnects", async () => { + const device = { + deviceId: "sim-ls5000-0", + model: "SUPER COOLSCAN 5000 ED", + kind: "simulated" as const, + firmware: "1.03-sim", + connection: "USB (simulated)", + supported: true, + }; + const connectedStatus = { + connected: true, + adapter: null, + mediaLoaded: false, + carrier: null, + frameCount: null, + lamp: "stable" as const, + transport: "idle" as const, + activeJobId: null, + }; + const disconnectedStatus = { + ...connectedStatus, + connected: false, + lamp: "off" as const, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as { method: string }; + if (request.method === "scanner.list") { + return new Response(JSON.stringify({ result: { devices: [device] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ + error: { + code: "NOT_CONNECTED", + message: "no scanner is connected", + recoverable: true, + }, + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ); + }), + ); + + class FakeWebSocket { + static instance: FakeWebSocket | null = null; + listeners = new Map void>>(); + close = vi.fn(); + + constructor(readonly url: string) { + FakeWebSocket.instance = this; + } + + addEventListener( + name: string, + listener: (event: { code?: number; data?: unknown }) => void, + ): void { + const current = this.listeners.get(name) ?? []; + current.push(listener); + this.listeners.set(name, current); + } + + emit(name: string, event: { code?: number; data?: unknown } = {}): void { + for (const listener of this.listeners.get(name) ?? []) listener(event); + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + + const subscribers = new Set<(raw: unknown) => void>(); + const transport: EngineTransport = { + async sendRequest(method: string): Promise { + if (method === "scanner.connect") return { device, status: connectedStatus }; + if (method === "scanner.disconnect") return {}; + return undefined; + }, + subscribeEvents(callback): () => void { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }; + const controller = new SessionStore(transport); + const observer = new SessionStore(transport); + const delivered: unknown[] = []; + const unlisten = await onEngineEvent((raw) => { + delivered.push(raw); + for (const subscriber of [...subscribers]) subscriber(raw); + }); + notifyWebSessionReady(); + FakeWebSocket.instance?.emit("open"); + await vi.waitFor(() => { + expect(delivered).toContainEqual({ + event: "scanstudio.webEventStream", + payload: { state: "ready", engineConnected: false }, + }); + }); + + await controller.connect(device.deviceId); + expect(controller.getState().connection.device).toEqual(device); + expect(observer.getState().connection.device).toBeNull(); + + FakeWebSocket.instance?.emit("message", { + data: JSON.stringify({ event: "scanner.status", payload: { status: connectedStatus } }), + }); + expect(observer.getState().connection).toEqual({ + connected: true, + device, + status: connectedStatus, + }); + + await controller.disconnect(); + expect(controller.getState().connection.connected).toBe(false); + expect(observer.getState().connection.connected).toBe(true); + + FakeWebSocket.instance?.emit("message", { + data: JSON.stringify({ event: "scanner.status", payload: { status: disconnectedStatus } }), + }); + expect(observer.getState().connection).toEqual({ + connected: false, + device: null, + status: null, + }); + unlisten(); + }); +}); diff --git a/ports/tauri/app/src/engine/client.ts b/ports/tauri/app/src/engine/client.ts index b97eb6e..a2c0146 100644 --- a/ports/tauri/app/src/engine/client.ts +++ b/ports/tauri/app/src/engine/client.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { isTauriRuntime } from "../runtime"; +import { controlLeaseHeaders } from "../controlLease"; export interface EngineError { code: string; @@ -7,17 +7,281 @@ export interface EngineError { recoverable: boolean; } +export type UnlistenFn = () => void; + +const WEB_REQUEST_ENDPOINT = "/api/v1/engine/request"; +const WEB_EVENT_ENDPOINT = "/api/v1/engine/events"; +const WEB_SESSION_READY_EVENT = "scanstudio:web-session-ready"; +export const WEB_EVENT_STREAM_STATE_EVENT = "scanstudio:web-event-stream-state"; + +export interface WebEventStreamState { + ready: boolean; + message: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asEngineError(value: unknown, fallback: string): EngineError { + if ( + typeof value === "object" && + value !== null && + "code" in value && + typeof value.code === "string" && + "message" in value && + typeof value.message === "string" + ) { + return { + code: value.code, + message: value.message, + recoverable: + "recoverable" in value && typeof value.recoverable === "boolean" + ? value.recoverable + : false, + }; + } + return { code: "INTERNAL", message: fallback, recoverable: false }; +} + +async function webRequest(method: string, params: unknown): Promise { + let response: Response; + try { + response = await fetch(WEB_REQUEST_ENDPOINT, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", ...controlLeaseHeaders() }, + body: JSON.stringify({ method, params }), + }); + } catch (error) { + throw asEngineError( + error, + "The ScanStudio server could not be reached. Check the server and try again.", + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw asEngineError( + null, + `The ScanStudio server returned an unreadable response (${response.status}).`, + ); + } + + if ( + typeof payload === "object" && + payload !== null && + "error" in payload + ) { + throw asEngineError( + payload.error, + `The engine request failed (${response.status}).`, + ); + } + if (!response.ok) { + throw asEngineError( + payload, + `The ScanStudio server refused the request (${response.status}).`, + ); + } + if ( + typeof payload !== "object" || + payload === null || + !("result" in payload) + ) { + throw asEngineError(null, "The ScanStudio server response did not contain a result."); + } + return payload.result as T; +} + +function webSocketUrl(): string { + const url = new URL(WEB_EVENT_ENDPOINT, window.location.href); + url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function publishWebEventStreamState(ready: boolean, message: string | null = null): void { + window.dispatchEvent( + new CustomEvent(WEB_EVENT_STREAM_STATE_EVENT, { + detail: { ready, message }, + }), + ); +} + +function listenToWebEvents(handler: (payload: unknown) => void): UnlistenFn { + let socket: WebSocket | null = null; + let retryTimer: number | null = null; + let stopped = false; + let retryDelayMs = 500; + let singletonDevice: unknown = null; + + const deliver = (payload: unknown): void => { + handler(payload); + if ( + !isRecord(payload) || + payload.event !== "scanner.status" || + !isRecord(payload.payload) || + !isRecord(payload.payload.status) || + typeof payload.payload.status.connected !== "boolean" + ) { + return; + } + const status = payload.payload.status; + if (status.connected === false) { + handler({ + event: "scanstudio.webEventStream", + payload: { state: "ready", engineConnected: false }, + }); + } else if (singletonDevice !== null) { + handler({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device: singletonDevice, + status, + }, + }); + } + }; + + const markDisconnected = (): void => { + handler({ + event: "scanstudio.webEventStream", + payload: { state: "disconnected" }, + }); + publishWebEventStreamState( + false, + "Reconnecting to the scanner event stream…", + ); + }; + + const connect = (): void => { + if (stopped || socket !== null) return; + const candidate = new WebSocket(webSocketUrl()); + const pendingEvents: unknown[] = []; + let hydrating = true; + socket = candidate; + const commitHydration = (snapshot: unknown): void => { + if (stopped || socket !== candidate) return; + handler(snapshot); + if (stopped || socket !== candidate) return; + hydrating = false; + for (const pending of pendingEvents.splice(0)) deliver(pending); + publishWebEventStreamState(true); + }; + candidate.addEventListener("open", () => { + retryDelayMs = 500; + void (async () => { + try { + const listed = await webRequest<{ devices?: unknown }>("scanner.list", {}); + if (!Array.isArray(listed.devices) || listed.devices.length !== 1) { + throw new Error("The scanner inventory could not be restored."); + } + singletonDevice = listed.devices[0]; + const status = await webRequest("scanner.status", {}); + if (stopped || socket !== candidate) return; + commitHydration({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device: listed.devices[0], + status, + }, + }); + } catch (error) { + if (stopped || socket !== candidate) return; + const engineError = asEngineError(error, "The scanner state could not be restored."); + if (engineError.code === "NOT_CONNECTED") { + commitHydration({ + event: "scanstudio.webEventStream", + payload: { state: "ready", engineConnected: false }, + }); + return; + } + publishWebEventStreamState( + false, + "Scanner state could not be restored; reconnecting…", + ); + candidate.close(1011, "state reconciliation failed"); + } + })(); + }); + candidate.addEventListener("message", (event) => { + let payload: unknown; + try { + payload = JSON.parse(String(event.data)); + } catch { + payload = event.data; + } + if (hydrating) pendingEvents.push(payload); + else deliver(payload); + }); + candidate.addEventListener("close", (event) => { + if (socket !== candidate) return; + socket = null; + if (stopped) return; + markDisconnected(); + if (event.code === 4401 || event.code === 4403) return; + retryTimer = window.setTimeout(connect, retryDelayMs); + retryDelayMs = Math.min(retryDelayMs * 2, 10_000); + }); + }; + + const sessionReady = (): void => { + if (retryTimer !== null) { + window.clearTimeout(retryTimer); + retryTimer = null; + } + retryDelayMs = 500; + connect(); + }; + markDisconnected(); + window.addEventListener(WEB_SESSION_READY_EVENT, sessionReady); + return () => { + stopped = true; + window.removeEventListener(WEB_SESSION_READY_EVENT, sessionReady); + if (retryTimer !== null) window.clearTimeout(retryTimer); + socket?.close(1000, "client closed"); + socket = null; + }; +} + +export function notifyWebSessionReady(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(WEB_SESSION_READY_EVENT)); + } +} + export async function engineRequest( method: string, params: unknown = {}, ): Promise { + if (!isTauriRuntime()) return webRequest(method, params); + const { invoke } = await import("@tauri-apps/api/core"); return invoke("engine_request", { method, params }); } export async function engineState(): Promise<{ running: boolean; pid: number | null }> { + if (!isTauriRuntime()) { + const response = await fetch("/healthz", { credentials: "same-origin" }); + if (!response.ok) return { running: false, pid: null }; + const payload = (await response.json()) as { engine?: { running?: boolean; pid?: number | null } }; + return { + running: payload.engine?.running === true, + pid: payload.engine?.pid ?? null, + }; + } + const { invoke } = await import("@tauri-apps/api/core"); return invoke("engine_state"); } export function onEngineEvent(handler: (payload: unknown) => void): Promise { - return listen("engine://event", (e) => handler(e.payload)); + if (!isTauriRuntime()) return Promise.resolve(listenToWebEvents(handler)); + return import("@tauri-apps/api/event").then(({ listen }) => + listen("engine://event", (event) => handler(event.payload)), + ); } diff --git a/ports/tauri/app/src/global.css b/ports/tauri/app/src/global.css new file mode 100644 index 0000000..c4358e4 --- /dev/null +++ b/ports/tauri/app/src/global.css @@ -0,0 +1,81 @@ +:root { + /* Exact web mappings of ScanStudioTheme.swift's incumbent visual tokens. */ + --scan-workspace: rgb(7.8% 8.6% 9.4%); /* #141618 at 8-bit display depth */ + --scan-sidebar: rgb(11% 12.2% 13.3%); /* #1c1f22 */ + --scan-inspector: rgb(11% 12.2% 13.3%); /* #1c1f22 */ + --scan-raised: rgb(14.1% 15.3% 16.9%); /* #24272b */ + --scan-divider: rgb(255 255 255 / 10%); + --scan-primary-text: rgb(93.3% 94.5% 94.9%); /* #eef1f2 */ + --scan-secondary-text: rgb(60.4% 63.9% 65.9%); /* #9aa3a8 */ + --scan-amber: rgb(91% 63.9% 23.9%); /* #e8a33d */ + --scan-cyan: rgb(31% 78.8% 85.1%); /* #4fc9d9 */ + --scan-red: rgb(83.9% 27.1% 27.1%); /* #d64545 */ + --scan-green: rgb(24.7% 74.9% 43.5%); /* #3fbf6f */ + --scan-row-label: rgb(255 255 255 / 70%); + --scan-section-label: rgb(255 255 255 / 55%); + + /* Native component treatments: tinted tags, hairlines, and dark media wells. */ + --scan-border-emphasis: rgb(255 255 255 / 14%); + --scan-control-hover: rgb(255 255 255 / 7%); + --scan-thumbnail-well: rgb(0 0 0 / 34%); + --scan-overlay: rgb(0 0 0 / 68%); + --scan-amber-fill: rgb(91% 63.9% 23.9% / 18%); + --scan-amber-border: rgb(91% 63.9% 23.9% / 50%); + --scan-cyan-fill: rgb(31% 78.8% 85.1% / 18%); + --scan-cyan-border: rgb(31% 78.8% 85.1% / 50%); + --scan-red-fill: rgb(83.9% 27.1% 27.1% / 18%); + --scan-red-border: rgb(83.9% 27.1% 27.1% / 50%); + --scan-green-fill: rgb(24.7% 74.9% 43.5% / 18%); + --scan-green-border: rgb(24.7% 74.9% 43.5% / 50%); + --scan-card-radius: 9px; + --scan-thumbnail-radius: 6px; + --scan-control-radius: 4px; + + color-scheme: dark; + color: var(--scan-primary-text); + background: var(--scan-workspace); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + min-width: 20rem; + height: 100%; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: rgb(79 201 217 / 18%); +} + +:where(button, a, input, select, textarea):focus-visible { + outline-color: var(--scan-cyan); + outline-offset: 2px; +} + +@media (pointer: coarse) { + button, + select, + input:not([type="checkbox"]):not([type="radio"]) { + min-height: 2.75rem; + } +} diff --git a/ports/tauri/app/src/main.tsx b/ports/tauri/app/src/main.tsx index 2be325e..5f7aa08 100644 --- a/ports/tauri/app/src/main.tsx +++ b/ports/tauri/app/src/main.tsx @@ -1,9 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import WebRuntimeGate from "./WebRuntimeGate"; +import "./global.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , ); diff --git a/ports/tauri/app/src/runtime.ts b/ports/tauri/app/src/runtime.ts new file mode 100644 index 0000000..38b185c --- /dev/null +++ b/ports/tauri/app/src/runtime.ts @@ -0,0 +1,11 @@ +export function isTauriRuntime(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export function isWebRuntime(): boolean { + return typeof window !== "undefined" && !isTauriRuntime(); +} + +export function isWebSimulatorPreview(): boolean { + return isWebRuntime() && import.meta.env.MODE === "web"; +} diff --git a/ports/tauri/app/src/scannerControl.tsx b/ports/tauri/app/src/scannerControl.tsx new file mode 100644 index 0000000..df9b869 --- /dev/null +++ b/ports/tauri/app/src/scannerControl.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext, type ReactNode } from "react"; + +const ScannerControlContext = createContext(true); + +export function ScannerControlProvider({ + canControl, + children, +}: { + canControl: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Native hosts own their local engine, while the web gate supplies lease ownership. */ +export function useScannerControl(): boolean { + return useContext(ScannerControlContext); +} diff --git a/ports/tauri/app/src/session/store/__tests__/selection.test.ts b/ports/tauri/app/src/session/store/__tests__/selection.test.ts index a0c1f60..c7102bb 100644 --- a/ports/tauri/app/src/session/store/__tests__/selection.test.ts +++ b/ports/tauri/app/src/session/store/__tests__/selection.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from "vitest"; import { SessionStore } from "../session"; import { createScriptedTransport, type ScriptedTransportHandle } from "../../testing/harness"; -import type { EngineError, ScanProject, ScannerStatus } from "../../wire/types"; +import type { DeviceInfo, EngineError, ScanProject, ScannerStatus } from "../../wire/types"; interface Call { method: string; @@ -65,6 +65,14 @@ const UNLOADED: ScannerStatus = { activeJobId: null, }; +const SIMULATOR: DeviceInfo = { + deviceId: "sim-ls5000-0", + model: "SUPER COOLSCAN 5000 ED", + kind: "simulated", + firmware: "1.03-sim", + connection: "USB (simulated)", +}; + const PROJECT: ScanProject = { schemaVersion: 4, id: "proj-reset", @@ -165,6 +173,97 @@ describe("SessionStore selection (additive UI state)", () => { }); describe("SessionStore preview outcome exposure", () => { + it("hydrates the connected simulator after a browser refresh", () => { + const { store, handle } = scriptedFixture(); + handle.emitEvent({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device: SIMULATOR, + status: LOADED_ROLL36, + }, + }); + + expect(store.getState().connection).toEqual({ + connected: true, + device: SIMULATOR, + status: LOADED_ROLL36, + }); + }); + + it.each([ + ["carrier change", { ...LOADED_ROLL36, carrier: "strip6" }], + ["frame-count change", { ...LOADED_ROLL36, frameCount: 35 }], + ["eject", UNLOADED], + ] satisfies Array<[string, ScannerStatus]>)( + "invalidates preview data and approval on same-device hydration after a %s", + async (_transition, hydratedStatus) => { + const { store, handle, calls } = scriptedFixture(); + handle.emitEvent({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device: SIMULATOR, + status: LOADED_ROLL36, + }, + }); + + await store.acquireThumbnails(); + const operationId = calls[0].params.operationId as string; + handle.emitEvent({ + event: "scanner.thumbnail", + payload: { + frameIndex: 1, + thumbnail: { brightness: 0.5, tint: 0.1, needsApproval: true }, + operationId, + }, + }); + handle.emitEvent({ + event: "scanner.thumbnailsComplete", + payload: { count: 1, operationId }, + }); + await store.approveFrame(1); + store.toggleFrameSelection(1, false); + + expect(store.getState()).toMatchObject({ + thumbnails: { 1: expect.any(Object) }, + thumbnailOperationIds: { 1: operationId }, + latestCompletedPreviewOperationId: operationId, + approvedFrames: { [operationId]: [1] }, + selectedFrameIndices: [1], + focusedFrameIndex: 1, + }); + + handle.emitEvent({ + event: "scanstudio.webEventStream", + payload: { + state: "ready", + engineConnected: true, + device: SIMULATOR, + status: hydratedStatus, + }, + }); + + const state = store.getState(); + expect(state.connection).toEqual({ + connected: true, + device: SIMULATOR, + status: hydratedStatus, + }); + expect(state.thumbnails).toEqual({}); + expect(state.thumbnailOperationIds).toEqual({}); + expect(state.activeOperationId).toBeNull(); + expect(state.latestCompletedPreviewOperationId).toBeNull(); + expect(state.approvedFrames).toEqual({}); + expect(state.previewOutcome).toBeNull(); + expect(state.previewError).toBeNull(); + expect(state.selectedFrameIndices).toEqual([]); + expect(state.focusedFrameIndex).toBeNull(); + }, + ); + it("is null initially", () => { const { store } = scriptedFixture(); expect(store.getState().previewOutcome).toBeNull(); @@ -214,6 +313,25 @@ describe("SessionStore preview outcome exposure", () => { expect(store.getState().latestCompletedPreviewOperationId).toBe(active); }); + it("fails an active preview closed when the browser event stream drops", async () => { + const { store, handle } = scriptedFixture(); + await store.acquireThumbnails(); + + handle.emitEvent({ + event: "scanstudio.webEventStream", + payload: { state: "disconnected" }, + }); + + expect(store.getState().previewOutcome).toBe("failed"); + expect(store.getState().activeOperationId).toBeNull(); + expect(store.getState().latestCompletedPreviewOperationId).toBeNull(); + expect(store.getState().previewError).toEqual({ + code: "EVENT_STREAM_INTERRUPTED", + message: + "The browser connection was interrupted during preview. Request a fresh preview before scanning.", + }); + }); + it("resets to null when the wire rejects the preview request", async () => { const { store } = scriptedFixture((method) => { if (method === "scanner.acquireThumbnails") { diff --git a/ports/tauri/app/src/session/store/session.ts b/ports/tauri/app/src/session/store/session.ts index 6b34330..f6f35fc 100644 --- a/ports/tauri/app/src/session/store/session.ts +++ b/ports/tauri/app/src/session/store/session.ts @@ -53,6 +53,7 @@ import { type Thumbnail, isEngineError, isDutyCycleReport, + isDeviceInfo, isFrameState, isJobState, isScanProject, @@ -275,6 +276,18 @@ function normalizedScannerStatus(status: ScannerStatus): ScannerStatus { return { ...status, mediaLoaded: false, frameCount: null }; } +function scannerMediaRegistrationChanged( + previous: ScannerStatus | null, + status: ScannerStatus, +): boolean { + if (previous === null) return false; + const ejected = previous.mediaLoaded === true && status.mediaLoaded === false; + const mediaChanged = + previous.carrier !== status.carrier || + previous.frameCount !== status.frameCount; + return ejected || mediaChanged; +} + function isFilmFeedInterrupted(error: EngineError): boolean { if (error.code === "FILM_FEED_INTERRUPTED") return true; // Legacy engines folded the bridge classification into an INTERNAL or @@ -604,10 +617,7 @@ export class SessionStore { const registrationChanged = status.connected === false || status.filmPresent === false || - (previous !== null && - ((previous.mediaLoaded === true && status.mediaLoaded === false) || - previous.carrier !== status.carrier || - previous.frameCount !== status.frameCount)); + scannerMediaRegistrationChanged(previous, status); if (status.filmPresent === false) { this.#invalidatePreviewRegistration(); } else if (registrationChanged) { @@ -1666,7 +1676,13 @@ export class SessionStore { if (!isRecord(payload) || !isScannerStatus(payload.status)) return; const previous = this.#state.connection.status; const status = normalizedScannerStatus(payload.status); - this.#state.connection = { ...this.#state.connection, status }; + this.#state.connection = status.connected === false + ? { connected: false, device: null, status: null } + : { + ...this.#state.connection, + connected: this.#state.connection.device !== null, + status, + }; // Approval-binding invalidation observed through status transitions // (roll.approve triggers 4-5): eject (mediaLoaded true -> false), // media change (carrier/frameCount change), and disconnect @@ -1679,15 +1695,9 @@ export class SessionStore { if (status.connected === false) { this.#state.latestCompletedPreviewOperationId = null; registrationChanged = true; - } else if (previous !== null) { - const ejected = previous.mediaLoaded === true && status.mediaLoaded === false; - const mediaChanged = - previous.carrier !== status.carrier || - previous.frameCount !== status.frameCount; - if (ejected || mediaChanged) { - this.#state.latestCompletedPreviewOperationId = null; - registrationChanged = true; - } + } else if (scannerMediaRegistrationChanged(previous, status)) { + this.#state.latestCompletedPreviewOperationId = null; + registrationChanged = true; } if (status.filmPresent === false) { this.#invalidatePreviewRegistration(); @@ -1779,6 +1789,62 @@ export class SessionStore { this.#notify(); return; } + case "scanstudio.webEventStream": { + const payload = event.payload as { + state?: unknown; + engineConnected?: unknown; + device?: unknown; + status?: unknown; + }; + if (!isRecord(payload)) return; + if (payload.state === "disconnected" && this.#previewOutcome === "active") { + // The web gateway intentionally has no event replay in this first + // slice. If its socket drops during a preview, the browser cannot + // prove whether the unseen terminal event was success or failure. + // Release the local busy lane, invalidate approval, and require a + // fresh preview instead of leaving the UI active forever. + this.#previewOutcome = "failed"; + this.#state.previewOutcome = "failed"; + this.#state.previewError = { + code: "EVENT_STREAM_INTERRUPTED", + message: + "The browser connection was interrupted during preview. Request a fresh preview before scanning.", + }; + this.#state.activeOperationId = null; + this.#state.latestCompletedPreviewOperationId = null; + this.#invalidateScanAuthorization(); + this.#notify(); + return; + } + if (payload.state === "ready" && payload.engineConnected === false) { + this.#state.connection = { connected: false, device: null, status: null }; + this.#invalidatePreviewRegistration(); + this.#notify(); + } else if ( + payload.state === "ready" && + payload.engineConnected === true && + isDeviceInfo(payload.device) && + isScannerStatus(payload.status) + ) { + const previousStatus = this.#state.connection.status; + const deviceChanged = + this.#state.connection.device?.deviceId !== payload.device.deviceId; + const status = normalizedScannerStatus(payload.status); + const registrationChanged = + deviceChanged || + status.connected === false || + status.filmPresent === false || + scannerMediaRegistrationChanged(previousStatus, status); + this.#state.connection = { + connected: true, + device: payload.device, + status, + }; + if (registrationChanged) this.#invalidatePreviewRegistration(); + this.#notify(); + } + return; + } case "scan.progress": { const payload = event.payload as { jobId?: unknown; jobPercent?: unknown; etaSeconds?: unknown }; if ( diff --git a/ports/tauri/app/src/shell/AppShell.module.css b/ports/tauri/app/src/shell/AppShell.module.css index 48c51b6..d3869c2 100644 --- a/ports/tauri/app/src/shell/AppShell.module.css +++ b/ports/tauri/app/src/shell/AppShell.module.css @@ -1,9 +1,11 @@ .shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); - height: 100vh; + height: 100%; width: 100%; overflow: hidden; + background: var(--scan-workspace); + color: var(--scan-primary-text); } .shell[data-has-inspector="true"] { @@ -12,14 +14,81 @@ .sidebar { overflow-y: auto; - border-right: 1px solid #e5e7eb; + border-right: 1px solid var(--scan-divider); + background: var(--scan-sidebar); } .workspace { overflow-y: auto; + background: var(--scan-workspace); } .inspector { overflow-y: auto; - border-left: 1px solid #e5e7eb; + border-left: 1px solid var(--scan-divider); + background: var(--scan-inspector); +} + +@media (max-width: 64rem) { + .shell { + grid-template-columns: 220px minmax(0, 1fr); + } + + .shell[data-has-inspector="true"] { + grid-template-columns: 220px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + } + + .shell[data-has-inspector="true"] .sidebar { + grid-column: 1; + grid-row: 1 / -1; + } + + .shell[data-has-inspector="true"] .workspace { + grid-column: 2; + grid-row: 1; + } + + .shell[data-has-inspector="true"] .inspector { + grid-column: 2; + grid-row: 2; + max-height: 14rem; + border-top: 1px solid var(--scan-divider); + border-left: 0; + } +} + +@media (max-width: 44rem) { + .shell, + .shell[data-has-inspector="true"] { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(11rem, 36dvh) minmax(0, 1fr); + } + + .shell[data-has-inspector="true"] { + grid-template-rows: minmax(11rem, 32dvh) minmax(0, 1fr) auto; + } + + .sidebar, + .shell[data-has-inspector="true"] .sidebar { + grid-column: 1; + grid-row: 1; + border-right: 0; + border-bottom: 1px solid var(--scan-divider); + padding-bottom: env(safe-area-inset-bottom); + } + + .workspace, + .shell[data-has-inspector="true"] .workspace { + grid-column: 1; + grid-row: 2; + padding-bottom: env(safe-area-inset-bottom); + } + + .shell[data-has-inspector="true"] .inspector { + grid-column: 1; + grid-row: 3; + max-height: 11rem; + padding-bottom: env(safe-area-inset-bottom); + } } diff --git a/ports/tauri/app/src/views/Capture/CaptureWorkflow.module.css b/ports/tauri/app/src/views/Capture/CaptureWorkflow.module.css index 587e04c..161d8d2 100644 --- a/ports/tauri/app/src/views/Capture/CaptureWorkflow.module.css +++ b/ports/tauri/app/src/views/Capture/CaptureWorkflow.module.css @@ -10,18 +10,19 @@ .doneNote { margin: 0; padding: 0.5rem 0.75rem; - border: 1px solid #bbf7d0; - border-radius: 6px; - background: #f0fdf4; - color: #166534; + border: 1px solid var(--scan-green-border); + border-radius: 9px; + background: var(--scan-green-fill); + color: var(--scan-green); font-size: 0.9rem; } .controlButton { padding: 0.35rem 0.75rem; border-radius: 4px; - border: 1px solid #d1d5db; - background: #ffffff; + border: 1px solid var(--scan-divider); + background: var(--scan-raised); + color: var(--scan-primary-text); cursor: pointer; font-size: 0.9rem; align-self: flex-start; diff --git a/ports/tauri/app/src/views/ContactSheet.module.css b/ports/tauri/app/src/views/ContactSheet.module.css index 035e0fd..a88e867 100644 --- a/ports/tauri/app/src/views/ContactSheet.module.css +++ b/ports/tauri/app/src/views/ContactSheet.module.css @@ -32,16 +32,17 @@ display: flex; align-items: center; gap: 0.4rem; - color: #374151; + color: var(--scan-row-label); font-size: 0.8rem; font-weight: 600; } .focusControl select { padding: 0.32rem 0.45rem; - border: 1px solid #d1d5db; + border: 1px solid var(--scan-divider); border-radius: 4px; - background: #ffffff; + background: var(--scan-raised); + color: var(--scan-primary-text); } .batchTransformControls { @@ -51,9 +52,10 @@ .batchTransformControls summary { padding: 0.35rem 0.6rem; - border: 1px solid #d1d5db; + border: 1px solid var(--scan-divider); border-radius: 4px; - background: #f9fafb; + background: var(--scan-raised); + color: var(--scan-primary-text); cursor: pointer; user-select: none; } @@ -69,17 +71,18 @@ flex-wrap: wrap; gap: 0.4rem; padding: 0.5rem; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #ffffff; - box-shadow: 0 0.35rem 1rem rgb(17 24 39 / 14%); + border: 1px solid var(--scan-divider); + border-radius: 9px; + background: var(--scan-raised); + box-shadow: 0 0.35rem 1rem rgb(0 0 0 / 34%); } .controlButton { padding: 0.35rem 0.75rem; border-radius: 4px; - border: 1px solid #d1d5db; - background: #ffffff; + border: 1px solid var(--scan-divider); + background: var(--scan-raised); + color: var(--scan-primary-text); cursor: pointer; } @@ -91,7 +94,7 @@ .mediaGuidance { max-width: 60ch; margin: 0; - color: #4b5563; + color: var(--scan-secondary-text); font-size: 0.9rem; line-height: 1.45; } @@ -99,10 +102,10 @@ .failureBanner { margin: 0; padding: 0.5rem 0.75rem; - border: 1px solid #fecaca; - border-radius: 6px; - background: #fef2f2; - color: #b91c1c; + border: 1px solid var(--scan-red-border); + border-radius: 9px; + background: var(--scan-red-fill); + color: var(--scan-primary-text); font-size: 0.9rem; } @@ -117,9 +120,9 @@ display: block; aspect-ratio: 3 / 2; padding: 0.4rem; - border: 1px solid #e5e7eb; + border: 1px solid var(--scan-border-emphasis); border-radius: 6px; - background: #f9fafb; + background: var(--scan-thumbnail-well); cursor: pointer; overflow: hidden; } @@ -152,10 +155,10 @@ border-radius: 4px; background: repeating-linear-gradient( 45deg, - #f3f4f6, - #f3f4f6 0.5rem, - #e5e7eb 0.5rem, - #e5e7eb 1rem + var(--scan-raised), + var(--scan-raised) 0.5rem, + var(--scan-sidebar) 0.5rem, + var(--scan-sidebar) 1rem ); } @@ -166,14 +169,14 @@ left: 0.45rem; padding: 0.1rem 0.3rem; border-radius: 3px; - background: rgb(255 255 255 / 82%); + background: rgb(0 0 0 / 68%); font-size: 0.7rem; - color: #6b7280; + color: var(--scan-primary-text); } .selected { - border-color: #2563eb; - outline: 2px solid #2563eb; + border-color: var(--scan-amber); + outline: 2px solid var(--scan-amber); outline-offset: -2px; } @@ -185,8 +188,8 @@ content: "Edit"; padding: 0.1rem 0.35rem; border-radius: 999px; - background: rgb(17 24 39 / 78%); - color: #ffffff; + background: rgb(0 0 0 / 78%); + color: var(--scan-primary-text); font-size: 0.62rem; font-weight: 600; letter-spacing: 0.02em; diff --git a/ports/tauri/app/src/views/ContactSheet.tsx b/ports/tauri/app/src/views/ContactSheet.tsx index 27c4690..3ff8686 100644 --- a/ports/tauri/app/src/views/ContactSheet.tsx +++ b/ports/tauri/app/src/views/ContactSheet.tsx @@ -1,5 +1,6 @@ import { useEffect, useSyncExternalStore } from "react"; import { sessionStore, type SessionState } from "../session"; +import { isWebSimulatorPreview } from "../runtime"; import type { DerivativeTransform, Thumbnail } from "../session/wire/types"; import styles from "./ContactSheet.module.css"; @@ -68,12 +69,18 @@ function shortcutTargetIsEditable(target: EventTarget | null): boolean { } export interface ContactSheetProps { + canControl?: boolean; onInspectFrame?: (frameIndex: number) => void; onCapture?: () => void; } -export default function ContactSheet({ onInspectFrame, onCapture }: ContactSheetProps = {}) { +export default function ContactSheet({ + canControl = true, + onInspectFrame, + onCapture, +}: ContactSheetProps = {}) { const state = useSyncExternalStore(stableSubscribe, stableGetSnapshot); + const simulatorPreview = isWebSimulatorPreview(); const status = state.connection.status; const project = state.project; const mediaLoaded = status?.mediaLoaded === true; @@ -81,7 +88,8 @@ export default function ContactSheet({ onInspectFrame, onCapture }: ContactSheet const deviceKind = state.connection.device?.kind ?? null; const canLoadSimulatedMedia = !mediaLoaded && connected && deviceKind === "simulated"; const canPreview = - project !== null && (mediaLoaded || (connected && deviceKind === "real")); + (project !== null || simulatorPreview) && + (mediaLoaded || (connected && deviceKind === "real")); const frameCount = mediaLoaded ? (status?.frameCount ?? 0) : 0; const selectionEmpty = state.selectedFrameIndices.length === 0; const transformsEditable = @@ -119,8 +127,11 @@ export default function ContactSheet({ onInspectFrame, onCapture }: ContactSheet }, [focusedFrameIndex, transformsEditable]); const preview = (): void => { - if (project === null) return; - void sessionStore.acquireThumbnails(undefined, project.filmProcess); + if (project === null && !simulatorPreview) return; + void sessionStore.acquireThumbnails( + undefined, + project?.filmProcess ?? "c41ColorNegative", + ); }; const frames: number[] = []; @@ -138,6 +149,7 @@ export default function ContactSheet({ onInspectFrame, onCapture }: ContactSheet key={carrier} type="button" className={styles.controlButton} + disabled={!canControl} onClick={() => void sessionStore.loadMedia(carrier)} > {carrier} @@ -157,7 +169,7 @@ export default function ContactSheet({ onInspectFrame, onCapture }: ContactSheet type="button" className={styles.controlButton} data-testid="preview-button" - disabled={state.previewOutcome === "active"} + disabled={!canControl || state.previewOutcome === "active"} onClick={preview} > Preview diff --git a/ports/tauri/app/src/views/DefectOverlay.module.css b/ports/tauri/app/src/views/DefectOverlay.module.css index 0d94e1c..3129658 100644 --- a/ports/tauri/app/src/views/DefectOverlay.module.css +++ b/ports/tauri/app/src/views/DefectOverlay.module.css @@ -18,9 +18,9 @@ letter-spacing: 0.05em; padding: 0.15rem 0.5rem; border-radius: 999px; - border: 1px solid #fcd34d; - background: #fffbeb; - color: #92400e; + border: 1px solid var(--scan-amber-border); + background: var(--scan-amber-fill); + color: var(--scan-amber); } .realBadge { @@ -30,17 +30,17 @@ letter-spacing: 0.05em; padding: 0.15rem 0.5rem; border-radius: 999px; - border: 1px solid #bbf7d0; - background: #f0fdf4; - color: #166534; + border: 1px solid var(--scan-green-border); + background: var(--scan-green-fill); + color: var(--scan-green); } .canvas { width: 100%; aspect-ratio: 1; - border: 1px solid #e5e7eb; + border: 1px solid var(--scan-divider); border-radius: 4px; - background: #111827; + background: var(--scan-thumbnail-well); } .marker { @@ -50,19 +50,19 @@ .cleanNotice { margin: 0; padding: 0.5rem 0.75rem; - border: 1px solid #bbf7d0; - border-radius: 6px; - background: #f0fdf4; - color: #166534; + border: 1px solid var(--scan-green-border); + border-radius: 9px; + background: var(--scan-green-fill); + color: var(--scan-green); font-size: 0.9rem; } .iceOffNotice { margin: 0; padding: 0.5rem 0.75rem; - border: 1px solid #fcd34d; - border-radius: 6px; - background: #fffbeb; - color: #92400e; + border: 1px solid var(--scan-amber-border); + border-radius: 9px; + background: var(--scan-amber-fill); + color: var(--scan-amber); font-size: 0.9rem; } diff --git a/ports/tauri/app/src/views/DeviceBar.module.css b/ports/tauri/app/src/views/DeviceBar.module.css index 07a3d18..85c2327 100644 --- a/ports/tauri/app/src/views/DeviceBar.module.css +++ b/ports/tauri/app/src/views/DeviceBar.module.css @@ -25,8 +25,8 @@ flex-direction: column; gap: 0.5rem; padding: 0.75rem; - border: 1px solid #e5e7eb; - border-radius: 6px; + border: 1px solid var(--scan-divider); + border-radius: 9px; } .deviceModel { @@ -40,27 +40,34 @@ letter-spacing: 0.05em; padding: 0.1rem 0.4rem; border-radius: 999px; - background: #f3f4f6; - border: 1px solid #e5e7eb; + background: var(--scan-cyan-fill); + border: 1px solid var(--scan-cyan-border); + color: var(--scan-cyan); } .controlButton { align-self: flex-start; padding: 0.35rem 0.75rem; border-radius: 4px; - border: 1px solid #d1d5db; - background: #ffffff; + border: 1px solid var(--scan-divider); + background: var(--scan-raised); + color: var(--scan-primary-text); cursor: pointer; } +.controlButton:disabled { + cursor: not-allowed; + opacity: 0.5; +} + .statusBlock { display: flex; flex-direction: column; gap: 0.35rem; margin: 0; padding: 0.75rem; - border: 1px solid #e5e7eb; - border-radius: 6px; + border: 1px solid var(--scan-divider); + border-radius: 9px; } .statusRow { @@ -71,6 +78,7 @@ .statusRow dt { font-weight: 600; min-width: 6rem; + color: var(--scan-row-label); } .statusRow dd { diff --git a/ports/tauri/app/src/views/DeviceBar.tsx b/ports/tauri/app/src/views/DeviceBar.tsx index 48262b7..b5ee84c 100644 --- a/ports/tauri/app/src/views/DeviceBar.tsx +++ b/ports/tauri/app/src/views/DeviceBar.tsx @@ -33,7 +33,15 @@ function stableGetSnapshot(): Readonly { return cachedSnapshot; } -export default function DeviceBar() { +export interface DeviceBarProps { + canControl?: boolean; + showDiagnosticActions?: boolean; +} + +export default function DeviceBar({ + canControl = true, + showDiagnosticActions = true, +}: DeviceBarProps) { const [devices, setDevices] = useState(null); useEffect(() => { @@ -78,6 +86,7 @@ export default function DeviceBar() {