肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

处理copilot update更新缓慢问题获得的两个意外收获

大模型

今早发现copilot update更新的时候突然变慢了,速度只有10KB/s,于是跑去找ChatGPT问了问,为什么我终端已经设置了all_proxy,但是copilot update的时候却没有使用?

ChatGPT和我说,这是因为copilot这种有时候只会使用环境变量,我需要设置的是http_proxy和https_proxy。我设置好之后,更新了copilot,这下有500KB/s的速度了。

两个意外收获

再次打开copilot,对话后我发现,默认的模型变成sonnet-4.6了。我很意外!因为我是中国用户的原因,很久以前就没法使用任何Claude的模型,只能使用OpenAI的。之前我虽然听说如果VPN开启全局模式也能做到,但是我嫌麻烦,就没有弄。

此外,我还发现,现在copilot响应速度快了很多。之前我就觉得copilot的gpt-5.4比OpenAI Codex app里的慢很多。我还以为是copilot的问题。因为我问ChatGPT为啥copilot比Codex的慢,它还给我解释的头头是道,什么copilot搞了负载均衡,额外会加载其他内容,还有Codex可能是独享的,copilot的是共享的。不过从这次改变可以知道,其实原因并不是ChatGPT说的那样,就是因为原本的代理设置不被copilot支持导致的。我们应该警惕大模型的回复,有的时候它说的也不一定对。

中国开发者测试Google Play应用内购买的正确方法

Android

作为一名苦逼的中国开发者,我们要想测试发布到Google Play的商店,会收到中美双方的排挤。本文是我通过调查资料,最终总结的一个我认为最适合的方式,希望后来者可以少走弯路。

开发者账号

你需要一个开发者账户,一个测试账号。这是因为作为收钱的开发者账户,我们需要使用自己的真实信息,这样才能通过Google的开发者账户的验证。因此,开发者账户是中国账户,加上单Visa的信用卡即可,我使用的是招商银行的单Visa的全币卡。你还需要找银行要一下对账单,准备好身份证,这些都是Google验证时需要的。

测试账户

测试账户需要新建一个美国账户。感谢AI,ChatGPT告诉我,Google账户是根据你注册时的IP来判断你属于哪个国家的。所以,我们主要开启一个美国的VPN,然后注册就可以。特别的,注册时会要求手机号来验证。很多人担心能否用中国的手机号来验证,没问题的。因为这个只是验证你是真人,不作为判断国别的依据。因此,只要你注册时的IP是美国,以后也一直使用这个IP,就没问题。

此外,由于我们的测试账户只是为了测试。并且我们也没有美国的信用卡用来支付,所以我们的账户将不绑定任何信用卡。以免因为绑定了中国的信用卡而导致账户被风控。

测试应用

  1. 上传应用到Google Play
  2. 选择内部测试
  3. 然后在内部测试中,创建一个新的组,将测试账户的邮箱添加上去
  4. 复制定下的链接,用手机登录测试账户,打开这个链接
  5. 安装测试应用,就可以测试了。

其他问题

如果测试时,在选择“快速卡,一直通过”之后遇到问题,那是因为你的VPN选择的是规则,导致Play商店和Google服务的IP不一致。解决办法就是关掉应用,然后将VPN的规则改为全局,然后重新测试。这样就可以了。

最后

我们采用这种方式,是没办法的的办法。因为我们中国的开发者账户直接用来测试,经常会遇到“设备或者账户不支持支付”这类的问题。那样的话,我们就没有办法测试应用内购买是否正确了。

第三方应用使用DocumentGroup时,模仿Pages的工具栏

SwiftUI

最近,我在开发iOS的Markdown应用的时候遇到一个问题,应用是使用DocumentGroup开发的,但是默认会显示标题,从而导致留给工具栏的空间太小,工具栏的图标经常被折叠起来。

IMG_1181

我一开始只是想把后退去掉,换成三个点的菜单,然后去掉标题。结果发现后退去不掉。于是我就想看看苹果自己是如何做的。于是我打开了苹果自己的Notes和Numbers。不过Notes其实是有内部库的,于是主要参考的Numbers。

IMG_1184

我把Numbers截图给AI,说想要弄一个这个风格的。结果AI搞不定。它虽然设置了标题为空,但是标题始终显示。于是我有把这个描述给Grok,Grok说,这是因为DocumentGroup自动包含了一层Navigation,你需要将它禁用。结果我禁用了,还是不行。又去问Grok,它又说,这是因为iOS的某些版本,有bug,不执行这个操作。但是网友总结了三种办法,1,2,3,然后挨个试。结果第一种就可以了。

这个是我应用最终的效果。

IMG_1205

最核心的代码只需要如下的部分。

截屏2026-04-12 10.50.33

Swift Package应用签名与公证实战总结

Swift Packages

问题背景

fork 了一个第三方 macOS 应用,原 release 版本未签名,但提供了源码,为Swift Package格式,需要重新签名并公证后分发。


遇到的坑

1. Swift Package Manager 无法启用 Hardened Runtime

问题:使用 swift build 构建的可执行文件,无法通过签名添加 hardened runtime。公证时一直报错:

The executable does not have the hardened runtime enabled.

原因:Swift Package Manager 构建时不会启用 hardened runtime,签名时添加也不被认可。

解决:使用 XcodeGen 生成 Xcode 项目,在项目配置中启用。

2. Apple Development 证书无法公证

问题:最初只有 Apple Development 证书(用于开发调试),公证时报错:

The binary is not signed with a valid Developer ID certificate.

原因:公证必须使用 Developer ID Application 证书,不能用 Apple Development。

解决:在 Apple Developer 账号中添加 Developer ID Application 证书。

3. 时间戳问题

问题:签名缺少安全时间戳:

The signature does not include a secure timestamp.

解决:在签名配置中添加 --timestamp 参数。

4. get-task-allow entitlement 问题

问题:分发版本包含开发用 entitlement:

The executable requests the com.apple.security.get-task-allow entitlement.

解决:在 entitlements 配置中明确设置 com.apple.security.get-task-allow: false


经验教训

证书类型

证书类型 用途
Apple Development 开发调试,不能公证
Developer ID Application 分发和公证 ✅

签名配置要点

  1. Hardened Runtime - 必须启用
  2. 时间戳 - 必须添加
  3. entitlements - 分发版本禁止包含 get-task-allow
  4. 使用 Manual 签名 - 避免自动签名冲突

最佳实践(简单有效)

1. 使用 Xcode 项目而非 SPM

# 安装 XcodeGen
brew install xcodegen

# 配置 project.yml(关键设置)
targets:
  YourApp:
    settings:
      base:
        ENABLE_HARDENED_RUNTIME: YES
        CODE_SIGN_STYLE: Manual
        CODE_SIGN_IDENTITY: "Developer ID Application: Your Name (TEAM_ID)"
        OTHER_CODE_SIGN_FLAGS: "--timestamp"
    entitlements:
      properties:
        com.apple.security.get-task-allow: false

# 生成项目
xcodegen generate

# 构建
xcodebuild -project YourApp.xcodeproj -scheme YourApp -configuration Release build

2. 验证签名

codesign -dvv YourApp.app/Contents/MacOS/YourApp

确认输出包含:

  • flags=0x10000(runtime) - hardened runtime 已启用
  • Timestamp=... - 有时间戳

3. 公证流程

# 1. 创建 zip
zip -r YourApp.zip YourApp.app

# 2. 提交公证(需要 App 专用密码)
xcrun notarytool submit YourApp.zip \
  --apple-id "your@email.com" \
  --password "app-specific-password" \
  --team-id "TEAM_ID"

# 3. 等待完成
xcrun notarytool wait <submission-id> --apple-id "your@email.com" --password "password" --team-id "TEAM_ID"

# 4. 附加票据
xcrun stapler staple YourApp.app

总结

最简单有效的方式

  1. XcodeGen 生成 Xcode 项目(不要用纯 SPM)
  2. project.yml 中配置好签名和 hardened runtime
  3. xcodebuild 构建
  4. notarytool 公证

不要试图用 swift build + 手动签名的方式来处理需要公证的分发版本,SPM 的构建产物不兼容。

NavigationSplitView的一些奇技淫巧

SwiftUI

Sidebar按钮隐藏起来的正确做法

使用

.toolbar(removing: .sidebarToggle)

可以隐藏侧边栏切换按钮。但是注意,这个不是放在作用在NavigationSplitView上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用:

NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

隐藏Sidebar的正确做法

NavigationSplitView是三栏的,但是如果你要使用两栏,则有两种方式。

使用sidebar

使用sidebar的好处是,sidebar可以隐藏,也可以显示。
使用sidebar+detail的方式。

不使用sidebar

如果你希望永远显示为两栏,不希望隐藏任何一个栏隐藏,那么使用此方式。

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

这个的重点在于,通过sidebar设置隐藏了切换sidebar按钮。同时,选择两栏模式,默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。

配合Settings使用

在使用Settings时候,包含sidebar的NavigationSplitView的sidebar按钮会导致出现问题。因此,必须使用上面的第二种方式,隐藏sidebar,并且使用一直存在的两栏。

macOS在工具栏上切换页面,TabView和Picker怎么选?

macOS

目前的SwiftUI,如果你希望通过工具栏的按钮直接切换标签,可以使用TabView,也可以使用Picker,不过在细节上,二者有所不同。

TabView

TabView是最简单的方式,只要你应用的根视图为TabView,那么你的系统架构会自动转成Navigation Tab Bar的方式,自动在工具栏显示Tab的标签。

不过这个方式有一种缺陷,就是应用的标题不会在工具栏上显示。如果你想强制限制,必须引入AppKit然后,覆盖Window的相应设置。

Picker

使用Picker的方式则更为友好。之需要在toolbar里添加Picker,然后使用segment样式,就可以获得同样的显示效果。然后使用Switch切换视图即可。

这么做不如直接使用TabView简单,但是可定制化强,并且不会影响标题的显示。

结论

如果不需要设置标题栏,使用TabView更为简便。否则就使用Picker,这样效果更好。

macOS系统菜单栏显示多图标的两种方式

macOS

一直以来都是使用的菜单栏单图标的方式。今天心血来潮,想在Focus原本的图标旁边新增一个刷新的图标,可以用来快速重置计时器。

把任务分配给AI,AI很快完成了第一版。我运行一看,怎么没看到新图标,再仔细一找,的确是两个图标了,但是两个图标是各自独立的,彼此之间还间隔了几个其它的图标。这和我看到的不一样,我其实希望的是像音乐播放器那样的,几个图标一体的方式。于是我和AI说明了要一体的。AI表示了解,然后重新生成了代码。AI特意解释说,一体之后,点击时就需要用点击的位置进行二次判断,还确定用户点击的是哪个图标。

最终结果我实验了一下,的确和音乐播放器的一样。看来他们也是这么做的。

SwiftUI应用伴随系统登录自动启动后显示macOS应用的窗口的办法

macOS

我们知道SwiftUI应用本身没有应用窗口的概念,它是使用WindowGroup来自动管理窗口的。这在用户手动启动应用的时候没有问题。但是如果你设置了应用伴随系统登录后自动启动后,SwiftUI的应用会存在一些问题。

这是因为这种方式启动应用后,SwiftUI的应用不会主动创建窗口,视图会在用户手动点击应用之后才创建。而这可能不是我们所需要的。因为如果我们有使用onAppear来执行一些代码。我们实际上是希望代码可以在应用启动时就运行,而这个机制会导致执行会推迟到用户点击时。

因此,我们需要保证即便SwiftUI没有生成Window,也要自己主动来生成Window。

问题分析

要解决这个问题,我们首先要了解这个启动的整个过程,然后才能知道如何来改进。具体调试的过程我就不讲了,最终我确认启动的过程是这样的:

  1. 用户登录。
  2. 应用启动。
  3. 应用在后台启动,但是没有主窗口,因此无法自动切换到前台。

我们可以使用NSApplication.shared.windows.count来判断。如果是用户手动打开的应用,SwiftUI会创建SwiftUI.AppKitWindow的窗口。而如果是跟随系统登录后后台启动,则不会有这个窗口。

我使用的判断函数是这个:

func isLaunchedAtLogin() -> Bool {
  NSApplication.shared.windows.count < 2
}

之所以用2,而不是1判断。是因为我还使用了菜单栏图标,菜单栏图标使用的是NSStatusItem,因此还会包含一个叫NSStatusWindow的窗口。

解决方案

func applicationDidFinishLaunching(_ notification: Notification) {
    createContentViewWindow()
}

/// 用户启动时SwiftUI的窗口会先创建,因此window不会为nil,但是有可能存在延迟,因为是通过.updateWindow还获取的。
/// 所以这里使用其他的方式进行判断,而不是使用window是否为nil
private func createContentViewWindow() {
  /// 若是用户点击,会有两个窗口,一个是SwiftUI创建的主窗口,一个status窗口。后者应该是对应菜单栏图标的。
  /// 如果是伴随系统启动,则SwiftUI创建的主窗口不存在,只有status的窗口。
  func isLaunchedAtLogin() -> Bool {
    NSApplication.shared.windows.count < 2
  }

  if isLaunchedAtLogin() {
    let contentView = ContentView()
      .environment(\.managedObjectContext, ModelProvider.shared.container.viewContext)
    let window = NSWindow(
      contentRect: NSRect(x: 0, y: 0, width: 400, height: 240),
      styleMask: [.titled, .closable, .miniaturizable, .resizable],
      backing: .buffered, defer: false)

    window.setFrameAutosaveName("Main Window")
    window.contentView = NSHostingView(rootView: contentView)
    window.center()
    window.makeKeyAndOrderFront(nil)
    self.window = window
  }
}

小结

传统上,如果我们使用loginItem来实现应用伴随系统启动,那么为了区分是用户手动启动,还是伴随系统启动,需要使用传递参数的方式。但是传递参数,就需要使用额外的launcher辅助应用。

设置辅助应用的步骤是很复杂的。因此,我们现在大多都是直接使用下面的代码来直接使用自动伴随系统登录启动。

try SMAppService.mainApp.register()

这个办法虽然大大简化了设置系统启动后启动应用的步骤。但是这么做之后,由于没有辅助应用,也就没法使用传递参数的办法了。

本文给出的使用NSApplication.shared.windows.count,用窗口数量来间接判断的方式,利用了SwiftUI在后台启动后,不会主动创建窗口的特性,解决了这个问题。