feat(desktop): browser device login through prebuilt op-auth library

The proprietary device-login client (ZSeven-W/op-platform) ships as a
C-ABI static library committed under op-auth-bridge/prebuilt/<target>;
builds without an artifact fall back to an inert stub so any checkout
compiles with login hidden. The desktop host starts the pairing flow
from the sign-in modal, opens the verification page in the system
browser, polls flow status each frame, restores persisted sessions at
startup, and revokes the device token on sign-out.
This commit is contained in:
Kayshen-X 2026-07-25 19:08:38 +08:00
parent 39287ac03d
commit cacaeefc95
23 changed files with 674 additions and 21 deletions

9
Cargo.lock generated
View file

@ -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",

View file

@ -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 }

View file

@ -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");
}
}

View file

@ -0,0 +1 @@
0.8.3

View file

@ -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/<target>/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"
}
}

View file

@ -0,0 +1,112 @@
//! Backend backed by the prebuilt proprietary static library. Compiled
//! only when `build.rs` found `prebuilt/<target>/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<bool> = 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() };
}
}

View file

@ -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<String>,
avatar_url: Option<String>,
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<String> {
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()
}
);
}
}

View file

@ -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();
}
}

View file

@ -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

View file

@ -868,6 +868,11 @@ impl ApplicationHandler<DesktopEvent> 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()

View file

@ -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<I: Iterator<Item = String>>(args: I) -> Option<u16> {
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) {

View file

@ -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):

View file

@ -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<u64>,
/// 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<String>,
/// 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,
}
}

View file

@ -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) {

View file

@ -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))

View file

@ -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<String> {
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
}
}

View file

@ -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;

View file

@ -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),

View file

@ -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() {

View file

@ -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

View file

@ -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

View file

@ -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();