mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-09-28 16:12:57 +08:00
feat(editor): engine surface for native mobile sign-in, account center, and locale
Region-aware auth configure (cn/global), begin-login/sign-out/account-snapshot FFI + JNI, one-shot pending flags for the native login / account center / language picker, runtime locale switching, safe-area band painting that blends with the app bar and dock, and a Language entry in the mobile more panel.
This commit is contained in:
@@ -152,6 +152,32 @@ pub const ENV_DEV_FAKE_LOGIN: &str = "OPENPENCIL_DEV_FAKE_LOGIN";
|
||||
/// not redirect credential-bearing requests.
|
||||
pub const PRODUCTION_SSO_ORIGIN: &str = "https://sso.zseven.cn";
|
||||
|
||||
/// Overseas SSO origin. Same logical account space as
|
||||
/// [`PRODUCTION_SSO_ORIGIN`]; the mobile shells pick one region per install
|
||||
/// (IP-informed default with a user override) before the runtime starts.
|
||||
pub const GLOBAL_SSO_ORIGIN: &str = "https://sso.zseven.tech";
|
||||
|
||||
/// Regional SSO deployment an embedded mobile shell signs in against.
|
||||
///
|
||||
/// Region is an *install-time* input: the proprietary runtime initializes
|
||||
/// once per process and persists its device credential against a single
|
||||
/// origin, so switching regions takes effect on the next launch.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileSsoRegion {
|
||||
China,
|
||||
Global,
|
||||
}
|
||||
|
||||
impl MobileSsoRegion {
|
||||
/// The credential-bearing SSO origin for this region.
|
||||
pub fn origin(self) -> &'static str {
|
||||
match self {
|
||||
MobileSsoRegion::China => PRODUCTION_SSO_ORIGIN,
|
||||
MobileSsoRegion::Global => GLOBAL_SSO_ORIGIN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the runtime needs at startup.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AuthInitConfig {
|
||||
@@ -262,14 +288,17 @@ pub fn platform_id() -> &'static str {
|
||||
/// Pinned production configuration for an embedded iOS or Android shell.
|
||||
/// Unlike desktop startup this deliberately ignores environment overrides:
|
||||
/// an app process must not redirect persisted device credentials through an
|
||||
/// ambient shell variable.
|
||||
/// ambient shell variable. The shell supplies the region it resolved
|
||||
/// (persisted preference, falling back to an IP-informed default); both
|
||||
/// values are first-party production origins, never caller-provided URLs.
|
||||
pub fn mobile_init_config(
|
||||
storage_dir: PathBuf,
|
||||
device_name: impl Into<String>,
|
||||
app_version: impl Into<String>,
|
||||
region: MobileSsoRegion,
|
||||
) -> AuthInitConfig {
|
||||
AuthInitConfig {
|
||||
base_url: PRODUCTION_SSO_ORIGIN.to_string(),
|
||||
base_url: region.origin().to_string(),
|
||||
storage_dir,
|
||||
device_name: device_name.into(),
|
||||
app_version: app_version.into(),
|
||||
@@ -426,11 +455,26 @@ mod abi_tests {
|
||||
|
||||
#[test]
|
||||
fn mobile_configuration_is_pinned_to_production() {
|
||||
let config = mobile_init_config(PathBuf::from("/private/app/auth"), "Test Phone", "1.0.0");
|
||||
let config = mobile_init_config(
|
||||
PathBuf::from("/private/app/auth"),
|
||||
"Test Phone",
|
||||
"1.0.0",
|
||||
MobileSsoRegion::China,
|
||||
);
|
||||
assert_eq!(config.base_url, PRODUCTION_SSO_ORIGIN);
|
||||
assert_eq!(config.storage_dir, PathBuf::from("/private/app/auth"));
|
||||
assert_eq!(config.device_name, "Test Phone");
|
||||
assert_eq!(config.app_version, "1.0.0");
|
||||
|
||||
let global = mobile_init_config(
|
||||
PathBuf::from("/private/app/auth"),
|
||||
"Test Phone",
|
||||
"1.0.0",
|
||||
MobileSsoRegion::Global,
|
||||
);
|
||||
assert_eq!(global.base_url, GLOBAL_SSO_ORIGIN);
|
||||
assert_eq!(MobileSsoRegion::China.origin(), "https://sso.zseven.cn");
|
||||
assert_eq!(MobileSsoRegion::Global.origin(), "https://sso.zseven.tech");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -147,6 +147,19 @@ pub struct EditorUiState {
|
||||
pub export_quick_menu_hover: Option<crate::export_quick_menu_state::ExportQuickRow>,
|
||||
/// Pending file-menu action for the host runner to handle.
|
||||
pub pending_file_action: Option<FileAction>,
|
||||
/// One-shot request for the mobile shell to present its native
|
||||
/// account-center screen (set by the touch more-panel's Account tile;
|
||||
/// desktop chrome keeps its painted account surfaces and never sets it).
|
||||
pub pending_account_center: bool,
|
||||
/// One-shot request for the mobile shell to start the sign-in flow (set
|
||||
/// by the touch more-panel's Sign in tile). The shell configures the
|
||||
/// auth runtime for its resolved region if needed, then calls
|
||||
/// `op_editor_begin_login`; the engine-painted login modal never opens
|
||||
/// on touch chrome.
|
||||
pub pending_mobile_login: bool,
|
||||
/// One-shot request for the mobile shell to present its native language
|
||||
/// picker (set by the touch more-panel's Language tile).
|
||||
pub pending_language_picker: bool,
|
||||
/// Recent files (head = newest, cap 10).
|
||||
pub recent_files: Vec<RecentFile>,
|
||||
/// TopBar display name; `None` = "Untitled".
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Default for EditorUiState {
|
||||
export_quick_menu_open: false,
|
||||
export_quick_menu_hover: None,
|
||||
pending_file_action: None,
|
||||
pending_account_center: false,
|
||||
pending_mobile_login: false,
|
||||
pending_language_picker: false,
|
||||
recent_files: Vec::new(),
|
||||
file_name_display: None,
|
||||
document_dirty: false,
|
||||
|
||||
@@ -45,6 +45,29 @@ pub fn centered_icon_rect(target: Rect, icon_size: f32) -> Rect {
|
||||
/// its canonical 24×24 coordinate system. Every subpath must share the
|
||||
/// same transform; fitting each subpath independently deforms compound
|
||||
/// icons such as Settings, Braces and Download.
|
||||
/// Surface colors the platform safe-area bands should carry so the touch
|
||||
/// chrome reads as one continuous surface: the top band matches the app
|
||||
/// bar, and (phones only — tablets float their dock) the bottom band
|
||||
/// matches the edge-to-edge dock. `None` keeps the theme background.
|
||||
pub fn safe_area_band_colors(state: &EditorState) -> (Option<Color>, Option<Color>) {
|
||||
if !state.editor_ui.touch_chrome() {
|
||||
return (None, None);
|
||||
}
|
||||
let theme = crate::widgets::editor_state_ext::theme_for(&state.editor_ui);
|
||||
let top = Some(mix(theme.background, theme.card, 0.42));
|
||||
// Mirrors the dock's paint gating: a modal sheet or the variables panel
|
||||
// hides the dock, so the band returns to the plain background.
|
||||
let bottom = if state.editor_ui.compact_layout()
|
||||
&& state.editor_ui.mobile_sheet.is_none()
|
||||
&& !state.editor_ui.variables_panel_open
|
||||
{
|
||||
Some(mix(theme.background, theme.card, 0.48))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(top, bottom)
|
||||
}
|
||||
|
||||
pub fn paint_touch_icon(
|
||||
cx: &mut PaintCx<'_>,
|
||||
target: Rect,
|
||||
|
||||
@@ -37,6 +37,7 @@ pub enum MobileMoreEntry {
|
||||
Ai,
|
||||
SignIn,
|
||||
Account,
|
||||
Language,
|
||||
Collaboration,
|
||||
Settings,
|
||||
Variables,
|
||||
@@ -47,7 +48,7 @@ pub enum MobileMoreEntry {
|
||||
impl MobileMoreEntry {
|
||||
/// Exhaustive semantic entries. Paint and hit-test use [`Self::visible`]
|
||||
/// because Sign in and Account are mutually exclusive states of one tile.
|
||||
pub const ALL: [MobileMoreEntry; 12] = [
|
||||
pub const ALL: [MobileMoreEntry; 13] = [
|
||||
MobileMoreEntry::NewFile,
|
||||
MobileMoreEntry::OpenFile,
|
||||
MobileMoreEntry::Templates,
|
||||
@@ -56,6 +57,7 @@ impl MobileMoreEntry {
|
||||
MobileMoreEntry::SignIn,
|
||||
MobileMoreEntry::Account,
|
||||
MobileMoreEntry::Collaboration,
|
||||
MobileMoreEntry::Language,
|
||||
MobileMoreEntry::Settings,
|
||||
MobileMoreEntry::Variables,
|
||||
MobileMoreEntry::Preview,
|
||||
@@ -78,6 +80,7 @@ impl MobileMoreEntry {
|
||||
} else {
|
||||
MobileMoreEntry::SignIn
|
||||
},
|
||||
MobileMoreEntry::Language,
|
||||
MobileMoreEntry::Settings,
|
||||
MobileMoreEntry::Variables,
|
||||
MobileMoreEntry::Preview,
|
||||
@@ -95,6 +98,7 @@ impl MobileMoreEntry {
|
||||
MobileMoreEntry::SignIn => "settings.account.signIn",
|
||||
MobileMoreEntry::Account => "settings.account.title",
|
||||
MobileMoreEntry::Collaboration => "collab.topbar.collaborate",
|
||||
MobileMoreEntry::Language => "tooltip.topbar.language",
|
||||
MobileMoreEntry::Settings => "settings.title",
|
||||
MobileMoreEntry::Variables => "toolbar.variables",
|
||||
MobileMoreEntry::Preview => "tooltip.topbar.preview",
|
||||
@@ -117,6 +121,7 @@ impl MobileMoreEntry {
|
||||
MobileMoreEntry::Ai => Icon::from_name("sparkles").unwrap_or(Icon::Sparkles),
|
||||
MobileMoreEntry::SignIn | MobileMoreEntry::Account => Icon::User,
|
||||
MobileMoreEntry::Collaboration => Icon::Users,
|
||||
MobileMoreEntry::Language => Icon::Globe,
|
||||
MobileMoreEntry::Settings => Icon::from_name("settings").unwrap_or(Icon::Settings),
|
||||
MobileMoreEntry::Variables => Icon::from_name("braces").unwrap_or(Icon::Braces),
|
||||
MobileMoreEntry::Preview => Icon::from_name("play").unwrap_or(Icon::Play),
|
||||
@@ -408,8 +413,8 @@ mod tests {
|
||||
#[test]
|
||||
fn restored_entries_reuse_localized_labels_and_desktop_icons() {
|
||||
let mut state = EditorState::starter();
|
||||
assert_eq!(MobileMoreEntry::ALL.len(), 12);
|
||||
assert_eq!(MobileMoreEntry::visible(&state).len(), 11);
|
||||
assert_eq!(MobileMoreEntry::ALL.len(), 13);
|
||||
assert_eq!(MobileMoreEntry::visible(&state).len(), 12);
|
||||
assert_eq!(MobileMoreEntry::ALL[0], MobileMoreEntry::NewFile);
|
||||
assert_eq!(MobileMoreEntry::ALL[1], MobileMoreEntry::OpenFile);
|
||||
assert_eq!(MobileMoreEntry::NewFile.icon(), Icon::FilePlus);
|
||||
@@ -442,7 +447,7 @@ mod tests {
|
||||
fn account_state_swaps_one_tile_without_moving_collaboration_or_changing_count() {
|
||||
let mut state = touch_state(EditorSizeClass::Compact);
|
||||
let anonymous = MobileMoreEntry::visible(&state);
|
||||
assert_eq!(anonymous.len(), 11);
|
||||
assert_eq!(anonymous.len(), 12);
|
||||
assert!(anonymous.contains(&MobileMoreEntry::SignIn));
|
||||
assert!(!anonymous.contains(&MobileMoreEntry::Account));
|
||||
assert_eq!(anonymous[5], MobileMoreEntry::Collaboration);
|
||||
|
||||
@@ -138,11 +138,27 @@ typedef enum OpPointerPhase {
|
||||
typedef enum OpShellAction {
|
||||
OpShellAction_None = 0,
|
||||
OpShellAction_OpenDocument = 1,
|
||||
/* Present the pending device-login flow in the shell's native login UI
|
||||
* (the enum name predates the WebView retirement). */
|
||||
OpShellAction_OpenLoginWebView = 2,
|
||||
OpShellAction_CloseLoginWebView = 3,
|
||||
OpShellAction_ExportDocument = 4,
|
||||
OpShellAction_OpenAccountCenter = 5,
|
||||
/* Configure the auth runtime for the resolved region if needed, then
|
||||
* call op_editor_begin_login. */
|
||||
OpShellAction_RequestLogin = 6,
|
||||
/* Present the shell's native language picker; apply the choice with
|
||||
* op_editor_set_locale. */
|
||||
OpShellAction_OpenLanguagePicker = 7,
|
||||
} OpShellAction;
|
||||
|
||||
/* Regional SSO deployments for op_editor_configure_auth. Both map to pinned
|
||||
* first-party origins inside the engine; the shell only picks a region. */
|
||||
typedef enum OpAuthRegion {
|
||||
OpAuthRegion_China = 0,
|
||||
OpAuthRegion_Global = 1,
|
||||
} OpAuthRegion;
|
||||
|
||||
/* Surrounding-text snapshot for the shell's input connection. The text
|
||||
* pointer is BORROWED from the engine and valid only until the next engine
|
||||
* call; offsets are UTF-16 code units. */
|
||||
@@ -316,9 +332,29 @@ OpStatus op_editor_export_to_path(OpEngine *engine, const uint8_t *path_ptr, siz
|
||||
OpStatus op_editor_cancel_export(OpEngine *engine);
|
||||
|
||||
/* Configure the real mobile auth backend. `storage_dir` must be a private
|
||||
* app-owned directory; device name and app version are display metadata. The
|
||||
* production SSO origin is pinned by the engine and is not shell-provided. */
|
||||
OpStatus op_editor_configure_auth(OpEngine *engine, const uint8_t *storage_dir_ptr, size_t storage_dir_len, const uint8_t *device_name_ptr, size_t device_name_len, const uint8_t *app_version_ptr, size_t app_version_len);
|
||||
* app-owned directory; device name and app version are display metadata.
|
||||
* `region` is an OpAuthRegion value; both regional SSO origins are pinned by
|
||||
* the engine and are never shell-provided. */
|
||||
OpStatus op_editor_configure_auth(OpEngine *engine, const uint8_t *storage_dir_ptr, size_t storage_dir_len, const uint8_t *device_name_ptr, size_t device_name_len, const uint8_t *app_version_ptr, size_t app_version_len, int32_t region);
|
||||
|
||||
/* Copy a JSON snapshot of the signed-in account ({"signed_in":bool,
|
||||
* "display_name":…, "username":…, "primary_email":…, "avatar_url":…,
|
||||
* "device_id":…}). NULL/0 reports the required length; the snapshot is
|
||||
* re-read per call and never consumed. Not NUL-terminated. */
|
||||
OpStatus op_editor_account_snapshot(OpEngine *engine, uint8_t *buffer, size_t capacity, size_t *required);
|
||||
|
||||
/* Apply a UI locale by BCP-47 tag; unsupported tags are rejected. */
|
||||
OpStatus op_editor_set_locale(OpEngine *engine, const uint8_t *tag_ptr, size_t tag_len);
|
||||
|
||||
/* Copy the current UI locale's BCP-47 tag (never consumed). */
|
||||
OpStatus op_editor_locale_code(OpEngine *engine, uint8_t *buffer, size_t capacity, size_t *required);
|
||||
|
||||
/* Start the device-login flow after configuring auth (RequestLogin
|
||||
* follow-up). NotReady = stub backend; surface natively. */
|
||||
OpStatus op_editor_begin_login(OpEngine *engine);
|
||||
|
||||
/* Revoke the device session and clear the engine's account mirror. */
|
||||
OpStatus op_editor_auth_sign_out(OpEngine *engine);
|
||||
|
||||
/* Peek/copy the pending UTF-8 login URL. NULL/0 reports the required length
|
||||
* without consuming it. A complete copy consumes it; a short copy fails and
|
||||
|
||||
@@ -50,6 +50,58 @@ pub unsafe extern "C" fn op_editor_take_shell_action(
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a UI locale by BCP-47 tag ("zh-CN", "en-US", …). Unsupported tags
|
||||
/// are rejected so the shell's picker cannot silently fall back.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `engine` must be live and called on its owner thread; a non-empty byte
|
||||
/// range must cover readable UTF-8 for its declared length.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn op_editor_set_locale(
|
||||
engine: *mut crate::OpEngine,
|
||||
tag_ptr: *const u8,
|
||||
tag_len: usize,
|
||||
) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
let tag = crate::error::read_utf8(tag_ptr, tag_len, STRING_CAP, "locale tag")?;
|
||||
let Some(locale) = op_editor_core::editor_ui_state::Locale::from_tag(&tag) else {
|
||||
return Err(FfiError::invalid("unsupported locale tag"));
|
||||
};
|
||||
let host = session.editor_mut()?;
|
||||
host.editor_state_mut().editor_ui.locale = locale;
|
||||
host.mark_editor_state_dirty();
|
||||
session.request_redraw();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the current UI locale's BCP-47 tag (never consumed).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `engine` must be live and called on its owner thread; `required` must be
|
||||
/// writable and a non-null `buffer` must cover `capacity` bytes.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn op_editor_locale_code(
|
||||
engine: *mut crate::OpEngine,
|
||||
buffer: *mut u8,
|
||||
capacity: usize,
|
||||
required: *mut usize,
|
||||
) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
let code = session
|
||||
.editor()
|
||||
.map(|host| host.editor_state().editor_ui.locale.code())
|
||||
.ok_or_else(|| FfiError::new(OpStatus::NotReady, "engine is not in editor mode"))?;
|
||||
crate::error::write_bytes(code.as_bytes(), buffer, capacity, required)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and atomically install a document selected by the platform shell.
|
||||
/// Parsing, compatibility migration, editor metadata extraction, and scene
|
||||
/// validation all finish before the live host is touched, so a rejected file
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Embedded mobile authentication bridge.
|
||||
//!
|
||||
//! The Rust host owns the device-code flow and polling. Platform shells only
|
||||
//! present the verification URL in an in-app WebView, cancel it when the user
|
||||
//! closes that view, and dismiss it when the engine emits the close action.
|
||||
//! The Rust host owns the device-code flow and polling. Platform shells
|
||||
//! present the verification URL in their native login UI (email/password
|
||||
//! against the regional SSO JSON API plus a system-browser hand-off for
|
||||
//! third-party providers), cancel the flow when the user closes that UI, and
|
||||
//! dismiss it when the engine emits the close action. The account-center
|
||||
//! snapshot and sign-out calls back the shells' native account screens.
|
||||
|
||||
use crate::error::{read_utf8, FfiError, FfiResult};
|
||||
use crate::lifecycle::{call_session, Session};
|
||||
@@ -13,10 +16,20 @@ use std::path::PathBuf;
|
||||
pub const SHELL_ACTION_NONE: i32 = 0;
|
||||
/// Present the platform document picker.
|
||||
pub const SHELL_ACTION_OPEN_DOCUMENT: i32 = 1;
|
||||
/// Present the pending device-login URL in an embedded WebView.
|
||||
/// Present the pending device-login flow in the shell's native login UI
|
||||
/// (historically an embedded WebView — the constant name is part of the
|
||||
/// frozen shell contract).
|
||||
pub const SHELL_ACTION_OPEN_LOGIN_WEBVIEW: i32 = 2;
|
||||
/// Dismiss the embedded login WebView without canceling the completed flow.
|
||||
/// Dismiss the shell login UI without canceling the completed flow.
|
||||
pub const SHELL_ACTION_CLOSE_LOGIN_WEBVIEW: i32 = 3;
|
||||
/// Present the shell's native account-center screen.
|
||||
pub const SHELL_ACTION_OPEN_ACCOUNT_CENTER: i32 = 5;
|
||||
/// Start the sign-in flow: the shell configures the auth runtime for its
|
||||
/// resolved region if needed, then calls [`op_editor_begin_login`].
|
||||
pub const SHELL_ACTION_REQUEST_LOGIN: i32 = 6;
|
||||
/// Present the shell's native language picker
|
||||
/// ([`crate::op_editor_set_locale`] applies the choice).
|
||||
pub const SHELL_ACTION_OPEN_LANGUAGE_PICKER: i32 = 7;
|
||||
|
||||
const AUTH_POLL_INTERVAL_MS: u64 = 250;
|
||||
const AUTH_STORAGE_PATH_CAP: usize = 16 * 1024;
|
||||
@@ -34,8 +47,16 @@ pub(crate) struct EditorAuthShellState {
|
||||
pub(crate) close_pending: bool,
|
||||
}
|
||||
|
||||
/// SSO region codes for [`op_editor_configure_auth`]. Both map to pinned
|
||||
/// first-party origins inside `op-auth-bridge` — the shell only ever picks a
|
||||
/// region, never supplies a URL.
|
||||
pub const AUTH_REGION_CHINA: i32 = 0;
|
||||
pub const AUTH_REGION_GLOBAL: i32 = 1;
|
||||
|
||||
/// Configure the real mobile auth backend with a shell-owned private storage
|
||||
/// directory. The SSO origin is pinned to production by `op-auth-bridge`.
|
||||
/// directory and the regional SSO deployment the shell resolved (persisted
|
||||
/// preference, falling back to an IP-informed default). Both regional
|
||||
/// origins stay pinned by `op-auth-bridge`.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
@@ -50,9 +71,15 @@ pub unsafe extern "C" fn op_editor_configure_auth(
|
||||
device_name_len: usize,
|
||||
app_version_ptr: *const u8,
|
||||
app_version_len: usize,
|
||||
region: i32,
|
||||
) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
let region = match region {
|
||||
AUTH_REGION_CHINA => op_host_native::MobileSsoRegion::China,
|
||||
AUTH_REGION_GLOBAL => op_host_native::MobileSsoRegion::Global,
|
||||
_ => return Err(FfiError::invalid("unknown auth region code")),
|
||||
};
|
||||
let storage_dir = read_nonempty_utf8(
|
||||
storage_dir_ptr,
|
||||
storage_dir_len,
|
||||
@@ -78,7 +105,7 @@ pub unsafe extern "C" fn op_editor_configure_auth(
|
||||
"auth app version",
|
||||
)?;
|
||||
let host = session.editor_mut()?;
|
||||
if !host.configure_mobile_auth(storage_dir, device_name, app_version) {
|
||||
if !host.configure_mobile_auth(storage_dir, device_name, app_version, region) {
|
||||
return Err(FfiError::new(
|
||||
OpStatus::NotReady,
|
||||
"mobile authentication backend is unavailable",
|
||||
@@ -145,11 +172,126 @@ pub unsafe extern "C" fn op_editor_cancel_login(engine: *mut crate::OpEngine) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a JSON snapshot of the signed-in account for the shell's native
|
||||
/// account-center screen: `{"signed_in":bool, "display_name":…,
|
||||
/// "username":…, "primary_email":…, "avatar_url":…, "device_id":…}`.
|
||||
/// Optional fields are `null` when the runtime did not report them; every
|
||||
/// field except `signed_in` is absent-as-null when signed out.
|
||||
///
|
||||
/// A null buffer with zero capacity reports the required byte length; the
|
||||
/// snapshot is re-read on every call and never consumed.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `engine` must be live and called on its owner thread. `required` must be
|
||||
/// writable; a non-null `buffer` must cover `capacity` writable bytes.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn op_editor_account_snapshot(
|
||||
engine: *mut crate::OpEngine,
|
||||
buffer: *mut u8,
|
||||
capacity: usize,
|
||||
required: *mut usize,
|
||||
) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
if required.is_null() {
|
||||
return Err(FfiError::invalid(
|
||||
"account snapshot required-length pointer is null",
|
||||
));
|
||||
}
|
||||
let json = session
|
||||
.editor()
|
||||
.map(|host| host.mobile_account_snapshot_json())
|
||||
.ok_or_else(|| FfiError::new(OpStatus::NotReady, "engine is not in editor mode"))?;
|
||||
let bytes = json.as_bytes();
|
||||
required.write(bytes.len());
|
||||
if buffer.is_null() {
|
||||
if capacity == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(FfiError::invalid(
|
||||
"account snapshot buffer is null with nonzero capacity",
|
||||
));
|
||||
}
|
||||
if capacity < bytes.len() {
|
||||
return Err(FfiError::invalid(format!(
|
||||
"account snapshot buffer covers {capacity} bytes but {} are required",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buffer, bytes.len());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the engine's device-login flow after the shell configured the auth
|
||||
/// runtime (`SHELL_ACTION_REQUEST_LOGIN` follow-up). `NotReady` means the
|
||||
/// backend is a stub or refused a flow handle — the shell shows a native
|
||||
/// "sign-in unavailable" notice instead of an engine modal.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `engine` must be live and called on its owner thread.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn op_editor_begin_login(engine: *mut crate::OpEngine) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
let host = session.editor_mut()?;
|
||||
if !host.begin_mobile_login() {
|
||||
return Err(FfiError::new(
|
||||
OpStatus::NotReady,
|
||||
"mobile authentication backend is unavailable",
|
||||
));
|
||||
}
|
||||
session.request_redraw();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign the account out: revoke the device session in the auth runtime and
|
||||
/// clear the engine's account mirror. Safe to call when already signed out.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `engine` must be live and called on its owner thread.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn op_editor_auth_sign_out(engine: *mut crate::OpEngine) -> OpStatus {
|
||||
unsafe {
|
||||
call_session(engine, |session| {
|
||||
let host = session.editor_mut()?;
|
||||
host.sign_out_account();
|
||||
session.request_redraw();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_shell_action(session: &mut Session) -> FfiResult<i32> {
|
||||
if let Some(action) = take_auth_shell_action(&mut session.auth_shell) {
|
||||
return Ok(action);
|
||||
}
|
||||
|
||||
{
|
||||
let host = session.editor_mut()?;
|
||||
if host.editor_state().editor_ui.pending_account_center {
|
||||
host.editor_state_mut().editor_ui.pending_account_center = false;
|
||||
host.mark_editor_state_dirty();
|
||||
return Ok(SHELL_ACTION_OPEN_ACCOUNT_CENTER);
|
||||
}
|
||||
if host.editor_state().editor_ui.pending_mobile_login {
|
||||
host.editor_state_mut().editor_ui.pending_mobile_login = false;
|
||||
host.mark_editor_state_dirty();
|
||||
return Ok(SHELL_ACTION_REQUEST_LOGIN);
|
||||
}
|
||||
if host.editor_state().editor_ui.pending_language_picker {
|
||||
host.editor_state_mut().editor_ui.pending_language_picker = false;
|
||||
host.mark_editor_state_dirty();
|
||||
return Ok(SHELL_ACTION_OPEN_LANGUAGE_PICKER);
|
||||
}
|
||||
}
|
||||
|
||||
let pending = session
|
||||
.editor_mut()?
|
||||
.editor_state()
|
||||
@@ -610,6 +752,7 @@ mod tests {
|
||||
device.len(),
|
||||
version.as_ptr(),
|
||||
version.len(),
|
||||
AUTH_REGION_CHINA,
|
||||
)
|
||||
},
|
||||
OpStatus::InvalidArg
|
||||
|
||||
@@ -67,16 +67,19 @@ pub use desc::{
|
||||
#[cfg(feature = "editor")]
|
||||
pub use editor::{
|
||||
op_editor_cancel_gesture, op_editor_ime_commit, op_editor_ime_focused, op_editor_ime_preedit,
|
||||
op_editor_key, op_editor_move, op_editor_open_document, op_editor_pan, op_editor_pinch,
|
||||
op_editor_press, op_editor_release, op_editor_right_press, op_editor_take_shell_action,
|
||||
op_editor_text, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT, KEY_ARROW_UP, KEY_BACKSPACE,
|
||||
KEY_DELETE, KEY_DUPLICATE, KEY_ENTER, KEY_ESCAPE, KEY_REDO, KEY_UNDO,
|
||||
op_editor_key, op_editor_locale_code, op_editor_move, op_editor_open_document, op_editor_pan,
|
||||
op_editor_pinch, op_editor_press, op_editor_release, op_editor_right_press,
|
||||
op_editor_set_locale, op_editor_take_shell_action, op_editor_text, KEY_ARROW_DOWN,
|
||||
KEY_ARROW_LEFT, KEY_ARROW_RIGHT, KEY_ARROW_UP, KEY_BACKSPACE, KEY_DELETE, KEY_DUPLICATE,
|
||||
KEY_ENTER, KEY_ESCAPE, KEY_REDO, KEY_UNDO,
|
||||
};
|
||||
#[cfg(feature = "editor")]
|
||||
pub use editor_auth::{
|
||||
op_editor_cancel_login, op_editor_configure_auth, op_editor_copy_login_url,
|
||||
SHELL_ACTION_CLOSE_LOGIN_WEBVIEW, SHELL_ACTION_NONE, SHELL_ACTION_OPEN_DOCUMENT,
|
||||
SHELL_ACTION_OPEN_LOGIN_WEBVIEW,
|
||||
op_editor_account_snapshot, op_editor_auth_sign_out, op_editor_begin_login,
|
||||
op_editor_cancel_login, op_editor_configure_auth, op_editor_copy_login_url, AUTH_REGION_CHINA,
|
||||
AUTH_REGION_GLOBAL, SHELL_ACTION_CLOSE_LOGIN_WEBVIEW, SHELL_ACTION_NONE,
|
||||
SHELL_ACTION_OPEN_ACCOUNT_CENTER, SHELL_ACTION_OPEN_DOCUMENT,
|
||||
SHELL_ACTION_OPEN_LANGUAGE_PICKER, SHELL_ACTION_OPEN_LOGIN_WEBVIEW, SHELL_ACTION_REQUEST_LOGIN,
|
||||
};
|
||||
#[cfg(feature = "editor")]
|
||||
pub use editor_export::{
|
||||
|
||||
@@ -137,6 +137,36 @@ pub(crate) fn paint_into_canvas(session: &mut Session, canvas: &skia_safe::Canva
|
||||
// the logical-point layout maps onto the physical surface.
|
||||
let (w, h) = (usable_w, usable_h);
|
||||
canvas.scale((session.dpr, session.dpr));
|
||||
// Blend the platform-owned bands into the touch chrome: the
|
||||
// status-bar band carries the app-bar surface and the phone's
|
||||
// gesture band carries the dock surface, so the chrome reads as
|
||||
// one continuous sheet instead of stripes of canvas backdrop.
|
||||
if !host.preview_slideshow_active() {
|
||||
let (top_band, bottom_band) =
|
||||
op_editor_ui::widgets::mobile_chrome::safe_area_band_colors(
|
||||
host.editor_state(),
|
||||
);
|
||||
let full_w = usable_w + session.insets.left + session.insets.right;
|
||||
let mut band_frame = NativeFrameBackend::new(backend, canvas);
|
||||
if let (Some(color), true) = (top_band, session.insets.top > 0.0) {
|
||||
band_frame.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(0.0, 0.0),
|
||||
size: Point2D::new(full_w, session.insets.top),
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
if let (Some(color), true) = (bottom_band, session.insets.bottom > 0.0) {
|
||||
band_frame.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(0.0, session.insets.top + usable_h),
|
||||
size: Point2D::new(full_w, session.insets.bottom),
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
if insets_left > 0.0 || insets_top > 0.0 {
|
||||
canvas.translate((insets_left, insets_top));
|
||||
}
|
||||
@@ -354,11 +384,79 @@ mod editor_viewport_tests {
|
||||
paint_into_canvas(&mut session, surface.canvas());
|
||||
let mut pixels = vec![0_u8; 320 * 480 * 4];
|
||||
assert!(surface.read_rgba8(&mut pixels));
|
||||
let top_band = (12 * 320 + 160) * 4;
|
||||
// The band carries whatever chrome surface sits under it (the touch
|
||||
// app bar when touch chrome is active, else the root surface) — the
|
||||
// invariant is continuity with the row right below, in light colors.
|
||||
let sample = |x: usize, y: usize| {
|
||||
let i = (y * 320 + x) * 4;
|
||||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||||
};
|
||||
assert_eq!(
|
||||
&pixels[top_band..top_band + 4],
|
||||
&[0xef, 0xef, 0xef, 0xff],
|
||||
"light chrome must extend its root surface through the safe band"
|
||||
sample(160, 12),
|
||||
sample(160, 30),
|
||||
"light chrome must stay continuous through the safe band"
|
||||
);
|
||||
assert!(
|
||||
sample(160, 12).iter().all(|channel| *channel > 0xC0),
|
||||
"light theme band must stay light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_chrome_blends_the_safe_bands_with_bar_and_dock_surfaces() {
|
||||
let mut session = Session::new(CreateOptions {
|
||||
document: SAMPLE_DOC.to_owned(),
|
||||
width: 320.0,
|
||||
height: 480.0,
|
||||
dpr: 1.0,
|
||||
callbacks: Callbacks::default(),
|
||||
asset_base: None,
|
||||
editor_mode: true,
|
||||
})
|
||||
.expect("editor session");
|
||||
session.insets = crate::viewport::OpInsets {
|
||||
top: 24.0,
|
||||
right: 0.0,
|
||||
bottom: 20.0,
|
||||
left: 0.0,
|
||||
};
|
||||
{
|
||||
let ui = &mut session
|
||||
.editor
|
||||
.as_mut()
|
||||
.expect("editor host")
|
||||
.editor_state_mut()
|
||||
.editor_ui;
|
||||
ui.touch = true;
|
||||
ui.size_class = op_editor_core::size_class::EditorSizeClass::Compact;
|
||||
}
|
||||
|
||||
let mut surface = SkiaSurface::new_raster(320, 480);
|
||||
paint_into_canvas(&mut session, surface.canvas());
|
||||
let mut pixels = vec![0_u8; 320 * 480 * 4];
|
||||
assert!(surface.read_rgba8(&mut pixels));
|
||||
|
||||
let sample = |x: usize, y: usize| {
|
||||
let i = (y * 320 + x) * 4;
|
||||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||||
};
|
||||
// The status band must match the app-bar surface painted right
|
||||
// below it, and the gesture band must match the dock painted right
|
||||
// above it — no black canvas stripes.
|
||||
assert_eq!(
|
||||
sample(160, 12),
|
||||
sample(160, 30),
|
||||
"status band must blend with the app bar"
|
||||
);
|
||||
assert_eq!(
|
||||
sample(160, 470),
|
||||
sample(160, 445),
|
||||
"gesture band must blend with the bottom dock"
|
||||
);
|
||||
assert_ne!(
|
||||
sample(160, 12),
|
||||
[0, 0, 0],
|
||||
"band must not stay canvas-black"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,19 @@ use jni::sys::{jfloat, jint, jlong, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use op_engine_ffi::{
|
||||
op_editor_account_snapshot, op_editor_auth_sign_out, op_editor_begin_login,
|
||||
op_editor_cancel_export, op_editor_cancel_login, op_editor_configure_auth,
|
||||
op_editor_copy_export_file_name, op_editor_copy_login_url, op_editor_export_to_path,
|
||||
op_editor_open_document, op_editor_take_shell_action, OpStatus, SHELL_ACTION_NONE,
|
||||
op_editor_locale_code, op_editor_open_document, op_editor_set_locale,
|
||||
op_editor_take_shell_action, OpStatus, SHELL_ACTION_NONE,
|
||||
};
|
||||
|
||||
use crate::bindings::{call_status, jstring_bytes, with_engine};
|
||||
use crate::engine_thread::STATUS_CLOSING;
|
||||
|
||||
/// `OpNative.nativeEditorConfigureAuth` — initialize the real mobile auth
|
||||
/// backend with a private shell-owned storage directory.
|
||||
/// backend with a private shell-owned storage directory and the regional SSO
|
||||
/// deployment the shell resolved (an `OpAuthRegion` code).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorConfigureAuth<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
@@ -25,6 +28,7 @@ pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorConfigur
|
||||
storage_dir: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
app_version: JString<'local>,
|
||||
region: jint,
|
||||
) -> jint {
|
||||
let Some(storage_dir) = jstring_bytes(&mut env, &storage_dir) else {
|
||||
return OpStatus::InvalidArg as jint;
|
||||
@@ -44,10 +48,56 @@ pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorConfigur
|
||||
device_name.len(),
|
||||
app_version.as_ptr(),
|
||||
app_version.len(),
|
||||
region,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorAccountSnapshot` — copies the JSON account snapshot
|
||||
/// for the native account center, or returns null when it cannot be read.
|
||||
/// The snapshot is re-read per call and never consumed.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorAccountSnapshot<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
engine: jlong,
|
||||
) -> jstring {
|
||||
let bytes = with_engine(engine, move |e| {
|
||||
let mut required = 0usize;
|
||||
let status =
|
||||
unsafe { op_editor_account_snapshot(e, std::ptr::null_mut(), 0, &mut required) };
|
||||
if status != OpStatus::Ok || required == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0_u8; required];
|
||||
let status = unsafe {
|
||||
op_editor_account_snapshot(e, bytes.as_mut_ptr(), bytes.len(), &mut required)
|
||||
};
|
||||
(status == OpStatus::Ok).then_some(bytes)
|
||||
})
|
||||
.flatten();
|
||||
let Some(bytes) = bytes else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
let Ok(text) = String::from_utf8(bytes) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
env.new_string(text)
|
||||
.map(|value| value.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorSignOut` — revoke the device session and clear the
|
||||
/// engine's account mirror (native account-center sign out).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorSignOut<'local>(
|
||||
_env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
engine: jlong,
|
||||
) -> jint {
|
||||
call_status(engine, move |e| unsafe { op_editor_auth_sign_out(e) })
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorTakeLoginUrl` — atomically copies and consumes the
|
||||
/// pending UTF-8 verification URL, or returns null when none is ready.
|
||||
#[no_mangle]
|
||||
@@ -152,6 +202,63 @@ pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorCancelEx
|
||||
call_status(engine, move |e| unsafe { op_editor_cancel_export(e) })
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorBeginLogin` — start the device flow after the shell
|
||||
/// configured the auth runtime (RequestLogin follow-up).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorBeginLogin<'local>(
|
||||
_env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
engine: jlong,
|
||||
) -> jint {
|
||||
call_status(engine, move |e| unsafe { op_editor_begin_login(e) })
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorSetLocale` — apply a UI locale by BCP-47 tag.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorSetLocale<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
engine: jlong,
|
||||
tag: JString<'local>,
|
||||
) -> jint {
|
||||
let Some(tag) = jstring_bytes(&mut env, &tag) else {
|
||||
return OpStatus::InvalidArg as jint;
|
||||
};
|
||||
call_status(engine, move |e| unsafe {
|
||||
op_editor_set_locale(e, tag.as_ptr(), tag.len())
|
||||
})
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorLocaleCode` — the current UI locale's BCP-47 tag.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_tech_zseven_openpencil_OpNative_nativeEditorLocaleCode<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
engine: jlong,
|
||||
) -> jstring {
|
||||
let bytes = with_engine(engine, move |e| {
|
||||
let mut required = 0usize;
|
||||
let status = unsafe { op_editor_locale_code(e, std::ptr::null_mut(), 0, &mut required) };
|
||||
if status != OpStatus::Ok || required == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0_u8; required];
|
||||
let status =
|
||||
unsafe { op_editor_locale_code(e, bytes.as_mut_ptr(), bytes.len(), &mut required) };
|
||||
(status == OpStatus::Ok).then_some(bytes)
|
||||
})
|
||||
.flatten();
|
||||
let Some(bytes) = bytes else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
let Ok(text) = String::from_utf8(bytes) else {
|
||||
return std::ptr::null_mut();
|
||||
};
|
||||
env.new_string(text)
|
||||
.map(|value| value.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// `OpNative.nativeEditorTakeShellAction` — returns an `OpShellAction` value.
|
||||
/// Engine failures are negative so they cannot alias a valid action; an
|
||||
/// unknown/closing handle uses the existing `STATUS_CLOSING` sentinel.
|
||||
|
||||
@@ -124,6 +124,10 @@ pub use preview::{PreviewEnterError, PreviewLayoutError, PreviewSession};
|
||||
target_os = "android"
|
||||
))]
|
||||
pub use widget_host::{CursorHint, WidgetHostNative};
|
||||
// Regional SSO selection for embedded mobile shells; re-exported so the
|
||||
// C-ABI layer can name it without depending on op-auth-bridge directly.
|
||||
#[cfg(feature = "widget-host")]
|
||||
pub use op_auth_bridge::MobileSsoRegion;
|
||||
|
||||
// canvas_view_stub stays desktop-only (uses glow GL-isolation probe).
|
||||
#[cfg(feature = "gl-host")]
|
||||
|
||||
@@ -17,14 +17,18 @@ impl WidgetHostNative {
|
||||
/// and mirror any persisted session into this editor host.
|
||||
///
|
||||
/// The caller owns platform storage protection and supplies a private app
|
||||
/// directory. The SSO origin remains pinned inside `op-auth-bridge`.
|
||||
/// directory plus the regional SSO deployment it resolved (persisted
|
||||
/// preference or IP-informed default); both regional origins stay pinned
|
||||
/// inside `op-auth-bridge`.
|
||||
pub fn configure_mobile_auth(
|
||||
&mut self,
|
||||
storage_dir: std::path::PathBuf,
|
||||
device_name: String,
|
||||
app_version: String,
|
||||
region: op_auth_bridge::MobileSsoRegion,
|
||||
) -> bool {
|
||||
let config = op_auth_bridge::mobile_init_config(storage_dir, device_name, app_version);
|
||||
let config =
|
||||
op_auth_bridge::mobile_init_config(storage_dir, device_name, app_version, region);
|
||||
if !op_auth_bridge::available() || !op_auth_bridge::init_mobile(&config) {
|
||||
self.editor_state.editor_ui.account_ui_available = false;
|
||||
return false;
|
||||
@@ -48,6 +52,18 @@ impl WidgetHostNative {
|
||||
true
|
||||
}
|
||||
|
||||
/// Mobile-shell login entry: start the device flow after the shell has
|
||||
/// configured the auth runtime. Returns whether a flow is now active —
|
||||
/// `false` means the backend is a stub or refused a handle, which the
|
||||
/// shell surfaces natively (touch chrome never opens the engine modal).
|
||||
pub fn begin_mobile_login(&mut self) -> bool {
|
||||
if !self.editor_state.editor_ui.account_ui_available {
|
||||
return false;
|
||||
}
|
||||
self.begin_browser_login();
|
||||
self.auth_login_handle.is_some()
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -249,6 +265,45 @@ impl WidgetHostNative {
|
||||
changed
|
||||
}
|
||||
|
||||
/// JSON snapshot of the signed-in account for the mobile shells' native
|
||||
/// account-center screens. Re-read from the auth runtime on every call;
|
||||
/// `{"signed_in":false}` when signed out or the backend is a stub.
|
||||
pub fn mobile_account_snapshot_json(&self) -> String {
|
||||
match op_auth_bridge::poll(op_auth_bridge::SESSION_HANDLE) {
|
||||
AuthStatus::SignedIn {
|
||||
display_name,
|
||||
username,
|
||||
primary_email,
|
||||
avatar_url,
|
||||
device_id,
|
||||
} => serde_json::json!({
|
||||
"signed_in": true,
|
||||
"display_name": display_name,
|
||||
"username": username,
|
||||
"primary_email": primary_email,
|
||||
"avatar_url": avatar_url,
|
||||
"device_id": device_id,
|
||||
})
|
||||
.to_string(),
|
||||
_ => serde_json::json!({ "signed_in": false }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Native-shell sign-out: revoke the device session in the private
|
||||
/// runtime and clear this host's account mirror. This is the same
|
||||
/// sequence the engine-painted account menu's Sign out row runs; the
|
||||
/// mobile shells reach it through the C ABI from their native account
|
||||
/// screen instead of a painted press.
|
||||
pub fn sign_out_account(&mut self) {
|
||||
op_auth_bridge::sign_out();
|
||||
self.forget_auth_session_profile();
|
||||
let ui = &mut self.editor_state.editor_ui;
|
||||
if ui.account != AccountState::Anonymous {
|
||||
ui.account = AccountState::Anonymous;
|
||||
}
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Forget host-local comparison state after either native sign-out path.
|
||||
pub(in crate::widget_host) fn forget_auth_session_profile(&mut self) {
|
||||
self.auth_session_refresh_deadline = None;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn anonymous_more_sign_in_is_reachable_and_honest_at_every_touch_breakpoint() {
|
||||
fn anonymous_more_sign_in_queues_the_shell_login_request() {
|
||||
for (class, width, height) in [
|
||||
(EditorSizeClass::Compact, 320.0, 568.0),
|
||||
(EditorSizeClass::Medium, 834.0, 1_112.0),
|
||||
@@ -24,10 +24,14 @@ fn anonymous_more_sign_in_is_reachable_and_honest_at_every_touch_breakpoint() {
|
||||
height,
|
||||
));
|
||||
|
||||
// Touch chrome hands the whole sign-in experience to the shell: the
|
||||
// engine-painted login modal never opens; the one-shot request lets
|
||||
// the shell configure auth lazily and present its native screen (or
|
||||
// a native unavailability notice).
|
||||
let ui = &host.editor_state().editor_ui;
|
||||
assert_eq!(ui.mobile_sheet, None, "{class:?}");
|
||||
assert!(ui.login_modal_open, "{class:?}");
|
||||
assert!(ui.login_modal_stub_hint_shown, "{class:?}");
|
||||
assert!(ui.pending_mobile_login, "{class:?}");
|
||||
assert!(!ui.login_modal_open, "{class:?}");
|
||||
assert!(!ui.account_menu_open, "{class:?}");
|
||||
assert!(!ui.collab.panel.open, "{class:?}");
|
||||
assert!(!ui.collab.panel.join_address_focused, "{class:?}");
|
||||
@@ -37,23 +41,18 @@ fn anonymous_more_sign_in_is_reachable_and_honest_at_every_touch_breakpoint() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_more_sign_in_starts_auth_without_waiting_for_modal_confirmation() {
|
||||
fn begin_mobile_login_reports_stub_unavailability_without_a_modal() {
|
||||
let mut host = touch_host(EditorSizeClass::Compact);
|
||||
// The runtime gate is the host's authoritative signal. This test build has
|
||||
// the inert bridge, so an immediate begin settles to Failed(Unavailable)
|
||||
// instead of creating a handle; importantly it never shows the stub hint.
|
||||
// Unconfigured backend fails closed.
|
||||
assert!(!host.begin_mobile_login());
|
||||
// The runtime gate is the host's authoritative signal. This test build
|
||||
// has the inert bridge, so an immediate begin settles to
|
||||
// Failed(Unavailable) instead of creating a handle; the shell surfaces
|
||||
// that natively and the modal stays closed.
|
||||
host.editor_state_mut().editor_ui.account_ui_available = true;
|
||||
|
||||
assert!(press_more_entry(
|
||||
&mut host,
|
||||
MobileMoreEntry::SignIn,
|
||||
390.0,
|
||||
844.0,
|
||||
));
|
||||
|
||||
assert!(!host.begin_mobile_login());
|
||||
let ui = &host.editor_state().editor_ui;
|
||||
assert!(ui.login_modal_open);
|
||||
assert!(!ui.login_modal_stub_hint_shown);
|
||||
assert!(!ui.login_modal_open);
|
||||
assert_eq!(
|
||||
ui.login_modal_status,
|
||||
Some(op_editor_core::LoginFlowStatus::Failed(
|
||||
@@ -63,7 +62,7 @@ fn configured_more_sign_in_starts_auth_without_waiting_for_modal_confirmation()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_in_more_account_opens_the_actual_account_settings_tab() {
|
||||
fn signed_in_more_account_requests_the_native_account_center() {
|
||||
for (class, width, height) in [
|
||||
(EditorSizeClass::Compact, 390.0, 844.0),
|
||||
(EditorSizeClass::Medium, 834.0, 1_112.0),
|
||||
@@ -85,16 +84,11 @@ fn signed_in_more_account_opens_the_actual_account_settings_tab() {
|
||||
|
||||
let state = host.editor_state();
|
||||
assert_eq!(state.editor_ui.mobile_sheet, None, "{class:?}");
|
||||
assert!(state.editor_ui.agent_settings_open, "{class:?}");
|
||||
assert_eq!(
|
||||
state.editor_ui.agent_settings.tab,
|
||||
AgentSettingsTab::Account
|
||||
);
|
||||
assert_eq!(
|
||||
AgentSettingsPanel::for_editor(state).active_tab(),
|
||||
AgentSettingsTab::Account,
|
||||
"the signed-in mobile Account entry must not silently fall back to Agents"
|
||||
);
|
||||
// The SSO account experience on phones is platform-native: the tile
|
||||
// queues a one-shot shell request instead of opening the painted
|
||||
// desktop Settings surface.
|
||||
assert!(state.editor_ui.pending_account_center, "{class:?}");
|
||||
assert!(!state.editor_ui.agent_settings_open, "{class:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,11 @@ mod mobile_auth_collab_tests;
|
||||
mod mobile_layer_drag_tests;
|
||||
use op_editor_core::size_class::{EditorSizeClass, MobileSheetKind};
|
||||
use op_editor_core::{
|
||||
agent_settings::AgentSettingsTab,
|
||||
editor_ui_state::{EffectParamFocus, FileAction},
|
||||
AccountState, AssetCenterTab, AuthenticatedCollabSession, CollabAvailability,
|
||||
CollabConnectionPhase, CollabUiRole, EffectField, FontPickerPurpose, NodeId, PropertyFocus,
|
||||
Tool,
|
||||
};
|
||||
use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel;
|
||||
use op_editor_ui::widgets::{host_canvas_geometry, CollabPanel, MobileAppBar, MobileMoreEntry};
|
||||
use op_editor_ui::{Point2D, Rect};
|
||||
|
||||
|
||||
@@ -514,35 +514,31 @@ impl WidgetHostNative {
|
||||
match entry {
|
||||
op_editor_ui::widgets::MobileMoreEntry::Ai => unreachable!("handled above"),
|
||||
op_editor_ui::widgets::MobileMoreEntry::SignIn => {
|
||||
let backend_available = {
|
||||
let ui = &mut self.editor_state.editor_ui;
|
||||
ui.account_menu_open = false;
|
||||
ui.collab.panel.open = false;
|
||||
ui.collab.panel.join_address_focused = false;
|
||||
ui.login_modal_open = true;
|
||||
ui.login_modal_hover = None;
|
||||
// Mobile targets without the proprietary auth bridge
|
||||
// keep this entry visible, but the modal must say so
|
||||
// honestly.
|
||||
ui.login_modal_stub_hint_shown = !ui.account_ui_available;
|
||||
ui.account_ui_available
|
||||
};
|
||||
// A configured mobile backend starts immediately. The
|
||||
// modal remains behind the platform WebView so terminal
|
||||
// denial/expiry can still surface an actionable error.
|
||||
if backend_available {
|
||||
self.begin_browser_login();
|
||||
}
|
||||
}
|
||||
op_editor_ui::widgets::MobileMoreEntry::Account => {
|
||||
// Touch chrome uses a full, reachable Account settings
|
||||
// surface rather than a dropdown anchored to an unpainted
|
||||
// desktop avatar button.
|
||||
// Touch chrome never opens the engine-painted login
|
||||
// modal: the shell owns the whole sign-in experience.
|
||||
// The one-shot request lets it configure the auth
|
||||
// runtime lazily (region resolution) before starting
|
||||
// the flow, and surface unavailability natively.
|
||||
let ui = &mut self.editor_state.editor_ui;
|
||||
ui.account_menu_open = false;
|
||||
ui.agent_settings_open = true;
|
||||
ui.agent_settings.tab =
|
||||
op_editor_core::agent_settings::AgentSettingsTab::Account;
|
||||
ui.collab.panel.open = false;
|
||||
ui.collab.panel.join_address_focused = false;
|
||||
ui.pending_mobile_login = true;
|
||||
}
|
||||
op_editor_ui::widgets::MobileMoreEntry::Language => {
|
||||
// The shell presents a native 15-language sheet and
|
||||
// applies the choice through `op_editor_set_locale`.
|
||||
self.editor_state.editor_ui.pending_language_picker = true;
|
||||
}
|
||||
op_editor_ui::widgets::MobileMoreEntry::Account => {
|
||||
// Touch chrome hands the account surface to the mobile
|
||||
// shell's native account-center screen (drained through
|
||||
// the FFI shell-action channel) instead of the painted
|
||||
// desktop Settings tab: the SSO account experience on
|
||||
// phones is platform-native by design.
|
||||
let ui = &mut self.editor_state.editor_ui;
|
||||
ui.account_menu_open = false;
|
||||
ui.pending_account_center = true;
|
||||
self.editor_state.chat.blur_input(self.now_ms);
|
||||
}
|
||||
op_editor_ui::widgets::MobileMoreEntry::Collaboration => {
|
||||
|
||||
Reference in New Issue
Block a user