私有系统集成
CAEmitterBehavior、CGEvent tap、Dock 资源叉与系统视图树改造。
本主题范围:TelegramSwift 的 macOS 私有 API、运行时 hack 与系统集成。结论:该版本最激进的做法是私有 Core Animation 行为、系统内部视图树改造、CGEvent tap,以及 Finder 资源叉改图标。
CAPortalLayer 没有活跃用法。它只在 CAPortalLayer.h#L2-L23 声明。CAPortalLayer.m#L1 为空。全仓 rg 只找到该声明和包定义。因此,本笔记不把它计入技巧。
1. 拼接私有类名,并用函数指针调用 CAEmitterBehavior
做了什么:代码运行时构造私有类 CAEmitterBehavior。代码也构造真实 selector behaviorWithType:。代码把 method(for:) 的 IMP 强转为 C 函数指针。随后,代码通过 emitterBehaviors 的 KVC 键装配吸附和生命周期行为。
为什么这样做:CAEmitterLayer 的普通 Swift 接口不能表达这里的吸附器。代码因而复用系统粒子求解器。它避免自己逐帧计算大量尘粒的轨迹。
关键代码摘录
public func createEmitterBehavior(type: String) -> NSObject {
let selector = ["behaviorWith", "Type:"].joined(separator: "")
let behaviorClass = NSClassFromString(["CA", "Emitter", "Behavior"].joined(separator: "")) as! NSObject.Type
let behaviorWithType = behaviorClass.method(for: NSSelectorFromString(selector))!
let castedBehaviorWithType = unsafeBitCast(
behaviorWithType,
to: (@convention(c) (Any?, Selector, Any?) -> NSObject).self
)
return castedBehaviorWithType(behaviorClass, NSSelectorFromString(selector), type)
}
let fingerAttractor = createEmitterBehavior(type: "simpleAttractor")
fingerAttractor.setValue("fingerAttractor", forKey: "name")
let alphaBehavior = createEmitterBehavior(type: "valueOverLife")
alphaBehavior.setValue("color.alpha", forKey: "keyPath")
alphaBehavior.setValue([0.0, 0.0, 1.0, 0.0, -1.0], forKey: "values")
alphaBehavior.setValue(true, forKey: "additive")解混淆确认:['CA', 'Emitter', 'Behavior'].joined() 的结果是 CAEmitterBehavior。['behaviorWith', 'Type:'].joined() 的结果是 behaviorWithType:。这两个结果直接传给 NSClassFromString 和 NSSelectorFromString。
代价与风险:这是私有 API。as!、强制取 IMP 与 unsafeBitCast 都会在类或方法消失时崩溃。emitterBehaviors 及其 KVC 路径也可能被系统改名。App Store 审核风险很高。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 2 处源码位置。
- REA 第 1 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:
"CA"、"Emitter"、"Behavior"以 Swift 小字符串存于__DATA.__data0x5e75208/18/28;"behaviorWith"、"Type:"存于 0x5e751c0/d0。完整名CAEmitterBehavior、behaviorWithType出现 0 次。"simpleAttractor"编成 5 处、"valueOverLife"编成 3 处MOVZ/MOVK立即数。KVC 路径emitterBehaviors.fingerAttractor.*、randomAttractor0/1.*以明文存于__cstringmacOS 26.7:类存在;
+behaviorWithType:存在;两种类型都能创建结论:已发布,当前系统仍可用
2. 用监听型 CGEvent tap 实现全局按住说话
做了什么:代码监听 HID 级键盘、修饰键和其他鼠标键。它把 CGEvent 转回 NSEvent。它选择 .listenOnly,所以监听器不拦截原始输入。权限不足时,代码退化为应用内 NSEvent monitor。
为什么这样做:普通窗口事件只能在应用前台工作。这里要支持全局按住说话。监听型 event tap 同时保留其他应用的按键行为。
关键代码摘录
if hasPermission {
func callback(proxy: CGEventTapProxy, type: CGEventType,
event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
if let event = NSEvent(cgEvent: event) {
let processor = Unmanaged<ProcessEvent>.fromOpaque(refcon!).takeUnretainedValue()
processor.process(event)
}
return Unmanaged.passRetained(event)
}
let eventMask: Int32 = (1 << CGEventType.keyDown.rawValue)
| (1 << CGEventType.otherMouseDown.rawValue)
| (1 << CGEventType.otherMouseUp.rawValue)
| (1 << CGEventType.keyUp.rawValue)
| (1 << CGEventType.flagsChanged.rawValue)
self.eventTap = CGEvent.tapCreate(tap: .cghidEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: CGEventMask(eventMask),
callback: callback,
userInfo: UnsafeMutableRawPointer(Unmanaged.passRetained(processEvent).toOpaque()))
}代价与风险:这需要“输入监控”授权。用户可能拒绝授权。userInfo 以 passRetained 传入,但该段代码未见对应 release。是否由其他路径释放未验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:回调转
NSEvent,用Unmanaged传递处理器。 - REA 第 10 项
- 已发布 · 仍可用REA 二进制REA 运行时观测
13.0 二进制:导入
CGEventTapCreate、CGEventTapEnable、IOHIDCheckAccess、IOHIDRequestAccessmacOS 26.7:LLDB 把断点解析到 SkyLight 的
SLEventTapCreate;启动 40 秒内未调用结论:已发布;默认未启用
3. 用 FinderInfo 位和资源叉永久改写 Dock 图标
做了什么:代码先让 sips -i 生成图标资源叉。代码把资源叉复制到主 .app 包内名为 Icon\r 的文件。代码设置 com.apple.FinderInfo 第 8 字节的第 2 位。最后,代码删除 Dock 图标缓存,并执行 killall Dock。
为什么这样做:NSApplication.applicationIconImage 只能改变当前进程的图标。它不能保证 Finder 和 Dock 重启后仍使用新图标。资源叉和 FinderInfo 是 Finder 的旧式自定义图标协议。
关键代码摘录
static func enableCustomIcon(bundle: String) -> Bool {
var info = readCustomIconAttribute(bundle: bundle)
if info.isEmpty {
info = Data(repeating: 0, count: kFinderInfoSize)
}
info[8] |= 4
return writeCustomIconAttribute(bundle: bundle, value: info)
}
static func refreshDock(silence: Bool) -> Bool {
let _ = launch(command: "/bin/bash", arguments: ["-c", "rm /var/folders/*/*/*/com.apple.dock.iconcache"])
if !silence {
let killallResult = launch(command: "/usr/bin/killall", arguments: ["Dock"])
if killallResult != 0 { return false }
}
return true
}
let bundle = bundlePath()
let icon = bundle + "/Icon\r"
guard let rsrc = readResourceFork(path: path), !rsrc.isEmpty else { return nil }
if !writeResourceFork(path: icon, data: rsrc) || !enableCustomIcon(bundle: bundle) {
return nil
}
return refreshDock(silence: silence) ? digest(rsrc) : nil代价与风险:代码改写已安装 app bundle。签名、文件权限和沙盒都可能阻止它。删除缓存和杀死 Dock 会干扰用户。系统缓存路径和 Finder 元数据格式也有维护风险。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:POSIX 扩展属性与
killall Dock刷新缓存。 - REA 第 9 项
- 已发布 · 仍可用REA 二进制
13.0 二进制:
com.apple.ResourceFork、com.apple.FinderInfo、/usr/bin/killall、“Failed to runkillall Dock”;导入setxattr、getxattr、removexattrmacOS 26.7:未执行(会改动系统状态)
结论:已发布。非沙盒的 Developer ID 分发让写自身 bundle 成为可能
4. 深挖 AVMovableView,再点击隐藏的全屏按钮
做了什么:代码按类名找到 AVKit 内部的 AVMovableView。代码假设六层子视图链的末端是 NSButton。代码通过 performClick 触发那个按钮的全屏动作。
为什么这样做:代码保留 AVPlayerView 的系统控制条。它又让外层图库可驱动系统全屏实现。普通公开调用不能表达这条内部控件路径。
关键代码摘录
override func toggleFullScreen() {
if let view = _cachedView {
let controls = HackUtils.findElements(byClass: "AVMovableView", in: view)?.first as? NSView
if let controls = controls {
if let view = controls.subviews.first?.subviews.first?.subviews.first?
.subviews.last?.subviews.first?.subviews.last {
if let view = view as? NSButton {
view.performClick(self)
}
}
}
}
}代价与风险:AVMovableView 和子视图层级都不是稳定契约。任一 macOS 版本调整都会使全屏无效。错误类型或层级只会静默失败。审核风险很高。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
findElements(byClass:)+ 子视图链定位。 - REA 第 6 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:
"AVMovableView"编成 3 处立即数(0x102517a20、0x102518434、0x1025185f8),与源码 3 个调用点一致macOS 26.7:类存在,并出现在
AVPlayerView视图树中结论:已发布;内部按钮路径未验证
- REA 第 18 项
- 版本差异REA 二进制REA 运行时观测
13.0 二进制:
HackUtils出现 0 次;ObjcUtils类存在,带findElementsByClass:inView:和:array:macOS 26.7:
ObjcUtils方法因符号已剥离,LLDB 无法下断结论:版本差异:功能可能并入
ObjcUtils(未验证)
5. 重排 NSTitlebarView 内部子视图,腾出 50 pt 标题栏
做了什么:代码改写 window.titleView 的 frame。代码递归按字符串找到私有 NSTitlebarView。代码遍历前三个内部子视图。代码隐藏它们,并按 Retina 像素取整后重设坐标。
为什么这样做:Passport 窗口使用透明标题栏和自定义导航区。系统的默认交通灯布局不能适配 50 pt 高度。公开 NSWindow API 不提供内部子视图的逐个坐标控制。
关键代码摘录
@objc func windowDidNeedSaveState(_ notification: Notification) {
if let titleView = window.titleView {
let frame = NSMakeRect(0, window.frame.height - barHeight, titleView.frame.width, barHeight)
if !NSEqualRects(frame, titleView.frame) {
titleView.frame = frame
}
if let controls = (HackUtils.findElements(byClass: "NSTitlebarView", in: titleView)?.first as? NSView)?.subviews {
var xs: [CGFloat] = [18, 58, 38]
for i in 0 ..< min(controls.count, xs.count) {
let view = controls[i]
view.isHidden = true
view.setFrameOrigin(xs[i], floorToScreenPixels(System.backingScale,
(barHeight - view.frame.height) / 2))
}
}
}
}代价与风险:NSTitlebarView、子视图顺序和前三项含义都不是公开契约。该代码不能确认前三项各自对应哪个交通灯。坐标顺序 [18, 58, 38] 也依赖旧层级。系统升级风险和审核风险很高。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
HackUtils.findElements定位并像素对齐。 - REA 第 7 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:
"NSTitlebarView"编成 1 处立即数(0x1036c54f8);NSTitlebarContainerView在__cstringmacOS 26.7:两个类都在标题栏视图树中
结论:已发布,结构仍在
- REA 第 18 项
- 版本差异REA 二进制REA 运行时观测
13.0 二进制:
HackUtils出现 0 次;ObjcUtils类存在,带findElementsByClass:inView:和:array:macOS 26.7:
ObjcUtils方法因符号已剥离,LLDB 无法下断结论:版本差异:功能可能并入
ObjcUtils(未验证)
6. 用私有文本菜单 selector 删除系统子菜单
做了什么:代码检查 AppKit 生成的文本右键菜单。代码通过 _shareServiceSelected: 识别系统分享服务项。代码发现分享、字体或替换项时,删除整个父菜单。代码再插入 Telegram 的文本转换项。
为什么这样做:NSMenuItem 没有稳定的公开分类可区分这些系统项目。检查 action 让代码无需匹配本地化标题。它也能保持自定义转换项的位置。
关键代码摘录
public override func menu(for event: NSEvent) -> NSMenu? {
let menu = super.menu(for: event) as? NSMenu
var removeItems = [NSMenuItem]()
var addedTransformations = false
menu?.items.enumerated().forEach { (idx, item) in
if item.action == NSSelectorFromString("submenuAction:") {
item.submenu?.items.enumerated().forEach { (subIdx, subItem) in
if subItem.action == NSSelectorFromString("_shareServiceSelected:")
|| subItem.action == NSSelectorFromString("orderFrontFontPanel:")
|| subItem.action == NSSelectorFromString("orderFrontSubstitutionsPanel:")
|| subItem.action == NSSelectorFromString("changeLayoutOrientation:") {
removeItems.append(item)
} else if subItem.action == #selector(capitalizeWord(_:)) {
addedTransformations = true
if self.selectedRange.length > 0 {
if self.customDelegate?.inputTextCanTransform() == true {
self.transformItems.enumerated().forEach { (transformIdx, obj) in
item.submenu?.insertItem(obj, at: transformIdx)
}
}
}
}
}
}
}代价与风险:_shareServiceSelected: 是私有 selector。submenuAction: 和其他系统菜单结构也会变化。某些系统版本可能删错菜单,或无法删掉目标菜单。审核风险存在。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:遍历子菜单,按 selector 过滤。
- REA 第 5 项
- 系统结构已变化REA 二进制macOS 26.7 实测
13.0 二进制:该 selector 同时在
__cstring和__objc_methnamemacOS 26.7:
NSTextView默认菜单中没有它,分享项改用_performStandardShareMenuItem:。13.0 二进制中没有新 selector结论:分享项的过滤条件在 26.7 上可能失效(推断,未在 Telegram 内实测)
7. 直接调用未公开的对角缩放光标 selector
做了什么:代码向 NSCursor 类发送两个下划线 selector。它取得两种对角窗口缩放光标。调用点把它们用于图像编辑窗口和通话窗口的角落拖拽。
为什么这样做:系统内部光标可与 Finder 式窗口缩放保持一致。代码不用打包和维护自绘光标资源。
关键代码摘录
@implementation ObjcUtils
+(NSCursor * __nullable)windowResizeNorthWestSouthEastCursor {
NSCursor *cursor = [[NSCursor class]
performSelector:@selector(_windowResizeNorthWestSouthEastCursor)];
return cursor;
}
+(NSCursor * __nullable)windowResizeNorthEastSouthWestCursor {
NSCursor *cursor = [[NSCursor class]
performSelector:@selector(_windowResizeNorthEastSouthWestCursor)];
return cursor;
}
+ (NSData *)dataFromHexString:(NSString *)string代价与风险:_windowResizeNorthWestSouthEastCursor 与 _windowResizeNorthEastSouthWestCursor 都是私有 API。代码没有先检查 respondsToSelector:。selector 消失会触发未识别 selector 崩溃。审核风险很高。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
performSelector:调用类方法。 - REA 第 4 项
- 已发布 · 仍可用REA 二进制macOS 26.7 实测
13.0 二进制:两个 selector 都在
__objc_methname(0x1050992e1、0x105099307)macOS 26.7:LLDB 在 AppKit 中解析到
+[NSCursor _windowResizeNorthWestSouthEastCursor]等两个方法结论:已发布,仍可用
8. 按私有 MKZoomSegmentedControl 类名移动地图缩放控件
做了什么:代码在每次 layout 后扫描 MKMapView 的内部树。代码找到 MKZoomSegmentedControl。代码把它固定到 (20, 20)。代码在调整 map frame 时暂时置空 delegate。
为什么这样做:自定义地图面板会改变地图可见区域。系统控件的默认位置会与头部和列表冲突。公开 MapKit API 不提供此缩放控件的布局对象。
关键代码摘录
let delegate = mapView.delegate
mapView.delegate = nil
mapView.frame = NSMakeRect(0, mapY, frame.width, mapHeight)
mapView.delegate = delegate
let pinPoint = mapView.focus(NSMakeSize(4, 4))
locationPinView.centerX(y: pinPoint.midY - locationPinView.frame.height)
expandButton.center()
loadingView.center()
let zoomControls = HackUtils.findElements(byClass: "MKZoomSegmentedControl", in: mapView).first as? NSView
if let zoomControls = zoomControls {
zoomControls.setFrameOrigin(20, 20)
}代价与风险:MKZoomSegmentedControl 是内部类名。控件可能不存在,或在新版 MapKit 改名。短暂清空 delegate 还可能丢失重设 frame 期间的回调。审核风险存在。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
findElements(byClass:)定位控件后setFrameOrigin。 - REA 第 8 项
- 系统结构已变化REA 二进制macOS 26.7 实测
13.0 二进制:
__cstring0x104c3bd50macOS 26.7:类存在,但设置
showsZoomControls = true后不在MKMapView视图树中结论:代码用
if let保护,会静默跳过(本次测试条件下) - REA 第 18 项
- 版本差异REA 二进制REA 运行时观测
13.0 二进制:
HackUtils出现 0 次;ObjcUtils类存在,带findElementsByClass:inView:和:array:macOS 26.7:
ObjcUtils方法因符号已剥离,LLDB 无法下断结论:版本差异:功能可能并入
ObjcUtils(未验证)
9. 用 CATransaction 包裹系统全屏切换,并延迟发布状态
做了什么:Window 在调用 super.toggleFullScreen 前后显式开关 Core Animation 事务。它允许 processFullScreen 先介入。它把保存状态写在实际切换后。无介入器时,它下一轮主队列才发布信号。
为什么这样做:直接调用 super 无法让应用插入额外的全屏流程。这个封装也把“请求切换”和“状态流更新”分开。调用方可在系统动画前安排自己的界面状态。
关键代码摘录
public var processFullScreen: ((Bool, @escaping (Bool) -> Void) -> Void)?
private func invokeFullScreen(_ sender: Any?) {
CATransaction.begin()
super.toggleFullScreen(sender)
CATransaction.commit()
saver?.isFullScreen = isFullScreen
}
open override func toggleFullScreen(_ sender: Any?) {
let newValue = !isFullScreen
self.onToggleFullScreen?(newValue)
if let process = processFullScreen {
process(newValue, { [weak self] value in
self?.invokeFullScreen(nil)
self?.onToggleFullScreen?(newValue)
})
} else {
invokeFullScreen(nil)
DispatchQueue.main.async {
self.isFullScreenValue.set(newValue)
}
}
}代价与风险:未验证 CATransaction 是否针对特定 macOS bug。回调闭包忽略参数 value。若 processFullScreen 不回调,窗口不会切换。异步状态还可能与系统拒绝的切换短暂不一致。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
10. 混用 IOPS 摘要和 AppleSmartBattery 注册表私有键
做了什么:代码先用公开 IOPS 获取充满或耗尽剩余时间。代码再连接 IOKit 的 AppleSmartBattery 服务。代码直接读取容量、循环次数、温度和设计循环数等键。它把电流与电压换算为带正负号的瓦数。
为什么这样做:IOPS 的公开摘要没有这些健康指标。注册表键使 Telegram 能显示或使用更详细的内部电池信息。代码每分钟新建 InternalFinder 后读取一次。
关键代码摘录
fileprivate func open() {
self.serviceInternal = IOServiceGetMatchingService(
kIOMasterPortDefault, IOServiceMatching("AppleSmartBattery"))
}
public func getInternalBattery() -> InternalBattery? {
self.open()
if self.serviceInternal == 0 {
return nil
}
let battery = self.getBatteryData()
self.close()
return battery
}
battery.currentCapacity = self.getIntValue("CurrentCapacity" as CFString)
battery.maxCapacity = self.getIntValue("MaxCapacity" as CFString)
battery.designCapacity = self.getIntValue("DesignCapacity" as CFString)
battery.cycleCount = self.getIntValue("CycleCount" as CFString)
battery.designCycleCount = self.getIntValue("DesignCycleCount9C" as CFString)
battery.acPowered = self.getBoolValue("ExternalConnected" as CFString)
battery.isCharging = self.getBoolValue("IsCharging" as CFString)
battery.temperature = self.getTemperature()代价与风险:AppleSmartBattery 及 DesignCycleCount9C 等键没有稳定公开契约。不同机型可能缺键或返回不同类型。getTemperature 和制造日期处的强制转换也可能崩溃。维护风险高。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 2 处源码位置。
- REA 第 11 项
- 已发布 · 仍可用REA 二进制REA 运行时观测
13.0 二进制:
IOHIDSystem、HIDIdleTime、AppleSmartBattery;导入IOServiceGetMatchingService、IORegistryEntryCreateCFProperty、IOPSCopyPowerSourcesInfomacOS 26.7:启动时
IOServiceGetMatchingService被调用 6 次,调用栈中没有 Telegram 帧结论:已发布
11. 用 Carbon 键盘布局翻译物理键,并重试非 Unicode 布局
做了什么:代码先映射功能键和修饰键。普通物理键则从当前 ASCII 键盘布局取得 UCKeyboardLayout。代码把 Cocoa 修饰键转成 Carbon 标志。代码用 UCKeyTranslate 得到用户当前布局下的快捷键文案。
为什么这样做:直接显示 key code 会让非 US 键盘难以理解。普通 NSEvent 的字符也不适合配置快捷键的物理键表达。代码还明确尝试规避中文和日文非 Unicode 布局崩溃。
关键代码摘录
NSString *mapped = [characterMap objectForKey:@(keyCode)];
if (mapped != nil) {
[final appendString:mapped];
} else {
TISInputSourceRef currentKeyboard = TISCopyCurrentASCIICapableKeyboardLayoutInputSource();
CFDataRef uchr = (CFDataRef)TISGetInputSourceProperty(
currentKeyboard, kTISPropertyUnicodeKeyLayoutData);
if (!uchr) {
CFRelease(currentKeyboard);
currentKeyboard = TISCopyCurrentASCIICapableKeyboardLayoutInputSource();
uchr = (CFDataRef)TISGetInputSourceProperty(
currentKeyboard, kTISPropertyUnicodeKeyLayoutData);
}
const UCKeyboardLayout *keyboardLayout =
(const UCKeyboardLayout *)CFDataGetBytePtr(uchr);
if (keyboardLayout) {
UInt32 keyModifiers = CarbonModifierFlagsFromCocoaModifiers(modifiers);
OSStatus status = UCKeyTranslate(keyboardLayout, keyCode,
kUCKeyActionDown, keyModifiers, LMGetKbdType(), 0,
&deadKeyState, maxStringLength, &actualStringLength, unicodeString);代价与风险:Carbon 是旧接口。第二次调用仍使用同一个获取函数。若 uchr 仍为空,代码仍传入 CFDataGetBytePtr。该保护是否足够未验证。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 13 项
- 已发布 · 仍可用REA 二进制
13.0 二进制:导入
TISCopyCurrentKeyboardInputSource、TISCopyCurrentASCIICapableKeyboardLayoutInputSource、UCKeyTranslatemacOS 26.7:未执行
结论:已发布
12. 把可能阻塞的输入监控授权移出主线程
做了什么:无提示检查同步返回授权状态。需要提示时,代码在用户发起队列调用 IOHIDRequestAccess。代码立即返回 NO。10.15 以前,它回退到辅助功能授权。另一方法还拼接系统设置深链。
为什么这样做:源码注释说明带提示的调用会阻塞。把它移出主线程可避免按下“启用按住说话”时卡住界面。普通同步提示会直接阻塞交互线程。
关键代码摘录
+ (BOOL)checkInputMonitoringWithPrompt:(BOOL)prompt {
if (@available(macOS 10.15, *)) {
static const IOHIDRequestType accessType = kIOHIDRequestTypeListenEvent;
if (prompt) {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
IOHIDRequestAccess(accessType);
});
return NO;
} else {
return kIOHIDAccessTypeGranted == IOHIDCheckAccess(accessType);
}
} else {
return [PermissionsManager checkAccessibilityWithPrompt:prompt];
}
}
+ (NSURL *)securitySettingsUrlForKey:(NSString *)key {
return [NSURL URLWithString:[NSString stringWithFormat:
@"x-apple.systempreferences:com.apple.preference.security?%@", key]];
}代价与风险:提示开始与返回值不同步。调用方必须再次检查状态。x-apple.systempreferences 参数不是稳定公开 URL 契约。不同 macOS 设置页可能失效。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
13. 直接用 POSIX 扩展属性存取 Finder 元数据
做了什么:代码先以空缓冲区查询属性长度。代码再分配精确长度的缓冲区并读取原始字节。它把 POSIX errno 包装成 Swift 错误。Dock 包用它读写 com.apple.FinderInfo 和 com.apple.ResourceFork。
为什么这样做:UserDefaults 和普通文件内容不能修改 Finder 的隐藏元数据。这个小封装让 Swift 代码能直接操作 macOS 文件系统属性。
关键代码摘录
public struct XAttr {
public struct Error: Swift.Error {
public let localizedDescription = String(utf8String: strerror(errno))
}
public static func set(named name: String, data: Data, atPath path: String) throws {
if setxattr(path, name, (data as NSData).bytes, data.count, 0, 0) == -1 {
throw Error()
}
}
public static func remove(named name: String, atPath path: String) throws {
if removexattr(path, name, 0) == -1 {
throw Error()
}
}
public static func get(named name: String, atPath path: String) throws -> Data {
let bufLength = getxattr(path, name, nil, 0, 0, 0)
guard bufLength != -1, let buf = malloc(bufLength),
getxattr(path, name, buf, bufLength, 0, 0) != -1 else { throw Error() }
return Data(bytes: buf, count: bufLength)
}
}代价与风险:该函数中没有 free(buf)。Data(bytes:count:) 是否复制该缓冲区需结合 SDK 实现确认。若它采用复制语义,这里会每次读取泄漏一块内存,未验证。扩展属性还会因卷格式、权限和沙盒而失败。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
14. 从 IOHIDSystem 读取原始空闲纳秒数
做了什么:代码遍历 IOHIDSystem。代码读取注册表键 HIDIdleTime。代码把读出的数右移 30 位。通知锁定逻辑用该值与超时比较。在线状态还要求该值小于 30 秒。
为什么这样做:NSApp.isActive 不能说明用户是否长期未触碰输入设备。HID 空闲时间允许应用锁和在线状态使用同一个系统级活动信号。
关键代码摘录
int64_t SystemIdleTime(void) {
int64_t idlesecs = -1;
io_iterator_t iter = 0;
if (IOServiceGetMatchingServices(kIOMasterPortDefault,
IOServiceMatching("IOHIDSystem"), &iter) == KERN_SUCCESS) {
io_registry_entry_t entry = IOIteratorNext(iter);
if (entry) {
CFMutableDictionaryRef dict = NULL;
if (IORegistryEntryCreateCFProperties(entry, &dict,
kCFAllocatorDefault, 0) == KERN_SUCCESS) {
CFNumberRef obj = CFDictionaryGetValue(dict, CFSTR("HIDIdleTime"));
if (obj) {
int64_t nanoseconds = 0;
if (CFNumberGetValue(obj, kCFNumberSInt64Type, &nanoseconds)) {
idlesecs = (nanoseconds >> 30);
}
}
CFRelease(dict);
}
IOObjectRelease(entry);
}
IOObjectRelease(iter);
}
return idlesecs;
}代价与风险:HIDIdleTime 是注册表实现细节。源码注释称右移 30 位等于除以 10^9,但两者并不精确相等。超时边界会有约 7% 的偏差。不同硬件或系统可能缺少该键。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 11 项
- 已发布 · 仍可用REA 二进制REA 运行时观测
13.0 二进制:
IOHIDSystem、HIDIdleTime、AppleSmartBattery;导入IOServiceGetMatchingService、IORegistryEntryCreateCFProperty、IOPSCopyPowerSourcesInfomacOS 26.7:启动时
IOServiceGetMatchingService被调用 6 次,调用栈中没有 Telegram 帧结论:已发布
15. 为 M2 后的泛化 hw.model 启动 system_profiler
做了什么:代码不只读 sysctlbyname("hw.model")。它启动 Apple 的 system_profiler。代码请求 JSON 硬件摘要。代码从 machine_name 和 chip_type 合成展示用型号。外部命令失败后才回退到 sysctl。
为什么这样做:源码明确说明 M2 起的 hw.model 可能只给 Mac数字,数字。网络初始化需要更可读的设备名。普通 sysctl 回退不能解决这个型号退化。
关键代码摘录
func deviceFromSystemProfiler() -> String? {
// Starting with MacBook M2 the hw.model returns simply Mac[digits],[digits].
let process = Process()
if #available(macOS 10.13, *) {
process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler")
} else {
process.launchPath = "/usr/sbin/system_profiler"
}
process.arguments = ["-json", "SPHardwareDataType", "-detailLevel", "mini"]
let pipe = Pipe()
process.standardOutput = pipe
do {
if #available(macOS 10.13, *) { try process.run() }
else { process.launch() }
} catch {
return nil
}
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let document = try? JSONSerialization.jsonObject(with: data, options: [])
guard let fields = (document as? [String: Any])?["SPHardwareDataType"] as? [[String: Any]],
!fields.isEmpty else { return nil }代价与风险:waitUntilExit() 会同步等待外部进程。system_profiler 可能很慢。JSON 键名和命令可用性也会变化。失败时会降级为空字符串。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- REA 第 12 项
- 已发布 · 仍可用REA 二进制
13.0 二进制:
/usr/sbin/system_profilermacOS 26.7:未执行
结论:已发布
16. 用双时间戳文件做无信号处理器的崩溃哨兵
做了什么:启动时,代码检查名为 crashhandler 的文件。代码每次中间保存更新时间戳。下次启动时,文件只含一个时间戳,或两个时间戳相差小于 10 秒,都会被视为异常退出。应用代理启动后立即写入一次,并在终止时删除文件。
为什么这样做:这避开了崩溃 signal handler 的不安全上下文。它也不依赖外部崩溃上报服务。普通“是否正常退出”布尔值无法提供启动后的存活时间。
关键代码摘录
public func isCrashedLastTime(_ folder: String) -> Bool {
let url = folder + "/" + "crashhandler"
if let dateString = try? String(contentsOf: URL(fileURLWithPath: url)) {
let components = dateString.components(separatedBy: " ")
if components.count == 2 {
let initedDate = Int32(components[0]) ?? 0
let lastSavedDate = Int32(components[1]) ?? 0
return lastSavedDate - initedDate < 10
} else {
return true
}
}
return FileManager.default.fileExists(atPath: url)
}
public func crashIntermediateDate(_ folder: String) {
let url = folder + "/" + "crashhandler"
if let dateString = try? String(contentsOf: URL(fileURLWithPath: url)) {
let time = "\(Int32(Date().timeIntervalSince1970))"
var components = dateString.components(separatedBy: " ")
if components.count == 2 { components[1] = time }
else if components.count == 1 { components.append(time) }
try? FileManager.default.removeItem(atPath: url)
FileManager.default.createFile(atPath: url,
contents: components.joined(separator: " ").data(using: .utf8), attributes: nil)代价与风险:这是启发式算法,不是崩溃捕获。启动后 10 秒内正常退出会误报。文件写入失败被 try? 忽略。秒级 Int32 还存在 2038 年溢出风险。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
17. 对 Spotlight 做稳定 ID 差分,而非全量重建索引
做了什么:每个 Spotlight 条目 ID 带帐户 ID 和 peer ID。代码维护前一份条目列表。代码计算删除、插入和更新集合。它只把插入和更新交给 indexSearchableItems。它只删除消失的标识符。应用收到 CSSearchableItemActionType 后,再按帐户路由至聊天。
为什么这样做:全量重建会制造不必要的系统索引 I/O。稳定 ID 还能安全地区分多个帐户的同一 peer。普通全量写入会放大频繁搜索历史变化的代价。
关键代码摘录
let signal = combineLatest(queue: .mainQueue(), accountPeer, peers)
disposable.set(signal.start(next: { [weak self] accountPeer, peers in
guard let `self` = self else { return }
// `items` is rebuilt from the current peers.
let (delete, insert, update) = mergeListsStableWithUpdates(
leftList: self.previousItems, rightList: items)
if !insert.isEmpty || !update.isEmpty {
CSSearchableIndex.default().indexSearchableItems(
insert.map { $0.1.item }, completionHandler: nil)
CSSearchableIndex.default().indexSearchableItems(
update.map { $0.1.item }, completionHandler: nil)
}
var deleted: [SpotlightItem] = []
for index in delete.reversed() {
deleted.append(self.previousItems.remove(at: index))
}
self.previousItems = items
if !deleted.isEmpty {
CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: deleted.map { $0.stableId.stringValue }, completionHandler: nil)
}
}))代价与风险:两处上游 distinctUntilChanged 只比较 peer 数量。相同数量但内容变化时,信号可能被抑制。Spotlight 条目因此可能陈旧。该风险在同文件 #L104-L121 可见。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。
18. 把 Dock 徽标字符串当成状态栏图标的输入
做了什么:未读数先写入 NSDockTile.badgeLabel。状态栏图标更新函数随后再读取这个字符串。代码在资源队列生成图像。代码回主队列替换状态栏图片。它也按 macOS 版本和外观选择黑白颜色。
为什么这样做:这让 Dock 和状态栏从同一格式化后的字符串派生数值。它减少两套未读数格式化逻辑。后台生成图像也避免把绘制放在主线程。
关键代码摘录
if dockTile {
NSApplication.shared.dockTile.badgeLabel = result.dockText
forceUpdateStatusBarIconByDockTile(sharedContext: sharedContext)
}
func forceUpdateStatusBarIconByDockTile(sharedContext: SharedAccountContext) {
if let count = Int(NSApplication.shared.dockTile.badgeLabel ?? "0") {
var color: NSColor = .black
if #available(OSX 10.14, *) {
if systemAppearance.name != .aqua {
color = .white
}
}
if #available(OSX 11.0, *) {
color = .white
}
resourcesQueue.async {
let icon = generateStatusBarIcon(count, color: color)
Queue.mainQueue().async {
sharedContext.updateStatusBarImage(icon)
}
}
}
}代价与风险:badgeLabel 是展示属性,不是强类型状态存储。空字符串或非数字会使状态栏更新被跳过。Dock 被其他代码改写时,状态栏会跟随错误值。无明显私有 API 风险。
- 源码分析
- 已在 TelegramSwift 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 本条没有单独核对。REA 专题列出了已核对的 20 项。