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:
Kayshen-X
2026-08-16 22:47:50 +08:00
parent 17211f7b96
commit d20be24101
16 changed files with 663 additions and 89 deletions
+47 -3
View File
@@ -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);
+39 -3
View File
@@ -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
+52
View File
@@ -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
+150 -7
View 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
+10 -7
View File
@@ -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::{
+102 -4
View File
@@ -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"
);
}
}
+109 -2
View File
@@ -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.
+4
View File
@@ -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 => {