From 755f439a50ae7ae156643fd86552e07e0c957aaa Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Fri, 18 Sep 2026 17:22:05 +0800 Subject: [PATCH 1/8] chore: prepare v2.4.1 release --- Cargo.lock | 10 +-- Cargo.toml | 2 +- app/package-lock.json | 4 +- app/package.json | 2 +- app/src-tauri/src/commands.rs | 16 ++++- app/src-tauri/src/main.rs | 7 ++- app/src/i18n.js | 4 ++ app/src/settings.html | 11 ++++ app/src/settings.js | 20 +++++- config/default.toml | 1 + crates/familiar-cli/src/main.rs | 6 +- crates/familiar-core/src/config.rs | 8 +++ crates/familiar-core/src/event.rs | 4 ++ crates/familiar-core/src/state_machine.rs | 77 ++++++++++++++++++++--- crates/familiar-core/tests/config.rs | 16 +++++ crates/familiar-hooks/src/adapter.rs | 44 ++++++++++++- docs/releases/v2.4.1.md | 77 +++++++++++++++++++++++ 17 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 docs/releases/v2.4.1.md diff --git a/Cargo.lock b/Cargo.lock index 6c9efc8..c1ec245 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,7 +1370,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "familiar-api" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "axum", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "familiar-app" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "axum", @@ -1416,7 +1416,7 @@ dependencies = [ [[package]] name = "familiar-cli" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "axum", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "familiar-core" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "chrono", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "familiar-hooks" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5c8a771..5eb88d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "2.4.0" +version = "2.4.1" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/app/package-lock.json b/app/package-lock.json index 827ef9d..0d6a56a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "familiar-app-ui", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "familiar-app-ui", - "version": "2.4.0", + "version": "2.4.1", "license": "MIT OR Apache-2.0", "devDependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/app/package.json b/app/package.json index f3bdc82..0919f40 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "familiar-app-ui", - "version": "2.4.0", + "version": "2.4.1", "private": true, "license": "MIT OR Apache-2.0", "type": "module", diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 2c61ed5..7871833 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -22,20 +22,30 @@ pub struct AppConfigState { /// Shared with `StateMachine` so event→status mapping changes take effect /// immediately on save (mirrors how `hidden_sessions` is kept in sync). pub event_status_map: Arc>, + pub idle_session_timeout_secs: Arc, pub revision: AtomicU64, } impl AppConfigState { - pub fn new(config: FamiliarConfig, event_status_map: Arc>) -> Self { + pub fn new( + config: FamiliarConfig, + event_status_map: Arc>, + idle_session_timeout_secs: Arc, + ) -> Self { let hidden_sessions = config.sessions.hidden_sessions.iter().cloned().collect(); let map = config.renderer.desktop_pet.event_status_agent_map(); if let Ok(mut guard) = event_status_map.write() { *guard = map; } + idle_session_timeout_secs.store( + u64::from(config.renderer.desktop_pet.idle_session_timeout_secs), + Ordering::SeqCst, + ); Self { config: RwLock::new(config), hidden_sessions: RwLock::new(hidden_sessions), event_status_map, + idle_session_timeout_secs, revision: AtomicU64::new(1), } } @@ -55,6 +65,10 @@ impl AppConfigState { if let Ok(mut guard) = self.event_status_map.write() { *guard = new_map; } + self.idle_session_timeout_secs.store( + u64::from(new_config.renderer.desktop_pet.idle_session_timeout_secs), + Ordering::SeqCst, + ); *self.config.write().unwrap() = new_config; *self.hidden_sessions.write().unwrap() = new_hidden; self.revision.fetch_add(1, Ordering::SeqCst); diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index dbfff00..b0332fb 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -132,15 +132,20 @@ fn main() { let event_bus = EventBus::new(100, 100); let config = load_config(); let event_status_map = Arc::new(std::sync::RwLock::new(EventStatusMap::new())); + let idle_session_timeout_secs = Arc::new(std::sync::atomic::AtomicU64::new(u64::from( + config.renderer.desktop_pet.idle_session_timeout_secs, + ))); let app_config_state = Arc::new(AppConfigState::new( config.clone(), event_status_map.clone(), + idle_session_timeout_secs.clone(), )); - let state_machine = StateMachine::with_event_map( + let state_machine = StateMachine::with_event_map_and_idle_timeout( event_bus.clone(), config.renderer.desktop_pet.celebration_secs, config.renderer.desktop_pet.sleep_timeout_secs, event_status_map.clone(), + idle_session_timeout_secs, ); let event_bus_for_server = event_bus.clone(); let config_for_setup = config.clone(); diff --git a/app/src/i18n.js b/app/src/i18n.js index e96dbc9..9bc6ab1 100644 --- a/app/src/i18n.js +++ b/app/src/i18n.js @@ -162,6 +162,8 @@ export const translations = { 'desc_celebration_secs': '任务完成后庆祝动画持续的秒数(1 - 15)', 'lbl_sleep_timeout_secs': '休眠超时时长', 'desc_sleep_timeout_secs': '待命状态下无事件输入自动进入休眠的秒数(10 - 3600)', + 'lbl_idle_session_timeout_secs': '闲置会话清理时长', + 'desc_idle_session_timeout_secs': '会话持续闲置多久后自动移除气泡(60 - 3600 秒)', 'group_pet_pack': '素材包管理', 'desc_pet_pack': '管理与切换桌面宠物显示的素材包,支持从外部导入 .fpack 格式的素材包', @@ -482,6 +484,8 @@ export const translations = { 'desc_celebration_secs': 'How long (in seconds) the celebration animation plays after a task completes (1 - 15)', 'lbl_sleep_timeout_secs': 'Sleep Inactivity Timeout', 'desc_sleep_timeout_secs': 'Seconds of inactivity in Idle state before automatically entering Sleepy state (10 - 3600)', + 'lbl_idle_session_timeout_secs': 'Idle Session Cleanup', + 'desc_idle_session_timeout_secs': 'Remove a session bubble after it remains Idle for this many seconds (60 - 3600)', 'group_pet_pack': 'Sprite Pack Management', 'desc_pet_pack': 'Manage and switch desktop pet sprite packs. Import .fpack format sprite packs from external sources', diff --git a/app/src/settings.html b/app/src/settings.html index 9d96e46..d196497 100644 --- a/app/src/settings.html +++ b/app/src/settings.html @@ -194,6 +194,17 @@

宠物

+
+
+
闲置会话清理时长
+
会话持续闲置多久后自动移除气泡(60 - 3600 秒)
+
+
+ + 600s +
+
+
桌宠缩放比例
diff --git a/app/src/settings.js b/app/src/settings.js index 2bc1162..80e9c9e 100644 --- a/app/src/settings.js +++ b/app/src/settings.js @@ -115,6 +115,8 @@ document.addEventListener('DOMContentLoaded', async () => { const valCelebrationSecs = document.getElementById('val-celebration-secs'); const elSleepTimeoutSecs = document.getElementById('setting-sleep-timeout-secs'); const valSleepTimeoutSecs = document.getElementById('val-sleep-timeout-secs'); + const elIdleSessionTimeoutSecs = document.getElementById('setting-idle-session-timeout-secs'); + const valIdleSessionTimeoutSecs = document.getElementById('val-idle-session-timeout-secs'); const saveBtn = document.getElementById('save-btn'); const sessionListContainer = document.getElementById('session-list-container'); @@ -416,6 +418,13 @@ document.addEventListener('DOMContentLoaded', async () => { elSleepTimeoutSecs.addEventListener('input', updateSleepVal); updateSleepVal(); } + if (elIdleSessionTimeoutSecs && valIdleSessionTimeoutSecs) { + const updateIdleSessionVal = () => { + valIdleSessionTimeoutSecs.textContent = elIdleSessionTimeoutSecs.value + 's'; + }; + elIdleSessionTimeoutSecs.addEventListener('input', updateIdleSessionVal); + updateIdleSessionVal(); + } // --- Auto-Save Logic --- @@ -496,6 +505,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (elSleepTimeoutSecs) { currentConfig.renderer['desktop-pet'].sleep_timeout_secs = parseInt(elSleepTimeoutSecs.value, 10); } + if (elIdleSessionTimeoutSecs) { + currentConfig.renderer['desktop-pet'].idle_session_timeout_secs = parseInt(elIdleSessionTimeoutSecs.value, 10); + } // Drop an empty mapping so no empty `[..event_status_map]` section is // written to the TOML file on save. if (currentConfig.renderer['desktop-pet'].event_status_map && @@ -700,6 +712,12 @@ document.addEventListener('DOMContentLoaded', async () => { elSleepTimeoutSecs.value = petConf.sleep_timeout_secs; if (valSleepTimeoutSecs) valSleepTimeoutSecs.textContent = petConf.sleep_timeout_secs + 's'; } + if (petConf.idle_session_timeout_secs !== undefined && elIdleSessionTimeoutSecs) { + elIdleSessionTimeoutSecs.value = petConf.idle_session_timeout_secs; + if (valIdleSessionTimeoutSecs) { + valIdleSessionTimeoutSecs.textContent = petConf.idle_session_timeout_secs + 's'; + } + } } renderEventStatusTable(); @@ -1163,7 +1181,7 @@ document.addEventListener('DOMContentLoaded', async () => { const autoSaveInputs = [ elRemoteEndpoint, elRemotePath, elRemoteToken, elRemoteConnectTimeout, elRemoteReconnectInitial, elRemoteReconnectMax, - elApiPort, elPetScale, elPetOpacity, elPetHoverOpacity, elUdsPath, elTcpPort, elCelebrationSecs, elSleepTimeoutSecs, + elApiPort, elPetScale, elPetOpacity, elPetHoverOpacity, elUdsPath, elTcpPort, elCelebrationSecs, elSleepTimeoutSecs, elIdleSessionTimeoutSecs, elCleanupAgeDays ]; autoSaveInputs.forEach(el => { diff --git a/config/default.toml b/config/default.toml index d915d91..8381dbc 100644 --- a/config/default.toml +++ b/config/default.toml @@ -55,6 +55,7 @@ dashboard_layout = "vertical" dashboard_alignment = "bottom" celebration_secs = 10 sleep_timeout_secs = 300 +idle_session_timeout_secs = 600 # Optional event → pet status overrides. Keys are AgentEventType kinds # (AgentStarted, Thinking, Processing, ReadingFile, WritingFile, diff --git a/crates/familiar-cli/src/main.rs b/crates/familiar-cli/src/main.rs index e834f68..2736eb8 100644 --- a/crates/familiar-cli/src/main.rs +++ b/crates/familiar-cli/src/main.rs @@ -100,13 +100,17 @@ async fn run_server(config_path: Option<&Path>, bind_override: Option<&str>) -> let config = load_config(config_path)?; let event_bus = EventBus::new(100, 100); let ingest_bus = event_bus.clone(); - let state_machine = StateMachine::with_event_map( + let idle_session_timeout_secs = std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + u64::from(config.renderer.desktop_pet.idle_session_timeout_secs), + )); + let state_machine = StateMachine::with_event_map_and_idle_timeout( event_bus, config.renderer.desktop_pet.celebration_secs, config.renderer.desktop_pet.sleep_timeout_secs, std::sync::Arc::new(std::sync::RwLock::new( config.renderer.desktop_pet.event_status_agent_map(), )), + idle_session_timeout_secs, ); state_machine.start_processing().await; diff --git a/crates/familiar-core/src/config.rs b/crates/familiar-core/src/config.rs index 5ba4d97..13aa3be 100644 --- a/crates/familiar-core/src/config.rs +++ b/crates/familiar-core/src/config.rs @@ -238,6 +238,9 @@ pub struct DesktopPetConfig { pub celebration_secs: u32, #[serde(default = "default_sleep_timeout_secs")] pub sleep_timeout_secs: u32, + /// Remove user-visible sessions that remain Idle without another event. + #[serde(default = "default_idle_session_timeout_secs")] + pub idle_session_timeout_secs: u32, #[serde(default)] pub event_status_map: BTreeMap, } @@ -355,6 +358,10 @@ fn default_sleep_timeout_secs() -> u32 { 300 } +fn default_idle_session_timeout_secs() -> u32 { + 600 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MenuBarConfig { pub show_active_count: bool, @@ -492,6 +499,7 @@ impl Default for FamiliarConfig { dashboard_alignment: DashboardAlignment::Bottom, celebration_secs: 4, sleep_timeout_secs: 300, + idle_session_timeout_secs: 600, event_status_map: BTreeMap::new(), }, menu_bar: MenuBarConfig { diff --git a/crates/familiar-core/src/event.rs b/crates/familiar-core/src/event.rs index 82d4408..0559b7c 100644 --- a/crates/familiar-core/src/event.rs +++ b/crates/familiar-core/src/event.rs @@ -24,6 +24,9 @@ pub enum AgentCategory { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AgentEventType { + /// An upstream lifecycle event that is intentionally excluded from the + /// user-visible state machine (for example, a known internal prompt). + Ignored, // Lifecycle /// Emitted when an agent opens a session. This is intentionally not a /// visible activity: the user-facing lifecycle starts at their prompt. @@ -82,6 +85,7 @@ impl AgentEventType { /// and must not be formatted into persistent operational logs. pub fn kind(&self) -> &'static str { match self { + Self::Ignored => "Ignored", Self::SessionStarted => "SessionStarted", Self::AgentStarted { .. } => "AgentStarted", Self::AgentStopped => "AgentStopped", diff --git a/crates/familiar-core/src/state_machine.rs b/crates/familiar-core/src/state_machine.rs index c2b5deb..77d0391 100644 --- a/crates/familiar-core/src/state_machine.rs +++ b/crates/familiar-core/src/state_machine.rs @@ -12,6 +12,7 @@ pub struct StateMachine { event_bus: EventBus, celebration_secs: i64, sleep_timeout_secs: i64, + idle_session_timeout_secs: Arc, event_status_map: Arc>, revision: Arc, state_updates: watch::Sender>, @@ -19,11 +20,12 @@ pub struct StateMachine { impl StateMachine { pub fn new(event_bus: EventBus, celebration_secs: u32, sleep_timeout_secs: u32) -> Self { - Self::with_event_map( + Self::with_event_map_and_idle_timeout( event_bus, celebration_secs, sleep_timeout_secs, Arc::new(std::sync::RwLock::new(EventStatusMap::new())), + Arc::new(AtomicU64::new(600)), ) } @@ -35,6 +37,22 @@ impl StateMachine { celebration_secs: u32, sleep_timeout_secs: u32, event_status_map: Arc>, + ) -> Self { + Self::with_event_map_and_idle_timeout( + event_bus, + celebration_secs, + sleep_timeout_secs, + event_status_map, + Arc::new(AtomicU64::new(600)), + ) + } + + pub fn with_event_map_and_idle_timeout( + event_bus: EventBus, + celebration_secs: u32, + sleep_timeout_secs: u32, + event_status_map: Arc>, + idle_session_timeout: Arc, ) -> Self { let (state_updates, _) = watch::channel(Arc::new(RenderState::default())); Self { @@ -42,6 +60,7 @@ impl StateMachine { event_bus, celebration_secs: celebration_secs as i64, sleep_timeout_secs: sleep_timeout_secs as i64, + idle_session_timeout_secs: idle_session_timeout, event_status_map, revision: Arc::new(AtomicU64::new(1)), state_updates, @@ -103,6 +122,7 @@ impl StateMachine { let state_ref_cleanup = self.render_state.clone(); let celebration_secs = self.celebration_secs; let sleep_timeout_secs = self.sleep_timeout_secs; + let idle_session_timeout_secs = self.idle_session_timeout_secs.clone(); let revision_ref_cleanup = self.revision.clone(); let state_updates_cleanup = self.state_updates.clone(); tokio::spawn(async move { @@ -114,14 +134,19 @@ impl StateMachine { let len_before = state.agents.len(); let mood_before = state.mood.clone(); - // Handle cleanup of completed agents + // Handle cleanup of completed agents and stale Idle sessions. state.agents.retain(|agent| { - if agent.status == AgentStatus::Completed { - if let Some(last) = agent.last_event_at { - if now.signed_duration_since(last).num_seconds() > celebration_secs { - return false; // Remove if completed for > celebration_secs seconds - } - } + let Some(last) = agent.last_event_at else { + return true; + }; + let inactive_secs = now.signed_duration_since(last).num_seconds(); + if agent.status == AgentStatus::Completed && inactive_secs > celebration_secs { + return false; + } + if agent.status == AgentStatus::Idle + && inactive_secs >= idle_session_timeout_secs.load(Ordering::SeqCst) as i64 + { + return false; } true }); @@ -169,7 +194,10 @@ impl StateMachine { // SessionStart only establishes an upstream session. Do not create a // visible Agent or refresh activity here; the user-facing lifecycle // begins when UserPromptSubmit becomes AgentStarted. - if matches!(event.event_type, AgentEventType::SessionStarted) { + if matches!( + event.event_type, + AgentEventType::Ignored | AgentEventType::SessionStarted + ) { return; } @@ -592,6 +620,37 @@ mod tests { assert_eq!(state.mood, FamiliarMood::Sleepy); } + #[tokio::test] + async fn test_idle_session_is_removed_after_configured_timeout() { + let bus = EventBus::new(100, 1000); + let idle_timeout = Arc::new(AtomicU64::new(1)); + let machine = StateMachine::with_event_map_and_idle_timeout( + bus.clone(), + 4, + 300, + Arc::new(std::sync::RwLock::new(EventStatusMap::new())), + idle_timeout, + ); + machine.start_processing().await; + + bus.publish(AgentEvent { + session_id: Some("stale-idle-session".into()), + id: Uuid::new_v4(), + timestamp: chrono::Utc::now(), + source: AgentSource::Codex, + category: AgentCategory::Coding, + event_type: AgentEventType::AgentStarted { + instruction: Some("hello".into()), + }, + metadata: None, + }) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(2200)).await; + assert!(machine.get_state().await.agents.is_empty()); + } + #[tokio::test] async fn test_remove_agent_drops_session_and_recomputes_state() { let bus = EventBus::new(100, 1000); diff --git a/crates/familiar-core/tests/config.rs b/crates/familiar-core/tests/config.rs index 13b0fb3..58ae8a7 100644 --- a/crates/familiar-core/tests/config.rs +++ b/crates/familiar-core/tests/config.rs @@ -20,6 +20,22 @@ fn legacy_config_defaults_to_showing_on_all_desktops() { assert!(config.renderer.desktop_pet.show_on_all_desktops); } +#[test] +fn legacy_config_defaults_idle_session_timeout_to_ten_minutes() { + let legacy_config = include_str!("../../../config/default.toml") + .replace("idle_session_timeout_secs = 600\n", ""); + let path = std::env::temp_dir().join(format!( + "familiar-legacy-idle-session-timeout-{}.toml", + std::process::id() + )); + + std::fs::write(&path, legacy_config).expect("write legacy config"); + let config = FamiliarConfig::load_from_file(&path).expect("load legacy config"); + std::fs::remove_file(path).expect("remove legacy config"); + + assert_eq!(config.renderer.desktop_pet.idle_session_timeout_secs, 600); +} + #[test] fn legacy_config_defaults_onboarded_to_false() { let legacy_config = diff --git a/crates/familiar-hooks/src/adapter.rs b/crates/familiar-hooks/src/adapter.rs index edd355a..31642c1 100644 --- a/crates/familiar-hooks/src/adapter.rs +++ b/crates/familiar-hooks/src/adapter.rs @@ -11,6 +11,9 @@ pub struct CliAgentHookAdapter { } impl CliAgentHookAdapter { + const CODEX_INTERNAL_PROMPT: &'static str = + "You are a helpful assistant. You will be presented with a user prompt"; + pub fn new(source: AgentSource) -> Self { Self { agent_source: source, @@ -136,7 +139,16 @@ impl CliAgentHookAdapter { "SessionStart" | "start" => AgentEventType::SessionStarted, "USER_INPUT" | "UserPromptSubmit" => { let instruction = Self::extract_instruction(json); - AgentEventType::AgentStarted { instruction } + if self.agent_source == AgentSource::Codex + && instruction + .as_deref() + .map(str::trim) + .is_some_and(|prompt| prompt.starts_with(Self::CODEX_INTERNAL_PROMPT)) + { + AgentEventType::Ignored + } else { + AgentEventType::AgentStarted { instruction } + } } "Stop" | "stop" | "exit" | "SessionEnd" => AgentEventType::TaskCompleted { summary: "Task finished".into(), @@ -318,6 +330,36 @@ mod tests { assert!(matches!(event.event_type, AgentEventType::SessionStarted)); } + #[test] + fn codex_internal_placeholder_prompt_is_ignored() { + let adapter = CliAgentHookAdapter::new(AgentSource::Codex); + let event = adapter + .parse_hook_input(&json!({ + "hook_event_name": "UserPromptSubmit", + "session_id": "internal-session", + "prompt": "You are a helpful assistant. You will be presented with a user prompt. Generate a concise title.\n" + })) + .unwrap(); + + assert!(matches!(event.event_type, AgentEventType::Ignored)); + } + + #[test] + fn non_codex_placeholder_prompt_is_not_ignored() { + let event = parse( + "UserPromptSubmit", + json!({ + "session_id": "user-session", + "prompt": "You are a helpful assistant. You will be presented with a user prompt" + }), + ); + + assert!(matches!( + event.event_type, + AgentEventType::AgentStarted { .. } + )); + } + #[test] fn test_stop_failure_maps_to_task_failed_with_message() { let event = parse( diff --git a/docs/releases/v2.4.1.md b/docs/releases/v2.4.1.md new file mode 100644 index 0000000..40e5bcd --- /dev/null +++ b/docs/releases/v2.4.1.md @@ -0,0 +1,77 @@ +# Familiar v2.4.1 + +[中文](#中文) | [English](#english) + +## 中文 + +### 主要功能与修复 + +- 忽略 Codex 标题生成等内部线程使用的 `You are a helpful assistant. You will be presented with a user prompt` 提示,不再创建闲置气泡。 +- 为持续处于 `Idle` 的会话增加自动清理,避免缺少结束事件时气泡永久残留。 +- 设置页新增闲置会话清理时长,默认 600 秒(10 分钟),范围 60–3600 秒,保存后即时生效。 + +### 兼容性与配置迁移 + +- 版本元数据升级至 `2.4.1`。 +- 新增 `renderer.desktop-pet.idle_session_timeout_secs` 配置字段;旧配置缺失该字段时自动使用 600 秒,无需手动迁移。 +- 现有 Hook 配置和 `event_status_map` 保持兼容。 + +### 隐私影响 + +- 不新增遥测、网络上报或远程数据采集。 +- 黑名单仅针对 Codex Hook 中已识别的内部提示前缀,不上传或持久化新的提示内容。 + +### 支持的平台与架构 + +- macOS Apple Silicon(arm64)与 Intel(x86_64)桌面包。 +- Linux x86_64 deb 与 AppImage;桌面运行需要发行版提供 Tauri/WebKitGTK 依赖。 +- Windows x86_64 NSIS 安装包。 +- `familiar-cli serve` 可在上述平台运行。 + +### 签名与公证状态 + +- macOS 使用 CI 配置的 ad-hoc 签名标识,不提供 Apple Developer ID 签名或公证;Gatekeeper 可能要求首次手动允许。 +- Windows 安装包未配置代码签名;SmartScreen 可能要求首次安装手动确认。 +- Linux deb/AppImage 未由发行版渠道签名。 + +### 性能数据 + +本版本未建立新的可复现端到端性能基线,不宣称量化性能变化。 + +--- + +## English + +### Major features and fixes + +- Ignore the `You are a helpful assistant. You will be presented with a user prompt` prompt prefix used by Codex internal threads such as title generation, so it no longer creates an idle bubble. +- Automatically clean up sessions that remain `Idle`, preventing bubbles from persisting when an end event is missing. +- Add an idle-session cleanup timeout setting, defaulting to 600 seconds (10 minutes), configurable from 60 to 3600 seconds and applied immediately after saving. + +### Compatibility and configuration migration + +- Version metadata is upgraded to `2.4.1`. +- Add `renderer.desktop-pet.idle_session_timeout_secs`; older configurations default to 600 seconds without manual migration. +- Existing Hook configuration and `event_status_map` remain compatible. + +### Privacy impact + +- No telemetry, network reporting, or remote data collection is added. +- The blacklist targets only the identified internal Codex Hook prompt prefix; no new prompt content is uploaded or persisted. + +### Supported platforms and architectures + +- macOS desktop bundles for Apple Silicon (arm64) and Intel (x86_64). +- Linux x86_64 deb and AppImage packages; the desktop requires the distribution's Tauri/WebKitGTK dependencies. +- Windows x86_64 NSIS installer. +- `familiar-cli serve` can run on the platforms above. + +### Signing and notarization status + +- macOS uses the ad-hoc signing identity configured in CI and is not signed with an Apple Developer ID or notarized; Gatekeeper may require manual approval on first launch. +- The Windows installer is not code-signed; SmartScreen may require manual confirmation on first install. +- Linux deb/AppImage artifacts are not signed by a distribution channel. + +### Performance data + +No new reproducible end-to-end performance baseline was established for this release, and no quantified performance change is claimed. From e640fa0ccbc8e6e8c4c5f7088a93dae5791bd95e Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Sun, 20 Sep 2026 20:01:52 +0800 Subject: [PATCH 2/8] docs: include Homebrew sync in release process --- docs/RELEASE_PROCESS.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 4bd8469..bcc0643 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -518,7 +518,45 @@ Alpha、Beta 或 RC 版本增加 `--prerelease`。正式语义化版本不加该 发布动作是外部状态变更。只有用户明确要求发布时才能执行,不得因为完成了本地构建就自动创建 Release。 -## 15. 发布后复核 +## 15. 同步 Homebrew tap + +GitHub Release 发布并确认公开制品可下载后,必须同步更新 +[`Monster12138/homebrew-familiar`](https://github.com/Monster12138/homebrew-familiar)。 +Formula 使用源码 Tag,Cask 使用 Release 中的两个 macOS DMG;校验和必须从实际 +下载文件计算,不要手工填写: + +```bash +VERSION=2.4.1 +TAG="v${VERSION}" +TAP_DIR="$(mktemp -d)/homebrew-familiar" +WORK_DIR="$(mktemp -d)" +git clone https://github.com/Monster12138/homebrew-familiar.git "$TAP_DIR" +gh release download "$TAG" --repo Monster12138/familiar \ + --pattern "Familiar_${VERSION}_macos_aarch64.dmg" \ + --pattern "Familiar_${VERSION}_macos_x64.dmg" --dir "$WORK_DIR" +curl -LfsS -o "$WORK_DIR/source.tar.gz" \ + "https://codeload.github.com/Monster12138/familiar/tar.gz/refs/tags/${TAG}" + +SOURCE_SHA="$(shasum -a 256 "$WORK_DIR/source.tar.gz" | awk '{print $1}')" +ARM_SHA="$(shasum -a 256 "$WORK_DIR/Familiar_${VERSION}_macos_aarch64.dmg" | awk '{print $1}')" +INTEL_SHA="$(shasum -a 256 "$WORK_DIR/Familiar_${VERSION}_macos_x64.dmg" | awk '{print $1}')" +perl -0pi -e \ + "s#v[0-9]+\\.[0-9]+\\.[0-9]+\\.tar\\.gz#v${VERSION}.tar.gz#; s/sha256 \"[0-9a-f]{64}\"/sha256 \"$SOURCE_SHA\"/" \ + "$TAP_DIR/Formula/familiar-cli.rb" +perl -0pi -e \ + "s/version \"[^\"]+\"/version \"$VERSION\"/; s/sha256 arm: \"[^\"]+\", intel: \"[^\"]+\"/sha256 arm: \"$ARM_SHA\", intel: \"$INTEL_SHA\"/" \ + "$TAP_DIR/Casks/familiar.rb" +git -C "$TAP_DIR" diff --check +git -C "$TAP_DIR" add Formula/familiar-cli.rb Casks/familiar.rb +git -C "$TAP_DIR" commit -m "chore: update Familiar to v${VERSION}" +git -C "$TAP_DIR" push origin main +``` + +推送后确认 tap 的最新提交和两个文件均为目标版本;如本机安装了 Homebrew, +再运行 `brew audit --new --strict --formula Monster12138/familiar/familiar-cli` +和 `brew test Monster12138/familiar/familiar-cli`。 + +## 16. 发布后复核 ```bash gh release view "$VERSION" \ From 3ff17dc1b2588e883a5dc62ef23eb848349f3e05 Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Tue, 29 Sep 2026 20:45:53 +0800 Subject: [PATCH 3/8] feat: release creative workshop in v3.0.0 --- AGENTS.md | 30 ++ Cargo.lock | 10 +- Cargo.toml | 2 +- app/package-lock.json | 4 +- app/package.json | 2 +- app/src-tauri/capabilities/default.json | 3 +- app/src-tauri/capabilities/workshop.json | 6 + app/src-tauri/src/commands.rs | 73 +++- app/src-tauri/src/main.rs | 5 + app/src/i18n.js | 32 ++ app/src/settings.css | 9 + app/src/settings.html | 10 + app/src/settings.js | 19 +- app/src/workshop.css | 85 +++++ app/src/workshop.html | 29 ++ app/src/workshop.js | 240 +++++++++++++ app/vite.config.js | 3 +- crates/familiar-core/src/sprite_pack.rs | 411 +++++++++++++++++++++++ docs/SPRITE_PACK_CREATION_GUIDE.md | 6 +- docs/releases/v3.0.0.md | 75 +++++ 20 files changed, 1039 insertions(+), 15 deletions(-) create mode 100644 app/src-tauri/capabilities/workshop.json create mode 100644 app/src/workshop.css create mode 100644 app/src/workshop.html create mode 100644 app/src/workshop.js create mode 100644 docs/releases/v3.0.0.md diff --git a/AGENTS.md b/AGENTS.md index 4b9a54b..2d79f03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,36 @@ cover the change and report the unrelated failure clearly. - Prefer focused comments that explain platform constraints or non-obvious behavior; do not narrate straightforward code. +## Desktop UI and Sprite Pack Workshop + +- Keep secondary windows visually consistent with the settings panel. Reuse its + neutral dark backgrounds, text, borders, blue action accent, control shapes, + and spacing; reserve a checkerboard for transparent image previews. When a + style token changes, update related windows together or share the token. +- Put major features in the settings sidebar as clear, labeled entries. Small + actions inside a section may use simple icons with accessible names. Avoid + redundant instructional labels when the action is clear from context. +- Keep user-facing forms focused on concepts users choose. Generate technical + identifiers internally, and do not require or display them unless users need + them to complete a task. Mark optional fields explicitly; validate only + fields and assets that the format actually requires. +- In image editors, make selection and preview direct: selecting a state shows + that state's image, switching packs selects the first available image, and + previews preserve the source aspect ratio. Support both file picking and + dropping onto a specific state; highlight the drop target and report invalid + files at that state. For native Tauri drops, use the webview event and verify + platform coordinate units before mapping the drop point to a DOM element. +- Keep editable user packs distinct from built-in packs. Copy built-in packs + before editing; allow deletion only for user packs. Save through the backend + with validation and recovery of the previous version, then refresh the pack + list and active pet through the existing update event. Export the saved + version, saving pending edits first. +- Protect unsaved work on navigation and the system close button with an + in-window confirmation that does not block other app windows. Do not add a + second close control when the native window control already serves that + purpose. Use transient feedback for successes and errors, and provide both + Chinese and English text for every visible label and message. + ## Agent Hooks Integration Rules for adding or updating hook integration for a coding agent (Claude Code, diff --git a/Cargo.lock b/Cargo.lock index c1ec245..b00cf9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,7 +1370,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "familiar-api" -version = "2.4.1" +version = "3.0.0" dependencies = [ "anyhow", "axum", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "familiar-app" -version = "2.4.1" +version = "3.0.0" dependencies = [ "anyhow", "axum", @@ -1416,7 +1416,7 @@ dependencies = [ [[package]] name = "familiar-cli" -version = "2.4.1" +version = "3.0.0" dependencies = [ "anyhow", "axum", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "familiar-core" -version = "2.4.1" +version = "3.0.0" dependencies = [ "anyhow", "chrono", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "familiar-hooks" -version = "2.4.1" +version = "3.0.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5eb88d5..1dd21d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "2.4.1" +version = "3.0.0" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/app/package-lock.json b/app/package-lock.json index 0d6a56a..7894f7f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "familiar-app-ui", - "version": "2.4.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "familiar-app-ui", - "version": "2.4.1", + "version": "3.0.0", "license": "MIT OR Apache-2.0", "devDependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/app/package.json b/app/package.json index 0919f40..fc76476 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "familiar-app-ui", - "version": "2.4.1", + "version": "3.0.0", "private": true, "license": "MIT OR Apache-2.0", "type": "module", diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 391b07a..b58e2d2 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -6,7 +6,8 @@ "bubble", "stats", "settings", - "onboard" + "onboard", + "workshop" ], "permissions": [ "core:default", diff --git a/app/src-tauri/capabilities/workshop.json b/app/src-tauri/capabilities/workshop.json new file mode 100644 index 0000000..c45a714 --- /dev/null +++ b/app/src-tauri/capabilities/workshop.json @@ -0,0 +1,6 @@ +{ + "identifier": "workshop-close", + "description": "Allow the workshop to finish its own close confirmation", + "windows": ["workshop"], + "permissions": ["core:window:allow-destroy"] +} diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 7871833..75890e4 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -1096,7 +1096,72 @@ fn test_via_shell(agent: &str, event_name: &str) -> Result Result<(), String> { + if let Some(window) = app_handle.get_webview_window("workshop") { + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + tauri::WebviewWindowBuilder::new( + &app_handle, + "workshop", + tauri::WebviewUrl::App("workshop.html".into()), + ) + .title("Familiar Workshop") + .inner_size(1040.0, 740.0) + .min_inner_size(760.0, 560.0) + .build() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn pick_workshop_image() -> Result, String> { + Ok(rfd::AsyncFileDialog::new() + .add_filter("Image", &["png", "apng", "gif", "webp", "svg"]) + .pick_file() + .await + .map(|file| file.path().to_string_lossy().to_string())) +} + +#[tauri::command] +pub fn read_workshop_image(path: String) -> Result, String> { + let path = std::path::Path::new(&path); + let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?; + if !metadata.is_file() || metadata.len() > 20 * 1024 * 1024 { + return Err("Image exceeds 20 MiB".to_string()); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn save_workshop_pack( + app_handle: tauri::AppHandle, + draft: WorkshopDraft, +) -> Result { + let info = SpritePackManager::save_workshop(draft).map_err(|e| e.to_string())?; + use tauri::Emitter; + let _ = app_handle.emit("sprite_pack_imported", &info); + Ok(info) +} + +#[tauri::command] +pub async fn export_workshop_pack(id: String) -> Result, String> { + let Some(file) = rfd::AsyncFileDialog::new() + .set_file_name(format!("{id}.fpack")) + .add_filter("Familiar Sprite Pack", &["fpack"]) + .save_file() + .await + else { + return Ok(None); + }; + let path = file.path().to_path_buf(); + SpritePackManager::export_workshop(&id, &path).map_err(|e| e.to_string())?; + Ok(Some(path.to_string_lossy().to_string())) +} use tauri::Manager; @@ -1136,6 +1201,7 @@ pub async fn import_sprite_pack( #[tauri::command] pub fn delete_sprite_pack( + app_handle: tauri::AppHandle, config_state: tauri::State<'_, Arc>, id: String, ) -> Result<(), String> { @@ -1143,7 +1209,10 @@ pub fn delete_sprite_pack( return Err("Cannot delete the active sprite pack; switch packs first".to_string()); } - SpritePackManager::delete_user_pack(&id).map_err(|error| error.to_string()) + SpritePackManager::delete_user_pack(&id).map_err(|error| error.to_string())?; + use tauri::Emitter; + let _ = app_handle.emit("sprite_pack_deleted", &id); + Ok(()) } #[tauri::command] diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index b0332fb..145bacf 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -623,6 +623,11 @@ fn main() { commands::preview_uninstall_hook, commands::test_hook_point, commands::get_sprite_packs, + commands::open_workshop_window, + commands::pick_workshop_image, + commands::read_workshop_image, + commands::save_workshop_pack, + commands::export_workshop_pack, commands::import_sprite_pack, commands::delete_sprite_pack, commands::get_active_sprite_pack, diff --git a/app/src/i18n.js b/app/src/i18n.js index 9bc6ab1..9ae5c02 100644 --- a/app/src/i18n.js +++ b/app/src/i18n.js @@ -1,5 +1,21 @@ export const translations = { 'zh-CN': { + 'btn_open_workshop': '创意工坊', + 'workshop_title': '创意工坊', 'workshop_intro': '用本地图片制作 Familiar 素材包', + 'workshop_new': '新建', 'workshop_save': '保存', 'workshop_export': '导出素材包', + 'workshop_packs': '素材包', 'workshop_name': '名称', + 'workshop_author': '作者(选填)', 'workshop_version': '版本(选填)', 'workshop_email': '邮箱(选填)', + 'workshop_license': '许可证(选填)', 'workshop_source': '来源链接(选填)', 'workshop_description': '简介(选填)', + 'workshop_states': '状态图片', 'workshop_hint': '空闲态必填;其他状态缺省时会回退。支持 PNG、APNG、GIF、WebP、SVG,可将图片拖到对应状态。', + 'workshop_drop_one': '每次只能拖入一张图片', 'workshop_drop_format': '仅支持 PNG、APNG、GIF、WebP、SVG 图片', + 'workshop_preview': '实时预览', 'workshop_choose': '选择', 'workshop_remove': '移除', + 'workshop_no_image': '尚未选择图片', 'workshop_discard': '有未保存的改动,确定放弃吗?', + 'workshop_copy_suffix': '副本', 'workshop_saved': '素材包已保存', 'workshop_exported': '素材包已导出', + 'workshop_invalid_image': '无法读取图片', + 'workshop_keep_editing': '继续编辑', 'workshop_discard_action': '放弃改动', + 'workshop_delete_confirm': '确定删除素材包“{name}”吗?此操作无法撤销。', 'workshop_delete_action': '删除', + 'workshop_delete_confirm_dirty': '删除“{name}”并丢弃当前未保存的修改吗?此操作无法撤销。', + 'workshop_delete_success': '素材包已删除', 'workshop_delete_failed': '删除失败:', 'nav_close': '关闭设置', 'nav_search': '搜索设置...', 'nav_pref': '偏好', @@ -306,6 +322,22 @@ export const translations = { 'evt_SubagentStopped': '子代理停止', }, 'en-US': { + 'btn_open_workshop': 'Workshop', + 'workshop_title': 'Creative Workshop', 'workshop_intro': 'Build Familiar packs from local images', + 'workshop_new': 'New', 'workshop_save': 'Save', 'workshop_export': 'Export pack', + 'workshop_packs': 'Packs', 'workshop_name': 'Name', + 'workshop_author': 'Author (optional)', 'workshop_version': 'Version (optional)', 'workshop_email': 'Email (optional)', + 'workshop_license': 'License (optional)', 'workshop_source': 'Source URL (optional)', 'workshop_description': 'Description (optional)', + 'workshop_states': 'State images', 'workshop_hint': 'Idle is required; missing states fall back. Supports PNG, APNG, GIF, WebP and SVG. Drop an image onto its state.', + 'workshop_drop_one': 'Drop one image at a time', 'workshop_drop_format': 'Only PNG, APNG, GIF, WebP and SVG images are supported', + 'workshop_preview': 'Live preview', 'workshop_choose': 'Choose', 'workshop_remove': 'Remove', + 'workshop_no_image': 'No image selected', 'workshop_discard': 'Discard unsaved changes?', + 'workshop_copy_suffix': 'Copy', 'workshop_saved': 'Pack saved', 'workshop_exported': 'Pack exported', + 'workshop_invalid_image': 'Image cannot be decoded', + 'workshop_keep_editing': 'Keep editing', 'workshop_discard_action': 'Discard changes', + 'workshop_delete_confirm': 'Delete pack “{name}”? This cannot be undone.', 'workshop_delete_action': 'Delete', + 'workshop_delete_confirm_dirty': 'Delete “{name}” and discard unsaved edits? This cannot be undone.', + 'workshop_delete_success': 'Pack deleted', 'workshop_delete_failed': 'Delete failed: ', 'nav_close': 'Close Settings', 'nav_search': 'Search settings...', 'nav_pref': 'Preferences', diff --git a/app/src/settings.css b/app/src/settings.css index 24a415c..c78f10c 100644 --- a/app/src/settings.css +++ b/app/src/settings.css @@ -121,6 +121,15 @@ body { margin-bottom: 2px; } +.menu-action { + width: 100%; + border: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} + .menu-item svg { color: var(--text-muted); } diff --git a/app/src/settings.html b/app/src/settings.html index d196497..f37e99f 100644 --- a/app/src/settings.html +++ b/app/src/settings.html @@ -22,6 +22,10 @@ 宠物素材 + Hooks @@ -308,6 +312,12 @@

素材包管理

管理与切换桌面宠物显示的素材包,支持从外部导入 .fpack 格式的素材包

+
+
+ +
+ + + + + + + +

状态图片

空闲态必填;其余状态缺省时会回退到空闲态。支持 PNG、APNG、GIF、WebP、SVG。

+ +
+ +
+ diff --git a/app/src/workshop.js b/app/src/workshop.js new file mode 100644 index 0000000..a336eff --- /dev/null +++ b/app/src/workshop.js @@ -0,0 +1,240 @@ +import { applyTranslations, t } from './i18n.js'; +import { invoke, convertFileSrc } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; + +const STATES = ['idle','working','thinking','interacting','celebrating','alarmed','sleeping','watching','happy']; +const fields = ['name','author','version','email','license','source','description']; +const $ = id => document.getElementById(id); +let lang = 'zh-CN', packs = [], assets = {}, editingId = null, dirty = false, toastTimer; +let createdAt = null; +let selectedPackId = null; +let selectedState = 'idle'; +const previewUrls = new Map(); +const installedUrls = new Map(); +let discardResolve = null; +let draggedState = null; +const stateLabel = key => t(`state_${key === 'happy' ? 'interacting' : key}`, lang); +function toast(message, type = '') { const node = $('toast'); node.textContent = message; node.className = `show ${type}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => node.className = '', 3500); } +function markDirty() { dirty = true; } +function setStateAsset(key, path) { + assets[key] = path; + selectedState = key; + markDirty(); + renderStates(); + renderPreview(); +} +function highlightDropState(key) { + if (draggedState === key) return; + document.querySelector('.state-row.drag-over')?.classList.remove('drag-over'); + draggedState = key; + if (key) document.querySelector(`.state-row[data-state="${key}"]`)?.classList.add('drag-over'); +} +function stateAtDropPosition(position) { + // Wry's macOS drop callback uses Cocoa view coordinates (points), even though Tauri labels them physical. + const scale = /Macintosh|Mac OS X/.test(navigator.userAgent) ? 1 : window.devicePixelRatio; + const row = document.elementFromPoint(position.x / scale, position.y / scale)?.closest('.state-row'); + return row?.dataset.state || null; +} +async function imageUrl(path) { + if (!path) return ''; + if (installedUrls.has(path)) return installedUrls.get(path); + if (previewUrls.has(path)) return previewUrls.get(path); + const bytes = await invoke('read_workshop_image', {path}); + const extension = path.split('.').pop().toLowerCase(); + const mime = {png:'image/png',apng:'image/png',gif:'image/gif',webp:'image/webp',svg:'image/svg+xml'}[extension] || 'application/octet-stream'; + const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], {type:mime})); + previewUrls.set(path, url); + return url; +} +function renderStates() { + $('states').replaceChildren(); + for (const key of STATES) { + const row = document.createElement('div'); row.className = `state-row ${selectedState === key ? 'selected' : ''}`; row.dataset.state = key; + row.tabIndex = 0; + row.setAttribute('role', 'button'); + row.setAttribute('aria-pressed', String(selectedState === key)); + row.onclick = () => { selectedState = key; renderStates(); renderPreview(); }; + row.onkeydown = event => { if (event.target === row && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); row.click(); } }; + const img = document.createElement('img'); img.alt = ''; + if (assets[key]) imageUrl(assets[key]).then(url => img.src = url).catch(() => img.alt = '×'); + const label = document.createElement('span'); label.className = 'state-name'; label.textContent = stateLabel(key) + (key === 'idle' ? ' *' : ''); + const file = document.createElement('span'); file.className = 'file-name'; file.textContent = assets[key]?.split(/[\\/]/).pop() || '—'; file.title = assets[key] || ''; + const choose = document.createElement('button'); choose.textContent = t('workshop_choose', lang); + choose.onclick = async event => { event.stopPropagation(); try { const path = await invoke('pick_workshop_image'); if (path) setStateAsset(key, path); } catch (e) { toast(String(e), 'error'); } }; + const remove = document.createElement('button'); remove.textContent = t('workshop_remove', lang); remove.disabled = !assets[key]; + remove.onclick = event => { event.stopPropagation(); delete assets[key]; markDirty(); renderStates(); renderPreview(); }; + row.append(img,label,file,choose,remove); $('states').append(row); + } +} +function renderPreview() { + const selected = selectedState; + const path = assets[selected]; + $('preview-image').removeAttribute('src'); + if (path) imageUrl(path).then(url => { if (selectedState === selected && assets[selected] === path) $('preview-image').src = url; }).catch(e => toast(String(e), 'error')); + $('preview-state-name').textContent = stateLabel(selected); + $('preview-file').textContent = path ? path.split(/[\\/]/).pop() : t('workshop_no_image', lang); +} +function confirmAction(message, actionText) { + if (discardResolve) return Promise.resolve(false); + $('discard-title').textContent = message; + $('discard-confirm').textContent = actionText; + $('discard-overlay').hidden = false; + $('discard-cancel').focus(); + return new Promise(resolve => { discardResolve = resolve; }); +} +function confirmDiscard() { + if (!dirty) return Promise.resolve(true); + return confirmAction(t('workshop_discard', lang), t('workshop_discard_action', lang)); +} +function finishDiscard(answer) { + $('discard-overlay').hidden = true; + $('discard-title').textContent = t('workshop_discard', lang); + $('discard-confirm').textContent = t('workshop_discard_action', lang); + const resolve = discardResolve; + discardResolve = null; + resolve?.(answer); +} +function releasePreviewUrls() { + for (const url of previewUrls.values()) URL.revokeObjectURL(url); + previewUrls.clear(); +} +function reset() { + releasePreviewUrls(); installedUrls.clear(); + editingId = null; createdAt = null; selectedPackId = null; selectedState = 'idle'; assets = {}; dirty = false; + for (const key of fields) $(key).value = ''; + $('version').value = '1.0.0'; renderStates(); renderPreview(); renderPacks(); +} +async function loadPack(pack, copy) { + if (!await confirmDiscard()) return; + releasePreviewUrls(); + editingId = copy ? null : pack.manifest.id; createdAt = copy ? null : pack.manifest.created_at; assets = {}; + selectedPackId = pack.manifest.id; + installedUrls.clear(); + for (const [key,file] of Object.entries(pack.manifest.states || {})) { + const path = `${pack.path}/${file}`; + assets[key] = path; + installedUrls.set(path, pack.is_builtin ? `/sprites/${pack.manifest.id}/${file}` : convertFileSrc(path)); + } + for (const key of fields) $(key).value = pack.manifest[key] || ''; + if (copy) $('name').value += ` ${t('workshop_copy_suffix', lang)}`; + selectedState = STATES.find(key => assets[key]) || 'idle'; + dirty = false; renderStates(); renderPreview(); renderPacks(); +} +function renderPacks() { + $('packs').replaceChildren(); + for (const pack of packs) { + const row = document.createElement('div'); row.className = `pack-option ${selectedPackId === pack.manifest.id ? 'active' : ''}`; + const select = document.createElement('button'); select.className = 'pack-row'; + select.textContent = pack.manifest.name; + select.onclick = () => { void loadPack(pack, pack.is_builtin); }; + row.append(select); + if (!pack.is_builtin) { + const remove = document.createElement('button'); + remove.className = 'pack-delete'; + remove.title = t('btn_delete_pack', lang); + remove.setAttribute('aria-label', `${t('btn_delete_pack', lang)} ${pack.manifest.name}`); + remove.innerHTML = ''; + remove.onclick = () => { void deletePack(pack); }; + row.append(remove); + } + $('packs').append(row); + } +} +async function deletePack(pack) { + const hasUnsavedEdits = selectedPackId === pack.manifest.id && dirty; + const messageKey = hasUnsavedEdits ? 'workshop_delete_confirm_dirty' : 'workshop_delete_confirm'; + const message = t(messageKey, lang).replace('{name}', pack.manifest.name); + if (!await confirmAction(message, t('workshop_delete_action', lang))) return; + try { + await invoke('delete_sprite_pack', {id: pack.manifest.id}); + packs = packs.filter(item => item.manifest.id !== pack.manifest.id); + if (selectedPackId === pack.manifest.id) reset(); + renderPacks(); + if (selectedPackId === null && packs.length) await loadPack(packs[0], packs[0].is_builtin); + toast(t('workshop_delete_success', lang), 'success'); + } catch (error) { + toast(t('workshop_delete_failed', lang) + String(error), 'error'); + } +} +async function refreshPacks() { packs = await invoke('get_sprite_packs'); renderPacks(); } +function manifest() { + const data = Object.fromEntries(fields.map(key => [key, $(key).value.trim()])); + return {format_version: 1, ...data, id: editingId || '', license: data.license || null, source: data.source || null, + created_at: createdAt || new Date().toISOString().slice(0,10), preview: null, states: {}, sha256: null}; +} +async function save() { + try { + document.querySelectorAll('.invalid').forEach(node => node.classList.remove('invalid')); + for (const [state, path] of Object.entries(assets)) { + const url = await imageUrl(path); + await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = resolve; + image.onerror = () => reject(`${state}: ${t('workshop_invalid_image', lang)}`); + image.src = url; + }); + } + const data = manifest(); + const info = await invoke('save_workshop_pack', {draft: {manifest: data, assets, editing_id: editingId}}); + editingId = info.manifest.id; createdAt = info.manifest.created_at; + selectedPackId = info.manifest.id; + releasePreviewUrls(); + installedUrls.clear(); + for (const [key,file] of Object.entries(info.manifest.states)) { + const path = `${info.path}/${file}`; + assets[key] = path; + installedUrls.set(path, convertFileSrc(path) + `?v=${encodeURIComponent(info.asset_revision)}`); + } + dirty = false; await refreshPacks(); renderStates(); renderPreview(); toast(t('workshop_saved', lang), 'success'); + return true; + } catch (e) { + const key = /^([a-z_]+):/.exec(String(e))?.[1]; + const target = $(key) || document.querySelector(`[data-state="${key}"]`); + if (target) { target.classList.add('invalid'); target.scrollIntoView({block:'nearest'}); } + toast(String(e), 'error'); return false; + } +} +async function exportPack() { + if (dirty || !editingId) { if (!await save()) return; } + try { const path = await invoke('export_workshop_pack', {id: editingId}); if (path) toast(t('workshop_exported', lang), 'success'); } + catch (e) { toast(String(e), 'error'); } +} + +async function init() { + try { const config = await invoke('get_config'); lang = config.general?.language || lang; } catch (_) { /* defaults to Chinese */ } + applyTranslations(lang); document.documentElement.lang = lang.startsWith('zh') ? 'zh' : 'en'; + $('new').onclick = async () => { if (await confirmDiscard()) reset(); }; + $('discard-cancel').onclick = () => finishDiscard(false); + $('discard-confirm').onclick = () => finishDiscard(true); + $('save').onclick = save; $('export').onclick = exportPack; + $('discard-overlay').addEventListener('click', event => { if (event.target === $('discard-overlay')) finishDiscard(false); }); + for (const key of fields) $(key).addEventListener('input', markDirty); + await getCurrentWebviewWindow().onCloseRequested(event => { + if (!dirty) return; + event.preventDefault(); + void confirmDiscard() + .then(confirmed => { if (confirmed) return getCurrentWebviewWindow().destroy(); }) + .catch(error => toast(String(error), 'error')); + }); + const webview = getCurrentWebview(); + await webview.onDragDropEvent(event => { + const {type, position, paths} = event.payload; + if (type === 'leave') { highlightDropState(null); return; } + const key = stateAtDropPosition(position); + if (type === 'enter' || type === 'over') { highlightDropState(key); return; } + highlightDropState(null); + if (!key || type !== 'drop') return; + if (paths.length !== 1) { toast(t('workshop_drop_one', lang), 'error'); return; } + const path = paths[0]; + if (!/\.(png|apng|gif|webp|svg)$/i.test(path)) { toast(t('workshop_drop_format', lang), 'error'); return; } + void imageUrl(path).then(() => setStateAsset(key, path)).catch(error => toast(String(error), 'error')); + }); + await listen('sprite_pack_imported', refreshPacks); + await listen('sprite_pack_deleted', refreshPacks); + window.addEventListener('beforeunload', releasePreviewUrls); + reset(); await refreshPacks(); + if (packs.length) await loadPack(packs[0], packs[0].is_builtin); +} +init().catch(e => toast(String(e), 'error')); diff --git a/app/vite.config.js b/app/vite.config.js index fe813b5..2aba68c 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -57,7 +57,8 @@ export default defineConfig({ settings: 'src/settings.html', bubble: 'src/bubble.html', stats: 'src/stats.html', - onboard: 'src/onboard.html' + onboard: 'src/onboard.html', + workshop: 'src/workshop.html' } } }, diff --git a/crates/familiar-core/src/sprite_pack.rs b/crates/familiar-core/src/sprite_pack.rs index bda6001..1cfe503 100644 --- a/crates/familiar-core/src/sprite_pack.rs +++ b/crates/familiar-core/src/sprite_pack.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; /// Current sprite pack format version. @@ -54,7 +55,208 @@ pub struct SpritePackInfo { pub struct SpritePackManager; +const MAX_ASSET_BYTES: u64 = 20 * 1024 * 1024; +const MAX_PACK_BYTES: u64 = 80 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkshopDraft { + pub manifest: SpritePackManifest, + /// State name to a local image path chosen by the user or from a loaded pack. + pub assets: HashMap, + /// Present only when updating the same installed custom pack. + pub editing_id: Option, +} + impl SpritePackManager { + fn next_workshop_id_in(root: &Path, name: &str) -> String { + let base: String = name + .to_ascii_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + let base = if base.is_empty() { + "my-pack" + } else { + base[..base.len().min(48)].trim_end_matches('-') + }; + let used: std::collections::HashSet = Self::discover_packs() + .into_iter() + .map(|p| p.manifest.id) + .collect(); + let mut id = base.to_string(); + let mut suffix = 2; + while used.contains(&id) || root.join(&id).exists() { + id = format!("{base}-{suffix}"); + suffix += 1; + } + id + } + + pub fn save_workshop(draft: WorkshopDraft) -> Result { + Self::save_workshop_in(&Self::get_user_sprite_dir(), draft) + } + + fn save_workshop_in(root: &Path, mut draft: WorkshopDraft) -> Result { + if draft.manifest.version.trim().is_empty() { + draft.manifest.version = "1.0.0".to_string(); + } + if draft.editing_id.is_none() && draft.manifest.id.is_empty() { + draft.manifest.id = Self::next_workshop_id_in(root, &draft.manifest.name); + } + let id = draft.manifest.id.clone(); + if !valid_pack_id(&id) { + return Err(anyhow!("id: use lowercase letters, digits and hyphens")); + } + if draft.manifest.name.trim().is_empty() { + return Err(anyhow!("name: required")); + } + if draft.manifest.name.len() > 100 { + return Err(anyhow!("name: too long")); + } + if draft.manifest.format_version != SPRITE_PACK_FORMAT_VERSION { + return Err(anyhow!("format_version: unsupported version")); + } + if !draft.assets.contains_key("idle") { + return Err(anyhow!("idle: image required")); + } + let target = root.join(&id); + if let Some(editing) = &draft.editing_id { + if editing != &id || !target.is_dir() { + return Err(anyhow!("id: custom pack to edit was not found")); + } + } else if target.exists() + || Self::discover_packs() + .iter() + .any(|pack| pack.manifest.id == id) + { + return Err(anyhow!("id: pack already exists")); + } + let mut total = 0u64; + let mut validated = Vec::new(); + for (state, source) in &draft.assets { + if !matches!( + state.as_str(), + "idle" + | "working" + | "thinking" + | "interacting" + | "happy" + | "celebrating" + | "alarmed" + | "sleeping" + | "watching" + ) { + return Err(anyhow!("{state}: unsupported state")); + } + let path = PathBuf::from(source); + let (extension, size) = validate_image(&path).map_err(|e| anyhow!("{state}: {e}"))?; + total = total + .checked_add(size) + .ok_or_else(|| anyhow!("pack: too large"))?; + if total > MAX_PACK_BYTES { + return Err(anyhow!("pack: exceeds 80 MiB")); + } + validated.push((state.clone(), path, extension)); + } + fs::create_dir_all(root)?; + let staging = root.join(format!(".familiar-workshop-{}", uuid::Uuid::new_v4())); + fs::create_dir(&staging)?; + let result = (|| -> Result { + draft.manifest.states.clear(); + draft.manifest.preview = None; + draft.manifest.sha256 = None; + for (state, path, extension) in validated { + let filename = format!("{state}.{extension}"); + fs::copy(path, staging.join(&filename))?; + draft.manifest.states.insert(state, filename); + } + draft.manifest.preview = draft.manifest.states.get("idle").cloned(); + fs::write( + staging.join("pack.json"), + serde_json::to_vec_pretty(&draft.manifest)?, + )?; + let _ = Self::load_pack_from_dir(&staging, false)?; + if target.exists() { + let backup_root = root.parent().unwrap_or(root).join("sprite-backups"); + fs::create_dir_all(&backup_root)?; + let backup = backup_root.join(format!( + "familiar-{id}.bak.{}", + chrono::Utc::now().format("%Y%m%d%H%M%S%3f") + )); + fs::rename(&target, &backup)?; + if let Err(error) = fs::rename(&staging, &target) { + let _ = fs::rename(&backup, &target); + return Err(error.into()); + } + } else { + fs::rename(&staging, &target)?; + } + Self::load_pack_from_dir(&target, false) + })(); + if staging.exists() { + let _ = fs::remove_dir_all(&staging); + } + result + } + + pub fn export_workshop(id: &str, destination: &Path) -> Result<()> { + Self::export_workshop_in(&Self::get_user_sprite_dir(), id, destination) + } + + fn export_workshop_in(root: &Path, id: &str, destination: &Path) -> Result<()> { + if !valid_pack_id(id) { + return Err(anyhow!("Invalid sprite pack ID")); + } + let pack_dir = root.join(id); + if !pack_dir.is_dir() { + return Err(anyhow!("Custom sprite pack not found")); + } + let info = Self::load_pack_from_dir(&pack_dir, false)?; + let temp = destination.with_extension(format!("fpack.tmp.{}", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let file = fs::File::create(&temp)?; + let mut archive = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let mut names = vec!["pack.json".to_string()]; + names.extend(info.manifest.states.values().cloned()); + names.sort(); + names.dedup(); + for name in names { + let mut components = Path::new(&name).components(); + if !matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(_)), None) + ) { + return Err(anyhow!("pack: invalid asset filename")); + } + let path = pack_dir.join(&name); + archive.start_file(name, options)?; + let mut source = fs::File::open(path)?; + std::io::copy(&mut source, &mut archive)?; + } + archive.finish()?; + if destination.exists() { + let backup = + destination.with_extension(format!("fpack.bak.{}", uuid::Uuid::new_v4())); + fs::rename(destination, &backup)?; + if let Err(error) = fs::rename(&temp, destination) { + let _ = fs::rename(&backup, destination); + return Err(error.into()); + } + let _ = fs::remove_file(backup); + } else { + fs::rename(&temp, destination)?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temp); + } + result + } pub fn get_search_dirs() -> Vec<(PathBuf, bool)> { let mut dirs = Vec::new(); @@ -287,6 +489,61 @@ fn delete_pack_from_dir(user_sprite_dir: &Path, pack_id: &str) -> Result<()> { Ok(()) } +fn valid_pack_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && !id.starts_with('-') + && !id.ends_with('-') + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn validate_image(path: &Path) -> Result<(String, u64)> { + let metadata = fs::metadata(path).map_err(|_| anyhow!("image file not found"))?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_ASSET_BYTES { + return Err(anyhow!("image must be 1 byte to 20 MiB")); + } + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!(ext.as_str(), "png" | "apng" | "gif" | "webp" | "svg") { + return Err(anyhow!("unsupported image format")); + } + let mut file = fs::File::open(path)?; + let mut header = [0u8; 16]; + let count = file.read(&mut header)?; + let valid = match ext.as_str() { + "png" | "apng" => header.starts_with(b"\x89PNG\r\n\x1a\n"), + "gif" => header.starts_with(b"GIF87a") || header.starts_with(b"GIF89a"), + "webp" => header.starts_with(b"RIFF") && &header[8..12] == b"WEBP", + "svg" => { + let mut content = String::new(); + file = fs::File::open(path)?; + file.read_to_string(&mut content)?; + let lower = content.to_ascii_lowercase(); + lower.contains(" false, + }; + if count == 0 || !valid { + return Err(anyhow!("invalid or unsafe image content")); + } + Ok((ext, metadata.len())) +} + fn discover_packs_in_dirs(search_dirs: Vec<(PathBuf, bool)>) -> Vec { let mut packs = Vec::new(); let mut seen_ids = std::collections::HashSet::new(); @@ -395,6 +652,160 @@ mod tests { }; use std::collections::HashMap; + fn workshop_draft( + id: &str, + image: &std::path::Path, + editing: Option<&str>, + ) -> super::WorkshopDraft { + super::WorkshopDraft { + manifest: SpritePackManifest { + format_version: 1, + id: id.into(), + name: "Test".into(), + author: "A".into(), + created_at: "2026-01-01".into(), + email: String::new(), + version: "1.0.0".into(), + license: None, + source: None, + description: String::new(), + preview: None, + states: HashMap::new(), + sha256: None, + }, + assets: HashMap::from([("idle".into(), image.to_string_lossy().into_owned())]), + editing_id: editing.map(str::to_string), + } + } + + #[test] + fn workshop_create_edit_and_backup() { + let root = std::env::temp_dir() + .join(format!("familiar-workshop-{}", uuid::Uuid::new_v4())) + .join("sprites"); + std::fs::create_dir_all(&root).unwrap(); + let image = root.parent().unwrap().join("source.png"); + std::fs::write(&image, b"\x89PNG\r\n\x1a\nasset").unwrap(); + let created = super::SpritePackManager::save_workshop_in( + &root, + workshop_draft("my-test-pack", &image, None), + ) + .unwrap(); + assert_eq!(created.manifest.states["idle"], "idle.png"); + assert!(super::SpritePackManager::save_workshop_in( + &root, + workshop_draft("my-test-pack", &image, None) + ) + .is_err()); + let copied = super::SpritePackManager::save_workshop_in( + &root, + workshop_draft("my-test-copy", &root.join("my-test-pack/idle.png"), None), + ) + .unwrap(); + assert_eq!(copied.manifest.id, "my-test-copy"); + let mut invalid_edit = workshop_draft("my-test-pack", &image, Some("my-test-pack")); + invalid_edit.assets.insert( + "idle".into(), + root.join("missing.png").to_string_lossy().into_owned(), + ); + assert!(super::SpritePackManager::save_workshop_in(&root, invalid_edit).is_err()); + assert!(root.join("my-test-pack/idle.png").is_file()); + let edited = super::SpritePackManager::save_workshop_in( + &root, + workshop_draft("my-test-pack", &image, Some("my-test-pack")), + ) + .unwrap(); + assert_eq!(edited.manifest.id, "my-test-pack"); + assert_eq!( + std::fs::read_dir(root.parent().unwrap().join("sprite-backups")) + .unwrap() + .count(), + 1 + ); + let _ = std::fs::remove_dir_all(root.parent().unwrap()); + } + + #[test] + fn workshop_generates_unique_ids_and_keeps_id_when_renamed() { + let root = std::env::temp_dir() + .join(format!("familiar-workshop-ids-{}", uuid::Uuid::new_v4())) + .join("sprites"); + std::fs::create_dir_all(&root).unwrap(); + let image = root.parent().unwrap().join("source.png"); + std::fs::write(&image, b"\x89PNG\r\n\x1a\nasset").unwrap(); + let mut draft = workshop_draft("", &image, None); + draft.manifest.name = "My Cat".into(); + draft.manifest.author.clear(); + draft.manifest.version.clear(); + let first = super::SpritePackManager::save_workshop_in(&root, draft.clone()).unwrap(); + let second = super::SpritePackManager::save_workshop_in(&root, draft).unwrap(); + assert_eq!(first.manifest.id, "my-cat"); + assert!(first.manifest.author.is_empty()); + assert_eq!(first.manifest.version, "1.0.0"); + assert_eq!(second.manifest.id, "my-cat-2"); + let mut edit = workshop_draft("my-cat", &image, Some("my-cat")); + edit.manifest.name = "Renamed Cat".into(); + let renamed = super::SpritePackManager::save_workshop_in(&root, edit).unwrap(); + assert_eq!(renamed.manifest.id, "my-cat"); + assert_eq!(renamed.manifest.name, "Renamed Cat"); + let _ = std::fs::remove_dir_all(root.parent().unwrap()); + } + + #[test] + fn workshop_rejects_missing_idle_invalid_image_and_id_without_replacing() { + let root = std::env::temp_dir() + .join(format!("familiar-workshop-bad-{}", uuid::Uuid::new_v4())) + .join("sprites"); + std::fs::create_dir_all(&root).unwrap(); + let image = root.parent().unwrap().join("source.png"); + std::fs::write(&image, b"invalid").unwrap(); + let mut draft = workshop_draft("bad", &image, None); + assert!( + super::SpritePackManager::save_workshop_in(&root, draft.clone()) + .unwrap_err() + .to_string() + .contains("idle") + ); + draft.assets.clear(); + assert!( + super::SpritePackManager::save_workshop_in(&root, draft.clone()) + .unwrap_err() + .to_string() + .contains("idle") + ); + draft.manifest.id = "../bad".into(); + assert!(super::SpritePackManager::save_workshop_in(&root, draft).is_err()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(root.parent().unwrap()); + } + + #[test] + fn workshop_export_is_importable_archive() { + let root = std::env::temp_dir() + .join(format!("familiar-workshop-export-{}", uuid::Uuid::new_v4())) + .join("sprites"); + std::fs::create_dir_all(&root).unwrap(); + let image = root.parent().unwrap().join("source.gif"); + std::fs::write(&image, b"GIF89a000000000").unwrap(); + super::SpritePackManager::save_workshop_in( + &root, + workshop_draft("export-test", &image, None), + ) + .unwrap(); + let archive_path = root.parent().unwrap().join("export-test.fpack"); + super::SpritePackManager::export_workshop_in(&root, "export-test", &archive_path).unwrap(); + let mut archive = zip::ZipArchive::new(std::fs::File::open(archive_path).unwrap()).unwrap(); + assert_eq!(archive.len(), 2); + assert!(archive.by_name("pack.json").is_ok()); + assert!(archive.by_name("idle.gif").is_ok()); + let extracted = root.parent().unwrap().join("extracted"); + archive.extract(&extracted).unwrap(); + let pack_dir = super::find_archive_pack_dir(&extracted).unwrap(); + let reopened = super::SpritePackManager::load_pack_from_dir(&pack_dir, false).unwrap(); + assert_eq!(reopened.manifest.id, "export-test"); + let _ = std::fs::remove_dir_all(root.parent().unwrap()); + } + #[test] fn archive_pack_dir_ignores_macos_metadata() { let root = diff --git a/docs/SPRITE_PACK_CREATION_GUIDE.md b/docs/SPRITE_PACK_CREATION_GUIDE.md index 032e5f8..1fcce47 100644 --- a/docs/SPRITE_PACK_CREATION_GUIDE.md +++ b/docs/SPRITE_PACK_CREATION_GUIDE.md @@ -2,6 +2,10 @@ 本指南介绍如何从一张普通宠物/人物图片出发,利用 AI 工具扣图提取主体、生成各状态的像素风图像,并按照 **Familiar 素材包规范 (Format Version 1)** 打包导出为 `.fpack` 文件。 +## 使用创意工坊 + +在 Familiar 的“宠物素材 → 创意工坊”打开独立编辑窗口。可以新建素材包、选择自定义包继续编辑,或选择内置包制作副本。资料字段仅名称必填;素材至少要有一张 `idle` 图片才能保存。点击状态图片行可在右侧预览,切换素材包时默认预览第一张已有图片。工坊在保存新包时根据名称自动生成唯一标识符,之后修改名称不会改变该标识符。点击“保存”后,作品进入本机素材包列表,正在使用的素材也会刷新;点击“导出素材包”可分享 `.fpack` 文件。编辑自定义包时,旧版本会保存在用户数据目录的 `sprite-backups` 文件夹。创意工坊只在本机处理图片,不会上传素材。 + --- ## 一、素材包规范与文件结构 @@ -62,7 +66,7 @@ my-custom-pet/ - `format_version`: **[必需]** 素材格式版本号,当前版本为 `1`。 - `id`: **[必需]** 素材包唯一标识符(建议全小写,中划线分隔,如 `tabby-cat`)。 - `name`: **[必需]** 显示在设置面板中的素材包名称。 -- `author`: **[必需]** 作者姓名或昵称。 +- `author`: **[选填]** 作者姓名或昵称;创意工坊可留空。 - `created_at`: 创作日期 (YYYY-MM-DD)。 - `email`: 联系邮箱。 - `version`: 素材包版本(如 `1.0.0`)。 diff --git a/docs/releases/v3.0.0.md b/docs/releases/v3.0.0.md new file mode 100644 index 0000000..28620b9 --- /dev/null +++ b/docs/releases/v3.0.0.md @@ -0,0 +1,75 @@ +# Familiar v3.0.0 + +[中文](#中文) | [English](#english) + +## 中文 + +### 主要功能与修复 + +- 新增创意工坊独立窗口,可从设置页侧边栏或素材包管理区打开。支持新建素材包、编辑自定义包,以及复制内置包后改编。 +- 可为各状态选择或拖入 PNG、APNG、GIF、WebP、SVG 图片;点击状态行即可预览,预览保持原始宽高比。只有名称和 `idle` 图片为必填项,素材包标识符由应用自动生成。 +- 保存前校验图片和素材包大小、格式与路径;编辑已有自定义包时保留本地备份。保存后刷新素材列表和正在使用的桌宠。支持删除自定义包及导出已保存作品为 `.fpack`。 +- 切换素材包或关闭有未保存改动的工坊窗口时,在窗口内确认,避免阻塞其他窗口。补齐中英文界面文案,并使工坊配色与设置面板一致。 + +### 兼容性与配置迁移 + +- 版本元数据升级至 `3.0.0`。现有素材包仍使用 `pack.json` Format Version 1 和 `.fpack` 格式,无需迁移;现有配置和 Hook 安装不需要更改。 +- 内置素材包仍可直接使用,但须复制为自定义包后才能在工坊修改。 + +### 隐私影响 + +- 工坊在本机读取、保存和导出图片,不上传素材,也不新增遥测或远程服务。 + +### 支持的平台与架构 + +- macOS Apple Silicon(arm64)与 Intel(x86_64)桌面包。 +- Linux x86_64 deb 与 AppImage;桌面运行需要发行版提供 Tauri/WebKitGTK 依赖。 +- Windows x86_64 NSIS 安装包。 +- `familiar-cli serve` 可在上述平台运行。 + +### 签名与公证状态 + +- macOS 使用 CI 配置的 ad-hoc 签名标识,不提供 Apple Developer ID 签名或公证;Gatekeeper 可能要求首次手动允许。 +- Windows 安装包未配置代码签名;SmartScreen 可能要求首次安装手动确认。 +- Linux deb/AppImage 未由发行版渠道签名。 + +### 性能数据 + +本版本未建立新的可复现端到端性能基线,不宣称量化性能变化。 + +--- + +## English + +### Major features and fixes + +- Add a separate Creative Workshop window, accessible from the settings sidebar and pack management area. Create packs, edit custom packs, or make an editable copy of a built-in pack. +- Choose or drop PNG, APNG, GIF, WebP, and SVG images for each state. Click a state to preview it at its original aspect ratio. Only the name and `idle` image are required; the app generates pack identifiers. +- Validate image and pack sizes, formats, and paths before saving. Keep a local backup when editing a custom pack. Saving refreshes the pack list and active desktop pet. Custom packs can be deleted, and saved work can be exported as `.fpack`. +- Confirm unsaved changes inside the workshop window when switching packs or closing it, without blocking other windows. Add Chinese and English UI text and align workshop colors with the settings panel. + +### Compatibility and configuration migration + +- Version metadata is upgraded to `3.0.0`. Existing packs still use `pack.json` Format Version 1 and the `.fpack` format; no migration is needed. Existing configuration and Hook installations do not need changes. +- Built-in packs remain usable as before, but must be copied before editing in the workshop. + +### Privacy impact + +- The workshop reads, saves, and exports images locally. It adds no asset uploads, telemetry, or remote service. + +### Supported platforms and architectures + +- macOS desktop bundles for Apple Silicon (arm64) and Intel (x86_64). +- Linux x86_64 deb and AppImage packages; the desktop requires the distribution's Tauri/WebKitGTK dependencies. +- Windows x86_64 NSIS installer. +- `familiar-cli serve` can run on the platforms above. + +### Signing and notarization status + +- macOS uses the ad-hoc signing identity configured in CI and is not signed with an Apple Developer ID or notarized; Gatekeeper may require manual approval on first launch. +- The Windows installer is not code-signed; SmartScreen may require manual confirmation on first install. +- Linux deb/AppImage artifacts are not signed by a distribution channel. + +### Performance data + +No new reproducible end-to-end performance baseline was established for this release, and no quantified performance change is claimed. From 9b521fcca758d7821c85a6ae77e69ae06d5c9a9a Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Mon, 12 Oct 2026 20:09:47 +0800 Subject: [PATCH 4/8] feat: add fullscreen auto-hide and bump version to 3.0.1 --- Cargo.lock | 10 +- Cargo.toml | 2 +- app/package-lock.json | 4 +- app/package.json | 2 +- app/src-tauri/src/fullscreen_visibility.rs | 217 +++++++++++++++++++++ app/src-tauri/src/main.rs | 2 + app/src/i18n.js | 4 + app/src/settings.html | 11 ++ app/src/settings.js | 6 +- config/default.toml | 2 + crates/familiar-core/src/config.rs | 3 + crates/familiar-core/tests/config.rs | 13 ++ 12 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 app/src-tauri/src/fullscreen_visibility.rs diff --git a/Cargo.lock b/Cargo.lock index b00cf9e..4becb8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1370,7 +1370,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "familiar-api" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "axum", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "familiar-app" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "axum", @@ -1416,7 +1416,7 @@ dependencies = [ [[package]] name = "familiar-cli" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "axum", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "familiar-core" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "chrono", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "familiar-hooks" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1dd21d4..9e1d117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.0.0" +version = "3.0.1" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/app/package-lock.json b/app/package-lock.json index 7894f7f..937169b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "familiar-app-ui", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "familiar-app-ui", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT OR Apache-2.0", "devDependencies": { "@tauri-apps/api": "^2.11.1", diff --git a/app/package.json b/app/package.json index fc76476..2691c35 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "familiar-app-ui", - "version": "3.0.0", + "version": "3.0.1", "private": true, "license": "MIT OR Apache-2.0", "type": "module", diff --git a/app/src-tauri/src/fullscreen_visibility.rs b/app/src-tauri/src/fullscreen_visibility.rs new file mode 100644 index 0000000..3f97b93 --- /dev/null +++ b/app/src-tauri/src/fullscreen_visibility.rs @@ -0,0 +1,217 @@ +//! Only restore visibility that this feature itself suppressed. +use std::sync::Arc; +use tauri::Manager; + +pub fn start(app: tauri::AppHandle, config: Arc) { + tauri::async_runtime::spawn(async move { + let mut hidden = false; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(window) = app.get_webview_window("main") else { + break; + }; + let enabled = config.get_config().renderer.desktop_pet.hide_on_fullscreen; + if !enabled && !hidden { + continue; + } + // AppKit queries and visibility changes must run on the main thread. + let (tx, rx) = tokio::sync::oneshot::channel(); + let target = window.clone(); + if window + .run_on_main_thread(move || { + let suppress = enabled && is_fullscreen(&target); + let mut next = hidden; + if suppress { + if target.is_visible().unwrap_or(false) && target.hide().is_ok() { + next = true; + } + } else if hidden && target.show().is_ok() { + next = false; + } + let _ = tx.send(next); + }) + .is_err() + { + break; + } + if let Ok(next) = rx.await { + hidden = next; + } + } + }); +} + +#[cfg(any(target_os = "macos", target_os = "windows", test))] +fn fills_screen(window: (f64, f64, f64, f64), screen: (f64, f64, f64, f64)) -> bool { + // Allow rounding at fractional DPI, but exclude ordinary maximized windows. + (window.0 - screen.0).abs() <= 2.0 + && (window.1 - screen.1).abs() <= 2.0 + && (window.2 - screen.2).abs() <= 2.0 + && (window.3 - screen.3).abs() <= 2.0 +} + +#[cfg(target_os = "macos")] +fn is_fullscreen(window: &tauri::WebviewWindow) -> bool { + use cocoa::base::{id, nil}; + use cocoa::foundation::NSRect; + use objc::{class, msg_send, sel, sel_impl}; + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(options: u32, relative: u32) -> id; + fn CGDisplayBounds(display: u32) -> NSRect; + } + unsafe { + let Ok(ptr) = window.ns_window() else { + return false; + }; + let screen: id = msg_send![ptr as id, screen]; + if screen == nil { + return false; + } + let description: id = msg_send![screen, deviceDescription]; + let key = cocoa::foundation::NSString::alloc(nil); + use cocoa::foundation::NSString; + let key = key.init_str("NSScreenNumber"); + let number: id = msg_send![description, objectForKey: key]; + let _: () = msg_send![key, release]; + if number == nil { + return false; + } + let display: u32 = msg_send![number, unsignedIntValue]; + let bounds = CGDisplayBounds(display); + let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; + let front: id = msg_send![workspace, frontmostApplication]; + if front == nil { + return false; + } + let pid: i32 = msg_send![front, processIdentifier]; + if pid == std::process::id() as i32 { + return false; + } + // Onscreen only + exclude desktop elements. Bounds/PID/layer are available + // without reading titles or capturing any screen content. + let list = CGWindowListCopyWindowInfo(1 | 16, 0); + if list == nil { + return false; + } + let count: usize = msg_send![list, count]; + let mut result = false; + for i in 0..count { + let entry: id = msg_send![list, objectAtIndex: i]; + let value = |name: &str| -> id { + let key = NSString::alloc(nil).init_str(name); + let val: id = msg_send![entry, objectForKey: key]; + let _: () = msg_send![key, release]; + val + }; + let owner = value("kCGWindowOwnerPID"); + let layer = value("kCGWindowLayer"); + if owner == nil || layer == nil { + continue; + } + let owner: i32 = msg_send![owner, intValue]; + let layer: i32 = msg_send![layer, intValue]; + if owner != pid || layer != 0 { + continue; + } + let rect = value("kCGWindowBounds"); + if rect == nil { + continue; + } + let component = |name: &str| -> f64 { + let key = NSString::alloc(nil).init_str(name); + let val: id = msg_send![rect, objectForKey: key]; + let _: () = msg_send![key, release]; + if val == nil { + return f64::NAN; + } + msg_send![val, doubleValue] + }; + let rect = ( + component("X"), + component("Y"), + component("Width"), + component("Height"), + ); + let screen = ( + bounds.origin.x, + bounds.origin.y, + bounds.size.width, + bounds.size.height, + ); + if rect.0 < screen.0 + screen.2 + && rect.0 + rect.2 > screen.0 + && rect.1 < screen.1 + screen.3 + && rect.1 + rect.3 > screen.1 + { + result = fills_screen(rect, screen); + break; + } + } + let _: () = msg_send![list, release]; + result + } +} + +#[cfg(target_os = "windows")] +fn is_fullscreen(window: &tauri::WebviewWindow) -> bool { + use windows::Win32::Foundation::RECT; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetShellWindow, GetWindowRect, GetWindowThreadProcessId, + }; + unsafe { + let front = GetForegroundWindow(); + if front.0.is_null() || front == GetShellWindow() { + return false; + } + let mut pid = 0; + GetWindowThreadProcessId(front, Some(&mut pid)); + if pid == std::process::id() { + return false; + } + let mut rect = RECT::default(); + if GetWindowRect(front, &mut rect).is_err() { + return false; + } + let Ok(Some(monitor)) = window.current_monitor() else { + return false; + }; + let pos = monitor.position(); + let size = monitor.size(); + fills_screen( + ( + rect.left as f64, + rect.top as f64, + (rect.right - rect.left) as f64, + (rect.bottom - rect.top) as f64, + ), + ( + pos.x as f64, + pos.y as f64, + size.width as f64, + size.height as f64, + ), + ) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn is_fullscreen(_window: &tauri::WebviewWindow) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn distinguishes_fullscreen_from_maximized_and_other_monitors() { + let screen = (0., 0., 1920., 1080.); + assert!(fills_screen(screen, screen)); + assert!(fills_screen((1., 0., 1919., 1080.), screen)); + assert!(!fills_screen((0., 25., 1920., 1015.), screen)); + assert!(!fills_screen((1920., 0., 1920., 1080.), screen)); + assert!(!fills_screen((f64::NAN, 0., 1920., 1080.), screen)); + } +} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 145bacf..2756cf4 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -6,6 +6,7 @@ mod commands; mod desktop_pet_window; +mod fullscreen_visibility; mod remote_state; mod tray; mod updates; @@ -279,6 +280,7 @@ fn main() { // unplugged. A pet that is still visible is never touched, so // unrelated monitor changes (another display sleeping, docking // flaps) no longer move it. + fullscreen_visibility::start(app.handle().clone(), app_config_state_for_setup.clone()); let watcher_app_handle = app.handle().clone(); let watcher_config_state = app_config_state_for_setup.clone(); tauri::async_runtime::spawn(async move { diff --git a/app/src/i18n.js b/app/src/i18n.js index 9ae5c02..1ab77f6 100644 --- a/app/src/i18n.js +++ b/app/src/i18n.js @@ -134,6 +134,8 @@ export const translations = { 'lbl_pet_scale': '缩放比例', 'desc_pet_scale': '调整显示大小 (1.0 - 5.0)', + 'lbl_hide_fullscreen': '全屏时自动隐藏', + 'desc_hide_fullscreen': '前台应用在桌宠所在屏幕全屏时隐藏,退出后自动恢复(macOS / Windows)', 'lbl_always_top': '总是在最前', 'desc_always_top': '悬浮在所有系统窗口之上,避免被遮挡', 'lbl_all_desktops': '跟随桌面切换', @@ -472,6 +474,8 @@ export const translations = { 'lbl_pet_scale': 'Scale', 'desc_pet_scale': 'Adjust the display size (1.0 - 5.0)', + 'lbl_hide_fullscreen': 'Auto-hide in fullscreen', + 'desc_hide_fullscreen': 'Hide when the foreground app fills the pet’s screen; restore on exit (macOS / Windows).', 'lbl_always_top': 'Always on Top', 'desc_always_top': 'Keep floating above all system windows', 'lbl_all_desktops': 'Follow Desktop Switching', diff --git a/app/src/settings.html b/app/src/settings.html index f37e99f..aeed73b 100644 --- a/app/src/settings.html +++ b/app/src/settings.html @@ -88,6 +88,17 @@

宠物

+
+
+
全屏时自动隐藏
+
前台应用在桌宠所在屏幕全屏时隐藏,退出后自动恢复(macOS / Windows)
+
+ +
+
总是在最前
diff --git a/app/src/settings.js b/app/src/settings.js index 07cefae..ffe2e80 100644 --- a/app/src/settings.js +++ b/app/src/settings.js @@ -58,6 +58,7 @@ document.addEventListener('DOMContentLoaded', async () => { const elPetScale = document.getElementById('setting-pet-scale'); const valPetScale = document.getElementById('val-pet-scale'); + const elPetFullscreen = document.getElementById('setting-pet-fullscreen'); const elPetAlwaysTop = document.getElementById('setting-pet-always-top'); const elPetAllDesktops = document.getElementById('setting-pet-all-desktops'); const elPetClickThrough = document.getElementById('setting-pet-click-through'); @@ -107,6 +108,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { const platform = await invoke('get_platform'); if (platform === 'windows') hideSettingItem(elUdsPath); + if (platform === 'linux') hideSettingItem(elPetFullscreen); if (platform !== 'darwin') hideSettingItem(elPetAllDesktops); } catch (e) { console.error('Failed to detect platform:', e); @@ -487,6 +489,7 @@ document.addEventListener('DOMContentLoaded', async () => { elRemoteReconnectMax.value = reconnectMax; currentConfig.renderer['desktop-pet'].scale = parseFloat(elPetScale.value); + currentConfig.renderer['desktop-pet'].hide_on_fullscreen = elPetFullscreen.checked; currentConfig.renderer['desktop-pet'].always_on_top = elPetAlwaysTop.checked; currentConfig.renderer['desktop-pet'].show_on_all_desktops = elPetAllDesktops.checked; currentConfig.renderer['desktop-pet'].click_through = elPetClickThrough.checked; @@ -674,6 +677,7 @@ document.addEventListener('DOMContentLoaded', async () => { elPetScale.value = petConf.scale; if (valPetScale) valPetScale.textContent = Number(petConf.scale).toFixed(1); } + elPetFullscreen.checked = petConf.hide_on_fullscreen ?? false; if (petConf.always_on_top !== undefined) elPetAlwaysTop.checked = petConf.always_on_top; if (petConf.show_on_all_desktops !== undefined) { elPetAllDesktops.checked = petConf.show_on_all_desktops; @@ -1186,7 +1190,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Bind all controls for auto-save const autoSaveControls = [ elRuntimeMode, elRemoteTls, - elPetAlwaysTop, elPetAllDesktops, elPetClickThrough, elPetHideOnHover, elPetWindowFrame, elPetSnapCorner, elShowBubble, elShowPet, elShowStats, + elPetFullscreen, elPetAlwaysTop, elPetAllDesktops, elPetClickThrough, elPetHideOnHover, elPetWindowFrame, elPetSnapCorner, elShowBubble, elShowPet, elShowStats, elDashboardStyle, elDashboardPosition, elDashboardLayout, elDashboardAlignment, elCleanupBackups, elCleanupLogs, elUpdateStartup, elUpdateInterval diff --git a/config/default.toml b/config/default.toml index 8381dbc..46b8b4e 100644 --- a/config/default.toml +++ b/config/default.toml @@ -32,6 +32,8 @@ scale = 1.3 # pet keeps its relative spot when monitors change. position = "bottom-right" always_on_top = true +# Hide on this monitor while the foreground application fills the screen. +hide_on_fullscreen = false show_on_all_desktops = true opacity = 1.0 # Opacity applied while the pointer hovers over the pet (0.1 - 1.0). Keeps the diff --git a/crates/familiar-core/src/config.rs b/crates/familiar-core/src/config.rs index 13aa3be..c632385 100644 --- a/crates/familiar-core/src/config.rs +++ b/crates/familiar-core/src/config.rs @@ -199,6 +199,8 @@ pub struct DesktopPetConfig { pub scale: f32, pub position: String, pub always_on_top: bool, + #[serde(default)] + pub hide_on_fullscreen: bool, #[serde(default = "default_true")] pub show_on_all_desktops: bool, pub opacity: f32, @@ -482,6 +484,7 @@ impl Default for FamiliarConfig { scale: 2.0, position: "bottom-right".to_string(), always_on_top: true, + hide_on_fullscreen: false, show_on_all_desktops: true, opacity: 0.95, show_task_bubble: true, diff --git a/crates/familiar-core/tests/config.rs b/crates/familiar-core/tests/config.rs index 58ae8a7..8d26dc8 100644 --- a/crates/familiar-core/tests/config.rs +++ b/crates/familiar-core/tests/config.rs @@ -635,3 +635,16 @@ fn show_window_frame_serializes_as_bool() { "unexpected serialized show_window_frame:\n{serialized}" ); } + +#[test] +fn fullscreen_hiding_defaults_off_and_roundtrips() { + let source = include_str!("../../../config/default.toml"); + let legacy: FamiliarConfig = toml::from_str(&source.replace("hide_on_fullscreen = false\n", "")).unwrap(); + assert!(!legacy.renderer.desktop_pet.hide_on_fullscreen); + let mut enabled = legacy; + enabled.renderer.desktop_pet.hide_on_fullscreen = true; + let serialized = toml::to_string(&enabled).unwrap(); + assert!(serialized.contains("hide_on_fullscreen = true")); + let restored: FamiliarConfig = toml::from_str(&serialized).unwrap(); + assert!(restored.renderer.desktop_pet.hide_on_fullscreen); +} From 3d8360ac70143ea5ed01c2d7ffcd11626e96255b Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Mon, 12 Oct 2026 20:27:26 +0800 Subject: [PATCH 5/8] docs: improve bilingual README layout and onboarding images --- README.md | 135 ++++++++++++++++++++++++++++++++++++--------------- README_zh.md | 135 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 190 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 8eceddf..956ff04 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,48 @@ -# Familiar +

+ Familiar +

-> *A desktop pet that reacts to your world.* +

Familiar

-[简体中文](README_zh.md) +

A desktop pet that reacts to your world.

+ +

+ Latest release + Rust CI + License: MIT or Apache-2.0 + Platforms: macOS, Windows, Linux +

+ +

+ 简体中文 · English · Download · Documentation · Issues +

Familiar is a local-first desktop companion for coding agents. It turns Hook events into the actions, task bubbles, and status changes of a pixel-art desktop pet—without adding another dashboard to watch. -## Features +
+Contents -- **Reactive desktop pet** — Pixel-art actions follow agent activity without interrupting your flow. -- **Agent integrations** — Connect Claude Code, Codex CLI, DeepSeek Harness, Qoder, and Google Antigravity through their Hook APIs. -- **Remote connections** — Run the Agent and Familiar server remotely while the local desktop client subscribes to pet state. -- **System dashboard** — Show CPU, memory, and disk usage beside the pet. -- **Sprite packs** — Import more companions with the `.fpack` format. -- **Lightweight** — Pause unnecessary polling while UI surfaces are hidden. -- **Privacy-first** — Process events locally by default, with no telemetry and minimal operational logs. +- [Why Familiar?](#why-familiar) +- [Install](#install) +- [Quick start](#quick-start) +- [Features](#features) +- [Supported integrations](#supported-integrations) +- [Remote mode](#remote-mode) +- [Privacy](#privacy) +- [Build from source](#build-from-source) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) -## Supported integrations +
-| Integration | Transport | Status | -| --- | --- | --- | -| Claude Code | Hooks | Available | -| Codex CLI | Hooks | Available | -| DeepSeek Harness | Hooks | Available | -| Qoder | Hooks | Available | -| Google Antigravity | Hooks | Available | +--- -Familiar only manages its own Hook entries. Installation creates a backup and preserves existing agent configuration. +## Why Familiar? + +- **One less window to watch**: Follow agent activity through pet actions and task bubbles. +- **Fits your workflow**: Connect through agent Hook APIs while preserving existing configuration. +- **Your data, your choice**: Run locally by default; configure remote connections when needed. ## Install @@ -48,15 +64,17 @@ brew install Monster12138/familiar/familiar-cli Download the latest release from [GitHub Releases](https://github.com/Monster12138/familiar/releases). -Current release targets: - -- macOS arm64 and x86_64 -- Windows x86_64 -- Linux x86_64 (`.deb` and AppImage) +| Platform | Architecture | Installation | +| --- | --- | --- | +| macOS | arm64 / x86_64 | Homebrew Cask or release download | +| Windows | x86_64 | Release download | +| Linux | x86_64 | `.deb` / AppImage | -macOS packages are not notarized with an Apple Developer ID, and Windows packages are not code-signed. Gatekeeper or SmartScreen may require manual approval on first launch. +> [!NOTE] +> macOS packages are not notarized with an Apple Developer ID, and Windows packages are not code-signed. Gatekeeper or SmartScreen may require manual approval on first launch. -### First launch on macOS +
+First launch on macOS: allow an unnotarized app Because Familiar is not yet notarized, macOS blocks it the first time you open it. If you installed Familiar from this repository's Homebrew tap or GitHub @@ -64,35 +82,56 @@ Releases, allow it manually: 1. Open Familiar. When macOS says it cannot verify the app, click **Done**. - macOS cannot verify Familiar + macOS cannot verify Familiar 2. Open **System Settings → Privacy & Security**, scroll to **Security**, find the message that Familiar was blocked, and click **Open Anyway**. - Open Anyway in macOS Privacy and Security settings + Open Anyway in macOS Privacy and Security settings 3. In the confirmation dialog, click **Open Anyway** again. Authenticate with your password or Touch ID if macOS asks. Familiar will open normally from then on. - Confirm opening Familiar on macOS + Confirm opening Familiar on macOS Only bypass this warning when you obtained Familiar from the official sources linked above. -## Build from source +
-Requirements: Rust 1.88+, Node.js 18+, npm, and the desktop prerequisites for your platform. +## Quick start -```bash -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings +1. **Launch Familiar** and choose local mode in onboarding. +2. **Connect an agent**: Preview and confirm Hook injection in onboarding or **Settings → Hooks**. +3. **Start an agent task** and watch the pet actions and task bubbles respond to its activity. +4. **Make it yours**: Choose visible content, click-through, or fullscreen auto-hide in **Settings → Display Configuration**, and switch companions in **Pet Materials**. -cd app -npm ci -npm run build -npm run tauri dev -``` +> [!TIP] +> For agents running on another machine, use [remote mode](#remote-mode) and manage Hooks on the server. + +## Features + +- **Reactive desktop pet** — Pixel-art actions follow agent activity without interrupting your flow. +- **Agent integrations** — Connect Claude Code, Codex CLI, DeepSeek Harness, Qoder, and Google Antigravity through their Hook APIs. +- **Remote connections** — Run the Agent and Familiar server remotely while the local desktop client subscribes to pet state. +- **System dashboard** — Show CPU, memory, and disk usage beside the pet. +- **Sprite packs** — Import more companions with the `.fpack` format. +- **Lightweight** — Pause unnecessary polling while UI surfaces are hidden. +- **Privacy-first** — Process events locally by default, with no telemetry and minimal operational logs. +- **Fullscreen auto-hide** — Optionally hide the pet when the foreground app fills its screen on macOS / Windows, then restore it on exit. Off by default; unavailable on Linux. + +## Supported integrations + +| Integration | Transport | Status | +| --- | --- | --- | +| Claude Code | Hooks | Available | +| Codex CLI | Hooks | Available | +| DeepSeek Harness | Hooks | Available | +| Qoder | Hooks | Available | +| Google Antigravity | Hooks | Available | + +Familiar only manages its own Hook entries. Installation creates a backup and preserves existing agent configuration. ## Remote mode @@ -108,6 +147,22 @@ Remote mode sends bounded render-state summaries to the configured Familiar serv See [Privacy & Data Handling](docs/PRIVACY.md) for details. +## Build from source + +Requirements: Rust 1.88+, Node.js 18+, npm, and the desktop prerequisites for your platform. + +```bash +# Build the CLI resource required by the desktop app +cargo build --release -p familiar-cli +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings + +cd app +npm ci +npm run build +npm run tauri dev +``` + ## Documentation - [Privacy & Data Handling](docs/PRIVACY.md) diff --git a/README_zh.md b/README_zh.md index 2cbf08d..292861f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,32 +1,48 @@ -# Familiar +

+ Familiar +

-> *一只会回应你工作世界的桌面宠物。* +

Familiar

-[English](README.md) +

一只会回应你工作世界的桌面宠物。

+ +

+ Latest release + Rust CI + License: MIT or Apache-2.0 + Platforms: macOS, Windows, Linux +

+ +

+ 简体中文 · English · 下载 · 文档 · 反馈 +

Familiar 是一个本地优先的编程 Agent 桌面伴侣。它把 Hook 事件转化为像素桌宠的动作、任务气泡和状态变化,让你无需盯着另一个面板也能感知 Agent 的工作进度。 -## 特性 +
+目录 -- **会回应的桌面宠物** —— 像素风动作随 Agent 活动变化,不打断工作流。 -- **Agent 集成** —— 通过 Hook API 连接 Claude Code、Codex CLI、DeepSeek Harness、Qoder 和 Google Antigravity。 -- **支持远程连接** —— Agent 与 Familiar 服务端可在远程机器运行,本地桌面端订阅桌宠状态。 -- **系统状态仪表** —— 在桌宠旁显示 CPU、内存和磁盘占用。 -- **Sprite Pack 皮肤包** —— 使用 `.fpack` 格式导入更多桌宠形象。 -- **轻量低占用** —— UI 隐藏时暂停不必要的轮询。 -- **隐私优先** —— 默认在本机处理事件,无遥测,运行日志最小化。 +- [为什么选择 Familiar](#为什么选择-familiar) +- [安装](#安装) +- [快速开始](#快速开始) +- [功能特性](#功能特性) +- [支持的集成](#支持的集成) +- [远程模式](#远程模式) +- [隐私](#隐私) +- [从源码构建](#从源码构建) +- [文档](#文档) +- [参与贡献](#参与贡献) +- [开源协议](#开源协议) -## 支持的集成 +
-| 集成 | 传输方式 | 状态 | -| --- | --- | --- | -| Claude Code | Hooks | 可用 | -| Codex CLI | Hooks | 可用 | -| DeepSeek Harness | Hooks | 可用 | -| Qoder | Hooks | 可用 | -| Google Antigravity | Hooks | 可用 | +--- -Familiar 只管理自己注入的 Hook。安装前会创建备份,并保留 Agent 的现有配置。 +## 为什么选择 Familiar + +- **少盯一个窗口**:用桌宠动作和任务气泡感知 Agent 活动。 +- **融入现有工作流**:通过 Agent 的 Hook API 接入,保留已有配置。 +- **由你掌控数据**:默认本地运行;需要跨机器协作时再配置远程连接。 ## 安装 @@ -48,45 +64,68 @@ brew install Monster12138/familiar/familiar-cli 从 [GitHub Releases](https://github.com/Monster12138/familiar/releases) 下载最新版本。 -当前发布目标: - -- macOS arm64 与 x86_64 -- Windows x86_64 -- Linux x86_64(`.deb` 与 AppImage) +| 平台 | 架构 | 安装方式 | +| --- | --- | --- | +| macOS | arm64 / x86_64 | Homebrew Cask 或发布包 | +| Windows | x86_64 | 发布包 | +| Linux | x86_64 | `.deb` / AppImage | -macOS 安装包尚未使用 Apple Developer ID 签名和公证,Windows 安装包尚未进行代码签名。首次启动时,Gatekeeper 或 SmartScreen 可能要求手动允许。 +> [!NOTE] +> macOS 安装包尚未使用 Apple Developer ID 签名和公证,Windows 安装包尚未进行代码签名。首次启动时,Gatekeeper 或 SmartScreen 可能要求手动允许。 -### macOS 首次启动 +
+macOS 首次启动:允许打开未公证的应用 由于 Familiar 尚未完成 Apple 公证,macOS 会在首次启动时阻止应用打开。如果你是通过本仓库的 Homebrew Tap 或 GitHub Releases 安装 Familiar,请按以下步骤手动允许: 1. 打开 Familiar。macOS 提示无法验证应用时,点击**完成**。 - macOS 无法验证 Familiar + macOS 无法验证 Familiar 2. 打开**系统设置 → 隐私与安全性**,向下滚动到**安全性**区域,找到 Familiar 已被阻止的提示,然后点击**仍要打开**。 - 在 macOS 隐私与安全性设置中点击仍要打开 + 在 macOS 隐私与安全性设置中点击仍要打开 3. 在确认对话框中再次点击**仍要打开**。如果 macOS 要求认证,请输入密码或使用 Touch ID。此后 Familiar 即可正常打开。 - 确认在 macOS 上打开 Familiar + 确认在 macOS 上打开 Familiar 仅当 Familiar 来自上方链接的官方来源时,才应绕过此安全警告。 -## 从源码构建 +
-需要 Rust 1.88+、Node.js 18+、npm,以及对应平台的桌面开发依赖。 +## 快速开始 -```bash -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings +1. **启动 Familiar**,在引导面板选择本地模式。 +2. **连接 Agent**:在引导面板或 **设置 → Hooks** 中,为需要的 Agent 预览并确认注入 Hooks。 +3. **开始一个 Agent 任务**,观察桌宠动作与任务气泡随工作状态变化。 +4. **调整桌面显示**:在 **设置 → 显示配置** 中选择显示内容、鼠标穿透或全屏自动隐藏;在 **宠物素材** 中切换形象。 -cd app -npm ci -npm run build -npm run tauri dev -``` +> [!TIP] +> 远程机器上的 Agent 请使用[远程模式](#远程模式),并在服务端管理 Hooks。 + +## 功能特性 + +- **会回应的桌面宠物** —— 像素风动作随 Agent 活动变化,不打断工作流。 +- **Agent 集成** —— 通过 Hook API 连接 Claude Code、Codex CLI、DeepSeek Harness、Qoder 和 Google Antigravity。 +- **支持远程连接** —— Agent 与 Familiar 服务端可在远程机器运行,本地桌面端订阅桌宠状态。 +- **系统状态仪表** —— 在桌宠旁显示 CPU、内存和磁盘占用。 +- **Sprite Pack 皮肤包** —— 使用 `.fpack` 格式导入更多桌宠形象。 +- **轻量低占用** —— UI 隐藏时暂停不必要的轮询。 +- **隐私优先** —— 默认在本机处理事件,无遥测,运行日志最小化。 +- **全屏自动隐藏** —— macOS / Windows 可在前台应用全屏时隐藏桌宠,退出后自动恢复;默认关闭,Linux 不显示此选项。 + +## 支持的集成 + +| 集成 | 传输方式 | 状态 | +| --- | --- | --- | +| Claude Code | Hooks | 可用 | +| Codex CLI | Hooks | 可用 | +| DeepSeek Harness | Hooks | 可用 | +| Qoder | Hooks | 可用 | +| Google Antigravity | Hooks | 可用 | + +Familiar 只管理自己注入的 Hook。安装前会创建备份,并保留 Agent 的现有配置。 ## 远程模式 @@ -102,6 +141,22 @@ npm run tauri dev 详细说明见[隐私与数据处理](docs/PRIVACY.md)。 +## 从源码构建 + +需要 Rust 1.88+、Node.js 18+、npm,以及对应平台的桌面开发依赖。 + +```bash +# 构建桌面端打包所需的 CLI 资源 +cargo build --release -p familiar-cli +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings + +cd app +npm ci +npm run build +npm run tauri dev +``` + ## 文档 - [隐私与数据处理](docs/PRIVACY.md) From a4c9da25af61fdcff7f828b9e4fb90b31f30e77c Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Mon, 12 Oct 2026 20:32:29 +0800 Subject: [PATCH 6/8] docs: highlight multi-agent support in README --- README.md | 1 + README_zh.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 956ff04..8d0db93 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Familiar is a local-first desktop companion for coding agents. It turns Hook eve ## Why Familiar? +- **Multi-agent support**: Claude-code, ChatGpt, DeepSeek Harness... one desktop pet handles them all. - **One less window to watch**: Follow agent activity through pet actions and task bubbles. - **Fits your workflow**: Connect through agent Hook APIs while preserving existing configuration. - **Your data, your choice**: Run locally by default; configure remote connections when needed. diff --git a/README_zh.md b/README_zh.md index 292861f..5e7225d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -40,6 +40,7 @@ Familiar 是一个本地优先的编程 Agent 桌面伴侣。它把 Hook 事件 ## 为什么选择 Familiar +- **多 Agent 支持**:Claude-code、ChatGpt、DeepSeek Harness...,一只桌面宠物全搞定。 - **少盯一个窗口**:用桌宠动作和任务气泡感知 Agent 活动。 - **融入现有工作流**:通过 Agent 的 Hook API 接入,保留已有配置。 - **由你掌控数据**:默认本地运行;需要跨机器协作时再配置远程连接。 From a21fe983924124b79fcd6313fde243baaa0aa3d2 Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Tue, 13 Oct 2026 16:15:51 +0800 Subject: [PATCH 7/8] fix: restore fullscreen-hidden pet without stealing focus --- app/src-tauri/src/fullscreen_visibility.rs | 103 ++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/fullscreen_visibility.rs b/app/src-tauri/src/fullscreen_visibility.rs index 3f97b93..15c1316 100644 --- a/app/src-tauri/src/fullscreen_visibility.rs +++ b/app/src-tauri/src/fullscreen_visibility.rs @@ -24,10 +24,12 @@ pub fn start(app: tauri::AppHandle, config: Arc let suppress = enabled && is_fullscreen(&target); let mut next = hidden; if suppress { - if target.is_visible().unwrap_or(false) && target.hide().is_ok() { + if target.is_visible().unwrap_or(false) + && hide_for_fullscreen(&target).is_ok() + { next = true; } - } else if hidden && target.show().is_ok() { + } else if hidden && restore_without_focus(&target).is_ok() { next = false; } let _ = tx.send(next); @@ -43,6 +45,58 @@ pub fn start(app: tauri::AppHandle, config: Arc }); } +fn hide_for_fullscreen(window: &tauri::WebviewWindow) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + // Pair native hide/show: Tauri caches visibility when hiding, so + // mixing Tauri hide with native show makes subsequent hides no-ops. + set_windows_visibility(window.hwnd().map_err(|error| error.to_string())?, false) + } + #[cfg(not(target_os = "windows"))] + window.hide().map_err(|error| error.to_string()) +} + +/// Called on the main thread. Automatic restoration must preserve the user's +/// input target; Tauri's macOS `show()` calls `makeKeyAndOrderFront`. +fn restore_without_focus(window: &tauri::WebviewWindow) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use cocoa::base::id; + use objc::{msg_send, sel, sel_impl}; + + let native = window.ns_window().map_err(|error| error.to_string())?; + unsafe { + let _: () = msg_send![native as id, orderFrontRegardless]; + } + Ok(()) + } + #[cfg(target_os = "windows")] + { + set_windows_visibility(window.hwnd().map_err(|error| error.to_string())?, true) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + window.show().map_err(|error| error.to_string()) +} + +#[cfg(target_os = "windows")] +fn set_windows_visibility( + window: windows::Win32::Foundation::HWND, + visible: bool, +) -> Result<(), String> { + use windows::Win32::UI::WindowsAndMessaging::{ + IsWindowVisible, ShowWindow, SW_HIDE, SW_SHOWNA, + }; + unsafe { + // ShowWindow returns the previous visibility, not a success flag. + // SW_SHOWNA preserves size/position and never activates the pet. + let _ = ShowWindow(window, if visible { SW_SHOWNA } else { SW_HIDE }); + if IsWindowVisible(window).as_bool() != visible { + return Err("failed to update desktop pet visibility".to_string()); + } + } + Ok(()) +} + #[cfg(any(target_os = "macos", target_os = "windows", test))] fn fills_screen(window: (f64, f64, f64, f64), screen: (f64, f64, f64, f64)) -> bool { // Allow rounding at fractional DPI, but exclude ordinary maximized windows. @@ -205,6 +259,51 @@ fn is_fullscreen(_window: &tauri::WebviewWindow) -> bool { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "windows")] + #[test] + fn repeated_windows_restoration_preserves_foreground_window() { + use windows::core::w; + use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, GetForegroundWindow, IsWindowVisible, WINDOW_EX_STYLE, + WS_POPUP, + }; + unsafe { + let native = CreateWindowExW( + WINDOW_EX_STYLE::default(), + w!("STATIC"), + w!("Familiar visibility test"), + WS_POPUP, + 0, + 0, + 100, + 100, + None, + None, + None, + None, + ) + .expect("create hidden test window"); + struct TestWindow(windows::Win32::Foundation::HWND); + impl Drop for TestWindow { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(self.0); + } + } + } + let _guard = TestWindow(native); + let foreground = GetForegroundWindow(); + for _ in 0..3 { + set_windows_visibility(native, true).expect("restore without activation"); + assert!(IsWindowVisible(native).as_bool()); + assert_eq!(GetForegroundWindow(), foreground); + set_windows_visibility(native, false).expect("hide again"); + assert!(!IsWindowVisible(native).as_bool()); + assert_eq!(GetForegroundWindow(), foreground); + } + } + } + #[test] fn distinguishes_fullscreen_from_maximized_and_other_monitors() { let screen = (0., 0., 1920., 1080.); From a4b800116ddb25551ffd374ece3ebfe6ab6b1b6b Mon Sep 17 00:00:00 2001 From: Monster12138 Date: Tue, 13 Oct 2026 23:18:28 +0800 Subject: [PATCH 8/8] docs: add v3.0.1 release notes --- docs/releases/v3.0.1.md | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/releases/v3.0.1.md diff --git a/docs/releases/v3.0.1.md b/docs/releases/v3.0.1.md new file mode 100644 index 0000000..bc99397 --- /dev/null +++ b/docs/releases/v3.0.1.md @@ -0,0 +1,73 @@ +# Familiar v3.0.1 + +[中文](#中文) | [English](#english) + +## 中文 + +### 主要功能与修复 + +- 新增“全屏时自动隐藏”选项。启用后,macOS 和 Windows 上的桌宠会在前台应用占满桌宠所在屏幕时隐藏,并在退出全屏后恢复。Linux 不显示此选项。 +- 自动恢复桌宠时不再激活宠物窗口,避免打断当前应用的键盘输入焦点。Windows 使用不激活窗口的显示方式,并配对原生显示/隐藏调用,保证连续全屏切换仍能正常工作。 +- 优化中英文 README 的导航、安装信息和首次启动说明。 + +### 兼容性与配置迁移 + +- 版本元数据升级至 `3.0.1`。新增配置 `renderer.desktop-pet.hide_on_fullscreen`,默认值为 `false`;旧配置无需迁移,现有桌宠行为保持不变。 +- 全屏隐藏目前支持 macOS 和 Windows 桌面应用;Linux 不支持此功能,也不显示相关选项。 + +### 隐私影响 + +- 全屏检测在本机读取前台应用的窗口边界和进程信息,用于判断其是否占满桌宠所在屏幕;不会读取窗口标题或屏幕内容,也不会新增遥测或远程传输。 + +### 支持的平台与架构 + +- macOS 桌面包:Apple Silicon(arm64)与 Intel(x86_64)。 +- Linux x86_64:deb 与 AppImage;桌面运行需要发行版提供 Tauri/WebKitGTK 依赖。 +- Windows x86_64:NSIS 安装包。 +- `familiar-cli serve` 可在上述平台运行。 + +### 签名与公证状态 + +- macOS 使用 CI 配置的 ad-hoc 签名标识,不提供 Apple Developer ID 签名或公证;Gatekeeper 可能要求首次手动允许。 +- Windows 安装包未配置代码签名;SmartScreen 可能要求首次安装手动确认。 +- Linux deb/AppImage 未由发行版渠道签名。 + +### 性能数据 + +本版本未建立新的可复现端到端性能基线,不宣称量化性能变化。 + +--- + +## English + +### Major features and fixes + +- Add an **Auto-hide in fullscreen** setting. When enabled, the pet hides when the foreground app fills the pet's display on macOS and Windows, then returns after fullscreen ends. The setting is hidden on Linux. +- Restore the pet without activating its window, preserving keyboard focus in the current app. Windows uses a non-activating show operation paired with native hide/show calls so repeated fullscreen transitions continue to work. +- Improve navigation, installation information, and first-launch guidance in both READMEs. + +### Compatibility and configuration migration + +- Version metadata is upgraded to `3.0.1`. The new `renderer.desktop-pet.hide_on_fullscreen` setting defaults to `false`; existing configurations need no migration and retain their current behavior. +- Fullscreen auto-hide is supported on macOS and Windows desktop apps. Linux does not support this feature and does not show the setting. + +### Privacy impact + +- Fullscreen detection reads foreground window bounds and process information locally to determine whether the app fills the pet's display. It does not read window titles or screen contents, and adds no telemetry or remote transfer. + +### Supported platforms and architectures + +- macOS desktop bundles for Apple Silicon (arm64) and Intel (x86_64). +- Linux x86_64 deb and AppImage packages; the desktop requires the distribution's Tauri/WebKitGTK dependencies. +- Windows x86_64 NSIS installer. +- `familiar-cli serve` can run on the platforms above. + +### Signing and notarization status + +- macOS uses the ad-hoc signing identity configured in CI and is not signed with an Apple Developer ID or notarized; Gatekeeper may require manual approval on first launch. +- The Windows installer is not code-signed; SmartScreen may require manual confirmation on first install. +- Linux deb/AppImage artifacts are not signed by a distribution channel. + +### Performance data + +No new reproducible end-to-end performance baseline was established for this release, and no quantified performance change is claimed.