Skip to content

About

macOS「打开方式」统一管理工具(fork 自 Run1997/OpenWith-GUI):本 fork 新增「按候选应用筛选」等功能。Swift/SwiftUI,含批量修改、写入自校验。 | A macOS default-application manager (fork of Run1997/OpenWith-GUI): this fork adds a candidate-app filter and more. Swift/SwiftUI.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

English | 简体中文

OpenWithGUI

OpenWithGUI — macOS「打开方式」统一管理器

告别 Finder 里一个扩展名一次 显示简介 → 打开方式 → 全部更改… —— 用一张表看清并批量改掉全系统默认应用。

Platform Swift UI Network License


它解决什么问题

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(一键构建,推荐)

把下面这段提示词直接发给你的本地 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 安装

下载 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 手动添加扩展名并指定应用

典型工作流:把「某 app 支持的一批格式」一次性接管

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
Loading

可见行筛选流水线

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"]
Loading

加载 / 扫描时序

首次进入或点 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()
Loading

批量修改时序

写入走 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"
Loading

写入结果判定

回读后逐行比对,只有系统状态确实等于目标应用才算成功,否则降级为「待确认」,避免给出虚假的成功反馈。

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"]
Loading

数据模型

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 : "对应一次写入"
Loading

📂 目录结构

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 会归一到同一个 key json。
  • 候选应用推导(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 声明,是声明能力而非「已注册接管」;部分应用可能声明与实际不符。

📄 License

本项目遵循 MIT License(来自上游,版权归 Copyright (c) 2026 Run1997)。


🔀 vs 上游

本仓库是 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,添加编译教程和同类项目推荐 文档 增加从源码编译教程、同类项目、更新截图

1. 新增「按 Candidate App 筛选」(核心功能差异)

上游只有「按默认应用筛选」。本 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 行测试:筛选选项去重排序、按候选筛选可见行、清空回退、筛选后选中行迁移等。

2. 文档完善

  • 中英 README 增加「此 Fork 重大更新」小节;
  • 增加从源码编译完整教程与 GitHub Release 上传示例;
  • 增加同类项目推荐(ColeMei/openwith、Run1997/OpenWith-GUI);
  • 更新 docs/assets/openwithgui-screenshot.png(旧图 248,372 B → 新图 89,274 B)。

上游原有的能力(非本 fork 新增)

表格总览、按默认应用 / 状态筛选、按扩展名搜索、多选批量修改、单条候选分组选择、自定义扩展名增删、写入自校验与状态标记、DMG 打包脚本、App 图标等,均来自上游。本 fork 保持与上游兼容,未改动其行为。


🙏 致谢 / Credits

  • Run1997/OpenWith-GUI —— 本仓库的上游项目,提供了完整的表格管理器、Launch Services 封装与打包脚本。本 fork 在其基础上扩展。
  • ColeMei/openwith —— 一个用 Rust TUI 在终端管理 macOS 扩展名关联的同类项目,提供了很好的参照。
  • linux.do —— 社区讨论与支持。

OpenWithGUI2 · 让整个系统的「打开方式」,回到一张表里

About

macOS「打开方式」统一管理工具(fork 自 Run1997/OpenWith-GUI):本 fork 新增「按候选应用筛选」等功能。Swift/SwiftUI,含批量修改、写入自校验。 | A macOS default-application manager (fork of Run1997/OpenWith-GUI): this fork adds a candidate-app filter and more. Swift/SwiftUI.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages