@@ -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 的既定路径)。
4752fn 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 文件 )。
5362fn 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` 是兜底逃生舱。
130142fn 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。
203238pub 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(调用方静默)。
232260fn 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]
263293pub 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+
286329async 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 个事件放行,其余丢弃
0 commit comments