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:
parent
39287ac03d
commit
cacaeefc95
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
crates/op-auth-bridge/Cargo.toml
Normal file
12
crates/op-auth-bridge/Cargo.toml
Normal 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 }
|
||||
33
crates/op-auth-bridge/build.rs
Normal file
33
crates/op-auth-bridge/build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
0.8.3
|
||||
BIN
crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/libop_auth.a
Normal file
BIN
crates/op-auth-bridge/prebuilt/aarch64-apple-darwin/libop_auth.a
Normal file
Binary file not shown.
94
crates/op-auth-bridge/src/lib.rs
Normal file
94
crates/op-auth-bridge/src/lib.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
112
crates/op-auth-bridge/src/real.rs
Normal file
112
crates/op-auth-bridge/src/real.rs
Normal 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() };
|
||||
}
|
||||
}
|
||||
114
crates/op-auth-bridge/src/status.rs
Normal file
114
crates/op-auth-bridge/src/status.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
52
crates/op-auth-bridge/src/stub.rs
Normal file
52
crates/op-auth-bridge/src/stub.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
116
crates/op-host-native/src/widget_host/auth_flow.rs
Normal file
116
crates/op-host-native/src/widget_host/auth_flow.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue