diff --git a/.github/workflows/xcode-build.yml b/.github/workflows/xcode-build.yml index b3bd2f1c..626bba04 100644 --- a/.github/workflows/xcode-build.yml +++ b/.github/workflows/xcode-build.yml @@ -11,6 +11,10 @@ on: description: Release build number required: true default: "53" + release_notes: + description: Markdown GitHub release notes. Leave empty to use Docs/GitHubReleaseNotes.md. Do not use GitHub's generated default. + required: false + default: "" push: branches: - main @@ -72,6 +76,8 @@ jobs: name: Build macOS Release runs-on: macos-26 timeout-minutes: 45 + permissions: + contents: write env: RELEASE_ROOT: build/release-ci @@ -147,6 +153,34 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "zip_path=$ZIP_PATH" >> "$GITHUB_OUTPUT" echo "checksum_path=$CHECKSUM_PATH" >> "$GITHUB_OUTPUT" + echo "asset_name=OpenNOW-$VERSION-macOS.zip" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Prepare GitHub release notes + id: notes + env: + RELEASE_NOTES_INPUT: ${{ inputs.release_notes }} + run: | + set -euo pipefail + NOTES_PATH="$RELEASE_ROOT/RELEASE_NOTES.md" + if [[ -n "${RELEASE_NOTES_INPUT//[[:space:]]/}" ]]; then + printf '%s\n' "$RELEASE_NOTES_INPUT" > "$NOTES_PATH" + else + cp Docs/GitHubReleaseNotes.md "$NOTES_PATH" + fi + if [[ ! -s "$NOTES_PATH" ]]; then + echo "Release notes are required" >&2 + exit 1 + fi + if grep -q 'Automatically generated release notes' "$NOTES_PATH"; then + echo "Release notes must be a custom description, not GitHub's generated default" >&2 + exit 1 + fi + if (( $(wc -c < "$NOTES_PATH") < 80 )); then + echo "Release notes must be at least 80 characters and describe the release in detail" >&2 + exit 1 + fi + echo "notes_path=$NOTES_PATH" >> "$GITHUB_OUTPUT" shell: bash - name: Upload release artifact @@ -159,3 +193,35 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 7 + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.package.outputs.version }} + name: OpenNOW ${{ steps.package.outputs.version }} + body_path: ${{ steps.notes.outputs.notes_path }} + files: ${{ steps.package.outputs.zip_path }} + fail_on_unmatched_files: true + draft: false + prerelease: false + make_latest: true + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Keep only the updater macOS zip on the GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: v${{ steps.package.outputs.version }} + ASSET_NAME: ${{ steps.package.outputs.asset_name }} + run: | + set -euo pipefail + assets="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.assets[] | [.id, .name] | @tsv')" + while IFS=$'\t' read -r id name; do + [[ -z "${id:-}" ]] && continue + if [[ "$name" != "$ASSET_NAME" ]]; then + echo "Removing non-updater GitHub release asset: $name" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${id}" + fi + done <<< "$assets" + shell: bash diff --git a/Docs/GitHubReleaseNotes.md b/Docs/GitHubReleaseNotes.md new file mode 100644 index 00000000..03400173 --- /dev/null +++ b/Docs/GitHubReleaseNotes.md @@ -0,0 +1,8 @@ +OpenNOW 1.51 is the recommended NVST release and replaces 1.50. + +## Microphone Permission Hotfix +- Resolves microphone authorization before creating Geronimo and its SDL event processor. +- Prevents the first microphone permission prompt from nesting AppKit event handling while the native SDL pump is active. +- Starts without native microphone capture when access is denied or restricted instead of entering permission-sensitive native setup. + +The GitHub release asset is `OpenNOW-1.51-macOS.zip`. Checksum sidecar files are not part of the auto-updater payload. diff --git a/OPN/Services/OpenNOWGitHubReleaseMetadata.swift b/OPN/Services/OpenNOWGitHubReleaseMetadata.swift new file mode 100644 index 00000000..67eb8123 --- /dev/null +++ b/OPN/Services/OpenNOWGitHubReleaseMetadata.swift @@ -0,0 +1,89 @@ +import Foundation + +enum OpenNOWGitHubReleaseParseError: Error, Equatable { + case missingMacOSZipAsset + case missingVersionOrDownloadURL +} + +enum OpenNOWGitHubReleaseAsset { + static func isInstallableMacOSZip(named name: String) -> Bool { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let lowercased = trimmed.lowercased() + if lowercased.contains(".sha256") || lowercased.hasSuffix(".sig") || lowercased.hasSuffix(".asc") || lowercased.hasSuffix(".dmg") { + return false + } + if trimmed.hasPrefix("OpenNOW-") && trimmed.hasSuffix("-macOS.zip") { + return true + } + return lowercased.hasSuffix(".zip") && lowercased.contains("macos") + } + + static func nestedMacOSZipFileName(in entryNames: [String]) -> String? { + let fileNames = entryNames.compactMap { entry -> String? in + let components = entry.split(separator: "/").map(String.init) + guard !components.contains("__MACOSX") else { return nil } + let lastComponent = components.last ?? entry + let trimmed = lastComponent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix(".") else { return nil } + return trimmed + } + return fileNames.first { isInstallableMacOSZip(named: $0) } + } + + static func isZipArchive(dataPrefix: Data) -> Bool { + guard dataPrefix.count >= 4 else { return false } + let bytes = Array(dataPrefix.prefix(4)) + return bytes == [0x50, 0x4B, 0x03, 0x04] + || bytes == [0x50, 0x4B, 0x05, 0x06] + || bytes == [0x50, 0x4B, 0x07, 0x08] + } +} + +extension OpenNOWGitHubRelease { + static func parse(json: [String: Any]) throws -> OpenNOWGitHubRelease { + let tagName = json["tag_name"] as? String ?? "" + let version = normalizedVersion(tagName) + let releaseNotes = json["body"] as? String ?? "" + let releaseURL = json["html_url"] as? String ?? "" + let assets = json["assets"] as? [[String: Any]] ?? [] + let zipAssets = assets.filter { asset in + isInstallableMacOSZip(named: asset["name"] as? String ?? "") + } + let versionToken = "-\(version)-" + let selectedAsset = zipAssets.first { asset in + (asset["name"] as? String ?? "").contains(versionToken) + } ?? zipAssets.first + + guard let selectedAsset else { + throw OpenNOWGitHubReleaseParseError.missingMacOSZipAsset + } + let assetName = selectedAsset["name"] as? String ?? "" + let assetDownloadURL = selectedAsset["browser_download_url"] as? String ?? "" + guard !version.isEmpty, !assetName.isEmpty, !assetDownloadURL.isEmpty else { + throw OpenNOWGitHubReleaseParseError.missingVersionOrDownloadURL + } + + return OpenNOWGitHubRelease( + version: version, + tagName: tagName, + releaseNotes: releaseNotes, + releaseURL: releaseURL, + assetName: assetName, + assetDownloadURL: assetDownloadURL + ) + } + + static func normalizedVersion(_ version: String) -> String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.lowercased().hasPrefix("v") ? String(trimmed.dropFirst()) : trimmed + } + + static func isInstallableMacOSZip(named name: String) -> Bool { + OpenNOWGitHubReleaseAsset.isInstallableMacOSZip(named: name) + } + + static func nestedMacOSZipFileName(in entryNames: [String]) -> String? { + OpenNOWGitHubReleaseAsset.nestedMacOSZipFileName(in: entryNames) + } +} diff --git a/OPN/Services/OpenNOWGitHubUpdater.swift b/OPN/Services/OpenNOWGitHubUpdater.swift index 9080cdf5..66a4d1e2 100644 --- a/OPN/Services/OpenNOWGitHubUpdater.swift +++ b/OPN/Services/OpenNOWGitHubUpdater.swift @@ -1,6 +1,6 @@ import Foundation -struct OpenNOWGitHubRelease: Sendable { +struct OpenNOWGitHubRelease: Equatable, Sendable { let version: String let tagName: String let releaseNotes: String @@ -96,6 +96,8 @@ actor OpenNOWGitHubUpdater { } var request = URLRequest(url: downloadURL) + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + request.setValue("OpenNOW-Updater", forHTTPHeaderField: "User-Agent") logInfo("Downloading update archive version=\(release.version) asset=\(release.assetName)") let networkStart = OPNNetworkLog.start(&request, operation: "updater.archiveDownload") let archiveURL: URL @@ -107,7 +109,7 @@ actor OpenNOWGitHubUpdater { OPNNetworkLog.finish(request, operation: "updater.archiveDownload", startedAt: networkStart, data: nil, response: nil, error: error) throw error } - guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + guard isZipArchive(at: archiveURL) else { throw UpdateError.downloadFailed("GitHub did not return the update archive.") } @@ -123,17 +125,21 @@ actor OpenNOWGitHubUpdater { try fileManager.createDirectory(at: extractURL, withIntermediateDirectories: true) try fileManager.copyItem(at: downloadedArchiveURL, to: archiveCopyURL) logInfo("Staging update archive version=\(release.version) asset=\(release.assetName)") - - let extractProcess = Process() - extractProcess.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - extractProcess.arguments = ["-x", "-k", archiveCopyURL.path, extractURL.path] - try extractProcess.run() - extractProcess.waitUntilExit() - guard extractProcess.terminationStatus == 0 else { - throw UpdateError.extractionFailed - } - - guard let newBundleURL = findAppBundle(in: extractURL) else { + try extractZip(archiveCopyURL, to: extractURL) + + let newBundleURL: URL + if let bundleURL = findAppBundle(in: extractURL) { + newBundleURL = bundleURL + } else if let nestedZipURL = findNestedMacOSZip(in: extractURL) { + let nestedExtractURL = stagingURL.appendingPathComponent("extracted-nested", isDirectory: true) + try fileManager.createDirectory(at: nestedExtractURL, withIntermediateDirectories: true) + logInfo("Extracting nested update archive asset=\(nestedZipURL.lastPathComponent)") + try extractZip(nestedZipURL, to: nestedExtractURL) + guard let nestedBundleURL = findAppBundle(in: nestedExtractURL) else { + throw UpdateError.validationFailed("The update archive did not contain an app bundle.") + } + newBundleURL = nestedBundleURL + } else { throw UpdateError.validationFailed("The update archive did not contain an app bundle.") } clearQuarantine(for: newBundleURL) @@ -189,36 +195,47 @@ actor OpenNOWGitHubUpdater { } private func release(from json: [String: Any]) throws -> OpenNOWGitHubRelease { - let tagName = json["tag_name"] as? String ?? "" - let version = normalizedVersion(tagName) - let releaseNotes = json["body"] as? String ?? "" - let releaseURL = json["html_url"] as? String ?? "" - let assets = json["assets"] as? [[String: Any]] ?? [] - let selectedAsset = assets.first { asset in - let name = asset["name"] as? String ?? "" - return name.hasPrefix("OpenNOW-") && name.hasSuffix("-macOS.zip") - } ?? assets.first { asset in - let name = (asset["name"] as? String ?? "").lowercased() - return name.hasSuffix(".zip") && name.contains("macos") + do { + return try OpenNOWGitHubRelease.parse(json: json) + } catch is OpenNOWGitHubReleaseParseError { + throw UpdateError.noReleaseAsset } + } - guard let selectedAsset else { - throw UpdateError.noReleaseAsset + private func extractZip(_ archiveURL: URL, to destinationURL: URL) throws { + let extractProcess = Process() + extractProcess.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + extractProcess.arguments = ["-x", "-k", archiveURL.path, destinationURL.path] + try extractProcess.run() + extractProcess.waitUntilExit() + guard extractProcess.terminationStatus == 0 else { + throw UpdateError.extractionFailed } - let assetName = selectedAsset["name"] as? String ?? "" - let assetDownloadURL = selectedAsset["browser_download_url"] as? String ?? "" - guard !version.isEmpty, !assetDownloadURL.isEmpty else { - throw UpdateError.noReleaseAsset + } + + private func findNestedMacOSZip(in directoryURL: URL) -> URL? { + let enumerator = FileManager.default.enumerator(at: directoryURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) + var zipURLs: [URL] = [] + while let url = enumerator?.nextObject() as? URL { + let isRegularFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + if isRegularFile { + zipURLs.append(url) + } + } + let names = zipURLs.map(\.lastPathComponent) + guard let nestedName = OpenNOWGitHubRelease.nestedMacOSZipFileName(in: names) else { + return nil } + return zipURLs.first { $0.lastPathComponent == nestedName } + } - return OpenNOWGitHubRelease( - version: version, - tagName: tagName, - releaseNotes: releaseNotes, - releaseURL: releaseURL, - assetName: assetName, - assetDownloadURL: assetDownloadURL - ) + private func isZipArchive(at fileURL: URL) -> Bool { + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { + return false + } + defer { try? handle.close() } + let prefix = (try? handle.read(upToCount: 4)) ?? Data() + return OpenNOWGitHubReleaseAsset.isZipArchive(dataPrefix: prefix) } private func findAppBundle(in directoryURL: URL) -> URL? { @@ -321,8 +338,7 @@ actor OpenNOWGitHubUpdater { } private func normalizedVersion(_ version: String) -> String { - let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.lowercased().hasPrefix("v") ? String(trimmed.dropFirst()) : trimmed + OpenNOWGitHubRelease.normalizedVersion(version) } private func compareVersion(_ left: String, to right: String) -> Int { diff --git a/Tests/Core/OpenNOWGitHubUpdaterTests.swift b/Tests/Core/OpenNOWGitHubUpdaterTests.swift new file mode 100644 index 00000000..4c599b12 --- /dev/null +++ b/Tests/Core/OpenNOWGitHubUpdaterTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing +@testable import OpenNOW + +struct OpenNOWGitHubUpdaterTests { + @Test func selectsVersionedMacOSZipAndIgnoresChecksumSidecar() throws { + let release = try OpenNOWGitHubRelease.parse(json: githubReleaseJSON( + tagName: "v1.51", + body: "OpenNOW 1.51 is the recommended NVST release and replaces 1.50.", + assets: [ + checksumAsset(name: "OpenNOW-1.51-macOS.zip.sha256"), + zipAsset(name: "OpenNOW-1.51-macOS.zip", downloadURL: "https://github.com/OpenCloudGaming/OpenNOW-Mac/releases/download/v1.51/OpenNOW-1.51-macOS.zip"), + ] + )) + + #expect(release.version == "1.51") + #expect(release.tagName == "v1.51") + #expect(release.assetName == "OpenNOW-1.51-macOS.zip") + #expect(release.assetDownloadURL.hasSuffix("/OpenNOW-1.51-macOS.zip")) + #expect(!release.releaseNotes.isEmpty) + #expect(!release.releaseNotes.contains("Automatically generated release notes")) + } + + @Test func prefersZipMatchingReleaseVersion() throws { + let release = try OpenNOWGitHubRelease.parse(json: githubReleaseJSON( + tagName: "v1.51", + body: "OpenNOW 1.51 replaces 1.50 with the microphone permission hotfix.", + assets: [ + zipAsset(name: "OpenNOW-1.50-macOS.zip", downloadURL: "https://example.com/OpenNOW-1.50-macOS.zip"), + zipAsset(name: "OpenNOW-1.51-macOS.zip", downloadURL: "https://example.com/OpenNOW-1.51-macOS.zip"), + ] + )) + + #expect(release.assetName == "OpenNOW-1.51-macOS.zip") + #expect(release.assetDownloadURL == "https://example.com/OpenNOW-1.51-macOS.zip") + } + + @Test func rejectsReleaseWithOnlyChecksumOrSourceArchives() { + #expect(throws: OpenNOWGitHubReleaseParseError.missingMacOSZipAsset) { + try OpenNOWGitHubRelease.parse(json: githubReleaseJSON( + tagName: "v1.51", + body: "OpenNOW 1.51", + assets: [ + checksumAsset(name: "OpenNOW-1.51-macOS.zip.sha256"), + zipAsset(name: "source.zip", downloadURL: "https://example.com/source.zip"), + ] + )) + } + } + + @Test func acceptsLegacyUnversionedMacOSZipName() throws { + let release = try OpenNOWGitHubRelease.parse(json: githubReleaseJSON( + tagName: "v0.1.9", + body: "Legacy macOS zip naming used by early OpenNOW GitHub releases.", + assets: [ + zipAsset(name: "OpenNOW-macOS-arm64.zip", downloadURL: "https://example.com/OpenNOW-macOS-arm64.zip"), + ] + )) + + #expect(release.version == "0.1.9") + #expect(release.assetName == "OpenNOW-macOS-arm64.zip") + } + + @Test func findsNestedActionsArtifactZip() { + #expect( + OpenNOWGitHubRelease.nestedMacOSZipFileName(in: [ + "OpenNOW-1.51-macOS.zip.sha256", + "OpenNOW-1.51-macOS.zip", + ]) == "OpenNOW-1.51-macOS.zip" + ) + #expect( + OpenNOWGitHubRelease.nestedMacOSZipFileName(in: [ + "__MACOSX/OpenNOW-1.51-macOS.zip", + ".DS_Store", + ]) == nil + ) + } + + @Test func recognizesZipMagicBytes() { + #expect(OpenNOWGitHubReleaseAsset.isZipArchive(dataPrefix: Data([0x50, 0x4B, 0x03, 0x04, 0x00]))) + #expect(!OpenNOWGitHubReleaseAsset.isZipArchive(dataPrefix: Data("".utf8))) + #expect(!OpenNOWGitHubReleaseAsset.isZipArchive(dataPrefix: Data([0x50, 0x4B]))) + } + + @Test func liveLatestReleaseMetadataIsInstallable() async throws { + let url = try #require(URL(string: "https://api.github.com/repos/OpenCloudGaming/OpenNOW-Mac/releases/latest")) + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("OpenNOW-Updater", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + #expect((200..<300).contains(http.statusCode)) + #expect(!data.isEmpty) + let decoded = try JSONSerialization.jsonObject(with: data) + let json = try #require(decoded as? [String: Any]) + let release = try OpenNOWGitHubRelease.parse(json: json) + + #expect(OpenNOWGitHubRelease.isInstallableMacOSZip(named: release.assetName)) + #expect(!release.assetName.lowercased().contains(".sha256")) + #expect(!release.releaseNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(!release.releaseNotes.contains("Automatically generated release notes")) + #expect(release.assetDownloadURL.hasPrefix("https://github.com/OpenCloudGaming/OpenNOW-Mac/releases/download/")) + #expect(release.assetDownloadURL.hasSuffix("/\(release.assetName)")) + } + + private func githubReleaseJSON(tagName: String, body: String, assets: [[String: Any]]) -> [String: Any] { + [ + "tag_name": tagName, + "body": body, + "html_url": "https://github.com/OpenCloudGaming/OpenNOW-Mac/releases/tag/\(tagName)", + "assets": assets, + ] + } + + private func zipAsset(name: String, downloadURL: String) -> [String: Any] { + [ + "name": name, + "browser_download_url": downloadURL, + "content_type": "application/zip", + "state": "uploaded", + ] + } + + private func checksumAsset(name: String) -> [String: Any] { + [ + "name": name, + "browser_download_url": "https://github.com/OpenCloudGaming/OpenNOW-Mac/releases/download/v1.51/\(name)", + "content_type": "application/octet-stream", + "state": "uploaded", + ] + } +}