From c12f196b73262997afd90bfdcd78835654697c46 Mon Sep 17 00:00:00 2001 From: JerrySmith Date: Thu, 20 Aug 2026 22:30:24 +0800 Subject: [PATCH 1/2] fix(macOS): use static release update manifest --- .github/workflows/ci-macos.yml | 3 + .github/workflows/release-macos.yml | 49 ++- .github/workflows/release-windows.yml | 22 + Resources/zh-Hans.lproj/Localizable.strings | 12 + .../Updates/MacUpdateNetworkTransport.swift | 110 +++++ .../MacOS/Updates/UpdateChecker.swift | 415 +++++++----------- .../MacOS/Updates/UpdateManifest.swift | 172 ++++++++ Sources/Lithe/Views/App/RootView.swift | 2 +- Sources/Lithe/Views/App/SettingsView.swift | 5 +- Tests/LitheTests/AppLocalizationTests.swift | 26 ++ Tests/LitheTests/UpdateCheckerTests.swift | 330 ++++++++++++++ scripts/create-macos-update-manifest.rb | 72 +++ scripts/test-macos-update-manifest.rb | 70 +++ 13 files changed, 1010 insertions(+), 278 deletions(-) create mode 100644 Sources/Lithe/Platform/MacOS/Updates/MacUpdateNetworkTransport.swift create mode 100644 Sources/Lithe/Platform/MacOS/Updates/UpdateManifest.swift create mode 100644 Tests/LitheTests/UpdateCheckerTests.swift create mode 100755 scripts/create-macos-update-manifest.rb create mode 100755 scripts/test-macos-update-manifest.rb diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 4a9f185a7..6b02d55d9 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -207,6 +207,9 @@ jobs: fi done ruby -c scripts/update-homebrew-cask.rb + ruby -c scripts/create-macos-update-manifest.rb + ruby -c scripts/test-macos-update-manifest.rb + ruby scripts/test-macos-update-manifest.rb plutil -lint Resources/Info.plist - name: Build release configuration diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 2308d5a9f..10fea79c1 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -15,7 +15,8 @@ permissions: contents: read concurrency: - group: release-macos-${{ github.ref }} + # Both platform workflows update the same Release and latest.json asset. + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} cancel-in-progress: false jobs: @@ -177,7 +178,29 @@ jobs: (cd dist && shasum -a 256 -c "Lithe-${LITHE_VERSION}-${architecture}.dmg.sha256") done - - name: Create GitHub Release + - name: Generate macOS update manifest + env: + GH_TOKEN: ${{ github.token }} + LITHE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + merge_args=() + existing_dir="$RUNNER_TEMP/existing-update-manifest" + mkdir -p "$existing_dir" + if gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "latest.json" \ + --dir "$existing_dir" >/dev/null 2>&1; then + merge_args+=(--merge-manifest "$existing_dir/latest.json") + fi + ruby scripts/create-macos-update-manifest.rb \ + --version "$LITHE_VERSION" \ + --repository "$GITHUB_REPOSITORY" \ + --release-tag "$RELEASE_TAG" \ + "${merge_args[@]}" + ruby -rjson -e 'manifest = JSON.parse(File.read("dist/latest.json")); abort "Invalid schema" unless manifest["schemaVersion"] == 1' + + - name: Create or update GitHub Release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} @@ -185,24 +208,32 @@ jobs: shell: bash run: | notes_file="docs/releases/v${LITHE_VERSION}.md" - release_args=( + create_args=( "$RELEASE_TAG" + --repo "$GITHUB_REPOSITORY" + --target "$GITHUB_SHA" + --title "Lithe $RELEASE_TAG" + ) + upload_args=( "dist/Lithe-${LITHE_VERSION}-arm64.dmg" "dist/Lithe-${LITHE_VERSION}-arm64.dmg.sha256" "dist/Lithe-${LITHE_VERSION}-x86_64.dmg" "dist/Lithe-${LITHE_VERSION}-x86_64.dmg.sha256" - --repo "$GITHUB_REPOSITORY" - --target "$GITHUB_SHA" - --title "Lithe $RELEASE_TAG" + "dist/latest.json" ) if [[ -f "$notes_file" ]]; then - release_args+=(--notes-file "$notes_file") + create_args+=(--notes-file "$notes_file") else - release_args+=(--generate-notes) + create_args+=(--generate-notes) fi - gh release create "${release_args[@]}" + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "${create_args[@]}" || \ + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + fi + gh release upload "$RELEASE_TAG" "${upload_args[@]}" \ + --repo "$GITHUB_REPOSITORY" --clobber update-cask: name: Update Homebrew Cask diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 6cd408224..1d08ea27c 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -14,6 +14,11 @@ on: permissions: contents: read +concurrency: + # Both platform workflows update the same Release and latest.json asset. + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} + cancel-in-progress: false + jobs: release: name: Build and publish Lithe for Windows @@ -145,6 +150,23 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Could not create or find release $env:RELEASE_TAG" } } } + + $existingManifestDirectory = Join-Path $env:RUNNER_TEMP "lithe-existing-update-manifest" + New-Item -ItemType Directory -Force -Path $existingManifestDirectory | Out-Null + gh release download $env:RELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --pattern "latest.json" ` + --dir $existingManifestDirectory 2>$null + if ($LASTEXITCODE -eq 0) { + $existingManifest = Get-Content -LiteralPath (Join-Path $existingManifestDirectory "latest.json") -Raw | ConvertFrom-Json + if ($existingManifest.schemaVersion -eq 1) { + $windowsManifest = Get-Content -LiteralPath "dist/latest.json" -Raw | ConvertFrom-Json + foreach ($property in @("schemaVersion", "releaseURL", "assets")) { + $windowsManifest | Add-Member -NotePropertyName $property -NotePropertyValue $existingManifest.$property -Force + } + $windowsManifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath "dist/latest.json" -Encoding utf8 + } + } gh release upload $env:RELEASE_TAG ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe" ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe.sha256" ` diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 0ba7cedec..8fc8a07a4 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -641,6 +641,18 @@ "No release is available yet" = "暂时没有可用版本"; "Could not check for updates" = "无法检查更新"; "There is no published GitHub Release to check yet." = "目前还没有可检查的 GitHub Release。"; +"GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets." = "GitHub 因共享 API 配额已耗尽而拒绝了更新请求。请打开 Release 页面,或等待配额重置后重试。"; +"The update server returned HTTP %@. Open the Release page to download the update manually, or try again later." = "更新服务器返回 HTTP %@。请打开 Release 页面手动下载,或稍后重试。"; +"The update request timed out. Check your proxy or VPN connection and try again." = "更新请求超时。请检查代理或 VPN 连接后重试。"; +"A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "无法与 GitHub 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。"; +"GitHub could not be reached. Check your internet, proxy, or VPN connection and try again." = "无法连接 GitHub。请检查网络、代理或 VPN 连接后重试。"; +"The update server returned an unexpected response. Open the Release page and download the update manually." = "更新服务器返回了异常响应。请打开 Release 页面手动下载更新。"; +"The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。"; +"This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "此版本的 Lithe 无法读取版本为 %@ 的更新清单格式。请打开 Release 页面手动更新。"; +"No update package is available for this Mac architecture. Open the Release page to check available downloads." = "没有适用于此 Mac 架构的更新包。请打开 Release 页面查看可用下载。"; +"The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。"; +"The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "无法下载更新包。请检查网络、代理或 VPN 连接后重试。"; +"macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。"; "Check your internet connection and try again later." = "请检查网络连接,稍后再试。"; "No Java navigation results" = "没有 Java 导航结果"; "Choose Implementation" = "选择实现"; diff --git a/Sources/Lithe/Platform/MacOS/Updates/MacUpdateNetworkTransport.swift b/Sources/Lithe/Platform/MacOS/Updates/MacUpdateNetworkTransport.swift new file mode 100644 index 000000000..a5f2a3b78 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Updates/MacUpdateNetworkTransport.swift @@ -0,0 +1,110 @@ +import Foundation + +struct UpdateHTTPResponse: Sendable { + let statusCode: Int + let headers: [String: String] + let body: Data + + func header(named name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +protocol UpdateNetworkTransport: Sendable { + func fetch(_ request: URLRequest) async throws -> UpdateHTTPResponse + /// Returns a temporary file whose ownership transfers to the caller. + func download( + _ request: URLRequest, + progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void + ) async throws -> URL +} + +enum UpdateTransportError: Error { + case invalidResponse +} + +final class MacUpdateNetworkTransport: UpdateNetworkTransport, @unchecked Sendable { + private let session: URLSession + private let fileManager: FileManager + + init(session: URLSession = .shared, fileManager: FileManager = .default) { + self.session = session + self.fileManager = fileManager + } + + func fetch(_ request: URLRequest) async throws -> UpdateHTTPResponse { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw UpdateTransportError.invalidResponse + } + return UpdateHTTPResponse( + statusCode: response.statusCode, + headers: response.allHeaderFields.reduce(into: [:]) { headers, entry in + headers[String(describing: entry.key)] = String(describing: entry.value) + }, + body: data + ) + } + + func download( + _ request: URLRequest, + progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void + ) async throws -> URL { + let (bytes, response) = try await session.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw UpdateTransportError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw UpdateCheckError.httpStatus(httpResponse.statusCode) + } + + let totalBytes = response.expectedContentLength > 0 + ? response.expectedContentLength + : nil + let destination = fileManager.temporaryDirectory + .appendingPathComponent("lithe-update-\(UUID().uuidString).dmg") + guard fileManager.createFile(atPath: destination.path, contents: nil) else { + throw UpdateCheckError.downloadFailed + } + + do { + let handle = try FileHandle(forWritingTo: destination) + defer { try? handle.close() } + + var buffer = Data() + buffer.reserveCapacity(64 * 1024) + var downloadedBytes: Int64 = 0 + var lastProgressUpdate = Date.distantPast + + for try await byte in bytes { + buffer.append(byte) + downloadedBytes += 1 + + if buffer.count >= 64 * 1024 { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + let now = Date() + if now.timeIntervalSince(lastProgressUpdate) >= 0.05 { + lastProgressUpdate = now + await progress(UpdateDownloadProgress( + downloadedBytes: downloadedBytes, + totalBytes: totalBytes + )) + } + } + } + + if !buffer.isEmpty { + try handle.write(contentsOf: buffer) + } + await progress(UpdateDownloadProgress( + downloadedBytes: downloadedBytes, + totalBytes: totalBytes + )) + return destination + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + } +} diff --git a/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift b/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift index 96f6741c6..5dc717d08 100644 --- a/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift +++ b/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift @@ -2,40 +2,6 @@ import AppKit import CryptoKit import Foundation -struct GitHubReleaseAsset: Decodable, Sendable { - let name: String - let browserDownloadURL: URL - let digest: String? - - enum CodingKeys: String, CodingKey { - case name - case browserDownloadURL = "browser_download_url" - case digest - } -} - -struct GitHubRelease: Decodable, Sendable { - let tagName: String - let htmlURL: URL - let name: String? - let prerelease: Bool - let draft: Bool - let assets: [GitHubReleaseAsset] - - var displayVersion: String { - tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName - } - - enum CodingKeys: String, CodingKey { - case tagName = "tag_name" - case htmlURL = "html_url" - case name - case prerelease - case draft - case assets - } -} - struct UpdateNotice: Identifiable { let id = UUID() let title: String @@ -93,7 +59,35 @@ enum UpdateStatus: Equatable { case installing(version: String) case upToDate(version: String) case noRelease - case failed + case failed(message: String) +} + +struct UpdateEndpointConfiguration: Equatable { + static let productionManifestURL = URL( + string: "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest.json" + )! + + let manifestURL: URL + let allowsLocalHTTP: Bool + + static let production = UpdateEndpointConfiguration( + manifestURL: productionManifestURL, + allowsLocalHTTP: false + ) + + init(manifestURL: URL, allowsLocalHTTP: Bool = false) { + self.manifestURL = manifestURL + self.allowsLocalHTTP = allowsLocalHTTP + } + + static func isLoopbackHost(_ host: String) -> Bool { + switch host.lowercased() { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } + } } @MainActor @@ -104,17 +98,44 @@ final class UpdateChecker: ObservableObject { @Published private(set) var updatePrompt: UpdatePrompt? @Published private(set) var status: UpdateStatus = .idle - private static let latestReleaseURL = URL(string: "https://api.github.com/repos/1lck/Lithe-IDEA/releases/latest")! private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60 private static let lastAutomaticCheckKey = "lithe.update.lastAutomaticCheck" + private static let releasePageURL = URL(string: "https://github.com/1lck/Lithe-IDEA/releases/latest")! let currentVersion: String var isBusy: Bool { isChecking || isInstalling } - private var latestRelease: GitHubRelease? + private let transport: any UpdateNetworkTransport + private let preferences: UserDefaults + private let now: () -> Date + private let architecture: UpdateArchitecture? + private let endpoint: UpdateEndpointConfiguration + private var availableManifest: UpdateManifest? + private var availableAsset: UpdateManifestAsset? init(bundle: Bundle = .main) { currentVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + transport = MacUpdateNetworkTransport() + preferences = .standard + now = Date.init + architecture = .current + endpoint = .production + } + + init( + currentVersion: String, + transport: any UpdateNetworkTransport, + preferences: UserDefaults, + now: @escaping () -> Date = Date.init, + architecture: UpdateArchitecture? = .current, + endpoint: UpdateEndpointConfiguration = .production + ) { + self.currentVersion = currentVersion + self.transport = transport + self.preferences = preferences + self.now = now + self.architecture = architecture + self.endpoint = endpoint } func checkForUpdates(manual: Bool = false) async { @@ -123,29 +144,29 @@ final class UpdateChecker: ObservableObject { isChecking = true status = .checking - latestRelease = nil + availableManifest = nil + availableAsset = nil updatePrompt = nil if manual { notice = nil } defer { isChecking = false } - if !manual { - UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey) - } - do { - let release = try await fetchLatestRelease() - guard !release.draft, !release.prerelease else { - status = .noRelease - return + let manifest = try await fetchLatestManifest() + guard let architecture else { throw UpdateCheckError.noCompatibleAsset } + let asset = try manifest.asset(for: architecture) + + if !manual { + preferences.set(now(), forKey: Self.lastAutomaticCheckKey) } - if isNewer(release.displayVersion, than: currentVersion) { - latestRelease = release - status = .available(version: release.displayVersion, url: release.htmlURL) + if UpdateVersion.isNewer(manifest.version, than: currentVersion) { + availableManifest = manifest + availableAsset = asset + status = .available(version: manifest.version, url: manifest.releaseURL) updatePrompt = UpdatePrompt( - title: "Lithe \(release.displayVersion) is available", + title: "Lithe \(manifest.version) is available", message: "Lithe will download the update and restart after replacing the current app.", - releaseURL: release.htmlURL + releaseURL: manifest.releaseURL ) } else if manual { status = .upToDate(version: currentVersion) @@ -167,12 +188,13 @@ final class UpdateChecker: ObservableObject { ) } } catch { - status = .failed + let updateError = normalizedError(error) + status = .failed(message: updateError.userMessage) if manual { notice = UpdateNotice( title: "Could not check for updates", - message: "Check your internet connection and try again later.", - action: .dismiss + message: updateError.userMessage, + action: .open(Self.releasePageURL) ) } } @@ -180,7 +202,8 @@ final class UpdateChecker: ObservableObject { func installAvailableUpdate() async { guard !isBusy, - let release = latestRelease, + let manifest = availableManifest, + let asset = availableAsset, case .available(let version, _) = status else { return } isInstalling = true @@ -190,117 +213,33 @@ final class UpdateChecker: ObservableObject { defer { isInstalling = false } do { - let asset = try updateAsset(for: release) - let downloadedURL: URL + var request = URLRequest(url: asset.url) + request.setValue("Lithe/\(currentVersion)", forHTTPHeaderField: "User-Agent") let updateChecker = self - do { - downloadedURL = try await Self.downloadUpdate( - from: asset.browserDownloadURL, - userAgent: "Lithe/\(currentVersion)", - progress: { progress in - await MainActor.run { - updateChecker.status = .downloading(version: version, progress: progress) - } + let downloadedURL = try await transport.download( + request, + progress: { progress in + await MainActor.run { + updateChecker.status = .downloading(version: version, progress: progress) } - ) - } catch { - if error is UpdateCheckError { - throw error } - throw UpdateCheckError.downloadFailed - } + ) + defer { try? FileManager.default.removeItem(at: downloadedURL) } try verify(downloadedFile: downloadedURL, against: asset) status = .installing(version: version) try scheduleReplacement(with: downloadedURL, version: version) } catch { - status = .failed + let updateError = normalizedError(error, fallback: .downloadFailed) + status = .failed(message: updateError.userMessage) notice = UpdateNotice( title: "Could not install update", - message: userMessage(for: error), - action: .dismiss + message: updateError.userMessage, + action: .open(manifest.releaseURL) ) } } - private nonisolated static func downloadUpdate( - from url: URL, - userAgent: String, - progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void - ) async throws -> URL { - var request = URLRequest(url: url) - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - - let bytes: URLSession.AsyncBytes - let response: URLResponse - do { - (bytes, response) = try await URLSession.shared.bytes(for: request) - } catch { - throw UpdateCheckError.downloadFailed - } - - guard let httpResponse = response as? HTTPURLResponse else { - throw UpdateCheckError.invalidResponse - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw UpdateCheckError.httpStatus(httpResponse.statusCode) - } - - let totalBytes = response.expectedContentLength > 0 - ? response.expectedContentLength - : nil - let destination = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-update-\(UUID().uuidString).dmg") - FileManager.default.createFile(atPath: destination.path, contents: nil) - - do { - let handle = try FileHandle(forWritingTo: destination) - defer { try? handle.close() } - - var buffer = Data() - buffer.reserveCapacity(64 * 1024) - var downloadedBytes: Int64 = 0 - var lastProgressUpdate = Date.distantPast - - for try await byte in bytes { - buffer.append(byte) - downloadedBytes += 1 - - if buffer.count >= 64 * 1024 { - try handle.write(contentsOf: buffer) - buffer.removeAll(keepingCapacity: true) - let now = Date() - if now.timeIntervalSince(lastProgressUpdate) >= 0.05 { - lastProgressUpdate = now - await progress( - UpdateDownloadProgress( - downloadedBytes: downloadedBytes, - totalBytes: totalBytes - ) - ) - } - } - } - - if !buffer.isEmpty { - try handle.write(contentsOf: buffer) - } - await progress( - UpdateDownloadProgress( - downloadedBytes: downloadedBytes, - totalBytes: totalBytes - ) - ) - return destination - } catch { - try? FileManager.default.removeItem(at: destination) - if error is UpdateCheckError { - throw error - } - throw UpdateCheckError.downloadFailed - } - } - func openRelease(_ url: URL?) { guard let url else { return } notice = nil @@ -312,55 +251,46 @@ final class UpdateChecker: ObservableObject { updatePrompt = nil } - private static let releasePageURL = URL(string: "https://github.com/1lck/Lithe-IDEA/releases/latest")! - - private func fetchLatestRelease() async throws -> GitHubRelease { - var request = URLRequest(url: Self.latestReleaseURL) - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + private func fetchLatestManifest() async throws -> UpdateManifest { + var request = URLRequest(url: endpoint.manifestURL) + request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("Lithe/\(currentVersion)", forHTTPHeaderField: "User-Agent") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw UpdateCheckError.invalidResponse - } - if httpResponse.statusCode == 404 { + let response = try await transport.fetch(request) + if response.statusCode == 404 { throw UpdateCheckError.noPublishedRelease } - guard (200..<300).contains(httpResponse.statusCode) else { - throw UpdateCheckError.httpStatus(httpResponse.statusCode) + if response.statusCode == 403, isRateLimited(response) { + throw UpdateCheckError.rateLimited } - return try JSONDecoder().decode(GitHubRelease.self, from: data) - } - - private func updateAsset(for release: GitHubRelease) throws -> GitHubReleaseAsset { - let version = release.displayVersion - let architectureAssetName = "Lithe-\(version)-\(currentArchitecture).dmg" - let universalAssetName = "Lithe-\(version).dmg" - - if let asset = release.assets.first(where: { $0.name == architectureAssetName }) { - return asset + guard (200..<300).contains(response.statusCode) else { + throw UpdateCheckError.httpStatus(response.statusCode) } - if let asset = release.assets.first(where: { $0.name == universalAssetName }) { - return asset + do { + return try JSONDecoder().decode(UpdateManifest.self, from: response.body) + .validated(allowingLocalHTTP: endpoint.allowsLocalHTTP) + } catch let error as UpdateCheckError { + throw error + } catch { + throw UpdateCheckError.invalidManifest } - throw UpdateCheckError.noCompatibleAsset } - private func verify(downloadedFile: URL, against asset: GitHubReleaseAsset) throws { - guard let rawDigest = asset.digest else { - throw UpdateCheckError.missingChecksum + private func isRateLimited(_ response: UpdateHTTPResponse) -> Bool { + if response.header(named: "X-RateLimit-Remaining") == "0" { + return true } + let body = String(data: response.body, encoding: .utf8)?.lowercased() ?? "" + return body.contains("rate limit") + } - let expectedDigest = rawDigest - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "sha256:", with: "") - .lowercased() + private func verify(downloadedFile: URL, against asset: UpdateManifestAsset) throws { let data = try Data(contentsOf: downloadedFile, options: .mappedIfSafe) let actualDigest = SHA256.hash(data: data) .map { String(format: "%02x", $0) } .joined() - guard actualDigest == expectedDigest else { + guard actualDigest == asset.normalizedSHA256 else { throw UpdateCheckError.checksumMismatch } } @@ -513,96 +443,49 @@ final class UpdateChecker: ObservableObject { } } - private var currentArchitecture: String { - #if arch(arm64) - return "arm64" - #elseif arch(x86_64) - return "x86_64" - #else - return "unknown" - #endif - } - - private func userMessage(for error: Error) -> String { - guard let updateError = error as? UpdateCheckError else { - return "The update could not be installed. Download the release manually and try again." - } - return updateError.userMessage - } - private func shouldPerformAutomaticCheck() -> Bool { - guard let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date else { + guard let lastCheck = preferences.object(forKey: Self.lastAutomaticCheckKey) as? Date else { return true } - return Date().timeIntervalSince(lastCheck) >= Self.automaticCheckInterval + return now().timeIntervalSince(lastCheck) >= Self.automaticCheckInterval } - private func isNewer(_ candidate: String, than current: String) -> Bool { - guard let candidateComponents = versionComponents(candidate), - let currentComponents = versionComponents(current) else { - return false + private func normalizedError( + _ error: Error, + fallback: UpdateCheckError = .connectionFailed + ) -> UpdateCheckError { + if let updateError = error as? UpdateCheckError { + return updateError } - - let count = max(candidateComponents.count, currentComponents.count) - for index in 0.. currentValue - } + if error is UpdateTransportError { + return .invalidResponse } - return false - } - - private func versionComponents(_ version: String) -> [Int]? { - let normalized = version - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^v", with: "", options: .regularExpression) - .split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true) - .first - .map(String.init) ?? "" - let components = normalized.split(separator: ".", omittingEmptySubsequences: true) - guard !components.isEmpty else { return nil } - - var values: [Int] = [] - for component in components { - guard let value = Int(component) else { return nil } - values.append(value) + guard let urlError = error as? URLError else { + return fallback } - return values - } -} -private enum UpdateCheckError: Error { - case noPublishedRelease - case invalidResponse - case httpStatus(Int) - case noCompatibleAsset - case missingChecksum - case checksumMismatch - case downloadFailed - case notAppBundle - case appNotFoundInDiskImage - case toolFailed(String) - - var userMessage: String { - switch self { - case .noCompatibleAsset: - return "No update package is available for this Mac. Download the release manually." - case .missingChecksum: - return "The update package has no checksum and cannot be verified." - case .checksumMismatch: - return "The downloaded update failed its checksum verification." - case .downloadFailed: - return "The update package could not be downloaded. Check your internet connection and try again." - case .notAppBundle: - return "Self-update is only available when Lithe is running from a packaged Lithe.app." - case .appNotFoundInDiskImage: - return "The downloaded disk image does not contain Lithe.app." - case .toolFailed: - return "macOS could not prepare the update disk image. Download the release manually and try again." - case .noPublishedRelease, .invalidResponse, .httpStatus: - return "The update could not be installed. Download the release manually and try again." + switch urlError.code { + case .timedOut: + return .timedOut + case .secureConnectionFailed, + .serverCertificateHasBadDate, + .serverCertificateUntrusted, + .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid, + .clientCertificateRejected, + .clientCertificateRequired, + .appTransportSecurityRequiresSecureConnection: + return .tlsOrProxyFailure + case .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + .networkConnectionLost, + .notConnectedToInternet, + .internationalRoamingOff, + .dataNotAllowed: + return .connectionFailed + default: + return fallback } } } diff --git a/Sources/Lithe/Platform/MacOS/Updates/UpdateManifest.swift b/Sources/Lithe/Platform/MacOS/Updates/UpdateManifest.swift new file mode 100644 index 000000000..101c05bad --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Updates/UpdateManifest.swift @@ -0,0 +1,172 @@ +import Foundation + +struct UpdateManifest: Decodable, Sendable { + let schemaVersion: Int + let version: String + let releaseURL: URL + let assets: [String: UpdateManifestAsset] + + func validated(allowingLocalHTTP: Bool = false) throws -> UpdateManifest { + guard schemaVersion == 1 else { + throw UpdateCheckError.unsupportedSchema(schemaVersion) + } + guard let versionComponents = UpdateVersion.components(version), + versionComponents.count == 3, + Self.isAllowedWebURL(releaseURL, allowingLocalHTTP: allowingLocalHTTP), + !assets.isEmpty else { + throw UpdateCheckError.invalidManifest + } + + for asset in assets.values { + guard Self.isAllowedWebURL(asset.url, allowingLocalHTTP: allowingLocalHTTP), + asset.sha256.range( + of: #"^[0-9a-fA-F]{64}$"#, + options: .regularExpression + ) != nil else { + throw UpdateCheckError.invalidManifest + } + } + return self + } + + func asset(for architecture: UpdateArchitecture) throws -> UpdateManifestAsset { + guard let asset = assets[architecture.rawValue] else { + throw UpdateCheckError.noCompatibleAsset + } + return asset + } + + private static func isAllowedWebURL(_ url: URL, allowingLocalHTTP: Bool) -> Bool { + guard let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased(), + !host.isEmpty else { + return false + } + if scheme == "https" { + return true + } + return allowingLocalHTTP + && scheme == "http" + && UpdateEndpointConfiguration.isLoopbackHost(host) + } +} + +struct UpdateManifestAsset: Decodable, Equatable, Sendable { + let url: URL + let sha256: String + + var normalizedSHA256: String { + sha256.lowercased() + } +} + +enum UpdateArchitecture: String, Sendable { + case arm64 + case x86_64 + + static var current: UpdateArchitecture? { + #if arch(arm64) + return .arm64 + #elseif arch(x86_64) + return .x86_64 + #else + return nil + #endif + } +} + +enum UpdateVersion { + static func isNewer(_ candidate: String, than current: String) -> Bool { + guard let candidateComponents = components(candidate), + let currentComponents = components(current) else { + return false + } + + let count = max(candidateComponents.count, currentComponents.count) + for index in 0.. currentValue + } + } + return false + } + + static func components(_ version: String) -> [Int]? { + let normalized = version + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + .split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true) + .first + .map(String.init) ?? "" + let rawComponents = normalized.split(separator: ".", omittingEmptySubsequences: false) + guard !rawComponents.isEmpty else { return nil } + + var values: [Int] = [] + for component in rawComponents { + guard !component.isEmpty, let value = Int(component), value >= 0 else { return nil } + values.append(value) + } + return values + } +} + +enum UpdateCheckError: Error, Equatable { + case noPublishedRelease + case invalidResponse + case rateLimited + case httpStatus(Int) + case timedOut + case tlsOrProxyFailure + case connectionFailed + case invalidManifest + case unsupportedSchema(Int) + case noCompatibleAsset + case checksumMismatch + case downloadFailed + case notAppBundle + case appNotFoundInDiskImage + case toolFailed(String) + + var userMessage: String { + switch self { + case .rateLimited: + return String(localized: "GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets.") + case .httpStatus(let status): + return String( + format: String(localized: "The update server returned HTTP %@. Open the Release page to download the update manually, or try again later."), + String(status) + ) + case .timedOut: + return String(localized: "The update request timed out. Check your proxy or VPN connection and try again.") + case .tlsOrProxyFailure: + return String(localized: "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.") + case .connectionFailed: + return String(localized: "GitHub could not be reached. Check your internet, proxy, or VPN connection and try again.") + case .invalidResponse: + return String(localized: "The update server returned an unexpected response. Open the Release page and download the update manually.") + case .invalidManifest: + return String(localized: "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.") + case .unsupportedSchema(let version): + return String( + format: String(localized: "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually."), + String(version) + ) + case .noCompatibleAsset: + return String(localized: "No update package is available for this Mac architecture. Open the Release page to check available downloads.") + case .checksumMismatch: + return String(localized: "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page.") + case .downloadFailed: + return String(localized: "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again.") + case .notAppBundle: + return String(localized: "Self-update is only available when Lithe is running from a packaged Lithe.app.") + case .appNotFoundInDiskImage: + return String(localized: "The downloaded disk image does not contain Lithe.app.") + case .toolFailed: + return String(localized: "macOS could not prepare the update disk image. Open the Release page and install it manually.") + case .noPublishedRelease: + return String(localized: "There is no published GitHub Release to check yet.") + } + } +} diff --git a/Sources/Lithe/Views/App/RootView.swift b/Sources/Lithe/Views/App/RootView.swift index bc8c5175c..4210aefe9 100644 --- a/Sources/Lithe/Views/App/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -49,7 +49,7 @@ struct RootView: View { return Alert( title: Text(LocalizedStringKey(notice.title)), message: Text(LocalizedStringKey(notice.message)), - primaryButton: .default(Text("Download")) { + primaryButton: .default(Text("Open Release Page")) { updateChecker.openRelease(url) }, secondaryButton: .cancel() diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift index ab7a0c96f..bfd66ea02 100644 --- a/Sources/Lithe/Views/App/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -1107,9 +1107,10 @@ struct SettingsView: View { case .noRelease: Text("No published release is available yet.") .foregroundStyle(LitheTheme.secondaryText) - case .failed: - Label("Could not check for updates.", systemImage: "exclamationmark.triangle") + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle") .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Tests/LitheTests/AppLocalizationTests.swift b/Tests/LitheTests/AppLocalizationTests.swift index 3ef5099e5..990cd7c5a 100644 --- a/Tests/LitheTests/AppLocalizationTests.swift +++ b/Tests/LitheTests/AppLocalizationTests.swift @@ -50,6 +50,32 @@ struct AppLocalizationTests { #expect(translations["Restore Default"] == "恢复默认") } + @Test + func simplifiedChineseResourcesCoverUpdateFailures() throws { + let translations = try simplifiedChineseTranslations() + let requiredKeys = [ + "GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets.", + "The update server returned HTTP %@. Open the Release page to download the update manually, or try again later.", + "The update request timed out. Check your proxy or VPN connection and try again.", + "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.", + "GitHub could not be reached. Check your internet, proxy, or VPN connection and try again.", + "The update server returned an unexpected response. Open the Release page and download the update manually.", + "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", + "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually.", + "No update package is available for this Mac architecture. Open the Release page to check available downloads.", + "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page.", + "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again.", + "Self-update is only available when Lithe is running from a packaged Lithe.app.", + "The downloaded disk image does not contain Lithe.app.", + "macOS could not prepare the update disk image. Open the Release page and install it manually.", + "There is no published GitHub Release to check yet." + ] + + for key in requiredKeys { + #expect(translations[key] != nil, "Missing update translation: \(key)") + } + } + @Test func simplifiedChineseResourcesCoverGitHubPullRequests() throws { let translations = try simplifiedChineseTranslations() diff --git a/Tests/LitheTests/UpdateCheckerTests.swift b/Tests/LitheTests/UpdateCheckerTests.swift new file mode 100644 index 000000000..bfba32eeb --- /dev/null +++ b/Tests/LitheTests/UpdateCheckerTests.swift @@ -0,0 +1,330 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("macOS update manifest") +struct UpdateManifestTests { + @Test + func productionUpdateEndpointUsesStaticHTTPSManifest() { + #expect(UpdateEndpointConfiguration.production.manifestURL == UpdateEndpointConfiguration.productionManifestURL) + #expect(UpdateEndpointConfiguration.production.manifestURL.scheme == "https") + #expect(UpdateEndpointConfiguration.production.allowsLocalHTTP == false) + } + + @Test + func localUpdateModeAllowsOnlyLoopbackHTTPManifestAssets() throws { + let localManifest = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData( + armURL: "http://127.0.0.1:8765/Lithe-0.3.1-arm64.dmg", + intelURL: "http://localhost:8765/Lithe-0.3.1-x86_64.dmg" + ) + ) + + #expect(throws: UpdateCheckError.invalidManifest) { + try localManifest.validated() + } + #expect(throws: Never.self) { + try localManifest.validated(allowingLocalHTTP: true) + } + } + + @Test + func decodesAndSelectsArchitectureSpecificChecksumMetadata() throws { + let manifest = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(version: "0.3.1") + ).validated() + + let armAsset = try manifest.asset(for: .arm64) + let intelAsset = try manifest.asset(for: .x86_64) + + #expect(manifest.schemaVersion == 1) + #expect(manifest.version == "0.3.1") + #expect(armAsset.url.lastPathComponent == "Lithe-0.3.1-arm64.dmg") + #expect(armAsset.normalizedSHA256 == String(repeating: "a", count: 64)) + #expect(intelAsset.url.lastPathComponent == "Lithe-0.3.1-x86_64.dmg") + #expect(intelAsset.normalizedSHA256 == String(repeating: "b", count: 64)) + } + + @Test + func rejectsUnsupportedSchemaInvalidChecksumAndInsecureURL() throws { + let unsupported = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(schemaVersion: 2) + ) + #expect(throws: UpdateCheckError.unsupportedSchema(2)) { + try unsupported.validated() + } + + let invalidChecksum = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(armChecksum: "not-a-checksum") + ) + #expect(throws: UpdateCheckError.invalidManifest) { + try invalidChecksum.validated() + } + + let insecure = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(releaseURL: "http://example.com/releases/v0.3.1") + ) + #expect(throws: UpdateCheckError.invalidManifest) { + try insecure.validated() + } + + let incompleteVersion = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(version: "0.3") + ) + #expect(throws: UpdateCheckError.invalidManifest) { + try incompleteVersion.validated() + } + } + + @Test + func reportsMissingCompatibleAssetSeparately() throws { + let manifest = try JSONDecoder().decode( + UpdateManifest.self, + from: manifestData(includeIntel: false) + ).validated() + + #expect(throws: UpdateCheckError.noCompatibleAsset) { + try manifest.asset(for: .x86_64) + } + } + + @Test(arguments: [ + ("0.3.1", "0.3.0", true), + ("0.3.0", "0.3.0", false), + ("0.3", "0.3.0", false), + ("1.0.0", "0.99.99", true), + ("v0.4.0-preview", "0.3.9", true), + ("invalid", "0.3.0", false) + ]) + func comparesVersions(candidate: String, current: String, expected: Bool) { + #expect(UpdateVersion.isNewer(candidate, than: current) == expected) + } +} + +@Suite("macOS update checker") +@MainActor +struct UpdateCheckerTests { + @Test + func fetchesStaticManifestAndRecordsOnlySuccessfulAutomaticCheck() async throws { + let recorder = UpdateRequestRecorder() + let transport = StubUpdateNetworkTransport(fetch: { request in + await recorder.record(request) + return UpdateHTTPResponse(statusCode: 200, headers: [:], body: manifestData()) + }) + let preferences = makePreferences() + defer { clear(preferences) } + let now = Date(timeIntervalSince1970: 1_800_000_000) + let checker = UpdateChecker( + currentVersion: "0.3.0", + transport: transport, + preferences: preferences, + now: { now }, + architecture: .arm64 + ) + + await checker.checkForUpdates() + await checker.checkForUpdates() + + let requests = await recorder.requests + #expect(requests.count == 1) + #expect(requests.first?.url?.absoluteString == "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest.json") + #expect(requests.first?.url?.host == "github.com") + #expect(checker.status == .available( + version: "0.3.1", + url: URL(string: "https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.1")! + )) + } + + @Test + func failedAutomaticCheckIsNotSuppressedForTwentyFourHours() async { + let recorder = UpdateRequestRecorder() + let transport = StubUpdateNetworkTransport(fetch: { request in + await recorder.record(request) + throw URLError(.notConnectedToInternet) + }) + let preferences = makePreferences() + defer { clear(preferences) } + let checker = UpdateChecker( + currentVersion: "0.3.0", + transport: transport, + preferences: preferences, + architecture: .arm64 + ) + + await checker.checkForUpdates() + await checker.checkForUpdates() + + #expect(await recorder.requests.count == 2) + guard case .failed(let message) = checker.status else { + Issue.record("Expected a failed update status") + return + } + #expect(message.contains("proxy")) + } + + @Test(arguments: [ + FailureScenario( + response: UpdateHTTPResponse( + statusCode: 403, + headers: ["X-RateLimit-Remaining": "0"], + body: Data(#"{"message":"API rate limit exceeded"}"#.utf8) + ), + error: nil, + expectedMessageFragment: "shared API limit" + ), + FailureScenario( + response: UpdateHTTPResponse(statusCode: 503, headers: [:], body: Data()), + error: nil, + expectedMessageFragment: "HTTP 503" + ), + FailureScenario( + response: nil, + error: URLError(.serverCertificateUntrusted), + expectedMessageFragment: "TLS inspection" + ), + FailureScenario( + response: nil, + error: UpdateTransportError.invalidResponse, + expectedMessageFragment: "unexpected response" + ), + FailureScenario( + response: UpdateHTTPResponse(statusCode: 200, headers: [:], body: Data("not json".utf8)), + error: nil, + expectedMessageFragment: "manifest is invalid" + ) + ]) + func presentsActionableManualCheckFailures(scenario: FailureScenario) async { + let transport = StubUpdateNetworkTransport(fetch: { _ in + if let error = scenario.error { throw error } + return scenario.response! + }) + let preferences = makePreferences() + defer { clear(preferences) } + let checker = UpdateChecker( + currentVersion: "0.3.0", + transport: transport, + preferences: preferences, + architecture: .arm64 + ) + + await checker.checkForUpdates(manual: true) + + #expect(checker.notice?.message.contains(scenario.expectedMessageFragment) == true) + guard case .open(let url) = checker.notice?.action else { + Issue.record("Expected the Release page fallback action") + return + } + #expect(url.absoluteString == "https://github.com/1lck/Lithe-IDEA/releases/latest") + } + + @Test + func rejectsDownloadedAssetWhenManifestChecksumDoesNotMatch() async throws { + let downloadedFile = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-update-test-\(UUID().uuidString).dmg") + try Data("unexpected disk image".utf8).write(to: downloadedFile) + defer { try? FileManager.default.removeItem(at: downloadedFile) } + + let transport = StubUpdateNetworkTransport( + fetch: { _ in + UpdateHTTPResponse(statusCode: 200, headers: [:], body: manifestData()) + }, + download: { request, progress in + #expect(request.url?.lastPathComponent == "Lithe-0.3.1-arm64.dmg") + await progress(UpdateDownloadProgress(downloadedBytes: 21, totalBytes: 21)) + return downloadedFile + } + ) + let preferences = makePreferences() + defer { clear(preferences) } + let checker = UpdateChecker( + currentVersion: "0.3.0", + transport: transport, + preferences: preferences, + architecture: .arm64 + ) + + await checker.checkForUpdates(manual: true) + await checker.installAvailableUpdate() + + #expect(checker.notice?.message.contains("SHA-256") == true) + #expect(!FileManager.default.fileExists(atPath: downloadedFile.path)) + } + + private func makePreferences() -> UserDefaults { + let suiteName = "lithe.update-tests.\(UUID().uuidString)" + return UserDefaults(suiteName: suiteName)! + } + + private func clear(_ preferences: UserDefaults) { + for key in preferences.dictionaryRepresentation().keys { + preferences.removeObject(forKey: key) + } + } +} + +struct FailureScenario: Sendable, CustomTestStringConvertible { + let response: UpdateHTTPResponse? + let error: (any Error & Sendable)? + let expectedMessageFragment: String + + var testDescription: String { expectedMessageFragment } +} + +private final class StubUpdateNetworkTransport: UpdateNetworkTransport, @unchecked Sendable { + private let fetchHandler: @Sendable (URLRequest) async throws -> UpdateHTTPResponse + private let downloadHandler: @Sendable ( + URLRequest, + @escaping @Sendable (UpdateDownloadProgress) async -> Void + ) async throws -> URL + + init( + fetch: @escaping @Sendable (URLRequest) async throws -> UpdateHTTPResponse, + download: @escaping @Sendable ( + URLRequest, + @escaping @Sendable (UpdateDownloadProgress) async -> Void + ) async throws -> URL = { _, _ in throw UpdateCheckError.downloadFailed } + ) { + fetchHandler = fetch + downloadHandler = download + } + + func fetch(_ request: URLRequest) async throws -> UpdateHTTPResponse { + try await fetchHandler(request) + } + + func download( + _ request: URLRequest, + progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void + ) async throws -> URL { + try await downloadHandler(request, progress) + } +} + +private actor UpdateRequestRecorder { + private(set) var requests: [URLRequest] = [] + + func record(_ request: URLRequest) { + requests.append(request) + } +} + +private func manifestData( + schemaVersion: Int = 1, + version: String = "0.3.1", + releaseURL: String = "https://github.com/1lck/Lithe-IDEA/releases/tag/v0.3.1", + armChecksum: String = String(repeating: "a", count: 64), + armURL: String = "https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.1/Lithe-0.3.1-arm64.dmg", + intelURL: String = "https://github.com/1lck/Lithe-IDEA/releases/download/v0.3.1/Lithe-0.3.1-x86_64.dmg", + includeIntel: Bool = true +) -> Data { + let intelEntry = includeIntel + ? #", "x86_64": {"url":"\#(intelURL)","sha256":"\#(String(repeating: "b", count: 64))"}"# + : "" + return Data(#"{"schemaVersion":\#(schemaVersion),"version":"\#(version)","releaseURL":"\#(releaseURL)","assets":{"arm64":{"url":"\#(armURL)","sha256":"\#(armChecksum)"}\#(intelEntry)}}"#.utf8) +} diff --git a/scripts/create-macos-update-manifest.rb b/scripts/create-macos-update-manifest.rb new file mode 100755 index 000000000..79496712f --- /dev/null +++ b/scripts/create-macos-update-manifest.rb @@ -0,0 +1,72 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "optparse" +require "pathname" + +options = { + output_directory: "dist", + release_tag: nil, + merge_manifest: nil +} + +OptionParser.new do |parser| + parser.banner = "Usage: create-macos-update-manifest.rb --version VERSION --repository OWNER/REPO [options]" + parser.on("--version VERSION") { |value| options[:version] = value } + parser.on("--repository OWNER/REPO") { |value| options[:repository] = value } + parser.on("--release-tag TAG") { |value| options[:release_tag] = value } + parser.on("--output-directory PATH") { |value| options[:output_directory] = value } + parser.on("--merge-manifest PATH") { |value| options[:merge_manifest] = value } +end.parse! + +version = options[:version] +repository = options[:repository] +abort "Version must use the form MAJOR.MINOR.PATCH" unless version&.match?(/\A\d+\.\d+\.\d+\z/) +abort "Repository must use the form OWNER/REPO" unless repository&.match?(%r{\A[^/\s]+/[^/\s]+\z}) + +release_tag = options[:release_tag] || "v#{version}" +abort "Release tag contains unsupported characters" unless release_tag.match?(/\A[A-Za-z0-9._-]+\z/) + +root = Pathname(__dir__).parent +output_directory = root.join(options[:output_directory]).cleanpath +assets = {} + +%w[arm64 x86_64].each do |architecture| + asset_name = "Lithe-#{version}-#{architecture}.dmg" + asset_path = output_directory.join(asset_name) + checksum_path = output_directory.join("#{asset_name}.sha256") + abort "Missing macOS release asset: #{asset_path}" unless asset_path.file? + abort "Missing macOS checksum: #{checksum_path}" unless checksum_path.file? + + checksum = checksum_path.read.split.first&.downcase + abort "Invalid SHA-256 metadata: #{checksum_path}" unless checksum&.match?(/\A[0-9a-f]{64}\z/) + + actual_checksum = Digest::SHA256.file(asset_path).hexdigest + abort "Checksum mismatch for #{asset_name}" unless checksum == actual_checksum + + assets[architecture] = { + "url" => "https://github.com/#{repository}/releases/download/#{release_tag}/#{asset_name}", + "sha256" => checksum + } +end + +manifest = if options[:merge_manifest] + merge_path = root.join(options[:merge_manifest]).cleanpath + JSON.parse(merge_path.read) + else + {} + end + +manifest.merge!( + "schemaVersion" => 1, + "version" => version, + "releaseURL" => "https://github.com/#{repository}/releases/tag/#{release_tag}", + "assets" => assets +) + +output_directory.mkpath +manifest_path = output_directory.join("latest.json") +manifest_path.write("#{JSON.pretty_generate(manifest)}\n") +puts "macOS update manifest created: #{manifest_path}" diff --git a/scripts/test-macos-update-manifest.rb b/scripts/test-macos-update-manifest.rb new file mode 100755 index 000000000..e8088890e --- /dev/null +++ b/scripts/test-macos-update-manifest.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "open3" +require "pathname" +require "tmpdir" + +root = Pathname(__dir__).parent +generator = root.join("scripts/create-macos-update-manifest.rb") +version = "9.8.7" + +Dir.mktmpdir("lithe-macos-manifest-") do |directory| + output = Pathname(directory) + %w[arm64 x86_64].each do |architecture| + asset = output.join("Lithe-#{version}-#{architecture}.dmg") + asset.write("test #{architecture} disk image") + output.join("#{asset.basename}.sha256").write("#{Digest::SHA256.file(asset).hexdigest} #{asset.basename}\n") + end + + existing_manifest = output.join("windows.json") + existing_manifest.write(JSON.generate( + "version" => version, + "notes" => "Windows release notes", + "platforms" => { + "windows-x86_64" => { + "signature" => "test-signature", + "url" => "https://github.com/example/Lithe-IDEA/releases/download/v#{version}/windows.exe" + } + } + )) + + relative_output = output.relative_path_from(root).to_s + relative_merge = existing_manifest.relative_path_from(root).to_s + stdout, stderr, status = Open3.capture3( + generator.to_s, + "--version", version, + "--repository", "example/Lithe-IDEA", + "--output-directory", relative_output, + "--merge-manifest", relative_merge, + chdir: root.to_s + ) + abort "Generator failed: #{stdout}#{stderr}" unless status.success? + + manifest = JSON.parse(output.join("latest.json").read) + raise "Schema version is incorrect" unless manifest["schemaVersion"] == 1 + raise "Release URL is incorrect" unless manifest["releaseURL"] == "https://github.com/example/Lithe-IDEA/releases/tag/v#{version}" + raise "Windows metadata was not preserved" unless manifest.dig("platforms", "windows-x86_64", "signature") == "test-signature" + + %w[arm64 x86_64].each do |architecture| + asset = output.join("Lithe-#{version}-#{architecture}.dmg") + entry = manifest.dig("assets", architecture) + expected_url = "https://github.com/example/Lithe-IDEA/releases/download/v#{version}/#{asset.basename}" + raise "#{architecture} URL is incorrect" unless entry["url"] == expected_url + raise "#{architecture} checksum is incorrect" unless entry["sha256"] == Digest::SHA256.file(asset).hexdigest + end + + output.join("Lithe-#{version}-arm64.dmg.sha256").write("#{"0" * 64} invalid.dmg\n") + _stdout, _stderr, invalid_status = Open3.capture3( + generator.to_s, + "--version", version, + "--repository", "example/Lithe-IDEA", + "--output-directory", relative_output, + chdir: root.to_s + ) + raise "Generator accepted a checksum mismatch" if invalid_status.success? +end + +puts "macOS update manifest test passed." From 0e40fad210ceda01ebd93c0bd976b015347ce3f6 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 21 Aug 2026 11:19:28 +0800 Subject: [PATCH 2/2] fix(release): separate platform update manifests --- .github/workflows/release-macos.yml | 20 ++++--------------- .github/workflows/release-windows.yml | 19 +----------------- .../MacOS/Updates/UpdateChecker.swift | 2 +- Tests/LitheTests/UpdateCheckerTests.swift | 2 +- scripts/create-macos-update-manifest.rb | 17 ++++------------ scripts/test-macos-update-manifest.rb | 10 +++++----- 6 files changed, 16 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 10fea79c1..281aa2bce 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -15,8 +15,7 @@ permissions: contents: read concurrency: - # Both platform workflows update the same Release and latest.json asset. - group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} + group: release-macos-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} cancel-in-progress: false jobs: @@ -180,25 +179,14 @@ jobs: - name: Generate macOS update manifest env: - GH_TOKEN: ${{ github.token }} LITHE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} run: | - merge_args=() - existing_dir="$RUNNER_TEMP/existing-update-manifest" - mkdir -p "$existing_dir" - if gh release download "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern "latest.json" \ - --dir "$existing_dir" >/dev/null 2>&1; then - merge_args+=(--merge-manifest "$existing_dir/latest.json") - fi ruby scripts/create-macos-update-manifest.rb \ --version "$LITHE_VERSION" \ --repository "$GITHUB_REPOSITORY" \ - --release-tag "$RELEASE_TAG" \ - "${merge_args[@]}" - ruby -rjson -e 'manifest = JSON.parse(File.read("dist/latest.json")); abort "Invalid schema" unless manifest["schemaVersion"] == 1' + --release-tag "$RELEASE_TAG" + ruby -rjson -e 'manifest = JSON.parse(File.read("dist/latest-macos.json")); abort "Invalid schema" unless manifest["schemaVersion"] == 1' - name: Create or update GitHub Release env: @@ -219,7 +207,7 @@ jobs: "dist/Lithe-${LITHE_VERSION}-arm64.dmg.sha256" "dist/Lithe-${LITHE_VERSION}-x86_64.dmg" "dist/Lithe-${LITHE_VERSION}-x86_64.dmg.sha256" - "dist/latest.json" + "dist/latest-macos.json" ) if [[ -f "$notes_file" ]]; then diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 1d08ea27c..2189eefbc 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -15,8 +15,7 @@ permissions: contents: read concurrency: - # Both platform workflows update the same Release and latest.json asset. - group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} + group: release-windows-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }} cancel-in-progress: false jobs: @@ -151,22 +150,6 @@ jobs: } } - $existingManifestDirectory = Join-Path $env:RUNNER_TEMP "lithe-existing-update-manifest" - New-Item -ItemType Directory -Force -Path $existingManifestDirectory | Out-Null - gh release download $env:RELEASE_TAG ` - --repo $env:GITHUB_REPOSITORY ` - --pattern "latest.json" ` - --dir $existingManifestDirectory 2>$null - if ($LASTEXITCODE -eq 0) { - $existingManifest = Get-Content -LiteralPath (Join-Path $existingManifestDirectory "latest.json") -Raw | ConvertFrom-Json - if ($existingManifest.schemaVersion -eq 1) { - $windowsManifest = Get-Content -LiteralPath "dist/latest.json" -Raw | ConvertFrom-Json - foreach ($property in @("schemaVersion", "releaseURL", "assets")) { - $windowsManifest | Add-Member -NotePropertyName $property -NotePropertyValue $existingManifest.$property -Force - } - $windowsManifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath "dist/latest.json" -Encoding utf8 - } - } gh release upload $env:RELEASE_TAG ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe" ` "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe.sha256" ` diff --git a/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift b/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift index 5dc717d08..115687b19 100644 --- a/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift +++ b/Sources/Lithe/Platform/MacOS/Updates/UpdateChecker.swift @@ -64,7 +64,7 @@ enum UpdateStatus: Equatable { struct UpdateEndpointConfiguration: Equatable { static let productionManifestURL = URL( - string: "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest.json" + string: "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest-macos.json" )! let manifestURL: URL diff --git a/Tests/LitheTests/UpdateCheckerTests.swift b/Tests/LitheTests/UpdateCheckerTests.swift index bfba32eeb..b47b1a7a8 100644 --- a/Tests/LitheTests/UpdateCheckerTests.swift +++ b/Tests/LitheTests/UpdateCheckerTests.swift @@ -133,7 +133,7 @@ struct UpdateCheckerTests { let requests = await recorder.requests #expect(requests.count == 1) - #expect(requests.first?.url?.absoluteString == "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest.json") + #expect(requests.first?.url?.absoluteString == "https://github.com/1lck/Lithe-IDEA/releases/latest/download/latest-macos.json") #expect(requests.first?.url?.host == "github.com") #expect(checker.status == .available( version: "0.3.1", diff --git a/scripts/create-macos-update-manifest.rb b/scripts/create-macos-update-manifest.rb index 79496712f..d9744a61c 100755 --- a/scripts/create-macos-update-manifest.rb +++ b/scripts/create-macos-update-manifest.rb @@ -8,8 +8,7 @@ options = { output_directory: "dist", - release_tag: nil, - merge_manifest: nil + release_tag: nil } OptionParser.new do |parser| @@ -18,7 +17,6 @@ parser.on("--repository OWNER/REPO") { |value| options[:repository] = value } parser.on("--release-tag TAG") { |value| options[:release_tag] = value } parser.on("--output-directory PATH") { |value| options[:output_directory] = value } - parser.on("--merge-manifest PATH") { |value| options[:merge_manifest] = value } end.parse! version = options[:version] @@ -52,21 +50,14 @@ } end -manifest = if options[:merge_manifest] - merge_path = root.join(options[:merge_manifest]).cleanpath - JSON.parse(merge_path.read) - else - {} - end - -manifest.merge!( +manifest = { "schemaVersion" => 1, "version" => version, "releaseURL" => "https://github.com/#{repository}/releases/tag/#{release_tag}", "assets" => assets -) +} output_directory.mkpath -manifest_path = output_directory.join("latest.json") +manifest_path = output_directory.join("latest-macos.json") manifest_path.write("#{JSON.pretty_generate(manifest)}\n") puts "macOS update manifest created: #{manifest_path}" diff --git a/scripts/test-macos-update-manifest.rb b/scripts/test-macos-update-manifest.rb index e8088890e..ca59d6e51 100755 --- a/scripts/test-macos-update-manifest.rb +++ b/scripts/test-macos-update-manifest.rb @@ -19,7 +19,7 @@ output.join("#{asset.basename}.sha256").write("#{Digest::SHA256.file(asset).hexdigest} #{asset.basename}\n") end - existing_manifest = output.join("windows.json") + existing_manifest = output.join("latest.json") existing_manifest.write(JSON.generate( "version" => version, "notes" => "Windows release notes", @@ -32,21 +32,21 @@ )) relative_output = output.relative_path_from(root).to_s - relative_merge = existing_manifest.relative_path_from(root).to_s stdout, stderr, status = Open3.capture3( generator.to_s, "--version", version, "--repository", "example/Lithe-IDEA", "--output-directory", relative_output, - "--merge-manifest", relative_merge, chdir: root.to_s ) abort "Generator failed: #{stdout}#{stderr}" unless status.success? - manifest = JSON.parse(output.join("latest.json").read) + manifest = JSON.parse(output.join("latest-macos.json").read) raise "Schema version is incorrect" unless manifest["schemaVersion"] == 1 raise "Release URL is incorrect" unless manifest["releaseURL"] == "https://github.com/example/Lithe-IDEA/releases/tag/v#{version}" - raise "Windows metadata was not preserved" unless manifest.dig("platforms", "windows-x86_64", "signature") == "test-signature" + windows_manifest = JSON.parse(existing_manifest.read) + raise "Windows manifest was modified" unless windows_manifest.dig("platforms", "windows-x86_64", "signature") == "test-signature" + raise "macOS manifest contains Windows metadata" if manifest.key?("platforms") %w[arm64 x86_64].each do |architecture| asset = output.join("Lithe-#{version}-#{architecture}.dmg")