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:
1 parent
6608f66b44
commit
639c2fbf9b
12 files changed
-1030
No files matched your search
Generated
-17
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user