Skip to content

Commit c12f196

Browse files
JerrySmithJerrySmith
authored andcommitted
fix(macOS): use static release update manifest
1 parent 0f2895e commit c12f196

13 files changed

Lines changed: 1010 additions & 278 deletions

File tree

.github/workflows/ci-macos.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ jobs:
207207
fi
208208
done
209209
ruby -c scripts/update-homebrew-cask.rb
210+
ruby -c scripts/create-macos-update-manifest.rb
211+
ruby -c scripts/test-macos-update-manifest.rb
212+
ruby scripts/test-macos-update-manifest.rb
210213
plutil -lint Resources/Info.plist
211214
212215
- name: Build release configuration

.github/workflows/release-macos.yml

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ permissions:
1515
contents: read
1616

1717
concurrency:
18-
group: release-macos-${{ github.ref }}
18+
# Both platform workflows update the same Release and latest.json asset.
19+
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }}
1920
cancel-in-progress: false
2021

2122
jobs:
@@ -177,32 +178,62 @@ jobs:
177178
(cd dist && shasum -a 256 -c "Lithe-${LITHE_VERSION}-${architecture}.dmg.sha256")
178179
done
179180
180-
- name: Create GitHub Release
181+
- name: Generate macOS update manifest
182+
env:
183+
GH_TOKEN: ${{ github.token }}
184+
LITHE_VERSION: ${{ needs.prepare.outputs.version }}
185+
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
186+
run: |
187+
merge_args=()
188+
existing_dir="$RUNNER_TEMP/existing-update-manifest"
189+
mkdir -p "$existing_dir"
190+
if gh release download "$RELEASE_TAG" \
191+
--repo "$GITHUB_REPOSITORY" \
192+
--pattern "latest.json" \
193+
--dir "$existing_dir" >/dev/null 2>&1; then
194+
merge_args+=(--merge-manifest "$existing_dir/latest.json")
195+
fi
196+
ruby scripts/create-macos-update-manifest.rb \
197+
--version "$LITHE_VERSION" \
198+
--repository "$GITHUB_REPOSITORY" \
199+
--release-tag "$RELEASE_TAG" \
200+
"${merge_args[@]}"
201+
ruby -rjson -e 'manifest = JSON.parse(File.read("dist/latest.json")); abort "Invalid schema" unless manifest["schemaVersion"] == 1'
202+
203+
- name: Create or update GitHub Release
181204
env:
182205
GH_TOKEN: ${{ github.token }}
183206
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
184207
LITHE_VERSION: ${{ needs.prepare.outputs.version }}
185208
shell: bash
186209
run: |
187210
notes_file="docs/releases/v${LITHE_VERSION}.md"
188-
release_args=(
211+
create_args=(
189212
"$RELEASE_TAG"
213+
--repo "$GITHUB_REPOSITORY"
214+
--target "$GITHUB_SHA"
215+
--title "Lithe $RELEASE_TAG"
216+
)
217+
upload_args=(
190218
"dist/Lithe-${LITHE_VERSION}-arm64.dmg"
191219
"dist/Lithe-${LITHE_VERSION}-arm64.dmg.sha256"
192220
"dist/Lithe-${LITHE_VERSION}-x86_64.dmg"
193221
"dist/Lithe-${LITHE_VERSION}-x86_64.dmg.sha256"
194-
--repo "$GITHUB_REPOSITORY"
195-
--target "$GITHUB_SHA"
196-
--title "Lithe $RELEASE_TAG"
222+
"dist/latest.json"
197223
)
198224
199225
if [[ -f "$notes_file" ]]; then
200-
release_args+=(--notes-file "$notes_file")
226+
create_args+=(--notes-file "$notes_file")
201227
else
202-
release_args+=(--generate-notes)
228+
create_args+=(--generate-notes)
203229
fi
204230
205-
gh release create "${release_args[@]}"
231+
if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
232+
gh release create "${create_args[@]}" || \
233+
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
234+
fi
235+
gh release upload "$RELEASE_TAG" "${upload_args[@]}" \
236+
--repo "$GITHUB_REPOSITORY" --clobber
206237
207238
update-cask:
208239
name: Update Homebrew Cask

.github/workflows/release-windows.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ on:
1414
permissions:
1515
contents: read
1616

17+
concurrency:
18+
# Both platform workflows update the same Release and latest.json asset.
19+
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref }}
20+
cancel-in-progress: false
21+
1722
jobs:
1823
release:
1924
name: Build and publish Lithe for Windows
@@ -145,6 +150,23 @@ jobs:
145150
if ($LASTEXITCODE -ne 0) { throw "Could not create or find release $env:RELEASE_TAG" }
146151
}
147152
}
153+
154+
$existingManifestDirectory = Join-Path $env:RUNNER_TEMP "lithe-existing-update-manifest"
155+
New-Item -ItemType Directory -Force -Path $existingManifestDirectory | Out-Null
156+
gh release download $env:RELEASE_TAG `
157+
--repo $env:GITHUB_REPOSITORY `
158+
--pattern "latest.json" `
159+
--dir $existingManifestDirectory 2>$null
160+
if ($LASTEXITCODE -eq 0) {
161+
$existingManifest = Get-Content -LiteralPath (Join-Path $existingManifestDirectory "latest.json") -Raw | ConvertFrom-Json
162+
if ($existingManifest.schemaVersion -eq 1) {
163+
$windowsManifest = Get-Content -LiteralPath "dist/latest.json" -Raw | ConvertFrom-Json
164+
foreach ($property in @("schemaVersion", "releaseURL", "assets")) {
165+
$windowsManifest | Add-Member -NotePropertyName $property -NotePropertyValue $existingManifest.$property -Force
166+
}
167+
$windowsManifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath "dist/latest.json" -Encoding utf8
168+
}
169+
}
148170
gh release upload $env:RELEASE_TAG `
149171
"dist/Lithe-$env:LITHE_VERSION-windows-x64.exe" `
150172
"dist/Lithe-$env:LITHE_VERSION-windows-x64.exe.sha256" `

Resources/zh-Hans.lproj/Localizable.strings

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,18 @@
641641
"No release is available yet" = "暂时没有可用版本";
642642
"Could not check for updates" = "无法检查更新";
643643
"There is no published GitHub Release to check yet." = "目前还没有可检查的 GitHub Release。";
644+
"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 页面,或等待配额重置后重试。";
645+
"The update server returned HTTP %@. Open the Release page to download the update manually, or try again later." = "更新服务器返回 HTTP %@。请打开 Release 页面手动下载,或稍后重试。";
646+
"The update request timed out. Check your proxy or VPN connection and try again." = "更新请求超时。请检查代理或 VPN 连接后重试。";
647+
"A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "无法与 GitHub 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。";
648+
"GitHub could not be reached. Check your internet, proxy, or VPN connection and try again." = "无法连接 GitHub。请检查网络、代理或 VPN 连接后重试。";
649+
"The update server returned an unexpected response. Open the Release page and download the update manually." = "更新服务器返回了异常响应。请打开 Release 页面手动下载更新。";
650+
"The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。";
651+
"This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "此版本的 Lithe 无法读取版本为 %@ 的更新清单格式。请打开 Release 页面手动更新。";
652+
"No update package is available for this Mac architecture. Open the Release page to check available downloads." = "没有适用于此 Mac 架构的更新包。请打开 Release 页面查看可用下载。";
653+
"The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。";
654+
"The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "无法下载更新包。请检查网络、代理或 VPN 连接后重试。";
655+
"macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。";
644656
"Check your internet connection and try again later." = "请检查网络连接,稍后再试。";
645657
"No Java navigation results" = "没有 Java 导航结果";
646658
"Choose Implementation" = "选择实现";
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Foundation
2+
3+
struct UpdateHTTPResponse: Sendable {
4+
let statusCode: Int
5+
let headers: [String: String]
6+
let body: Data
7+
8+
func header(named name: String) -> String? {
9+
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
10+
}
11+
}
12+
13+
protocol UpdateNetworkTransport: Sendable {
14+
func fetch(_ request: URLRequest) async throws -> UpdateHTTPResponse
15+
/// Returns a temporary file whose ownership transfers to the caller.
16+
func download(
17+
_ request: URLRequest,
18+
progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void
19+
) async throws -> URL
20+
}
21+
22+
enum UpdateTransportError: Error {
23+
case invalidResponse
24+
}
25+
26+
final class MacUpdateNetworkTransport: UpdateNetworkTransport, @unchecked Sendable {
27+
private let session: URLSession
28+
private let fileManager: FileManager
29+
30+
init(session: URLSession = .shared, fileManager: FileManager = .default) {
31+
self.session = session
32+
self.fileManager = fileManager
33+
}
34+
35+
func fetch(_ request: URLRequest) async throws -> UpdateHTTPResponse {
36+
let (data, response) = try await session.data(for: request)
37+
guard let response = response as? HTTPURLResponse else {
38+
throw UpdateTransportError.invalidResponse
39+
}
40+
return UpdateHTTPResponse(
41+
statusCode: response.statusCode,
42+
headers: response.allHeaderFields.reduce(into: [:]) { headers, entry in
43+
headers[String(describing: entry.key)] = String(describing: entry.value)
44+
},
45+
body: data
46+
)
47+
}
48+
49+
func download(
50+
_ request: URLRequest,
51+
progress: @escaping @Sendable (UpdateDownloadProgress) async -> Void
52+
) async throws -> URL {
53+
let (bytes, response) = try await session.bytes(for: request)
54+
guard let httpResponse = response as? HTTPURLResponse else {
55+
throw UpdateTransportError.invalidResponse
56+
}
57+
guard (200..<300).contains(httpResponse.statusCode) else {
58+
throw UpdateCheckError.httpStatus(httpResponse.statusCode)
59+
}
60+
61+
let totalBytes = response.expectedContentLength > 0
62+
? response.expectedContentLength
63+
: nil
64+
let destination = fileManager.temporaryDirectory
65+
.appendingPathComponent("lithe-update-\(UUID().uuidString).dmg")
66+
guard fileManager.createFile(atPath: destination.path, contents: nil) else {
67+
throw UpdateCheckError.downloadFailed
68+
}
69+
70+
do {
71+
let handle = try FileHandle(forWritingTo: destination)
72+
defer { try? handle.close() }
73+
74+
var buffer = Data()
75+
buffer.reserveCapacity(64 * 1024)
76+
var downloadedBytes: Int64 = 0
77+
var lastProgressUpdate = Date.distantPast
78+
79+
for try await byte in bytes {
80+
buffer.append(byte)
81+
downloadedBytes += 1
82+
83+
if buffer.count >= 64 * 1024 {
84+
try handle.write(contentsOf: buffer)
85+
buffer.removeAll(keepingCapacity: true)
86+
let now = Date()
87+
if now.timeIntervalSince(lastProgressUpdate) >= 0.05 {
88+
lastProgressUpdate = now
89+
await progress(UpdateDownloadProgress(
90+
downloadedBytes: downloadedBytes,
91+
totalBytes: totalBytes
92+
))
93+
}
94+
}
95+
}
96+
97+
if !buffer.isEmpty {
98+
try handle.write(contentsOf: buffer)
99+
}
100+
await progress(UpdateDownloadProgress(
101+
downloadedBytes: downloadedBytes,
102+
totalBytes: totalBytes
103+
))
104+
return destination
105+
} catch {
106+
try? fileManager.removeItem(at: destination)
107+
throw error
108+
}
109+
}
110+
}

0 commit comments

Comments
 (0)