Parallels Desktop 抢 macOS 默认打开方式:别关总闸,做文件关联白名单

最近遇到一个很离谱但又很现实的 macOS/Parallels Desktop 问题:下载了一个 oci-help.ini,双击时默认竟然不是用 macOS 文本编辑器打开,而是启动 PD 里的 Windows 虚拟机,用 Windows 记事本打开。

这事一开始看起来像是单个 .ini 文件的关联错了,但实际查下去发现不是。

现象

macOS 的 LaunchServices 里,很多文件类型的默认处理器被 Parallels 生成的 Windows app wrapper 抢走了,例如:

  • .ini → Windows 记事本
  • .svg → Windows/Edge 相关 wrapper
  • .rar / .zst / .cab → Windows 安装/资源管理器相关 wrapper
  • 一些 RAW 图片、媒体格式、脚本格式也被 Windows app 注册成默认打开方式

当时对目标文件查出来的结果是:

mdls -name kMDItemContentType -name kMDItemContentTypeTree /Users/m/Downloads/oci-help-darwin-amd64-v2.3.1/oci-help.ini

显示它是:

kMDItemContentType = "com.microsoft.ini"
kMDItemContentTypeTree = (
    "com.microsoft.ini",
    "public.text",
    "public.data",
    "public.item",
    "public.content"
)

也就是说,它本质上还是文本类文件,但默认打开程序被 PD 的 Windows 记事本 wrapper 接管了。

进一步用 CoreServices 查默认应用:

LSCopyDefaultApplicationURLForURL(...)

得到的是类似这样的路径:

/Users/m/Applications (Parallels)/{...} Applications.localized/记事本.app

根因

Parallels Desktop 的 Shared Applications / SmartSelect 会把 Windows 里的应用暴露到 macOS:

prlctl list "Windows 11" -i | sed -n '/Shared Applications:/,/SmartMount:/p'

当时配置是:

Shared Applications: (+)
  Host-to-guest apps sharing: on
  Guest-to-host apps sharing: on

其中 Guest-to-host apps sharing: on 的意思就是 Windows 应用可以出现在 macOS 这边。这个功能本身有用:例如可以从 Finder 的“打开方式”里手动选 Windows 应用。

问题是,PD 不只是“让你能选 Windows 应用”,它还会通过文件类型关联把某些 Windows 应用设成 macOS 默认打开程序。于是就出现了 .ini 双击启动 Windows 虚拟机这种奇观。

粗暴方案为什么不好

可以一键关掉 Windows 应用共享:

prlctl set "Windows 11" --sh-app-guest-to-host off

或者在 GUI 里关 Share Windows applications with Mac

但这个太粗暴:关了之后,macOS 这边也就基本看不到 Windows 应用了,右键“打开方式”手动调用 Windows app 的便利也没了。对经常需要偶尔用 Windows 程序打开文件的人来说,不实用。

更优雅的方案

最后采用的是:

  • 保持 Parallels 的 Shared Applications 开着
  • 不删除 Windows app wrapper
  • 只修 macOS 的默认打开方式
  • 白名单保留真正应该进 Windows 的类型,比如 .exe / .msi / .bat / .cmd / .com
  • 其他被 com.parallels.winapp... 抢成默认的类型拉回 macOS 本机应用

实际修复后,全量检查剩下的 Parallels 默认项只有 Windows 执行/安装入口:

.exe -> com.parallels.winapp...
.msi -> com.parallels.winapp...

.ini 已恢复成:

/Users/m/Downloads/oci-help-darwin-amd64-v2.3.1/oci-help.ini -> /System/Applications/TextEdit.app
.ini -> com.apple.TextEdit
.svg -> com.apple.Preview
.rar/.zst/.cab -> com.apple.archiveutility
.mhtml -> com.google.Chrome

做成一个守护脚本

我把这个做成了一个本机脚本,思路是扫描常见会被 PD 抢走的扩展名,检查当前默认 handler 是否是 com.parallels.winapp...。如果是,就按类型改回本机应用;如果是 .exe/.msi/.bat/.cmd/.com,保留给 Windows。

用法形态如下:

pd-file-association-guard.sh --audit
pd-file-association-guard.sh --apply

--audit 只看会改什么,--apply 才真正修复。

核心逻辑用 macOS CoreServices:

  • LSCopyDefaultRoleHandlerForContentType 查当前默认 handler
  • LSSetDefaultRoleHandlerForContentType 写回默认 handler
  • 同时兼容老 LaunchServices 动态 UTI 和新 UniformTypeIdentifiers.UTType 生成的 UTI,因为同一个扩展名在 macOS 上可能有两套动态 UTI 变体

这一点很关键:第一次只用新 UTType(filenameExtension:) 改,发现 .ini 好了,但 .svg/.rar/.ps1 这类还有旧 UTI 变体会继续回到 Windows。补上老的 UTTypeCreatePreferredIdentifierForTag 后才彻底收敛。

结论

Parallels 的“一键关共享”不是最好的答案。更好用的模型是:

Windows 应用可以被 macOS 手动调用,但不能随便成为 macOS 的默认打开程序。

也就是“共享功能开着,默认关联白名单化”。这样既保留 PD 的便利,又不会出现双击 .ini 把 Windows 虚拟机叫醒这种奇葩体验。

补一下脚本全文,前面只写了用法,没把可复制版本贴上来,确实不完整。

保存成 pd-file-association-guard.sh 后执行:

chmod +x pd-file-association-guard.sh
./pd-file-association-guard.sh --audit
./pd-file-association-guard.sh --apply

脚本如下:

#!/usr/bin/env bash
set -euo pipefail

mode="${1:---apply}"
if [[ "$mode" != "--apply" && "$mode" != "--audit" ]]; then
  echo "Usage: $0 [--apply|--audit]" >&2
  exit 2
fi

swift - "$mode" <<'SWIFT'
import Foundation
import CoreServices
import UniformTypeIdentifiers

let mode = CommandLine.arguments.dropFirst().first ?? "--apply"

let textEdit = "com.apple.TextEdit"
let preview = "com.apple.Preview"
let quickTime = "com.apple.QuickTimePlayerX"
let archiveUtility = "com.apple.archiveutility"
let chrome = "com.google.Chrome"
let safari = "com.apple.Safari"

func bundleExists(_ bundleID: String) -> Bool {
    if let urls = LSCopyApplicationURLsForBundleIdentifier(bundleID as CFString, nil)?.takeRetainedValue() as? [URL] {
        return !urls.isEmpty
    }
    return false
}

let browser = bundleExists(chrome) ? chrome : safari

let keepWindowsExts: Set<String> = ["exe", "msi", "bat", "cmd", "com"]
let archiveExts: Set<String> = ["cab", "rar", "zst", "tzst", "xar", "nupkg", "mtree"]
let webExts: Set<String> = ["mht", "mhtml", "xht", "xhtml"]
let textExts: Set<String> = [
    "adt", "all", "fh", "inf", "ini", "jse", "msc", "msg", "nfo", "oft", "osdx",
    "ps1", "psc1", "psd1", "psm1", "r3d", "rdp", "reg", "scf", "scp", "udl",
    "vbe", "vbs", "wsf", "wsh", "wtx"
]
let imageExts: Set<String> = [
    "3fr", "ari", "arw", "bay", "cap", "cr2", "cr3", "crw", "dcr", "dcs", "dng",
    "drf", "eip", "emf", "erf", "fff", "iiq", "jfif", "jxr", "k25", "kdc", "mdc",
    "mef", "mos", "mrw", "nef", "nrw", "orf", "ori", "paint", "pef", "raf", "raw",
    "rle", "rw2", "rwl", "rwz", "sr2", "srf", "srw", "svg", "thumb", "wdp", "wmf",
    "yuv"
]
let mediaExts: Set<String> = [
    "asf", "asx", "cda", "divx", "dvr-ms", "ec3", "m1v", "m2t", "m2ts", "mk3d",
    "mka", "mod", "mp2v", "mp4v", "mpa", "mpv", "mpv2", "mts", "mxf", "ogx", "rm",
    "rmi", "rmv", "rmvb", "tod", "ts", "tts", "vob", "wax", "wm", "wmd", "wms",
    "wmx", "wmz", "wpl", "wtv", "wvx", "xvid", "zpl"
]

let allExts = archiveExts
    .union(webExts)
    .union(textExts)
    .union(imageExts)
    .union(mediaExts)
    .union(keepWindowsExts)

func oldUTI(for ext: String) -> String? {
    UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil)?
        .takeRetainedValue() as String?
}

func newUTI(for ext: String) -> String? {
    UTType(filenameExtension: ext)?.identifier
}

func defaultHandler(for uti: String) -> String {
    LSCopyDefaultRoleHandlerForContentType(uti as CFString, .all)?.takeRetainedValue() as String? ?? "none"
}

func targetHandler(for ext: String) -> String? {
    if keepWindowsExts.contains(ext) { return nil }
    if webExts.contains(ext) { return browser }
    if archiveExts.contains(ext) { return archiveUtility }
    if textExts.contains(ext) { return textEdit }
    if imageExts.contains(ext) { return preview }
    if mediaExts.contains(ext) { return quickTime }
    return nil
}

var seen = Set<String>()
var changed: [(String, String, String, String)] = []
var kept: [(String, String, String)] = []
var alreadyNative = 0
var failed: [(String, String, String, OSStatus)] = []

for ext in allExts.sorted() {
    let candidates = [oldUTI(for: ext), newUTI(for: ext)].compactMap { $0 }
    for uti in candidates where seen.insert("\(ext):\(uti)").inserted {
        let current = defaultHandler(for: uti)
        guard current.contains("parallels") || current.contains("winapp") else {
            alreadyNative += 1
            continue
        }
        guard let target = targetHandler(for: ext) else {
            kept.append((ext, uti, current))
            continue
        }
        if mode == "--audit" {
            changed.append((ext, uti, current, target))
            continue
        }
        let status = LSSetDefaultRoleHandlerForContentType(uti as CFString, .all, target as CFString)
        if status == noErr {
            changed.append((ext, uti, current, target))
        } else {
            failed.append((ext, uti, target, status))
        }
    }
}

let action = mode == "--audit" ? "would-change" : "changed"
print("\(action)=\(changed.count)")
for row in changed {
    print("\(action)\t.\(row.0)\t\(row.1)\t\(row.3)\tfrom=\(row.2)")
}

print("kept-windows=\(kept.count)")
for row in kept {
    print("kept\t.\(row.0)\t\(row.1)\t\(row.2)")
}

print("already-native=\(alreadyNative)")
print("failed=\(failed.count)")
for row in failed {
    print("failed\t.\(row.0)\t\(row.1)\t\(row.2)\tstatus=\(row.3)")
}
SWIFT