Tinycast 卸载应用功能解析:只进废纸篓、绝不删除的 macOS 残留清理设计
Tinycast 卸载应用功能解析只进废纸篓、绝不删除的 macOS 残留清理设计【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycastTinycast 的 Uninstall Application 是一个“彻底卸载但仍可反悔”的 macOS 应用清理功能它从启动器 Actions 菜单⌘K →Uninstall Application进入针对任意.application条目打开一个作用域限定在该应用上的.uninstall调色板子屏幕把应用本体与其留在系统里的缓存、偏好设置、容器、Saved State、Launch Agent 等一并找出并移入废纸篓。本文以 docs/features/uninstall.md 为骨架结合Tinycast/Features/Uninstall/下的全部源码与 Tests/uninstall-test.swift讲解它的不可变约束、纯/非纯分层架构、四类归属匹配规则、搜索根表、锁定判定、流式尺寸计算与屏幕交互读完即可理解该功能“安全”二字的全部来源。功能入口与使用方式该功能没有任何启动器快捷键launcher keybinding它只作为一个菜单动作存在在启动器中对任意.application条目打开 Actions 菜单⌘K选择Uninstall Application。入口处有一个类型守卫非应用条目app.kind ! .application会被直接忽略见 UninstallCoordinator.swift。进入后调色板切换到.uninstall子屏幕会话UninstallSession随即启动发现扫描并收集索引中所有其他已安装应用的名称与 Bundle ID 传入扫描器——这是后续“名字防冲突”与“兄弟产品隔离”规则的数据基础let others appIndex.apps.filter { $0.kind .application $0.id ! app.id } session.begin( app: app, otherAppNames: others.map(\.name), otherBundleIDs: others.compactMap(\.bundleID), isRunning: runningApps.isRunning(app))不可变约束安全性的第一道契约原文档开篇列出的几条不变量是整个功能的宪法代码层面也处处印证只移入废纸篓永不删除。FileManager.trashItem是该功能唯一的删除调用出现在 UninstallRunner.swiftremoveItem在此功能中绝不出现。正因为误判的代价只是“从废纸篓拖回来”而不是数据丢失基于显示名display name的弱匹配才被容忍同理任何“永久删除”选项都必须在同一提交内砍掉名字匹配。决策的一半保持 Foundation-only 且纯净以便编译进uninstall-test扫描器交给规则层的只有目录的子条目名字child names永远不是 URL交给分类器的是一个PathFacts结构体。所有环境事实home 路径、是否拥有 Full Disk Access、父目录是否可写、是否 sticky 等都被注入规则层自身不碰文件系统。UninstallScanner探测 Full Disk Access 但从不请求它。探测是静默且无弹窗的见下文“锁定”一节。tccRelativePrefixes是实测出来的不是猜出来的对某个位置先创建再移走一个一次性目录来探测——能列举不等于能移动。~/Library/Containers能正常枚举却拒绝移动而~/Library/Application Scripts允许移动。被锁定的候选项永远无法进入勾选集合该不变量位于UninstallSelection的唯一一次集合交集里而不是在视图层。发现discover从不等待尺寸计算sizingUninstallScanner.discover先发布列表目录遍历随后流式跟进。任何让屏幕等待尺寸的东西spinner、↵ 上的完整性门槛都会把慢的一半重新挡在快的一半前面。Tinycast 拒绝卸载自己并且是拿运行中的身份Bundle.main.bundleIdentifier与Bundle.main.bundleURL来比对因此 Dev 渠道也会拒绝卸载自身。分层架构纯的做决定非纯的碰磁盘与WindowManagement相同的分层一半纯逻辑负责决策一半非纯逻辑负责磁盘操作。对应文件与职责如下文件角色Model/UninstallTarget.swiftUninstallTarget、UninstallEvidence、UninstallIdentity以及全部护栏在UninstallIdentity.make中集中应用Model/UninstallSearchRoot.swift根表去哪找、该处哪些匹配风格合法与binDirectoriesModel/UninstallRules.swift匹配逻辑外加isAcceptableCandidate兜底校验Model/UninstallProtection.swiftPathFacts→UninstallProtection的分类Model/UninstallPlan.swiftUninstallCandidate、UninstallPlan、UninstallSelectionService/UninstallScanner.swift非纯。discovercontentsOfDirectory、lstat、FDA 探测与measure目录遍历Service/UninstallRunner.swift非纯。trashItem仅此而已Service/UninstallSession.swiftMainActor生命周期屏幕背后的状态机UI/UninstallScreen.swift、UI/UninstallView.swift调色板屏幕、列表、行与动作菜单UI/UninstallCoordinator.swift动作面——确认对话框在这里不在 runner 里前五份文件独立编译进 Tests/uninstall-test.swift因此它们保持 Foundation-only并把每个环境事实都作为参数接收。扫描器交给规则层的是子条目名字而非 URL这让“纯层不做文件系统访问”从一句承诺变成了结构性事实。归属匹配四种证据三类规则加一种机制匹配针对UninstallRules.matchableForms展开匹配前会剥离.plist、.savedState、.binarycookies、.lockfile等后缀——而且是反复剥离因此….plist.lockfile也能逐级归约。strippedExtensions完整列表见 UninstallRules.swiftmatchableForms的实现保证剥离只会增加比较形态永远不会减少原始名字。bundleID精确匹配或命名空间子级边界检查是整个规则承重墙纯前缀匹配会让com.apple.SafariTechnologyPreview命中com.apple.Safari从而误删另一个产品的整个 profile。要求下一个字符是分隔符.或-意味着匹配只能是命名空间的后代——com.apple.iBooksX.CacheDelete匹配com.apple.iBooksX而com.apple.iBooksXtra不匹配。-也算分隔符因为它是厂商命名发布变体的惯用手法dev.zed.Zed-Preview.plist属于 Zed除非 Zed Preview 本身也安装了——此时由下面的兄弟规则把归属还给它。核心实现在 UninstallRules.swift 的owns(_:id:allowingPrefix:)。这条规则之上还有两道护栏厂商命名空间不做前缀匹配。两段式 ID 如com.adobe命名的是厂商而非产品因此allowsBundleIDPrefixMatch要求至少三段UninstallIdentity.make中以split(separator: .).count 3计算见 UninstallTarget.swift。com.adobe依然能精确匹配自身。已安装的兄弟产品拥有自己的产物。如果任何其他已安装应用的 Bundle ID 对同一组件是更长的匹配则该产物归它所有。否则卸载com.tinycast.app会连带清掉com.tinycast.app.beta与….dev——它们只是共享命名空间的不同产品这正是“渠道隔离”不变量的反向表述。groupContainer剥离 group. 与 Team ID 后再套 Bundle ID 规则先剥掉开头的group.和/或一个 10 字符 Team ID仅限大写字母与数字正是这个严格形状挡住了把something.com.foo.Bar误读成容器的可能然后把剩余部分套用 Bundle ID 规则。见groupContainerBase与isTeamIDUninstallRules.swift。displayName唯一被设防的弱证据这是最弱的一种匹配也是唯一加了大量护栏的。要求精确的、折叠大小写与变音符号的相等UninstallIdentity.folded去除首尾空白 .caseInsensitive.diacriticInsensitive折叠绝不使用前缀或子串因此 “Books” 与 “Books Reader” 在任何方向上都不能互相认领文件夹。在此之上名称必须 ≥ 3 个折叠后字符minimumNameLength 3不得是标准 Library 子目录名reservedNames集合preferences、caches、containers、keychains、launchagents等共 23 项见 UninstallTarget.swift不得与其他已安装应用共享——第二个名叫 “Mail” 的应用正是让~/Library/Application Support/Mail无法归属的原因。阈值是 3 而非 4Zed、IINA、Xee 都用三字符名字命名自己的文件夹安全性来自上述三道护栏而不是长度。displayName只在人类命名的根Application Support、Caches、Logs与插件井中启用其余位置子条目按构造就是 Bundle ID名字匹配在那里必然误报规则矩阵见 UninstallSearchRoot.swift。一个.displayName匹配默认是勾选的但行上会标注 “matched by name”让弱证据在确认前可见。它配得上这个待遇因为它精确、受限、从不认领其他已安装应用应答的名字——而且该功能只移入废纸篓多余的一行代价只是拖回来。binSymlink按链接目标归属绝不按名字在/usr/local/bin、/opt/homebrew/bin、~/.local/bin或~/bin中如果一个符号链接解析后落在应用 bundle 内部就归属该应用。归属依据是链接目标而非名字/usr/local/bin里的zed属于 Zed是因为它指向Zed.app/Contents/MacOS/cli而不是因为它叫 zed。binDirectories见 UninstallSearchRoot.swift解析逻辑含相对链接按自身目录解析而非 cwd在 UninstallScanner.swift。在标配 Mac 上/usr/local/bin属 root因此该行渲染为锁定Homebrew 把它改成用户可写后即可移除分类器从PathFacts直接得出这一点无需特判。拒绝卸载自身UninstallIdentity.make在两种情况下返回nil整个卸载被拒绝目标 Bundle ID 与运行中自身相同折叠比较或目标 bundle URL 与运行中自身的 bundle URL 标准化后相同——后一条正是 Dev 渠道也能拒绝卸载自身的原因UninstallTarget.swift。搜索根表home 目录本身不是根home 目录本身不是根这是一个决策而非疏忽。所有根都在~/Library或/Library之下~下任何东西都不会成为候选项。认领~/name只能靠名字匹配而~是唯一一个“错配会毁掉用户自己的工作而不是应用缓存”的地方——VS Code 的CFBundleName字面就是Code而~/Code在无数机器上是一个源码树。缩窄到点文件夹只是转移问题一个叫 “Local” 的应用会认领~/.local而为此做筛查需要一个无事实来源、手工维护的阻止列表——这与CalcCurrency.contested拒绝俚语是同一推理。按 62 个已安装应用实测整个根只值一个 115 kB 的文件夹收益微乎其微却承载了设计里唯一灾难性的失败模式。Raycast 确实会列出~/OrbStackTinycast 刻意不这么做。处处只看直接子级immediate children。Preferences/ByHost是独立根而不是把Preferences提升到深度 2——后者会钻进每个无关应用的子文件夹。除了~/Library与/Library的主干外根表覆盖插件井QuickLook、Services、PreferencePanes、Screen Savers、Internet Plug-Ins、Spotlight、Automator、Input Methods、Audio/Plug-Ins/{HAL,Components}——这些位置的子条目是以安装它们的产品命名的包装器这正是strippedExtensions还要剥掉.qlgenerator、.saver、.prefPane等后缀的原因。.app刻意不在剥离列表中。刻意排除在外的还有/private/var/db/receiptsroot 所有删除 receipt 会破坏安装器对系统的视图、~/Library/Keychains、/Library/Extensions以及一切用户文档位置。/usr/local只能通过binDirectories触达且只处理解析进 bundle 的符号链接——绝不按名字绝不递归。UninstallRules.isAcceptableCandidate是对任何匹配结果的兜底UninstallRules.swift必须是其根的立即子级、绝不是 home 或/、无相对路径分量、且与 app bundle单独输出的那行无重叠。锁定机制建议性分类而非安全边界UninstallProtection是建议性的不是安全边界——TCC 在系统调用层面求值因此它可能双向出错。它存在的意义是用诚实的理由把行置灰、跳过明显注定失败的尝试UninstallRunner仍会逐项上报失败。测试基架断言了如下优先级UninstallProtection.swift 的classify!exists→.missing从计划中剔除SF_RESTRICTED/SF_IMMUTABLE或只读卷 →.systemProtected——这正是/System/Applications/Books.app被锁定的原因而且它来自事实而非硬编码的/System前缀UF_IMMUTABLE→.userLocked独立分支用户可在“显示简介”中自行解除TCC 门控路径且无 Full Disk Access →.needsFullDiskAccess父目录不可写 →.parentNotWritablesticky 父目录且非当前用户所有 →.notOwned其余 →.removable第 5、6 步构成了完整的“所有权故事”且顺序是刻意的。删除一个目录条目由外层目录的写权限决定而非条目所有者可写文件夹里一个 root 所有的文件你有权移除。早先的版本先检查所有权结果把能正常移入废纸篓的行也置灰了。所有权只决定一种情况——sticky 父目录S_ISVTX即/tmp规则那里只有所有者能 unlink。这也解释了为什么/usr/local/bin/code保持锁定而/opt/homebrew/bin/orb不锁定/usr/local/bin是drwxr-xr-x root:wheel移动被直接拒绝实测而非推断而 Homebrew 把/opt/homebrew/bin留成组可写。Raycast 把/usr/local/bin那一行作为勾选提供那次移除没有管理员密码不可能成功而本功能从不索取管理员密码。被锁定的候选项永远进不了勾选集合。该不变量在UninstallSelection里每一次变更都汇入与plan.removableIDs的同一交集UninstallPlan.swift因此重新扫描会自动把新近锁定的行踢出勾选。TCC 列表是实测的不是假设的。对每个候选位置先创建再移走一个一次性目录的探测表明~/Library/Containers、~/Library/Group Containers、~/Library/Cookies拒绝移动而~/Library/Application Scripts与~/Library/Autosave Information允许——这正是 Books 的五条Application Scripts行可勾选、旁边五条Containers行被锁定的原因。注意列举目录不是测试两个容器根都能正常枚举却拒绝移入废纸篓。tccRelativePrefixes完整清单见 UninstallProtection.swift新增条目前必须重新实测。Full Disk Access 被探测从不被请求。探测方式是打开~/Library/Application Support/com.apple.TCC/TCC.db——TCC 对那次读取静默拒绝、无任何弹窗这使它在“本功能不索取任何权限”的规则下可用。探测每次扫描只跑一次而非每个候选项一次而且只会少报单文件夹授权会读成“无权限”后果不过是某行保持锁定。实现见 UninstallScanner.swift。符号链接永不被跟随一律lstat尺寸计算时不下降。一个符号链接候选被当作链接本身来判定和移走。尺寸计算发现与测量相差三个数量级扫描的两半相差三个数量级。发现discover是 36 个并行浅层contentsOfDirectory列举加每个命中一次lstat——毫秒级。测量sizing是对每个目录候选做一次递归枚举器遍历而/Applications/Xcode.app一个就有约 9.5 GB。所以这是两次调用而非一次UninstallSession在discover返回的瞬间发布.ready然后measure把每次遍历流式送到MainActor回调后者只写它刚测完的那一行经由UninstallPlan.setSize(_:forPath:)UninstallPlan.swift。完整流程见 UninstallSession.swift。UninstallCandidate.size是可选值nil就是待定状态——因此没有needsWalk标志、没有尺寸侧表。待定行渲染一个空白尺寸槽位而非短横线或 spinner槽位位于Spacer与尾部图标之间落地尺寸向左扩展、不挪动任何东西。总计把待定按 0 计算并持续攀升。≥前缀专留给命中SizeBudget.maxEntries默认 250,000 条目见 UninstallScanner.swift的遍历绝不兼任“还在数”的语义。需要接受的一个后果在头一秒内确认声明的总量会小于实际移入废纸篓的量。选中的一切仍会被移走——只有确认文案里的数字偏小。尺寸是逻辑字节而非分配块遍历用.totalFileSizeKeyinspect用st_size。分配块与其他所有工具严重不一致Xcode 以 decmpfs 压缩发布其块数读作 4.19 GB而 Finder 与 Raycast 读作 9.45 GB一个 593 字节的 plist 占一个块读作 4 kB。块数也不是更安全的数字——decmpfs 载荷可能住在 xattr 里st_blocks不计数因此它只是下限。相关实现见 UninstallScanner.swift。measure的任务组刻意在nonisolated上下文中构建它是UninstallSession.scanTask的结构化子任务这保证cancel()能触达枚举器循环内的Task.checkCancellation()在此构建也消除了“30 个并发遍历跑在哪个执行器上”的疑问。一次遍历抛错就产出空值因此被取消的行保持待定而不会发布一个虚假的 0。屏幕与交互这是一个PaletteMode用例与 Clipboard、Calculator History 相同返回箭头、Escape、裸退格退出、方向键导航、菜单打开时输入冻结全部来自共享契约见 docs/features/palette.md。它不加入 Tab 循环。搜索框按名称或位置过滤候选项没有排序控件footer 前导角保留标准菜单圆圈。主确认胶囊使用Theme.Colors.destructive渲染。↵ 执行卸载⌘↵ 切换高亮行点击复选框切换双击行切换。⌘K 提供 Uninstall、Select/Unselect File、Copy Path、Show in Finder、Show Info in Finder。卸载没有启动器键位——它只是菜单动作。Copy Path 留在屏幕内为复制一个路径而丢失整次扫描是不划算的交易两个 Finder 动作把焦点交给 Finder 从而隐藏调色板。Show Info 没有 AppKit 路由通过 Apple events 驱动 Finder首次使用会触发系统“自动化”权限弹窗UninstallCoordinator.swift。UninstallCoordinator.performUninstall()是唯一的漏斗因此 ↵ 和菜单行都无法跳过确认UninstallCoordinator.swift。它先退出正在运行的应用然后移入废纸篓并且只有 bundle 本体确实被移走时才清理应用的热键、收藏、可见性与排名——只清理残留物时应用仍处于已安装状态。bundle 最后移走两种顺序都可能留下部分状态但 bundle 还在时用户可以重跑卸载来重试bundle 一旦消失通向此屏幕的启动器条目也随之消失。成功显示消息胶囊部分失败会点名哪些留了下来presentUninstallReportUninstallCoordinator.swift。测试与验证./Scripts/run-tests.sh uninstall-test测试无文件系统、无临时目录——每个输入都是String或PathFacts。testStreamedSizesTests/uninstall-test.swift覆盖遍历无法覆盖的一半落地尺寸从不重排计划、从不干扰邻居的待定状态、从不重新推导勾选集合。除各规则断言外测试以一次跨身份扫描收尾对一组真实应用 × 每个根 × 每种产物形态任何应用的产物都不得被归属到另一个应用——这是唯一能整体捕获匹配器回归而非单条规则回归的测试。小结从架构看Tinycast 的卸载功能把“彻底清理”与“绝对安全”统一在几条结构性约束里纯逻辑层只吃名字与PathFacts、磁盘操作只有trashItem、锁定的候选永远进不了勾选集、发现永不等待尺寸、TCC 事实全部实测。想深入源码建议按 Model/UninstallTarget.swift → Model/UninstallSearchRoot.swift → Model/UninstallRules.swift → Service/UninstallScanner.swift 的顺序阅读最后用 Tests/uninstall-test.swift 验证你对各条规则的理解。【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考