diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885cd57..64743aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: make test - name: Build app - run: make build CONFIG=Release + run: make build CONFIG=Release BUMP=0 INSTALL=0 - name: Verify bundle run: | diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index 395046d..d3f471e 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,194 +1,198 @@ -79 commits in the last 26 weeks +94 commits in the last 26 weeks -Apr -May -Jun -Jul -Aug -Sep +May +Jun +Jul +Aug +Sep +Oct Mon Wed Fri -0 commits on 2026-03-29 -0 commits on 2026-03-30 -0 commits on 2026-03-31 -0 commits on 2026-04-01 -0 commits on 2026-04-02 -0 commits on 2026-04-03 -0 commits on 2026-04-04 -0 commits on 2026-04-05 -0 commits on 2026-04-06 -0 commits on 2026-04-07 -0 commits on 2026-04-08 -0 commits on 2026-04-09 -0 commits on 2026-04-10 -0 commits on 2026-04-11 -0 commits on 2026-04-12 -0 commits on 2026-04-13 -0 commits on 2026-04-14 -0 commits on 2026-04-15 -0 commits on 2026-04-16 -0 commits on 2026-04-17 -0 commits on 2026-04-18 -0 commits on 2026-04-19 -0 commits on 2026-04-20 -0 commits on 2026-04-21 -0 commits on 2026-04-22 -0 commits on 2026-04-23 -0 commits on 2026-04-24 -0 commits on 2026-04-25 -0 commits on 2026-04-26 -0 commits on 2026-04-27 -0 commits on 2026-04-28 -0 commits on 2026-04-29 -0 commits on 2026-04-30 -0 commits on 2026-05-01 -0 commits on 2026-05-02 -0 commits on 2026-05-03 -0 commits on 2026-05-04 -0 commits on 2026-05-05 -0 commits on 2026-05-06 -0 commits on 2026-05-07 -0 commits on 2026-05-08 -0 commits on 2026-05-09 -0 commits on 2026-05-10 -0 commits on 2026-05-11 -0 commits on 2026-05-12 -0 commits on 2026-05-13 -0 commits on 2026-05-14 -0 commits on 2026-05-15 -0 commits on 2026-05-16 -0 commits on 2026-05-17 -0 commits on 2026-05-18 -0 commits on 2026-05-19 -0 commits on 2026-05-20 -0 commits on 2026-05-21 -0 commits on 2026-05-22 -0 commits on 2026-05-23 -0 commits on 2026-05-24 -0 commits on 2026-05-25 -0 commits on 2026-05-26 -0 commits on 2026-05-27 -0 commits on 2026-05-28 -0 commits on 2026-05-29 -0 commits on 2026-05-30 -0 commits on 2026-05-31 -0 commits on 2026-06-01 -0 commits on 2026-06-02 -0 commits on 2026-06-03 -0 commits on 2026-06-04 -0 commits on 2026-06-05 -0 commits on 2026-06-06 -0 commits on 2026-06-07 -0 commits on 2026-06-08 -0 commits on 2026-06-09 -0 commits on 2026-06-10 -0 commits on 2026-06-11 -0 commits on 2026-06-12 -0 commits on 2026-06-13 -0 commits on 2026-06-14 -0 commits on 2026-06-15 -0 commits on 2026-06-16 -0 commits on 2026-06-17 -0 commits on 2026-06-18 -0 commits on 2026-06-19 -0 commits on 2026-06-20 -0 commits on 2026-06-21 -0 commits on 2026-06-22 -0 commits on 2026-06-23 -0 commits on 2026-06-24 -0 commits on 2026-06-25 -0 commits on 2026-06-26 -0 commits on 2026-06-27 -0 commits on 2026-06-28 -0 commits on 2026-06-29 -0 commits on 2026-06-30 -0 commits on 2026-07-01 -0 commits on 2026-07-02 -0 commits on 2026-07-03 -0 commits on 2026-07-04 -0 commits on 2026-07-05 -0 commits on 2026-07-06 -0 commits on 2026-07-07 -0 commits on 2026-07-08 -0 commits on 2026-07-09 -0 commits on 2026-07-10 -0 commits on 2026-07-11 -0 commits on 2026-07-12 -0 commits on 2026-07-13 -0 commits on 2026-07-14 -0 commits on 2026-07-15 -0 commits on 2026-07-16 -0 commits on 2026-07-17 -0 commits on 2026-07-18 -0 commits on 2026-07-19 -0 commits on 2026-07-20 -0 commits on 2026-07-21 -0 commits on 2026-07-22 -0 commits on 2026-07-23 -0 commits on 2026-07-24 -0 commits on 2026-07-25 -0 commits on 2026-07-26 -0 commits on 2026-07-27 -0 commits on 2026-07-28 -0 commits on 2026-07-29 -0 commits on 2026-07-30 -0 commits on 2026-07-31 -0 commits on 2026-08-01 -0 commits on 2026-08-02 -0 commits on 2026-08-03 -0 commits on 2026-08-04 -0 commits on 2026-08-05 -0 commits on 2026-08-06 -0 commits on 2026-08-07 -0 commits on 2026-08-08 -0 commits on 2026-08-09 -0 commits on 2026-08-10 -0 commits on 2026-08-11 -0 commits on 2026-08-12 -0 commits on 2026-08-13 -0 commits on 2026-08-14 -0 commits on 2026-08-15 -0 commits on 2026-08-16 -0 commits on 2026-08-17 -0 commits on 2026-08-18 -0 commits on 2026-08-19 -0 commits on 2026-08-20 -0 commits on 2026-08-21 -0 commits on 2026-08-22 -0 commits on 2026-08-23 -0 commits on 2026-08-24 -0 commits on 2026-08-25 -0 commits on 2026-08-26 -0 commits on 2026-08-27 -0 commits on 2026-08-28 -0 commits on 2026-08-29 -0 commits on 2026-08-30 -0 commits on 2026-08-31 -0 commits on 2026-09-01 -0 commits on 2026-09-02 -0 commits on 2026-09-03 -0 commits on 2026-09-04 -0 commits on 2026-09-05 -0 commits on 2026-09-06 -0 commits on 2026-09-07 -0 commits on 2026-09-08 -0 commits on 2026-09-09 -0 commits on 2026-09-10 -0 commits on 2026-09-11 -0 commits on 2026-09-12 -42 commits on 2026-09-13 -2 commits on 2026-09-14 -12 commits on 2026-09-15 -12 commits on 2026-09-16 -2 commits on 2026-09-17 -9 commits on 2026-09-18 -0 commits on 2026-09-19 -0 commits on 2026-09-20 +0 commits on 2026-04-12 +0 commits on 2026-04-13 +0 commits on 2026-04-14 +0 commits on 2026-04-15 +0 commits on 2026-04-16 +0 commits on 2026-04-17 +0 commits on 2026-04-18 +0 commits on 2026-04-19 +0 commits on 2026-04-20 +0 commits on 2026-04-21 +0 commits on 2026-04-22 +0 commits on 2026-04-23 +0 commits on 2026-04-24 +0 commits on 2026-04-25 +0 commits on 2026-04-26 +0 commits on 2026-04-27 +0 commits on 2026-04-28 +0 commits on 2026-04-29 +0 commits on 2026-04-30 +0 commits on 2026-05-01 +0 commits on 2026-05-02 +0 commits on 2026-05-03 +0 commits on 2026-05-04 +0 commits on 2026-05-05 +0 commits on 2026-05-06 +0 commits on 2026-05-07 +0 commits on 2026-05-08 +0 commits on 2026-05-09 +0 commits on 2026-05-10 +0 commits on 2026-05-11 +0 commits on 2026-05-12 +0 commits on 2026-05-13 +0 commits on 2026-05-14 +0 commits on 2026-05-15 +0 commits on 2026-05-16 +0 commits on 2026-05-17 +0 commits on 2026-05-18 +0 commits on 2026-05-19 +0 commits on 2026-05-20 +0 commits on 2026-05-21 +0 commits on 2026-05-22 +0 commits on 2026-05-23 +0 commits on 2026-05-24 +0 commits on 2026-05-25 +0 commits on 2026-05-26 +0 commits on 2026-05-27 +0 commits on 2026-05-28 +0 commits on 2026-05-29 +0 commits on 2026-05-30 +0 commits on 2026-05-31 +0 commits on 2026-06-01 +0 commits on 2026-06-02 +0 commits on 2026-06-03 +0 commits on 2026-06-04 +0 commits on 2026-06-05 +0 commits on 2026-06-06 +0 commits on 2026-06-07 +0 commits on 2026-06-08 +0 commits on 2026-06-09 +0 commits on 2026-06-10 +0 commits on 2026-06-11 +0 commits on 2026-06-12 +0 commits on 2026-06-13 +0 commits on 2026-06-14 +0 commits on 2026-06-15 +0 commits on 2026-06-16 +0 commits on 2026-06-17 +0 commits on 2026-06-18 +0 commits on 2026-06-19 +0 commits on 2026-06-20 +0 commits on 2026-06-21 +0 commits on 2026-06-22 +0 commits on 2026-06-23 +0 commits on 2026-06-24 +0 commits on 2026-06-25 +0 commits on 2026-06-26 +0 commits on 2026-06-27 +0 commits on 2026-06-28 +0 commits on 2026-06-29 +0 commits on 2026-06-30 +0 commits on 2026-07-01 +0 commits on 2026-07-02 +0 commits on 2026-07-03 +0 commits on 2026-07-04 +0 commits on 2026-07-05 +0 commits on 2026-07-06 +0 commits on 2026-07-07 +0 commits on 2026-07-08 +0 commits on 2026-07-09 +0 commits on 2026-07-10 +0 commits on 2026-07-11 +0 commits on 2026-07-12 +0 commits on 2026-07-13 +0 commits on 2026-07-14 +0 commits on 2026-07-15 +0 commits on 2026-07-16 +0 commits on 2026-07-17 +0 commits on 2026-07-18 +0 commits on 2026-07-19 +0 commits on 2026-07-20 +0 commits on 2026-07-21 +0 commits on 2026-07-22 +0 commits on 2026-07-23 +0 commits on 2026-07-24 +0 commits on 2026-07-25 +0 commits on 2026-07-26 +0 commits on 2026-07-27 +0 commits on 2026-07-28 +0 commits on 2026-07-29 +0 commits on 2026-07-30 +0 commits on 2026-07-31 +0 commits on 2026-08-01 +0 commits on 2026-08-02 +0 commits on 2026-08-03 +0 commits on 2026-08-04 +0 commits on 2026-08-05 +0 commits on 2026-08-06 +0 commits on 2026-08-07 +0 commits on 2026-08-08 +0 commits on 2026-08-09 +0 commits on 2026-08-10 +0 commits on 2026-08-11 +0 commits on 2026-08-12 +0 commits on 2026-08-13 +0 commits on 2026-08-14 +0 commits on 2026-08-15 +0 commits on 2026-08-16 +0 commits on 2026-08-17 +0 commits on 2026-08-18 +0 commits on 2026-08-19 +0 commits on 2026-08-20 +0 commits on 2026-08-21 +0 commits on 2026-08-22 +0 commits on 2026-08-23 +0 commits on 2026-08-24 +0 commits on 2026-08-25 +0 commits on 2026-08-26 +0 commits on 2026-08-27 +0 commits on 2026-08-28 +0 commits on 2026-08-29 +0 commits on 2026-08-30 +0 commits on 2026-08-31 +0 commits on 2026-09-01 +0 commits on 2026-09-02 +0 commits on 2026-09-03 +0 commits on 2026-09-04 +0 commits on 2026-09-05 +0 commits on 2026-09-06 +0 commits on 2026-09-07 +0 commits on 2026-09-08 +0 commits on 2026-09-09 +0 commits on 2026-09-10 +0 commits on 2026-09-11 +0 commits on 2026-09-12 +42 commits on 2026-09-13 +2 commits on 2026-09-14 +12 commits on 2026-09-15 +12 commits on 2026-09-16 +2 commits on 2026-09-17 +9 commits on 2026-09-18 +0 commits on 2026-09-19 +0 commits on 2026-09-20 +8 commits on 2026-09-21 +0 commits on 2026-09-22 +0 commits on 2026-09-23 +2 commits on 2026-09-24 +0 commits on 2026-09-25 +0 commits on 2026-09-26 +0 commits on 2026-09-27 +0 commits on 2026-09-28 +0 commits on 2026-09-29 +0 commits on 2026-09-30 +5 commits on 2026-10-01 +0 commits on 2026-10-02 +0 commits on 2026-10-03 +0 commits on 2026-10-04 +0 commits on 2026-10-05 +0 commits on 2026-10-06 +0 commits on 2026-10-07 +0 commits on 2026-10-08 -79 commits in the last 26 weeks · through 2026-09-20 +94 commits in the last 26 weeks · through 2026-10-08 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index f3c68c0..c4debf8 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,194 +1,198 @@ -近 26 周共 79 次提交 +近 26 周共 94 次提交 -4月 -5月 -6月 -7月 -8月 -9月 +5月 +6月 +7月 +8月 +9月 +10月 一 三 五 -2026-03-29:0 次提交 -2026-03-30:0 次提交 -2026-03-31:0 次提交 -2026-04-01:0 次提交 -2026-04-02:0 次提交 -2026-04-03:0 次提交 -2026-04-04:0 次提交 -2026-04-05:0 次提交 -2026-04-06:0 次提交 -2026-04-07:0 次提交 -2026-04-08:0 次提交 -2026-04-09:0 次提交 -2026-04-10:0 次提交 -2026-04-11:0 次提交 -2026-04-12:0 次提交 -2026-04-13:0 次提交 -2026-04-14:0 次提交 -2026-04-15:0 次提交 -2026-04-16:0 次提交 -2026-04-17:0 次提交 -2026-04-18:0 次提交 -2026-04-19:0 次提交 -2026-04-20:0 次提交 -2026-04-21:0 次提交 -2026-04-22:0 次提交 -2026-04-23:0 次提交 -2026-04-24:0 次提交 -2026-04-25:0 次提交 -2026-04-26:0 次提交 -2026-04-27:0 次提交 -2026-04-28:0 次提交 -2026-04-29:0 次提交 -2026-04-30:0 次提交 -2026-05-01:0 次提交 -2026-05-02:0 次提交 -2026-05-03:0 次提交 -2026-05-04:0 次提交 -2026-05-05:0 次提交 -2026-05-06:0 次提交 -2026-05-07:0 次提交 -2026-05-08:0 次提交 -2026-05-09:0 次提交 -2026-05-10:0 次提交 -2026-05-11:0 次提交 -2026-05-12:0 次提交 -2026-05-13:0 次提交 -2026-05-14:0 次提交 -2026-05-15:0 次提交 -2026-05-16:0 次提交 -2026-05-17:0 次提交 -2026-05-18:0 次提交 -2026-05-19:0 次提交 -2026-05-20:0 次提交 -2026-05-21:0 次提交 -2026-05-22:0 次提交 -2026-05-23:0 次提交 -2026-05-24:0 次提交 -2026-05-25:0 次提交 -2026-05-26:0 次提交 -2026-05-27:0 次提交 -2026-05-28:0 次提交 -2026-05-29:0 次提交 -2026-05-30:0 次提交 -2026-05-31:0 次提交 -2026-06-01:0 次提交 -2026-06-02:0 次提交 -2026-06-03:0 次提交 -2026-06-04:0 次提交 -2026-06-05:0 次提交 -2026-06-06:0 次提交 -2026-06-07:0 次提交 -2026-06-08:0 次提交 -2026-06-09:0 次提交 -2026-06-10:0 次提交 -2026-06-11:0 次提交 -2026-06-12:0 次提交 -2026-06-13:0 次提交 -2026-06-14:0 次提交 -2026-06-15:0 次提交 -2026-06-16:0 次提交 -2026-06-17:0 次提交 -2026-06-18:0 次提交 -2026-06-19:0 次提交 -2026-06-20:0 次提交 -2026-06-21:0 次提交 -2026-06-22:0 次提交 -2026-06-23:0 次提交 -2026-06-24:0 次提交 -2026-06-25:0 次提交 -2026-06-26:0 次提交 -2026-06-27:0 次提交 -2026-06-28:0 次提交 -2026-06-29:0 次提交 -2026-06-30:0 次提交 -2026-07-01:0 次提交 -2026-07-02:0 次提交 -2026-07-03:0 次提交 -2026-07-04:0 次提交 -2026-07-05:0 次提交 -2026-07-06:0 次提交 -2026-07-07:0 次提交 -2026-07-08:0 次提交 -2026-07-09:0 次提交 -2026-07-10:0 次提交 -2026-07-11:0 次提交 -2026-07-12:0 次提交 -2026-07-13:0 次提交 -2026-07-14:0 次提交 -2026-07-15:0 次提交 -2026-07-16:0 次提交 -2026-07-17:0 次提交 -2026-07-18:0 次提交 -2026-07-19:0 次提交 -2026-07-20:0 次提交 -2026-07-21:0 次提交 -2026-07-22:0 次提交 -2026-07-23:0 次提交 -2026-07-24:0 次提交 -2026-07-25:0 次提交 -2026-07-26:0 次提交 -2026-07-27:0 次提交 -2026-07-28:0 次提交 -2026-07-29:0 次提交 -2026-07-30:0 次提交 -2026-07-31:0 次提交 -2026-08-01:0 次提交 -2026-08-02:0 次提交 -2026-08-03:0 次提交 -2026-08-04:0 次提交 -2026-08-05:0 次提交 -2026-08-06:0 次提交 -2026-08-07:0 次提交 -2026-08-08:0 次提交 -2026-08-09:0 次提交 -2026-08-10:0 次提交 -2026-08-11:0 次提交 -2026-08-12:0 次提交 -2026-08-13:0 次提交 -2026-08-14:0 次提交 -2026-08-15:0 次提交 -2026-08-16:0 次提交 -2026-08-17:0 次提交 -2026-08-18:0 次提交 -2026-08-19:0 次提交 -2026-08-20:0 次提交 -2026-08-21:0 次提交 -2026-08-22:0 次提交 -2026-08-23:0 次提交 -2026-08-24:0 次提交 -2026-08-25:0 次提交 -2026-08-26:0 次提交 -2026-08-27:0 次提交 -2026-08-28:0 次提交 -2026-08-29:0 次提交 -2026-08-30:0 次提交 -2026-08-31:0 次提交 -2026-09-01:0 次提交 -2026-09-02:0 次提交 -2026-09-03:0 次提交 -2026-09-04:0 次提交 -2026-09-05:0 次提交 -2026-09-06:0 次提交 -2026-09-07:0 次提交 -2026-09-08:0 次提交 -2026-09-09:0 次提交 -2026-09-10:0 次提交 -2026-09-11:0 次提交 -2026-09-12:0 次提交 -2026-09-13:42 次提交 -2026-09-14:2 次提交 -2026-09-15:12 次提交 -2026-09-16:12 次提交 -2026-09-17:2 次提交 -2026-09-18:9 次提交 -2026-09-19:0 次提交 -2026-09-20:0 次提交 +2026-04-12:0 次提交 +2026-04-13:0 次提交 +2026-04-14:0 次提交 +2026-04-15:0 次提交 +2026-04-16:0 次提交 +2026-04-17:0 次提交 +2026-04-18:0 次提交 +2026-04-19:0 次提交 +2026-04-20:0 次提交 +2026-04-21:0 次提交 +2026-04-22:0 次提交 +2026-04-23:0 次提交 +2026-04-24:0 次提交 +2026-04-25:0 次提交 +2026-04-26:0 次提交 +2026-04-27:0 次提交 +2026-04-28:0 次提交 +2026-04-29:0 次提交 +2026-04-30:0 次提交 +2026-05-01:0 次提交 +2026-05-02:0 次提交 +2026-05-03:0 次提交 +2026-05-04:0 次提交 +2026-05-05:0 次提交 +2026-05-06:0 次提交 +2026-05-07:0 次提交 +2026-05-08:0 次提交 +2026-05-09:0 次提交 +2026-05-10:0 次提交 +2026-05-11:0 次提交 +2026-05-12:0 次提交 +2026-05-13:0 次提交 +2026-05-14:0 次提交 +2026-05-15:0 次提交 +2026-05-16:0 次提交 +2026-05-17:0 次提交 +2026-05-18:0 次提交 +2026-05-19:0 次提交 +2026-05-20:0 次提交 +2026-05-21:0 次提交 +2026-05-22:0 次提交 +2026-05-23:0 次提交 +2026-05-24:0 次提交 +2026-05-25:0 次提交 +2026-05-26:0 次提交 +2026-05-27:0 次提交 +2026-05-28:0 次提交 +2026-05-29:0 次提交 +2026-05-30:0 次提交 +2026-05-31:0 次提交 +2026-06-01:0 次提交 +2026-06-02:0 次提交 +2026-06-03:0 次提交 +2026-06-04:0 次提交 +2026-06-05:0 次提交 +2026-06-06:0 次提交 +2026-06-07:0 次提交 +2026-06-08:0 次提交 +2026-06-09:0 次提交 +2026-06-10:0 次提交 +2026-06-11:0 次提交 +2026-06-12:0 次提交 +2026-06-13:0 次提交 +2026-06-14:0 次提交 +2026-06-15:0 次提交 +2026-06-16:0 次提交 +2026-06-17:0 次提交 +2026-06-18:0 次提交 +2026-06-19:0 次提交 +2026-06-20:0 次提交 +2026-06-21:0 次提交 +2026-06-22:0 次提交 +2026-06-23:0 次提交 +2026-06-24:0 次提交 +2026-06-25:0 次提交 +2026-06-26:0 次提交 +2026-06-27:0 次提交 +2026-06-28:0 次提交 +2026-06-29:0 次提交 +2026-06-30:0 次提交 +2026-07-01:0 次提交 +2026-07-02:0 次提交 +2026-07-03:0 次提交 +2026-07-04:0 次提交 +2026-07-05:0 次提交 +2026-07-06:0 次提交 +2026-07-07:0 次提交 +2026-07-08:0 次提交 +2026-07-09:0 次提交 +2026-07-10:0 次提交 +2026-07-11:0 次提交 +2026-07-12:0 次提交 +2026-07-13:0 次提交 +2026-07-14:0 次提交 +2026-07-15:0 次提交 +2026-07-16:0 次提交 +2026-07-17:0 次提交 +2026-07-18:0 次提交 +2026-07-19:0 次提交 +2026-07-20:0 次提交 +2026-07-21:0 次提交 +2026-07-22:0 次提交 +2026-07-23:0 次提交 +2026-07-24:0 次提交 +2026-07-25:0 次提交 +2026-07-26:0 次提交 +2026-07-27:0 次提交 +2026-07-28:0 次提交 +2026-07-29:0 次提交 +2026-07-30:0 次提交 +2026-07-31:0 次提交 +2026-08-01:0 次提交 +2026-08-02:0 次提交 +2026-08-03:0 次提交 +2026-08-04:0 次提交 +2026-08-05:0 次提交 +2026-08-06:0 次提交 +2026-08-07:0 次提交 +2026-08-08:0 次提交 +2026-08-09:0 次提交 +2026-08-10:0 次提交 +2026-08-11:0 次提交 +2026-08-12:0 次提交 +2026-08-13:0 次提交 +2026-08-14:0 次提交 +2026-08-15:0 次提交 +2026-08-16:0 次提交 +2026-08-17:0 次提交 +2026-08-18:0 次提交 +2026-08-19:0 次提交 +2026-08-20:0 次提交 +2026-08-21:0 次提交 +2026-08-22:0 次提交 +2026-08-23:0 次提交 +2026-08-24:0 次提交 +2026-08-25:0 次提交 +2026-08-26:0 次提交 +2026-08-27:0 次提交 +2026-08-28:0 次提交 +2026-08-29:0 次提交 +2026-08-30:0 次提交 +2026-08-31:0 次提交 +2026-09-01:0 次提交 +2026-09-02:0 次提交 +2026-09-03:0 次提交 +2026-09-04:0 次提交 +2026-09-05:0 次提交 +2026-09-06:0 次提交 +2026-09-07:0 次提交 +2026-09-08:0 次提交 +2026-09-09:0 次提交 +2026-09-10:0 次提交 +2026-09-11:0 次提交 +2026-09-12:0 次提交 +2026-09-13:42 次提交 +2026-09-14:2 次提交 +2026-09-15:12 次提交 +2026-09-16:12 次提交 +2026-09-17:2 次提交 +2026-09-18:9 次提交 +2026-09-19:0 次提交 +2026-09-20:0 次提交 +2026-09-21:8 次提交 +2026-09-22:0 次提交 +2026-09-23:0 次提交 +2026-09-24:2 次提交 +2026-09-25:0 次提交 +2026-09-26:0 次提交 +2026-09-27:0 次提交 +2026-09-28:0 次提交 +2026-09-29:0 次提交 +2026-09-30:0 次提交 +2026-10-01:5 次提交 +2026-10-02:0 次提交 +2026-10-03:0 次提交 +2026-10-04:0 次提交 +2026-10-05:0 次提交 +2026-10-06:0 次提交 +2026-10-07:0 次提交 +2026-10-08:0 次提交 -近 26 周共 79 次提交 · 截至 2026-09-20 +近 26 周共 94 次提交 · 截至 2026-10-08 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4258a..c28bf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ## 未发布 +### 2026-10-09 + +#### 修复 + +- 合并显示时菜单栏的小标签颜色过淡:图标中带有彩色网速圆点时,CPU、内存等标签会直接画成灰色,在深色菜单栏上不容易看清。标签和网速箭头现在与数值使用同一种前景色,仍通过字号区分标签与读数。跟进 [Issue #5](https://github.com/gentpan/OpenStats/issues/5)。 +- 应用卸载的残留查找增强:读取应用真实名称、内嵌辅助组件的包名和签名中的应用组,按容器元数据识别 UUID 命名的沙盒数据;最近文档、CrashReporter、Sparkle、SentryCrash 和厂商目录内的应用子项也会检查。补上 VS Code、Insiders、Arc、Chrome 的明确数据位置。共享容器、按名称找到的 Sentry 数据和可能共用的辅助组件数据会标明并默认不勾选,厂商共享父目录始终保留。扫描及回收前重查路径,跳过符号链接重定向。参考 Pearcleaner 的查找思路,跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 应用卸载有部分文件未能移到废纸篓时,只统计实际成功的项目与空间,保留失败项供重试;只有应用本体成功移除才清除程序坞图标和累计卸载次数。 + ### 2026-10-01 #### 新增 diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 2520883..2657c3b 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -30,7 +30,7 @@ let package = Package( .testTarget(name: "HelperSharedTests", dependencies: ["HelperShared"]), .testTarget(name: "UpdatesTests", dependencies: ["Updates"]), .testTarget(name: "AccountSyncTests", dependencies: ["AccountSync"]), - .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics", "AccountSync"]), + .testTarget(name: "OpenStatsUITests", dependencies: ["OpenStatsUI", "Metrics", "AccountSync", "Cleaner"]), .testTarget(name: "LocalizationTests", dependencies: ["Localization"]), ], swiftLanguageModes: [.v6] diff --git a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift new file mode 100644 index 0000000..86e361a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstallIdentity.swift @@ -0,0 +1,151 @@ +import Foundation +import Security + +/// 只从应用本身与受限的嵌套 bundle 中收集身份,不用开发团队或通用 helper 名称推断归属。 +struct AppUninstallIdentity: Sendable { + let identifiers: Set + let names: Set + let applicationGroups: Set + let teamIdentifier: String? + + init(identifiers: Set, names: Set, applicationGroups: Set = [], teamIdentifier: String? = nil) { + self.identifiers = Set(identifiers.compactMap(Self.validIdentifier)) + self.names = Set(names.compactMap(Self.validName)) + self.applicationGroups = Set(applicationGroups.compactMap(Self.validIdentifier)) + self.teamIdentifier = teamIdentifier.flatMap(Self.validTeamIdentifier) + } + + init(app: InstalledApp) { + var identifiers = Set([app.bundleIdentifier]) + var names = Set([app.name, app.url.deletingPathExtension().lastPathComponent]) + var groups = Set() + var teamIdentifier: String? + + // 父目录的系统别名(例如 /var)可以规范化,应用本身与内部路径不接受符号链接。 + if let values = try? app.url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), + values.isDirectory == true, values.isSymbolicLink != true { + let root = app.url.resolvingSymlinksInPath().standardizedFileURL + if let metadata = Self.metadata(at: root, inside: root) { + if let identifier = metadata["CFBundleIdentifier"] as? String { identifiers.insert(identifier) } + for key in ["CFBundleName", "CFBundleDisplayName"] { + if let name = metadata[key] as? String { names.insert(name) } + } + } + if let signature = Self.signature(at: root) { + groups.formUnion(signature.groups) + teamIdentifier = signature.teamIdentifier + } + for bundle in Self.embeddedBundles(in: root) { + if let metadata = Self.metadata(at: bundle, inside: root), + let identifier = metadata["CFBundleIdentifier"] as? String { + identifiers.insert(identifier) + if let signature = Self.signature(at: bundle) { groups.formUnion(signature.groups) } + } + } + } + + self.init(identifiers: identifiers, names: names, applicationGroups: groups, teamIdentifier: teamIdentifier) + } + + static func validIdentifier(_ value: String) -> String? { + guard !value.isEmpty, value.utf8.count <= 255 else { return nil } + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + let alphanumeric = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + guard parts.count >= 2, parts.allSatisfy({ part in + !part.isEmpty && part.unicodeScalars.allSatisfy(allowed.contains) + && part.unicodeScalars.contains(where: alphanumeric.contains) + }) else { return nil } + return value + } + + static func validName(_ value: String) -> String? { + guard value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil } + let name = value.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty, name != ".", name != "..", name.utf8.count <= 255, + !name.contains("/"), !name.contains("\\") else { return nil } + return name + } + + static func validTeamIdentifier(_ value: String) -> String? { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + guard !value.isEmpty, value.utf8.count <= 64, value.unicodeScalars.allSatisfy(allowed.contains) else { return nil } + return value + } + + static func applicationGroups(from entitlements: [String: Any]) -> Set { + let groups = entitlements["com.apple.security.application-groups"] as? [Any] ?? [] + return Set(groups.compactMap { ($0 as? String).flatMap(validIdentifier) }) + } + + /// 必须实际位于根 bundle 内,且整个内部路径不能经由符号链接。 + static func isSafeURL(_ url: URL, inside root: URL) -> Bool { + let path = url.standardizedFileURL.path + let rootPath = root.standardizedFileURL.path + guard path == rootPath || path.hasPrefix(rootPath + "/"), + url.resolvingSymlinksInPath().standardizedFileURL.path == path, + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]), + values.isSymbolicLink != true else { return false } + return true + } + + static func metadata(at bundle: URL, inside root: URL) -> [String: Any]? { + let url = bundle.appendingPathComponent("Contents/Info.plist") + guard isSafeURL(bundle, inside: root), isSafeURL(url, inside: root), + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + values.isRegularFile == true, let size = values.fileSize, size <= 1_048_576, + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else { return nil } + return plist as? [String: Any] + } + + static func embeddedBundles(in root: URL) -> [URL] { + let locations = ["Contents/Frameworks", "Contents/XPCServices", "Contents/PlugIns"] + let ignoredDirectories: Set = ["Resources", "_CodeSignature", "MacOS", "Headers", "Modules"] + var pending = locations.map { (url: root.appendingPathComponent($0), depth: 0) } + var visited = Set() + var results: [URL] = [] + var examined = 0 + let manager = FileManager.default + + while !pending.isEmpty, examined < 512, results.count < 64 { + let (directory, depth) = pending.removeFirst() + guard depth <= 8, isSafeURL(directory, inside: root), visited.insert(directory.path).inserted, + let children = try? manager.contentsOfDirectory(at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: .skipsHiddenFiles) else { continue } + for child in children.sorted(by: { $0.path < $1.path }) { + guard examined < 512, results.count < 64 else { break } + examined += 1 + guard isSafeURL(child, inside: root), + let values = try? child.resourceValues(forKeys: [.isDirectoryKey]), values.isDirectory == true else { continue } + if ["app", "xpc", "appex"].contains(child.pathExtension.lowercased()), + let info = metadata(at: child, inside: root), let identifier = info["CFBundleIdentifier"] as? String, + validIdentifier(identifier) != nil { + results.append(child) + } + if depth < 8, !ignoredDirectories.contains(child.lastPathComponent) { + pending.append((child, depth + 1)) + } + } + } + return results + } + + private struct Signature { + let groups: Set + let teamIdentifier: String? + } + + private static func signature(at url: URL) -> Signature? { + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, let code else { return nil } + let validationFlags = SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures) + guard SecStaticCodeCheckValidity(code, validationFlags, nil) == errSecSuccess else { return nil } + var information: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess, + let dictionary = information as? [String: Any] else { return nil } + let entitlements = dictionary[kSecCodeInfoEntitlementsDict as String] as? [String: Any] ?? [:] + let team = (dictionary[kSecCodeInfoTeamIdentifier as String] as? String).flatMap(validTeamIdentifier) + return Signature(groups: applicationGroups(from: entitlements), teamIdentifier: team) + } +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift index 78a21cd..859fced 100644 --- a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift +++ b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -8,7 +8,7 @@ public struct InstalledApp: Sendable, Identifiable, Hashable { public let name: String public let bundleIdentifier: String public let version: String? - /// 签名团队,用于查找 Group Containers + /// 签名团队(可能未读取);共享容器通过真实应用组或容器记录识别。 public let teamIdentifier: String? public init(url: URL, name: String, bundleIdentifier: String, version: String?, teamIdentifier: String?) { @@ -44,6 +44,15 @@ public struct AppLeftover: Sendable, Identifiable, Hashable { public let url: URL public let kind: Kind public let size: UInt64 + /// 共享容器或仅按名称识别的数据,默认保留,由用户逐项选择。 + public let requiresReview: Bool + + public init(url: URL, kind: Kind, size: UInt64, requiresReview: Bool = false) { + self.url = url + self.kind = kind + self.size = size + self.requiresReview = requiresReview + } } public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertible { @@ -62,8 +71,7 @@ public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertib } } -/// 查找应用的残留文件。只按包名(bundle identifier)精确匹配或以“包名.”开头, -/// 应用名只用于 Application Support 与 Logs 下与应用同名的目录,避免误伤名字相近的其他应用 +/// 根据应用与内嵌组件的标识、容器元数据和明确路径查找残留;共享候选默认保留。 public enum AppUninstaller { public static func applicationDirectories(home: String = NSHomeDirectory()) -> [URL] { [URL(fileURLWithPath: "/Applications"), URL(fileURLWithPath: home).appendingPathComponent("Applications")] @@ -103,55 +111,189 @@ public enum AppUninstaller { public static func validate(_ app: InstalledApp, home: String = NSHomeDirectory()) throws { let path = app.url.resolvingSymlinksInPath().path guard app.url.pathExtension == "app" else { throw AppUninstallError.notAnApp } + if (try? app.url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { throw AppUninstallError.unsafePath(path) } if path.hasPrefix("/System/") || app.bundleIdentifier.hasPrefix("com.apple.") { throw AppUninstallError.systemApp } - let allowed = applicationDirectories(home: home).map { $0.path + "/" } - guard allowed.contains(where: { path.hasPrefix($0) }) else { throw AppUninstallError.unsafePath(path) } + let original = app.url.standardizedFileURL.path + let homeApplications = URL(fileURLWithPath: home).resolvingSymlinksInPath().appendingPathComponent("Applications").path + "/" + // /Applications 和 home 以下的 Applications 不能通过解析可替换的根目录来扩大白名单。 + let inSystemApplications = original.hasPrefix("/Applications/") && path == original + let inHomeApplications = path.hasPrefix(homeApplications) && isUnredirected(app.url, home: home) + guard inSystemApplications || inHomeApplications else { throw AppUninstallError.unsafePath(path) } } public static func leftovers(for app: InstalledApp, home: String = NSHomeDirectory()) -> [AppLeftover] { + leftovers(for: app, home: home, identity: AppUninstallIdentity(app: app)) + } + + static func leftovers(for app: InstalledApp, home: String, identity: AppUninstallIdentity) -> [AppLeftover] { let library = URL(fileURLWithPath: home).appendingPathComponent("Library") - let id = app.bundleIdentifier let manager = FileManager.default var results: [AppLeftover] = [] - var seen = Set() - func add(_ url: URL, _ kind: AppLeftover.Kind) { + func matches(_ name: String) -> Bool { + identity.identifiers.contains { matchesIdentifier(name, $0) } + } + + func auxiliaryMatch(_ name: String) -> Bool { + matches(name) && !matchesIdentifier(name, app.bundleIdentifier) + } + + func add(_ url: URL, _ kind: AppLeftover.Kind, review: Bool = false) { let path = url.standardizedFileURL.path - guard !seen.contains(path), manager.fileExists(atPath: path) else { return } - seen.insert(path) - results.append(AppLeftover(url: url, kind: kind, size: CleanEngine.allocatedSize(of: url))) + guard manager.fileExists(atPath: path), + (try? validateLeftover(url, for: app, home: home)) != nil, + !results.contains(where: { path.hasPrefix($0.url.standardizedFileURL.path + "/") }) else { return } + if let index = results.firstIndex(where: { $0.url.standardizedFileURL.path == path }) { + if review && !results[index].requiresReview { + let item = results[index] + results[index] = AppLeftover(url: item.url, kind: item.kind, size: item.size, requiresReview: true) + } + return + } + var requiresReview = review + results.removeAll { item in + if item.url.standardizedFileURL.path.hasPrefix(path + "/") { requiresReview = requiresReview || item.requiresReview; return true } + return false + } + results.append(AppLeftover(url: url, kind: kind, size: CleanEngine.allocatedSize(of: url), requiresReview: requiresReview)) } - /// 目录下名字等于包名、以“包名.”开头或满足 extra 的条目 - func scan(_ relative: String, _ kind: AppLeftover.Kind, extra: (String) -> Bool = { _ in false }) { + func entries(_ relative: String) -> [URL] { let directory = library.appendingPathComponent(relative) - let names = (try? manager.contentsOfDirectory(atPath: directory.path)) ?? [] - for name in names where matchesIdentifier(name, id) || extra(name) { - add(directory.appendingPathComponent(name), kind) + guard isUnredirected(directory, home: home) else { return [] } + // 保留调用方的 home 路径写法;FileManager 的 URL 形式可能把 /var 转成 /private/var。 + return ((try? manager.contentsOfDirectory(atPath: directory.path)) ?? []) + .sorted().map { directory.appendingPathComponent($0) } + } + + func scan(_ relative: String, _ kind: AppLeftover.Kind, names: Bool = false, review: Bool = false) { + for entry in entries(relative) { + let named = names && identity.names.contains(entry.lastPathComponent) + if matches(entry.lastPathComponent) || named { + add(entry, kind, review: review || auxiliaryMatch(entry.lastPathComponent) || (named && !matches(entry.lastPathComponent))) + } + } + } + + /// 只深入厂商目录一层,选择匹配的应用子项,保留厂商目录本身。 + func scanNested(_ relative: String, _ kind: AppLeftover.Kind) { + for parent in entries(relative) { + guard (try? parent.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + !results.contains(where: { $0.url == parent }), isUnredirected(parent, home: home) else { continue } + let children = ((try? manager.contentsOfDirectory(atPath: parent.path)) ?? []) + .map { parent.appendingPathComponent($0) } + for child in children where matches(child.lastPathComponent) { add(child, kind, review: auxiliaryMatch(child.lastPathComponent)) } } } add(app.url, .application) - scan("Application Support", .support) { $0 == app.name } + scan("Application Support", .support, names: true) + scan("Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", .support) + scan("Application Support/CrashReporter", .logs) scan("Caches", .caches) + scan("Caches/org.sparkle-project.Sparkle", .caches) + scan("Caches/SentryCrash", .caches) + // Sentry 的目录使用 CFBundleName 而非包名;同名数据可能共用,默认不选。 + for entry in entries("Caches/SentryCrash") where identity.names.contains(entry.lastPathComponent) { + add(entry, .caches, review: true) + } + scanNested("Application Support", .support) + scanNested("Caches", .caches) + scanNested("Logs", .logs) scan("HTTPStorages", .caches) scan("Preferences", .preferences) scan("Preferences/ByHost", .preferences) - scan("Containers", .containers) + for entry in entries("Containers") { + let owner = containerIdentifier(at: entry, home: home) + if let owner { + if identity.identifiers.contains(owner) { add(entry, .containers, review: owner != app.bundleIdentifier && !owner.hasPrefix(app.bundleIdentifier + ".")) } + } else if matches(entry.lastPathComponent) { add(entry, .containers, review: auxiliaryMatch(entry.lastPathComponent)) } + } scan("Application Scripts", .containers) - scan("Group Containers", .containers) { name in - // 形如 “TEAMID.com.example.app” 或 “group.com.example.app” - name.hasSuffix("." + id) || name == "group." + id || name.hasPrefix("group." + id + ".") + for relative in ["Group Containers", "Application Scripts"] { + for entry in entries(relative) { + let name = entry.lastPathComponent + let owner = containerIdentifier(at: entry, home: home) + let group = identity.applicationGroups.contains(name) + || owner.map { identity.applicationGroups.contains($0) } == true + || (relative == "Group Containers" && identity.identifiers.contains { id in + name.hasSuffix("." + id) || name == "group." + id || name.hasPrefix("group." + id + ".") + }) + if group { add(entry, .containers, review: true) } + } } scan("Saved Application State", .savedState) - scan("Logs", .logs) { $0 == app.name } - scan("Logs/DiagnosticReports", .logs) { $0.hasPrefix(app.url.deletingPathExtension().lastPathComponent + "-") } + scan("Logs", .logs, names: true) + for entry in entries("Logs/DiagnosticReports") where identity.names.contains(where: { + entry.lastPathComponent.hasPrefix($0 + "-") || entry.lastPathComponent.hasPrefix($0 + "_") + }) { add(entry, .logs, review: !matches(entry.lastPathComponent)) } scan("WebKit", .webData) scan("Cookies", .webData) scan("LaunchAgents", .launchAgents) + + for path in knownPaths(for: app.bundleIdentifier) { + add(URL(fileURLWithPath: home).appendingPathComponent(path.relative), path.kind, review: path.review) + } return results } + private static func containerIdentifier(at directory: URL, home: String) -> String? { + let metadata = directory.appendingPathComponent(".com.apple.containermanagerd.metadata.plist") + guard isUnredirected(metadata, home: home), + let values = try? metadata.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, let size = values.fileSize, size <= 1_048_576, + let data = try? Data(contentsOf: metadata), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } + return plist["MCMMetadataIdentifier"] as? String + } + + private static func knownPaths(for identifier: String) -> [(relative: String, kind: AppLeftover.Kind, review: Bool)] { + switch identifier.lowercased() { + case "com.microsoft.vscode": + [("Library/Application Support/Code", .support, false), (".vscode", .support, false), (".vscode-shared", .support, true)] + case "com.microsoft.vscodeinsiders": + [("Library/Application Support/Code - Insiders", .support, false), (".vscode-insiders", .support, false), (".vscode-shared", .support, true)] + case "company.thebrowser.browser": + [("Library/Application Support/Arc", .support, false), ("Library/Caches/Arc", .caches, false)] + case "com.google.chrome": + [("Library/Application Support/Google/Chrome", .support, false), ("Library/Caches/Google/Chrome", .caches, false)] + default: [] + } + } + + private static let libraryRoots = ["Application Support", "Caches", "HTTPStorages", "Preferences", "Containers", + "Application Scripts", "Group Containers", "Saved Application State", "Logs", "WebKit", "Cookies", "LaunchAgents"] + private static let sharedNames: Set = ["google", "microsoft", "mozilla", "jetbrains", "adobe", "bravesoftware", "opera software", + "steam", "objective-see", "apple", "crashreporter", "sentrycrash", "org.sparkle-project.sparkle", "com.apple.sharedfilelist"] + + /// 在扫描和回收前重查:只允许固定目录内的应用条目,拒绝共享根及 home 以下的符号链接重定向。 + public static func validateLeftover(_ url: URL, for app: InstalledApp, home: String = NSHomeDirectory()) throws { + if url.standardizedFileURL == app.url.standardizedFileURL { + try validate(app, home: home) + return + } + let base = URL(fileURLWithPath: home).standardizedFileURL.path + let path = url.standardizedFileURL.path + guard !url.pathComponents.contains(".."), !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }), + path.hasPrefix(base + "/"), isUnredirected(url, home: home) else { throw AppUninstallError.unsafePath(path) } + let relative = String(path.dropFirst(base.count + 1)) + if knownPaths(for: app.bundleIdentifier).contains(where: { $0.relative == relative }) { return } + guard let root = libraryRoots.first(where: { relative.hasPrefix("Library/" + $0 + "/") }) else { throw AppUninstallError.unsafePath(path) } + let inside = String(relative.dropFirst(("Library/" + root + "/").count)) + if !inside.contains("/"), sharedNames.contains(inside.lowercased()) { throw AppUninstallError.unsafePath(path) } + if relative == "Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments" + || relative == "Library/Preferences/ByHost" || relative == "Library/Logs/DiagnosticReports" { throw AppUninstallError.unsafePath(path) } + } + + private static func isUnredirected(_ url: URL, home: String) -> Bool { + let originalHome = URL(fileURLWithPath: home).standardizedFileURL + let path = url.standardizedFileURL.path + guard path.hasPrefix(originalHome.path + "/") else { return false } + let relative = String(path.dropFirst(originalHome.path.count + 1)) + let expected = originalHome.resolvingSymlinksInPath().appendingPathComponent(relative).standardizedFileURL.path + return url.resolvingSymlinksInPath().standardizedFileURL.path == expected + } + /// 名字等于包名,或是包名后接“.”的派生名(com.example.app.plist、com.example.app.savedState、com.example.app.helper) static func matchesIdentifier(_ name: String, _ identifier: String) -> Bool { guard !identifier.isEmpty else { return false } diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index 9bde0ad..1bdca51 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -206,7 +206,7 @@ npm 缓存 npm Cache 仪表盘 Dashboard 企业 IP Business IP 企业网络 Business network -会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回 Also finds its caches, preferences, containers and launch agents in your Library and moves them all to the Trash, where you can restore them +找出应用留下的数据、缓存、容器与登录启动项,勾选后移到废纸篓,可以放回 Finds the app's data, caches, containers, and login items. Select items to move to the Trash; you can put them back. 会结束这个应用的 {} 个进程,未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 This ends {} processes of the app and unsaved work may be lost. Force Quit ends them immediately without letting the app save. 传输速率 Link rate 位置 Location @@ -361,7 +361,14 @@ npm 缓存 npm Cache 只在菜单栏显示电池或打开电池页面时记录 Recorded only while Battery is in the menu bar or the Battery page is open 只显示 OpenStats 标志,点击弹出完整面板 Shows only the OpenStats logo; click for the full panel 只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes) -只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. The Dock icon is removed as well +查找应用数据、辅助组件和沙盒容器;可能共用的数据默认不勾选。所选项目会移到废纸篓,程序坞图标只在应用成功移除后清除。 Finds app data, bundled helpers, and sandbox containers. Potentially shared data is left unchecked. Selected items move to the Trash; the Dock icon is removed only after the app is successfully moved. +可能与其他应用共用,默认保留 May be shared with other apps; kept by default +请先选择要移到废纸篓的项目。 Select items to move to the Trash first. +应用本体未能移动,仍保留在原处,可重试。 The app could not be moved. It remains in its original location; you can try again. +已将 {} 的 {} 项残留移到废纸篓,约 {}{}。需要时可以在废纸篓里放回。 Moved {2} leftovers for {1} to the Trash, about {3}{4}. You can put them back from the Trash. +清理“{}”的残留? Clean up leftovers for “{}”? +勾选的 {} 项残留会移到废纸篓,约 {}。清空废纸篓前都可以放回。 The {} selected leftovers will move to the Trash, about {}. You can restore them until you empty the Trash. +清理残留 Clean up leftovers 只设置了系统代理:浏览器等应用走代理,命令行工具、游戏等不读代理设置的程序仍在直连。要接管全部流量,可以开启增强模式或 TUN 模式。 Only a system proxy is set: browsers and most apps use it, but command-line tools, games, and other programs that ignore proxy settings still connect directly. To route all traffic, turn on enhanced mode or TUN mode. 可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied 可执行文件:{} Executable: {} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift index 7208a3d..2b5bffb 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift @@ -329,12 +329,13 @@ enum MenuBarRenderer { alert ? .systemRed : .labelColor } - /// 颜色保持动态(labelColor / secondaryLabelColor),在绘制时才按菜单栏外观解析 + /// 标签与普通读数同用动态 labelColor;彩色合并图不用模板着色, + /// secondaryLabelColor 在菜单栏的 vibrancy 外观下会成为低对比度灰色。 /// 两行文字:宽度按标签、数值与样本里最宽的算,所以数值变长也不会挤动旁边的项目。 /// `centered` 时两行各自居中,标签比数值宽时(风扇、温度)数值不会贴在右边 private static func stackedText(label: String, value: String, sample: String, alert: Bool, centered: Bool = false) -> Segment { - let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.labelFont, .foregroundColor: NSColor.secondaryLabelColor] + let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.labelFont, .foregroundColor: NSColor.labelColor] let valueAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.stackedValueFont, .foregroundColor: foreground(alert)] let width = ceil(max(textWidth(label, labelAttributes), textWidth(value, valueAttributes), textWidth(sample, valueAttributes))) return Segment(width: width) { rect in @@ -352,7 +353,7 @@ enum MenuBarRenderer { } private static func inlineText(label: String, value: String, sample: String, alert: Bool) -> Segment { - let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.inlineLabelFont, .foregroundColor: NSColor.secondaryLabelColor] + let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.inlineLabelFont, .foregroundColor: NSColor.labelColor] let labelWidth = ceil(textWidth(label, labelAttributes)) let valueSegment = inlineValue(value, sample: sample, alert: alert) return Segment(width: labelWidth + Metrics.innerGap + valueSegment.width) { rect in @@ -566,7 +567,7 @@ enum MenuBarRenderer { NSBezierPath(ovalIn: NSRect(x: rect.minX, y: center - Metrics.dotSize / 2, width: Metrics.dotSize, height: Metrics.dotSize)).fill() } else { - drawText(arrow, [.font: Metrics.networkFont, .foregroundColor: NSColor.secondaryLabelColor], + drawText(arrow, [.font: Metrics.networkFont, .foregroundColor: NSColor.labelColor], rightEdge: rect.minX + markerWidth, baseline: baseline) } drawText(text, attributes, rightEdge: rect.maxX, baseline: baseline) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift index 68038ab..c1d7c53 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Charts.swift @@ -512,64 +512,81 @@ struct ScoreBand: View { private static let tickHeight: CGFloat = DS.Space.s3 var body: some View { - let bands = DS.Grade.bands let clamped = min(100, max(0, score)) GeometryReader { proxy in let width = proxy.size.width ZStack(alignment: .topLeading) { VStack(spacing: DS.Space.s1 / 2) { - HStack(spacing: 1) { - ForEach(Array(bands.enumerated()), id: \.offset) { index, band in - let lower = index == 0 ? 0 : bands[index - 1].upper - let segmentWidth = width * CGFloat(band.upper - lower) / 100 - ZStack { - Rectangle().fill(band.color) - if segmentWidth >= DS.Space.s6 { - Text(verbatim: band.grade) - .dsFont(.xs, weight: .semibold) - .foregroundStyle(DS.Palette.onPrimary) - } - } - .frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1))) - } - } - .frame(height: height) - ZStack(alignment: .topLeading) { - // A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下 - ForEach([0] + bands.map(\.upper).filter { $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) }, id: \.self) { tick in - Text(verbatim: "\(tick)") - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - .monospacedDigit() - .fixedSize() - .alignmentGuide(.leading) { dimensions in - // 0 靠左、100 靠右,其余居中对齐刻度 - tick == 0 ? 0 : tick == 100 ? dimensions.width - width : dimensions.width / 2 - width * CGFloat(tick) / 100 - } - } - } - .frame(width: width, height: Self.tickHeight, alignment: .topLeading) + colorBands(width: width) + tickLabels(width: width) } .padding(.top, Self.markerHeight) - - // 得分标记:气泡里写等级,尖角指向色带 - let color = DS.Grade.color(for: clamped) - VStack(spacing: 0) { - Text(verbatim: DS.Grade.bands.first { clamped < $0.upper }?.grade ?? "A+") - .dsFont(.xs, weight: .semibold) - .foregroundStyle(DS.Palette.onPrimary) - .padding(.horizontal, DS.Space.s1) - .frame(height: DS.Size.iconInline) - .background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm)) - Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1) - } - .fixedSize() - .alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(clamped) / 100 } + scoreMarker(width: width, score: clamped) } } .frame(height: height + Self.markerHeight + Self.tickHeight + DS.Space.s1 / 2) .accessibilityLabel(tr("纯净度 \(clamped) 分")) } + + private func colorBands(width: CGFloat) -> some View { + let bands = DS.Grade.bands + return HStack(spacing: 1) { + ForEach(Array(bands.enumerated()), id: \.offset) { index, band in + let lower = index == 0 ? 0 : bands[index - 1].upper + let segmentWidth = width * CGFloat(band.upper - lower) / 100 + ZStack { + Rectangle().fill(band.color) + if segmentWidth >= DS.Space.s6 { + Text(verbatim: band.grade) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.onPrimary) + } + } + .frame(width: max(0, segmentWidth - (index == bands.count - 1 ? 0 : 1))) + } + } + .frame(height: height) + } + + private func tickLabels(width: CGFloat) -> some View { + // A 与 A+ 的分界太密,刻度上不标 95;弹窗那么窄时 85 也挤不下 + let ticks: [Int] = [0] + DS.Grade.bands.map(\.upper).filter { + $0 != 95 && ($0 != 85 || width >= DS.Size.panelWidth / 2) + } + return ZStack(alignment: .topLeading) { + ForEach(ticks, id: \.self) { tick in + Text(verbatim: "\(tick)") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + .fixedSize() + .alignmentGuide(.leading) { dimensions in + // 0 靠左、100 靠右,其余居中对齐刻度 + if tick == 0 { return 0 } + if tick == 100 { return dimensions.width - width } + return dimensions.width / 2 - width * CGFloat(tick) / 100 + } + } + } + .frame(width: width, height: Self.tickHeight, alignment: .topLeading) + } + + /// 得分标记:气泡里写等级,尖角指向色带 + private func scoreMarker(width: CGFloat, score: Int) -> some View { + let color = DS.Grade.color(for: score) + let grade = DS.Grade.bands.first { score < $0.upper }?.grade ?? "A+" + return VStack(spacing: 0) { + Text(verbatim: grade) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.onPrimary) + .padding(.horizontal, DS.Space.s1) + .frame(height: DS.Size.iconInline) + .background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm)) + Triangle().fill(color).frame(width: DS.Space.s2, height: DS.Space.s1) + } + .fixedSize() + .alignmentGuide(.leading) { dimensions in dimensions.width / 2 - width * CGFloat(score) / 100 } + } } private struct Triangle: Shape { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift index 8c6c687..36c3b89 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift @@ -39,13 +39,32 @@ struct UninstallerPage: View { } return true } - .confirmationDialog(uninstaller.selected.map { tr("卸载“\($0.name)”?") } ?? "", isPresented: $confirming, titleVisibility: .visible) { + .confirmationDialog(confirmationTitle, isPresented: $confirming, titleVisibility: .visible) { Button(tr("移到废纸篓"), role: .destructive) { uninstaller.uninstall() } Button(tr("取消"), role: .cancel) {} } message: { - Text(tr("应用与勾选的 \(max(0, uninstaller.chosen.count - 1)) 项残留会移到废纸篓,约 \(Format.bytes(uninstaller.chosenSize, base: .decimal))。清空废纸篓前都可以放回。")) + Text(confirmationMessage) } } + + private var confirmationTitle: String { + guard let app = model.uninstaller.selected else { return "" } + return includesApplication ? tr("卸载“\(app.name)”?") : tr("清理“\(app.name)”的残留?") + } + + private var includesApplication: Bool { + let uninstaller = model.uninstaller + return uninstaller.leftovers.contains { $0.kind == .application && uninstaller.chosen.contains($0.id) } + } + + private var confirmationMessage: String { + let uninstaller = model.uninstaller + let size = Format.bytes(uninstaller.chosenSize, base: .decimal) + let count = uninstaller.chosen.count - (includesApplication ? 1 : 0) + return includesApplication + ? tr("应用与勾选的 \(count) 项残留会移到废纸篓,约 \(size)。清空废纸篓前都可以放回。") + : tr("勾选的 \(count) 项残留会移到废纸篓,约 \(size)。清空废纸篓前都可以放回。") + } } private struct AppListColumn: View { @@ -178,12 +197,19 @@ private struct AppDetailCard: View { HStack(spacing: DS.Space.s2) { DSCheckbox(isOn: uninstaller.chosen.contains(item.id)) { uninstaller.toggle(item) } .disabled(item.kind == .application) - Text(verbatim: item.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - .help(item.url.path) + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(verbatim: item.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .help(item.url.path) + if item.requiresReview { + Text(tr("可能与其他应用共用,默认保留")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + } Spacer(minLength: DS.Space.s2) Text(verbatim: Format.bytes(item.size, base: .decimal)) .dsFont(.xs) @@ -201,17 +227,23 @@ private struct AppDetailCard: View { HairlineDivider() HStack(spacing: DS.Space.s3) { Spacer() - Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() } + Button(actionTitle) { confirm() } .buttonStyle(DSButtonStyle(kind: .primary)) - .disabled(running || uninstaller.isScanning || uninstaller.isRemoving) + .disabled(running || uninstaller.isScanning || uninstaller.isRemoving || uninstaller.chosen.isEmpty) } - // 程序坞里的图标始终一并移除,不单独做开关:留着一个空图标没有意义 - Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除")) + // 本体成功移除后才清除程序坞图标。 + Text(tr("查找应用数据、辅助组件和沙盒容器;可能共用的数据默认不勾选。所选项目会移到废纸篓,程序坞图标只在应用成功移除后清除。")) .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) .fixedSize(horizontal: false, vertical: true) } } + + private var actionTitle: String { + let uninstaller = model.uninstaller + if uninstaller.isRemoving { return tr("正在移除…") } + return uninstaller.leftovers.contains { $0.kind == .application } ? tr("卸载") : tr("清理残留") + } } private struct DropHint: View { @@ -225,7 +257,7 @@ private struct DropHint: View { Text(tr("从左侧选择应用,或把应用拖到这里")) .dsFont(.sm, weight: .medium) .foregroundStyle(DS.Palette.textPrimary) - Text(tr("会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回")) + Text(tr("找出应用留下的数据、缓存、容器与登录启动项,勾选后移到废纸篓,可以放回")) .dsFont(.xs) .foregroundStyle(DS.Palette.textSecondary) .multilineTextAlignment(.center) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift index 493c3eb..23512b9 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/ActivityRanking.swift @@ -27,9 +27,12 @@ struct ActivityRanking: Equatable { } func ranked(limit: Int) -> [Entry] { - Array(scores.map { Entry(id: $0.key, score: $0.value) } - .sorted { $0.score == $1.score ? $0.id < $1.id : $0.score > $1.score } - .prefix(limit)) + let entries: [Entry] = scores.map { Entry(id: $0.key, score: $0.value) } + let sorted = entries.sorted { lhs, rhs in + if lhs.score == rhs.score { return lhs.id < rhs.id } + return lhs.score > rhs.score + } + return Array(sorted.prefix(limit)) } var isEmpty: Bool { scores.isEmpty } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift index f71b407..7cff548 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift @@ -5,6 +5,28 @@ import Localization import Metrics import Observation +/// 回收结果只以系统返回的源路径为准,不把未成功的项目计入清理记录。 +struct UninstallResultSummary: Sendable { + let successfulItems: [AppLeftover] + let failedItems: [AppLeftover] + let applicationWasRequested: Bool + let applicationMoved: Bool + + init(app: InstalledApp, requested: [AppLeftover], movedSources: Set) { + let movedPaths = Set(movedSources.map { $0.standardizedFileURL.path }) + let appPath = app.url.standardizedFileURL.path + successfulItems = requested.filter { movedPaths.contains($0.url.standardizedFileURL.path) } + failedItems = requested.filter { !movedPaths.contains($0.url.standardizedFileURL.path) } + applicationWasRequested = requested.contains { $0.url.standardizedFileURL.path == appPath } + applicationMoved = successfulItems.contains { $0.url.standardizedFileURL.path == appPath } + } + + var movedCount: Int { successfulItems.count } + var leftoverCount: Int { movedCount - (applicationMoved ? 1 : 0) } + var freedBytes: UInt64 { successfulItems.reduce(0) { $0 + $1.size } } + var isPartial: Bool { !failedItems.isEmpty } +} + /// 卸载应用:列出已安装的应用,查找残留文件,连同残留一起移到废纸篓,并从程序坞移除图标 @MainActor @Observable @@ -63,11 +85,15 @@ public final class UninstallerController { let found = await Task.detached { AppUninstaller.leftovers(for: app) }.value guard selected == app else { return } leftovers = found - chosen = Set(found.map(\.id)) + chosen = Self.initialSelection(for: found) isScanning = false } } + static func initialSelection(for items: [AppLeftover]) -> Set { + Set(items.filter { !$0.requiresReview }.map(\.id)) + } + /// 拖进来的 .app func select(url: URL) { guard let app = AppUninstaller.app(at: url) else { @@ -99,38 +125,63 @@ public final class UninstallerController { func uninstall() { guard let app = selected, !isRemoving else { return } + let requested = leftovers.filter { chosen.contains($0.id) } do { try AppUninstaller.validate(app) guard !isRunning(app) else { throw AppUninstallError.running } + for item in requested { try AppUninstaller.validateLeftover(item.url, for: app) } } catch { outcome = ("\(error)", true) return } - let urls = leftovers.filter { chosen.contains($0.id) }.map(\.url) - let freed = chosenSize + guard !requested.isEmpty else { + outcome = (tr("请先选择要移到废纸篓的项目。"), true) + return + } + let urls = requested.map(\.url) isRemoving = true outcome = nil // NSWorkspace 负责需要管理员权限的情况(例如 root 拥有的应用),并能在废纸篓中“放回原处” NSWorkspace.shared.recycle(urls) { [weak self] moved, error in - let movedCount = moved.count + let summary = UninstallResultSummary(app: app, requested: requested, movedSources: Set(moved.keys)) let message = error?.localizedDescription Task { @MainActor in guard let self else { return } self.isRemoving = false - if movedCount == 0 { - self.outcome = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true) + if summary.movedCount == 0 { + if self.selected == app { + self.outcome = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true) + } return } - // 应用已经进了废纸篓,程序坞里的图标只会变成问号,一并移除 - let dock = Self.removeDockTile(for: app.url) - self.tally?.recordUninstall(bytes: freed) - Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项") - let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : "" - self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。"), - message != nil) - self.selected = nil - self.leftovers = [] - self.apps.removeAll { $0 == app } + let dock: Bool + if summary.applicationMoved { + // 只有本体已进废纸篓时,才移除列表与程序坞图标并计一次卸载。 + dock = Self.removeDockTile(for: app.url) + self.apps.removeAll { $0 == app } + self.sizes.removeValue(forKey: app.id) + self.tally?.recordUninstall(bytes: summary.freedBytes) + } else { + dock = false + self.tally?.recordClean(bytes: summary.freedBytes) + } + Log.app.notice("回收 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(summary.movedCount) 项,未移动 \(summary.failedItems.count) 项") + // 回收期间可能已选择其他应用,完成旧操作不覆盖新应用的详情。 + guard self.selected == app else { return } + let partial = summary.isPartial ? tr(",\(summary.failedItems.count) 项未能移动") : "" + let freed = Format.bytes(summary.freedBytes, base: .decimal) + let text: String + if summary.applicationMoved { + text = tr("已将 \(app.name) 与 \(summary.leftoverCount) 项残留移到废纸篓,约 \(freed)\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。") + } else { + let appFailure = summary.applicationWasRequested ? "\n" + tr("应用本体未能移动,仍保留在原处,可重试。") : "" + text = tr("已将 \(app.name) 的 \(summary.leftoverCount) 项残留移到废纸篓,约 \(freed)\(partial)。需要时可以在废纸篓里放回。") + appFailure + } + self.outcome = (text, summary.isPartial || message != nil) + let movedIDs = Set(summary.successfulItems.map(\.id)) + self.leftovers.removeAll { movedIDs.contains($0.id) } + self.chosen.subtract(movedIDs) + if self.leftovers.isEmpty { self.selected = nil } } } } diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift new file mode 100644 index 0000000..801153f --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallIdentityTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import Cleaner + +@Suite struct AppUninstallIdentityTests { + private func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("uninstall-identity-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url.resolvingSymlinksInPath() + } + + private func makeBundle(at url: URL, identifier: String, name: String, displayName: String? = nil) throws { + let contents = url.appendingPathComponent("Contents") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var plist: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundleName": name] + if let displayName { plist["CFBundleDisplayName"] = displayName } + // 未签名的 Info.plist 中宣称的 entitlement 不能用来选择共享容器。 + plist["com.apple.security.application-groups"] = ["group.untrusted.plist"] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + } + + private func app(at url: URL, identifier: String = "com.example.foo", name: String = "Displayed App") -> InstalledApp { + InstalledApp(url: url, name: name, bundleIdentifier: identifier, version: nil, teamIdentifier: nil) + } + + @Test func readsMainBundleNamesAndEmbeddedIdentifiersWithoutHelperNames() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("File Name.app") + try makeBundle(at: root, identifier: "com.example.foo", name: "Sentry Name", displayName: "Localized Name") + let bundles = [ + ("Contents/Frameworks/Widget.framework/Versions/A/Helpers/Worker.app", "com.example.worker", "CrashHandler"), + ("Contents/XPCServices/Service.xpc", "com.example.service", "Service"), + ("Contents/PlugIns/Extension.appex", "com.example.extension", "Extension"), + ] + for (path, identifier, name) in bundles { + try makeBundle(at: root.appendingPathComponent(path), identifier: identifier, name: name) + } + // 扫描根之外的普通资源 bundle 不作为嵌套应用身份。 + try makeBundle(at: root.appendingPathComponent("Contents/Resources/Other.app"), identifier: "com.other.resource", name: "Other") + + let identity = AppUninstallIdentity(app: app(at: root)) + #expect(identity.identifiers == ["com.example.foo", "com.example.worker", "com.example.service", "com.example.extension"]) + #expect(identity.names == ["Displayed App", "File Name", "Sentry Name", "Localized Name"]) + #expect(identity.applicationGroups.isEmpty) + #expect(identity.teamIdentifier == nil) + } + + @Test func filtersUnsafeMetadataAndInjectedIdentities() { + let identity = AppUninstallIdentity( + identifiers: ["com.example.foo", "com", "org", "group", "", ".", "..", ".com.foo", "com..foo", "com.foo/other", "com.foo\\other", "com.foo\n"], + names: [" Foo ", "中文应用", "", " ", ".", "..", "../Other", "a/b", "a\\b", "Bad\0Name", "Bad\n"], + applicationGroups: ["group.com.example.foo", "com", "org", "group", "", ".", "../outside", "group.*"], + teamIdentifier: "TEAM/OTHER") + #expect(identity.identifiers == ["com.example.foo"]) + #expect(identity.names == ["Foo", "中文应用"]) + #expect(identity.applicationGroups == ["group.com.example.foo"]) + #expect(identity.teamIdentifier == nil) + let groups = AppUninstallIdentity.applicationGroups(from: [ + "com.apple.security.application-groups": ["TEAM123.com.example.shared", "group.com.example.foo", "../outside", "group.*", 42] as [Any], + "com.apple.developer.icloud-container-identifiers": ["iCloud.com.example.foo"], + "keychain-access-groups": ["TEAM123.*"], + ]) + #expect(groups == ["TEAM123.com.example.shared", "group.com.example.foo"]) + } + + @Test func refusesSymlinkBundlesDirectoriesAndMetadata() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("Foo.app") + let outside = directory.appendingPathComponent("Outside.app") + try makeBundle(at: root, identifier: "com.example.foo", name: "Foo") + try makeBundle(at: outside, identifier: "com.other.outside", name: "Outside") + let manager = FileManager.default + let frameworks = root.appendingPathComponent("Contents/Frameworks") + try manager.createDirectory(at: frameworks, withIntermediateDirectories: true) + try manager.createSymbolicLink(at: frameworks.appendingPathComponent("Linked.app"), withDestinationURL: outside) + + let externalServices = directory.appendingPathComponent("ExternalServices") + try makeBundle(at: externalServices.appendingPathComponent("Other.xpc"), identifier: "com.other.service", name: "Other") + try manager.createSymbolicLink(at: root.appendingPathComponent("Contents/XPCServices"), withDestinationURL: externalServices) + + let linkedMetadata = root.appendingPathComponent("Contents/PlugIns/Linked.appex/Contents") + try manager.createDirectory(at: linkedMetadata, withIntermediateDirectories: true) + try manager.createSymbolicLink(at: linkedMetadata.appendingPathComponent("Info.plist"), + withDestinationURL: outside.appendingPathComponent("Contents/Info.plist")) + let identity = AppUninstallIdentity(app: app(at: root, name: "Foo")) + #expect(identity.identifiers == ["com.example.foo"]) + #expect(identity.names == ["Foo"]) + #expect(AppUninstallIdentity.embeddedBundles(in: root).isEmpty) + #expect(!AppUninstallIdentity.isSafeURL(outside, inside: root)) + #expect(!AppUninstallIdentity.isSafeURL(frameworks.appendingPathComponent("Linked.app"), inside: root)) + } + + @Test func refusesLinkedMainBundleButPreservesSuppliedBasicIdentity() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let outside = directory.appendingPathComponent("Outside.app") + let linked = directory.appendingPathComponent("Linked.app") + try makeBundle(at: outside, identifier: "com.other.outside", name: "Outside") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: outside) + let identity = AppUninstallIdentity(app: app(at: linked, name: "Displayed App")) + #expect(identity.identifiers == ["com.example.foo"]) + #expect(identity.names == ["Displayed App", "Linked"]) + #expect(identity.applicationGroups.isEmpty) + } + + @Test func boundsTraversalDepth() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("Foo.app") + try makeBundle(at: root, identifier: "com.example.foo", name: "Foo") + let nestedPath = "Contents/Frameworks/" + Array(repeating: "level", count: 12).joined(separator: "/") + "/Deep.app" + try makeBundle(at: root.appendingPathComponent(nestedPath), identifier: "com.other.deep", name: "Deep") + #expect(AppUninstallIdentity(app: app(at: root)).identifiers == ["com.example.foo"]) + } + + @Test func readsSignedGroupsAndRejectsThemAfterSignatureIsBroken() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let root = directory.appendingPathComponent("Signed Fixture.app") + let macos = root.appendingPathComponent("Contents/MacOS") + let executable = macos.appendingPathComponent("Fixture") + try FileManager.default.createDirectory(at: macos, withIntermediateDirectories: true) + // 写入副本字节,避免把系统二进制的受保护文件标记复制到测试目录。 + try Data(contentsOf: URL(fileURLWithPath: "/usr/bin/true")).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let plistURL = root.appendingPathComponent("Contents/Info.plist") + var metadata: [String: Any] = [ + "CFBundleIdentifier": "com.example.signaturefixture", + "CFBundleName": "Signed Fixture", + "CFBundlePackageType": "APPL", + "CFBundleExecutable": "Fixture", + "CFBundleVersion": "1", + ] + try PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0).write(to: plistURL) + let entitlements = directory.appendingPathComponent("entitlements.plist") + try PropertyListSerialization.data(fromPropertyList: [ + "com.apple.security.application-groups": ["group.com.example.signaturefixture"], + ], format: .xml, options: 0).write(to: entitlements) + + // 只给临时 bundle 重新签名;无需证书、网络或运行这个二进制。 + let signer = Process() + let diagnostics = Pipe() + signer.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + signer.arguments = ["--force", "--sign", "-", "--timestamp=none", "--entitlements", entitlements.path, root.path] + signer.standardOutput = FileHandle.nullDevice + signer.standardError = diagnostics + try signer.run() + let output = String(decoding: diagnostics.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + signer.waitUntilExit() + try #require(signer.terminationStatus == 0, "codesign failed: \(output)") + + let fixture = app(at: root, identifier: "com.example.signaturefixture", name: "Signed Fixture") + let signed = AppUninstallIdentity(app: fixture) + #expect(signed.applicationGroups == ["group.com.example.signaturefixture"]) + #expect(signed.teamIdentifier == nil) // ad-hoc 签名没有开发团队。 + + // 保持 plist 格式有效,只改签名覆盖的内容,确保不会接受失效签名中的 group。 + metadata["CFBundleName"] = "Changed Fixture" + try PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0).write(to: plistURL) + let broken = AppUninstallIdentity(app: fixture) + #expect(broken.applicationGroups.isEmpty) + #expect(broken.identifiers == ["com.example.signaturefixture"]) + #expect(broken.names.contains("Changed Fixture")) + } +} diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift new file mode 100644 index 0000000..a6d5405 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerEnhancedTests.swift @@ -0,0 +1,324 @@ +import Foundation +import Testing +@testable import Cleaner + +@Suite struct AppUninstallerEnhancedTests { + private struct Fixture { + let root: URL + let home: URL + let outside: URL + + init() throws { + let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent("uninstall-enhanced-\(UUID().uuidString)") + root = rootURL + home = rootURL.appendingPathComponent("Home") + outside = rootURL.appendingPathComponent("Outside") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + func app(name: String = "Foo", identifier: String = "com.example.foo") throws -> InstalledApp { + let url = home.appendingPathComponent("Applications/\(name).app") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return InstalledApp(url: url, name: name, bundleIdentifier: identifier, version: nil, teamIdentifier: nil) + } + + @discardableResult + func directory(_ relative: String) throws -> URL { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @discardableResult + func file(_ relative: String) throws -> URL { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("test data".utf8).write(to: url) + return url + } + + func metadata(_ identifier: String, in directory: URL) throws { + let data = try PropertyListSerialization.data(fromPropertyList: ["MCMMetadataIdentifier": identifier], format: .binary, options: 0) + try data.write(to: directory.appendingPathComponent(".com.apple.containermanagerd.metadata.plist")) + } + + func symlink(_ relative: String, to destination: URL) throws -> URL { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: url, withDestinationURL: destination) + return url + } + } + + @Test func containerMetadataDeterminesOwnershipEvenForUUIDDirectories() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let owned = try fixture.directory("Library/Containers/12345678-ABCD-4567-ABCD-123456789ABC") + try fixture.metadata(app.bundleIdentifier, in: owned) + let unrelated = try fixture.directory("Library/Containers/ABCDEF12-3456-4567-ABCD-123456789ABC") + try fixture.metadata("com.other.app", in: unrelated) + let misleadingName = try fixture.directory("Library/Containers/com.example.foo") + try fixture.metadata("com.other.app", in: misleadingName) + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: []) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, owned.path])) + #expect(found.first { $0.url.path == owned.path }?.requiresReview == false) + #expect(FileManager.default.fileExists(atPath: unrelated.path)) + #expect(FileManager.default.fileExists(atPath: misleadingName.path)) + } + + @Test func verifiedEmbeddedIdentifiersFindTheirOwnResidualData() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let helperIdentifier = "com.vendor.separate-helper" + let preference = try fixture.file("Library/Preferences/\(helperIdentifier).plist") + let cache = try fixture.directory("Library/Caches/\(helperIdentifier)") + let unrelated = try fixture.file("Library/Preferences/com.vendor.separate-helper-other.plist") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier, helperIdentifier], names: []) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, preference.path, cache.path])) + #expect(!found.contains { $0.url.path == unrelated.path }) + } + + @Test func sentryBundleNameCandidateRequiresReviewAndDoesNotMatchSimilarNames() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app(name: "Localized Display Name") + let namedCache = try fixture.directory("Library/Caches/SentryCrash/Foo") + let similarlyNamed = try fixture.directory("Library/Caches/SentryCrash/FooBar") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: ["Foo"]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, namedCache.path])) + #expect(found.first { $0.url.path == namedCache.path }?.requiresReview == true) + #expect(!found.contains { $0.url.path == similarlyNamed.path }) + } + + @Test func nestedVendorEntriesPreserveVendorRootsAndOtherApplications() throws { + let fixture = try Fixture() + defer { fixture.remove() } + // A coincidental app name must not make a shared vendor root selectable. + let app = try fixture.app(name: "Microsoft") + let support = try fixture.directory("Library/Application Support/Microsoft/com.example.foo") + let cache = try fixture.directory("Library/Caches/Microsoft/com.example.foo.cache") + let log = try fixture.file("Library/Logs/Microsoft/com.example.foo.log") + let neighbors = [ + try fixture.directory("Library/Application Support/Microsoft/com.other.app"), + try fixture.directory("Library/Caches/Microsoft/com.example.foobar"), + try fixture.file("Library/Logs/Microsoft/com.other.app.log"), + ] + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: ["Microsoft"]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, support.path, cache.path, log.path])) + for relative in ["Library/Application Support/Microsoft", "Library/Caches/Microsoft", "Library/Logs/Microsoft"] { + let parent = fixture.home.appendingPathComponent(relative) + #expect(!found.contains { $0.url.path == parent.path }) + #expect(FileManager.default.fileExists(atPath: parent.path)) + } + for neighbor in neighbors { #expect(!found.contains { $0.url.path == neighbor.path }) } + } + + @Test func vscodeChannelsKeepDistinctDataAndRequireReviewForSharedData() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let stable = try fixture.app(name: "Visual Studio Code", identifier: "com.microsoft.VSCode") + let insiders = try fixture.app(name: "Visual Studio Code - Insiders", identifier: "com.microsoft.VSCodeInsiders") + let stableData = try fixture.directory("Library/Application Support/Code") + let insidersData = try fixture.directory("Library/Application Support/Code - Insiders") + let stableExtensions = try fixture.directory(".vscode") + let insidersExtensions = try fixture.directory(".vscode-insiders") + let shared = try fixture.directory(".vscode-shared") + + let stableFound = AppUninstaller.leftovers(for: stable, home: fixture.home.path, + identity: AppUninstallIdentity(identifiers: [stable.bundleIdentifier], names: [])) + let insidersFound = AppUninstaller.leftovers(for: insiders, home: fixture.home.path, + identity: AppUninstallIdentity(identifiers: [insiders.bundleIdentifier], names: [])) + + #expect(Set(stableFound.map { $0.url.path }) == Set([stable.url.path, stableData.path, stableExtensions.path, shared.path])) + #expect(Set(insidersFound.map { $0.url.path }) == Set([insiders.url.path, insidersData.path, insidersExtensions.path, shared.path])) + #expect(stableFound.first { $0.url.path == shared.path }?.requiresReview == true) + #expect(insidersFound.first { $0.url.path == shared.path }?.requiresReview == true) + #expect(stableFound.first { $0.url.path == stableData.path }?.requiresReview == false) + #expect(insidersFound.first { $0.url.path == insidersData.path }?.requiresReview == false) + } + + @Test func applicationGroupsUseExactEntitlementsAndKeepReviewWhenIdentifiersAlsoMatch() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let groupIdentifier = "TEAM123.com.vendor.shared" + let group = try fixture.directory("Library/Group Containers/\(groupIdentifier)") + let uuidGroup = try fixture.directory("Library/Group Containers/ABCDEF12-3456-4567-ABCD-123456789ABC") + try fixture.metadata(groupIdentifier, in: uuidGroup) + let unrelated = try fixture.directory("Library/Group Containers/TEAM123.com.vendor.other") + let scriptsIdentifier = "com.example.foo.shared" + let scripts = try fixture.directory("Library/Application Scripts/\(scriptsIdentifier)") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: [], + applicationGroups: [groupIdentifier, scriptsIdentifier]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, group.path, uuidGroup.path, scripts.path])) + for candidate in [group, uuidGroup, scripts] { + #expect(found.first { $0.url.path == candidate.path }?.requiresReview == true) + } + #expect(!found.contains { $0.url.path == unrelated.path }) + } + + @Test(arguments: ["scanRoot", "vendorParent", "leaf"]) + func symlinkRedirectsCannotEscapeFromAnyLevelOfTheScan(_ level: String) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let externalTarget = fixture.outside.appendingPathComponent(app.bundleIdentifier) + try FileManager.default.createDirectory(at: externalTarget, withIntermediateDirectories: true) + try Data("must be preserved".utf8).write(to: externalTarget.appendingPathComponent("keep")) + let redirectedCandidate: URL + switch level { + case "scanRoot": + let root = try fixture.symlink("Library/Caches", to: fixture.outside) + redirectedCandidate = root.appendingPathComponent(app.bundleIdentifier) + case "vendorParent": + let parent = try fixture.symlink("Library/Caches/Vendor", to: fixture.outside) + redirectedCandidate = parent.appendingPathComponent(app.bundleIdentifier) + default: + redirectedCandidate = try fixture.symlink("Library/Caches/\(app.bundleIdentifier)", to: externalTarget) + } + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: []) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path])) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(redirectedCandidate, for: app, home: fixture.home.path) + } + #expect(FileManager.default.fileExists(atPath: externalTarget.appendingPathComponent("keep").path)) + } + + @Test func validatesAgainWhenAParentIsReplacedAfterScanning() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let candidate = try fixture.directory("Library/Caches/Vendor/com.example.foo") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: []) + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + #expect(found.contains { $0.url.path == candidate.path }) + #expect(throws: Never.self) { try AppUninstaller.validateLeftover(candidate, for: app, home: fixture.home.path) } + + try FileManager.default.removeItem(at: candidate.deletingLastPathComponent()) + let externalTarget = fixture.outside.appendingPathComponent(app.bundleIdentifier) + try FileManager.default.createDirectory(at: externalTarget, withIntermediateDirectories: true) + _ = try fixture.symlink("Library/Caches/Vendor", to: fixture.outside) + + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(candidate, for: app, home: fixture.home.path) + } + #expect(FileManager.default.fileExists(atPath: externalTarget.path)) + } + + @Test func knownApplicationDataReplacesItsAlreadyFoundChildren() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app(name: "Visual Studio Code", identifier: "com.microsoft.VSCode") + let support = try fixture.directory("Library/Application Support/Code") + let child = try fixture.directory("Library/Application Support/Code/com.microsoft.VSCode.cache") + try fixture.file("Library/Application Support/Code/com.microsoft.VSCode.cache/data") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: []) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, support.path])) + #expect(!found.contains { $0.url.path == child.path }) + #expect(found.count == Set(found.map { $0.url.path }).count) + } + + @Test func sharedScanRootsCannotBeSelectedEvenWhenTheirNamesMatchTheApp() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let sharedRoots = [ + "Library/Application Support/Microsoft", + "Library/Application Support/JetBrains", + "Library/Application Support/CrashReporter", + "Library/Caches/SentryCrash", + "Library/Caches/org.sparkle-project.Sparkle", + "Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", + "Library/Preferences/ByHost", + "Library/Logs/DiagnosticReports", + ] + for path in sharedRoots { try fixture.directory(path) } + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], + names: ["Microsoft", "JetBrains", "CrashReporter", "SentryCrash", "org.sparkle-project.Sparkle"]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path])) + for path in sharedRoots + ["Library/Caches", "Library/Application Support"] { + let url = fixture.home.appendingPathComponent(path) + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(url, for: app, home: fixture.home.path) + } + } + } + + @Test func nameCandidatesRequireReviewAndParentsInheritReviewFromChildren() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app(name: "Visual Studio Code", identifier: "com.microsoft.VSCode") + let namedSupport = try fixture.directory("Library/Application Support/Named Data") + let namedLogs = try fixture.directory("Library/Logs/Named Logs") + let applicationData = try fixture.directory("Library/Application Support/Code") + let child = try fixture.directory("Library/Application Support/Code/com.vendor.shared-helper") + let primaryPreference = try fixture.file("Library/Preferences/com.microsoft.VSCode.plist") + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier, "com.vendor.shared-helper"], + names: ["Named Data", "Named Logs"]) + + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + + #expect(Set(found.map { $0.url.path }) == Set([app.url.path, namedSupport.path, namedLogs.path, + applicationData.path, primaryPreference.path])) + for candidate in [namedSupport, namedLogs, applicationData] { + #expect(found.first { $0.url.path == candidate.path }?.requiresReview == true) + } + #expect(found.first { $0.url.path == primaryPreference.path }?.requiresReview == false) + #expect(!found.contains { $0.url.path == child.path }) + } + + @Test func applicationRootReplacedAfterScanningCannotAuthorizeAnOutsideApplication() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let app = try fixture.app() + let identity = AppUninstallIdentity(identifiers: [app.bundleIdentifier], names: []) + let found = AppUninstaller.leftovers(for: app, home: fixture.home.path, identity: identity) + #expect(found.contains { $0.url.path == app.url.path && $0.kind == .application }) + + try FileManager.default.removeItem(at: app.url.deletingLastPathComponent()) + let externalApplication = fixture.outside.appendingPathComponent(app.url.lastPathComponent) + try FileManager.default.createDirectory(at: externalApplication, withIntermediateDirectories: true) + let preserved = externalApplication.appendingPathComponent("keep") + try Data("another application's data".utf8).write(to: preserved) + _ = try fixture.symlink("Applications", to: fixture.outside) + + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validate(app, home: fixture.home.path) + } + #expect(throws: AppUninstallError.self) { + try AppUninstaller.validateLeftover(app.url, for: app, home: fixture.home.path) + } + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } +} diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift index a673dad..54cfc14 100644 --- a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift @@ -50,6 +50,47 @@ import Testing #expect(!found.contains("com.example.foobar")) } + @Test func findsNestedLeftoversWithoutSelectingSharedDirectories() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + let lib = home.appendingPathComponent("Library") + let appURL = home.appendingPathComponent("Applications/Foo.app") + try touch(appURL, directory: true) + let app = InstalledApp(url: appURL, name: "Foo", bundleIdentifier: "com.example.foo", version: nil, teamIdentifier: nil) + let locations: [(path: String, target: String, kind: AppLeftover.Kind, directory: Bool)] = [ + ("Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", "com.example.foo.sfl3", .support, false), + ("Application Support/CrashReporter", "com.example.foo.plist", .logs, false), + ("Caches/org.sparkle-project.Sparkle", "com.example.foo", .caches, true), + ("Caches/SentryCrash", "com.example.foo", .caches, true), + ] + var targets = Set([appURL.path]) + var preserved: [URL] = [] + for location in locations { + let sharedDirectory = lib.appendingPathComponent(location.path) + let target = sharedDirectory.appendingPathComponent(location.target) + try touch(target, directory: location.directory) + if location.directory { try touch(target.appendingPathComponent("data")) } + targets.insert(target.path) + + // 名字相近的应用、其他应用和共享元数据都不能随目标应用一起选中。 + let neighbors = ["com.example.foobar.plist", "com.other.app.plist", "metadata.plist"] + .map { sharedDirectory.appendingPathComponent($0) } + for neighbor in neighbors { try touch(neighbor) } + preserved += [sharedDirectory] + neighbors + } + + let leftovers = AppUninstaller.leftovers(for: app, home: home.path) + #expect(Set(leftovers.map { $0.url.path }) == targets) + for location in locations { + let target = lib.appendingPathComponent(location.path).appendingPathComponent(location.target) + #expect(leftovers.first { $0.url == target }?.kind == location.kind) + } + for url in preserved { + #expect(!leftovers.contains { $0.url == url }) + #expect(FileManager.default.fileExists(atPath: url.path)) + } + } + @Test func refusesSystemAndOutsideApps() { let system = InstalledApp(url: URL(fileURLWithPath: "/System/Applications/Notes.app"), name: "Notes", bundleIdentifier: "com.apple.Notes", version: nil, teamIdentifier: nil) diff --git a/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerControllerTests.swift b/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerControllerTests.swift new file mode 100644 index 0000000..b30b652 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/OpenStatsUITests/UninstallerControllerTests.swift @@ -0,0 +1,82 @@ +import Cleaner +import Foundation +import Testing +@testable import OpenStatsUI + +@Suite struct UninstallResultSummaryTests { + private let app = InstalledApp(url: URL(fileURLWithPath: "/Applications/Example.app"), name: "Example", + bundleIdentifier: "com.example.app", version: nil, teamIdentifier: nil) + + private func item(_ path: String, kind: AppLeftover.Kind, size: UInt64, review: Bool = false) -> AppLeftover { + AppLeftover(url: URL(fileURLWithPath: path), kind: kind, size: size, requiresReview: review) + } + + @MainActor + @Test func sharedAndNameOnlyCandidatesStartUnselected() { + let body = item(app.url.path, kind: .application, size: 1_000) + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let shared = item("/Users/test/Library/Group Containers/team.shared", kind: .containers, size: 300, review: true) + let nameOnly = item("/Users/test/Library/Application Support/Example", kind: .support, size: 400, review: true) + + #expect(UninstallerController.initialSelection(for: [body, cache, shared, nameOnly]) == [body.id, cache.id]) + } + + @Test func movingOnlyResidualsDoesNotCountAsUninstall() { + let body = item(app.url.path, kind: .application, size: 1_000) + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let summary = UninstallResultSummary(app: app, requested: [body, cache], movedSources: [cache.url]) + + #expect(summary.applicationWasRequested) + #expect(!summary.applicationMoved) + #expect(summary.successfulItems == [cache]) + #expect(summary.failedItems == [body]) + #expect(summary.movedCount == 1 && summary.leftoverCount == 1) + #expect(summary.freedBytes == 200) + #expect(summary.isPartial) + } + + @Test func partialUninstallCountsOnlySuccessfulFilesAndKeepsFailures() { + let body = item(app.url.path, kind: .application, size: 1_000) + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let preferences = item("/Users/test/Library/Preferences/com.example.app.plist", kind: .preferences, size: 50) + let summary = UninstallResultSummary(app: app, requested: [body, cache, preferences], movedSources: [body.url, preferences.url]) + + #expect(summary.applicationMoved) + #expect(summary.successfulItems == [body, preferences]) + #expect(summary.failedItems == [cache]) + #expect(summary.movedCount == 2 && summary.leftoverCount == 1) + #expect(summary.freedBytes == 1_050) + #expect(summary.isPartial) + } + + @Test func residualRetryDoesNotCountTheApplicationTwice() { + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let summary = UninstallResultSummary(app: app, requested: [cache], movedSources: [cache.url]) + + #expect(!summary.applicationWasRequested && !summary.applicationMoved) + #expect(summary.movedCount == 1 && summary.leftoverCount == 1) + #expect(summary.freedBytes == 200) + #expect(summary.failedItems.isEmpty && !summary.isPartial) + } + + @Test func unexpectedSourceURLsDoNotInflateTheResult() { + let body = item(app.url.path, kind: .application, size: 1_000) + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let unknown = URL(fileURLWithPath: "/Users/test/Library/Caches/other.app") + let summary = UninstallResultSummary(app: app, requested: [body, cache], movedSources: [cache.url, unknown]) + + #expect(summary.movedCount == 1) + #expect(summary.freedBytes == 200) + #expect(!summary.applicationMoved && summary.failedItems == [body]) + } + + @Test func noSuccessfulMoveLeavesEveryRequestedItemAvailableForRetry() { + let body = item(app.url.path, kind: .application, size: 1_000) + let cache = item("/Users/test/Library/Caches/com.example.app", kind: .caches, size: 200) + let summary = UninstallResultSummary(app: app, requested: [body, cache], movedSources: []) + + #expect(summary.successfulItems.isEmpty && summary.failedItems == [body, cache]) + #expect(summary.movedCount == 0 && summary.freedBytes == 0) + #expect(!summary.applicationMoved && summary.isPartial) + } +} diff --git a/README.md b/README.md index 3606a30..0e897d5 100644 --- a/README.md +++ b/README.md @@ -62,33 +62,49 @@ system language by default; switch it in Settings → General → Language. -Latest release **0.6.2** (2026-09-21) · **1** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.6.2** (2026-09-21) · **15** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
-
2026-09-21 · Unreleased · 1 added +2026-10-09 · Unreleased · 3 fixed -**Added** +**Fixed** -- 菜单栏多一种“双行居中”风格:两行各自居中。原来的“双行文字”是两行一起靠右对齐,标签比数值宽的时候(风扇 0、温度 54°),数值会被推到右边看着偏。居中之后数值正落在标签下面。设置 · 菜单栏里选整体风格,也可以只给某一项单独用。 +- 合并显示时菜单栏的小标签颜色过淡:图标中带有彩色网速圆点时,CPU、内存等标签会直接画成灰色,在深色菜单栏上不容易看清。标签和网速箭头现在与数值使用同一种前景色,仍通过字号区分标签与读数。跟进 [Issue #5](https://github.com/gentpan/OpenStats/issues/5)。 +- 应用卸载的残留查找增强:读取应用真实名称、内嵌辅助组件的包名和签名中的应用组,按容器元数据识别 UUID 命名的沙盒数据;最近文档、CrashReporter、Sparkle、SentryCrash 和厂商目录内的应用子项也会检查。补上 VS Code、Insiders、Arc、Chrome 的明确数据位置。共享容器、按名称找到的 Sentry 数据和可能共用的辅助组件数据会标明并默认不勾选,厂商共享父目录始终保留。扫描及回收前重查路径,跳过符号链接重定向。参考 Pearcleaner 的查找思路,跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 应用卸载有部分文件未能移到废纸篓时,只统计实际成功的项目与空间,保留失败项供重试;只有应用本体成功移除才清除程序坞图标和累计卸载次数。
-2026-09-21 · 0.6.2 · 2 fixed +2026-10-01 · Unreleased · 3 added · 2 changed -**Fixed** +**Added** + +- 菜单栏布局多一种“仅图标”:菜单栏只显示 OpenStats 标志,不画任何数值,点击弹出完整面板。原来要把所有显示项目都关掉才能只剩一个图标,而且显示的是通用的心电图符号;现在关掉所有项目时也显示 OpenStats 标志。仅图标模式下“整体风格”和“显示项目”暂不生效,已开启的项目会保留,切回其他布局后恢复。 +- “合并显示”和“仅图标”点开的是完整面板,指标、进程与常用工具集中在一页:顶部是健康评分与芯片、内存、系统、运行时间标签,下面两列卡片显示 CPU、GPU、内存、磁盘、网络、风扇(可直接切换自动 / 降温 / 强冷),再往下是电池、高占用进程,以及摄像头和麦克风是否正在使用、防休眠与清理入口、累计清理记录(已清理空间、已卸载应用、已优化次数)。顶部标签列出这台 Mac 上的全部项目,不再只列菜单栏里开启的。 +- 风扇从停转起步时显示“正在启动风扇…”。Apple 芯片的风扇固件从停转起步要先低速拖转约 5 秒,期间转速读数一直是 0,之前看起来像是点了降温没反应。 -- 账号登录后应用直接退出:在登录窗口里选完 GitHub、Google 或 Apple 账号,应用立即闪退,重新打开也没有登录状态。原因是系统在后台队列回调登录结果,而回调闭包带着主线程隔离,被运行时当场中止,令牌还没来得及换取和保存。现在回调改为不带隔离、收到后再回到主线程处理,并补了回归测试。 -- 英文界面下测速窗口的“开始”按钮没有翻译,仍显示中文。 +**Changed** + +- “合并为一个”改名为“合并显示”。 +- 仪表盘的芯片、内存、系统、运行时间、机型标签始终排成一行,放不下时依次省去机型、把运行时间缩成最大单位。
-2026-09-18 · 0.6.1 · 1 added +2026-09-24 · Unreleased · 2 added · 4 fixed **Added** -- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。解决 [Issue #2](https://github.com/gentpan/OpenStats/issues/2):合并显示时点击只能打开主窗口,看不到各项状态。 +- 网络测速可以选线路:开着 Surge、Clash 等 VPN 或代理时,窗口顶部出现“直连 / 经 Surge”切换。直连绑定物理网卡、绕开代理,测的是本机宽带;经代理测的是代理线路。下载、上传与延迟都按选的线路走,每张卡片标出这次走的是哪条。 +- 国内分省三网延迟改用电信、联通、移动的标志,不再用“电 / 联 / 移”字块。 + +**Fixed** + +- 开着 VPN 时网络测速的延迟全是假的(零点几到十几毫秒):TCP 建连被本机代理当场应答,测到的只是本机到代理这一段,与节点远近无关。现在直连时绕开代理计时;经代理时改用同一条连接上的 HTTP 往返,每次都要等目标真的回应。国内三网节点只允许测建连,始终直连测,VPN 不让绕开时直接提示,不再给出假数字。远距离线路偶尔丢握手包,国内节点多测一次、超时放宽到 4 秒。 +- 本机宽带的延迟扣掉 Cloudflare 响应头里报的服务器处理时间,与 Cloudflare 官方测速的算法一致。 +- 测速后顶栏出现“本次用掉”时,标题“网络测速”被挤成竖排。 +- 绕开代理解析域名时,Cloudflare DoH 连不上就换 Google、阿里的 DoH,出口与分流窗口的原始出口归属地也因此更容易查到。
@@ -217,9 +233,10 @@ unclean exit restores them at the next boot. - **Uninstall apps**: lists third-party apps in Applications with their size. Pick one or drop an app in, and OpenStats finds what it left in your Library — app data, caches, preferences, sandbox containers, saved window state, logs, web data and - login items — each of which you can untick. Confirming moves everything, app included, to the Trash (restorable) and - removes its Dock icon. Matching is by bundle identifier and same-named folders only; built-in and Apple apps are not - listed, and running apps must be quit first. + login items — each of which you can untick. It also checks bundled helper identifiers, UUID container metadata, + signed application groups and known app-specific data paths. Potentially shared data is left unchecked. + Confirming moves selected items to the Trash (restorable); the Dock icon is removed only after the app is successfully + moved. Built-in and Apple apps are not listed, and running apps must be quit first. - **Startup items**: lists LaunchAgents for the current user and all users, plus system LaunchDaemons, with the owning app, executable, whether it runs at login or is kept alive, and whether it is running, loaded or disabled. Your own items can be disabled or re-enabled in place (recorded in launchd's disabled list and unloaded, no files deleted); the rest are diff --git a/README.zh-CN.md b/README.zh-CN.md index ae64aa0..35dcde4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,33 +57,49 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.6.2**(2026-09-21) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.6.2**(2026-09-21) · 开发中 **15** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-21 · 未发布 · 新增 1 +2026-10-09 · 未发布 · 修复 3 -**新增** +**修复** -- 菜单栏多一种“双行居中”风格:两行各自居中。原来的“双行文字”是两行一起靠右对齐,标签比数值宽的时候(风扇 0、温度 54°),数值会被推到右边看着偏。居中之后数值正落在标签下面。设置 · 菜单栏里选整体风格,也可以只给某一项单独用。 +- 合并显示时菜单栏的小标签颜色过淡:图标中带有彩色网速圆点时,CPU、内存等标签会直接画成灰色,在深色菜单栏上不容易看清。标签和网速箭头现在与数值使用同一种前景色,仍通过字号区分标签与读数。跟进 [Issue #5](https://github.com/gentpan/OpenStats/issues/5)。 +- 应用卸载的残留查找增强:读取应用真实名称、内嵌辅助组件的包名和签名中的应用组,按容器元数据识别 UUID 命名的沙盒数据;最近文档、CrashReporter、Sparkle、SentryCrash 和厂商目录内的应用子项也会检查。补上 VS Code、Insiders、Arc、Chrome 的明确数据位置。共享容器、按名称找到的 Sentry 数据和可能共用的辅助组件数据会标明并默认不勾选,厂商共享父目录始终保留。扫描及回收前重查路径,跳过符号链接重定向。参考 Pearcleaner 的查找思路,跟进 [Issue #4](https://github.com/gentpan/OpenStats/issues/4)。 +- 应用卸载有部分文件未能移到废纸篓时,只统计实际成功的项目与空间,保留失败项供重试;只有应用本体成功移除才清除程序坞图标和累计卸载次数。
-2026-09-21 · 0.6.2 · 修复 2 +2026-10-01 · 未发布 · 新增 3 · 调整 2 -**修复** +**新增** + +- 菜单栏布局多一种“仅图标”:菜单栏只显示 OpenStats 标志,不画任何数值,点击弹出完整面板。原来要把所有显示项目都关掉才能只剩一个图标,而且显示的是通用的心电图符号;现在关掉所有项目时也显示 OpenStats 标志。仅图标模式下“整体风格”和“显示项目”暂不生效,已开启的项目会保留,切回其他布局后恢复。 +- “合并显示”和“仅图标”点开的是完整面板,指标、进程与常用工具集中在一页:顶部是健康评分与芯片、内存、系统、运行时间标签,下面两列卡片显示 CPU、GPU、内存、磁盘、网络、风扇(可直接切换自动 / 降温 / 强冷),再往下是电池、高占用进程,以及摄像头和麦克风是否正在使用、防休眠与清理入口、累计清理记录(已清理空间、已卸载应用、已优化次数)。顶部标签列出这台 Mac 上的全部项目,不再只列菜单栏里开启的。 +- 风扇从停转起步时显示“正在启动风扇…”。Apple 芯片的风扇固件从停转起步要先低速拖转约 5 秒,期间转速读数一直是 0,之前看起来像是点了降温没反应。 -- 账号登录后应用直接退出:在登录窗口里选完 GitHub、Google 或 Apple 账号,应用立即闪退,重新打开也没有登录状态。原因是系统在后台队列回调登录结果,而回调闭包带着主线程隔离,被运行时当场中止,令牌还没来得及换取和保存。现在回调改为不带隔离、收到后再回到主线程处理,并补了回归测试。 -- 英文界面下测速窗口的“开始”按钮没有翻译,仍显示中文。 +**调整** + +- “合并为一个”改名为“合并显示”。 +- 仪表盘的芯片、内存、系统、运行时间、机型标签始终排成一行,放不下时依次省去机型、把运行时间缩成最大单位。
-2026-09-18 · 0.6.1 · 新增 1 +2026-09-24 · 未发布 · 新增 2 · 修复 4 **新增** -- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。解决 [Issue #2](https://github.com/gentpan/OpenStats/issues/2):合并显示时点击只能打开主窗口,看不到各项状态。 +- 网络测速可以选线路:开着 Surge、Clash 等 VPN 或代理时,窗口顶部出现“直连 / 经 Surge”切换。直连绑定物理网卡、绕开代理,测的是本机宽带;经代理测的是代理线路。下载、上传与延迟都按选的线路走,每张卡片标出这次走的是哪条。 +- 国内分省三网延迟改用电信、联通、移动的标志,不再用“电 / 联 / 移”字块。 + +**修复** + +- 开着 VPN 时网络测速的延迟全是假的(零点几到十几毫秒):TCP 建连被本机代理当场应答,测到的只是本机到代理这一段,与节点远近无关。现在直连时绕开代理计时;经代理时改用同一条连接上的 HTTP 往返,每次都要等目标真的回应。国内三网节点只允许测建连,始终直连测,VPN 不让绕开时直接提示,不再给出假数字。远距离线路偶尔丢握手包,国内节点多测一次、超时放宽到 4 秒。 +- 本机宽带的延迟扣掉 Cloudflare 响应头里报的服务器处理时间,与 Cloudflare 官方测速的算法一致。 +- 测速后顶栏出现“本次用掉”时,标题“网络测速”被挤成竖排。 +- 绕开代理解析域名时,Cloudflare DoH 连不上就换 Google、阿里的 DoH,出口与分流窗口的原始出口归属地也因此更容易查到。
@@ -191,8 +207,9 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 ## 卸载应用与启动项 - **卸载应用**:列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来,找出它留在资源库里的应用数据、缓存、 - 偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回), - 并从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行的应用会提示先退出。 + 偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项;同时检查内嵌辅助组件、UUID 容器记录、签名中的应用组和已知应用数据目录。 + 可能共用的数据默认不勾选,可逐项选择;确认后所选项目移到废纸篓(可放回),应用本体成功移除后才清除程序坞图标。 + 系统自带和 Apple 的应用不列出,正在运行的应用会提示先退出。 - **启动项**:列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中、已加载、已停用状态。当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载, 不删除文件),其余只读,并提供系统登录项设置的入口。 diff --git a/Scripts/install_local.sh b/Scripts/install_local.sh index cf9de6f..bbc4de9 100755 --- a/Scripts/install_local.sh +++ b/Scripts/install_local.sh @@ -47,4 +47,4 @@ fi open "$TARGET" version="$(defaults read "$TARGET/Contents/Info" CFBundleShortVersionString) ($(defaults read "$TARGET/Contents/Info" CFBundleVersion))" -echo "✅ 已安装并启动 OpenStats $version:$TARGET" +echo "✅ 已安装并启动 OpenStats ${version}:$TARGET" diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 206b7ce..5913fac 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -1,5 +1,10 @@ # 第三方声明 +## Pearcleaner + +应用卸载器的残留目录覆盖、容器元数据识别和应用组查找思路参考 +[alienator88/Pearcleaner](https://github.com/alienator88/Pearcleaner)。OpenStats 的扫描、安全校验及回收逻辑由本项目独立实现。 + ## flag-icons `Assets/flags-svg/` 中的国旗 SVG(应用内为 `Scripts/render_flags.sh` 渲染的 PNG)来自