From 8352570edacf5bec7d4242f3d56da62e047779d6 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:21:39 +0800 Subject: [PATCH 01/13] feat(sync): support self-hosted cloud_sync_e2ee server via static token Adds an alternative to GitHub OAuth for encrypted multi-device sync: a user-configurable server origin plus a static bearer token, stored securely via the existing CredentialStore (never in plain preferences). A configured custom token always takes priority over a GitHub session and bypasses the system-proxy preference, since a self-hosted server is the user's own explicitly reachable endpoint. Verified end-to-end (sign-in, vault read, create, enable) against a real self-hosted deployment through the actual desktop UI. Co-Authored-By: Claude Sonnet 5 --- .../src/cloud_sync_e2ee/adapter.rs | 2 + .../openless-core/src/cloud_sync_e2ee/api.rs | 8 + .../src/cloud_sync_e2ee/service.rs | 272 ++++++++++++++---- .../src/cloud_sync_e2ee/setup_prompt_tests.rs | 1 + .../src/cloud_sync_e2ee/tests.rs | 115 ++++++++ .../src/cloud_sync_e2ee_protocol/transport.rs | 21 +- .../crates/openless-core/src/credentials.rs | 3 + .../crates/openless-core/src/shared_types.rs | 11 + .../src-tauri/src/commands/cloud_sync_e2ee.rs | 17 ++ .../app/src-tauri/src/commands/credentials.rs | 12 + openless-all/app/src-tauri/src/coordinator.rs | 7 +- openless-all/app/src-tauri/src/lib.rs | 2 + .../src-tauri/src/persistence/credentials.rs | 76 +++++ openless-all/app/src/i18n/de.ts | 6 + openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/es.ts | 6 + openless-all/app/src/i18n/fr.ts | 6 + openless-all/app/src/i18n/ja.ts | 6 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + .../app/src/lib/ipc/cloud-sync-e2ee.ts | 2 + openless-all/app/src/lib/types.ts | 2 + .../src/pages/settings/CloudSyncSection.tsx | 86 +++++- 24 files changed, 625 insertions(+), 56 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index 8fcaaabfe..318dac5c0 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -80,6 +80,7 @@ pub(crate) fn build( credentials.clone(), ); log::error!("[e2ee-adapter] stage=store_new"); + let credential_store_for_service = credentials.clone(); let store = Arc::new( CoreSyncStore::new( repositories, @@ -104,6 +105,7 @@ pub(crate) fn build( marketplace, local, store.clone(), + credential_store_for_service, events, ); Ok((service, store)) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs index b01b5b66a..fac19f0e7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -156,6 +156,14 @@ impl OpenLessBackend { self.encrypted_sync_service()?.sign_out().await } + pub async fn cloud_sync_e2ee_sign_in_with_token( + &self, + ) -> Result { + self.encrypted_sync_service()? + .sign_in_with_custom_token() + .await + } + pub async fn cloud_sync_e2ee_begin_sign_in(&self) -> Result { self.encrypted_sync_service()?.begin_sign_in().await } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs index 640ac19cf..7b101f177 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -21,9 +21,10 @@ use crate::cloud_sync_e2ee_protocol::{ transport::{Metadata, MetadataResult, OperationStatus, ProxyPolicy, SyncSession, Transport}, types::*, }; +use crate::credentials::{CredentialKey, CredentialNamespace, CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT}; use crate::events::{BackendEventKind, BackendEventPublisher}; use crate::marketplace::MarketplaceService; -use crate::{BackendError, SecretValue}; +use crate::{BackendError, CredentialStore, SecretValue}; use super::{ document_error, @@ -61,10 +62,19 @@ pub(crate) struct SyncServiceConfig { pub github_client_id: String, } +/// How the current `Connection` authenticated. Re-validated on every use so a +/// credential change (GitHub sign-out, or the custom token being edited) is +/// detected the same way `current_account` already detects a GitHub account +/// switch — never trust a cached `Connection` past a credential mismatch. +enum ConnectionCredential { + Github(SecretValue), + CustomToken(SecretValue), +} + struct Connection { transport: Transport, session: SyncSession, - github_token: SecretValue, + credential: ConnectionCredential, expires_at: Instant, } @@ -117,6 +127,7 @@ struct Shared { marketplace: Arc, data: Arc, local: LocalStorage, + credential_store: Arc, events: BackendEventPublisher, runtime: tokio::sync::Mutex, status: Mutex, @@ -137,6 +148,7 @@ impl EncryptedSyncService { marketplace: Arc, local: LocalStorage, data: Arc, + credential_store: Arc, events: BackendEventPublisher, ) -> Self { let status = EncryptedSyncStatus::initial(config.origin.clone()); @@ -145,6 +157,7 @@ impl EncryptedSyncService { marketplace, data, local, + credential_store, events, runtime: tokio::sync::Mutex::new(Runtime { initialized: false, @@ -226,20 +239,40 @@ impl EncryptedSyncService { } } + fn custom_token_key() -> SyncResult { + CredentialKey::new(CredentialNamespace::Application, None, CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT) + .map_err(|_| error("service_unavailable")) + } + + /// `None` means "no custom token configured" (use GitHub identity), not a + /// credential-store failure — a read error is folded into `None` so a + /// transient store hiccup falls back to the GitHub path rather than + /// wedging the whole connect flow. + async fn read_custom_token(&self) -> Option { + let key = Self::custom_token_key().ok()?; + self.0.credential_store.read(key).await.ok().flatten() + } + async fn current_account(&self, runtime: &Runtime) -> SyncResult<()> { self.check_cancelled()?; let connection = runtime .connection .as_ref() .ok_or_else(|| error("sign_in_required"))?; - if self - .0 - .marketplace - .read_access_token() - .await - .map_err(|_| error("sign_in_required"))? - != connection.github_token - { + let unchanged = match &connection.credential { + ConnectionCredential::Github(token) => { + self.0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + == *token + } + ConnectionCredential::CustomToken(token) => { + self.read_custom_token().await.as_ref() == Some(token) + } + }; + if !unchanged { return Err(error("account_changed")); } self.check_cancelled() @@ -372,8 +405,21 @@ impl EncryptedSyncService { } // Capture once after recovery has applied the current preferences. Both // cache validation and client construction use this exact policy. - let proxy_policy = - ProxyPolicy::for_origin(&self.0.config.origin, crate::net::use_system_proxy()); + // The system-proxy preference exists specifically to route around + // GitHub connectivity issues for the official server; a self-hosted + // custom-token server is the user's own explicitly configured, + // directly reachable endpoint and must never be silently routed + // through a system/env proxy that may not even be running. + let use_system_proxy = + self.read_custom_token().await.is_none() && crate::net::use_system_proxy(); + let proxy_policy = ProxyPolicy::for_origin(&self.0.config.origin, use_system_proxy); + log::warn!( + "[e2ee-token] connect: use_system_proxy={use_system_proxy} proxy_policy={proxy_policy:?} env HTTPS_PROXY={:?} HTTP_PROXY={:?} ALL_PROXY={:?} NO_PROXY={:?}", + std::env::var("HTTPS_PROXY").ok(), + std::env::var("HTTP_PROXY").ok(), + std::env::var("ALL_PROXY").ok(), + std::env::var("NO_PROXY").ok(), + ); self.connect_with_proxy_policy(runtime, proxy_policy).await } @@ -382,11 +428,29 @@ impl EncryptedSyncService { runtime: &mut Runtime, proxy_policy: ProxyPolicy, ) -> SyncResult<()> { + // A configured custom token always takes priority over a GitHub sign-in: + // the user explicitly opted into self-hosted mode by setting it, and a + // session can only ever be bound to one credential at a time. + let custom_token = self.read_custom_token().await; + log::warn!( + "[e2ee-token] connect_with_proxy_policy: origin={} custom_token_present={} has_cached_connection={}", + self.0.config.origin, + custom_token.is_some(), + runtime.connection.is_some() + ); let valid = if let Some(connection) = &runtime.connection { + let credential_unchanged = match (&connection.credential, &custom_token) { + (ConnectionCredential::CustomToken(existing), Some(current)) => existing == current, + (ConnectionCredential::Github(existing), None) => { + self.0.marketplace.read_access_token().await.ok().as_ref() == Some(existing) + } + // Switching between GitHub and custom-token mode (token just + // added, or just cleared) always forces a fresh connect. + _ => false, + }; connection.expires_at > Instant::now() + Duration::from_secs(30) && connection.transport.proxy_policy() == proxy_policy - && self.0.marketplace.read_access_token().await.ok().as_ref() - == Some(&connection.github_token) + && credential_unchanged } else { false }; @@ -394,13 +458,19 @@ impl EncryptedSyncService { return Ok(()); } #[cfg(not(test))] - let transport = Transport::new( + let transport = match Transport::new( &self.0.config.origin, &self.0.config.github_client_id, proxy_policy, ) .await - .map_err(protocol_error)?; + { + Ok(transport) => transport, + Err(e) => { + log::warn!("[e2ee-token] Transport::new failed: {e:?}"); + return Err(protocol_error(e)); + } + }; #[cfg(test)] let transport = if self.0.config.origin.starts_with("http://127.0.0.1:") { Transport::for_test_with_proxy_policy( @@ -419,34 +489,57 @@ impl EncryptedSyncService { .await .map_err(protocol_error)? }; - let (token, account) = self - .0 - .marketplace - .sync_identity() - .await - .map_err(|_| error("sign_in_required"))?; - self.check_cancelled()?; - let session = transport - .exchange(token.expose_secret(), &account.github_id) - .await - .map_err(protocol_error)?; - if session.account().github_id != account.github_id - || transport.service_origin().trim_end_matches('/') + let (session, credential, account) = if let Some(token) = custom_token { + self.check_cancelled()?; + log::warn!("[e2ee-token] calling exchange_with_token against {}", self.0.config.origin); + let session = match transport.exchange_with_token(token.expose_secret()).await { + Ok(session) => { + log::warn!("[e2ee-token] exchange_with_token succeeded"); + session + } + Err(e) => { + log::warn!("[e2ee-token] exchange_with_token failed: {e:?}"); + return Err(protocol_error(e)); + } + }; + if transport.service_origin().trim_end_matches('/') != self.0.config.origin.trim_end_matches('/') - { - return Err(error("account_changed")); - } + { + return Err(error("account_changed")); + } + let account = session.account().clone(); + (session, ConnectionCredential::CustomToken(token), account) + } else { + let (token, account) = self + .0 + .marketplace + .sync_identity() + .await + .map_err(|_| error("sign_in_required"))?; + self.check_cancelled()?; + let session = transport + .exchange(token.expose_secret(), &account.github_id) + .await + .map_err(protocol_error)?; + if session.account().github_id != account.github_id + || transport.service_origin().trim_end_matches('/') + != self.0.config.origin.trim_end_matches('/') + { + return Err(error("account_changed")); + } + if self + .0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + != token + { + return Err(error("account_changed")); + } + (session, ConnectionCredential::Github(token), account) + }; let backup_retention_days = transport.capabilities().max_backup_retention_days; - if self - .0 - .marketplace - .read_access_token() - .await - .map_err(|_| error("sign_in_required"))? - != token - { - return Err(error("account_changed")); - } let owner = account.github_id.as_str().to_string(); if runtime.settings.owner_id.as_deref() != Some(&owner) { if runtime.settings.owner_id.is_some() { @@ -473,7 +566,7 @@ impl EncryptedSyncService { runtime.connection = Some(Connection { transport, session, - github_token: token, + credential, expires_at, }); runtime.baseline = self.0.local.read(&owner, "baseline").await?; @@ -693,7 +786,7 @@ impl EncryptedSyncService { } let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; self.begin(); - let result = async { + let result: SyncResult = async { self.connect(&mut runtime).await?; self.refresh_metadata(&mut runtime).await?; runtime.settings.consent_version = Some(consent_version); @@ -718,6 +811,14 @@ impl EncryptedSyncService { Ok(step) } .await; + match &result { + Ok(step) => log::warn!("[e2ee-token] prepare_enable: succeeded, next_step={step:?}"), + Err(e) => log::warn!( + "[e2ee-token] prepare_enable: failed code={:?} message={}", + e.code, + e.message + ), + } self.finish(&mut runtime, &result); result.map(|next_step| EnablePreparation { next_step, @@ -797,6 +898,7 @@ impl EncryptedSyncService { if consent_version != CONSENT_VERSION { return Err(error("consent_required")); } + log::warn!("[e2ee-token] create: entered, observed_revision={observed_revision}"); let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; self.0.auto_suspended.store(false, Ordering::Release); @@ -806,7 +908,14 @@ impl EncryptedSyncService { self.reconcile_for_review(&mut runtime).await?; self.refresh_metadata(&mut runtime).await?; let metadata = self.metadata(&runtime)?.value(); + log::warn!( + "[e2ee-token] create: server state={:?} server_revision={} observed={}", + metadata.state, + metadata.revision.as_str(), + observed.as_str() + ); if metadata.state == VaultState::Active || metadata.revision != observed { + log::warn!("[e2ee-token] create: revision_conflict (state or revision mismatch)"); return Err(error("revision_conflict")); } if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { @@ -1708,6 +1817,9 @@ impl EncryptedSyncService { self.0.cancelled.store(true, Ordering::Release); // Preserve the account API's existing fail-closed guarantee immediately, // even while an earlier sync task is draining its native I/O worker. + // Harmless to set even in custom-token mode: it only makes the + // marketplace's own `read_access_token` fail until the next real + // GitHub login, which self-heals and deletes nothing. self.0.marketplace.invalidate_authentication(); let mut runtime = self.0.runtime.lock().await; runtime.key = None; @@ -1715,14 +1827,32 @@ impl EncryptedSyncService { runtime.settings.enabled = false; runtime.settings.remember_key = false; let connection = runtime.connection.take(); - // OAuth logout is independent of encrypted journal/key cleanup. Always - // attempt it; denied sync-key deletion cannot keep GitHub authorized. - let logout_result = self - .0 - .marketplace - .logout() + // A custom-token session must never delete the marketplace's GitHub + // credential: that identity is unrelated to this sync server and may + // still be in active use by the unrelated plugin-marketplace feature. + let was_custom_token = matches!( + connection.as_ref().map(|c| &c.credential), + Some(ConnectionCredential::CustomToken(_)) + ); + let logout_result: SyncResult<()> = if was_custom_token { + async { + let key = Self::custom_token_key()?; + self.0 + .credential_store + .remove(key) + .await + .map_err(|_| error("secure_storage_denied")) + } .await - .map_err(|_| error("secure_storage_denied")); + } else { + // OAuth logout is independent of encrypted journal/key cleanup. Always + // attempt it; denied sync-key deletion cannot keep GitHub authorized. + self.0 + .marketplace + .logout() + .await + .map_err(|_| error("secure_storage_denied")) + }; let cleanup_result = async { self.0.local.set_lockout(true).await?; self.initialize(&mut runtime).await?; @@ -1740,11 +1870,11 @@ impl EncryptedSyncService { if let Some(Connection { transport, session, - github_token, + credential, .. }) = connection { - drop(github_token); + drop(credential); // Local sign-out remains possible offline; the discarded session // also has a short server-enforced expiry. let _ = tokio::time::timeout(Duration::from_secs(5), transport.revoke_session(session)) @@ -1769,6 +1899,42 @@ impl EncryptedSyncService { result.map(|()| self.status()) } + /// Single-shot equivalent of the GitHub device-flow sign-in for a + /// self-hosted server: there is no polling loop because the token + /// exchange is one HTTP round trip, not an out-of-band user approval. + /// Requires a custom token to already be saved (see `CloudSyncSection`'s + /// save action) — this never falls back to a GitHub identity, so clicking + /// it without a saved token fails clearly instead of silently doing the + /// wrong thing. + pub(crate) async fn sign_in_with_custom_token(&self) -> SyncResult { + log::warn!("[e2ee-token] sign_in_with_custom_token: entered"); + if self.read_custom_token().await.is_none() { + log::warn!("[e2ee-token] sign_in_with_custom_token: no custom token found in credential store, aborting before any network call"); + return Err(error("sign_in_required")); + } + log::warn!("[e2ee-token] sign_in_with_custom_token: token found, attempting to acquire runtime lock"); + let mut runtime = match self.0.runtime.try_lock() { + Ok(runtime) => runtime, + Err(_) => { + log::warn!("[e2ee-token] sign_in_with_custom_token: runtime lock busy"); + return Err(error("busy")); + } + }; + self.begin(); + log::warn!("[e2ee-token] sign_in_with_custom_token: calling connect()"); + let result = self.connect(&mut runtime).await; + match &result { + Ok(()) => log::warn!("[e2ee-token] sign_in_with_custom_token: connect() succeeded"), + Err(e) => log::warn!( + "[e2ee-token] sign_in_with_custom_token: connect() failed: code={:?} message={}", + e.code, + e.message + ), + } + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + pub(crate) async fn begin_sign_in(&self) -> SyncResult { use crate::domains::MarketplaceApi; let flow = self diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs index 8496ba296..cfd186ae6 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs @@ -99,6 +99,7 @@ fn reopened_service(fixture: &Fixture, server: &Server) -> EncryptedSyncService marketplace, local, fixture.data.clone(), + fixture.vault.clone(), events, ) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs index f21bca7be..9859a8b6d 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -274,6 +274,8 @@ struct Remote { reject_upload: bool, hide_receipts: bool, drop_metadata_response: bool, + github_auth_requests: usize, + token_auth_requests: usize, } struct Server { origin: String, @@ -386,8 +388,17 @@ impl Server { json!({"id":owner.parse::().unwrap(),"login":"fixture-user"}) } ("POST", "/v1/auth/github") => { + remote.github_auth_requests += 1; json!({"protocolVersion":1,"accessToken":"a".repeat(43),"tokenType":"Bearer","expiresIn":900,"account":{"githubId":owner,"login":"fixture-user"}}) } + ("POST", "/v1/auth/token") => { + remote.token_auth_requests += 1; + // Must report the same owner as every other endpoint here: the + // server's vault-metadata-vs-session owner cross-check + // (`validate_against_metadata`) would otherwise reject it as + // `AccountMismatch`, regardless of which auth path was used. + json!({"protocolVersion":1,"accessToken":"b".repeat(43),"tokenType":"Bearer","expiresIn":900,"account":{"githubId":owner,"login":"fixture-token-user"}}) + } ("GET", "/v1/me/vault") => match &remote.snapshot { None => { json!({"protocolVersion":1,"state":"empty","ownerGithubId":owner,"revision":"0","vaultId":null,"keyId":null,"updatedAt":null,"payloadSchemaVersion":null,"ciphertextBytes":0,"ciphertextSha256":null,"lastOperationId":null}) @@ -499,6 +510,7 @@ impl Fixture { marketplace, local, data.clone(), + vault.clone(), events, ); Self { @@ -529,6 +541,33 @@ impl Fixture { ) .await } + async fn set_custom_token(&self, token: &str) { + self.vault + .write( + CredentialKey::new( + CredentialNamespace::Application, + None, + crate::credentials::CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT, + ) + .unwrap(), + SecretValue::new(token), + ) + .await + .unwrap(); + } + async fn custom_token(&self) -> Option { + self.vault + .read( + CredentialKey::new( + CredentialNamespace::Application, + None, + crate::credentials::CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT, + ) + .unwrap(), + ) + .await + .unwrap() + } } #[tokio::test] @@ -1311,5 +1350,81 @@ async fn round2_sync_key_deletion_failure_must_not_prevent_account_sign_out() { assert_eq!(fixture.service.status().auth_state, AuthState::SignedOut); } +#[tokio::test] +async fn custom_token_sign_in_takes_priority_over_github_and_never_calls_github_auth() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + // Fixture::new already seeded a working GitHub marketplace token; a custom + // token must still win without ever touching the GitHub auth endpoint. + fixture.set_custom_token("fixture-custom-token").await; + let status = fixture.service.sign_in_with_custom_token().await.unwrap(); + assert_eq!(status.auth_state, AuthState::SignedIn); + assert_eq!(status.account.unwrap().github_id, OWNER); + let remote = server.state.lock().unwrap(); + assert_eq!(remote.token_auth_requests, 1); + assert_eq!( + remote.github_auth_requests, 0, + "a configured custom token must win outright, never falling through to GitHub" + ); +} + +#[tokio::test] +async fn sign_in_with_custom_token_fails_clearly_without_a_saved_token() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + assert_eq!( + fixture + .service + .sign_in_with_custom_token() + .await + .unwrap_err() + .message, + "sign_in_required" + ); + assert_eq!(server.state.lock().unwrap().token_auth_requests, 0); +} + +#[tokio::test] +async fn custom_token_credential_change_forces_reconnect_not_stale_cache() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.set_custom_token("token-a").await; + fixture.prepare().await; + assert_eq!(server.state.lock().unwrap().token_auth_requests, 1); + // Same token, called again immediately: the cached connection is still + // valid and must not re-authenticate. + fixture.prepare().await; + assert_eq!(server.state.lock().unwrap().token_auth_requests, 1); + // The token value changes underneath the cached connection: this must be + // detected and force a fresh exchange, not silently reuse the old session. + fixture.set_custom_token("token-b").await; + fixture.prepare().await; + assert_eq!(server.state.lock().unwrap().token_auth_requests, 2); +} + +#[tokio::test] +async fn sign_out_with_custom_token_forgets_only_the_custom_token_not_github() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.set_custom_token("fixture-custom-token").await; + fixture.prepare().await; + assert_eq!(fixture.service.status().auth_state, AuthState::SignedIn); + fixture.service.sign_out().await.unwrap(); + assert_eq!(fixture.service.status().auth_state, AuthState::SignedOut); + assert!( + fixture.custom_token().await.is_none(), + "sign_out must forget the custom token it authenticated with" + ); + let github_token = fixture + .vault + .read(CredentialKey::new(CredentialNamespace::Marketplace, None, "github.oauth_token").unwrap()) + .await + .unwrap(); + assert!( + github_token.is_some(), + "sign_out from a custom-token session must never delete an unrelated GitHub credential" + ); +} + #[path = "setup_prompt_tests.rs"] mod setup_prompt_tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs index 85f21bcb1..7b3df3988 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs @@ -359,6 +359,22 @@ impl Transport { }) } + /// Static-token equivalent of `exchange`, for a self-hosted server that + /// bypasses GitHub OAuth. There is no pre-known expected account to check + /// against: the server's response is itself the identity assertion. + pub(crate) async fn exchange_with_token(&self, static_token: &str) -> Result { + let response = send( + self.request(Method::POST, "/v1/auth/token") + .header(AUTHORIZATION, bearer_header(static_token)?), + ) + .await?; + let session: AuthSession = success_json(response, CONTROL_BODY_LIMIT).await?; + Ok(SyncSession { + origin: self.origin.clone(), + session, + }) + } + /// Consume the local credential even when remote logout fails. A validated /// 401 means the credential is already unusable and is treated as logged out. pub(crate) async fn revoke_session(&self, session: SyncSession) -> Result<()> { @@ -811,7 +827,10 @@ fn bearer_header(token: &str) -> Result { } async fn send(request: RequestBuilder) -> Result { - let response = request.send().await.map_err(|_| Error::Transport)?; + let response = request.send().await.map_err(|e| { + log::warn!("[e2ee-token] raw reqwest send error: {e:?} (url={:?})", e.url()); + Error::Transport + })?; if response.status().is_redirection() && response.status() != StatusCode::NOT_MODIFIED { return Err(Error::InvalidResponse("redirect refused")); } diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index b74d8c65f..f840477b7 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -153,6 +153,9 @@ pub const OMNI_ENDPOINT_ACCOUNT: &str = "omni.endpoint"; pub const OMNI_MODEL_ACCOUNT: &str = "omni.model"; pub const OMNI_EXTRA_HEADERS_ACCOUNT: &str = "omni.extra_headers"; pub const OMNI_TEMPERATURE_ACCOUNT: &str = "omni.temperature"; +/// Static bearer token for a self-hosted `cloud_sync_e2ee` server, stored +/// under `CredentialNamespace::Application` (an alternative to GitHub OAuth). +pub const CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT: &str = "cloud_sync.custom_token"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 9da6eb7a3..63a002510 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -846,6 +846,12 @@ pub struct UserPreferences { /// saves can't wipe it. #[serde(default)] pub splash_seen_version: String, + /// Self-hosted encrypted-sync server origin (e.g. "https://sync.example.com/"), + /// overriding `DEFAULT_SYNC_SERVICE_ORIGIN`. `None` means use the default. + /// Validated against the same rules as the protocol's own `parse_origin` + /// (https-only, no userinfo/query/fragment, root path) before being saved. + #[serde(default)] + pub sync_custom_server_origin: Option, } impl UserPreferences { @@ -1125,6 +1131,8 @@ struct UserPreferencesWire { android_overlay_size_dp: u32, #[serde(default)] splash_seen_version: String, + #[serde(default)] + sync_custom_server_origin: Option, } fn deserialize_selection_polish_hotkey<'de, D>( @@ -1285,6 +1293,7 @@ impl Default for UserPreferencesWire { android_overlay_gesture_actions: None, android_overlay_size_dp: prefs.android_overlay_size_dp, splash_seen_version: prefs.splash_seen_version, + sync_custom_server_origin: prefs.sync_custom_server_origin, } } } @@ -1499,6 +1508,7 @@ impl<'de> Deserialize<'de> for UserPreferences { wire.android_overlay_size_dp, ), splash_seen_version: wire.splash_seen_version, + sync_custom_server_origin: wire.sync_custom_server_origin, }) } } @@ -1854,6 +1864,7 @@ impl Default for UserPreferences { android_overlay_gesture_actions: default_android_overlay_gesture_actions(), android_overlay_size_dp: default_android_overlay_size_dp(), splash_seen_version: String::new(), + sync_custom_server_origin: None, } } } diff --git a/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs index 075acfd2b..3e03fd01c 100644 --- a/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs +++ b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs @@ -183,6 +183,23 @@ pub async fn cloud_sync_e2ee_sign_out( core.cloud_sync_e2ee_sign_out().await } +#[tauri::command] +pub async fn cloud_sync_e2ee_sign_in_with_token( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + log::warn!( + "[e2ee-token] cloud_sync_e2ee_sign_in_with_token invoked, window label={:?} url={:?}", + window.label(), + window.url().map(|u| u.to_string()) + ); + if let Err(e) = require_settings_window(&window) { + log::warn!("[e2ee-token] require_settings_window rejected the call: {e:?}"); + return Err(e); + } + core.cloud_sync_e2ee_sign_in_with_token().await +} + #[tauri::command] pub async fn cloud_sync_e2ee_begin_sign_in( window: tauri::WebviewWindow, diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 2a5434292..dfe7ebe61 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -5,6 +5,7 @@ const LLM_TEMPERATURE_ACCOUNT: &str = openless_core::credentials::LLM_TEMPERATUR const OMNI_EXTRA_HEADERS_ACCOUNT: &str = openless_core::credentials::OMNI_EXTRA_HEADERS_ACCOUNT; const OMNI_TEMPERATURE_ACCOUNT: &str = openless_core::credentials::OMNI_TEMPERATURE_ACCOUNT; const MARKETPLACE_GITHUB_TOKEN_ACCOUNT: &str = "github.oauth_token"; +const CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT: &str = openless_core::credentials::CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT; /// Tauri host adapter for the framework-independent core credential port. /// @@ -368,6 +369,9 @@ fn read_vault_credential( (openless_core::CredentialNamespace::Marketplace, MARKETPLACE_GITHUB_TOKEN_ACCOUNT) => { CredentialsVault::get_marketplace_github_token() } + (openless_core::CredentialNamespace::Application, CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT) => { + CredentialsVault::get_cloud_sync_custom_token() + } (openless_core::CredentialNamespace::Application, _) => { return Err(invalid_credential_key(key)); } @@ -442,6 +446,13 @@ fn write_vault_credential( CredentialsVault::set_marketplace_github_token(value) } } + (openless_core::CredentialNamespace::Application, CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT) => { + if value.trim().is_empty() { + CredentialsVault::remove_cloud_sync_custom_token() + } else { + CredentialsVault::set_cloud_sync_custom_token(value) + } + } (openless_core::CredentialNamespace::Application, _) => { return Err(invalid_credential_key(key)); } @@ -883,6 +894,7 @@ fn credential_key( openless_core::CredentialNamespace::Omni } MARKETPLACE_GITHUB_TOKEN_ACCOUNT => openless_core::CredentialNamespace::Marketplace, + CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT => openless_core::CredentialNamespace::Application, _ => { let parsed = parse_account(account)?; match parsed { diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index af35194d5..e7750c547 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -469,10 +469,15 @@ fn shared_backend_from_stores( hotkey_status, qa_context, ); + let service_origin = prefs + .get() + .sync_custom_server_origin + .filter(|origin| !origin.trim().is_empty()) + .unwrap_or_else(|| openless_core::cloud_sync_e2ee::DEFAULT_SYNC_SERVICE_ORIGIN.into()); dependencies.marketplace_config = Some( openless_core::MarketplaceConfig::production().with_encrypted_sync( openless_core::cloud_sync_e2ee::EncryptedSyncConfig { - service_origin: openless_core::cloud_sync_e2ee::DEFAULT_SYNC_SERVICE_ORIGIN.into(), + service_origin, app_version: env!("CARGO_PKG_VERSION").into(), }, ), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 264e7063e..8217a2613 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -288,6 +288,7 @@ macro_rules! app_invoke_handler_desktop { commands::cloud_sync_e2ee_change_password, commands::cloud_sync_e2ee_delete_remote, commands::cloud_sync_e2ee_sign_out, + commands::cloud_sync_e2ee_sign_in_with_token, commands::cloud_sync_e2ee_begin_sign_in, commands::cloud_sync_e2ee_poll_sign_in, commands::cloud_sync_e2ee_cancel_sign_in, @@ -567,6 +568,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::cloud_sync_e2ee_change_password, $crate::commands::cloud_sync_e2ee_delete_remote, $crate::commands::cloud_sync_e2ee_sign_out, + $crate::commands::cloud_sync_e2ee_sign_in_with_token, $crate::commands::cloud_sync_e2ee_begin_sign_in, $crate::commands::cloud_sync_e2ee_poll_sign_in, $crate::commands::cloud_sync_e2ee_cancel_sign_in, diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 5ef7ef794..2e50c7783 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -634,6 +634,12 @@ struct CredsRoot { metadata_revision: u64, #[serde(default, skip_serializing_if = "CredsMarketplace::is_empty")] marketplace: CredsMarketplace, + /// Static bearer token for a self-hosted `cloud_sync_e2ee` server (an + /// alternative to GitHub OAuth). Isolated from `marketplace` on purpose: + /// it authenticates a different, optional server and must never be + /// confused with the GitHub identity used for account-change detection. + #[serde(default, skip_serializing_if = "CredsCloudSync::is_empty")] + cloud_sync: CredsCloudSync, } fn credsroot_default_version() -> u32 { @@ -763,6 +769,29 @@ impl std::fmt::Debug for MarketplaceGithubToken { } } +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[allow(non_snake_case)] +struct CredsCloudSync { + #[serde(default, skip_serializing_if = "Option::is_none")] + customToken: Option, +} + +impl CredsCloudSync { + fn is_empty(&self) -> bool { + self.customToken.is_none() + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(transparent)] +struct CloudSyncCustomToken(String); + +impl std::fmt::Debug for CloudSyncCustomToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("[REDACTED]") + } +} + /// Common metadata for a channel card — ASR / LLM share the same semantics: /// - `providerType` is the **protocol routing key** (deepseek / volcengine / /// bailian ...) and must stay independent of the map key: one vendor can @@ -1698,6 +1727,25 @@ fn write_marketplace_github_token(root: &mut CredsRoot, value: Option) { }); } +fn lookup_cloud_sync_custom_token(root: &CredsRoot) -> Option { + root.cloud_sync + .customToken + .as_ref() + .map(|token| token.0.as_str()) + .filter(|token| !token.trim().is_empty()) + .map(str::to_string) +} + +fn write_cloud_sync_custom_token(root: &mut CredsRoot, value: Option) { + root.cloud_sync.customToken = value.and_then(|token| { + if token.trim().is_empty() { + None + } else { + Some(CloudSyncCustomToken(token)) + } + }); +} + fn marketplace_token_is_rejected() -> bool { MARKETPLACE_TOKEN_REJECTED.load(Ordering::SeqCst) } @@ -3687,6 +3735,34 @@ impl CredentialsVault { ) } + /// Static bearer token for a self-hosted `cloud_sync_e2ee` server. Unlike + /// the GitHub token, this has no OAuth revocation/verification semantics + /// (no "rejected" tombstone) — it is a plain opaque secret, same tier as + /// an ASR/LLM API key. + pub fn get_cloud_sync_custom_token() -> Result> { + let _guard = credentials_lock().lock(); + Ok(lookup_cloud_sync_custom_token( + &load_credentials_for_update()?, + )) + } + + pub fn set_cloud_sync_custom_token(value: &str) -> Result<()> { + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_cloud_sync_custom_token( + root, + (!value.trim().is_empty()).then(|| value.to_string()), + ); + Ok(true) + }) + } + + pub fn remove_cloud_sync_custom_token() -> Result<()> { + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_cloud_sync_custom_token(root, None); + Ok(true) + }) + } + #[cfg(test)] pub(crate) fn seed_marketplace_github_token_for_tests(value: &str) { let _guard = credentials_lock().lock(); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ed9c03e2e..9f80d9d58 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -2,6 +2,12 @@ import type { zhCN } from './zh-CN'; export const de: typeof zhCN = { cloudSyncE2ee: { + customServerTitle: 'Selbst gehosteter Sync-Server (erweitert)', + customServerOrigin: 'Server-Adresse (https://...)', + customServerToken: 'Anmelde-Token', + customServerSave: 'Speichern', + customServerHint: + 'Leer lassen, um den offiziellen Sync-Server zu verwenden. Die Adresse muss mit https:// beginnen und eine Root-URL sein.', protocolTitle: 'Cloud-Sync: Vereinbarung und Datenschutz', protocolIntro: 'OpenLess und seine unabhängigen Entwickler respektieren Ihre Privatsphäre und schützen Ihre Daten. Lesen Sie diese Hinweise, bevor Sie fortfahren.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 970ab81bc..78cc64c30 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -5,6 +5,11 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { cloudSyncE2ee: { + customServerTitle: 'Self-hosted sync server (advanced)', + customServerOrigin: 'Server address (https://...)', + customServerToken: 'Sign-in token', + customServerSave: 'Save', + customServerHint: 'Leave blank to use the official sync server. The address must start with https:// and be a root URL.', protocolTitle: 'Cloud sync agreement and privacy notice', protocolIntro: 'OpenLess and its independent developers respect your privacy and work to protect your data. Read this notice before continuing.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index c3ecec806..a271faa52 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -2,6 +2,12 @@ import type { zhCN } from './zh-CN'; export const es: typeof zhCN = { cloudSyncE2ee: { + customServerTitle: 'Servidor de sincronización propio (avanzado)', + customServerOrigin: 'Dirección del servidor (https://...)', + customServerToken: 'Token de acceso', + customServerSave: 'Guardar', + customServerHint: + 'Déjelo en blanco para usar el servidor de sincronización oficial. La dirección debe comenzar con https:// y ser una URL raíz.', protocolTitle: 'Acuerdo de sincronización y privacidad', protocolIntro: 'OpenLess y sus desarrolladores independientes respetan tu privacidad y protegen tus datos. Lee este aviso antes de continuar.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c9b2bc894..52c8998ec 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -2,6 +2,12 @@ import type { zhCN } from './zh-CN'; export const fr: typeof zhCN = { cloudSyncE2ee: { + customServerTitle: 'Serveur de synchronisation auto-hébergé (avancé)', + customServerOrigin: 'Adresse du serveur (https://...)', + customServerToken: 'Jeton de connexion', + customServerSave: 'Enregistrer', + customServerHint: + "Laissez vide pour utiliser le serveur de synchronisation officiel. L'adresse doit commencer par https:// et être une URL racine.", protocolTitle: 'Accord de synchronisation et confidentialité', protocolIntro: 'OpenLess et ses développeurs indépendants respectent votre vie privée et protègent vos données. Lisez cette notice avant de continuer.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 64e9e0363..c002cbcfd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -7,6 +7,12 @@ import { en } from './en'; export const ja: typeof zhCN = { ...en, cloudSyncE2ee: { + customServerTitle: '自前の同期サーバー(上級者向け)', + customServerOrigin: 'サーバーアドレス(https://...)', + customServerToken: 'サインイン Token', + customServerSave: '保存', + customServerHint: + '空欄の場合は公式の同期サーバーを使用します。アドレスは https:// で始まるルート URL である必要があります。', protocolTitle: 'クラウド同期の同意事項とプライバシー', protocolIntro: 'OpenLess と開発者はプライバシーを尊重し、データの保護に取り組んでいます。続行する前に、以下をご確認ください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 371cc7e2d..64558e13c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -7,6 +7,11 @@ import { en } from './en'; export const ko: typeof zhCN = { ...en, cloudSyncE2ee: { + customServerTitle: '자체 동기화 서버 (고급)', + customServerOrigin: '서버 주소 (https://...)', + customServerToken: '로그인 Token', + customServerSave: '저장', + customServerHint: '비워두면 공식 동기화 서버를 사용합니다. 주소는 https://로 시작하는 루트 URL이어야 합니다.', protocolTitle: '클라우드 동기화 약관 및 개인정보 안내', protocolIntro: 'OpenLess와 독립 개발자는 개인정보를 존중하고 데이터를 보호하기 위해 노력합니다. 계속하기 전에 아래 안내를 읽어 주세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee582d872..067dabc7a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -3,6 +3,11 @@ export const zhCN = { cloudSyncE2ee: { + customServerTitle: '自建同步服务器(高级)', + customServerOrigin: '服务器地址(https://...)', + customServerToken: '登录 Token', + customServerSave: '保存', + customServerHint: '留空则使用官方同步服务器。地址必须是 https:// 开头的根地址。', protocolTitle: '云同步协议与隐私提醒', protocolIntro: 'OpenLess 及个人开发者尊重您的隐私,并致力于保护您的资料。请阅读以下说明,再决定是否继续。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5b7cc7b3e..dcc99122a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -3,6 +3,11 @@ import type { zhCN } from './zh-CN'; // Traditional Chinese resources, sharing the same copy keys as the other seven locales. export const zhTW: typeof zhCN = { cloudSyncE2ee: { + customServerTitle: '自建同步伺服器(進階)', + customServerOrigin: '伺服器位址(https://...)', + customServerToken: '登入 Token', + customServerSave: '儲存', + customServerHint: '留空則使用官方同步伺服器。位址必須以 https:// 開頭的根位址。', protocolTitle: '雲端同步協議與隱私提醒', protocolIntro: 'OpenLess 及個人開發者尊重您的隱私,並致力於保護您的資料。請閱讀以下說明,再決定是否繼續。', diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts index 2a47bb1a5..77c5f4df4 100644 --- a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts @@ -130,6 +130,8 @@ export const cloudSyncE2eeDeleteRemote = (input: { invokeOrMock('cloud_sync_e2ee_delete_remote', input, unavailable); export const cloudSyncE2eeSignOut = (): Promise => invokeOrMock('cloud_sync_e2ee_sign_out', undefined, unavailable); +export const cloudSyncE2eeSignInWithToken = (): Promise => + invokeOrMock('cloud_sync_e2ee_sign_in_with_token', undefined, unavailable); export const cloudSyncE2eeGetUiPreferences = (): Promise<{ locale?: string; fontScale?: string; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d9cf11ab8..d4a31688e 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -567,6 +567,8 @@ export interface UserPreferences { /** Major-version generation marker of the splash PV (e.g. '2'). Empty = never played; advanced exclusively by * the Rust-side take_splash_playback, preserved verbatim by the settings save path; the frontend is read-only. */ splashSeenVersion?: string; + /** Self-hosted encrypted-sync server origin, overriding the built-in default. Empty/undefined = use the default. */ + syncCustomServerOrigin?: string | null; } export interface MarketplaceListItem { diff --git a/openless-all/app/src/pages/settings/CloudSyncSection.tsx b/openless-all/app/src/pages/settings/CloudSyncSection.tsx index 0b21e820d..d109a20bd 100644 --- a/openless-all/app/src/pages/settings/CloudSyncSection.tsx +++ b/openless-all/app/src/pages/settings/CloudSyncSection.tsx @@ -6,7 +6,7 @@ import { Modal } from '../../components/ui/Modal'; import { Btn, Card } from '../_atoms'; import { Toggle } from './shared'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; -import { marketplaceAuthStatus } from '../../lib/ipc'; +import { marketplaceAuthStatus, readCredential, setCredential } from '../../lib/ipc'; import { isTauri } from '../../lib/ipc/shared'; import { CLOUD_SYNC_E2EE_CONSENT_VERSION as CONSENT_VERSION, @@ -23,6 +23,7 @@ import { cloudSyncE2eeChangePassword, cloudSyncE2eeDeleteRemote, cloudSyncE2eeSignOut, + cloudSyncE2eeSignInWithToken, mirrorEncryptedSyncUiPreferences, encryptedSyncScope, encryptedSyncErrorKey, @@ -38,6 +39,9 @@ import { type SyncConflictChoice, } from '../../lib/ipc/cloud-sync-e2ee'; +// Must match CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT in src-tauri/src/commands/credentials.rs. +const CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT = 'cloud_sync.custom_token'; + type Intent = 'enable' | 'unlock' | 'restore'; type Dialog = | { kind: 'consent'; intent: Intent } @@ -144,6 +148,27 @@ export function CloudSyncSection() { const actionSequence = useRef(0); const activeAction = useRef(null); const loginHint = prefs?.marketplaceDevLogin?.trim() ?? ''; + const [customServerOrigin, setCustomServerOrigin] = useState(''); + const [customServerToken, setCustomServerToken] = useState(''); + useEffect(() => { + setCustomServerOrigin(prefs?.syncCustomServerOrigin ?? ''); + }, [prefs?.syncCustomServerOrigin]); + useEffect(() => { + // The token lives in the OS secure-credential store (same tier as the + // GitHub token), not in plain preferences — load it separately. + readCredential(CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT) + .then((value) => setCustomServerToken(value ?? '')) + .catch(() => setCustomServerToken('')); + }, []); + const customServerInputStyle = { + width: '100%', + boxSizing: 'border-box' as const, + border: '1px solid var(--ol-line-strong)', + borderRadius: 9, + background: 'var(--ol-control-solid)', + color: 'var(--ol-ink)', + padding: '10px 12px', + }; const showError = (error: unknown) => { if (alive.current) setNotice({ key: `errors.${encryptedSyncErrorKey(error)}`, error: true }); @@ -560,6 +585,65 @@ export function CloudSyncSection() { {t('cloudSyncE2ee.signIn')} )} + {!loading && !signedIn && ( +
+ {t('cloudSyncE2ee.customServerTitle')} + + + { + const token = customServerToken.trim(); + setBusy(true); + void (async () => { + await Promise.all([ + updatePrefs((value) => ({ + ...value, + syncCustomServerOrigin: customServerOrigin.trim() || null, + })), + setCredential(CLOUD_SYNC_CUSTOM_TOKEN_ACCOUNT, token), + refresh(), + ]); + if (token) { + acceptStatus(await cloudSyncE2eeSignInWithToken()); + } else { + await load(); + } + })() + .catch(showError) + .finally(() => setBusy(false)); + }} + > + {t('cloudSyncE2ee.customServerSave')} + +

{t('cloudSyncE2ee.customServerHint')}

+
+ )} {signedIn && (
From 1b98c42a1f96ec4994863c6593b6a03d91e7eb52 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:30:37 +0800 Subject: [PATCH 02/13] fix(sync): wire Android into cloud-sync-e2ee restore path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes blocked every Android restore attempt before it ever inspected a document: - mobile_runtime.rs never called bind_restore_runtime_effects() (desktop does), so runtime_effects() always returned Unsupported during restore. - capture.rs required the UI-preferences extension slot to already be populated, which only happens after a user manually changes locale/font scale post-enable; a device that never touched that setting (true for a fresh install) could not create or restore any snapshot at all. - isTauri was a frozen const computed once at module-eval time; a mid-session Android webview rebuild (ensure_main_webview_window) could permanently route all subsequent backend calls through the browser-preview mock. Switched the two gating call sites to a live isTauriNow() check. Also registers sync_custom_server_origin (added for the self-hosted-server feature) in the preference registry as Excluded — it's a manually-entered, per-device value and must never be classified as syncable in any form. --- .../src/cloud_sync_e2ee_documents/registry.rs | 7 +++++++ .../src/cloud_sync_e2ee_store/capture.rs | 7 ++++++- .../app/src-tauri/src/mobile_runtime.rs | 4 ++++ openless-all/app/src/lib/ipc/shared.ts | 18 ++++++++++++++---- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3eedc0809..d688e401a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -207,6 +207,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "device_bound", }, + PreferenceField { + rust_name: "sync_custom_server_origin", + key: "syncCustomServerOrigin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::OptionalText, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "restore_clipboard_after_paste", key: "restoreClipboardAfterPaste", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs index 36da33b08..d0c21ac49 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs @@ -166,12 +166,17 @@ impl CoreSyncStore { &presets.disabled_builtin_preset_ids, &builtin_ids, )?; + // Populated lazily: nothing writes this slot until the user explicitly + // changes locale/font scale once sync is enabled (see + // `encryptedSyncUiBridge.ts`). A device that never touched that setting + // still needs a valid first snapshot/restore, so fall back to sane + // defaults instead of failing the whole capture. let ui_preferences = self .inner .extensions .read_device(DeviceExtensionKey::UiPreferences) .await? - .ok_or(DocumentError::Unsupported)?; + .unwrap_or_else(|| ui_preferences("system", "medium")); let window_positions = match self .inner .extensions diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index 52489e8d6..3be873317 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -41,6 +41,10 @@ pub fn run() { let core_backend = coordinator.backend(); app.manage(Arc::clone(&core_backend)); coordinator.tauri_host().bind(app.handle().clone()); + // Mirrors run_desktop(): without this, cloud-sync restore's runtime_effects() + // stays unbound forever and every restore fails with `sync_documents_unsupported` + // before it ever inspects a document (NativeLease::preflight()). + coordinator.bind_restore_runtime_effects()?; let startup = tauri::async_runtime::block_on(core_backend.start())?; if !startup.backend.running { return Err("OpenLess Core did not reach the running state".into()); diff --git a/openless-all/app/src/lib/ipc/shared.ts b/openless-all/app/src/lib/ipc/shared.ts index b54bd54c0..bfcfb7e27 100644 --- a/openless-all/app/src/lib/ipc/shared.ts +++ b/openless-all/app/src/lib/ipc/shared.ts @@ -12,8 +12,18 @@ declare global { } } -export const isTauri = - globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window; +// A page/webview reload (observed on Android after a Wry window rebuild) +// re-evaluates this module. If that happens to run before Tauri's own +// injection of `__TAURI_INTERNALS__` completes, a plain `const` computed once +// here would stay permanently `false` for the rest of that page's lifetime, +// even once the bridge becomes available moments later — silently routing +// every subsequent backend call through the browser-preview mock forever. +// `isTauriNow()` re-checks live; `isTauri` is kept as a snapshot for the many +// call sites that only use it for one-time UI/render decisions. +export function isTauriNow(): boolean { + return globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window; +} +export const isTauri = isTauriNow(); export const BACKEND_CONTRACT_VERSION = '2.0.0'; @@ -35,7 +45,7 @@ export function validateStartupSnapshot(snapshot: StartupSnapshot): StartupSnaps let backendReadyPromise: Promise | null = null; export function requireBackendReady(): Promise { - if (!isTauri) { + if (!isTauriNow()) { return Promise.resolve({ contractVersion: BACKEND_CONTRACT_VERSION, backend: { running: true }, @@ -63,7 +73,7 @@ export async function invokeOrMock( args: Record | undefined, mock: () => T, ): Promise { - if (!isTauri) { + if (!isTauriNow()) { return mock(); } if (cmd === 'get_startup_snapshot') { From 542d23f632a1fb56f328dc86981fd90e1819b31c Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:30:52 +0800 Subject: [PATCH 03/13] fix(sync): read custom server origin live instead of caching it at startup SyncServiceConfig.origin was a fixed snapshot taken once at backend construction; saving a new self-hosted server address in Settings updated the preferences file but never reached the already-running sync service, so the new address only took effect after a full app restart (affects both Windows and Android, same shared service.rs). Added SyncServiceData::custom_server_origin() (reads the live preferences store) and a private EncryptedSyncService::origin() helper that every connection attempt now calls instead of trusting config.origin directly. --- .../src/cloud_sync_e2ee/adapter.rs | 3 + .../src/cloud_sync_e2ee/service.rs | 71 ++++++++++--------- .../src/cloud_sync_e2ee/tests.rs | 3 + .../src/cloud_sync_e2ee_store/mod.rs | 3 + 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index 318dac5c0..4ba48b4bc 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -320,6 +320,9 @@ impl SyncServiceData for CoreSyncStore { fn device(&self) -> SourceDevice { CoreSyncStore::device(self) } + fn custom_server_origin(&self) -> Option { + CoreSyncStore::custom_server_origin(self) + } fn changes(&self) -> tokio::sync::watch::Receiver { CoreSyncStore::changes(self) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs index 7b101f177..954897755 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -52,6 +52,11 @@ pub(crate) trait SyncServiceData: Send + Sync { ) -> BoxFuture<'_, SyncResult<()>>; fn generation(&self) -> SyncResult; fn device(&self) -> SourceDevice; + /// Live read of the user's self-hosted server preference. A Settings-page + /// save must take effect on the next connection attempt, never only after + /// a full process restart — this is the one thing `SyncServiceConfig.origin` + /// (a fixed snapshot from backend construction) cannot provide by itself. + fn custom_server_origin(&self) -> Option; fn changes( &self, ) -> tokio::sync::watch::Receiver; @@ -151,7 +156,11 @@ impl EncryptedSyncService { credential_store: Arc, events: BackendEventPublisher, ) -> Self { - let status = EncryptedSyncStatus::initial(config.origin.clone()); + let initial_origin = data + .custom_server_origin() + .filter(|origin| !origin.trim().is_empty()) + .unwrap_or_else(|| config.origin.clone()); + let status = EncryptedSyncStatus::initial(initial_origin); Self(Arc::new(Shared { config, marketplace, @@ -180,6 +189,17 @@ impl EncryptedSyncService { })) } + /// Live origin for every new connection attempt. Never cache this past a + /// single call — a Settings-page save must take effect immediately, not + /// only after a process restart. + fn origin(&self) -> String { + self.0 + .data + .custom_server_origin() + .filter(|origin| !origin.trim().is_empty()) + .unwrap_or_else(|| self.0.config.origin.clone()) + } + pub(crate) fn status(&self) -> EncryptedSyncStatus { let mut value = self .0 @@ -188,6 +208,7 @@ impl EncryptedSyncService { .unwrap_or_else(|e| e.into_inner()) .clone(); value.sequence = self.0.sequence.load(Ordering::Acquire).to_string(); + value.service_origin = self.origin(); match self.0.data.generation() { Ok(generation) => value.local_generation = generation.as_str().into(), Err(_) => { @@ -412,7 +433,7 @@ impl EncryptedSyncService { // through a system/env proxy that may not even be running. let use_system_proxy = self.read_custom_token().await.is_none() && crate::net::use_system_proxy(); - let proxy_policy = ProxyPolicy::for_origin(&self.0.config.origin, use_system_proxy); + let proxy_policy = ProxyPolicy::for_origin(&self.origin(), use_system_proxy); log::warn!( "[e2ee-token] connect: use_system_proxy={use_system_proxy} proxy_policy={proxy_policy:?} env HTTPS_PROXY={:?} HTTP_PROXY={:?} ALL_PROXY={:?} NO_PROXY={:?}", std::env::var("HTTPS_PROXY").ok(), @@ -432,9 +453,10 @@ impl EncryptedSyncService { // the user explicitly opted into self-hosted mode by setting it, and a // session can only ever be bound to one credential at a time. let custom_token = self.read_custom_token().await; + let origin = self.origin(); log::warn!( "[e2ee-token] connect_with_proxy_policy: origin={} custom_token_present={} has_cached_connection={}", - self.0.config.origin, + origin, custom_token.is_some(), runtime.connection.is_some() ); @@ -458,12 +480,8 @@ impl EncryptedSyncService { return Ok(()); } #[cfg(not(test))] - let transport = match Transport::new( - &self.0.config.origin, - &self.0.config.github_client_id, - proxy_policy, - ) - .await + let transport = match Transport::new(&origin, &self.0.config.github_client_id, proxy_policy) + .await { Ok(transport) => transport, Err(e) => { @@ -472,26 +490,18 @@ impl EncryptedSyncService { } }; #[cfg(test)] - let transport = if self.0.config.origin.starts_with("http://127.0.0.1:") { - Transport::for_test_with_proxy_policy( - &self.0.config.origin, - &self.0.config.github_client_id, - proxy_policy, - ) - .await - .map_err(protocol_error)? + let transport = if origin.starts_with("http://127.0.0.1:") { + Transport::for_test_with_proxy_policy(&origin, &self.0.config.github_client_id, proxy_policy) + .await + .map_err(protocol_error)? } else { - Transport::new( - &self.0.config.origin, - &self.0.config.github_client_id, - proxy_policy, - ) - .await - .map_err(protocol_error)? + Transport::new(&origin, &self.0.config.github_client_id, proxy_policy) + .await + .map_err(protocol_error)? }; let (session, credential, account) = if let Some(token) = custom_token { self.check_cancelled()?; - log::warn!("[e2ee-token] calling exchange_with_token against {}", self.0.config.origin); + log::warn!("[e2ee-token] calling exchange_with_token against {}", origin); let session = match transport.exchange_with_token(token.expose_secret()).await { Ok(session) => { log::warn!("[e2ee-token] exchange_with_token succeeded"); @@ -502,9 +512,7 @@ impl EncryptedSyncService { return Err(protocol_error(e)); } }; - if transport.service_origin().trim_end_matches('/') - != self.0.config.origin.trim_end_matches('/') - { + if transport.service_origin().trim_end_matches('/') != origin.trim_end_matches('/') { return Err(error("account_changed")); } let account = session.account().clone(); @@ -522,8 +530,7 @@ impl EncryptedSyncService { .await .map_err(protocol_error)?; if session.account().github_id != account.github_id - || transport.service_origin().trim_end_matches('/') - != self.0.config.origin.trim_end_matches('/') + || transport.service_origin().trim_end_matches('/') != origin.trim_end_matches('/') { return Err(error("account_changed")); } @@ -592,7 +599,7 @@ impl EncryptedSyncService { let mut runtime = self.0.runtime.lock().await; self.initialize(&mut runtime).await?; self.check_cancelled()?; - let policy = ProxyPolicy::for_origin(&self.0.config.origin, use_system_proxy); + let policy = ProxyPolicy::for_origin(&self.origin(), use_system_proxy); self.connect_with_proxy_policy(&mut runtime, policy).await } @@ -1298,7 +1305,7 @@ impl EncryptedSyncService { fn scope(&self, runtime: &Runtime, vault: &str, key: &str) -> SyncResult { Ok(SyncScope { - service_origin: self.0.config.origin.clone(), + service_origin: self.origin(), owner_github_id: self.owner(runtime)?.into(), vault_id: vault.into(), key_id: key.into(), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs index 9859a8b6d..3969ca219 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -256,6 +256,9 @@ impl SyncServiceData for Data { fn device(&self) -> SourceDevice { self.documents.lock().unwrap().source_device.clone() } + fn custom_server_origin(&self) -> Option { + None + } fn changes(&self) -> tokio::sync::watch::Receiver { self.changes.subscribe() } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index d153cff4f..6672ae334 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -87,6 +87,9 @@ impl CoreSyncStore { pub fn device(&self) -> SourceDevice { self.inner.device.clone() } + pub fn custom_server_origin(&self) -> Option { + self.inner.repositories.preferences.get().sync_custom_server_origin + } pub fn changes(&self) -> tokio::sync::watch::Receiver { self.inner.gate.subscribe() } From c2d23ded5a11a60effb653025bc63b0fd53bdf1c Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:32:08 +0800 Subject: [PATCH 04/13] copy(sync): mention token sign-in in the encrypted-sync description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh-CN/zh-TW only — the description under "加密云同步" said sync happened via GitHub account only, no longer true now that a self-hosted server's static token is a supported sign-in path. --- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/i18n/zh-TW.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 067dabc7a..0dc9ecf94 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -29,7 +29,7 @@ export const zhCN = { setupPromptLater: '暂不开启', setupPromptOpen: '了解加密同步', title: '加密云同步', - description: '在本机加密后,通过 GitHub 账号跨设备同步。', + description: '在本机加密后,通过 GitHub 账号或 Token 跨设备同步。', enable: '启用加密同步', setPassword: '设置同步密码', stepEnableTitle: '第 1 步,共 3 步:开启同步', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index dcc99122a..23978b87f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -29,7 +29,7 @@ export const zhTW: typeof zhCN = { setupPromptLater: '暫不開啟', setupPromptOpen: '了解加密同步', title: '加密雲端同步', - description: '在本機加密後,透過 GitHub 帳號跨裝置同步。', + description: '在本機加密後,透過 GitHub 帳號或 Token 跨裝置同步。', enable: '啟用加密同步', setPassword: '設定同步密碼', stepEnableTitle: '第 1 步,共 3 步:開啟同步', From a0888b4338b4ed556774aaf8b90f64dcf7044633 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:35:26 +0800 Subject: [PATCH 05/13] feat(sync): show "user@host" for self-hosted sign-ins The account badge always showed "@login", which reads like a GitHub handle. When a custom server origin is configured, show "login@host" instead so a self-hosted account is visually distinct from a GitHub one. --- openless-all/app/src/pages/settings/CloudSyncSection.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src/pages/settings/CloudSyncSection.tsx b/openless-all/app/src/pages/settings/CloudSyncSection.tsx index d109a20bd..d5e2253e3 100644 --- a/openless-all/app/src/pages/settings/CloudSyncSection.tsx +++ b/openless-all/app/src/pages/settings/CloudSyncSection.tsx @@ -650,7 +650,9 @@ export function CloudSyncSection() { {t('cloudSyncE2ee.account')} {status?.account?.login - ? `@${status.account.login}` + ? customServerOrigin.trim() + ? `${status.account.login}@${customServerOrigin.trim().replace(/^[a-z]+:\/\//i, '').replace(/\/$/, '')}` + : `@${status.account.login}` : loginHint ? `@${loginHint}` : 'GitHub'} From 8daf0f1d3facf93192831eee99ca5bcce3ef701e Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:55:09 +0800 Subject: [PATCH 06/13] fix(tests): mock readCredential/setCredential in CloudSyncSection test The component imports both from '../../lib/ipc', but the test harness's dependency map only spread '../../lib/ipc/cloud-sync-e2ee', leaving the two functions undefined and crashing render with "readCredential is not a function". This broke Windows and macOS CI checks on PR #1133. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011h9QUkoFtCoDLsJjRDB6dX --- openless-all/app/src/pages/settings/CloudSyncSection.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openless-all/app/src/pages/settings/CloudSyncSection.test.ts b/openless-all/app/src/pages/settings/CloudSyncSection.test.ts index 4e65e1393..e0ea5cb7f 100644 --- a/openless-all/app/src/pages/settings/CloudSyncSection.test.ts +++ b/openless-all/app/src/pages/settings/CloudSyncSection.test.ts @@ -202,6 +202,10 @@ const dependencies: Record = { calls.push(['enable', enabled]); return update({ enabled }); }, + readCredential: async (_account: string) => null, + setCredential: async (account: string, value: string) => { + calls.push(['setCredential', account, value]); + }, }; const jsx = (type: unknown, props: Record) => ({ type, props }); const components = factory( From 8bfa09de951d12302be1052adf0c32d15c3925b5 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:34:29 +0800 Subject: [PATCH 07/13] feat(android-ime): mode accent through thinking, keyboard height sheet Raw / Quick notes / Cloud notes now keep their accent color from recording through the thinking step: status text, the thinking dots, the hint row and the link indicator all follow the armed mode instead of dropping back to gray/blue. Cloud notes also keeps it (and the dots) up during its webhook submit. Keyboard height and raise move out of the permanently docked panel at the bottom of keyboard settings into a bottom sheet with stepper sliders, a reset, and the 1:1 footprint flush with the screen bottom. Co-Authored-By: Claude Opus 5.5 --- .../app/android/kotlin/OpenLessImeService.kt | 116 ++++--- .../OpenLessKeyboardSettingsActivity.kt | 300 ++++++++++++++---- 2 files changed, 321 insertions(+), 95 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index be1543462..0d489ebb5 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -79,6 +79,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt pendingImeStop = false cloudNoteDestination = null pendingCloudArm = false + cloudNoteSubmitting = false } private fun stopImeSession() { @@ -180,17 +181,30 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt cloudNoteDestination = (prefs.getString("key_cloud_note_webhook_url", "") ?: "").trim() to (prefs.getString("key_cloud_note_webhook_token", "") ?: "").trim() } - voiceButton?.cloudNoteActive = value + voiceButton?.cloudNoteActive = value || cloudNoteSubmitting status?.setTextColor(recordingAccentColor()) } + // The webhook POST that follows a Cloud notes dictation (see + // submitCloudNoteText()). cloudNoteArmed is already back to false by + // then — the Core session it mirrors has completed — so this is what + // keeps the red accent up until the submit itself finishes. Display + // only: unlike `processing`, it never blocks a new mic tap. + private var cloudNoteSubmitting = false - /** Status text color while a recording prompt is showing — orange for an armed Raw stop, green for an armed Quick notes stop, red for an armed Cloud notes submit, normal otherwise. Single source of truth for rawModeArmed/quickNoteArmed/cloudNoteArmed's setters and updateStatus() alike, so the three gestures can never disagree on which one currently owns the color. */ - private fun recordingAccentColor(): Int = when { - state == "speaking" && rawModeArmed -> LINK_COLOR_RECORDING_RAW - state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE - state == "speaking" && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE - else -> statusNormalColor + /** Accent of whichever gesture mode is armed — orange for Raw, green for Quick notes, red for Cloud notes (including its submit step) — or null for an ordinary dictation. Single source of truth for the status line, the hint row and the link indicator, so they can never disagree on which mode currently owns the color. */ + private fun armedAccentColor(): Int? = when { + rawModeArmed -> LINK_COLOR_RECORDING_RAW + quickNoteArmed -> LINK_COLOR_QUICK_NOTE + cloudNoteArmed || cloudNoteSubmitting -> LINK_COLOR_CLOUD_NOTE + else -> null } + + /** True from the start of a recording through the "thinking" step that follows it — the span an armed mode keeps its accent for. */ + private fun dictationInProgress(): Boolean = state == "speaking" || state == "thinking" + + /** Status text color while a recording or thinking prompt is showing — the armed mode's accent, normal otherwise. */ + private fun recordingAccentColor(): Int = + if (dictationInProgress()) armedAccentColor() ?: statusNormalColor else statusNormalColor internal var inputMode = InputMode.VOICE private var englishLayer = EnglishLayer.LETTERS // Persisted across sessions the same way inputMode is (see @@ -3547,6 +3561,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt rawModeArmed = false quickNoteArmed = false cloudNoteArmed = false + cloudNoteSubmitting = false invalidateSession("已取消") cancelImeSession() } @@ -3609,14 +3624,13 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * what the user described as the link being "disconnected". */ private fun updateBackendLinkIndicator() { + val armedAccent = armedAccentColor() val color = when { - // Matches VoiceButton's own waveform color for each armed - // gesture exactly (rawWaveformColor's when block) — the - // breathing dot and the waveform should never disagree about - // which mode a recording is currently armed for. - recording && rawModeArmed -> LINK_COLOR_RECORDING_RAW - recording && quickNoteArmed -> LINK_COLOR_QUICK_NOTE - recording && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE + // Matches VoiceButton's own waveform/dots color for each armed + // gesture exactly (armedModeColor()) — the breathing dot and + // the mic animation should never disagree about which mode a + // dictation is currently armed for, recording or thinking. + (recording || processing || cloudNoteSubmitting) && armedAccent != null -> armedAccent recording -> LINK_COLOR_RECORDING processing -> LINK_COLOR_PROCESSING !backendLinkHealthy -> LINK_COLOR_ISSUE @@ -3785,7 +3799,15 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt voiceRawHint?.setTextColor(rawModeHintColor()) voiceRawHint?.visibility = if (rawModeHintVisible()) View.VISIBLE else View.GONE voiceButton?.isRecording = recording - voiceButton?.isProcessing = processing + // The Cloud notes submit shows the same thinking dots as the step + // before it, even though `processing` itself is already false. + voiceButton?.isProcessing = processing || cloudNoteSubmitting + // Re-pushed here, not only from each field's own setter: a panel + // rebuild mid-dictation (see toggleDictation()'s edit-panel exit) + // hands us a fresh VoiceButton that never saw those setters fire. + voiceButton?.rawModeActive = rawModeArmed + voiceButton?.quickNoteActive = quickNoteArmed + voiceButton?.cloudNoteActive = cloudNoteArmed || cloudNoteSubmitting updateDictationResultControls() updateBackendLinkIndicator() } @@ -3794,30 +3816,26 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * Swipe-up-for-Raw / swipe-left-for-Quick-notes / swipe-right-for- * Cloud-notes discoverability hint while idle; once a recording is * actually armed into one of those three modes (live through both - * recording and the following "thinking"/整理 step for Raw and Cloud - * notes — quick note never reaches "thinking", see - * quickNoteDictation()), the row repurposes itself to confirm whichever - * one is armed instead. Recording-or-thinking with none armed (an - * ordinary dictation) never reaches this text at all — see - * rawModeHintVisible(), which hides the row entirely for that case. + * recording and the following "thinking" step — 整理 for Raw and Cloud + * notes, plus Cloud notes' own submit; saving for Quick notes), the + * row repurposes itself to confirm whichever one is armed instead. + * Recording-or-thinking with none armed (an ordinary dictation) never + * reaches this text at all — see rawModeHintVisible(), which hides the + * row entirely for that case. */ private fun rawModeHintText(): String { return when { - (state == "speaking" || state == "thinking") && rawModeArmed -> ui("原样转写", "Raw Mode") - (state == "speaking" || state == "thinking") && cloudNoteArmed -> ui("云笔记", "Cloud notes") - state == "speaking" && quickNoteArmed -> ui("速记模式", "Quick notes") + dictationInProgress() && rawModeArmed -> ui("原样转写", "Raw Mode") + dictationInProgress() && quickNoteArmed -> ui("速记模式", "Quick notes") + dictationInProgress() && (cloudNoteArmed || cloudNoteSubmitting) -> ui("云笔记", "Cloud notes") else -> ui("上划RAW · 左划速记 · 右划云笔记", "Up: Raw · Left: Quick notes · Right: Cloud notes") } } - /** Same orange/green/red as the status line's own Raw/Quick-notes/Cloud-notes coloring and every other indicator for each mode (VoiceButton's armed pill/waveform) — muted gray otherwise. */ + /** Same orange/green/red as the status line's own Raw/Quick-notes/Cloud-notes coloring and every other indicator for each mode (VoiceButton's armed pill/waveform/dots) — muted gray otherwise. */ private fun rawModeHintColor(): Int { - return when { - (state == "speaking" || state == "thinking") && rawModeArmed -> LINK_COLOR_RECORDING_RAW - (state == "speaking" || state == "thinking") && cloudNoteArmed -> LINK_COLOR_CLOUD_NOTE - state == "speaking" && quickNoteArmed -> LINK_COLOR_QUICK_NOTE - else -> Color.argb((0.8f * 255).toInt(), 0xB0, 0xB0, 0xB0) - } + val armedAccent = if (dictationInProgress()) armedAccentColor() else null + return armedAccent ?: Color.argb((0.8f * 255).toInt(), 0xB0, 0xB0, 0xB0) } /** @@ -3829,7 +3847,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt * is actually armed (confirms it). */ private fun rawModeHintVisible(): Boolean { - return !((state == "speaking" || state == "thinking") && !rawModeArmed && !quickNoteArmed && !cloudNoteArmed) + return !(dictationInProgress() && armedAccentColor() == null) } /** The request owns its destination; delayed network callbacks cannot affect a later recording. */ @@ -3847,6 +3865,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt setState("error", "请先在设置中填写云笔记的地址/Token") return } + cloudNoteSubmitting = true setState("thinking", "正在提交云笔记") Thread { val mainHandler = android.os.Handler(Looper.getMainLooper()) @@ -4087,6 +4106,9 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private fun setState(nextState: String, message: String, revertDelayMs: Long = DONE_TO_IDLE_DELAY_MS) { state = nextState + // The submit only ever shows as "thinking"; any other state means + // it finished, failed, or a new dictation took over. + if (nextState != "thinking") cloudNoteSubmitting = false updateStatus(message) // A completed commit/edit (see the various setState("done", "已 // 上屏") call sites) used to just sit there until the next recording @@ -5443,6 +5465,10 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt Color.rgb(100, 100, 100), Color.rgb(70, 70, 70), ) } + // Per-dot alpha for the thinking ring when an armed mode tints it + // (see armedModeColor()) — the same strong/soft rhythm around the + // ring as processingDotColors' own grays, in one hue. + private val processingDotAlphas = intArrayOf(255, 205, 160, 240, 185, 220) var isRecording: Boolean = false set(value) { field = value @@ -5572,6 +5598,14 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt ) } + /** Accent of the armed gesture mode, shared by the recording waveform and the thinking dots so the two never disagree — null for an ordinary dictation. */ + private fun armedModeColor(): Int? = when { + rawModeActive -> LINK_COLOR_RECORDING_RAW + quickNoteActive -> LINK_COLOR_QUICK_NOTE + cloudNoteActive -> LINK_COLOR_CLOUD_NOTE + else -> null + } + private var phase = 0f private val animator = object : Runnable { override fun run() { @@ -5672,12 +5706,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val envelopeCenter = (barCount - 1) / 2f val gap = width * 0.86f / (barCount - 1) val startX = centerX - gap * (barCount - 1) / 2f - val rawWaveformColor = when { - rawModeActive -> LINK_COLOR_RECORDING_RAW - quickNoteActive -> LINK_COLOR_QUICK_NOTE - cloudNoteActive -> LINK_COLOR_CLOUD_NOTE - else -> waveformColor - } + val rawWaveformColor = armedModeColor() ?: waveformColor val currentWaveformColor = lerpColor(rawWaveformColor, cancelArmedWaveformColor, waveformCancelAmount) for (index in 0 until barCount) { val x = startX + index * gap @@ -5693,11 +5722,14 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt canvas.drawLine(x, centerY - halfHeight, x, centerY + halfHeight, paint) } } else if (isProcessing) { - // Analysis state uses the same restrained monochrome palette; + // Analysis state uses the same restrained monochrome palette + // for an ordinary dictation, and the armed mode's own accent + // (the color its recording waveform just had) otherwise; // the ring of dots keeps rotating exactly as before, and on // top of that the whole ring's radius now breathes — growing // then shrinking together as one — rather than each dot // sizing itself independently off its own angle. + val accent = armedModeColor() val colors = processingDotColors val baseOrbit = minOf(width * 0.28f, height * 0.52f) val baseDotRadius = minOf(width * 0.055f, height * 0.15f) @@ -5708,7 +5740,11 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt val angle = phase * 0.65f + index * (Math.PI.toFloat() / 3f) val x = centerX + kotlin.math.cos(angle.toDouble()).toFloat() * orbit val y = centerY + kotlin.math.sin(angle.toDouble()).toFloat() * orbit - paint.color = color + paint.color = if (accent == null) { + color + } else { + Color.argb(processingDotAlphas[index], Color.red(accent), Color.green(accent), Color.blue(accent)) + } canvas.drawCircle(x, y, dotRadius, paint) } } diff --git a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt index ddf95f7ea..8d0a565c5 100644 --- a/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt +++ b/openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt @@ -114,61 +114,46 @@ class OpenLessKeyboardSettingsActivity : Activity() { scroll.addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - // Live 1:1 footprint + its controlling sliders stay docked under the - // scrollable settings so dragging always updates a visible silhouette - // without scrolling the form away. - var heightDp = prefs.getInt( - OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, - OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP, - ).coerceIn(OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP) - var raiseDp = prefs.getInt( - OpenLessImeService.PREF_KEYBOARD_RAISE_DP, - OpenLessImeService.DEFAULT_KEYBOARD_RAISE_DP, - ).coerceIn(OpenLessImeService.MIN_KEYBOARD_RAISE_DP, OpenLessImeService.MAX_KEYBOARD_RAISE_DP) - val footprint = buildKeyboardFootprintPreview() - fun refreshFootprint() = footprint.setSizes(heightDp, raiseDp) - - val appearanceDock = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), dp(4)) - setBackgroundColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247))) + // Height/raise are adjusted in their own bottom sheet (see + // showKeyboardSizeDialog()), not docked under this form: the 1:1 + // footprint is as tall as the keyboard itself, so keeping it on this + // page permanently left everything above it only a sliver to scroll + // in. This row just names the current values and opens the sheet. + content.addView(sectionLabel(ui("键盘外观", "Keyboard appearance"))) + val sizeSummary = TextView(this).apply { + text = keyboardSizeSummary() + textSize = 14f + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) } - appearanceDock.addView(sectionLabel(ui("键盘外观(下方为 1:1 预览)", "Keyboard appearance (1:1 preview below)"))) - appearanceDock.addView( - sliderRow( - label = ui("按键区高度(拉伸)", "Key area height (stretch)"), - min = OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, - max = OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP, - current = heightDp, - onChange = { value -> - heightDp = value - prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, value).apply() - refreshFootprint() - }, - ), - ) - appearanceDock.addView( - sliderRow( - label = ui("整体抬高(底部留白)", "Raise (bottom gap)"), - min = OpenLessImeService.MIN_KEYBOARD_RAISE_DP, - max = OpenLessImeService.MAX_KEYBOARD_RAISE_DP, - current = raiseDp, - onChange = { value -> - raiseDp = value - prefs.edit().putInt(OpenLessImeService.PREF_KEYBOARD_RAISE_DP, value).apply() - refreshFootprint() - }, - ), + val sizeRow = LinearLayout(this).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(6), 0, dp(6)) + isClickable = true + setOnClickListener { showKeyboardSizeDialog { sizeSummary.text = keyboardSizeSummary() } } + } + sizeRow.addView( + TextView(this).apply { + text = ui("键盘高度", "Keyboard height") + textSize = 15f + setTextColor(tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), ) - root.addView( - appearanceDock, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), + sizeRow.addView(sizeSummary) + sizeRow.addView( + TextView(this).apply { + text = "›" + textSize = 18f + setTextColor(tone(Color.rgb(150, 150, 150), Color.rgb(110, 110, 115))) + setPadding(dp(8), 0, 0, 0) + }, ) - root.addView( - footprint.root, - LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), + content.addView( + sizeRow, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + }, ) - refreshFootprint() // Paired with onCreate()'s setDecorFitsSystemWindows(false): now that // the window draws edge-to-edge, this restores the padding the @@ -758,9 +743,214 @@ class OpenLessKeyboardSettingsActivity : Activity() { setPadding(0, 0, 0, dp(8)) } + private fun keyboardSizeSummary(): String { + val heightDp = OpenLessImeService.panelHeightDp(this) + val raiseDp = OpenLessImeService.raiseHeightDp(this) + return ui("按键区 ${heightDp}dp · 抬高 ${raiseDp}dp", "Keys ${heightDp}dp · raise ${raiseDp}dp") + } + + /** + * Bottom sheet for the key-area height and raise: two sliders over a + * 1:1 footprint that sits flush with the bottom of the screen — exactly + * where the real keyboard will. Values save as they change, same as + * every other row on this page; onClosed lets the caller refresh + * whatever it shows of them. + */ + private fun showKeyboardSizeDialog(onClosed: () -> Unit) { + var heightDp = OpenLessImeService.panelHeightDp(this) + var raiseDp = OpenLessImeService.raiseHeightDp(this) + val footprint = buildKeyboardFootprintPreview() + fun save() { + prefs.edit() + .putInt(OpenLessImeService.PREF_KEYBOARD_HEIGHT_DP, heightDp) + .putInt(OpenLessImeService.PREF_KEYBOARD_RAISE_DP, raiseDp) + .apply() + footprint.setSizes(heightDp, raiseDp) + } + val heightSlider = steppedSliderRow( + label = ui("按键区高度", "Key area height"), + min = OpenLessImeService.MIN_KEYBOARD_HEIGHT_DP, + max = OpenLessImeService.MAX_KEYBOARD_HEIGHT_DP, + current = heightDp, + onChange = { value -> + heightDp = value + save() + }, + ) + val raiseSlider = steppedSliderRow( + label = ui("底部抬高", "Raise from bottom"), + min = OpenLessImeService.MIN_KEYBOARD_RAISE_DP, + max = OpenLessImeService.MAX_KEYBOARD_RAISE_DP, + current = raiseDp, + onChange = { value -> + raiseDp = value + save() + }, + ) + + val dialog = android.app.Dialog(this) + val linkColor = tone(Color.rgb(94, 234, 212), Color.rgb(15, 118, 110)) + val titleRow = LinearLayout(this).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 0, 0, dp(12)) + } + titleRow.addView( + TextView(this).apply { + text = ui("键盘高度", "Keyboard height") + textSize = 17f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(tone(Color.WHITE, Color.rgb(30, 30, 34))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + titleRow.addView( + TextView(this).apply { + text = ui("恢复默认", "Reset") + textSize = 14f + setTextColor(linkColor) + setPadding(dp(10), dp(6), dp(10), dp(6)) + isClickable = true + setOnClickListener { + heightSlider.set(OpenLessImeService.DEFAULT_KEYBOARD_HEIGHT_DP) + raiseSlider.set(OpenLessImeService.DEFAULT_KEYBOARD_RAISE_DP) + } + }, + ) + titleRow.addView( + TextView(this).apply { + text = ui("完成", "Done") + textSize = 14f + setTypeface(typeface, android.graphics.Typeface.BOLD) + setTextColor(linkColor) + setPadding(dp(10), dp(6), 0, dp(6)) + isClickable = true + setOnClickListener { dialog.dismiss() } + }, + ) + val controls = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(8), dp(20), dp(2)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tone(Color.rgb(30, 30, 30), Color.rgb(245, 245, 247))) + val radius = dp(16).toFloat() + cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) + } + addView( + View(this@OpenLessKeyboardSettingsActivity).apply { + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tone(Color.rgb(90, 90, 90), Color.rgb(200, 200, 205))) + cornerRadius = dp(2).toFloat() + } + }, + LinearLayout.LayoutParams(dp(36), dp(4)).apply { + gravity = Gravity.CENTER_HORIZONTAL + bottomMargin = dp(10) + }, + ) + addView(titleRow) + addView(heightSlider.view) + addView(raiseSlider.view) + } + val sheet = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(controls, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(footprint.root, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + footprint.setSizes(heightDp, raiseDp) + + dialog.requestWindowFeature(android.view.Window.FEATURE_NO_TITLE) + dialog.setContentView(sheet) + dialog.setOnDismissListener { onClosed() } + dialog.window?.let { window -> + // The stock dialog background carries its own inset and rounded + // card; clearing it is what lets the sheet span the full width + // and sit flush with the bottom edge. + window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(Color.TRANSPARENT)) + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + window.setGravity(Gravity.BOTTOM) + window.setWindowAnimations(android.R.style.Animation_InputMethod) + } + dialog.show() + } + + private class SteppedSlider(val view: View, val set: (Int) -> Unit) + + /** + * Slider row for the keyboard-size sheet: the value reads as a plain + * "300 dp" on the right, and −/+ buttons give the single-dp nudges a + * drag can't hit reliably. `set` moves the slider from code (恢复默认) + * and reports through onChange exactly like a drag would. + */ + private fun steppedSliderRow(label: String, min: Int, max: Int, current: Int, onChange: (Int) -> Unit): SteppedSlider { + var value = current.coerceIn(min, max) + val textColor = tone(Color.rgb(220, 220, 220), Color.rgb(40, 40, 44)) + val valueView = TextView(this).apply { + text = "$value dp" + textSize = 15f + setTextColor(textColor) + } + val seekBar = SeekBar(this) + seekBar.max = (max - min).coerceAtLeast(1) + seekBar.progress = value - min + fun update(next: Int, fromSeekBar: Boolean) { + val clamped = next.coerceIn(min, max) + if (clamped == value) return + value = clamped + valueView.text = "$value dp" + if (!fromSeekBar) seekBar.progress = value - min + onChange(value) + } + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) update(progress + min, fromSeekBar = true) + } + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + fun stepButton(symbol: String, delta: Int): View = TextView(this).apply { + text = symbol + textSize = 18f + gravity = Gravity.CENTER + setTextColor(textColor) + background = android.graphics.drawable.GradientDrawable().apply { + shape = android.graphics.drawable.GradientDrawable.OVAL + setColor(tone(Color.rgb(60, 60, 60), Color.rgb(225, 225, 228))) + } + isClickable = true + setOnClickListener { update(value + delta, fromSeekBar = false) } + } + val header = LinearLayout(this).apply { gravity = Gravity.CENTER_VERTICAL } + header.addView( + TextView(this).apply { + text = label + textSize = 14f + setTextColor(tone(Color.rgb(200, 200, 200), Color.rgb(70, 70, 75))) + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + header.addView(valueView) + val controls = LinearLayout(this).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(6), 0, 0) + } + controls.addView(stepButton("−", -1), LinearLayout.LayoutParams(dp(34), dp(34))) + controls.addView(seekBar, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + controls.addView(stepButton("+", 1), LinearLayout.LayoutParams(dp(34), dp(34))) + val row = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = dp(14) + } + addView(header) + addView(controls) + } + return SteppedSlider(row, set = { update(it, fromSeekBar = false) }) + } + /** - * Full-width 1:1 IME footprint at the bottom of settings. Key-area block - * uses the stretch height; raise strip is empty lift space below keys. + * Full-width 1:1 IME footprint at the bottom of the keyboard-size sheet. + * Key-area block uses the stretch height; raise strip is empty lift + * space below keys. */ private fun buildKeyboardFootprintPreview(): KeyboardFootprintPreview { val caption = TextView(this).apply { @@ -841,8 +1031,8 @@ class OpenLessKeyboardSettingsActivity : Activity() { ) raiseBlock.visibility = if (raiseDp > 0) View.VISIBLE else View.GONE caption.text = ui( - "实时预览 · 按键区 ${heightDp}dp · 抬高 ${raiseDp}dp · 共 ${heightDp + raiseDp}dp", - "Live preview · keys ${heightDp}dp · raise ${raiseDp}dp · total ${heightDp + raiseDp}dp", + "1:1 预览 · 键盘共占屏幕底部 ${heightDp + raiseDp}dp", + "1:1 preview · keyboard takes the bottom ${heightDp + raiseDp}dp", ) root.requestLayout() } From f7536d953a1cbc6fb40735364508f70730b439ab Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:13:15 +0800 Subject: [PATCH 08/13] feat(android): add cursor-context privacy gate and capture logic Adds ImePrivacyPolicy: the gate (TYPE_NULL, password fields, IME_FLAG_NO_PERSONALIZED_LEARNING, sensitive packages) and the single, no-retry read of the text around the caret. The readers are never invoked while the cursorContextEnabled switch is off or a gate rejects the editor, and a null or throwing InputConnection degrades to no context. A surrogate pair cut by the read window is trimmed so no half character crosses JNI. Kept separate from ImeLearningPolicy: learning decides what stays on the device, cursor context may be sent to the polish LLM provider. The accessibility vocabulary observer now shares the same sensitive package list instead of carrying its own copy. Co-Authored-By: Claude Opus 5.5 --- .../app/android/kotlin/ImePrivacyPolicy.kt | 58 +++++++++++++++++++ .../kotlin/OpenLessAccessibilityService.kt | 2 +- .../kotlin/OpenLessAndroidPreferences.kt | 3 + .../kotlin/test/ImePrivacyPolicyTest.kt | 55 ++++++++++++++++++ .../app/scripts/copy-android-scaffolding.mjs | 2 + 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/android/kotlin/ImePrivacyPolicy.kt create mode 100644 openless-all/app/android/kotlin/test/ImePrivacyPolicyTest.kt diff --git a/openless-all/app/android/kotlin/ImePrivacyPolicy.kt b/openless-all/app/android/kotlin/ImePrivacyPolicy.kt new file mode 100644 index 000000000..48a2cb183 --- /dev/null +++ b/openless-all/app/android/kotlin/ImePrivacyPolicy.kt @@ -0,0 +1,58 @@ +package com.openless.app + +import android.text.InputType +import android.view.inputmethod.EditorInfo + +/** Text around the caret, read once when a recording starts. */ +internal data class AndroidCursorContext( + val before: String, + val after: String, + val packageName: String?, +) + +/** + * Gates for reading text out of the host editor. Kept apart from + * [ImeLearningPolicy]: learning decides what stays on this device, while + * cursor context may be sent to the configured polish LLM provider. + */ +internal object ImePrivacyPolicy { + // Read a little wider than the 600-char budget; Core trims to the final window. + const val CURSOR_BEFORE_CHARS = 600 + const val CURSOR_AFTER_CHARS = 200 + + private val sensitivePackageHints = + listOf("keepass", "bitwarden", "1password", "lastpass", "dashlane", "termux", "password") + + /** Single list for cursor context and accessibility vocabulary observation alike. */ + fun isSensitivePackage(packageName: String?): Boolean { + val name = packageName?.lowercase().orEmpty() + return sensitivePackageHints.any { name.contains(it) } + } + + fun allowsCursorContext(inputType: Int, imeOptions: Int, packageName: String?): Boolean = + inputType != InputType.TYPE_NULL && !ImeLearningPolicy.isPassword(inputType) && + imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING == 0 && + !isSensitivePackage(packageName) + + /** + * One read, no retry: a null, a throw or an empty editor all mean "no + * context" and dictation carries on. The readers are never invoked while + * the switch is off or a gate rejects the field. + */ + fun captureCursorContext( + enabled: Boolean, + inputType: Int, + imeOptions: Int, + packageName: String?, + readBefore: (Int) -> CharSequence?, + readAfter: (Int) -> CharSequence?, + ): AndroidCursorContext? { + if (!enabled || !allowsCursorContext(inputType, imeOptions, packageName)) return null + val before = runCatching { readBefore(CURSOR_BEFORE_CHARS)?.toString() }.getOrNull().orEmpty() + .let { if (it.firstOrNull()?.isLowSurrogate() == true) it.drop(1) else it } + val after = runCatching { readAfter(CURSOR_AFTER_CHARS)?.toString() }.getOrNull().orEmpty() + .let { if (it.lastOrNull()?.isHighSurrogate() == true) it.dropLast(1) else it } + if (before.isBlank() && after.isBlank()) return null + return AndroidCursorContext(before, after, packageName) + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index c0014d9e6..b500e4260 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -84,7 +84,7 @@ class OpenLessAccessibilityService : AccessibilityService() { if (node == null) { stopVocabularyObservation(); return } val packageName = node.packageName?.toString()?.lowercase().orEmpty() if (!node.isEditable || node.isPassword || packageName.isEmpty() || packageName == this.packageName || - listOf("keepass", "bitwarden", "1password", "lastpass", "dashlane", "termux", "password").any { packageName.contains(it) }) { + ImePrivacyPolicy.isSensitivePackage(packageName)) { node.recycle() stopVocabularyObservation() return diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index c8863a4dc..c6773329e 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -99,6 +99,9 @@ object OpenLessAndroidPreferences { fun strokeUsageEnabled(context: Context): Boolean = readPreferenceBoolean(context, "strokeUsageEnabled") ?: true + fun cursorContextEnabled(context: Context): Boolean = + readPreferenceBoolean(context, "cursorContextEnabled") ?: false + private fun readPreferenceString(context: Context, key: String): String? { for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { if (!file.isFile) { diff --git a/openless-all/app/android/kotlin/test/ImePrivacyPolicyTest.kt b/openless-all/app/android/kotlin/test/ImePrivacyPolicyTest.kt new file mode 100644 index 000000000..fe4c40b87 --- /dev/null +++ b/openless-all/app/android/kotlin/test/ImePrivacyPolicyTest.kt @@ -0,0 +1,55 @@ +package com.openless.app +import org.junit.Assert.* +import org.junit.Test +class ImePrivacyPolicyTest { + private var reads = 0 + private fun capture( + enabled: Boolean = true, inputType: Int = 1, imeOptions: Int = 0, packageName: String? = "com.tencent.mm", + before: CharSequence? = "前文", after: CharSequence? = "后文", + ) = ImePrivacyPolicy.captureCursorContext(enabled, inputType, imeOptions, packageName, + { reads++; before }, { reads++; after }) + + @Test fun disabledSwitchNeverReadsTheEditor() { + assertNull(capture(enabled = false)) + assertEquals(0, reads) + } + @Test fun passwordsNullAndPrivateEditorsAreNeverRead() { + for (input in listOf(0x81, 0xe1, 0x91, 0x12, 0)) assertNull(capture(inputType = input)) + assertNull(capture(imeOptions = 0x1000000)) + assertEquals(0, reads) + } + @Test fun sensitivePackagesAreNeverRead() { + for (name in listOf("com.x8bit.bitwarden", "com.kunzisoft.keepass.free", "com.termux", + "com.lastpass.lpandroid", "com.dashlane", "com.agilebits.onepassword", "com.onepassword.android")) { + assertTrue(name, ImePrivacyPolicy.isSensitivePackage(name)) + assertNull(name, capture(packageName = name)) + } + assertFalse(ImePrivacyPolicy.isSensitivePackage("com.tencent.mm")) + assertFalse(ImePrivacyPolicy.isSensitivePackage(null)) + assertEquals(0, reads) + } + @Test fun eitherSideAloneIsEnoughAndBothEmptyIsNothing() { + assertEquals(AndroidCursorContext("前文", "后文", "com.tencent.mm"), capture()) + assertEquals(AndroidCursorContext("前文", "", "com.tencent.mm"), capture(after = null)) + assertEquals(AndroidCursorContext("", "后文", null), capture(before = "", packageName = null)) + assertNull(capture(before = null, after = null)) + assertNull(capture(before = " \n", after = "")) + } + @Test fun aThrowingEditorDegradesToNoContext() { + assertNull(ImePrivacyPolicy.captureCursorContext(true, 1, 0, "app", + { throw IllegalStateException() }, { throw SecurityException() })) + assertEquals(AndroidCursorContext("", "后文", "app"), ImePrivacyPolicy.captureCursorContext(true, 1, 0, "app", + { throw IllegalStateException() }, { "后文" })) + } + @Test fun readsStayWithinTheRequestedWindow() { + val asked = mutableListOf() + ImePrivacyPolicy.captureCursorContext(true, 1, 0, "app", { asked += it; "a" }, { asked += it; "b" }) + assertEquals(listOf(600, 200), asked) + } + @Test fun emojiCutByTheReadWindowDoesNotLeaveHalfACharacter() { + val emoji = "🙂" + val context = capture(before = emoji.substring(1) + "前文" + emoji, after = emoji + "后文" + emoji.substring(0, 1))!! + assertEquals("前文$emoji", context.before) + assertEquals("${emoji}后文", context.after) + } +} diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 1378c307d..8c3b6c4ab 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -60,6 +60,7 @@ const KOTLIN_FILES = [ 'OpenLessContentWriter.kt', 'OpenLessImeService.kt', 'ImeLearningPolicy.kt', + 'ImePrivacyPolicy.kt', 'StrokeInputController.kt', 'StrokeInput.kt', 'LitePinyinController.kt', @@ -81,6 +82,7 @@ const KOTLIN_FILES = [ const KOTLIN_TEST_FILES = [ 'ImeLearningPolicyTest.kt', + 'ImePrivacyPolicyTest.kt', 'OpenLessContentReaderTest.kt', 'OpenLessCredentialCipherTest.kt', 'OpenLessShizukuBridgeTest.kt', From 1d6ba7c9a283dd3c611c4ff60c43ac5b3b7a03f2 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:13:15 +0800 Subject: [PATCH 09/13] feat(android): pass cursor context through IME JNI The IME snapshots the text around the caret through its own InputConnection when a recording starts and sends it with the "start" command as structured frontApp / cursorBefore / cursorAfter fields. Stop, cancel and cloud commands never resend it. native_bridge trims the two sides with Core's existing window budget (new window_from_split helper, same 80/20 rule as the desktop readers) and builds the envelope input with the shared cursor_context_input, so Android has no prompt format of its own. Logs carry character counts and the package name only. Co-Authored-By: Claude Opus 5.5 --- .../app/android/kotlin/OpenLessImeService.kt | 25 +++++++++++-- .../openless-core/src/host_document/mod.rs | 4 ++- .../openless-core/src/host_document/window.rs | 36 +++++++++++++++++++ .../src-tauri/src/android/native_bridge.rs | 26 ++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 0d489ebb5..6fc55eaf7 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -55,7 +55,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt private var pendingImeStop = false private var cloudNoteDestination: Pair? = null - private fun sendImeCommand(action: String): Boolean = try { + private fun sendImeCommand(action: String, cursorContext: AndroidCursorContext? = null): Boolean = try { val request = org.json.JSONObject().apply { put("action", action) put("requestId", imeRequestId) @@ -63,6 +63,12 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt put("raw", rawModeArmed) put("quickNote", quickNoteArmed) put("cloud", cloudNoteArmed) + // Core builds the prompt envelope; only the session's "start" carries the snapshot. + if (action == "start" && cursorContext != null) { + cursorContext.packageName?.let { put("frontApp", it) } + put("cursorBefore", cursorContext.before) + put("cursorAfter", cursorContext.after) + } } val response = org.json.JSONObject(OpenLessNative.nativeImeCommand(request.toString())) check(response.optBoolean("ok")) { response.optString("error", "IME command failed") } @@ -72,6 +78,21 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt false } + /** Snapshot of the text around the caret for this recording; must run when recording starts, never at stop. Logs metadata only. */ + private fun captureCursorContext(): AndroidCursorContext? { + if (!OpenLessAndroidPreferences.cursorContextEnabled(this)) return null + val started = android.os.SystemClock.elapsedRealtime() + val editor = currentInputEditorInfo + val connection = currentInputConnection + val captured = if (editor == null || connection == null) null else ImePrivacyPolicy.captureCursorContext( + enabled = true, inputType = editor.inputType, imeOptions = editor.imeOptions, packageName = editor.packageName, + readBefore = { connection.getTextBeforeCursor(it, 0) }, readAfter = { connection.getTextAfterCursor(it, 0) }) + android.util.Log.i("OpenLessImeService", "cursor-context android status=${if (captured == null) "none" else "ok"} " + + "source=input_connection before_chars=${captured?.before?.length ?: 0} after_chars=${captured?.after?.length ?: 0} " + + "package=${editor?.packageName} elapsed_ms=${android.os.SystemClock.elapsedRealtime() - started}") + return captured + } + private fun cancelImeSession() { if (imeRequestId != 0L) { lastCancelledImeRequest = imeRequestId; sendImeCommand("cancel") } imeRequestId = 0L @@ -3551,7 +3572,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt cloudNoteArmed = initialCloud imeRequestId = nextImeRequest.incrementAndGet() pendingImeStop = false - if (!sendImeCommand("start")) { recording = false; processing = false } + if (!sendImeCommand("start", captureCursorContext())) { recording = false; processing = false } } } diff --git a/openless-all/app/crates/openless-core/src/host_document/mod.rs b/openless-all/app/crates/openless-core/src/host_document/mod.rs index f71c8a8cd..1f0825b3e 100644 --- a/openless-all/app/crates/openless-core/src/host_document/mod.rs +++ b/openless-all/app/crates/openless-core/src/host_document/mod.rs @@ -12,7 +12,9 @@ pub use diff::{ minimal_edit, EditPair, LearnedRule, }; pub use observation::ObservedInsertion; -pub use window::{plan_window, utf16_offset_to_char_offset, window_around_cursor, WindowSpan}; +pub use window::{ + plan_window, utf16_offset_to_char_offset, window_around_cursor, window_from_split, WindowSpan, +}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/openless-all/app/crates/openless-core/src/host_document/window.rs b/openless-all/app/crates/openless-core/src/host_document/window.rs index 996206a2b..04331864f 100644 --- a/openless-all/app/crates/openless-core/src/host_document/window.rs +++ b/openless-all/app/crates/openless-core/src/host_document/window.rs @@ -34,6 +34,20 @@ pub fn window_around_cursor(text: &str, cursor: usize, budget: usize) -> Documen } } +/// Window for hosts that hand over the text already split at the caret (Android's +/// `InputConnection`), trimmed with the same budget rule as [`window_around_cursor`]. +/// `None` when both sides are blank, so callers send no context at all. +pub fn window_from_split(before: &str, after: &str, budget: usize) -> Option { + if before.trim().is_empty() && after.trim().is_empty() { + return None; + } + Some(window_around_cursor( + &format!("{before}{after}"), + before.chars().count(), + budget, + )) +} + pub fn utf16_offset_to_char_offset(text: &str, utf16_offset: usize) -> usize { let mut units = 0; for (index, character) in text.chars().enumerate() { @@ -44,3 +58,25 @@ pub fn utf16_offset_to_char_offset(text: &str, utf16_offset: usize) -> usize { } text.chars().count() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_text_keeps_the_caret_between_both_sides_and_respects_the_budget() { + let window = window_from_split("前文🙂", "😀后文", 600).unwrap(); + assert_eq!(window.before(), "前文🙂"); + assert_eq!(window.after(), "😀后文"); + + // Android reads up to 600 before + 200 after; the window still ends at 480/120. + let window = window_from_split(&"前".repeat(600), &"后".repeat(200), 600).unwrap(); + assert_eq!(window.before().chars().count(), 480); + assert_eq!(window.after().chars().count(), 120); + + assert_eq!(window_from_split("只有前文", "", 600).unwrap().after(), ""); + assert_eq!(window_from_split("", "只有后文", 600).unwrap().before(), ""); + assert!(window_from_split("", "", 600).is_none()); + assert!(window_from_split(" \n", "\t", 600).is_none()); + } +} diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index ebf4a9c19..1651d4c6f 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -487,6 +487,14 @@ struct ImeCommand { quick_note: bool, #[serde(default)] cloud: bool, + // Sent with "start" only: the editor's package and the text around the caret, + // snapshotted by the IME when recording begins. + #[serde(default)] + front_app: Option, + #[serde(default)] + cursor_before: Option, + #[serde(default)] + cursor_after: Option, } #[cfg(target_os = "android")] @@ -524,6 +532,22 @@ fn ime_command(json: &str) -> Result<(), String> { ); return; } + // Core owns the window budget and the envelope format; Kotlin only hands + // over the two sides of the caret. Metadata only in the log, never the text. + let cursor_context = openless_core::host_document::window_from_split( + command.cursor_before.as_deref().unwrap_or_default(), + command.cursor_after.as_deref().unwrap_or_default(), + crate::host_document::DEFAULT_BUDGET_CHARS, + ) + .map(|window| { + log::info!( + "[cursor-context] status=ok source=input_connection chars_before={} chars_after={} app={:?}", + window.before().chars().count(), + window.after().chars().count(), + command.front_app, + ); + openless_core::prompts::cursor_context_input(window.before(), window.after()) + }); let mut starting = Box::pin(backend.start_dictation_with_options(DictationStartOptions { insert_text: false, @@ -532,6 +556,8 @@ fn ime_command(json: &str) -> Result<(), String> { } else { openless_core::DictationOutputTarget::ForegroundApp }, + front_app: command.front_app.clone(), + cursor_context, ..Default::default() })); // The first poll reserves the Core session before any async preparation. From 7332e5ab10da462ba90c4c40b7d9c559a4f26b82 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:13:15 +0800 Subject: [PATCH 10/13] test(notes): pin cursor context to polish only for quick and cloud notes Quick notes and cloud notes already keep polish.cursor_context when the output target changes. This locks that in, together with the other half of the rule: the context informs polish but never becomes stored content. The Core test runs dictation, quick note and cloud note sessions with a caret snapshot, checks the polisher saw it each time, and scans the whole data directory for the snapshot text afterwards. The contract test covers the Kotlin and JNI side: InputConnection source, gate order, start-only payload, metadata-only logs, webhook body and history schema. Co-Authored-By: Claude Opus 5.5 --- .../app/crates/openless-core/src/api.rs | 108 +++++++++++++++++ .../android-cursor-context-contract.test.mjs | 111 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 openless-all/app/scripts/android-cursor-context-contract.test.mjs diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a96c1cbe9..9cc05a0ef 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -11562,6 +11562,114 @@ mod tests { } } + /// Android IME contract: the caret snapshot taken at start informs polish for + /// ordinary dictation, quick notes and cloud notes alike, yet the result carries + /// only the polished text and nothing on disk ever holds the snapshot. + #[tokio::test] + async fn cursor_context_informs_polish_for_every_target_but_is_never_persisted() { + struct ContextRecordingPolisher(Arc>>>); + impl crate::ports::TextPolisher for ContextRecordingPolisher { + fn polish( + &self, + _session_id: SessionId, + context: Arc, + _raw_text: String, + _partials: Arc, + ) -> BoxFuture<'static, Result> { + self.0 + .lock() + .unwrap() + .push(context.polish.cursor_context.clone()); + Box::pin(async { Ok(crate::ports::PolishOutput::text("polished words")) }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + } + fn files_containing(dir: &std::path::Path, needle: &str, found: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + files_containing(&path, needle, found); + } else if std::fs::read(&path) + .map(|bytes| bytes.windows(needle.len()).any(|w| w == needle.as_bytes())) + .unwrap_or(false) + { + found.push(path); + } + } + } + + const HOST_TEXT: &str = "host-document-text-7c1e"; + for target in ["dictation", "quick_note", "cloud_note"] { + let data_dir = std::env::temp_dir() + .join(format!("openless-cursor-context-{}", uuid::Uuid::new_v4())); + let seen = Arc::new(Mutex::new(Vec::new())); + let engine = crate::PipelineDictationEngine::new( + Arc::new(crate::ExternalAudioRecorder::with_recordings_directory( + data_dir.join("recordings"), + )), + Arc::new(crate::testing::FixtureTranscriptionEngine::successful( + "spoken words", + 1000, + )), + Arc::new(ContextRecordingPolisher(seen.clone())), + ); + let backend = backend_with_dictation_engine(data_dir.clone(), Arc::new(engine)); + backend.start().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.cursor_context_enabled = true; + prefs.record_audio_for_debug = true; + backend.set_preferences(prefs).unwrap(); + let id = backend + .start_external_dictation_with_options(DictationStartOptions { + insert_text: false, + front_app: Some("com.example.notes".into()), + cursor_context: Some(crate::prompts::cursor_context_input(HOST_TEXT, "")), + ..DictationStartOptions::default() + }) + .await + .unwrap(); + backend.feed_external_pcm(id, &vec![1; 32000]).unwrap(); + if target == "cloud_note" { + backend.set_dictation_cloud_note(id, true).unwrap(); + } + let result = backend + .stop_dictation_session_with_options( + Some(id), + DictationStopOptions { + quick_note: (target != "cloud_note").then_some(target == "quick_note"), + ..DictationStopOptions::default() + }, + None, + ) + .await + .unwrap(); + assert_eq!(result.polished_text, "polished words", "{target}"); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1, "{target}"); + assert!( + seen[0].as_deref().is_some_and(|context| context.contains(HOST_TEXT)), + "{target}: polish must still see the cursor context" + ); + assert_eq!( + backend.list_history().unwrap().is_empty(), + target == "cloud_note", + "{target}" + ); + backend.shutdown().await.unwrap(); + let mut leaked = Vec::new(); + files_containing(&data_dir, HOST_TEXT, &mut leaked); + assert!(leaked.is_empty(), "{target}: cursor context persisted in {leaked:?}"); + // The scan is meaningful: what does get stored is readable the same way. + let mut stored = Vec::new(); + files_containing(&data_dir, "polished words", &mut stored); + assert_eq!(stored.is_empty(), target == "cloud_note", "{target}"); + std::fs::remove_dir_all(data_dir).unwrap(); + } + } + #[tokio::test] async fn external_audio_saves_failed_recordings_for_history_retry_and_prunes_successful_audio() { diff --git a/openless-all/app/scripts/android-cursor-context-contract.test.mjs b/openless-all/app/scripts/android-cursor-context-contract.test.mjs new file mode 100644 index 000000000..3db01455a --- /dev/null +++ b/openless-all/app/scripts/android-cursor-context-contract.test.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const read = (path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); + +function braceBody(source, signature) { + const signatureIndex = source.indexOf(signature); + assert.notEqual(signatureIndex, -1, `missing: ${signature}`); + const openBrace = source.indexOf('{', signatureIndex); + let depth = 0; + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') depth -= 1; + if (depth === 0) return source.slice(openBrace + 1, index); + } + assert.fail(`missing closing brace: ${signature}`); +} + +const ime = read('../android/kotlin/OpenLessImeService.kt'); +const policy = read('../android/kotlin/ImePrivacyPolicy.kt'); +const accessibility = read('../android/kotlin/OpenLessAccessibilityService.kt'); +const bridge = read('../src-tauri/src/android/native_bridge.rs'); +const dictationContext = read('../crates/openless-core/src/dictation_context.rs'); +const types = read('../crates/openless-core/src/types.rs'); + +// The IME reads the caret context through its own InputConnection, and the +// switch is checked before the editor is touched at all. +const capture = braceBody(ime, 'private fun captureCursorContext()'); +assert.match(capture, /currentInputConnection/, 'cursor context must come from the IME InputConnection'); +assert.match(capture, /getTextBeforeCursor\(/); +assert.match(capture, /getTextAfterCursor\(/); +assert.doesNotMatch(capture, /Accessibility/, 'phase one must not depend on Accessibility'); +assert.ok( + capture.indexOf('cursorContextEnabled') !== -1 && + capture.indexOf('cursorContextEnabled') < capture.indexOf('currentInputConnection'), + 'the cursorContextEnabled switch must be checked before the editor is read', +); +assert.match(capture, /ImePrivacyPolicy\.captureCursorContext\(/, 'reads must go through the privacy gate'); + +// Privacy gate: password, TYPE_NULL, no-personalized-learning and sensitive apps. +const allows = policy.slice(policy.indexOf('fun allowsCursorContext'), policy.indexOf('fun captureCursorContext')); +assert.match(allows, /TYPE_NULL/); +assert.match(allows, /isPassword\(/); +assert.match(allows, /IME_FLAG_NO_PERSONALIZED_LEARNING/); +assert.match(allows, /isSensitivePackage\(/); +const gated = braceBody(policy, 'fun captureCursorContext('); +assert.ok( + gated.indexOf('allowsCursorContext') < gated.indexOf('readBefore('), + 'the gate must run before any editor read', +); +assert.match( + accessibility, + /ImePrivacyPolicy\.isSensitivePackage\(/, + 'vocabulary observation must share the sensitive package list', +); + +// Metadata-only logging: lengths and package, never the text itself. +for (const line of capture.split('\n').filter((line) => line.includes('Log.') || line.includes('_chars='))) { + assert.doesNotMatch(line, /\.(before|after)\s*\}/, `cursor context text must not be logged: ${line.trim()}`); +} +const rustLog = bridge.slice(bridge.indexOf('[cursor-context] status=ok source=input_connection')); +assert.doesNotMatch( + rustLog.slice(0, rustLog.indexOf(');')), + /cursor_before|cursor_after|window\.text|window\.before\(\)\s*,|window\.after\(\)\s*,/, + 'the native log may only carry character counts', +); + +// Only "start" carries the snapshot, and it is taken when recording starts. +const send = braceBody(ime, 'private fun sendImeCommand('); +assert.match(send, /if \(action == "start" && cursorContext != null\)/); +for (const key of ['frontApp', 'cursorBefore', 'cursorAfter']) assert.match(send, new RegExp(`put\\("${key}"`)); +assert.match(ime, /sendImeCommand\("start", captureCursorContext\(\)\)/); +for (const action of ['stop', 'cancel', 'cloud']) { + assert.doesNotMatch( + ime, + new RegExp(`sendImeCommand\\("${action}",`), + `"${action}" must not resend cursor context`, + ); +} + +// Kotlin hands over structured sides; Core owns the envelope format. +for (const [name, source] of [['OpenLessImeService.kt', ime], ['ImePrivacyPolicy.kt', policy], ['native_bridge.rs', bridge]]) { + assert.doesNotMatch(source, //, `${name} must not build its own cursor_context envelope`); +} +const command = braceBody(bridge, 'struct ImeCommand'); +for (const field of ['front_app', 'cursor_before', 'cursor_after']) { + assert.match(command, new RegExp(`${field}: Option`), `ImeCommand.${field}`); +} +const start = bridge.slice(bridge.indexOf('"start" => {'), bridge.indexOf('"cloud" => {')); +assert.match(start, /openless_core::host_document::window_from_split\(/); +assert.match(start, /openless_core::prompts::cursor_context_input\(/); +assert.match(start, /front_app: command\.front_app/); +assert.match(start, /\bcursor_context,/, 'the IME start must fill DictationStartOptions.cursor_context'); + +// Quick notes and cloud notes keep the context for polish... +const withTarget = braceBody(dictationContext, 'pub fn with_output_target('); +assert.doesNotMatch(withTarget, /cursor_context/, 'switching the output target must not drop cursor context'); + +// ...but nothing downstream of polish may carry it. +const completed = bridge.slice(bridge.indexOf('"kind":"completed"')); +assert.match(completed.slice(0, completed.indexOf('),')), /"text":result\.polished_text/); +assert.doesNotMatch(completed.slice(0, completed.indexOf('),')), /cursor/i); +const webhook = braceBody(ime, 'private fun submitCloudNoteText('); +assert.match(webhook, /put\("content", text\)/); +assert.doesNotMatch(webhook, /cursor|getTextBeforeCursor|getTextAfterCursor/i, 'the cloud note webhook must only send the note text'); +const session = braceBody(types, 'pub struct DictationSession'); +assert.doesNotMatch(session, /cursor/i, 'history entries must not gain a cursor context field'); + +console.log('android cursor context contract: ok'); From 5655149dc47f7f1a334b8396223783130f3eb585 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:42:56 +0800 Subject: [PATCH 11/13] feat(settings): enable cursor context on Android Shows the Cursor context toggle on Android as well as macOS. The description in all 8 locales now names Android, says dictation, quick notes and cloud notes use the context without storing it, and states that a cloud polish model receives it with the polish request. Co-Authored-By: Claude Opus 5.5 --- openless-all/app/src/i18n/de.ts | 2 +- openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/es.ts | 2 +- openless-all/app/src/i18n/fr.ts | 2 +- openless-all/app/src/i18n/ja.ts | 2 +- openless-all/app/src/i18n/ko.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/i18n/zh-TW.ts | 2 +- openless-all/app/src/lib/types.ts | 3 ++- openless-all/app/src/pages/settings/DataStorageSection.tsx | 6 +++--- 10 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 9f80d9d58..f6cac71d4 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1304,7 +1304,7 @@ export const de: typeof zhCN = { desc: 'Gesprächsverlauf und Kontext, die auf diesem Gerät gespeichert werden.', cursorContextLabel: 'Cursorkontext (experimentell)', cursorContextDesc: - 'Text rund um den Cursor zur Überarbeitung an das Modell senden (nur macOS). Diese Einstellung ist vom lokalen Lernen getrennt. Passwortfelder und bekannte sensible Apps sind ausgeschlossen.', + 'Einen kurzen Textabschnitt rund um den Cursor als Referenz für Eigennamen, Homophone und Bezüge mit der Überarbeitung an das Modell senden (macOS / Android). Diktat, Schnellnotizen und Cloud-Notizen nutzen ihn, der Kontext selbst wird aber nie in Notizen, Verlauf oder Protokolle geschrieben; bei einem Cloud-Modell wird er mit der Überarbeitungsanfrage an diesen Anbieter gesendet. Diese Einstellung ist vom lokalen Lernen getrennt. Passwortfelder, Terminals und bekannte sensible Apps sind ausgeschlossen.', }, codingConsole: { title: 'Claude-Konsole', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 78cc64c30..5544e4ad4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1281,7 +1281,7 @@ export const en: typeof zhCN = { desc: 'Conversation history and context kept on this device.', cursorContextLabel: 'Cursor context (experimental)', cursorContextDesc: - 'Send nearby document text with polish requests (macOS only). This switch is separate from local vocabulary learning. Password fields and known sensitive apps are excluded.', + 'Send a short stretch of text around the cursor with polish requests as reference for names, homophones and pronouns (macOS / Android). Dictation, quick notes and cloud notes all use it, but the context itself is never written to notes, history or logs; with a cloud model it is sent to that provider with the polish request. This switch is separate from local vocabulary learning. Password fields, terminals and known sensitive apps are excluded.', }, codingConsole: { title: 'Claude Console', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index a271faa52..04b438068 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1298,7 +1298,7 @@ export const es: typeof zhCN = { desc: 'Historial de conversaciones y contexto guardados en este dispositivo.', cursorContextLabel: 'Contexto del cursor (experimental)', cursorContextDesc: - 'Envía el texto cercano al cursor al modelo para pulirlo (solo macOS). Es independiente del aprendizaje local. Se excluyen contraseñas y aplicaciones sensibles conocidas.', + 'Envía un fragmento breve del texto cercano al cursor al modelo como referencia para nombres propios, homófonos y pronombres al pulir (macOS / Android). El dictado, las notas rápidas y las notas en la nube lo usan, pero el contexto en sí nunca se guarda en notas, historial ni registros; con un modelo en la nube se envía a ese proveedor junto con la solicitud de pulido. Es independiente del aprendizaje local. Se excluyen contraseñas, terminales y aplicaciones sensibles conocidas.', }, codingConsole: { title: 'Consola de Claude', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 52c8998ec..731b5f0a6 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1319,7 +1319,7 @@ export const fr: typeof zhCN = { desc: 'Historique des conversations et contexte conservés sur cet appareil.', cursorContextLabel: 'Contexte du curseur (expérimental)', cursorContextDesc: - 'Envoyer le texte autour du curseur au modèle pour la reformulation (macOS uniquement). Ce réglage est indépendant de l’apprentissage local. Les champs de mot de passe et les applications sensibles connues sont exclus.', + 'Envoyer un court extrait du texte autour du curseur au modèle comme référence pour les noms propres, les homophones et les pronoms lors de la reformulation (macOS / Android). La dictée, les notes rapides et les notes cloud l’utilisent, mais le contexte lui-même n’est jamais écrit dans les notes, l’historique ou les journaux ; avec un modèle cloud, il est envoyé à ce fournisseur avec la demande de reformulation. Ce réglage est indépendant de l’apprentissage local. Les champs de mot de passe, les terminaux et les applications sensibles connues sont exclus.', }, codingConsole: { title: 'Console Claude', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c002cbcfd..668ed8507 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1268,7 +1268,7 @@ export const ja: typeof zhCN = { desc: 'この端末に保存される会話履歴とコンテキスト。', cursorContextLabel: 'カーソル文脈(実験的)', cursorContextDesc: - '推敲時にカーソル付近の文章をモデルへ送信します(macOSのみ)。端末内の単語学習とは独立した設定です。パスワード欄と既知の機密アプリは除外します。', + '推敲時にカーソル付近の短い文章を参考情報としてモデルへ送信し、固有名詞・同音語・指示語の判断に使います(macOS / Android)。音声入力、クイックメモ、クラウドメモのいずれも参照しますが、コンテキスト自体がメモ・履歴・ログに書き込まれることはありません。クラウドモデル使用時は、推敲リクエストとともにそのプロバイダーへ送信されます。端末内の単語学習とは独立した設定です。パスワード欄、ターミナル、既知の機密アプリは除外します。', }, codingConsole: { title: 'Claude コンソール', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 64558e13c..aa42d12ca 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1259,7 +1259,7 @@ export const ko: typeof zhCN = { desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', cursorContextLabel: '커서 문맥 (실험적)', cursorContextDesc: - '다듬기 요청 시 커서 주변 텍스트를 모델에 보냅니다(macOS 전용). 기기 내 단어 학습과 별도 설정입니다. 비밀번호 입력란과 알려진 민감한 앱은 제외됩니다.', + '다듬기 요청 시 커서 주변의 짧은 텍스트를 참고 자료로 모델에 보내 고유명사, 동음이의어, 지시어 판단에 사용합니다(macOS / Android). 받아쓰기, 빠른 메모, 클라우드 메모 모두 참고하지만 컨텍스트 자체는 메모, 기록, 로그에 저장되지 않습니다. 클라우드 모델을 사용하면 다듬기 요청과 함께 해당 제공업체로 전송됩니다. 기기 내 단어 학습과 별도 설정입니다. 비밀번호 입력란, 터미널, 알려진 민감한 앱은 제외됩니다.', }, codingConsole: { title: 'Claude 콘솔', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 0dc9ecf94..4edbe7094 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1226,7 +1226,7 @@ export const zhCN = { desc: '本机保留的历史会话与对话上下文。', cursorContextLabel: '光标上下文(实验)', cursorContextDesc: - '润色时将光标附近文本发给模型(仅 macOS)。此开关与本地手改学词独立;排除密码框和已知敏感应用。', + '润色时把光标附近的一小段文字发给模型作参考,用于专有词、同音词和指代的判断(macOS / Android)。听写、速记和云笔记都会参考,但上下文本身不会写入笔记、历史或日志;使用云端模型时会随本次润色请求发送给该模型服务。此开关与本地手改学词独立;排除密码框、终端及已知敏感应用。', }, codingConsole: { title: 'Claude 控制台', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 23978b87f..789a82a65 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1226,7 +1226,7 @@ export const zhTW: typeof zhCN = { desc: '本機保留的歷史會話與對話上下文。', cursorContextLabel: '遊標上下文(實驗)', cursorContextDesc: - '潤色時將游標附近文字傳給模型(僅 macOS)。此開關與本機手改學詞獨立;排除密碼欄位和已知敏感應用程式。', + '潤色時把游標附近的一小段文字傳給模型作參考,用於專有詞、同音詞和指代的判斷(macOS / Android)。聽寫、速記和雲筆記都會參考,但上下文本身不會寫入筆記、歷史或日誌;使用雲端模型時會隨本次潤色請求傳送給該模型服務。此開關與本機手改學詞獨立;排除密碼欄位、終端機及已知敏感應用程式。', }, codingConsole: { title: 'Claude 主控臺', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d4a31688e..fef08ff55 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -509,7 +509,8 @@ export interface UserPreferences { streamingInsertSaveClipboard: boolean; /** Whether to send the text near the cursor in the document the user is writing to LLM polish as context. * Default false — when on, every dictation reads the foreground app's body text and sends part of it to the LLM provider. - * macOS only; password fields / Secure Input / password managers / terminals are always hard-blocked. */ + * macOS and Android (IME) only; password fields / Secure Input (macOS) / password and + * no-personalized-learning editors (Android) / password managers / terminals are always hard-blocked. */ cursorContextEnabled: boolean; vocabularyLearningEnabled: boolean; vocabularyLearningSettings: { diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 50574856f..b2d66545d 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -83,9 +83,9 @@ export function DataStorageSection() { {/* Cursor context. Placing it under "Privacy" rather than "Polish" is deliberate: this toggle's real cost is not tokens but "sending text from other apps to the LLM provider". - Shown on macOS only — other platforms have no implementation, and a switch that changes - nothing would just mislead. */} - {detectOS() === 'mac' && ( + Shown on macOS and Android — other platforms have no implementation, and a switch that + changes nothing would just mislead. */} + {(detectOS() === 'mac' || detectOS() === 'android') && ( Date: Fri, 2 Oct 2026 22:42:56 +0800 Subject: [PATCH 12/13] docs(android): document InputConnection cursor context Covers the Android read path, what the context is and is not used for, the extra Android gates, and that the floating overlay does not read cursor context yet. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- README.zh.md | 6 ++++-- docs/android-ime.md | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9844e9551..c0b69216b 100644 --- a/README.md +++ b/README.md @@ -376,13 +376,15 @@ The dictionary handles your proper nouns, product names, names of people, and ne - **Learn from corrections (experimental).** Open Settings → Experiments & extensions → Learn from corrections to enable it and configure observation duration (10–60 seconds, default 60), suggestion duration (5–60 seconds, default 10), and maximum automatic phrase length (2–32 characters, default 12). It defaults to off and is not enabled by cursor context or cloud sync. On macOS, Windows and Android, supported editors can be observed after insertion. Suggestions require confirmation before expiry to enter the dictionary. Changing parameters stops the current observation and clears pending suggestions; new values apply to the next dictation. Android requires accessibility. When observation is unavailable, use **Remember a word** in history details; Android IME result editing also offers an unchecked dictionary option. Every path requires explicit confirmation and does not create global replacement rules. - **Entries that earn their keep get priority.** The hotword budget sent to ASR providers is finite (a few hundred characters). Entries are ranked by hit count, with a few reserved seats for words you just added by hand, so the terms you actually use keep their place instead of being pushed out by whatever you added most recently. -### Cursor context (opt-in, macOS) +### Cursor context (opt-in, macOS / Android) Settings → Privacy → Data storage → **Cursor context**. Off by default. When on, each dictation reads a few hundred characters around your cursor **in the app you are writing in** and sends them with the polish request, so the model knows what you are writing about. Chinese homophones (接口/借口, 大鱼/大禹) are indistinguishable to an acoustic model but obvious from context. Local vocabulary learning has a separate switch and does not require cursor context. Observed text is not sent to a model; words explicitly added to the dictionary participate in future ASR and polish requests as described above. -Cursor context excludes password fields, macOS Secure Input, known password managers and terminals. Turning it off stops reading cursor context for polishing; other authorized accessibility features, including local vocabulary learning and insertion, work independently. Turning vocabulary learning off stops observation and clears pending suggestions. +On Android, the OpenLess keyboard reads the text before and after the caret straight from its own `InputConnection` at the moment recording starts — no Accessibility permission involved, one read, no retry. Dictation, quick notes and cloud notes all use it for polishing, but the context only informs how the spoken words are written: it is never copied into the note, the history, the cloud note webhook or the logs. With a cloud polish model it is sent to that provider as part of the polish request. The floating overlay does not read cursor context yet. + +Cursor context excludes password fields, macOS Secure Input, Android editors that are password-typed or ask for no personalized learning, known password managers and terminals. Turning it off stops reading cursor context for polishing; other authorized accessibility features, including local vocabulary learning and insertion, work independently. Turning vocabulary learning off stops observation and clears pending suggestions. The main window is organized as Home / History / Dictionary / Settings. The Dictionary tab opens a separate editor window when you click "New". The Home tab shows total dictation time, total characters, average characters per minute, estimated time saved, and dictionary participation statistics. diff --git a/README.zh.md b/README.zh.md index dbddbc371..c4296d68a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -383,13 +383,15 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 - **手改学词(实验)。** 在「设置 → 实验与扩展 → 手改学词」进入独立配置页,设置观察时长(10–60 秒,默认 60)、建议保留时长(5–60 秒,默认 10)及自动建议最大词长(2–32 个字符,默认 12)。功能默认关闭,不跟随光标上下文或云同步授权。macOS、Windows 和 Android 可在支持的编辑器中观察插入后的修改,候选需在有效期内逐条确认才加入词典。修改参数会结束当前观察并清空待确认建议,下次听写生效。Android 需要无障碍服务。无法观察时,可在历史详情点击「记住词汇」手动输入正确词;Android 输入法编辑结果也提供默认不勾选的加入词典选项。所有入口均需明确确认,不自动创建全局替换规则。 - **真正在用的词优先。** 发给 ASR 的热词预算是有限的(几百字符)。条目按命中次数排序,并给刚手动添加的词留几个保底席位——这样你天天在用的那些词不会被「最近刚加的」挤出去。 -### 光标上下文(需手动开启,仅 macOS) +### 光标上下文(需手动开启,macOS / Android) 设置 → 隐私 → 数据存储 → **光标上下文**。默认关闭。 开启后,每次听写会读取**你正在写的那个应用里**光标附近的几百个字,随润色请求一起发出,让模型知道你在写什么。中文同音词(接口/借口、大鱼/大禹)声学模型分不出来,但上下文能分。手改学词是独立的本地功能,不需要开启此设置。观察文本本身不会发送给模型;确认加入词典的词会按词典规则参与后续 ASR/润色。 -光标上下文排除密码输入框、macOS Secure Input、已知密码管理器和终端。关闭此开关后不会为润色读取光标上下文;其他已授权的辅助功能(如手改学词或插入)独立工作。手改学词关闭后会停止观察并清空待确认建议。 +Android 上由 OpenLess 键盘在开始录音的那一刻,直接通过自身的 `InputConnection` 读取光标前后的文字——不需要无障碍权限,只读一次,不重试。听写、速记和云笔记润色时都会参考,但上下文只用来判断这次口述该怎么写:不会被抄进笔记、历史、云笔记 Webhook 或日志。使用云端润色模型时,上下文会随本次润色请求发送给该模型服务。悬浮球暂不读取光标上下文。 + +光标上下文排除密码输入框、macOS Secure Input、Android 上密码类型或声明不做个性化学习的输入框、已知密码管理器和终端。关闭此开关后不会为润色读取光标上下文;其他已授权的辅助功能(如手改学词或插入)独立工作。手改学词关闭后会停止观察并清空待确认建议。 主窗口组织为 首页 / 历史 / 词典 / 设置。点击“新建”时,词典页会打开一个独立的编辑窗口。首页展示总听写时长、总字数、平均每分钟字数、估算节省的时间,以及词典参与统计。 diff --git a/docs/android-ime.md b/docs/android-ime.md index cb831be00..48204623a 100644 --- a/docs/android-ime.md +++ b/docs/android-ime.md @@ -20,6 +20,7 @@ - 笔画输入:离线笔画字典、单字候选、确认后的词语联想、分词、简繁偏好、数字/符号面板、上滑数字和个人词频。 - 剪贴板:历史记录、收藏/删除/分类、选择/复制/粘贴、纠正词写入全局词典。 - 手改学词:独立授权默认关闭,无障碍服务在有界观察期内报告当前编辑器的文本变化,主进程 Core 判断建议与过期时间;确认卡或编辑结果的显式勾选才加入词典。跨进程共享截止时间,服务重连不延长观察。 +- 光标上下文(默认关闭):开始录音时通过 `InputConnection` 读取光标前后各一小段文字(`ImePrivacyPolicy.kt` 负责门禁:密码框、`TYPE_NULL`、`IME_FLAG_NO_PERSONALIZED_LEARNING` 和敏感应用一律不读),随 `start` 命令结构化传给 Rust,由 Core 裁剪到 600 字并组装 Prompt。听写、速记、云笔记润色时都参考,但不写入笔记、历史、Webhook 或日志;悬浮球路径暂未接入。 - 英文键盘:字母/数字/符号三层布局、英文候选词、个人词频、自定义词长按删除、按键预览和上滑输入数字/符号。 - 跨应用插入:按可用性使用无障碍、Shizuku 或剪贴板回退;权限和输入法启用状态在 Android 侧单独管理。 From 5b18cb0f436723191fb6b2705ceade5d373683f9 Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:42:37 +0800 Subject: [PATCH 13/13] ci: re-trigger checks The macOS jobs on the previous commit were cancelled before running because no hosted runner was acquired. No code change. Co-Authored-By: Claude Opus 5.5