macOS 的「默认打开应用」管理一直很别扭:
- 一次通常只能改一个扩展名,改十种格式要点十遍 Finder。
- 系统没有统一的总览面板,你看不到「到底哪些扩展名被哪个 app 接管」。
- 想找出「某个 app 声明自己能打开的所有格式」几乎不可能——系统只按扩展名反过来告诉你。
- 某些 app 会注册一大堆宽泛关联,留下混乱状态,且很难清理。
OpenWithGUI 把 Launch Services 里的扩展名 ↔ 应用关联一次性读出来,放进一张可搜索、可筛选、可多选的表里。 你既能逐个改,也能按应用筛选出一整批格式后一次性批量改。
它只在你本机运行、不联网、不上传任何数据;读取与修改都通过系统公开的 Launch Services API 完成,不碰任何私有存储。
本项目是 Run1997/OpenWith-GUI 的 fork(OpenWithGUI2),在上游基础上新增了「按 Candidate App 筛选」。差异详见 🔀 vs 上游。
- 📋 表格总览:一张表列出所有扩展名及其当前默认应用、Bundle ID 与状态。
- 🔍 只按扩展名搜索:搜索框仅匹配扩展名,结果稳定可预期,不会因应用名干扰。
- 🗂️ 按默认应用筛选:快速查看「某个 app 现在接管了哪些扩展名」。
- 🧩 按 Candidate App 筛选(本 fork 新增):查看「某个 app 声明自己能打开哪些扩展名」,无论它当前是不是默认应用。
- 🏷️ 按状态筛选:无默认应用 / 默认应用已失效 / 候选唯一 / 候选过多 / 用户手动添加 / 写入待确认 / 写入失败。
- ✅ 多选后批量改:勾选一批扩展名,一次性把默认应用改成同一个。
- 🧭 单条精修:修改单个扩展名时,候选应用会分组展示(Candidate Apps / Other Apps)。
- ➕ 自定义扩展名:手动添加扩展名并指定应用,也可删除自己添加的扩展名。
- 🔄 写入后自校验:改完立即回读系统状态,未生效则标记为「待确认」而非假装成功。
把下面这段提示词直接发给你的本地 AI Agent(Claude Code / Codex / OpenCode …):
请帮我从源码构建并运行 OpenWithGUI(GitHub: https://github.com/RayMorTwinkle/OpenWithGUI2)。
背景:这是一个 macOS「打开方式」统一管理器(Swift / SwiftUI),可批量查看与修改文件扩展名的默认打开应用。
环境要求:macOS 14+,Swift 6.0+(随 Xcode 16 或 Command Line Tools 提供)。
步骤:
1. 克隆:git clone https://github.com/RayMorTwinkle/OpenWithGUI2.git && cd OpenWithGUI2
2. 跑测试:swift test
3. 构建:swift build -c release
4. 打包成 App:./scripts/package-macos-app.sh --release
(如需 DMG:./scripts/package-macos-dmg.sh --release)
5. 产物在 dist/OpenWithGUI.app,可 open dist/OpenWithGUI.app 启动。
6. 向用户确认构建成功,并说明:工具栏可「按扩展名搜索 / 按默认应用筛选 / 按 Candidate App 筛选 / 按状态筛选」。git clone https://github.com/RayMorTwinkle/OpenWithGUI2.git
cd OpenWithGUI2
swift test # 运行单元测试
./scripts/package-macos-app.sh --release # 构建并打包 dist/OpenWithGUI.app
./scripts/package-macos-dmg.sh --release # 可选:再打一个 dist/OpenWithGUI.dmg
open dist/OpenWithGUI.app # 启动环境要求:macOS 14(Sonoma)及以上;从源码构建需 Swift 6.0+(
swift-tools-version: 6.0)。 若使用打包好的.app/.dmg,则不需要安装 Swift 或 Xcode。
下载 DMG → 拖入 Applications。若被「未认证开发者」拦截:右键 OpenWithGUI.app → 打开 → 再次确认;或在 系统设置 → 隐私与安全性 中放行。
| 控件 | 作用 |
|---|---|
Search extensions |
仅按扩展名过滤当前表格 |
Filter by App / Clear App Filter |
按当前默认应用筛选 |
Filter by Candidate App / Clear Candidate Filter |
按候选应用筛选(本 fork 新增) |
Filter by Status / Clear Status Filter |
按状态标记筛选 |
Refresh |
重新扫描 /Applications 等目录并回读系统状态 |
Add Extension |
手动添加扩展名并指定应用 |
1. 点「Filter by Candidate App」→ 选中目标 app(例如 Bandizip 365)
→ 表格只剩它声明能打开的扩展名(.7z / .aac / .zip …)
2. 在表中多选需要的扩展名(⌘ / ⇧ 点选)
3. 右侧 Batch Update 面板 →「Choose Target App」→ 选择要接管的应用
4. 完成后底部/侧栏显示 “N succeeded, M failed”;
未即时生效的行会标为 Pending Verification,可点 Refresh 复查
| 标记 | 含义 |
|---|---|
| No Issues | 无特殊状态 |
| No Default App | 当前没有默认应用 |
| Missing Default App | 记录了默认应用,但该应用已不存在 |
| Single Candidate | 只有一个候选应用 |
| Many Candidates | 候选应用 ≥ 5 个 |
| User Added | 该扩展名由用户手动添加 |
| Pending Verification | 修改已提交,但回读尚未确认 |
| Write Failed | 修改失败 |
GUI 层只依赖一个 @Observable ViewModel;ViewModel 通过 AssociationRepository / AssociationWriter 两个协议对接系统层,所有系统访问都收敛在 Services 里,便于测试注入。
flowchart TB
subgraph UI["SwiftUI 视图层"]
direction LR
RV["RootView<br/>工具栏 / sheet 编排"]
TV["AssociationTableView"]
SB["AssociationDetailSidebar<br/>BatchActionSidebar"]
PS["AppPickerSheet<br/>AddExtensionSheet"]
end
subgraph VM["ViewModel 层"]
ALVM["AssociationListViewModel<br/>@MainActor @Observable"]
end
subgraph SVC["Services 层"]
REPO["SystemAssociationRepository"]
WRITER["SystemAssociationWriter"]
SCAN["AppCatalogScanner"]
LSC["LaunchServicesClient"]
USTORE["UserAddedExtensionStore"]
PARSER["DocumentTypeParser"]
end
subgraph OS["macOS 系统接口"]
LSAPI["Launch Services<br/>LSCopyDefaultApplicationURLForContentType<br/>LSSetDefaultRoleHandlerForContentType"]
UTI["UniformTypeIdentifiers<br/>UTType(filenameExtension:)"]
UD["UserDefaults<br/>key: userAddedExtensions"]
APPS["/Applications · /System/Applications · ~/Applications"]
end
RV --> ALVM
TV --> ALVM
SB --> ALVM
PS --> ALVM
ALVM --> REPO
ALVM --> WRITER
REPO --> SCAN
REPO --> LSC
REPO --> USTORE
SCAN --> PARSER
SCAN --> APPS
LSC --> LSAPI
REPO --> UTI
USTORE --> UD
visibleRows 是一个有顺序的纯计算属性:搜索 → 默认应用 → Candidate App → 状态 → 排序。候选筛选被插入在默认应用筛选之后、状态筛选之前。
flowchart LR
ROWS["rows<br/>(全量)"] --> S["searchText<br/>按扩展名 contains"]
S --> D{"selectedDefaultApp<br/>BundleIdentifier?"}
D -->|有| D1["currentDefaultApp<br/>== 该 bundleID"]
D -->|无| K{"selectedCandidateApp<br/>BundleIdentifier?"}
D1 --> K
K -->|有| K1["candidateApps 中存在<br/>该 bundleID"]
K -->|无| T{"selectedStatusFilter?"}
K1 --> T
T -->|有| T1["匹配状态标记<br/>noIssues = 无标记"]
T -->|无| O["排序 sort"]
T1 --> O
O --> V["visibleRows"]
首次进入或点 Refresh 时,并行拉取「扩展名关联行」与「应用候选列表」;扫描目录时读取每个 .app 的 Info.plist 文档类型来推导候选应用。
sequenceDiagram
autonumber
participant RV as RootView
participant VM as AssociationListViewModel
participant R as SystemAssociationRepository
participant S as AppCatalogScanner
participant P as DocumentTypeParser
participant LS as LaunchServicesClient
participant UT as UTType
RV->>VM: load()
par 并行
VM->>R: loadRows()
R->>S: scan()
S->>S: 枚举 /Applications · /System/Applications · ~/Applications 下的 *.app
S->>P: extensions(from: Info.plist)
P->>P: 读取 CFBundleDocumentTypes → CFBundleTypeExtensions
P->>UT: UTType(identifier).preferredFilenameExtension (LSItemContentTypes)
S-->>R: InstalledAppCatalog(allApps, candidateAppsByExtension)
loop 每个扩展名
R->>UT: UTType(filenameExtension:)
R->>LS: defaultAppURL(for: typeIdentifier)
LS-->>R: 当前默认应用 URL
end
R-->>VM: [ExtensionAssociationRow]
and
VM->>R: loadAppChoices()
R-->>VM: [AppDescriptor]
end
VM-->>RV: rows = …, phase = .loaded
RV->>VM: selectFirstRowIfNeeded()
写入走 AssociationWriter:逐个扩展名调用 LSSetDefaultRoleHandlerForContentType,随后回读系统状态做自校验。
sequenceDiagram
autonumber
participant U as 用户
participant RV as RootView
participant VM as AssociationListViewModel
participant W as SystemAssociationWriter
participant LS as LaunchServicesClient
participant R as SystemAssociationRepository
U->>RV: 多选扩展名 → Choose Target App → 选中 app
RV->>VM: apply(app:to: sortedSelection)
VM->>W: setDefaultApp(app, for: extensions)
loop 每个扩展名
W->>LS: setDefaultHandler(bundleIdentifier, for: typeIdentifier)
LS-->>W: noErr / 抛错
end
W-->>VM: [AssociationWriteResult]
VM->>R: refreshRows(for: extensions)
R-->>VM: 回读后的行
VM->>VM: merge() 判定每行结果
VM-->>RV: lastBatchSummary = "N succeeded, M failed"
回读后逐行比对,只有系统状态确实等于目标应用才算成功,否则降级为「待确认」,避免给出虚假的成功反馈。
flowchart TD
W["writeResult"] --> E{"errorMessage?"}
E -->|有| FAIL["statusFlags += writeFailed<br/>AssociationOperationResult.failed"]
E -->|无| C{"refreshedCurrentDefaultApp<br/>== targetApp?"}
C -->|是| OK["AssociationOperationResult.succeeded"]
C -->|否| PEND["AssociationOperationResult.pendingVerification<br/>statusFlags += writePendingVerification"]
erDiagram
APP_DESCRIPTOR {
string bundleIdentifier PK
string displayName
string appURL
bool isAvailable
}
EXTENSION_ASSOCIATION_ROW {
string normalizedExtension PK
string rawExtension
bool isUserAdded
}
ASSOCIATION_WRITE_RESULT {
string normalizedExtension
string errorMessage
}
ASSOCIATION_STATUS_FLAG {
string rawValue
}
EXTENSION_ASSOCIATION_ROW ||--o| APP_DESCRIPTOR : "currentDefaultApp"
EXTENSION_ASSOCIATION_ROW ||--o{ APP_DESCRIPTOR : "candidateApps"
EXTENSION_ASSOCIATION_ROW }o--o{ ASSOCIATION_STATUS_FLAG : "statusFlags"
ASSOCIATION_WRITE_RESULT ||--o| EXTENSION_ASSOCIATION_ROW : "对应一次写入"
OpenWithGUI2/
├── Package.swift # SwiftPM 清单:swift-tools-version 6.0,macOS 14+
├── assets/
│ └── logo.svg # README 头图(squircle 图标)
├── Sources/OpenWithGUIApp/
│ ├── OpenWithGUIApp.swift # @main:注入 SystemAssociationRepository / Writer
│ ├── Models/
│ │ ├── AppDescriptor.swift # bundleIdentifier / displayName / appURL / isAvailable
│ │ ├── ExtensionAssociationRow.swift # 核心行模型 + normalize() + statusFlags
│ │ ├── AssociationStatusFlag.swift # 8 种状态标记
│ │ ├── AssociationOperationResult.swift
│ │ ├── AppPickerChoice.swift # app: / special: 两类选择项
│ │ └── AppPickerSection.swift # Candidate Apps / Other Apps 分组
│ ├── ViewModels/
│ │ └── AssociationListViewModel.swift # 筛选流水线 / 加载 / 批量修改 / 回读合并
│ ├── Services/
│ │ ├── AssociationRepository.swift # 读取协议
│ │ ├── AssociationWriter.swift # 写入协议
│ │ ├── SystemAssociationRepository.swift
│ │ ├── SystemAssociationWriter.swift
│ │ ├── AppCatalogScanner.swift # 扫描 .app 目录
│ │ ├── DocumentTypeParser.swift # 解析 CFBundleDocumentTypes
│ │ ├── LaunchServicesClient.swift # LSCopy… / LSSet… 封装
│ │ └── UserAddedExtensionStore.swift # UserDefaults 持久化
│ └── Views/
│ ├── RootView.swift # 工具栏 / 6 个 sheet 编排
│ ├── AssociationTableView.swift
│ ├── AssociationDetailSidebar.swift
│ ├── BatchActionSidebar.swift
│ ├── AppPickerSheet.swift
│ ├── AddExtensionSheet.swift
│ ├── TableScrollResetView.swift
│ └── ToolbarSearchField.swift
├── Tests/OpenWithGUIAppTests/ # Swift Testing(@Test / #expect)
│ ├── Models/ · Services/
│ └── ViewModels/AssociationListViewModelTests.swift
├── scripts/
│ ├── package-macos-app.sh # 构建 + 组装 .app + 临时签名
│ ├── package-macos-dmg.sh # 再打成 .dmg
│ └── generate-app-icon.swift
├── Assets/ # AppIcon.icns / .iconset
└── docs/assets/openwithgui-screenshot.png
- 扩展名归一化(
ExtensionAssociationRow.normalize):trim首尾空白与换行 →trim首尾.→ 转小写;结果为空返回nil。所以.JSON、json、.json会归一到同一个 keyjson。 - 候选应用推导(
AppCatalogScanner+DocumentTypeParser):枚举/Applications、/System/Applications、~/Applications(skipsHiddenFiles+skipsPackageDescendants),读取每个.app的CFBundleDocumentTypes:既取CFBundleTypeExtensions(过滤"*"),也把LSItemContentTypes里的 UTType 通过UTType(identifier)?.preferredFilenameExtension折算成扩展名;最后按bundleIdentifier去重并按displayName不区分大小写排序。 - 默认应用解析(
SystemAssociationRepository.resolveDefaultApp):UTType(filenameExtension:)→identifier→LSCopyDefaultApplicationURLForContentType;命中的 URL 会去 catalog 里按appURL精确匹配已知 app,匹配不到则用Bundle(url:)?.bundleIdentifier兜底(再兜底unknown.<ext>)。 - 筛选流水线的固定顺序:
searchText → selectedDefaultAppBundleIdentifier → selectedCandidateAppBundleIdentifier → selectedStatusFilter → sort。sort支持extensionAscending/extensionDescending/defaultAppAscending。 - Candidate App 筛选选项(本 fork):从
rows.flatMap { $0.candidateApps }按bundleIdentifier去重、按displayName排序生成,与「默认应用筛选」的来源(currentDefaultApp)相互独立。 - 状态标记的判定阈值:
manyCandidateThreshold = 5;noDefaultApp与missingDefaultApp互斥(先判 nil,再判isAvailable == false);noIssues在筛选时等价于「statusFlags为空」。 - 写入自校验(
merge):按扩展名把writeResults与refreshRows对齐,逐行判定 failed → pendingVerification → succeeded;批量摘要文案为"<成功> succeeded, <失败> failed"。 - 用户自定义扩展名持久化:
UserAddedExtensionStore使用UserDefaults键userAddedExtensions,存为排序后的字符串数组;增删都先调用normalize校验。 - Launch Services 封装:
LaunchServicesClient.live封装LSCopyDefaultApplicationURLForContentType/LSSetDefaultRoleHandlerForContentType/LSCopyAllRoleHandlersForContentType(role 均为.all),使上层可在测试里注入桩。 - 打包产物:
Info.plist的CFBundleIdentifier = com.openwithgui.app、LSMinimumSystemVersion = 14.0、CFBundleIconFile = AppIcon;打包脚本以codesign --force --deep -s -做临时(ad-hoc)签名。 - 无网络依赖:全部逻辑为本机文件系统 + 系统框架调用,不引入任何第三方包(
Package.swift无dependencies)。
Q:改完之后 Finder / 应用里没立刻生效?
A:默认应用变更经由 Launch Services 提交,可能不会即时反映。OpenWithGUI 会在写入后回读系统状态:未确认的行标记为 Pending Verification(橙色),点 Refresh 复查即可。
Q:Filter by App 和 Filter by Candidate App 有什么区别?
A:前者按当前默认应用筛(谁现在接管了这个扩展名);后者按候选应用筛(这个 app 声明自己能打开哪些扩展名,哪怕当前不是默认)。批量改「某 app 支持的一批格式」用后者。
Q:批量修改支持哪些格式?
A:能写入的前提是 macOS 认识该扩展名(UTType(filenameExtension:) 有值)。系统不认识的扩展名会以 macOS does not recognize this extension yet. 形式失败,不影响其它行。
Q:会扫描到哪些应用?
A:默认只扫 /Applications、/System/Applications 与用户主目录下的 ~/Applications;不递归进入 .app 包体内部。
Q:需要联网或上传数据吗? A:完全不需要。没有任何网络请求,也不写系统私有数据库。
- 本应用会真实修改系统默认应用关联(通过公开的 Launch Services API)。批量操作前建议先用
Refresh确认现状。 - 需要 macOS 14+;旧版本系统不受支持。
- 从源码构建需要 Swift 6.0+(工具链版本绑定在
Package.swift)。 - 打包脚本使用 ad-hoc 签名,首次打开可能触发 Gatekeeper 提示,需手动放行。
- 「候选应用」来自各 app 的
Info.plist声明,是声明能力而非「已注册接管」;部分应用可能声明与实际不符。
本项目遵循 MIT License(来自上游,版权归 Copyright (c) 2026 Run1997)。
本仓库是 Run1997/OpenWith-GUI(@main)的 fork,当前 领先上游 3 个提交(ahead 3 / behind 0,6 个文件,+409 / -1)。逐提交说明如下:
| 提交 | 类型 | 内容 |
|---|---|---|
5b44c43 Add candidate app filter |
功能 | 新增「按 Candidate App 筛选」 |
c01e2da docs: add major updates section for fork |
文档 | README 增加 Fork 更新说明 |
4920ba3 docs: 更新 README,添加编译教程和同类项目推荐 |
文档 | 增加从源码编译教程、同类项目、更新截图 |
上游只有「按默认应用筛选」。本 fork 增加了独立的「按候选应用筛选」,让你能回答一个上游答不了的问题:这个 app 声明自己能打开哪些扩展名?
Sources/OpenWithGUIApp/ViewModels/AssociationListViewModel.swift- 新增状态
selectedCandidateAppBundleIdentifier; - 新增
candidateAppFilterOptions(按bundleIdentifier去重、按displayName排序); - 新增
applyCandidateAppFilter(_:)/clearCandidateAppFilter()/clearCandidateAppFilterSelectingFirstVisibleRow(); - 在
visibleRows流水线中插入候选筛选(位于默认应用筛选之后、状态筛选之前)。
- 新增状态
Sources/OpenWithGUIApp/Views/RootView.swift- 新增状态
showingCandidateAppFilterPicker; - 工具栏新增按钮
Filter by Candidate App/Clear Candidate Filter; - 新增对应的
AppPickerSheet弹窗,并提供All Candidate Apps作为「清空」入口。
- 新增状态
Tests/OpenWithGUIAppTests/ViewModels/AssociationListViewModelTests.swift- 新增 168 行测试:筛选选项去重排序、按候选筛选可见行、清空回退、筛选后选中行迁移等。
- 中英 README 增加「此 Fork 重大更新」小节;
- 增加从源码编译完整教程与 GitHub Release 上传示例;
- 增加同类项目推荐(
ColeMei/openwith、Run1997/OpenWith-GUI); - 更新
docs/assets/openwithgui-screenshot.png(旧图 248,372 B → 新图 89,274 B)。
表格总览、按默认应用 / 状态筛选、按扩展名搜索、多选批量修改、单条候选分组选择、自定义扩展名增删、写入自校验与状态标记、DMG 打包脚本、App 图标等,均来自上游。本 fork 保持与上游兼容,未改动其行为。
- Run1997/OpenWith-GUI —— 本仓库的上游项目,提供了完整的表格管理器、Launch Services 封装与打包脚本。本 fork 在其基础上扩展。
- ColeMei/openwith —— 一个用 Rust TUI 在终端管理 macOS 扩展名关联的同类项目,提供了很好的参照。
- linux.do —— 社区讨论与支持。