Files
gentpan 40b7e41cbf 支持 Intel 芯片:分别打包 Apple 芯片版与 Intel 版,官网按芯片提供两个下载按钮
- Makefile 支持 ARCH=arm64 / x86_64,release.sh 两种芯片各自构建、签名、公证,生成 DMG 与升级包
- 版本清单顶层仍是 Apple 芯片版(兼容 0.3.0),intel 字段放 Intel 版;应用按芯片挑包,安装前核对架构
- Homebrew cask 按 arch 选择安装包
- 官网下载区改为 Apple 芯片 / Intel 两张卡片,README 安装段落与常见问题同步
2026-09-15 16:21:29 +05:00

231 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoKit
import Foundation
import Localization
import Security
public enum UpdateError: Error, LocalizedError, Equatable {
case download(String)
case checksumMismatch
case archive(String)
case invalidBundle(String)
case signature(String)
case gatekeeper
case install(String)
public var errorDescription: String? {
switch self {
case .download(let reason): tr("下载失败:\(reason)")
case .checksumMismatch: tr("安装包校验值与版本清单不一致,已放弃安装")
case .archive(let reason): tr("解压失败:\(reason)")
case .invalidBundle(let reason): tr("安装包内容不正确:\(reason)")
case .signature(let reason): tr("签名校验未通过:\(reason)")
case .gatekeeper: tr("新版本未通过 Apple 公证检查,已放弃安装")
case .install(let reason): tr("替换应用失败:\(reason)")
}
}
}
/// 在线升级的各个步骤:下载 → 校验 sha256 → 解压 → 校验包名 / 版本 / 签名团队 / 公证 → 原地替换 → 重启
public enum UpdateInstaller {
// MARK: 下载
public static func download(_ url: URL, into directory: URL,
progress: @escaping @Sendable (Double) -> Void) async throws -> URL {
let destination = directory.appendingPathComponent(url.lastPathComponent)
try await Downloader(destination: destination, progress: progress).run(url)
return destination
}
public static func sha256(of file: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: file)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
// MARK: 解压
/// ditto 解压,保留包内符号链接与扩展属性;返回解出的 .app
public static func extractApp(from archive: URL, into directory: URL) throws -> URL {
let output = directory.appendingPathComponent("extracted", isDirectory: true)
try? FileManager.default.removeItem(at: output)
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let result = run("/usr/bin/ditto", ["-x", "-k", archive.path, output.path])
guard result.status == 0 else { throw UpdateError.archive(result.output) }
let apps = (try? FileManager.default.contentsOfDirectory(at: output, includingPropertiesForKeys: nil))?
.filter { $0.pathExtension == "app" } ?? []
guard apps.count == 1, let app = apps.first else { throw UpdateError.archive(tr("压缩包里应当只有一个 .app")) }
return app
}
// MARK: 校验
/// 包名、版本号、签名团队必须与预期一致,并通过严格签名校验与 Gatekeeper(公证)检查
public static func verify(_ app: URL, bundleIdentifier: String, version: String, teamIdentifier: String,
architecture: UpdateArchitecture = .current) throws {
guard let bundle = Bundle(url: app) else { throw UpdateError.invalidBundle(tr("无法读取")) }
guard bundle.bundleIdentifier == bundleIdentifier else {
throw UpdateError.invalidBundle(tr("包名是 \(bundle.bundleIdentifier ?? tr("空"))"))
}
let bundleVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
guard bundleVersion == version else {
throw UpdateError.invalidBundle(tr("版本是 \(bundleVersion ?? tr("空")),清单写的是 \(version)"))
}
// Apple 芯片版与 Intel 版分开发布,装错了应用根本打不开
guard bundle.executableArchitectures?.contains(where: { $0.intValue == architecture.executableArchitecture }) == true else {
throw UpdateError.invalidBundle(tr("安装包不支持这台 Mac 的芯片"))
}
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else {
throw UpdateError.signature(tr("无法读取签名"))
}
let requirementText = "anchor apple generic and identifier \"\(bundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
var requirement: SecRequirement?
guard SecRequirementCreateWithString(requirementText as CFString, [], &requirement) == errSecSuccess else {
throw UpdateError.signature(tr("无法创建签名要求"))
}
let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate)
let status = SecStaticCodeCheckValidity(code, flags, requirement)
guard status == errSecSuccess else {
throw UpdateError.signature(tr("不是团队 \(teamIdentifier) 签名的完整应用(\(status))"))
}
let assessment = run("/usr/sbin/spctl", ["--assess", "--type", "execute", "-vv", app.path])
guard assessment.status == 0, assessment.output.contains("accepted") else { throw UpdateError.gatekeeper }
}
/// 应用自身的签名团队;开发构建(未签名或临时签名)返回 nil
public static func teamIdentifier(of app: URL) -> String? {
var staticCode: SecStaticCode?
guard SecStaticCodeCreateWithPath(app as CFURL, [], &staticCode) == errSecSuccess, let code = staticCode else { return nil }
var info: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess,
let dictionary = info as? [String: Any] else { return nil }
return dictionary[kSecCodeInfoTeamIdentifier as String] as? String
}
// MARK: 替换
/// 旧版先改名为备份,再把新版移到原位置;移动失败时还原旧版。两者在同一卷上,改名是原子的。
public static func replace(_ current: URL, with candidate: URL, backupDirectory: URL) throws {
let manager = FileManager.default
let backup = backupDirectory.appendingPathComponent("previous-\(current.lastPathComponent)")
try? manager.removeItem(at: backup)
do {
try manager.moveItem(at: current, to: backup)
} catch {
throw UpdateError.install(error.localizedDescription)
}
do {
try manager.moveItem(at: candidate, to: current)
} catch {
try? manager.moveItem(at: backup, to: current)
throw UpdateError.install(error.localizedDescription)
}
try? manager.removeItem(at: backup)
}
/// 当前用户能否直接改写应用所在目录(管理员账户对 /Applications 通常可以)
public static func canReplaceInPlace(_ app: URL) -> Bool {
let parent = app.deletingLastPathComponent().path
return FileManager.default.isWritableFile(atPath: parent) && FileManager.default.isWritableFile(atPath: app.path)
}
/// 等当前进程退出后重新打开应用。路径与 PID 作为位置参数传入,不拼进脚本
public static func relaunch(_ app: URL, afterExitOf pid: Int32 = ProcessInfo.processInfo.processIdentifier) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.2; done; /usr/bin/open \"$2\"",
"openstats-relaunch", String(pid), app.path]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
}
// MARK: 工具
@discardableResult
static func run(_ executable: String, _ arguments: [String]) -> (status: Int32, output: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
return (-1, error.localizedDescription)
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (process.terminationStatus, String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines))
}
}
/// 带进度的下载:系统把文件下载到临时位置,完成回调里立即移到目标位置
private final class Downloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
private let destination: URL
private let progress: @Sendable (Double) -> Void
private let lock = NSLock()
private var continuation: CheckedContinuation<Void, Error>?
private var moveError: Error?
init(destination: URL, progress: @escaping @Sendable (Double) -> Void) {
self.destination = destination
self.progress = progress
}
func run(_ url: URL) async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 60
configuration.timeoutIntervalForResource = 15 * 60
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
lock.withLock { self.continuation = continuation }
session.downloadTask(with: url).resume()
}
} onCancel: {
session.invalidateAndCancel()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64,
totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
progress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
if let response = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) {
moveError = UpdateError.download(tr("服务器返回 \(response.statusCode)"))
return
}
do {
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)
} catch {
moveError = error
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let continuation = lock.withLock { () -> CheckedContinuation<Void, Error>? in
defer { self.continuation = nil }
return self.continuation
}
if let error = error ?? moveError {
let message = (error as? UpdateError)?.errorDescription ?? error.localizedDescription
continuation?.resume(throwing: error as? UpdateError ?? UpdateError.download(message))
} else {
continuation?.resume()
}
}
}