Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ jobs:
- name: Check Windows UI configuration contract
run: npm run check:windows-ui-config

- name: Check PIN persistence security contract
run: npm run check:pin-persistence-security

- name: Build frontend (tsc + vite)
run: npm run build

Expand Down
1 change: 1 addition & 0 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"check:hotkey-side-modifiers": "tsx src/lib/hotkeySideModifiers.test.ts",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs",
"check:windows-ui-config": "node scripts/windows-ui-config.test.mjs",
"check:pin-persistence-security": "node scripts/pin-persistence-security-contract.test.mjs",
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
},
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"

const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8")

const [pinModule, coordinator, command, appCargo, backendCargo, backendTests, ci] =
await Promise.all([
read("src-tauri/src/remote_server/pin_persistence.rs"),
read("src-tauri/src/coordinator.rs"),
read("src-tauri/src/commands/remote_input.rs"),
read("src-tauri/Cargo.toml"),
read("src-tauri/backend-tests/Cargo.toml"),
read("src-tauri/backend-tests/tests/backend_rust.rs"),
read("../../.github/workflows/ci.yml"),
])

for (const token of ["O_NOFOLLOW", "O_NONBLOCK", "O_CLOEXEC"]) {
assert.match(pinModule, new RegExp(`custom_flags\\([^)]*${token}`), `Unix open must use ${token}`)
}
assert.match(pinModule, /file\.metadata\(\)/, "validation must fstat the opened file")
assert.match(pinModule, /file\.set_permissions\(/, "permission repair must use the opened file")
assert.match(pinModule, /\.take\(MAX_PIN_FILE_BYTES \+ 1\)/, "reads must remain bounded after fstat")

for (const token of [
"FILE_FLAG_OPEN_REPARSE_POINT",
"GetFileInformationByHandle",
"GetFileType",
"FILE_ATTRIBUTE_REPARSE_POINT",
"nNumberOfLinks",
"ReplaceFileW",
"MoveFileExW",
]) {
assert.match(pinModule, new RegExp(token), `Windows implementation must use ${token}`)
}
assert.doesNotMatch(
pinModule,
/remove_file\(path\)/,
"replacement must never delete the destination before installing the new PIN",
)
assert.match(pinModule, /backup_path/, "Windows replacement must retain a rollback path")

for (const cargo of [appCargo, backendCargo]) {
assert.match(cargo, /"Win32_Storage_FileSystem"/, "Windows file APIs must be enabled")
}
assert.match(
backendTests,
/src\/remote_server\/pin_persistence\.rs/,
"the Rust-only Windows harness must execute PIN persistence tests",
)
assert.match(
ci,
/if: runner\.os == 'Windows'[\s\S]*cargo test --manifest-path src-tauri\/backend-tests\/Cargo\.toml/,
"Windows CI must execute the Rust-only PIN tests",
)

const assertCoordinatorContract = (source) => {
const regenerate = source.match(
/pub fn regenerate_remote_pin[\s\S]*?\r?\n }\r?\n\r?\n #\[cfg\(not\(mobile\)\)\]/,
)?.[0]
assert.ok(regenerate, "Coordinator regenerate implementation must be present")
assert.match(regenerate, /-> Result<String, String>/, "reset must surface persistence errors")
assert.match(regenerate, /persist_and_commit_remote_pin[\s\S]*save_pin[\s\S]*refresh_remote_server/, "reset must delegate persistence and state commit as one transaction")
const transaction = source.match(
/fn persist_and_commit_remote_pin[\s\S]*?\r?\n}\r?\n\r?\nimpl Coordinator/,
)?.[0]
assert.ok(transaction, "PIN reset transaction helper must be present")
assert.match(transaction, /persist\(&pin\)\?;[\s\S]*\*slot\.lock\(\) = Some\(pin\.clone\(\)\);[\s\S]*refresh\(\)/, "persist must succeed before memory commit and server refresh")
}

assertCoordinatorContract(coordinator)
assertCoordinatorContract(coordinator.replace(/\r?\n/g, "\r\n"))
assert.match(command, /regenerate_remote_pin[\s\S]*-> Result<String, String>/, "Tauri command must reject on reset failure")

console.log("PIN persistence security contract passed")
8 changes: 6 additions & 2 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ version = "3.6.3"
default-features = false
features = ["windows-native"]

[target.'cfg(unix)'.dependencies]
# PIN persistence uses O_NOFOLLOW/O_NONBLOCK/O_CLOEXEC; macOS qwen-asr also
# uses libc::free for strings allocated by the C runtime.
libc = "0.2"

[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9"
gtk = "0.18"
Expand All @@ -113,8 +118,6 @@ coreaudio-sys = "0.2"
objc2 = { version = "0.5", features = ["exception"] }
objc2-foundation = "0.2"
objc2-app-kit = "0.2"
# qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。
libc = "0.2"
# 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。
# 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是
# NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。
Expand All @@ -131,6 +134,7 @@ windows = { version = "0.58", features = [
"Win32_Graphics_Gdi",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Ole",
"Win32_System_Registry",
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/backend-tests/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/backend-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ arboard = "3"
cpal = "0.15"
enigo = "0.2"
global-hotkey = "0.6"
libc = "0.2"
log = "0.4"
once_cell = "1"
parking_lot = "0.12"
Expand All @@ -26,6 +27,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ mod hotkey;
#[cfg(not(target_os = "macos"))]
#[path = "../../src/insertion.rs"]
mod insertion;
#[path = "../../src/remote_server/pin_persistence.rs"]
mod pin_persistence;
#[path = "../../src/recorder.rs"]
mod recorder;
#[path = "../../src/shortcut_binding.rs"]
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/src/commands/remote_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub fn list_local_ips() -> Vec<String> {
}

#[tauri::command]
pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String {
pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result<String, String> {
coord.regenerate_remote_pin()
}

Expand Down
92 changes: 80 additions & 12 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,19 @@ impl Drop for CancellableRetranscribeGuard {
}
}

#[cfg(not(mobile))]
fn persist_and_commit_remote_pin(
slot: &Mutex<Option<String>>,
pin: String,
persist: impl FnOnce(&str) -> Result<(), String>,
refresh: impl FnOnce(),
) -> Result<String, String> {
persist(&pin)?;
*slot.lock() = Some(pin.clone());
refresh();
Ok(pin)
}

impl Coordinator {
pub fn new() -> Self {
#[cfg(target_os = "windows")]
Expand Down Expand Up @@ -1554,14 +1567,23 @@ impl Coordinator {
}

#[cfg(not(mobile))]
pub fn regenerate_remote_pin(self: &Arc<Self>) -> String {
pub fn regenerate_remote_pin(self: &Arc<Self>) -> Result<String, String> {
let pin = crate::remote_server::generate_pin();
*self.inner.remote_pin.lock() = Some(pin.clone());
if let Some(app) = self.inner.app.lock().clone() {
crate::remote_server::save_pin(&app, &pin);
}
self.refresh_remote_server();
pin
let app = self
.inner
.app
.lock()
.clone()
.ok_or_else(|| "OpenLess app handle is unavailable".to_string())?;
persist_and_commit_remote_pin(
&self.inner.remote_pin,
pin,
|pin| {
crate::remote_server::save_pin(&app, pin)
.map_err(|error| format!("persist pairing PIN failed: {error}"))
},
|| self.refresh_remote_server(),
)
}

#[cfg(not(mobile))]
Expand Down Expand Up @@ -1604,12 +1626,24 @@ impl Coordinator {
let Some(app) = app else {
return;
};
let pin = {
let mut guard = coord.inner.remote_pin.lock();
if guard.is_none() {
*guard = Some(crate::remote_server::load_or_create_pin(&app));
let pin = if let Some(pin) = coord.inner.remote_pin.lock().clone() {
pin
} else {
match crate::remote_server::load_or_create_pin(&app) {
Ok(pin) => {
*coord.inner.remote_pin.lock() = Some(pin.clone());
pin
}
Err(error) => {
let reason = format!("persist pairing PIN failed: {error}");
let _ = app.emit(
"remote-input:error",
serde_json::json!({"reason": reason, "port": prefs.remote_input_port}),
);
log::error!("[remote-input] {reason}");
return;
}
}
guard.clone().unwrap_or_default()
};
let port = prefs.remote_input_port;
match crate::remote_server::start(crate::remote_server::RemoteServerConfig {
Expand Down Expand Up @@ -2469,6 +2503,40 @@ mod tests {
Uuid::from_u128(n)
}

#[test]
fn failed_remote_pin_persistence_keeps_memory_and_server_state() {
let slot = Mutex::new(Some("123456".to_string()));
let refreshed = std::sync::atomic::AtomicBool::new(false);

let result = persist_and_commit_remote_pin(
&slot,
"654321".to_string(),
|_| Err("injected persistence failure".to_string()),
|| refreshed.store(true, Ordering::SeqCst),
);

assert_eq!(result.unwrap_err(), "injected persistence failure");
assert_eq!(slot.lock().as_deref(), Some("123456"));
assert!(!refreshed.load(Ordering::SeqCst));
}

#[test]
fn successful_remote_pin_persistence_commits_memory_before_refresh() {
let slot = Mutex::new(Some("123456".to_string()));
let observed = Mutex::new(None::<String>);

let result = persist_and_commit_remote_pin(
&slot,
"654321".to_string(),
|_| Ok(()),
|| *observed.lock() = slot.lock().clone(),
);

assert_eq!(result.as_deref(), Ok("654321"));
assert_eq!(slot.lock().as_deref(), Some("654321"));
assert_eq!(observed.lock().as_deref(), Some("654321"));
}

#[test]
fn split_polish_translate_parses_both_sections() {
let out = format!(
Expand Down
42 changes: 20 additions & 22 deletions openless-all/app/src-tauri/src/remote_server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,30 +131,28 @@ fn pin_path(app: &AppHandle) -> Option<std::path::PathBuf> {
.map(|d| d.join("remote-input-pin.txt"))
}

/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动
/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。
pub fn load_or_create_pin(app: &AppHandle) -> String {
if let Some(p) = pin_path(app) {
if let Ok(s) = std::fs::read_to_string(&p) {
let s = s.trim();
if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) {
return s.to_string();
}
}
}
let pin = generate_pin();
save_pin(app, &pin);
pin
mod pin_persistence;

/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定。
pub fn load_or_create_pin(app: &AppHandle) -> std::io::Result<String> {
let path = pin_path(app).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"OpenLess config directory is unavailable",
)
})?;
pin_persistence::load_or_create_pin_at_path(&path, generate_pin)
}

/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。
pub fn save_pin(app: &AppHandle, pin: &str) {
if let Some(p) = pin_path(app) {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&p, pin);
}
/// 原子写配对码到磁盘;只有成功后调用方才能提交内存状态。
pub fn save_pin(app: &AppHandle, pin: &str) -> std::io::Result<()> {
let path = pin_path(app).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"OpenLess config directory is unavailable",
)
})?;
pin_persistence::persist_pin_atomically(&path, pin)
}

fn is_private_lan(ip: &Ipv4Addr) -> bool {
Expand Down
Loading
Loading