diff --git a/Cargo.lock b/Cargo.lock index 45fe80387..330771d91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3168,6 +3168,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "op-auth-bridge" +version = "0.8.2" +dependencies = [ + "serde_json", +] + [[package]] name = "op-cli" version = "0.8.2" @@ -3315,6 +3322,7 @@ dependencies = [ "op-acp", "op-ai", "op-ai-skills", + "op-auth-bridge", "op-codegen", "op-config-store", "op-design-lint", @@ -3358,6 +3366,7 @@ dependencies = [ "jian-skia", "khronos-egl", "libloading", + "op-auth-bridge", "op-editor-core", "op-editor-ui", "op-i18n", diff --git a/crates/op-auth-bridge/Cargo.toml b/crates/op-auth-bridge/Cargo.toml new file mode 100644 index 000000000..e2a092914 --- /dev/null +++ b/crates/op-auth-bridge/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "op-auth-bridge" +description = "Thin bridge to the proprietary op-auth static library, with a stub fallback" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +serde_json = { workspace = true } diff --git a/crates/op-auth-bridge/build.rs b/crates/op-auth-bridge/build.rs new file mode 100644 index 000000000..1ad8015b7 --- /dev/null +++ b/crates/op-auth-bridge/build.rs @@ -0,0 +1,33 @@ +//! Link the prebuilt proprietary auth library when one exists for the +//! current target; otherwise the crate compiles its stub and the account +//! UI stays hidden. Open-source checkouts therefore always build. + +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(op_auth_prebuilt)"); + println!("cargo:rerun-if-changed=prebuilt"); + + let target = env::var("TARGET").unwrap_or_default(); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let prebuilt_dir = manifest_dir.join("prebuilt").join(&target); + if !prebuilt_dir.join("libop_auth.a").is_file() { + return; + } + + println!("cargo:rustc-cfg=op_auth_prebuilt"); + println!("cargo:rustc-link-search=native={}", prebuilt_dir.display()); + println!("cargo:rustc-link-lib=static=op_auth"); + + // System libraries the static library's TLS/network stack expects. + if target.contains("apple-darwin") { + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + } else if target.contains("windows-msvc") { + println!("cargo:rustc-link-lib=ws2_32"); + println!("cargo:rustc-link-lib=bcrypt"); + println!("cargo:rustc-link-lib=advapi32"); + println!("cargo:rustc-link-lib=ntdll"); + } +} diff --git a/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/VERSION b/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/VERSION new file mode 100644 index 000000000..ee94dd834 --- /dev/null +++ b/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/VERSION @@ -0,0 +1 @@ +0.8.3 diff --git a/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/libop_auth.a b/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/libop_auth.a new file mode 100644 index 000000000..2667abbb2 Binary files /dev/null and b/crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/libop_auth.a differ diff --git a/crates/op-auth-bridge/src/lib.rs b/crates/op-auth-bridge/src/lib.rs new file mode 100644 index 000000000..3e570ed71 --- /dev/null +++ b/crates/op-auth-bridge/src/lib.rs @@ -0,0 +1,94 @@ +//! Bridge to the proprietary op-auth client library. +//! +//! The real device-login implementation lives in the private +//! `ZSeven-W/op-platform` repository and ships as a prebuilt C-ABI static +//! library committed under `prebuilt//libop_auth.a`. When no +//! artifact exists for the current target — or its ABI version does not +//! match [`REQUIRED_ABI_VERSION`] — this crate falls back to a stub whose +//! [`available`] returns false, and hosts keep the account UI hidden. +//! +//! Poll model: [`login_begin`] returns a handle the host polls each frame +//! via [`poll`]; handle [`SESSION_HANDLE`] reports the restored/signed-in +//! session. All strings cross the FFI as UTF-8 pointer + length and are +//! freed by the bridge immediately after copying. + +mod status; + +#[cfg(op_auth_prebuilt)] +#[path = "real.rs"] +mod backend; + +#[cfg(not(op_auth_prebuilt))] +#[path = "stub.rs"] +mod backend; + +use std::path::PathBuf; + +pub use status::AuthStatus; + +/// ABI revision this bridge understands; must match the library's +/// `op_auth_abi_version()`. +pub const REQUIRED_ABI_VERSION: u32 = 1; + +/// `poll` handle that reports the signed-in session instead of a flow. +pub const SESSION_HANDLE: u64 = 0; + +/// Everything the runtime needs at startup. +#[derive(Clone, Debug)] +pub struct AuthInitConfig { + /// SSO origin, e.g. `https://sso.zseven.cn` (override via + /// `OPENPENCIL_SSO_URL` for local development). + pub base_url: String, + /// Directory owning the persisted credential file. + pub storage_dir: PathBuf, + pub device_name: String, + pub app_version: String, +} + +/// Whether a working auth backend is linked into this build. +pub fn available() -> bool { + backend::available() +} + +/// Initialize the runtime once per process; returns false when the +/// backend is unavailable or already initialized. +pub fn init(config: &AuthInitConfig) -> bool { + backend::init(config) +} + +/// Load a persisted session, if any; the profile then arrives via +/// `poll(SESSION_HANDLE)`. Returns whether a credential was restored. +pub fn restore() -> bool { + backend::restore() +} + +/// Start a browser login and return the flow handle (0 when unavailable). +pub fn login_begin() -> u64 { + backend::login_begin() +} + +/// Poll a flow handle (or [`SESSION_HANDLE`]) for its current status. +pub fn poll(handle: u64) -> AuthStatus { + backend::poll(handle) +} + +/// Abort an in-flight login flow. +pub fn cancel(handle: u64) { + backend::cancel(handle) +} + +/// Drop the local session and revoke the device token server-side. +pub fn sign_out() { + backend::sign_out() +} + +/// The zseven-sso platform identifier for this build target. +pub fn platform_id() -> &'static str { + if cfg!(target_os = "macos") { + "desktop_macos" + } else if cfg!(target_os = "windows") { + "desktop_windows" + } else { + "desktop_linux" + } +} diff --git a/crates/op-auth-bridge/src/real.rs b/crates/op-auth-bridge/src/real.rs new file mode 100644 index 000000000..976246968 --- /dev/null +++ b/crates/op-auth-bridge/src/real.rs @@ -0,0 +1,112 @@ +//! Backend backed by the prebuilt proprietary static library. Compiled +//! only when `build.rs` found `prebuilt//libop_auth.a`; the ABI +//! handshake still guards against stale artifacts at runtime. + +use std::sync::OnceLock; + +use crate::status::AuthStatus; +use crate::{AuthInitConfig, REQUIRED_ABI_VERSION}; + +#[repr(C)] +struct OpAuthStatus { + code: i32, + payload: *mut u8, + payload_len: usize, +} + +extern "C" { + fn op_auth_abi_version() -> u32; + fn op_auth_runtime_init(config_json: *const u8, len: usize) -> bool; + fn op_auth_restore() -> i32; + fn op_auth_login_begin() -> u64; + fn op_auth_poll(handle: u64, out: *mut OpAuthStatus) -> i32; + fn op_auth_cancel(handle: u64); + fn op_auth_sign_out(); + fn op_auth_string_free(ptr: *mut u8, len: usize); +} + +pub(crate) fn available() -> bool { + static ABI_OK: OnceLock = OnceLock::new(); + *ABI_OK.get_or_init(|| { + // SAFETY: no arguments; the symbol exists whenever this module compiles. + let version = unsafe { op_auth_abi_version() }; + let matches = version == REQUIRED_ABI_VERSION; + if !matches { + eprintln!( + "op-auth-bridge: prebuilt library ABI {version} does not match \ + required {REQUIRED_ABI_VERSION}; account features disabled" + ); + } + matches + }) +} + +pub(crate) fn init(config: &AuthInitConfig) -> bool { + if !available() { + return false; + } + let json = serde_json::json!({ + "base_url": config.base_url, + "storage_dir": config.storage_dir.to_string_lossy(), + "device_name": config.device_name, + "platform": crate::platform_id(), + "app_version": config.app_version, + }) + .to_string(); + // SAFETY: pointer/length describe the live `json` buffer. + unsafe { op_auth_runtime_init(json.as_ptr(), json.len()) } +} + +pub(crate) fn restore() -> bool { + if !available() { + return false; + } + // SAFETY: no pointer arguments. + unsafe { op_auth_restore() == 1 } +} + +pub(crate) fn login_begin() -> u64 { + if !available() { + return 0; + } + // SAFETY: no pointer arguments. + unsafe { op_auth_login_begin() } +} + +pub(crate) fn poll(handle: u64) -> AuthStatus { + if !available() { + return AuthStatus::Idle; + } + let mut raw = OpAuthStatus { + code: 0, + payload: std::ptr::null_mut(), + payload_len: 0, + }; + // SAFETY: `raw` is a valid out-pointer for the duration of the call. + let code = unsafe { op_auth_poll(handle, &mut raw) }; + let payload = if raw.payload.is_null() { + None + } else { + // SAFETY: the library handed us `payload_len` readable bytes that we + // free exactly once after copying. + let bytes = unsafe { std::slice::from_raw_parts(raw.payload, raw.payload_len) }; + let text = String::from_utf8_lossy(bytes).into_owned(); + unsafe { op_auth_string_free(raw.payload, raw.payload_len) }; + Some(text) + }; + AuthStatus::decode(code, payload.as_deref()) +} + +pub(crate) fn cancel(handle: u64) { + if available() { + // SAFETY: no pointer arguments. + unsafe { op_auth_cancel(handle) }; + } +} + +pub(crate) fn sign_out() { + if available() { + // SAFETY: no pointer arguments. + unsafe { op_auth_sign_out() }; + } +} diff --git a/crates/op-auth-bridge/src/status.rs b/crates/op-auth-bridge/src/status.rs new file mode 100644 index 000000000..43fcd6755 --- /dev/null +++ b/crates/op-auth-bridge/src/status.rs @@ -0,0 +1,114 @@ +//! Status snapshots surfaced to hosts, decoded from FFI payload JSON. + +/// Mirror of the library's status codes; payload shapes documented per +/// variant. Unknown codes decode to [`AuthStatus::Idle`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuthStatus { + /// Session handle: signed out. Flow handle: unknown handle. + Idle, + /// Login flow: start request in flight. + Starting, + /// Waiting for the user to approve in the browser; the host opens + /// `verification_uri` the first time it observes this state. + WaitingApproval { + verification_uri: String, + }, + /// Approved; exchanging the pairing for a device token. + Exchanging, + /// Signed in (terminal for flows; steady state for the session). + SignedIn { + display_name: String, + primary_email: Option, + avatar_url: Option, + device_id: String, + }, + /// Terminal failure; `code` is one of `denied`, `expired`, `network`, + /// `protocol`, `server`. + Error { + code: String, + }, + Canceled, +} + +impl AuthStatus { + pub fn is_terminal(&self) -> bool { + matches!( + self, + AuthStatus::SignedIn { .. } | AuthStatus::Error { .. } | AuthStatus::Canceled + ) + } + + /// Decode one FFI observation. Used by the real backend; the stub + /// never produces payloads (hence dead code in stub builds). + #[cfg_attr(not(op_auth_prebuilt), allow(dead_code))] + pub(crate) fn decode(code: i32, payload: Option<&str>) -> Self { + let field = |key: &str| -> Option { + let parsed: serde_json::Value = serde_json::from_str(payload?).ok()?; + parsed[key].as_str().map(str::to_string) + }; + match code { + 1 => AuthStatus::Starting, + 2 => AuthStatus::WaitingApproval { + verification_uri: field("verification_uri").unwrap_or_default(), + }, + 3 => AuthStatus::Exchanging, + 4 => AuthStatus::SignedIn { + display_name: field("display_name").unwrap_or_default(), + primary_email: field("primary_email"), + avatar_url: field("avatar_url"), + device_id: field("device_id").unwrap_or_default(), + }, + 5 => AuthStatus::Error { + code: field("code").unwrap_or_else(|| "protocol".to_string()), + }, + 6 => AuthStatus::Canceled, + _ => AuthStatus::Idle, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_signed_in_payload() { + let payload = r#"{"display_name":"Kay","primary_email":"kay@example.com","avatar_url":null,"device_id":"d1"}"#; + assert_eq!( + AuthStatus::decode(4, Some(payload)), + AuthStatus::SignedIn { + display_name: "Kay".to_string(), + primary_email: Some("kay@example.com".to_string()), + avatar_url: None, + device_id: "d1".to_string(), + } + ); + } + + #[test] + fn decodes_waiting_approval_and_error() { + assert_eq!( + AuthStatus::decode(2, Some(r#"{"verification_uri":"https://x/login"}"#)), + AuthStatus::WaitingApproval { + verification_uri: "https://x/login".to_string() + } + ); + assert_eq!( + AuthStatus::decode(5, Some(r#"{"code":"denied"}"#)), + AuthStatus::Error { + code: "denied".to_string() + } + ); + } + + #[test] + fn unknown_codes_and_garbage_payloads_are_safe() { + assert_eq!(AuthStatus::decode(99, None), AuthStatus::Idle); + assert_eq!( + AuthStatus::decode(5, Some("not json")), + AuthStatus::Error { + code: "protocol".to_string() + } + ); + } +} diff --git a/crates/op-auth-bridge/src/stub.rs b/crates/op-auth-bridge/src/stub.rs new file mode 100644 index 000000000..885911c00 --- /dev/null +++ b/crates/op-auth-bridge/src/stub.rs @@ -0,0 +1,52 @@ +//! Fallback backend for builds without a prebuilt auth library: every +//! operation is a no-op and `available()` is false, so hosts keep the +//! account UI hidden while the build stays green on every platform. + +use crate::status::AuthStatus; +use crate::AuthInitConfig; + +pub(crate) fn available() -> bool { + false +} + +pub(crate) fn init(_config: &AuthInitConfig) -> bool { + false +} + +pub(crate) fn restore() -> bool { + false +} + +pub(crate) fn login_begin() -> u64 { + 0 +} + +pub(crate) fn poll(_handle: u64) -> AuthStatus { + AuthStatus::Idle +} + +pub(crate) fn cancel(_handle: u64) {} + +pub(crate) fn sign_out() {} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn stub_is_inert() { + assert!(!available()); + assert!(!init(&AuthInitConfig { + base_url: "https://sso.example".to_string(), + storage_dir: PathBuf::from("/tmp"), + device_name: "test".to_string(), + app_version: "0.0.0".to_string(), + })); + assert!(!restore()); + assert_eq!(login_begin(), 0); + assert_eq!(poll(0), AuthStatus::Idle); + cancel(1); + sign_out(); + } +} diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 5b8abd9c3..56331948c 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -82,6 +82,8 @@ op-editor-core = { path = "../op-editor-core" } op-editor-host-core = { path = "../op-editor-host-core" } # Shared `~/.openpencil` well-known paths used by CLI and desktop discovery. op-config-store = { path = "../op-config-store" } +# Device-login bridge (prebuilt proprietary library or inert stub). +op-auth-bridge = { path = "../op-auth-bridge" } # D′ architecture: pre-validation adapter translates PlannedFix → EditorCommand. op-design-lint = { path = "../op-design-lint" } # AI code-generation pipeline. The `ai` feature compiles the pull-based diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index ac5afee59..4b45a3060 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -868,6 +868,11 @@ impl ApplicationHandler for DesktopApp { if self.poll_update_probe() { self.redraw_dirty = true; } + // Drain the browser device-login flow (status + browser + // opens land here). + if self.poll_auth_flow() { + self.redraw_dirty = true; + } // Drain a finished background `git pull`. if self.poll_git_pull_job() { self.redraw_dirty = true; @@ -1656,6 +1661,7 @@ impl DesktopApp { let deadline = self.clock_start + Duration::from_millis(deadline_ms); event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); } else if self.update_probe.is_pending() + || self.host.auth_flow_active() || self.model_probe.is_pending() || self.image_search.is_pending() || self.image_panel.is_pending() @@ -1726,6 +1732,7 @@ impl DesktopApp { || self.pending_html_paste.is_some() || self.host.next_animation_deadline_ms().is_some() || self.update_probe.is_pending() + || self.host.auth_flow_active() || self.model_probe.is_pending() || self.image_search.is_pending() || self.image_panel.is_pending() diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index eaf6d8b4c..ceece58c8 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -393,6 +393,13 @@ impl DesktopApp { // — the picker paints the Import row + imported group here, and // web leaves the default `false` so those controls stay hidden. host.editor_state_mut().editor_ui.font_import_supported = true; + // Account gate + session restore. The bridge links the proprietary + // auth library when a prebuilt exists for this target; stub builds + // keep every account entry point hidden unless the dev fake-login + // env is set. Skipped under test like the update / model probes. + if !cfg!(test) { + init_auth_runtime(&mut host); + } let kit_browser_open_persisted = Some(host.editor_state().editor_ui.component_browser_open); if fit_blank_frame { host.fit_content_to_viewport(INITIAL_VIEWPORT_W, INITIAL_VIEWPORT_H); @@ -870,6 +877,17 @@ impl DesktopApp { } } + /// Drain the browser device-login flow: fold bridge status polls + /// into UI state and open the verification page (exactly once per + /// flow) in the system browser. + fn poll_auth_flow(&mut self) -> bool { + let changed = self.host.poll_auth(); + if let Some(url) = self.host.take_pending_browser_url() { + update_check::open_url(&url); + } + changed + } + /// Drain the background auto-update probe into `update_status`. /// When the probe reports a newer release, offer to open the /// download page — once per check. @@ -1113,6 +1131,46 @@ fn parse_live_mcp_port>(args: I) -> Option { None } +/// Initialize the device-login runtime (proprietary bridge library) and +/// restore a persisted session, then set the runtime account gate. Stub +/// builds (no prebuilt library for this target) leave the gate closed +/// unless `OPENPENCIL_DEV_FAKE_LOGIN=1` re-opens it for UI work. +fn init_auth_runtime(host: &mut WidgetHostNative) { + let dev_fake = std::env::var("OPENPENCIL_DEV_FAKE_LOGIN").as_deref() == Ok("1"); + let mut backend_ready = false; + if op_auth_bridge::available() { + if let Ok(dir) = op_config_store::openpencil_dir() { + let config = op_auth_bridge::AuthInitConfig { + // Local-dev override: point the flow at a locally served + // zseven-sso (e.g. http://127.0.0.1:5173). + base_url: std::env::var("OPENPENCIL_SSO_URL") + .unwrap_or_else(|_| "https://sso.zseven.cn".to_string()), + storage_dir: dir.join("auth"), + device_name: format!("OpenPencil Desktop ({})", std::env::consts::OS), + app_version: env!("CARGO_PKG_VERSION").to_string(), + }; + if op_auth_bridge::init(&config) { + backend_ready = true; + if op_auth_bridge::restore() { + if let op_auth_bridge::AuthStatus::SignedIn { + display_name, + primary_email, + .. + } = op_auth_bridge::poll(op_auth_bridge::SESSION_HANDLE) + { + host.editor_state_mut().editor_ui.account = + op_editor_core::AccountState::SignedIn { + handle: primary_email.unwrap_or_else(|| display_name.clone()), + display_name, + }; + } + } + } + } + } + host.editor_state_mut().editor_ui.account_ui_available = backend_ready || dev_fake; +} + /// Pop a native dialog offering to open the download page when a /// newer release is found. Yes opens the GitHub releases page. fn prompt_update_available(locale: op_editor_core::Locale, version: &str) { diff --git a/crates/op-host-native/Cargo.toml b/crates/op-host-native/Cargo.toml index 8b516c2c5..fc78e4ba8 100644 --- a/crates/op-host-native/Cargo.toml +++ b/crates/op-host-native/Cargo.toml @@ -67,6 +67,9 @@ serde_json = { workspace = true } # `op_editor_core::EditorState`. The host derives a read-only paint # `Document` from it each frame (see `widget_host.rs`). op-editor-core = { path = "../op-editor-core" } +# Device-login client bridge — links the proprietary prebuilt library when +# one exists for the target, else compiles an inert stub (login hidden). +op-auth-bridge = { path = "../op-auth-bridge" } op-i18n = { path = "../op-i18n" } # Native-only deps cfg-gated outside wasm32. This is critical (resolves codex round 2 B1 BLOCK): diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index f52293afc..bf7989183 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -52,6 +52,7 @@ mod agent_settings_image_gen_tests; mod agent_settings_tests; mod ai_chat_geometry; mod arc_drag; +mod auth_flow; mod blur_inputs; #[cfg(test)] mod blur_inputs_tests; @@ -396,6 +397,17 @@ pub struct WidgetHostNative { /// Last viewport size seen by paint/press. Used by handlers /// that don't receive viewport dims (e.g. apply_cursor_move /// driving the color-picker drag). + /// In-flight browser device-login flow handle from `op-auth-bridge` + /// (`None` = no login running). Polled by the desktop event loop via + /// [`Self::poll_auth`]. + pub(in crate::widget_host) auth_login_handle: Option, + /// Verification URL waiting for the desktop host to open in the + /// system browser (drained by `take_pending_browser_url`). + pub(in crate::widget_host) auth_pending_browser_url: Option, + /// Whether the current flow's verification URL was already queued — + /// the flow reports `WaitingApproval` every poll, but the browser + /// must open exactly once. + pub(in crate::widget_host) auth_browser_opened: bool, pub(in crate::widget_host) last_viewport_w: f32, pub(in crate::widget_host) last_viewport_h: f32, /// Live canvas Preview (Play) session — `Some` while @@ -783,6 +795,9 @@ impl WidgetHostNative { chat_panel_owner: op_editor_ui::widgets::AIChatPlaceholder::next_owner(), layer_panel_owner: op_editor_ui::widgets::LayerPanel::next_layer_panel_owner(), last_chat_session_index, + auth_login_handle: None, + auth_pending_browser_url: None, + auth_browser_opened: false, } } diff --git a/crates/op-host-native/src/widget_host/account_press.rs b/crates/op-host-native/src/widget_host/account_press.rs index 00c550fe5..3ef125185 100644 --- a/crates/op-host-native/src/widget_host/account_press.rs +++ b/crates/op-host-native/src/widget_host/account_press.rs @@ -31,30 +31,35 @@ impl WidgetHostNative { .map(op_editor_core::ButtonPressTarget::LoginModal); match hit { LoginModalHit::Close => { + self.cancel_auth_login(); self.editor_state.editor_ui.login_modal_open = false; self.editor_state.editor_ui.login_modal_hover = None; self.editor_state.editor_ui.login_modal_stub_hint_shown = false; } LoginModalHit::Outside => { self.blur_text_inputs_on_blank_press(); + self.cancel_auth_login(); self.editor_state.editor_ui.login_modal_open = false; self.editor_state.editor_ui.login_modal_hover = None; self.editor_state.editor_ui.login_modal_stub_hint_shown = false; } LoginModalHit::SignIn => { - // Dev/demo fast path — never reachable in a production - // build; the planned real flow uses OIDC Auth Code + - // PKCE via the system browser. if dev_fake_login_enabled() { + // Dev/demo fast path — exercises the signed-in UI + // without a backend. self.editor_state.editor_ui.account = op_editor_core::AccountState::dev_fake_signed_in(); self.editor_state.editor_ui.login_modal_open = false; self.editor_state.editor_ui.login_modal_hover = None; self.editor_state.editor_ui.login_modal_stub_hint_shown = false; + } else if op_auth_bridge::available() { + // Real flow: browser pairing against zseven-sso via + // the proprietary client library; progress lands in + // `login_modal_status` through `poll_auth`. + self.begin_browser_login(); } else { - // Honest stub: no session is created — just reveal - // the "coming soon" note instead of pretending the - // OIDC flow ran. + // Honest stub (no auth library linked): no session is + // created — just reveal the "coming soon" note. self.editor_state.editor_ui.login_modal_stub_hint_shown = true; } } @@ -103,6 +108,9 @@ impl WidgetHostNative { Some(AccountMenuRow::SignOut) => { self.close_account_menu(); self.editor_state.editor_ui.account = op_editor_core::AccountState::Anonymous; + // Revoke the device session (background thread inside the + // library; an inert no-op in stub builds). + op_auth_bridge::sign_out(); } None => { if !(menu_rect).contains(point) { diff --git a/crates/op-host-native/src/widget_host/account_press_tests.rs b/crates/op-host-native/src/widget_host/account_press_tests.rs index dfdbebd52..23789e919 100644 --- a/crates/op-host-native/src/widget_host/account_press_tests.rs +++ b/crates/op-host-native/src/widget_host/account_press_tests.rs @@ -103,8 +103,11 @@ fn account_release_gate_blocks_menu_for_signed_in_state() { #[test] fn stale_login_modal_state_does_not_dispatch_while_release_gate_is_hidden() { - const { assert!(!op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE) }; let mut host = WidgetHostNative::new(); + assert!( + !host.editor_state().editor_ui.account_ui_available, + "the runtime account gate must default to off" + ); host.editor_state_mut().editor_ui.login_modal_open = true; let modal = LoginModal::for_editor(host.editor_state()); @@ -124,8 +127,11 @@ fn stale_login_modal_state_does_not_dispatch_while_release_gate_is_hidden() { #[test] fn stale_account_menu_state_does_not_dispatch_while_release_gate_is_hidden() { - const { assert!(!op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE) }; let mut host = WidgetHostNative::new(); + assert!( + !host.editor_state().editor_ui.account_ui_available, + "the runtime account gate must default to off" + ); let signed_in = AccountState::SignedIn { display_name: "Fini".into(), handle: "fini".into(), @@ -175,7 +181,17 @@ fn login_modal_sign_in_without_dev_flag_shows_honest_stub_hint() { AccountState::Anonymous ); assert!(host.editor_state().editor_ui.login_modal_open); - assert!(host.editor_state().editor_ui.login_modal_stub_hint_shown); + if op_auth_bridge::available() { + // Real auth library linked: the press starts the browser flow + // instead of the stub hint. The runtime is uninitialized under + // test, so the flow settles into a failure note — never a session. + assert!(!host.editor_state().editor_ui.login_modal_stub_hint_shown); + assert!(host.editor_state().editor_ui.login_modal_status.is_some()); + } else { + // Stub build: honest "coming soon" note, no flow started. + assert!(host.editor_state().editor_ui.login_modal_stub_hint_shown); + assert!(host.editor_state().editor_ui.login_modal_status.is_none()); + } assert_eq!( host.editor_state().editor_ui.pressed_button, Some(ButtonPressTarget::LoginModal(LoginModalButton::SignIn)) diff --git a/crates/op-host-native/src/widget_host/auth_flow.rs b/crates/op-host-native/src/widget_host/auth_flow.rs new file mode 100644 index 000000000..c261178f9 --- /dev/null +++ b/crates/op-host-native/src/widget_host/auth_flow.rs @@ -0,0 +1,116 @@ +//! Browser device-login flow driver: bridges `op-auth-bridge` status +//! polls into editor UI state. The desktop event loop calls +//! [`WidgetHostNative::poll_auth`] from its background-drain pass and +//! opens URLs drained via [`WidgetHostNative::take_pending_browser_url`] +//! — this module never spawns processes itself. + +use super::WidgetHostNative; +use op_auth_bridge::AuthStatus; +use op_editor_core::{AccountState, LoginFlowError, LoginFlowStatus}; + +impl WidgetHostNative { + /// Start the browser device-login flow (called from the sign-in + /// modal's primary button when a real auth backend is linked). + pub(in crate::widget_host) fn begin_browser_login(&mut self) { + if self.auth_login_handle.is_some() { + return; // one flow at a time; the running one owns the modal + } + let handle = op_auth_bridge::login_begin(); + if handle == 0 { + self.editor_state.editor_ui.login_modal_status = + Some(LoginFlowStatus::Failed(LoginFlowError::Unavailable)); + return; + } + self.auth_login_handle = Some(handle); + self.auth_browser_opened = false; + self.editor_state.editor_ui.login_modal_status = Some(LoginFlowStatus::WaitingBrowser); + self.editor_state.editor_ui.login_modal_stub_hint_shown = false; + } + + /// Abort an in-flight login (modal closed / outside click). The + /// bridge flow settles into Canceled on its own thread; UI state + /// resets immediately. + pub fn cancel_auth_login(&mut self) { + if let Some(handle) = self.auth_login_handle.take() { + op_auth_bridge::cancel(handle); + } + self.auth_browser_opened = false; + self.auth_pending_browser_url = None; + self.editor_state.editor_ui.login_modal_status = None; + } + + /// Whether a login flow is running — drives the desktop event loop's + /// periodic wakeups so status changes land while the app idles. + pub fn auth_flow_active(&self) -> bool { + self.auth_login_handle.is_some() + } + + /// The verification URL the host should open in the system browser, + /// at most once per flow. + pub fn take_pending_browser_url(&mut self) -> Option { + self.auth_pending_browser_url.take() + } + + /// Poll the in-flight login flow (and the restored session) and fold + /// status changes into editor UI state. Returns whether anything + /// visible changed. + pub fn poll_auth(&mut self) -> bool { + let Some(handle) = self.auth_login_handle else { + return false; + }; + let ui = &mut self.editor_state.editor_ui; + let previous = ui.login_modal_status; + match op_auth_bridge::poll(handle) { + AuthStatus::Idle | AuthStatus::Starting => { + ui.login_modal_status = Some(LoginFlowStatus::WaitingBrowser); + } + AuthStatus::WaitingApproval { verification_uri } => { + ui.login_modal_status = Some(LoginFlowStatus::WaitingApproval); + if !self.auth_browser_opened && !verification_uri.is_empty() { + self.auth_browser_opened = true; + self.auth_pending_browser_url = Some(verification_uri); + } + } + AuthStatus::Exchanging => { + ui.login_modal_status = Some(LoginFlowStatus::Exchanging); + } + AuthStatus::SignedIn { + display_name, + primary_email, + .. + } => { + ui.account = AccountState::SignedIn { + handle: primary_email.unwrap_or_else(|| display_name.clone()), + display_name, + }; + ui.login_modal_status = None; + ui.login_modal_open = false; + ui.login_modal_hover = None; + self.auth_login_handle = None; + self.auth_browser_opened = false; + self.mark_dirty(); + return true; + } + AuthStatus::Error { code } => { + ui.login_modal_status = Some(LoginFlowStatus::Failed(match code.as_str() { + "denied" => LoginFlowError::Denied, + "expired" => LoginFlowError::Expired, + _ => LoginFlowError::Unavailable, + })); + self.auth_login_handle = None; + self.auth_browser_opened = false; + } + AuthStatus::Canceled => { + ui.login_modal_status = None; + self.auth_login_handle = None; + self.auth_browser_opened = false; + } + } + let changed = self.editor_state.editor_ui.login_modal_status != previous + || self.auth_pending_browser_url.is_some(); + if changed { + self.mark_dirty(); + } + changed + } +} diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index a2dab82d4..212f785c3 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -580,7 +580,7 @@ impl WidgetHostNative { } // Sign-in modal — owns the cursor while open. Hover the close // `✕` + the primary sign-in button. - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE + if self.editor_state.editor_ui.account_ui_available && self.editor_state.editor_ui.login_modal_open { use op_editor_ui::widgets::login_modal::LoginModal; @@ -597,7 +597,7 @@ impl WidgetHostNative { return changed; } // Signed-in account dropdown — owns the cursor while open. - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE + if self.editor_state.editor_ui.account_ui_available && self.editor_state.editor_ui.account_menu_open { use op_editor_ui::widgets::account_menu::AccountMenu; diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index c15bf9afd..f4dcf208c 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -589,7 +589,7 @@ impl WidgetHostNative { } // 10e. Sign-in modal — full-viewport scrim + centred card. - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE && ui.login_modal_open { + if ui.account_ui_available && ui.login_modal_open { use op_editor_ui::widgets::login_modal::LoginModal; frame.fill_rect( Rect { @@ -614,7 +614,7 @@ impl WidgetHostNative { // 10f. Signed-in account dropdown — anchored under the TopBar // avatar button, no scrim (same tier as the file menu / // locale picker). - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE && ui.account_menu_open { + if ui.account_ui_available && ui.account_menu_open { use op_editor_ui::widgets::account_menu::AccountMenu; let top_bar_rect = Rect { origin: Point2D::new(0.0, 0.0), diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 688df8beb..4c2c5b493 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -396,7 +396,7 @@ impl WidgetHostNative { self.dispatch_figma_import_press(x, y, viewport_width, viewport_height); return true; } - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE + if self.editor_state.editor_ui.account_ui_available && self.editor_state.editor_ui.login_modal_open { self.close_image_popovers_for_higher_overlay(); @@ -407,7 +407,7 @@ impl WidgetHostNative { // 0a'. Account dropdown — anchored under the TopBar avatar // button; must hit-test before the TopBar's own block so a // re-click on the avatar closes rather than re-toggling. - if op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE + if self.editor_state.editor_ui.account_ui_available && self.editor_state.editor_ui.account_menu_open { self.close_image_popovers_for_higher_overlay(); @@ -633,7 +633,7 @@ impl WidgetHostNative { return true; } TopBarHit::Account => { - if !op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE { + if !self.editor_state.editor_ui.account_ui_available { return false; } if self.editor_state.editor_ui.account.is_signed_in() { diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index ef11763b3..e6ee6eab5 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -443,7 +443,7 @@ impl WidgetHostNative { if ui.figma_import_in_progress || !ui.figma_import_pages.is_empty() || ui.export_dialog_open - || (op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE && ui.login_modal_open) + || (ui.account_ui_available && ui.login_modal_open) || ui.agent_settings_open || (ui.missing_fonts_modal_open && ui diff --git a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs index b6624a077..9c9441329 100644 --- a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs +++ b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs @@ -609,7 +609,7 @@ impl WidgetHost { if ui.figma_import_in_progress || !ui.figma_import_pages.is_empty() || ui.export_dialog_open - || (op_editor_ui::widgets::ACCOUNT_UI_AVAILABLE && ui.login_modal_open) + || (ui.account_ui_available && ui.login_modal_open) || ui.agent_settings_open || (ui.missing_fonts_modal_open && ui diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index ef68485c1..4b24cab71 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -514,9 +514,10 @@ impl WidgetHost { self.editor_state.editor_ui.toggle_preview(); } TopBarHit::Account => { - // Unreachable: `ACCOUNT_BUTTON_AVAILABLE` gates the - // avatar button out of the web build's hit-test (the - // sign-in flow is desktop-only). + // Unreachable: `TopBar::account_button_visible` is + // always false on wasm32 (the web host never sets + // `account_ui_available`; the sign-in flow is + // desktop-only), so the avatar never hit-tests here. } } self.mark_dirty();