chore: 删除未使用死文件与零引用依赖

前端:chat 桶文件、SegSimple、SwitchLite、androidMicrophonePermission 转发
shim、SelectionPolishSection 孤儿分区、autostart 环境类型声明;依赖移除
@formkit/auto-animate 与 @tauri-apps/plugin-autostart npm 包装层(Rust 插件
与 capability 保留,autostart 经原生 invoke 字符串调用)。
Rust:polish 旧副本两文件、android/types.rs、remote_server/lan_addresses.rs
(均为 Core 2.0 整合遗留,模块树未引用,编译行为不变)。
npm test 构建与全部测试通过;三 crate cargo fmt --check 干净。
This commit is contained in:
sim committed 2026-09-07 13:36:08 +08:00
1 parent 6608f66b44
commit 639c2fbf9b
12 files changed
-1030

No files matched your search

-17
View File
@@ -9,11 +9,9 @@
"version": "2.0.0-Beta.1",
"dependencies": {
"@base-ui/react": "^1.6.0",
"@formkit/auto-animate": "^0.9.0",
"@shadcn/react": "^0.2.0",
"@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
@@ -1226,12 +1224,6 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formkit/auto-animate": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz",
"integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==",
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -2223,15 +2215,6 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-autostart": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
-2
View File
@@ -34,11 +34,9 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@formkit/auto-animate": "^0.9.0",
"@shadcn/react": "^0.2.0",
"@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
@@ -1,380 +0,0 @@
//! Android-specific preference types and status payloads.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidInsertStrategy {
Auto,
Ime,
Accessibility,
Clipboard,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidOverlayTrigger {
Background,
Keyboard,
Always,
}
impl AndroidOverlayTrigger {
pub fn normalized(self) -> Self {
match self {
AndroidOverlayTrigger::Keyboard => AndroidOverlayTrigger::Background,
trigger => trigger,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AndroidOverlayActivationMode {
Tap,
LongPress,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AndroidOverlayLeftSwipeAction {
Translation,
StylePack,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AndroidOverlayCancelSwipeDirection {
Up,
Down,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidAccessibilityState {
Enabled,
NotEnabled,
NotAndroid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AndroidAccessibilityStatus {
pub state: AndroidAccessibilityState,
pub enabled: bool,
#[serde(default)]
pub operational: bool,
#[serde(default)]
pub message: String,
pub message_key: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidShizukuState {
NotInstalled,
NotRunning,
NotAuthorized,
Authorized,
BinderDead,
NotAndroid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AndroidAccessibilityDiagnosis {
pub registered: bool,
pub operational: bool,
#[serde(default)]
pub message: String,
pub message_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AndroidShizukuStatus {
pub state: AndroidShizukuState,
#[serde(default)]
pub message: String,
pub message_key: String,
pub accessibility: AndroidAccessibilityDiagnosis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_permission_message_key: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidAccessibilityRecoveryOutcome {
Success,
WriteRejected,
ServiceNotBound,
ShizukuUnavailable,
UserNotConfirmed,
ShellFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AndroidAccessibilityRecoveryResult {
pub outcome: AndroidAccessibilityRecoveryOutcome,
#[serde(default)]
pub message: String,
pub message_key: String,
}
pub fn default_android_insert_strategy() -> AndroidInsertStrategy {
AndroidInsertStrategy::Accessibility
}
pub fn default_android_overlay_trigger() -> AndroidOverlayTrigger {
AndroidOverlayTrigger::Background
}
pub fn default_android_overlay_activation_mode() -> AndroidOverlayActivationMode {
AndroidOverlayActivationMode::Tap
}
pub fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction {
AndroidOverlayLeftSwipeAction::Translation
}
pub fn default_android_overlay_cancel_swipe_direction() -> AndroidOverlayCancelSwipeDirection {
AndroidOverlayCancelSwipeDirection::Up
}
pub fn default_android_overlay_size_dp() -> u32 {
72
}
pub fn normalize_android_insert_strategy(strategy: AndroidInsertStrategy) -> AndroidInsertStrategy {
match strategy {
AndroidInsertStrategy::Auto | AndroidInsertStrategy::Ime => {
AndroidInsertStrategy::Accessibility
}
strategy => strategy,
}
}
pub fn normalize_android_overlay_size_dp(size_dp: u32) -> u32 {
size_dp.clamp(48, 120)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AndroidOverlaySettingsAction {
None,
RefreshLayout,
Transition {
from: AndroidOverlayTrigger,
to: AndroidOverlayTrigger,
},
}
pub fn classify_android_overlay_settings_change(
previous: &super::UserPreferences,
next: &super::UserPreferences,
) -> AndroidOverlaySettingsAction {
let trigger_changed =
previous.android_overlay_trigger.normalized() != next.android_overlay_trigger.normalized();
let size_changed = normalize_android_overlay_size_dp(previous.android_overlay_size_dp)
!= normalize_android_overlay_size_dp(next.android_overlay_size_dp);
if trigger_changed {
return AndroidOverlaySettingsAction::Transition {
from: previous.android_overlay_trigger.normalized(),
to: next.android_overlay_trigger.normalized(),
};
}
if size_changed {
return AndroidOverlaySettingsAction::RefreshLayout;
}
AndroidOverlaySettingsAction::None
}
#[cfg(test)]
mod android_overlay_tests {
use super::*;
use crate::types::UserPreferences;
fn overlay_prefs(
trigger: AndroidOverlayTrigger,
size_dp: u32,
activation: AndroidOverlayActivationMode,
) -> UserPreferences {
let mut prefs = UserPreferences::default();
prefs.android_overlay_trigger = trigger;
prefs.android_overlay_size_dp = size_dp;
prefs.android_overlay_activation_mode = activation;
prefs
}
#[test]
fn size_only_change_returns_refresh_layout() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
96,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::RefreshLayout,
);
}
#[test]
fn trigger_only_change_returns_transition() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Background,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::Transition {
from: AndroidOverlayTrigger::Background,
to: AndroidOverlayTrigger::Always,
},
);
}
#[test]
fn trigger_and_size_change_returns_transition_only() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Background,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
96,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::Transition {
from: AndroidOverlayTrigger::Background,
to: AndroidOverlayTrigger::Always,
},
);
}
#[test]
fn activation_only_change_returns_none() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::LongPress,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::None,
);
}
#[test]
fn out_of_bounds_size_200_to_120_returns_none_after_normalize() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Always,
200,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
120,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::None,
);
}
#[test]
fn out_of_bounds_size_below_min_normalizes_to_same_returns_none() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Always,
30,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
48,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::None,
);
}
#[test]
fn identical_normalized_size_returns_none() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::None,
);
}
#[test]
fn keyboard_trigger_normalizes_to_background_for_transition() {
let previous = overlay_prefs(
AndroidOverlayTrigger::Keyboard,
72,
AndroidOverlayActivationMode::Tap,
);
let next = overlay_prefs(
AndroidOverlayTrigger::Always,
72,
AndroidOverlayActivationMode::Tap,
);
assert_eq!(
classify_android_overlay_settings_change(&previous, &next),
AndroidOverlaySettingsAction::Transition {
from: AndroidOverlayTrigger::Background,
to: AndroidOverlayTrigger::Always,
},
);
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum AndroidOverlayPermissionState {
Granted,
NotGranted,
NotAndroid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AndroidOverlayStatus {
pub permission: AndroidOverlayPermissionState,
pub overlay_visible: bool,
pub message: String,
}
@@ -1,3 +0,0 @@
//! Compatibility re-export of shared LLM output sanitization.
pub(crate) use openless_core::output_cleaning::*;
@@ -1,7 +0,0 @@
//! Tauri compatibility re-exports for framework-independent prompt composition.
pub(crate) use openless_core::{
assemble_polish_system_prompt, build_hotword_block, compose_hotword_block_preview,
compose_polish_prompts, compose_qa_system_prompt, compose_system_prompt,
compose_translate_prompts, context_premise, PolishSystemPromptAssembly,
};
@@ -1,404 +0,0 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use uuid::Uuid;
const CACHE_FILE: &str = "remote-input-ips-v1.txt";
const MAX_CACHE_BYTES: u64 = 1024;
const MAX_CACHE_IPS: usize = 32;
const ENUMERATION_TIMEOUT: Duration = Duration::from_secs(1);
const ROUTE_DESTINATIONS: [&str; 4] = [
"8.8.8.8:80",
"1.1.1.1:80",
"192.168.8.1:80",
"192.168.1.1:80",
];
static ENUMERATION_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LanAddressSnapshot {
pub(crate) ips: Vec<Ipv4Addr>,
pub(crate) stale: bool,
}
pub(crate) fn normalize_lan_ips<I>(ips: I) -> Vec<Ipv4Addr>
where
I: IntoIterator<Item = Ipv4Addr>,
{
let mut normalized = ips.into_iter().filter(is_private_lan).collect::<Vec<_>>();
normalized.sort_unstable();
normalized.dedup();
normalized.truncate(MAX_CACHE_IPS);
normalized
}
pub(crate) fn discover_lan_addresses(config_dir: Option<&Path>) -> LanAddressSnapshot {
let live_ips = enumerate_interfaces_with_timeout();
if let Some(ips) = live_ips.as_ref().filter(|ips| !ips.is_empty()) {
if let Some(dir) = config_dir {
if let Err(error) = persist_cached_ips(dir, ips) {
log::warn!("[remote-input] persist LAN IP cache failed: {error}");
}
}
}
let cached_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty()) {
None
} else {
config_dir.and_then(load_cached_ips)
};
let route_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty())
|| cached_ips.as_ref().is_some_and(|ips| !ips.is_empty())
{
Vec::new()
} else {
local_lan_ipv4s_from_route()
};
let snapshot = choose_snapshot(live_ips, cached_ips, route_ips);
if snapshot.stale {
log::info!("[remote-input] using cached LAN IPs after interface discovery timeout");
}
snapshot
}
fn choose_snapshot(
live_ips: Option<Vec<Ipv4Addr>>,
cached_ips: Option<Vec<Ipv4Addr>>,
route_ips: Vec<Ipv4Addr>,
) -> LanAddressSnapshot {
if let Some(ips) = live_ips.filter(|ips| !ips.is_empty()) {
return LanAddressSnapshot { ips, stale: false };
}
if let Some(ips) = cached_ips.filter(|ips| !ips.is_empty()) {
return LanAddressSnapshot { ips, stale: true };
}
LanAddressSnapshot {
ips: normalize_lan_ips(route_ips),
stale: false,
}
}
fn is_private_lan(ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
!ip.is_loopback()
&& !ip.is_link_local()
&& ((octets[0] == 192 && octets[1] == 168)
|| octets[0] == 10
|| (octets[0] == 172 && (16..=31).contains(&octets[1])))
}
fn enumerate_interfaces_with_timeout() -> Option<Vec<Ipv4Addr>> {
if ENUMERATION_IN_FLIGHT
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return None;
}
let (tx, rx) = mpsc::sync_channel(1);
let worker = thread::Builder::new()
.name("openless-lan-addresses".to_string())
.spawn(move || {
struct ResetInFlight;
impl Drop for ResetInFlight {
fn drop(&mut self) {
ENUMERATION_IN_FLIGHT.store(false, Ordering::Release);
}
}
let _reset = ResetInFlight;
let result = enumerate_interfaces();
let _ = tx.send(result);
});
if worker.is_err() {
ENUMERATION_IN_FLIGHT.store(false, Ordering::Release);
return None;
}
match rx.recv_timeout(ENUMERATION_TIMEOUT) {
Ok(Ok(ips)) if !ips.is_empty() => Some(ips),
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => None,
}
}
fn enumerate_interfaces() -> Result<Vec<Ipv4Addr>, String> {
let ifaces = local_ip_address::list_afinet_netifas().map_err(|error| error.to_string())?;
let ips = ifaces.into_iter().filter_map(|(_, ip)| match ip {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
});
Ok(normalize_lan_ips(ips))
}
fn local_lan_ipv4s_from_route() -> Vec<Ipv4Addr> {
let mut ips = Vec::new();
for destination in ROUTE_DESTINATIONS {
let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else {
continue;
};
let _ = socket.set_write_timeout(Some(Duration::from_millis(200)));
if socket.connect(destination).is_err() {
continue;
}
if let Ok(SocketAddr::V4(address)) = socket.local_addr() {
ips.push(*address.ip());
}
}
normalize_lan_ips(ips)
}
fn cache_path(config_dir: &Path) -> std::path::PathBuf {
config_dir.join(CACHE_FILE)
}
fn load_cached_ips(config_dir: &Path) -> Option<Vec<Ipv4Addr>> {
let path = cache_path(config_dir);
let mut file = File::open(path).ok()?;
let mut contents = Vec::new();
std::io::Read::by_ref(&mut file)
.take(MAX_CACHE_BYTES + 1)
.read_to_end(&mut contents)
.ok()?;
if contents.len() as u64 > MAX_CACHE_BYTES {
return None;
}
parse_cached_ips(&contents).ok()
}
fn parse_cached_ips(contents: &[u8]) -> Result<Vec<Ipv4Addr>, String> {
let text = std::str::from_utf8(contents).map_err(|_| "cache is not UTF-8".to_string())?;
let mut ips = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
return Err("cache contains an empty line".to_string());
}
if ips.len() >= MAX_CACHE_IPS {
return Err("cache contains too many addresses".to_string());
}
let ip = line
.trim()
.parse::<Ipv4Addr>()
.map_err(|_| "cache contains an invalid IPv4 address".to_string())?;
if !is_private_lan(&ip) {
return Err("cache contains a non-private IPv4 address".to_string());
}
ips.push(ip);
}
let normalized = normalize_lan_ips(ips);
if normalized.is_empty() {
return Err("cache contains no LAN addresses".to_string());
}
Ok(normalized)
}
fn persist_cached_ips(config_dir: &Path, ips: &[Ipv4Addr]) -> io::Result<()> {
fs::create_dir_all(config_dir)?;
let path = cache_path(config_dir);
let temp_path = path.with_file_name(format!(".{CACHE_FILE}.tmp-{}", Uuid::new_v4().simple()));
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temp_path)?;
for ip in ips {
writeln!(file, "{ip}")?;
}
file.sync_all()?;
drop(file);
replace_cache_file(&temp_path, &path)
})();
if result.is_err() {
let _ = fs::remove_file(&temp_path);
}
result
}
#[cfg(unix)]
fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> {
fs::rename(temp_path, path)
}
#[cfg(target_os = "windows")]
fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> {
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::{
MoveFileExW, ReplaceFileW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
REPLACE_FILE_FLAGS,
};
let replacement = windows_wide_path(temp_path);
let destination = windows_wide_path(path);
if path.exists() {
let backup =
path.with_file_name(format!(".{CACHE_FILE}.backup-{}", Uuid::new_v4().simple()));
let backup_wide = windows_wide_path(&backup);
let replaced = unsafe {
ReplaceFileW(
PCWSTR(destination.as_ptr()),
PCWSTR(replacement.as_ptr()),
PCWSTR(backup_wide.as_ptr()),
REPLACE_FILE_FLAGS(0),
None,
None,
)
};
replaced.map_err(windows_io_error)?;
let _ = fs::remove_file(backup);
Ok(())
} else {
unsafe {
MoveFileExW(
PCWSTR(replacement.as_ptr()),
PCWSTR(destination.as_ptr()),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
}
.map_err(windows_io_error)
}
}
#[cfg(not(any(unix, target_os = "windows")))]
fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> {
fs::rename(temp_path, path)
}
#[cfg(target_os = "windows")]
fn windows_wide_path(path: &Path) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().chain(Some(0)).collect()
}
#[cfg(target_os = "windows")]
fn windows_io_error(error: windows::core::Error) -> io::Error {
io::Error::new(io::ErrorKind::Other, error)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_lan_ips_filters_private_addresses_and_deduplicates() {
let actual = normalize_lan_ips([
Ipv4Addr::new(192, 168, 1, 20),
Ipv4Addr::new(8, 8, 8, 8),
Ipv4Addr::new(10, 0, 0, 2),
Ipv4Addr::new(192, 168, 1, 20),
Ipv4Addr::new(172, 16, 4, 9),
Ipv4Addr::new(127, 0, 0, 1),
]);
assert_eq!(
actual,
vec![
Ipv4Addr::new(10, 0, 0, 2),
Ipv4Addr::new(172, 16, 4, 9),
Ipv4Addr::new(192, 168, 1, 20),
]
);
}
#[test]
fn parse_cached_ips_rejects_public_or_malformed_entries() {
assert!(parse_cached_ips(b"8.8.8.8\n").is_err());
assert!(parse_cached_ips(b"not-an-ip\n").is_err());
assert!(parse_cached_ips(b"10.0.0.2\n\n10.0.0.3\n").is_err());
}
#[test]
fn parse_cached_ips_accepts_bounded_private_entries() {
assert_eq!(
parse_cached_ips(b"192.168.1.20\n10.0.0.2\n192.168.1.20\n").unwrap(),
vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)]
);
}
#[test]
fn choose_snapshot_prefers_live_then_cache_then_route() {
let live = vec![Ipv4Addr::new(10, 0, 0, 2)];
let cached = vec![Ipv4Addr::new(192, 168, 1, 20)];
let route = vec![Ipv4Addr::new(172, 16, 0, 3)];
assert_eq!(
choose_snapshot(Some(live.clone()), Some(cached.clone()), route.clone()),
LanAddressSnapshot {
ips: live,
stale: false,
}
);
assert_eq!(
choose_snapshot(None, Some(cached.clone()), route.clone()),
LanAddressSnapshot {
ips: cached,
stale: true,
}
);
assert_eq!(
choose_snapshot(None, None, route.clone()),
LanAddressSnapshot {
ips: route,
stale: false,
}
);
assert_eq!(
choose_snapshot(None, None, Vec::new()),
LanAddressSnapshot {
ips: Vec::new(),
stale: false,
}
);
}
#[test]
fn cache_rejects_more_than_the_maximum_number_of_entries() {
let contents = (0..=MAX_CACHE_IPS)
.map(|_| "10.0.0.2")
.collect::<Vec<_>>()
.join("\n");
assert!(parse_cached_ips(contents.as_bytes()).is_err());
}
#[test]
fn oversized_cache_file_is_rejected_before_parsing() {
let directory = std::env::temp_dir().join(format!(
"openless-lan-addresses-oversized-{}",
Uuid::new_v4().simple()
));
fs::create_dir_all(&directory).unwrap();
fs::write(
cache_path(&directory),
vec![b'1'; (MAX_CACHE_BYTES + 1) as usize],
)
.unwrap();
assert_eq!(load_cached_ips(&directory), None);
let _ = fs::remove_dir_all(directory);
}
#[test]
fn cached_ips_round_trip_through_atomic_file() {
let directory = std::env::temp_dir().join(format!(
"openless-lan-addresses-test-{}",
Uuid::new_v4().simple()
));
let ips = vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)];
persist_cached_ips(&directory, &ips).unwrap();
assert_eq!(load_cached_ips(&directory), Some(ips));
let _ = fs::remove_dir_all(directory);
}
}
@@ -1,64 +0,0 @@
// 统一聊天面板组件库出口。
//
// 官方 shadcn base-rhea 组件(ui/ 目录源码按官方 Manual 安装 1:1 内嵌,行为来自
// @shadcn/react;两个面板 = 官方 message-scroller-demo 同款骨架):
// · 骨架 Card* / Empty* / InputGroup* —— 对话框壳、空状态、输入组。
// · 滚动框 MessageScroller* —— 两个面板共用的「扫描框」。
// · 消息 Message*/Bubble* —— 划词追问(QA)的对话形态(带头像)。
// · 标记 Marker* —— Less Computer 的工具调用 / 上下文压缩标签形态(无头像)。
// 本仓库组装层:ThinkingOrb/OrbAvatar(胶囊同款思考动画)、UserAvatar(GitHub)、
// AssistantMarkdown(流式 markdown)、useChatPanelLifecycle(出现/消失动画信号)。
export { Button, buttonVariants } from './ui/button';
export { Input } from './ui/input';
export { Textarea } from './ui/textarea';
export { Spinner } from './ui/spinner';
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './ui/card';
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from './ui/empty';
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from './ui/input-group';
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from './ui/message-scroller';
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from './ui/message';
export { Bubble, BubbleContent, BubbleGroup } from './ui/bubble';
export { Marker, MarkerContent, MarkerIcon, markerVariants } from './ui/marker';
export { ThinkingOrb, OrbAvatar, UserAvatar, useGithubLogin } from './avatars';
export { AssistantMarkdown } from './markdown';
export { useChatPanelLifecycle } from './lifecycle';
@@ -1,33 +0,0 @@
// SegSimple — segmented control used in the Settings modal sub-sections.
import { useState } from 'react';
import { segmentedTrackStyle } from '../../pages/settings/shared';
interface SegSimpleProps {
options: string[];
active: string;
}
export function SegSimple({ options, active }: SegSimpleProps) {
const [v, setV] = useState(active);
return (
<div style={segmentedTrackStyle}>
{options.map((o) => (
<button
key={o}
onClick={() => setV(o)}
style={{
padding: '5px 12px', fontSize: 12, fontWeight: 500, border: 0, borderRadius: 6,
fontFamily: 'inherit',
background: v === o ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: v === o ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: v === o ? 'var(--ol-segmented-active-shadow)' : 'none',
cursor: 'default',
}}
>
{o}
</button>
))}
</div>
);
}
@@ -1,32 +0,0 @@
// SwitchLite — small toggle used in the Settings modal sub-sections.
import { useState } from 'react';
interface SwitchLiteProps {
on?: boolean;
}
export function SwitchLite({ on: initial = false }: SwitchLiteProps) {
const [on, setOn] = useState(initial);
return (
<button
type="button"
className="ol-focus-ring"
onClick={() => setOn(!on)}
style={{
position: 'relative', width: 32, height: 18, borderRadius: 999, border: 0,
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
cursor: 'default',
outline: 'none',
}}
>
<span
style={{
position: 'absolute', top: 2, left: on ? 16 : 2,
width: 14, height: 14, borderRadius: 999, background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.25)', transition: 'left .16s var(--ol-motion-spring)',
}}
/>
</button>
);
}
@@ -1 +0,0 @@
export * from '@android/lib/androidMicrophonePermission';
@@ -1,82 +0,0 @@
// 通用 → 选区润色:这是与录音输入并列的独立入口,快捷键、交付方式均不再藏在快捷键列表中。
import type { PlatformCapabilities, SelectionPolishOutputMode } from '../../lib/types';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { defaultSelectionPolishShortcut } from '../../lib/hotkey';
import { setSelectionPolishHotkey } from '../../lib/ipc';
import { getPlatformCapabilities } from '../../lib/platform';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SectionTitle, SettingRow, chipSelectedStyle, segmentedTrackStyle } from './shared';
const outputOptions: Array<{ value: SelectionPolishOutputMode }> = [
{ value: 'directReplace' },
{ value: 'previewConfirm' },
];
export function SelectionPolishSection() {
const { t } = useTranslation();
const { prefs, capability, refresh, updatePrefs } = useHotkeySettings();
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); }, []);
// 选区润色的安全替换:Windows 用前台窗口/焦点控件校验,macOS 用前台应用 +
// 选区文本指纹校验;两者都具备后才提供设置入口(Linux 热键接入后同样可用)。
if (!prefs || !capability || !platformCaps?.supportsDesktopHotkey) return null;
return (
<Card>
<SectionTitle hint={t('settings.selectionPolish.hint')}>
{t('settings.selectionPolish.title', '选区润色')}
</SectionTitle>
<SettingRow
label={t('settings.selectionPolish.hotkey', '触发快捷键')}
desc={t('settings.selectionPolish.hotkeyDesc')}
>
<ShortcutRecorder
value={prefs.selectionPolishHotkey}
// 注意:不能开 sideSpecificModifiers —— 后端 set_selection_polish_hotkey 用
// reject_side_specific_non_dictation 拒绝非听写功能的侧键修饰(side-aware hook
// 仅听写支持多 owner),开启会把录制出的 ctrl-left/alt-right 等标签全部打回,
// 前端 catch 后误报「该快捷键组合不可用」(上游 #851 引入的自相矛盾)。
onSave={async binding => {
await setSelectionPolishHotkey(binding);
await refresh();
}}
onDisable={async () => {
await setSelectionPolishHotkey(null);
await refresh();
}}
// 「重置」= 恢复默认快捷键,等价于旧版独立「启用」按钮。
onReset={async () => {
await setSelectionPolishHotkey(defaultSelectionPolishShortcut());
await refresh();
}}
/>
</SettingRow>
<SettingRow label={t('settings.selectionPolish.delivery', '结果处理方式')}>
<div style={{ ...segmentedTrackStyle, flexWrap: 'wrap', gap: 4 }}>
{outputOptions.map(option => {
const selected = prefs.selectionPolishOutputMode === option.value;
return (
<button
key={option.value}
title={t(`settings.selectionPolish.${option.value}Hint`)}
onClick={() => void updatePrefs(current => ({ ...current, selectionPolishOutputMode: option.value }))}
style={{
...chipSelectedStyle(selected), border: 0, borderRadius: 6, padding: '6px 10px',
fontFamily: 'inherit', fontSize: 12, cursor: 'default', fontWeight: selected ? 600 : 500,
}}
>
{t(`settings.selectionPolish.${option.value}`)}
</button>
);
})}
</div>
</SettingRow>
</Card>
);
}
@@ -1,5 +0,0 @@
declare module '@tauri-apps/plugin-autostart' {
export function enable(): Promise<void>;
export function disable(): Promise<void>;
export function isEnabled(): Promise<boolean>;
}