Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/xcode-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
8 changes: 8 additions & 0 deletions Docs/GitHubReleaseNotes.md
Original file line number Diff line number Diff line change
@@ -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.
89 changes: 89 additions & 0 deletions OPN/Services/OpenNOWGitHubReleaseMetadata.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
96 changes: 56 additions & 40 deletions OPN/Services/OpenNOWGitHubUpdater.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

struct OpenNOWGitHubRelease: Sendable {
struct OpenNOWGitHubRelease: Equatable, Sendable {
let version: String
let tagName: String
let releaseNotes: String
Expand Down Expand Up @@ -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
Expand All @@ -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.")
}

Expand All @@ -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)
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading