Skip to content

Commit d50da58

Browse files
zoulukuangclaude
andcommitted
fix(telemetry): code-review 第二批 — update_applied 阻塞上报 + 启动期 IO 后台化 + 开关 env/jsonc/失败提示 [feat: telemetry-usage-stats]
把上批未修的剩余 code-review 项清完(telemetry 16 单测 + typecheck 16/16 + app 808 全绿): - anomalyco#2 update_applied relaunch 前易丢:加 track_blocking + track_event_blocking_cmd,前端 await 后再 relaunch - anomalyco#10 启动期同步文件 IO:track 重构,opt-out 判定+文件 IO 整个丢进后台 spawn - anomalyco#4 UI 开关忽略 env:get_telemetry_enabled 返回 is_enabled() 有效值 - anomalyco#5 不读 opencode.jsonc:read_config_telemetry 按合并优先级读 config.json<opencode.json<opencode.jsonc - anomalyco#7 写失败静默:set_telemetry_enabled 返回 Result,前端失败 showToast 提示(+i18n×3) - anomalyco#9 事件名无共享常量:前端加 TELEMETRY_EVENT 常量集中事件名 - 顺带抽 home_base()/config_dir() 去重路径解析 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 08bec2a commit d50da58

8 files changed

Lines changed: 131 additions & 54 deletions

File tree

docs/features/telemetry-usage-stats/3-changelog.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,18 @@ related: ./1-spec.md ./2-plan.md ./3-changelog.md
5858
- **#6 install_id 0600**:落盘后 `restrict_to_owner` 收紧到 0600(Unix),匿名设备 ID 不被同机其他用户读到。
5959
- **#8 install_id UUID 校验**:读到非 UUID 脏值(云同步/外部进程写入、含控制字符)丢弃重生成,防脏值进 payload / UA header(控制字符会让 reqwest 构造 header 失败 → 该机 telemetry 永久静默失效)。
6060
- 新增单测:T1c(脏值重生成)/ T1d(0600 权限,Unix)/ T8(channel→site 映射)。
61-
- **未在本批修**(已记下供择时处理):#2 update_applied 紧接 relaunch 易丢、#4/#5 UI 开关忽略 env / 不读 opencode.jsonc、#7 opt-out 写失败静默无提示、#9 事件名前后端无共享常量、#10 setup 主线程同步文件 IO。
61+
62+
### 第二批(2026-06-07,把上批"未修"的剩余项也修掉)
63+
64+
telemetry 16 单测 + typecheck 16/16 + app 808 全绿:
65+
- **#2 update_applied relaunch 前丢失**:新增 `track_blocking` + `track_event_blocking_cmd`(async,等发送完成再返回);前端 `update_applied` 改为 `await invoke("track_event_blocking_cmd")``relaunch()`,确保事件发出。`update_downloaded` 保持 fire-and-forget。
66+
- **#10 setup 主线程同步文件 IO**:`track` 重构为先 `prepare_event`(opt-out 判定 + 文件 IO)整个丢进后台 spawn,启动线程不再做任何文件 IO。
67+
- **#4 UI 开关忽略 env**:`get_telemetry_enabled` 改返回 `is_enabled()` 有效值(env>config>默认),UI 显示与实际上报一致(env 覆盖时不再显示假状态)。
68+
- **#5 不读 opencode.jsonc**:`read_config_telemetry` 改按 opencode 合并优先级读 `config.json`<`opencode.json`<`opencode.jsonc`(后者覆盖),用户在任一文件写 `telemetry:false` 都生效(jsonc 含注释时严格解析跳过,本仓无 json5 依赖;env 兜底)。顺带抽 `home_base()`/`config_dir()` 去重路径解析。
69+
- **#7 opt-out 写失败静默**:`set_telemetry_enabled` 改返回 `Result<(),String>`;前端 `setTelemetryEnabled` 不再吞错,设置页 `onTelemetryChange` 改 async + try/catch → 失败 `showToast` 提示 + refetch 回正(新增 i18n `...telemetry.saveFailed` en/zh/zht)。
70+
- **#9 事件名无共享常量**:前端新增 `TELEMETRY_EVENT` 常量集中事件名(注释指向 Rust `ALLOWED_EVENTS` 为准),消除 call site 散落字符串拼错风险。
71+
- 改动文件:`telemetry.rs` / `lib.rs` / `index.tsx` / `settings-general.tsx` / `platform.tsx`(签名不变)/ i18n×3。
72+
- **全部 code-review 项已清**(#1-#10);剩 `docs/legal` 隐私协议 3.2 更新检查段完整化仍归"启用自动升级"feat follow-up。
6273

6374
## 已知遗留 / 跟进
6475

packages/app/src/components/settings-general.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,21 @@ export const SettingsGeneral: Component = () => {
247247
{ initialValue: true },
248248
)
249249

250-
const onTelemetryChange = (checked: boolean) => {
251-
const update = platform.setTelemetryEnabled?.(checked)
252-
if (!update) return
253-
void update.finally(() => {
250+
const onTelemetryChange = async (checked: boolean) => {
251+
if (!platform.setTelemetryEnabled) return
252+
try {
253+
await platform.setTelemetryEnabled(checked)
254+
} catch (e) {
255+
// 写失败(只读/磁盘满)不再静默:提示用户,否则隐私开关"没关上"用户却以为关了
256+
showToast({
257+
variant: "error",
258+
title: language.t("settings.general.row.telemetry.saveFailed"),
259+
description: String(e),
260+
})
261+
} finally {
262+
// 无论成败都 refetch:回读真实有效值,UI 不会停在用户点击的错误态
254263
void refetchTelemetry()
255-
})
264+
}
256265
}
257266

258267
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,7 @@ export const dict = {
793793
"settings.general.row.telemetry.title": "Anonymous usage statistics",
794794
"settings.general.row.telemetry.description":
795795
"Send anonymous usage stats (app version, OS, launches, updates) to help improve DeskFox. No files, prompts, model names, or personal data are ever collected. You can turn this off anytime.",
796+
"settings.general.row.telemetry.saveFailed": "Failed to save the usage-statistics setting",
796797

797798
"settings.general.row.wayland.title": "Use native Wayland",
798799
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

packages/app/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,7 @@ export const dict = {
668668
"settings.general.row.telemetry.title": "匿名使用统计",
669669
"settings.general.row.telemetry.description":
670670
"发送匿名使用统计(软件版本、操作系统、启动与升级情况)以帮助改进 DeskFox。绝不采集任何文件、对话内容、模型名称或个人信息。可随时关闭。",
671+
"settings.general.row.telemetry.saveFailed": "保存使用统计设置失败",
671672
"settings.general.row.wayland.title": "使用原生 Wayland",
672673
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
673674
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",

packages/app/src/i18n/zht.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,7 @@ export const dict = {
664664
"settings.general.row.telemetry.title": "匿名使用統計",
665665
"settings.general.row.telemetry.description":
666666
"傳送匿名使用統計(軟體版本、作業系統、啟動與升級情況)以協助改進 DeskFox。絕不採集任何檔案、對話內容、模型名稱或個人資訊。可隨時關閉。",
667+
"settings.general.row.telemetry.saveFailed": "儲存使用統計設定失敗",
667668
"settings.general.row.wayland.title": "使用原生 Wayland",
668669
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
669670
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",

packages/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
633633
feishu_adapter::feishu_list_providers,
634634
// FORK: 匿名使用统计 — update_* 上报入口 + 设置开关读写 [feat: telemetry-usage-stats] 2026-06-06
635635
telemetry::track_event_cmd,
636+
telemetry::track_event_blocking_cmd,
636637
telemetry::get_telemetry_enabled,
637638
telemetry::set_telemetry_enabled
638639
])

packages/desktop/src-tauri/src/telemetry.rs

Lines changed: 86 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,24 @@ fn test_home() -> Option<PathBuf> {
4343
std::env::var("OPENCODE_TEST_HOME").ok().map(PathBuf::from)
4444
}
4545

46+
/// home 根(测试可经 OPENCODE_TEST_HOME 注入)—— cache_dir / config_dir 共用,避免重复解析。
47+
fn home_base() -> Option<PathBuf> {
48+
test_home().or_else(dirs::home_dir)
49+
}
50+
4651
/// install_id 缓存目录:`~/.cache/opencode/`(复用已删 SDK 的既定路径)。
4752
fn cache_dir() -> Option<PathBuf> {
48-
let base = test_home().or_else(dirs::home_dir)?;
49-
Some(base.join(".cache").join("opencode"))
53+
Some(home_base()?.join(".cache").join("opencode"))
54+
}
55+
56+
/// opencode 配置目录:`~/.config/opencode/`。
57+
fn config_dir() -> Option<PathBuf> {
58+
Some(home_base()?.join(".config").join("opencode"))
5059
}
5160

52-
/// opencode config 文件:`~/.config/opencode/config.json`(SDK 与 CLI 共用的 opt-out 来源)。
61+
/// UI 开关写入的 config 文件(`config.json`,opencode 合并加载的文件之一,隐私协议指明的 opt-out 文件)。
5362
fn config_path() -> Option<PathBuf> {
54-
let base = test_home().or_else(dirs::home_dir)?;
55-
Some(base.join(".config").join("opencode").join("config.json"))
63+
Some(config_dir()?.join("config.json"))
5664
}
5765

5866
// ---------------------------------------------------------------------------
@@ -126,12 +134,24 @@ fn resolve_enabled(env_val: Option<&str>, config_val: Option<bool>) -> bool {
126134
true
127135
}
128136

129-
/// 读 config.json 的顶层 `telemetry` 布尔字段;缺失/异常返回 None(降级,不 panic)。
137+
/// 读 telemetry opt-out 值。按 opencode 的合并优先级逐文件读
138+
/// (`config.json` < `opencode.json` < `opencode.jsonc`,后者覆盖前者),返回最后命中的值;
139+
/// 缺失/解析失败的文件跳过,全无则 None(降级,不 panic)。
140+
/// 注:opencode.jsonc 若含注释,serde_json 严格解析会失败而跳过(本仓无 json5 依赖);
141+
/// 多数 opt-out 写在 config.json(UI 也写这里),env `OPENCODE_TELEMETRY` 是兜底逃生舱。
130142
fn read_config_telemetry() -> Option<bool> {
131-
let path = config_path()?;
132-
let raw = fs::read_to_string(&path).ok()?;
133-
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
134-
json.get("telemetry").and_then(|v| v.as_bool())
143+
let dir = config_dir()?;
144+
let mut value = None;
145+
for file in ["config.json", "opencode.json", "opencode.jsonc"] {
146+
if let Ok(raw) = fs::read_to_string(dir.join(file)) {
147+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) {
148+
if let Some(b) = json.get("telemetry").and_then(|v| v.as_bool()) {
149+
value = Some(b);
150+
}
151+
}
152+
}
153+
}
154+
value
135155
}
136156

137157
// ---------------------------------------------------------------------------
@@ -196,38 +216,46 @@ fn is_allowed_event(name: &str) -> bool {
196216
ALLOWED_EVENTS.contains(&name)
197217
}
198218

199-
/// 上报一个事件。opt-out 时为 no-op;否则后台异步 POST,不等结果、不阻塞调用方。
219+
/// 决定是否上报并构造 (body, ua);白名单不通过 / opt-out 关闭都返回 None。**含文件 IO**
220+
/// (读 config + 读/写 install_id),故应在后台异步上下文调用,不要放主线程 / 启动线程。
221+
fn prepare_event(version: &str, identifier: &str, name: &str) -> Option<(String, String)> {
222+
if !is_allowed_event(name) || !is_enabled() {
223+
return None;
224+
}
225+
let install_id = get_or_create_install_id();
226+
let domain = domain_for_identifier(identifier);
227+
Some((
228+
build_event_body(name, domain, version, &install_id),
229+
user_agent(version, &install_id),
230+
))
231+
}
232+
233+
/// fire-and-forget:**全部工作**(opt-out 判定 + 文件 IO + HTTP)都丢后台,绝不阻塞调用方 /
234+
/// 启动线程(防 setup 钩子里的 app_open 在慢盘/网络盘上拖慢启动)。
200235
///
201236
/// - `version`:一般取 `app.package_info().version.to_string()`。
202237
/// - `identifier`:bundle identifier(`app.config().identifier`),用于按 channel 选 Plausible site。
203238
pub fn track(version: &str, identifier: &str, name: &str) {
204-
if !is_allowed_event(name) {
205-
return;
206-
}
207-
if !is_enabled() {
208-
return;
209-
}
210-
let install_id = get_or_create_install_id();
211-
let domain = domain_for_identifier(identifier);
212-
let body = build_event_body(name, domain, version, &install_id);
213-
let ua = user_agent(version, &install_id);
214-
// 后台发送:任何网络/构造失败都吞掉,绝不影响主程序(A9)。
239+
let (version, identifier, name) = (version.to_string(), identifier.to_string(), name.to_string());
215240
tauri::async_runtime::spawn(async move {
216-
send_event(body, ua).await;
241+
if let Some((body, ua)) = prepare_event(&version, &identifier, &name) {
242+
send_event(body, ua).await;
243+
}
217244
});
218245
}
219246

247+
/// 阻塞版:等发送完成(受 5s 超时上限)再返回 —— 给 relaunch 前的 `update_applied` 用。
248+
/// 否则 fire-and-forget 的后台请求会被紧接着的进程重启杀掉,事件长期统计性丢失。
249+
pub async fn track_blocking(version: &str, identifier: &str, name: &str) {
250+
if let Some((body, ua)) = prepare_event(version, identifier, name) {
251+
send_event(body, ua).await;
252+
}
253+
}
254+
220255
// ---------------------------------------------------------------------------
221256
// 设置开关读写(config.json telemetry 字段,UI「设置→通用」绑定)
222257
// ---------------------------------------------------------------------------
223258

224-
/// config 中 telemetry 字段的当前值;缺失视为默认开(true)。
225-
/// 注意:这反映用户在 UI 可控的 config 值;env `OPENCODE_TELEMETRY` 是更高优先级的逃生舱,
226-
/// 不经 UI(见 is_enabled 优先级)。
227-
fn read_telemetry_config_value() -> bool {
228-
read_config_telemetry().unwrap_or(true)
229-
}
230-
231259
/// 写 config.json 的 telemetry 字段,保留其余字段。失败返回 Err(调用方静默)。
232260
fn write_telemetry_config_in(path: Option<PathBuf>, enabled: bool) -> std::io::Result<()> {
233261
let Some(path) = path else {
@@ -257,21 +285,23 @@ fn write_telemetry_config_in(path: Option<PathBuf>, enabled: bool) -> std::io::R
257285
fs::rename(&tmp, &path)
258286
}
259287

260-
/// Tauri command:读当前统计开关(UI 初始化用)。
288+
/// Tauri command:读当前统计**有效**开关(UI 初始化用)。返回 is_enabled() 的有效值
289+
/// (env > config > 默认),而非仅 config —— 否则 env `OPENCODE_TELEMETRY` 覆盖时 UI 显示会与
290+
/// 实际上报行为脱节(显示"开"实则被 env 关掉,反之亦然)。
261291
#[tauri::command]
262292
#[specta::specta]
263293
pub fn get_telemetry_enabled() -> bool {
264-
read_telemetry_config_value()
294+
is_enabled()
265295
}
266296

267-
/// Tauri command:写统计开关(UI 切换时调)。失败静默
297+
/// Tauri command:写统计开关(UI 切换时调)。失败返回 Err 让前端可提示,不再静默吞
268298
#[tauri::command]
269299
#[specta::specta]
270-
pub fn set_telemetry_enabled(enabled: bool) {
271-
let _ = write_telemetry_config_in(config_path(), enabled);
300+
pub fn set_telemetry_enabled(enabled: bool) -> Result<(), String> {
301+
write_telemetry_config_in(config_path(), enabled).map_err(|e| e.to_string())
272302
}
273303

274-
/// Tauri command —— 前端(updater 流程)上报事件入口。
304+
/// Tauri command —— 前端(updater 流程)上报事件入口(fire-and-forget)
275305
/// name 受 ALLOWED_EVENTS 白名单约束:非白名单名静默丢弃,前端无法借此发任意事件。
276306
#[tauri::command]
277307
#[specta::specta]
@@ -283,6 +313,19 @@ pub fn track_event_cmd(app: tauri::AppHandle, name: String) {
283313
);
284314
}
285315

316+
/// Tauri command —— 同 track_event_cmd 但**等发送完成再返回**。给 relaunch 前的 `update_applied` 用,
317+
/// 前端 `await` 它后再 relaunch,确保事件发出去(否则进程重启会把后台请求杀掉)。
318+
#[tauri::command]
319+
#[specta::specta]
320+
pub async fn track_event_blocking_cmd(app: tauri::AppHandle, name: String) {
321+
track_blocking(
322+
&app.package_info().version.to_string(),
323+
&app.config().identifier,
324+
&name,
325+
)
326+
.await;
327+
}
328+
286329
async fn send_event(body: String, user_agent: String) {
287330
let client = match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
288331
Ok(c) => c,
@@ -444,19 +487,20 @@ mod tests {
444487
assert!(!arch.contains(' '));
445488
}
446489

447-
// T6 — opt-out 时 track 为 no-op(静默守卫,不触网)
490+
// T6 — opt-out 时 prepare_event 返回 None(不构造、不发送)
448491
#[test]
449-
fn t6_track_noop_when_disabled() {
450-
// 用 env 强制关闭,track 应直接返回不 spawn(不依赖 Tauri runtime)
451-
// SAFETY: 测试进程内临时设置 env。
492+
fn t6_prepare_none_when_disabled() {
493+
// SAFETY: 测试进程内临时设置 env(本测试是唯一动 OPENCODE_TELEMETRY 的)。
452494
unsafe {
453495
std::env::set_var("OPENCODE_TELEMETRY", "0");
454496
}
455-
// 不 panic 即通过(disabled 分支在 spawn 之前返回)
456-
track("2026.6.0", "ai.deskfox.app", "app_open");
497+
// env=0 → is_enabled false → prepare 返回 None(白名单事件也不发)
498+
assert!(prepare_event("2026.6.0", "ai.deskfox.app", "app_open").is_none());
457499
unsafe {
458500
std::env::remove_var("OPENCODE_TELEMETRY");
459501
}
502+
// 非白名单事件无论开关都 None
503+
assert!(prepare_event("2026.6.0", "ai.deskfox.app", "ai_request").is_none());
460504
}
461505

462506
// T4b — 事件白名单:仅 3 个事件放行,其余丢弃

packages/desktop/src/index.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ void initI18n()
4747

4848
let update: Update | null = null
4949

50+
// FORK: 匿名使用统计事件名 —— 必须与 Rust `ALLOWED_EVENTS`(telemetry.rs)逐字一致;
51+
// 集中此处避免各 call site 散落字符串字面量拼错(拼错会被 Rust 白名单静默丢弃,无报错)。
52+
// [feat: telemetry-usage-stats]
53+
const TELEMETRY_EVENT = {
54+
UPDATE_DOWNLOADED: "update_downloaded",
55+
UPDATE_APPLIED: "update_applied",
56+
} as const
57+
5058
const deepLinkEvent = "opencode:deep-link"
5159

5260
const emitDeepLinks = (urls: string[]) => {
@@ -324,9 +332,9 @@ const createPlatform = (): Platform => {
324332
.catch(() => false)
325333
if (!ok) return { updateAvailable: false }
326334
update = next
327-
// FORK: 匿名使用统计 update_downloaded(Rust 侧白名单校验 + opt-out + 静默失败)
335+
// FORK: 匿名使用统计 update_downloaded(fire-and-forget;Rust 侧白名单校验 + opt-out)
328336
// [feat: telemetry-usage-stats] 2026-06-06
329-
void invoke("track_event_cmd", { name: "update_downloaded" }).catch(() => {})
337+
void invoke("track_event_cmd", { name: TELEMETRY_EVENT.UPDATE_DOWNLOADED }).catch(() => {})
330338
return { updateAvailable: true, version: next.version }
331339
},
332340
updateAndRestart: async () => {
@@ -337,9 +345,9 @@ const createPlatform = (): Platform => {
337345
.then(() => true)
338346
.catch(() => false)
339347
if (!installed) return
340-
// FORK: 匿名使用统计 update_applied(install 成功、relaunch 前)
341-
// [feat: telemetry-usage-stats] 2026-06-06
342-
void invoke("track_event_cmd", { name: "update_applied" }).catch(() => {})
348+
// FORK: 匿名使用统计 update_applied —— 用**阻塞**上报 + await,确保 relaunch 前发出;
349+
// fire-and-forget 会被紧接着的进程重启杀掉,事件几乎必丢。[feat: telemetry-usage-stats]
350+
await invoke("track_event_blocking_cmd", { name: TELEMETRY_EVENT.UPDATE_APPLIED }).catch(() => {})
343351
await relaunch()
344352
},
345353
}
@@ -419,7 +427,8 @@ const createPlatform = (): Platform => {
419427
return invoke<boolean>("get_telemetry_enabled").catch(() => true)
420428
},
421429
setTelemetryEnabled: async (enabled: boolean) => {
422-
await invoke("set_telemetry_enabled", { enabled }).catch(() => {})
430+
// 不吞错:写失败(只读/磁盘满)抛出去,让设置页 toast 提示,不再静默(隐私开关失败需可见)
431+
await invoke("set_telemetry_enabled", { enabled })
423432
},
424433

425434
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),

0 commit comments

Comments
 (0)