From af91c264e74b5511af455500c8c9bfe000536be1 Mon Sep 17 00:00:00 2001 From: Mohamed Saleh Zaied Date: Wed, 3 Jun 2026 05:24:48 +0300 Subject: [PATCH] feat(apps): land Windows + Linux native shells (Slice 4 of skills-bridge-swift) Portable shell bootstrap binaries (windows-shell, linux-shell) that run the shared-core turn-start flow through capability adapters. Added both as workspace members; windows-shell-gui (Tauri 2) is excluded from the default workspace so cargo check --workspace stays buildable on macOS (Tauri GUI builds in CI). Validated: cargo check --workspace passes; cargo run -p skilly-{windows,linux}-shell -- --smoke both pass (allowed=true, phase=completed). AGENTS.md + phase-7 PRD added. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 12 + Cargo.lock | 18 + Cargo.toml | 6 +- apps/linux-shell/Cargo.toml | 10 + apps/linux-shell/src/main.rs | 471 ++++++++++++++++++ apps/windows-shell-gui/Cargo.toml | 24 + apps/windows-shell-gui/build.rs | 3 + apps/windows-shell-gui/dist/index.html | 91 ++++ apps/windows-shell-gui/icons/icon.png | Bin 0 -> 7828 bytes apps/windows-shell-gui/src/main.rs | 85 ++++ apps/windows-shell-gui/tauri.conf.json | 30 ++ apps/windows-shell/Cargo.toml | 18 + apps/windows-shell/src/lib.rs | 445 +++++++++++++++++ apps/windows-shell/src/main.rs | 54 ++ .../architecture/phase-7-windows-shell-prd.md | 218 ++++++++ 15 files changed, 1484 insertions(+), 1 deletion(-) create mode 100644 apps/linux-shell/Cargo.toml create mode 100644 apps/linux-shell/src/main.rs create mode 100644 apps/windows-shell-gui/Cargo.toml create mode 100644 apps/windows-shell-gui/build.rs create mode 100644 apps/windows-shell-gui/dist/index.html create mode 100644 apps/windows-shell-gui/icons/icon.png create mode 100644 apps/windows-shell-gui/src/main.rs create mode 100644 apps/windows-shell-gui/tauri.conf.json create mode 100644 apps/windows-shell/Cargo.toml create mode 100644 apps/windows-shell/src/lib.rs create mode 100644 apps/windows-shell/src/main.rs create mode 100644 docs/architecture/phase-7-windows-shell-prd.md diff --git a/AGENTS.md b/AGENTS.md index 6baea0a1..a48349d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,18 @@ UniFFI-generated iOS (Swift) and Android (Kotlin) bindings over `core/mobile-sdk | `scripts/package-mobile-sdk.sh` / `validate-mobile-sdk-consumers.sh` | Package and end-to-end validate generated SDK consumers. | | `.github/workflows/mobile-sdk-artifacts.yml` | Release-triggered packaging/publishing of mobile SDK + FFI tarballs. | +### Native Shells (`apps/`) — landed on `develop` (Slice 4) + +Platform shell bootstrap binaries that run the shared-core turn-start flow through explicit capability adapters (capture/hotkey/overlay/audio/permissions). See `docs/architecture/{adapter-contracts,phase-7-windows-shell-prd}.md`. + +| File | Purpose | +|------|---------| +| `apps/windows-shell/src/{main,lib}.rs` | Windows shell bootstrap + adapter trait surface; `--smoke` runs a turn-start through the Rust core. | +| `apps/linux-shell/src/main.rs` | Linux shell bootstrap with session-aware capability reporting; `--smoke` flag. | +| `apps/windows-shell-gui/*` | Windows host app (Tauri 2). **Excluded from the default cargo workspace** (`exclude` in root `Cargo.toml`) — needs Windows/CI build deps; not built by local `cargo check --workspace`. | + +> Validated on macOS: `cargo check --workspace` + `cargo run -p skilly-{windows,linux}-shell -- --smoke` both pass (turn-start `allowed=true`, `phase=completed`). The Tauri GUI builds in CI on Windows. + ### Skill Files The repo ships 5 bundled skills under `skills/`, also copied into the app bundle under `Resources/skills/` so new users get them without downloading anything. diff --git a/Cargo.lock b/Cargo.lock index 399c3f0d..ebabc85f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "skilly-linux-shell" +version = "0.1.0" +dependencies = [ + "skilly-core-domain", + "skilly-core-policy", + "skilly-core-realtime", +] + +[[package]] +name = "skilly-windows-shell" +version = "0.1.0" +dependencies = [ + "skilly-core-domain", + "skilly-core-policy", + "skilly-core-realtime", +] + [[package]] name = "smawk" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 7ab4ce2d..61840b5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,12 @@ members = [ "core/skills", "core/realtime", "core/mobile-sdk", - # apps/* shell crates are added in Slice 4 (Windows/Linux shells) + "apps/windows-shell", + "apps/linux-shell", ] +# windows-shell-gui pulls Tauri 2 (Windows/CI build deps); excluded from the +# default workspace so `cargo check --workspace` stays buildable on macOS. +exclude = ["apps/windows-shell-gui"] resolver = "2" [workspace.package] diff --git a/apps/linux-shell/Cargo.toml b/apps/linux-shell/Cargo.toml new file mode 100644 index 00000000..a0faee57 --- /dev/null +++ b/apps/linux-shell/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "skilly-linux-shell" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +skilly-core-domain = { path = "../../core/domain" } +skilly-core-policy = { path = "../../core/policy" } +skilly-core-realtime = { path = "../../core/realtime" } diff --git a/apps/linux-shell/src/main.rs b/apps/linux-shell/src/main.rs new file mode 100644 index 00000000..66fd1885 --- /dev/null +++ b/apps/linux-shell/src/main.rs @@ -0,0 +1,471 @@ +//! Linux native shell bootstrap with platform adapter contracts. + +use skilly_core_domain::{EntitlementState, PolicyConfig, PolicyInput}; +use skilly_core_policy::can_start_turn; +use skilly_core_realtime::{replay_events, RealtimeEvent}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AdapterCapabilityStatus { + Available, + Degraded { reason: String }, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone)] +struct PlatformCapabilitySnapshot { + capture: AdapterCapabilityStatus, + hotkey: AdapterCapabilityStatus, + overlay: AdapterCapabilityStatus, + audio_input: AdapterCapabilityStatus, + audio_output: AdapterCapabilityStatus, + permissions: AdapterCapabilityStatus, +} + +impl PlatformCapabilitySnapshot { + fn critical_blockers(&self) -> Vec { + [ + ("capture", &self.capture), + ("hotkey", &self.hotkey), + ("overlay", &self.overlay), + ("permissions", &self.permissions), + ] + .iter() + .filter_map( + |(adapter_name, capability_status)| match capability_status { + AdapterCapabilityStatus::Unavailable { reason } => { + Some(format!("{adapter_name} unavailable: {reason}")) + } + _ => None, + }, + ) + .collect() + } +} + +#[derive(Debug, Clone)] +struct AuthSession { + workos_user_id: String, + session_token: String, +} + +#[derive(Debug)] +struct LinuxCaptureAdapter; + +#[derive(Debug)] +struct LinuxHotkeyAdapter; + +#[derive(Debug)] +struct LinuxOverlayAdapter; + +#[derive(Debug)] +struct LinuxAudioAdapter; + +#[derive(Debug)] +struct LinuxPermissionAdapter; + +fn map_linux_capture_mode(capture_mode: &str, session_type: &str) -> AdapterCapabilityStatus { + if capture_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_LINUX_CAPTURE_MODE=disabled".to_string(), + }; + } + + match session_type { + "x11" => AdapterCapabilityStatus::Available, + "wayland" => AdapterCapabilityStatus::Degraded { + reason: "Wayland capture depends on portal availability".to_string(), + }, + _ => AdapterCapabilityStatus::Degraded { + reason: "capture session type unknown; runtime probing required".to_string(), + }, + } +} + +fn map_linux_hotkey_mode( + hotkey_mode: &str, + has_wayland_display: bool, + has_x11_display: bool, +) -> AdapterCapabilityStatus { + if hotkey_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_LINUX_HOTKEY_MODE=disabled".to_string(), + }; + } + + if has_x11_display { + return AdapterCapabilityStatus::Available; + } + if has_wayland_display { + return AdapterCapabilityStatus::Degraded { + reason: "Wayland global hotkeys depend on compositor support".to_string(), + }; + } + + AdapterCapabilityStatus::Degraded { + reason: "hotkey display context missing; runtime probing required".to_string(), + } +} + +fn map_linux_overlay_mode( + overlay_mode: &str, + has_wayland_display: bool, + has_x11_display: bool, +) -> AdapterCapabilityStatus { + if overlay_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_LINUX_OVERLAY_MODE=disabled".to_string(), + }; + } + + if has_x11_display { + return AdapterCapabilityStatus::Available; + } + if has_wayland_display { + return AdapterCapabilityStatus::Degraded { + reason: "Wayland overlays depend on compositor protocol support".to_string(), + }; + } + + AdapterCapabilityStatus::Degraded { + reason: "overlay display context missing; runtime probing required".to_string(), + } +} + +fn map_linux_audio_input( + has_pulse_server: bool, + has_pipewire_runtime_directory: bool, +) -> AdapterCapabilityStatus { + if has_pulse_server || has_pipewire_runtime_directory { + return AdapterCapabilityStatus::Available; + } + AdapterCapabilityStatus::Degraded { + reason: "audio server variables not detected; runtime probing required".to_string(), + } +} + +fn map_linux_audio_output( + has_pulse_server: bool, + has_pipewire_runtime_directory: bool, +) -> AdapterCapabilityStatus { + if has_pulse_server || has_pipewire_runtime_directory { + return AdapterCapabilityStatus::Available; + } + AdapterCapabilityStatus::Degraded { + reason: "audio server variables not detected; runtime probing required".to_string(), + } +} + +fn map_linux_permission_state(permission_state: &str) -> AdapterCapabilityStatus { + if permission_state == "blocked" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_LINUX_PERMISSION_STATE=blocked".to_string(), + }; + } + AdapterCapabilityStatus::Available +} + +fn entitlement_state_from_raw(entitlement_state_raw: &str) -> EntitlementState { + match entitlement_state_raw { + "none" => EntitlementState::None, + "trial" => EntitlementState::Trial, + "active" => EntitlementState::Active, + "canceled-valid" => EntitlementState::Canceled { + access_still_valid: true, + }, + "canceled-expired" => EntitlementState::Canceled { + access_still_valid: false, + }, + "expired" => EntitlementState::Expired, + _ => EntitlementState::Active, + } +} + +impl LinuxCaptureAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let capture_mode = std::env::var("SKILLY_LINUX_CAPTURE_MODE") + .unwrap_or_else(|_| "auto".to_string()) + .to_lowercase(); + let session_type = std::env::var("XDG_SESSION_TYPE") + .unwrap_or_else(|_| "unknown".to_string()) + .to_lowercase(); + map_linux_capture_mode(&capture_mode, &session_type) + } +} + +impl LinuxHotkeyAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let hotkey_mode = std::env::var("SKILLY_LINUX_HOTKEY_MODE") + .unwrap_or_else(|_| "auto".to_string()) + .to_lowercase(); + let has_wayland_display = std::env::var("WAYLAND_DISPLAY").is_ok(); + let has_x11_display = std::env::var("DISPLAY").is_ok(); + map_linux_hotkey_mode(&hotkey_mode, has_wayland_display, has_x11_display) + } +} + +impl LinuxOverlayAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let overlay_mode = std::env::var("SKILLY_LINUX_OVERLAY_MODE") + .unwrap_or_else(|_| "auto".to_string()) + .to_lowercase(); + let has_wayland_display = std::env::var("WAYLAND_DISPLAY").is_ok(); + let has_x11_display = std::env::var("DISPLAY").is_ok(); + map_linux_overlay_mode(&overlay_mode, has_wayland_display, has_x11_display) + } +} + +impl LinuxAudioAdapter { + fn input_capability(&self) -> AdapterCapabilityStatus { + map_linux_audio_input( + std::env::var("PULSE_SERVER").is_ok(), + std::env::var("PIPEWIRE_RUNTIME_DIR").is_ok(), + ) + } + + fn output_capability(&self) -> AdapterCapabilityStatus { + map_linux_audio_output( + std::env::var("PULSE_SERVER").is_ok(), + std::env::var("PIPEWIRE_RUNTIME_DIR").is_ok(), + ) + } +} + +impl LinuxPermissionAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let permission_state = std::env::var("SKILLY_LINUX_PERMISSION_STATE") + .unwrap_or_else(|_| "granted".to_string()) + .to_lowercase(); + map_linux_permission_state(&permission_state) + } +} + +#[derive(Debug)] +struct LinuxPlatformAdapters { + capture: LinuxCaptureAdapter, + hotkey: LinuxHotkeyAdapter, + overlay: LinuxOverlayAdapter, + audio: LinuxAudioAdapter, + permissions: LinuxPermissionAdapter, +} + +impl LinuxPlatformAdapters { + fn new() -> Self { + Self { + capture: LinuxCaptureAdapter, + hotkey: LinuxHotkeyAdapter, + overlay: LinuxOverlayAdapter, + audio: LinuxAudioAdapter, + permissions: LinuxPermissionAdapter, + } + } + + fn capability_snapshot(&self) -> PlatformCapabilitySnapshot { + PlatformCapabilitySnapshot { + capture: self.capture.capability(), + hotkey: self.hotkey.capability(), + overlay: self.overlay.capability(), + audio_input: self.audio.input_capability(), + audio_output: self.audio.output_capability(), + permissions: self.permissions.capability(), + } + } +} + +#[derive(Debug)] +struct ShellTurnFlowResult { + auth_user_id: String, + entitlement_state: EntitlementState, + allowed_to_start_turn: bool, + final_phase: String, + turns_completed: u64, + capability_snapshot: PlatformCapabilitySnapshot, +} + +fn load_auth_session() -> Result { + let workos_user_id = std::env::var("SKILLY_LINUX_WORKOS_USER_ID") + .unwrap_or_else(|_| "linux-dev-user".to_string()); + let session_token = std::env::var("SKILLY_LINUX_SESSION_TOKEN") + .unwrap_or_else(|_| "linux-session-token".to_string()); + + if session_token.trim().is_empty() { + return Err("linux session token is empty".to_string()); + } + + Ok(AuthSession { + workos_user_id, + session_token, + }) +} + +fn resolve_entitlement_state() -> EntitlementState { + let entitlement_state_raw = std::env::var("SKILLY_LINUX_ENTITLEMENT_STATUS") + .unwrap_or_else(|_| "active".to_string()) + .to_lowercase(); + entitlement_state_from_raw(&entitlement_state_raw) +} + +fn run_turn_flow() -> Result { + let auth_session = load_auth_session()?; + if auth_session.session_token.len() < 8 { + return Err("linux session token is too short".to_string()); + } + let platform_adapters = LinuxPlatformAdapters::new(); + let capability_snapshot = platform_adapters.capability_snapshot(); + let critical_blockers = capability_snapshot.critical_blockers(); + if !critical_blockers.is_empty() { + return Err(format!( + "linux shell capability blockers: {}", + critical_blockers.join("; ") + )); + } + + let entitlement_state = resolve_entitlement_state(); + let policy_input = PolicyInput { + user_id: Some(auth_session.workos_user_id.clone()), + entitlement_state: entitlement_state.clone(), + trial_seconds_used: std::env::var("SKILLY_LINUX_TRIAL_SECONDS_USED") + .ok() + .and_then(|raw_value| raw_value.parse::().ok()) + .unwrap_or(0), + usage_seconds_used: std::env::var("SKILLY_LINUX_USAGE_SECONDS_USED") + .ok() + .and_then(|raw_value| raw_value.parse::().ok()) + .unwrap_or(0), + }; + + let policy_decision = can_start_turn(&PolicyConfig::default(), &policy_input); + if !policy_decision.allowed { + return Err("policy blocked turn start".to_string()); + } + + let events = vec![ + RealtimeEvent::TurnStarted { + turn_id: "linux-turn-1".to_string(), + }, + RealtimeEvent::AudioCaptureCommitted { + turn_id: "linux-turn-1".to_string(), + }, + RealtimeEvent::AudioPlaybackStarted { + turn_id: "linux-turn-1".to_string(), + }, + RealtimeEvent::ResponseCompleted { + turn_id: "linux-turn-1".to_string(), + }, + ]; + let final_state = replay_events(&events) + .map_err(|replay_error| format!("replay failed: {replay_error:?}"))?; + + Ok(ShellTurnFlowResult { + auth_user_id: auth_session.workos_user_id, + entitlement_state, + allowed_to_start_turn: policy_decision.allowed, + final_phase: final_state.phase_name().to_string(), + turns_completed: final_state.turns_completed, + capability_snapshot, + }) +} + +fn main() { + let smoke_requested = std::env::args().any(|argument| argument == "--smoke"); + if !smoke_requested { + println!("skilly-linux-shell adapters ready (use --smoke)"); + return; + } + + match run_turn_flow() { + Ok(flow_result) => { + println!( + "linux shell flow passed: user={} entitlement={:?} allowed={} phase={} turns_completed={} capture={:?} hotkey={:?} overlay={:?} audio_in={:?} audio_out={:?} permissions={:?}", + flow_result.auth_user_id, + flow_result.entitlement_state, + flow_result.allowed_to_start_turn, + flow_result.final_phase, + flow_result.turns_completed, + flow_result.capability_snapshot.capture, + flow_result.capability_snapshot.hotkey, + flow_result.capability_snapshot.overlay, + flow_result.capability_snapshot.audio_input, + flow_result.capability_snapshot.audio_output, + flow_result.capability_snapshot.permissions + ); + } + Err(error_message) => { + eprintln!("linux shell flow failed: {error_message}"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linux_capture_mode_uses_degraded_for_unknown_session() { + assert!(matches!( + map_linux_capture_mode("auto", "unknown"), + AdapterCapabilityStatus::Degraded { .. } + )); + assert!(matches!( + map_linux_capture_mode("disabled", "x11"), + AdapterCapabilityStatus::Unavailable { .. } + )); + } + + #[test] + fn linux_overlay_mode_respects_disable_override() { + assert!(matches!( + map_linux_overlay_mode("disabled", false, true), + AdapterCapabilityStatus::Unavailable { .. } + )); + assert!(matches!( + map_linux_overlay_mode("auto", true, false), + AdapterCapabilityStatus::Degraded { .. } + )); + } + + #[test] + fn linux_entitlement_parser_handles_known_values() { + assert!(matches!( + entitlement_state_from_raw("trial"), + EntitlementState::Trial + )); + assert!(matches!( + entitlement_state_from_raw("canceled-expired"), + EntitlementState::Canceled { + access_still_valid: false + } + )); + assert!(matches!( + entitlement_state_from_raw("unknown"), + EntitlementState::Active + )); + } + + #[test] + fn critical_blockers_only_include_unavailable_capabilities() { + let snapshot = PlatformCapabilitySnapshot { + capture: AdapterCapabilityStatus::Degraded { + reason: "fallback".to_string(), + }, + hotkey: AdapterCapabilityStatus::Unavailable { + reason: "blocked".to_string(), + }, + overlay: AdapterCapabilityStatus::Available, + audio_input: AdapterCapabilityStatus::Available, + audio_output: AdapterCapabilityStatus::Available, + permissions: AdapterCapabilityStatus::Unavailable { + reason: "policy".to_string(), + }, + }; + + let blockers = snapshot.critical_blockers(); + assert_eq!(blockers.len(), 2); + assert!(blockers + .iter() + .any(|entry| entry.contains("hotkey unavailable"))); + assert!(blockers + .iter() + .any(|entry| entry.contains("permissions unavailable"))); + } +} diff --git a/apps/windows-shell-gui/Cargo.toml b/apps/windows-shell-gui/Cargo.toml new file mode 100644 index 00000000..0eebbd35 --- /dev/null +++ b/apps/windows-shell-gui/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "skilly-windows-shell-gui" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Skilly Windows host app (Tauri 2). See docs/architecture/phase-7-windows-shell-prd.md." + +[[bin]] +name = "skilly-windows-shell-gui" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +skilly-core-domain = { path = "../../core/domain" } +skilly-core-policy = { path = "../../core/policy" } +skilly-core-realtime = { path = "../../core/realtime" } +skilly-core-skills = { path = "../../core/skills" } +skilly-windows-shell = { path = "../windows-shell" } diff --git a/apps/windows-shell-gui/build.rs b/apps/windows-shell-gui/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/apps/windows-shell-gui/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/windows-shell-gui/dist/index.html b/apps/windows-shell-gui/dist/index.html new file mode 100644 index 00000000..199eb4f3 --- /dev/null +++ b/apps/windows-shell-gui/dist/index.html @@ -0,0 +1,91 @@ + + + + + + Skilly + + + +
+

Skilly Windows host

+

Phase 7 placeholder

+ +
click the button to invoke the Rust adapter snapshot
+
+ + + diff --git a/apps/windows-shell-gui/icons/icon.png b/apps/windows-shell-gui/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9b6e408b3cc44cd3aec8060efbbe4be5dc5cafce GIT binary patch literal 7828 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 literal 0 HcmV?d00001 diff --git a/apps/windows-shell-gui/src/main.rs b/apps/windows-shell-gui/src/main.rs new file mode 100644 index 00000000..379d32df --- /dev/null +++ b/apps/windows-shell-gui/src/main.rs @@ -0,0 +1,85 @@ +//! Skilly Windows host app (Tauri 2) — Phase 7 entry point. +//! +//! This crate hosts the user-facing Windows GUI app. Adapter trait +//! definitions and stub implementations live in `skilly_windows_shell` +//! (`apps/windows-shell`); this crate consumes the same trait surface so the +//! host code path is identical between dev (env-var stubs) and production +//! (real Windows-specific adapter implementations, landing in subsequent +//! commits behind `#[cfg(target_os = "windows")]` modules). +//! +//! Scope and roadmap: docs/architecture/phase-7-windows-shell-prd.md + +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use serde::Serialize; +use skilly_windows_shell::{ + stub::StubPlatformAdapters, AdapterCapabilityStatus, PlatformAdapters, + PlatformCapabilitySnapshot, +}; + +#[derive(Debug, Serialize)] +struct CapabilityWireStatus { + status: &'static str, + reason: Option, +} + +impl From<&AdapterCapabilityStatus> for CapabilityWireStatus { + fn from(adapter_capability_status: &AdapterCapabilityStatus) -> Self { + match adapter_capability_status { + AdapterCapabilityStatus::Available => CapabilityWireStatus { + status: "available", + reason: None, + }, + AdapterCapabilityStatus::Degraded { reason } => CapabilityWireStatus { + status: "degraded", + reason: Some(reason.clone()), + }, + AdapterCapabilityStatus::Unavailable { reason } => CapabilityWireStatus { + status: "unavailable", + reason: Some(reason.clone()), + }, + } + } +} + +#[derive(Debug, Serialize)] +struct CapabilitySnapshotPayload { + capture: CapabilityWireStatus, + hotkey: CapabilityWireStatus, + overlay: CapabilityWireStatus, + audio_input: CapabilityWireStatus, + audio_output: CapabilityWireStatus, + permissions: CapabilityWireStatus, + critical_blockers: Vec, +} + +impl From<&PlatformCapabilitySnapshot> for CapabilitySnapshotPayload { + fn from(platform_capability_snapshot: &PlatformCapabilitySnapshot) -> Self { + CapabilitySnapshotPayload { + capture: (&platform_capability_snapshot.capture).into(), + hotkey: (&platform_capability_snapshot.hotkey).into(), + overlay: (&platform_capability_snapshot.overlay).into(), + audio_input: (&platform_capability_snapshot.audio_input).into(), + audio_output: (&platform_capability_snapshot.audio_output).into(), + permissions: (&platform_capability_snapshot.permissions).into(), + critical_blockers: platform_capability_snapshot.critical_blockers(), + } + } +} + +#[tauri::command] +fn capability_snapshot() -> CapabilitySnapshotPayload { + let adapters = StubPlatformAdapters::new(); + let snapshot = adapters.capability_snapshot(); + (&snapshot).into() +} + +fn main() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![capability_snapshot]) + .run(tauri::generate_context!()) + .expect("failed to launch Skilly Windows host app"); +} diff --git a/apps/windows-shell-gui/tauri.conf.json b/apps/windows-shell-gui/tauri.conf.json new file mode 100644 index 00000000..0d1fe980 --- /dev/null +++ b/apps/windows-shell-gui/tauri.conf.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Skilly", + "version": "0.1.0", + "identifier": "app.tryskilly.skilly", + "build": { + "frontendDist": "dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Skilly", + "width": 360, + "height": 600, + "resizable": true, + "fullscreen": false, + "decorations": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": false, + "targets": "all", + "icon": [] + } +} diff --git a/apps/windows-shell/Cargo.toml b/apps/windows-shell/Cargo.toml new file mode 100644 index 00000000..ac3b9aeb --- /dev/null +++ b/apps/windows-shell/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skilly-windows-shell" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[lib] +name = "skilly_windows_shell" +path = "src/lib.rs" + +[[bin]] +name = "skilly-windows-shell" +path = "src/main.rs" + +[dependencies] +skilly-core-domain = { path = "../../core/domain" } +skilly-core-policy = { path = "../../core/policy" } +skilly-core-realtime = { path = "../../core/realtime" } diff --git a/apps/windows-shell/src/lib.rs b/apps/windows-shell/src/lib.rs new file mode 100644 index 00000000..8d6ac8c7 --- /dev/null +++ b/apps/windows-shell/src/lib.rs @@ -0,0 +1,445 @@ +//! Skilly Windows shell adapter contracts and stub implementations. +//! +//! This crate exposes: +//! - cross-platform adapter traits (Capture / Hotkey / Overlay / Audio / Permission) +//! - env-var-driven stub implementations used by the CI smoke binary +//! - the shared turn-flow harness that exercises auth + entitlement + replay +//! +//! Real Windows-specific adapter implementations will land here in subsequent +//! commits behind `#[cfg(target_os = "windows")]` modules. The GUI host crate +//! `apps/windows-shell-gui` consumes the same trait surface so adapters can be +//! swapped between stubs (dev) and real implementations (production) at the +//! shell boundary. + +use skilly_core_domain::{EntitlementState, PolicyConfig, PolicyInput}; +use skilly_core_policy::can_start_turn; +use skilly_core_realtime::{replay_events, RealtimeEvent}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdapterCapabilityStatus { + Available, + Degraded { reason: String }, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone)] +pub struct PlatformCapabilitySnapshot { + pub capture: AdapterCapabilityStatus, + pub hotkey: AdapterCapabilityStatus, + pub overlay: AdapterCapabilityStatus, + pub audio_input: AdapterCapabilityStatus, + pub audio_output: AdapterCapabilityStatus, + pub permissions: AdapterCapabilityStatus, +} + +impl PlatformCapabilitySnapshot { + pub fn critical_blockers(&self) -> Vec { + [ + ("capture", &self.capture), + ("hotkey", &self.hotkey), + ("overlay", &self.overlay), + ("permissions", &self.permissions), + ] + .iter() + .filter_map( + |(adapter_name, capability_status)| match capability_status { + AdapterCapabilityStatus::Unavailable { reason } => { + Some(format!("{adapter_name} unavailable: {reason}")) + } + _ => None, + }, + ) + .collect() + } +} + +pub trait CaptureAdapter { + fn capability(&self) -> AdapterCapabilityStatus; +} + +pub trait HotkeyAdapter { + fn capability(&self) -> AdapterCapabilityStatus; +} + +pub trait OverlayAdapter { + fn capability(&self) -> AdapterCapabilityStatus; +} + +pub trait AudioAdapter { + fn input_capability(&self) -> AdapterCapabilityStatus; + fn output_capability(&self) -> AdapterCapabilityStatus; +} + +pub trait PermissionAdapter { + fn capability(&self) -> AdapterCapabilityStatus; +} + +pub trait PlatformAdapters { + fn capability_snapshot(&self) -> PlatformCapabilitySnapshot; +} + +#[derive(Debug, Clone)] +pub struct AuthSession { + pub workos_user_id: String, + pub session_token: String, +} + +#[derive(Debug)] +pub struct ShellTurnFlowResult { + pub auth_user_id: String, + pub entitlement_state: EntitlementState, + pub allowed_to_start_turn: bool, + pub final_phase: String, + pub turns_completed: u64, + pub capability_snapshot: PlatformCapabilitySnapshot, +} + +pub fn entitlement_state_from_raw(entitlement_state_raw: &str) -> EntitlementState { + match entitlement_state_raw { + "none" => EntitlementState::None, + "trial" => EntitlementState::Trial, + "active" => EntitlementState::Active, + "canceled-valid" => EntitlementState::Canceled { + access_still_valid: true, + }, + "canceled-expired" => EntitlementState::Canceled { + access_still_valid: false, + }, + "expired" => EntitlementState::Expired, + _ => EntitlementState::Active, + } +} + +pub fn load_auth_session_from_env() -> Result { + let workos_user_id = std::env::var("SKILLY_WINDOWS_WORKOS_USER_ID") + .unwrap_or_else(|_| "windows-dev-user".to_string()); + let session_token = std::env::var("SKILLY_WINDOWS_SESSION_TOKEN") + .unwrap_or_else(|_| "windows-session-token".to_string()); + + if session_token.trim().is_empty() { + return Err("windows session token is empty".to_string()); + } + + Ok(AuthSession { + workos_user_id, + session_token, + }) +} + +pub fn resolve_entitlement_state_from_env() -> EntitlementState { + let entitlement_state_raw = std::env::var("SKILLY_WINDOWS_ENTITLEMENT_STATUS") + .unwrap_or_else(|_| "active".to_string()) + .to_lowercase(); + entitlement_state_from_raw(&entitlement_state_raw) +} + +pub fn run_turn_flow( + adapters: &A, + auth_session: AuthSession, + entitlement_state: EntitlementState, +) -> Result { + if auth_session.session_token.len() < 8 { + return Err("windows session token is too short".to_string()); + } + + let capability_snapshot = adapters.capability_snapshot(); + let critical_blockers = capability_snapshot.critical_blockers(); + if !critical_blockers.is_empty() { + return Err(format!( + "windows shell capability blockers: {}", + critical_blockers.join("; ") + )); + } + + let policy_input = PolicyInput { + user_id: Some(auth_session.workos_user_id.clone()), + entitlement_state: entitlement_state.clone(), + trial_seconds_used: std::env::var("SKILLY_WINDOWS_TRIAL_SECONDS_USED") + .ok() + .and_then(|raw_value| raw_value.parse::().ok()) + .unwrap_or(0), + usage_seconds_used: std::env::var("SKILLY_WINDOWS_USAGE_SECONDS_USED") + .ok() + .and_then(|raw_value| raw_value.parse::().ok()) + .unwrap_or(0), + }; + + let policy_decision = can_start_turn(&PolicyConfig::default(), &policy_input); + if !policy_decision.allowed { + return Err("policy blocked turn start".to_string()); + } + + let events = vec![ + RealtimeEvent::TurnStarted { + turn_id: "windows-turn-1".to_string(), + }, + RealtimeEvent::AudioCaptureCommitted { + turn_id: "windows-turn-1".to_string(), + }, + RealtimeEvent::AudioPlaybackStarted { + turn_id: "windows-turn-1".to_string(), + }, + RealtimeEvent::ResponseCompleted { + turn_id: "windows-turn-1".to_string(), + }, + ]; + let final_state = replay_events(&events) + .map_err(|replay_error| format!("replay failed: {replay_error:?}"))?; + + Ok(ShellTurnFlowResult { + auth_user_id: auth_session.workos_user_id, + entitlement_state, + allowed_to_start_turn: policy_decision.allowed, + final_phase: final_state.phase_name().to_string(), + turns_completed: final_state.turns_completed, + capability_snapshot, + }) +} + +pub mod stub { + //! Env-var-driven adapter implementations used by the CI smoke binary and + //! by the GUI host crate when running outside Windows production builds. + + use super::{ + AdapterCapabilityStatus, AudioAdapter, CaptureAdapter, HotkeyAdapter, OverlayAdapter, + PermissionAdapter, PlatformAdapters, PlatformCapabilitySnapshot, + }; + + pub fn map_windows_capture_mode(capture_mode: &str) -> AdapterCapabilityStatus { + if capture_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_CAPTURE_MODE=disabled".to_string(), + }; + } + if capture_mode == "dxgi-fallback" { + return AdapterCapabilityStatus::Degraded { + reason: "capture running with fallback mode".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + pub fn map_windows_hotkey_mode(hotkey_mode: &str) -> AdapterCapabilityStatus { + if hotkey_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_HOTKEY_MODE=disabled".to_string(), + }; + } + if hotkey_mode == "app-only" { + return AdapterCapabilityStatus::Degraded { + reason: "hotkey works only while shell window is focused".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + pub fn map_windows_overlay_mode(overlay_mode: &str) -> AdapterCapabilityStatus { + if overlay_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_OVERLAY_MODE=disabled".to_string(), + }; + } + if overlay_mode == "limited" { + return AdapterCapabilityStatus::Degraded { + reason: "overlay cannot draw across multiple monitors".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + pub fn map_windows_audio_input_mode(audio_input_mode: &str) -> AdapterCapabilityStatus { + if audio_input_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_AUDIO_INPUT=disabled".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + pub fn map_windows_audio_output_mode(audio_output_mode: &str) -> AdapterCapabilityStatus { + if audio_output_mode == "disabled" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_AUDIO_OUTPUT=disabled".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + pub fn map_windows_permission_state(permission_state: &str) -> AdapterCapabilityStatus { + if permission_state == "blocked" { + return AdapterCapabilityStatus::Unavailable { + reason: "SKILLY_WINDOWS_PERMISSION_STATE=blocked".to_string(), + }; + } + AdapterCapabilityStatus::Available + } + + #[derive(Debug, Default)] + pub struct StubCaptureAdapter; + + impl CaptureAdapter for StubCaptureAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let capture_mode = std::env::var("SKILLY_WINDOWS_CAPTURE_MODE") + .unwrap_or_else(|_| "graphics-capture".to_string()) + .to_lowercase(); + map_windows_capture_mode(&capture_mode) + } + } + + #[derive(Debug, Default)] + pub struct StubHotkeyAdapter; + + impl HotkeyAdapter for StubHotkeyAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let hotkey_mode = std::env::var("SKILLY_WINDOWS_HOTKEY_MODE") + .unwrap_or_else(|_| "global-hook".to_string()) + .to_lowercase(); + map_windows_hotkey_mode(&hotkey_mode) + } + } + + #[derive(Debug, Default)] + pub struct StubOverlayAdapter; + + impl OverlayAdapter for StubOverlayAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let overlay_mode = std::env::var("SKILLY_WINDOWS_OVERLAY_MODE") + .unwrap_or_else(|_| "layered-window".to_string()) + .to_lowercase(); + map_windows_overlay_mode(&overlay_mode) + } + } + + #[derive(Debug, Default)] + pub struct StubAudioAdapter; + + impl AudioAdapter for StubAudioAdapter { + fn input_capability(&self) -> AdapterCapabilityStatus { + let audio_input_mode = std::env::var("SKILLY_WINDOWS_AUDIO_INPUT") + .unwrap_or_else(|_| "wasapi".to_string()) + .to_lowercase(); + map_windows_audio_input_mode(&audio_input_mode) + } + + fn output_capability(&self) -> AdapterCapabilityStatus { + let audio_output_mode = std::env::var("SKILLY_WINDOWS_AUDIO_OUTPUT") + .unwrap_or_else(|_| "wasapi".to_string()) + .to_lowercase(); + map_windows_audio_output_mode(&audio_output_mode) + } + } + + #[derive(Debug, Default)] + pub struct StubPermissionAdapter; + + impl PermissionAdapter for StubPermissionAdapter { + fn capability(&self) -> AdapterCapabilityStatus { + let permission_state = std::env::var("SKILLY_WINDOWS_PERMISSION_STATE") + .unwrap_or_else(|_| "granted".to_string()) + .to_lowercase(); + map_windows_permission_state(&permission_state) + } + } + + #[derive(Debug, Default)] + pub struct StubPlatformAdapters { + pub capture: StubCaptureAdapter, + pub hotkey: StubHotkeyAdapter, + pub overlay: StubOverlayAdapter, + pub audio: StubAudioAdapter, + pub permissions: StubPermissionAdapter, + } + + impl StubPlatformAdapters { + pub fn new() -> Self { + Self::default() + } + } + + impl PlatformAdapters for StubPlatformAdapters { + fn capability_snapshot(&self) -> PlatformCapabilitySnapshot { + PlatformCapabilitySnapshot { + capture: self.capture.capability(), + hotkey: self.hotkey.capability(), + overlay: self.overlay.capability(), + audio_input: self.audio.input_capability(), + audio_output: self.audio.output_capability(), + permissions: self.permissions.capability(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stub::{map_windows_capture_mode, StubPlatformAdapters}; + + #[test] + fn windows_capture_mode_maps_degraded_and_unavailable() { + assert!(matches!( + map_windows_capture_mode("dxgi-fallback"), + AdapterCapabilityStatus::Degraded { .. } + )); + assert!(matches!( + map_windows_capture_mode("disabled"), + AdapterCapabilityStatus::Unavailable { .. } + )); + assert!(matches!( + map_windows_capture_mode("graphics-capture"), + AdapterCapabilityStatus::Available + )); + } + + #[test] + fn windows_entitlement_parser_handles_known_values() { + assert!(matches!( + entitlement_state_from_raw("trial"), + EntitlementState::Trial + )); + assert!(matches!( + entitlement_state_from_raw("canceled-valid"), + EntitlementState::Canceled { + access_still_valid: true + } + )); + assert!(matches!( + entitlement_state_from_raw("unknown"), + EntitlementState::Active + )); + } + + #[test] + fn critical_blockers_only_include_unavailable_capabilities() { + let snapshot = PlatformCapabilitySnapshot { + capture: AdapterCapabilityStatus::Degraded { + reason: "fallback".to_string(), + }, + hotkey: AdapterCapabilityStatus::Unavailable { + reason: "blocked".to_string(), + }, + overlay: AdapterCapabilityStatus::Available, + audio_input: AdapterCapabilityStatus::Available, + audio_output: AdapterCapabilityStatus::Available, + permissions: AdapterCapabilityStatus::Unavailable { + reason: "policy".to_string(), + }, + }; + + let blockers = snapshot.critical_blockers(); + assert_eq!(blockers.len(), 2); + assert!(blockers + .iter() + .any(|entry| entry.contains("hotkey unavailable"))); + assert!(blockers + .iter() + .any(|entry| entry.contains("permissions unavailable"))); + } + + #[test] + fn stub_platform_adapters_yield_available_snapshot_under_defaults() { + let adapters = StubPlatformAdapters::new(); + let snapshot = adapters.capability_snapshot(); + assert!(snapshot.critical_blockers().is_empty()); + } +} diff --git a/apps/windows-shell/src/main.rs b/apps/windows-shell/src/main.rs new file mode 100644 index 00000000..f3d159ee --- /dev/null +++ b/apps/windows-shell/src/main.rs @@ -0,0 +1,54 @@ +//! CI smoke binary for the Windows shell adapter contract. +//! +//! All adapter trait definitions, stub implementations, and the turn-flow +//! harness live in `skilly_windows_shell` (this crate's library target). This +//! binary is a thin CLI wrapper that exercises the env-var-driven stubs end +//! to end so the workspace CI can detect contract regressions without needing +//! a real Windows host. + +use skilly_windows_shell::{ + load_auth_session_from_env, resolve_entitlement_state_from_env, run_turn_flow, + stub::StubPlatformAdapters, +}; + +fn main() { + let smoke_requested = std::env::args().any(|argument| argument == "--smoke"); + if !smoke_requested { + println!("skilly-windows-shell adapters ready (use --smoke)"); + return; + } + + let auth_session = match load_auth_session_from_env() { + Ok(session) => session, + Err(error_message) => { + eprintln!("windows shell flow failed: {error_message}"); + std::process::exit(1); + } + }; + + let entitlement_state = resolve_entitlement_state_from_env(); + let adapters = StubPlatformAdapters::new(); + + match run_turn_flow(&adapters, auth_session, entitlement_state) { + Ok(flow_result) => { + println!( + "windows shell flow passed: user={} entitlement={:?} allowed={} phase={} turns_completed={} capture={:?} hotkey={:?} overlay={:?} audio_in={:?} audio_out={:?} permissions={:?}", + flow_result.auth_user_id, + flow_result.entitlement_state, + flow_result.allowed_to_start_turn, + flow_result.final_phase, + flow_result.turns_completed, + flow_result.capability_snapshot.capture, + flow_result.capability_snapshot.hotkey, + flow_result.capability_snapshot.overlay, + flow_result.capability_snapshot.audio_input, + flow_result.capability_snapshot.audio_output, + flow_result.capability_snapshot.permissions + ); + } + Err(error_message) => { + eprintln!("windows shell flow failed: {error_message}"); + std::process::exit(1); + } + } +} diff --git a/docs/architecture/phase-7-windows-shell-prd.md b/docs/architecture/phase-7-windows-shell-prd.md new file mode 100644 index 00000000..ec519231 --- /dev/null +++ b/docs/architecture/phase-7-windows-shell-prd.md @@ -0,0 +1,218 @@ +# PRD Addendum: Phase 7 — Windows Host App (Rust + Tauri) + +Status: draft +Date: 2026-04-27 +Branch: `feature/skills-bridge-swift` +Supersedes section "Open Questions #2" of the parent PRD (`rust-core-native-shells-prd.md`). + +## Summary +Phase 7 ships the first real native host app built on the Rust core: a Windows desktop application using Tauri 2. This is no longer a CLI smoke binary — it is a code-signed, installer-distributed, end-user app that runs the Skilly teaching companion on Windows. + +This addendum also commits to a follow-on Phase 8 in which the macOS host migrates from the existing SwiftUI implementation to the same Rust + Tauri stack, deprecating the Swift host. Phase 8 is out of scope for this PRD but is named here so Phase 7 design choices can be made with that future migration in mind. + +## Strategy Pivot Acknowledgment +The parent PRD's non-goal #1 was "Rewrite current macOS UI in a cross-platform UI framework." Phase 7 + 8 together reverse that decision. The reasons: +1. Phase 5 marked "Real Platform Adapters" complete, but the Windows/Linux adapters are env-var-driven stubs. Shipping a real Windows app forces real adapter implementations. +2. Maintaining two host implementations long-term (SwiftUI on macOS, fresh Rust on Windows) doubles the surface area indefinitely. +3. The Rust core is now stable; building a host on top of it is the next blocking-deliverable. +4. macOS Skilly v1.5 keeps shipping on the SwiftUI host while Phase 7 progresses, so revenue continuity is preserved. + +## Problem +Today there is no shippable Skilly experience on Windows. The Rust core, FFI surface, and adapter contracts exist, but the Windows shell binary cannot be installed by an end user, has no UI, no audio pipeline, no real screen capture, no overlay, and no auth flow. The macOS-to-Windows TAM gap is a meaningful business cost. + +## Goals +1. Ship a code-signed, installer-distributed Windows desktop app named "Skilly" with bundle identity `app.tryskilly.skilly`. +2. Reuse the existing Rust core (`core/policy`, `core/skills`, `core/realtime`, `core/domain`) without forking logic. +3. Reuse the existing Cloudflare Worker proxy unchanged — no new endpoints, no client-side API keys. +4. Achieve a v1 that delivers the core teaching loop: push-to-talk → screen capture → realtime AI response → cursor pointing. +5. Establish the Windows release pipeline (signing, MSI/MSIX/NSIS, auto-update) so future versions can ship with low overhead. +6. Set patterns Phase 8 (macOS migration) can adopt directly. + +## Non-goals (v1 scope cuts) +1. In-app subscription/Polar checkout UI — exhausted free trial redirects users to web checkout. +2. Multi-monitor cursor overlay — primary monitor only. +3. CapReachedModal and SubscriptionRequiredModal flows — v1 is free-trial only; paid users handled through web checkout for now. +4. Per-skill overflow menu (Pause/Resume/Reset/Show in Finder/Remove) — only Activate / Deactivate. +5. Settings tabs beyond Account and push-to-talk customization. +6. Admin allowlist UI surface. +7. Skill marketplace UI — drag-drop folder install only. +8. Custom voice picker — server-default voice only. +9. Telemetry JSONL viewer / debug surfaces. +10. Linux host app — explicit deferral to Phase 9+. + +## Users +- New Windows users discovering Skilly for the first time. +- Existing macOS subscribers asking when Windows ships. +- QA / release: validates installer, code-signing, auto-update, baseline teaching loop. + +## Platform Floor +Minimum supported: **Windows 11 22H2 (build 22621) on x64 and ARM64.** + +Rationale: +1. Windows Graphics Capture API (`Windows.Graphics.Capture`) is stable and supported on this baseline; pre-22H2 has multi-monitor coordinate quirks we will not work around in v1. +2. WebView2 Evergreen runtime is universal on this baseline (Tauri requirement). +3. Drops the long tail of Win 10 servicing branches that we cannot QA solo. + +Windows 10 support is explicitly deferred. Users below 22H2 are shown an unsupported-version notice in the installer. + +## UI Stack Decision: Tauri 2 +Selected over alternatives: + +| Option | Verdict | Reason | +|---|---|---| +| Tauri 2 | **Selected** | Rust backend co-located with core; web frontend reuses HTML/CSS skill from Worker landing pages; tray + global hotkey + WebView2 packaged; ~5–10 MB installer baseline | +| egui | Rejected | Pure-Rust UI is great for tools but companion-panel polish (animations, blur, custom shadows) is a high cost in immediate-mode UI | +| Native Win32 + WebView2 | Rejected | Reinvents what Tauri already provides; more brittle integration with `windows-rs` | +| Slint | Rejected | Smaller community, fewer Windows-specific integrations available off the shelf | +| .NET / Avalonia / WinUI 3 | Rejected | Brings a non-Rust runtime; conflicts with Phase 8 unification | + +Constraint: the chosen stack must be the same one Phase 8 (macOS migration) adopts. Tauri 2 satisfies this — it ships a working macOS target. + +## Architecture + +``` +Tauri 2 app (Windows host) +├── Frontend (TypeScript + WebView2) +│ ├── Companion panel UI (port of CompanionPanelView design tokens) +│ ├── Tray menu +│ ├── Settings (Account, Push-to-Talk) +│ └── Auth deep-link landing +├── Tauri commands (Rust) +│ ├── push_to_talk_pressed / released +│ ├── start_session / end_session +│ ├── activate_skill / deactivate_skill +│ ├── get_entitlement / start_checkout (opens web) +│ └── apply_settings +└── Rust subsystems + ├── skilly-core-domain (existing, unchanged) + ├── skilly-core-policy (existing, unchanged) + ├── skilly-core-skills (existing, unchanged) + ├── skilly-core-realtime (existing, unchanged) + ├── skilly-core-ffi (existing — used directly, not via dylib) + ├── windows-shell::adapters + │ ├── capture → windows-rs Graphics Capture API + │ ├── hotkey → RegisterHotKey for primary path; RAW_INPUT fallback + │ ├── overlay → layered window via windows-rs + │ ├── audio_in → wasapi crate at PCM16 16 kHz mono + │ ├── audio_out → wasapi crate at PCM16 24 kHz + │ ├── auth → reqwest + skilly:// URL protocol handler + │ ├── store → Windows Credential Manager via windows-rs + │ └── realtime → tokio-tungstenite OpenAI Realtime client (Rust port of OpenAIRealtimeClient.swift) + └── windows-shell::ui_bridge (Tauri command surface) +``` + +Key principles: +1. The existing CLI smoke binary `apps/windows-shell/src/main.rs` is preserved and continues to gate CI; the GUI app is a new crate `apps/windows-shell-gui` that depends on the same adapters once they are real. +2. Adapter modules expose the env-var stubs in test mode and real APIs in production mode behind a `cfg!` switch. Existing `--smoke` flow keeps passing. +3. The Worker proxy is reused unchanged. No new endpoints. The OpenAI client secret is fetched the same way. +4. The same `appcast.xml` feed used by macOS Sparkle is reused for `tauri-updater` after a feed format adapter (`appcast-windows.xml` or shared format). + +## Functional Requirements + +### Auth +1. Sign-in opens default browser via `tauri-plugin-shell` to Worker `/auth/url?state=…` exactly like macOS. +2. `skilly://auth/callback` URL protocol is registered by the installer (`HKCU\Software\Classes\skilly`). +3. Tokens stored in Windows Credential Manager keyed by app identifier. +4. Sign-out clears credentials and returns user to signed-out tray state. + +### Billing (v1 scope) +1. 15-minute lifetime free trial enforced by Rust `policy::can_start_turn` against `TrialTracker` state. +2. On trial exhaustion, app shows a tray notification and opens Worker `/checkout/create` in default browser. +3. After web checkout completes, app polls `/entitlement` and updates local trial/usage state. +4. No in-app paywall modals in v1. + +### Push-to-Talk +1. Default chord: `Ctrl + Alt`. Customizable in Settings. +2. `RegisterHotKey` is primary mechanism; if unavailable (locked-down enterprise machines), fall back to `RAW_INPUT` listener tied to a hidden window. +3. Hold-to-record semantics matching macOS: press starts capture, release commits the turn. + +### Capture +1. Windows Graphics Capture API for primary monitor. +2. JPEG encoding before send via `image` crate. +3. Multi-monitor support deferred to v2. + +### Overlay +1. Layered, click-through, always-on-top window for the Skilly cursor. +2. SwiftUI `OverlayWindow.swift` bezier flight animation ported to canvas-based rendering (HTML canvas inside an additional Tauri window, or `windows-rs` direct draw). +3. Response bubble follows cursor; auto-hide after 6 seconds matching `CompanionResponseOverlay`. +4. Single primary monitor only in v1. + +### Audio +1. Capture: WASAPI shared-mode at 16 kHz mono PCM16. +2. Playback: WASAPI shared-mode at 24 kHz PCM16. +3. Default device only in v1; device picker deferred. + +### Realtime +1. Rust port of `OpenAIRealtimeClient.swift` using `tokio-tungstenite` against `wss://api.openai.com/v1/realtime`. +2. Same event surface that the existing Swift client emits, mapped through `core/realtime`. +3. Telemetry JSONL written to `%LOCALAPPDATA%\Skilly\skilly-telemetry.jsonl`. + +### Skills +1. Loader reads `%APPDATA%\Skilly\skills\` on launch. +2. Drag-drop install: dropping a folder onto the panel or tray icon copies it into the skills directory. +3. Bundled skills shipped in installer payload, seeded on first launch (matches macOS `SkillStore` behavior). +4. Activation/deactivation only — no per-skill overflow menu. + +### Tray + Panel +1. Tray icon with menu: Open Panel / Toggle Active Skill / Settings / Quit. +2. Panel: borderless WebView2 window, 360 × 600 logical units, dark theme matching macOS panel. +3. Hover-to-show / click-outside-to-hide behavior. + +## Release Pipeline +1. Code signing: Apply for an EV code signing certificate (DigiCert / Sectigo / SSL.com). Budget 1–3 weeks lead time and ~$300–500/year. Track procurement as a critical-path item. +2. Installer: NSIS via `tauri-bundler` — produces `Skilly-{version}-setup.exe`. MSIX deferred; MSI not required for v1. +3. Auto-update: `tauri-updater` plugin against `appcast-windows.xml` hosted at the same domain as macOS appcast. +4. Distribution: GitHub Releases (matches existing macOS distribution); landing page download CTA on `tryskilly.app`. +5. Telemetry: PostHog identify with WorkOS user ID, mirroring macOS event names where applicable. + +## Success Metrics +1. Installer downloads from GitHub Releases reach 100 in first 4 weeks post-launch. +2. End-to-end teaching loop (sign-in → activate skill → push-to-talk → cursor points) verified on 3 different physical Windows 11 22H2+ machines before launch. +3. Crash-free session rate ≥ 95% over the first 1,000 turns measured via PostHog. +4. Trial-to-checkout conversion rate measurable (i.e., the redirect-to-web flow does not silently break). +5. Zero regression in macOS release pipeline during Phase 7. + +## Risks and Mitigations +| Risk | Mitigation | +|---|---| +| EV cert procurement delay blocks ship | Order in week 1, do all dev unsigned, sign at the very end | +| Windows Graphics Capture quirks on specific GPU drivers | Limit support to Win 11 22H2+ where API is stable; add capability probe at startup | +| Layered window + DPI scaling edge cases | Abstract DPI math behind one helper module with unit tests; QA on 100% / 125% / 150% / 200% scaling | +| WebView2 runtime missing on user machine | Bundle the Evergreen redistributable in the installer | +| Realtime WebSocket port introduces drift vs Swift | Port file-by-file with a side-by-side parity test that replays the same fixture transcripts through both clients | +| Auto-update format divergence from macOS Sparkle | Define a shared appcast schema upfront; document in `docs/architecture/appcast-schema.md` | +| Solo dev burnout on 6–10 week scope | Stage releases: alpha → closed beta → public — each stage cuts QA time by gating audience size | + +## Timeline (target, solo dev) +- Week 1: PRD freeze; spike — Tauri shell + tray + global hotkey + WASAPI loopback; EV cert order placed. +- Weeks 2–3: Capture + overlay adapters; OpenAI Realtime Rust client port begins. +- Week 4: Auth flow + Credential Manager + Worker integration; skill loader. +- Week 5: Realtime client complete; teaching loop end-to-end on dev machine. +- Week 6: Settings UI + push-to-talk customization + telemetry; bundled skills seeding. +- Week 7: Installer + auto-update plumbing; closed alpha to ~5 testers. +- Week 8: Polish, crash fixes, EV cert (assuming arrived by now); public beta. +- Week 9–10: Buffer for installer/signing surprises. + +## Out-of-scope (deferred to Phase 7.x or Phase 8) +1. macOS migration to the same Tauri stack — Phase 8. +2. Linux host app — Phase 9. +3. Multi-monitor overlay. +4. In-app subscription / Polar checkout UI on Windows. +5. Skill marketplace UI. +6. Per-skill overflow menu parity with macOS. +7. Custom voice picker. +8. Sub-Win-11 support. + +## Open Questions +1. Should the appcast feed be unified (`appcast.xml` with platform-suffixed URLs) or split (`appcast-mac.xml` / `appcast-windows.xml`)? Default to split; revisit at Phase 8. +2. Should the trial duration on Windows match macOS (15 minutes lifetime) or be relaxed for a launch promotion (e.g., 30 minutes)? Default to match macOS for fairness; revisit pre-launch. +3. Does Windows-first launch require localization (DE / FR / JA) or can it ship English-only? Default English-only for v1. +4. Should we offer a one-time lifetime price tier on Windows v1 to acquire early adopters? Defer to product owner decision before week 6. + +## Acceptance Criteria for Phase 7 Closure +1. Signed installer produces a working Skilly install on a clean Windows 11 22H2 VM. +2. Sign-in via WorkOS works end-to-end, tokens persist across app restart. +3. Push-to-talk → capture → realtime response → cursor pointing observed working with the bundled Blender skill. +4. Auto-update successfully upgrades from version N to N+1 on the same machine. +5. Telemetry events flow to PostHog tagged with `platform: windows`. +6. macOS release pipeline produces an unrelated v1.6 release without regression during Phase 7 development.