diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8021bec..4f193b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,24 @@ jobs: ONLY_ACTIVE_ARCH=YES ARCHS=arm64 + - name: Validate release entitlements + run: | + python3 - <<'PY' + import plistlib + from pathlib import Path + + base = plistlib.loads(Path("JammLab/JammLab.entitlements").read_bytes()) + release = plistlib.loads(Path("JammLab/JammLab.Release.entitlements").read_bytes()) + expected = dict(base) + expected["com.apple.security.network.client"] = True + + if release != expected: + raise SystemExit( + "Release entitlements must equal the base entitlements plus " + "com.apple.security.network.client=true" + ) + PY + build-smoke: name: Unsigned Build Smoke (${{ matrix.configuration }}) runs-on: macos-26 @@ -129,21 +147,58 @@ jobs: run: scripts/build_separator_helper.sh - name: Build unsigned release app - run: > - xcodebuild build - -project "$XCODE_PROJECT" - -scheme "$XCODE_SCHEME" - -configuration Release - -destination "$XCODE_DESTINATION" - -derivedDataPath "$DERIVED_DATA_PATH" - CODE_SIGN_IDENTITY=- - CODE_SIGN_STYLE=Manual - DEVELOPMENT_TEAM= - PROVISIONING_PROFILE_SPECIFIER= - MARKETING_VERSION="$APP_VERSION" - CURRENT_PROJECT_VERSION="${{ github.run_number }}" - ONLY_ACTIVE_ARCH=YES - ARCHS=arm64 + run: | + set -euo pipefail + update_check_build_setting=() + if [[ "$RELEASE_CHANNEL" == "stable" ]]; then + update_check_build_setting+=("SWIFT_ACTIVE_COMPILATION_CONDITIONS=JAMMLAB_STABLE_RELEASE") + fi + + xcodebuild test -quiet \ + -project "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration Release \ + -destination "$XCODE_DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + ENABLE_TESTABILITY=YES \ + ONLY_ACTIVE_ARCH=YES \ + ARCHS=arm64 \ + -only-testing:JammLabTests/AppVersionTests/testCompiledReleasePolicyMatchesBuildFlags \ + "${update_check_build_setting[@]}" + + xcodebuild build \ + -project "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration Release \ + -destination "$XCODE_DESTINATION" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM= \ + PROVISIONING_PROFILE_SPECIFIER= \ + MARKETING_VERSION="$APP_VERSION" \ + CURRENT_PROJECT_VERSION="${{ github.run_number }}" \ + ONLY_ACTIVE_ARCH=YES \ + ARCHS=arm64 \ + "${update_check_build_setting[@]}" + + app_path="$DERIVED_DATA_PATH/Build/Products/Release/JammLab.app" + built_version="$(plutil -extract CFBundleShortVersionString raw -o - "$app_path/Contents/Info.plist")" + if [[ "$built_version" != "$APP_VERSION" ]]; then + echo "Release app version '$built_version' does not match '$APP_VERSION'" >&2 + exit 1 + fi + + entitlements_file="$(mktemp)" + trap 'rm -f "$entitlements_file"' EXIT + codesign -d --entitlements :- "$app_path" > "$entitlements_file" + network_client="$(plutil -extract 'com\.apple\.security\.network\.client' raw -o - "$entitlements_file")" + if [[ "$network_client" != "true" ]]; then + echo "Release app is missing com.apple.security.network.client=true" >&2 + exit 1 + fi - name: Package unsigned DMG run: > diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287ecb..c9aa243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added a stable-release update check that shows GitHub release notes at launch, supports reminding later or skipping one version, and keeps development and beta builds offline. - Moved audio and Stem playback preparation off the main thread, with cancellable progress, safer memory limits, and transactional project/mode switching that keeps the current audio available if preparation fails. - Hardened the bundled Stem helper with a versioned v6 job protocol, startup capability checks, stale-helper detection, and one validated manifest for bundled models and compute modes. - Fixed automatic Stem transcription notation to show flats, naturals, and sharps consistently with the key signature and common-practice measure rules. diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index fbb757b..1193850 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -103,6 +103,11 @@ A14D01033000000100112233 /* NotationWindowToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00033000000100112233 /* NotationWindowToolbar.swift */; }; A14D01043000000100112233 /* AudioPlayerViewModel+NotationParts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00043000000100112233 /* AudioPlayerViewModel+NotationParts.swift */; }; A14D01053000000100112233 /* NotationTrackInteractionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A14D00053000000100112233 /* NotationTrackInteractionModels.swift */; }; + A15A01013000000100112233 /* AppUpdateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15A00013000000100112233 /* AppUpdateModels.swift */; }; + A15A01023000000100112233 /* GitHubLatestReleaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15A00023000000100112233 /* GitHubLatestReleaseClient.swift */; }; + A15A01033000000100112233 /* AppUpdateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15A00033000000100112233 /* AppUpdateCoordinator.swift */; }; + A15A01043000000100112233 /* UpdateAvailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15A00043000000100112233 /* UpdateAvailableView.swift */; }; + A15A01053000000100112233 /* AppUpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15A00053000000100112233 /* AppUpdateCheckerTests.swift */; }; 9F8E01032C40000100112233 /* StemModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D00012C30000100112233 /* StemModels.swift */; }; 9F8E01042C40000100112233 /* StemSeparationJobModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */; }; 9F8F01012C50000100112233 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8F00012C50000100112233 /* AppDefaults.swift */; }; @@ -417,6 +422,12 @@ A14D00033000000100112233 /* NotationWindowToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowToolbar.swift; sourceTree = ""; }; A14D00043000000100112233 /* AudioPlayerViewModel+NotationParts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioPlayerViewModel+NotationParts.swift"; sourceTree = ""; }; A14D00053000000100112233 /* NotationTrackInteractionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackInteractionModels.swift; sourceTree = ""; }; + A15A00013000000100112233 /* AppUpdateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateModels.swift; sourceTree = ""; }; + A15A00023000000100112233 /* GitHubLatestReleaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubLatestReleaseClient.swift; sourceTree = ""; }; + A15A00033000000100112233 /* AppUpdateCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateCoordinator.swift; sourceTree = ""; }; + A15A00043000000100112233 /* UpdateAvailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateAvailableView.swift; sourceTree = ""; }; + A15A00053000000100112233 /* AppUpdateCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateCheckerTests.swift; sourceTree = ""; }; + A15A00063000000100112233 /* JammLab.Release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = JammLab.Release.entitlements; sourceTree = ""; }; 9F8F00012C50000100112233 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefaults.swift; sourceTree = ""; }; 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackControlling.swift; sourceTree = ""; }; A14B00013000000100112233 /* AudioPlaybackPreparation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackPreparation.swift; sourceTree = ""; }; @@ -661,6 +672,7 @@ 9F7003002F80000100112233 /* Transcription */, 9F8A000B2C00000100112233 /* Assets.xcassets */, 9F8A000C2C00000100112233 /* JammLab.entitlements */, + A15A00063000000100112233 /* JammLab.Release.entitlements */, A14A00013000000100112233 /* AudioRenderAtomics.c */, A14A00023000000100112233 /* AudioRenderAtomics.h */, 9F7000202F80000100112233 /* JammLab-Bridging-Header.h */, @@ -674,6 +686,7 @@ 9F8A00062C00000100112233 /* AnalysisResult.swift */, 9F8F00012C50000100112233 /* AppDefaults.swift */, 9F9400012CA0000100112233 /* AppSettingsStore.swift */, + A15A00013000000100112233 /* AppUpdateModels.swift */, 9F8A00162C00000100112233 /* BeatGridSettings.swift */, 9F8A000A2C00000100112233 /* ImportedAudioFile.swift */, 9F8A00072C00000100112233 /* LoopRegion.swift */, @@ -706,6 +719,7 @@ 9F8F00022C50000100112233 /* AudioPlaybackControlling.swift */, A14B00013000000100112233 /* AudioPlaybackPreparation.swift */, 9FCB00012D80000100112233 /* AudioSampleConverter.swift */, + A15A00023000000100112233 /* GitHubLatestReleaseClient.swift */, 9F8A00172C00000100112233 /* BeatGridCalculator.swift */, 9FBE00012F70000100112233 /* NotationBeamLayout.swift */, 9FBE00032F70000100112233 /* NotationBeamGrouping.swift */, @@ -770,6 +784,7 @@ 9F8A03052C00000100112233 /* ViewModels */ = { isa = PBXGroup; children = ( + A15A00033000000100112233 /* AppUpdateCoordinator.swift */, 9F8A00032C00000100112233 /* AudioPlayerViewModel.swift */, 9FB201012D30000100112233 /* AudioPlayerViewModel+Project.swift */, 9FDE00042F60000100112233 /* AudioPlayerViewModel+Analysis.swift */, @@ -882,6 +897,7 @@ 9F8B03012C10000100112233 /* JammLabTests */ = { isa = PBXGroup; children = ( + A15A00053000000100112233 /* AppUpdateCheckerTests.swift */, 9F7000222F80000100112233 /* StemTranscriptionTests.swift */, 9F7000232F80000100112233 /* BasicPitchNativeTests.mm */, 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */, @@ -1039,6 +1055,7 @@ 9F8C00072C20000100112233 /* TransportBarView.swift */, 9FCB00082D80000100112233 /* TransportButton.swift */, 9F9000012C60000100112233 /* TransportControlsView.swift */, + A15A00043000000100112233 /* UpdateAvailableView.swift */, 9FAF00022D02000100112233 /* WindowCloseGuard.swift */, 9F8C00082C20000100112233 /* WindowTitleUpdater.swift */, ); @@ -1249,6 +1266,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A15A01013000000100112233 /* AppUpdateModels.swift in Sources */, + A15A01023000000100112233 /* GitHubLatestReleaseClient.swift in Sources */, + A15A01033000000100112233 /* AppUpdateCoordinator.swift in Sources */, + A15A01043000000100112233 /* UpdateAvailableView.swift in Sources */, 9F8A01062C00000100112233 /* AnalysisResult.swift in Sources */, 9F8F01012C50000100112233 /* AppDefaults.swift in Sources */, 9F8A01052C00000100112233 /* AudioAnalyzer.swift in Sources */, @@ -1398,6 +1419,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A15A01053000000100112233 /* AppUpdateCheckerTests.swift in Sources */, 9F7001222F80000100112233 /* StemTranscriptionTests.swift in Sources */, 9F7001232F80000100112233 /* BasicPitchNativeTests.mm in Sources */, 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */, @@ -1731,7 +1753,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; AUTOMATION_APPLE_EVENTS = NO; - CODE_SIGN_ENTITLEMENTS = JammLab/JammLab.entitlements; + CODE_SIGN_ENTITLEMENTS = JammLab/JammLab.Release.entitlements; CODE_SIGN_STYLE = Automatic; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -1754,7 +1776,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 57dcd44..fe56476 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -120,6 +120,8 @@ enum AppTheme { static let notationMinHeight: CGFloat = 420 static let tunerWidth: CGFloat = 360 static let tunerMinHeight: CGFloat = 460 + static let updateSheetWidth: CGFloat = 640 + static let updateReleaseNotesHeight: CGFloat = 240 } enum Colors { diff --git a/JammLab/JammLab.Release.entitlements b/JammLab/JammLab.Release.entitlements new file mode 100644 index 0000000..e87bbac --- /dev/null +++ b/JammLab/JammLab.Release.entitlements @@ -0,0 +1,21 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.exception.mach-lookup.global-name + + com.apple.audioanalyticsd + com.apple.linkd.autoShortcut + + com.apple.security.files.user-selected.executable + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + + diff --git a/JammLab/JammLabApp.swift b/JammLab/JammLabApp.swift index 9c902da..d491aac 100644 --- a/JammLab/JammLabApp.swift +++ b/JammLab/JammLabApp.swift @@ -10,12 +10,14 @@ struct JammLabApp: App { @NSApplicationDelegateAdaptor(JammLabAppDelegate.self) private var appDelegate @StateObject private var settingsStore: AppSettingsStore @StateObject private var viewModel: AudioPlayerViewModel + @StateObject private var updateCoordinator: AppUpdateCoordinator @StateObject private var recentProjectsStore = RecentProjectsStore.shared init() { let settingsStore = AppSettingsStore() _settingsStore = StateObject(wrappedValue: settingsStore) _viewModel = StateObject(wrappedValue: AudioPlayerViewModel(appSettingsStore: settingsStore)) + _updateCoordinator = StateObject(wrappedValue: AppUpdateCoordinator.live()) } var body: some Scene { @@ -23,6 +25,7 @@ struct JammLabApp: App { ContentView(viewModel: viewModel) .frame(minWidth: AppTheme.Window.minWidth, minHeight: AppTheme.Window.minHeight) .environment(\.appColors, AppThemeColors(palette: settingsStore.colorPalette)) + .appUpdateCheckHost(coordinator: updateCoordinator) .onAppear { appDelegate.viewModel = viewModel } diff --git a/JammLab/Models/AppUpdateModels.swift b/JammLab/Models/AppUpdateModels.swift new file mode 100644 index 0000000..8d2c5c0 --- /dev/null +++ b/JammLab/Models/AppUpdateModels.swift @@ -0,0 +1,87 @@ +import Foundation + +struct AppVersion: Comparable, Hashable, Identifiable, Sendable { + let major: Int + let minor: Int + let patch: Int + + var id: String { description } + + var description: String { + "\(major).\(minor).\(patch)" + } + + init?(stableVersionString rawValue: String) { + let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let versionValue = trimmedValue.hasPrefix("v") + ? String(trimmedValue.dropFirst()) + : trimmedValue + let components = versionValue.split(separator: ".", omittingEmptySubsequences: false) + + guard + components.count == 3, + components.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }), + let major = Int(components[0]), + let minor = Int(components[1]), + let patch = Int(components[2]) + else { + return nil + } + + self.major = major + self.minor = minor + self.patch = patch + } + + static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } +} + +struct AppRelease: Equatable, Identifiable, Sendable { + let version: AppVersion + let title: String + let notes: String + let pageURL: URL + + var id: AppVersion { version } +} + +struct AppBuildMetadata: Equatable, Sendable { + let version: AppVersion? + let isStableReleaseBuild: Bool + + var isStableRelease: Bool { + version != nil && isStableReleaseBuild + } + + init(versionString: String?, isStableReleaseBuild: Bool) { + version = versionString.flatMap(AppVersion.init(stableVersionString:)) + self.isStableReleaseBuild = isStableReleaseBuild + } + + init(bundle: Bundle = .main) { + self.init( + versionString: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + isStableReleaseBuild: Self.compiledIsStableReleaseBuild + ) + } + + static var compiledIsStableReleaseBuild: Bool { +#if JAMMLAB_STABLE_RELEASE + true +#else + false +#endif + } +} + +enum AppUpdateRuntimePolicy { + static func automaticChecksEnabled(for metadata: AppBuildMetadata) -> Bool { +#if DEBUG + false +#else + metadata.isStableRelease +#endif + } +} diff --git a/JammLab/Services/GitHubLatestReleaseClient.swift b/JammLab/Services/GitHubLatestReleaseClient.swift new file mode 100644 index 0000000..ccb192a --- /dev/null +++ b/JammLab/Services/GitHubLatestReleaseClient.swift @@ -0,0 +1,125 @@ +import Foundation + +protocol HTTPDataLoading { + func loadData(for request: URLRequest) async throws -> (Data, URLResponse) +} + +struct URLSessionHTTPDataLoader: HTTPDataLoading { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func loadData(for request: URLRequest) async throws -> (Data, URLResponse) { + try await session.data(for: request) + } +} + +protocol LatestReleaseProviding { + func fetchLatestRelease(currentVersion: AppVersion) async throws -> AppRelease +} + +enum GitHubLatestReleaseError: Error { + case invalidResponse + case unsuccessfulStatus(Int) + case unsupportedRelease + case invalidReleaseURL +} + +final class GitHubLatestReleaseClient: LatestReleaseProviding { + static let endpoint = URL(string: "https://api.github.com/repos/cyberflow/JammLab/releases/latest")! + static let apiVersion = "2026-03-10" + static let timeout: TimeInterval = 10 + + private let dataLoader: HTTPDataLoading + private let decoder: JSONDecoder + + init( + dataLoader: HTTPDataLoading = URLSessionHTTPDataLoader(), + decoder: JSONDecoder = JSONDecoder() + ) { + self.dataLoader = dataLoader + self.decoder = decoder + } + + func fetchLatestRelease(currentVersion: AppVersion) async throws -> AppRelease { + let request = makeRequest(currentVersion: currentVersion) + let (data, response) = try await dataLoader.loadData(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GitHubLatestReleaseError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + throw GitHubLatestReleaseError.unsuccessfulStatus(httpResponse.statusCode) + } + + let payload = try decoder.decode(GitHubReleaseResponse.self, from: data) + guard + !payload.draft, + !payload.prerelease, + let version = AppVersion(stableVersionString: payload.tagName) + else { + throw GitHubLatestReleaseError.unsupportedRelease + } + guard Self.isTrustedReleaseURL(payload.htmlURL) else { + throw GitHubLatestReleaseError.invalidReleaseURL + } + + let normalizedTitle = payload.name? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedNotes = payload.body? + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = normalizedTitle.flatMap { $0.isEmpty ? nil : $0 } + ?? "JammLab \(version.description)" + + return AppRelease( + version: version, + title: title, + notes: normalizedNotes ?? "", + pageURL: payload.htmlURL + ) + } + + func makeRequest(currentVersion: AppVersion) -> URLRequest { + var request = URLRequest( + url: Self.endpoint, + cachePolicy: .reloadRevalidatingCacheData, + timeoutInterval: Self.timeout + ) + request.httpMethod = "GET" + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue(Self.apiVersion, forHTTPHeaderField: "X-GitHub-Api-Version") + request.setValue("JammLab/\(currentVersion.description)", forHTTPHeaderField: "User-Agent") + return request + } + + static func isTrustedReleaseURL(_ url: URL) -> Bool { + guard + url.scheme?.lowercased() == "https", + url.host?.lowercased() == "github.com" + else { + return false + } + + return url.path.hasPrefix("/cyberflow/JammLab/releases/") + } +} + +private struct GitHubReleaseResponse: Decodable { + let tagName: String + let name: String? + let body: String? + let htmlURL: URL + let draft: Bool + let prerelease: Bool + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case name + case body + case htmlURL = "html_url" + case draft + case prerelease + } +} diff --git a/JammLab/ViewModels/AppUpdateCoordinator.swift b/JammLab/ViewModels/AppUpdateCoordinator.swift new file mode 100644 index 0000000..ce9f091 --- /dev/null +++ b/JammLab/ViewModels/AppUpdateCoordinator.swift @@ -0,0 +1,91 @@ +import Combine +import Foundation +import OSLog + +@MainActor +final class AppUpdateCoordinator: ObservableObject { + static let skippedVersionDefaultsKey = "updates.skippedVersion" + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.cyberflow.JammLab", + category: "AppUpdate" + ) + + let currentVersion: AppVersion? + + private let releaseProvider: LatestReleaseProviding + private let defaults: UserDefaults + private let checksEnabled: Bool + private var didAttemptLaunchCheck = false + + init( + releaseProvider: LatestReleaseProviding, + currentVersion: AppVersion?, + defaults: UserDefaults = .standard, + checksEnabled: Bool + ) { + self.releaseProvider = releaseProvider + self.currentVersion = currentVersion + self.defaults = defaults + self.checksEnabled = checksEnabled + } + + static func live( + bundle: Bundle = .main, + defaults: UserDefaults = .standard + ) -> AppUpdateCoordinator { + let metadata = AppBuildMetadata(bundle: bundle) + return AppUpdateCoordinator( + releaseProvider: GitHubLatestReleaseClient(), + currentVersion: metadata.version, + defaults: defaults, + checksEnabled: AppUpdateRuntimePolicy.automaticChecksEnabled(for: metadata) + ) + } + + func checkAtLaunch() async -> AppRelease? { + guard !didAttemptLaunchCheck else { return nil } + didAttemptLaunchCheck = true + + guard checksEnabled, let currentVersion else { return nil } + + do { + let release = try await releaseProvider.fetchLatestRelease(currentVersion: currentVersion) + let skippedVersion = storedSkippedVersion() + + if let skippedVersion, skippedVersion < release.version { + defaults.removeObject(forKey: Self.skippedVersionDefaultsKey) + } + + guard + release.version > currentVersion, + skippedVersion != release.version + else { + return nil + } + + return release + } catch { + Self.logger.debug( + "Automatic update check failed: \(String(describing: error), privacy: .public)" + ) + return nil + } + } + + func isSkipping(_ release: AppRelease) -> Bool { + storedSkippedVersion() == release.version + } + + func setSkipping(_ shouldSkip: Bool, release: AppRelease) { + if shouldSkip { + defaults.set(release.version.description, forKey: Self.skippedVersionDefaultsKey) + } else if storedSkippedVersion() == release.version { + defaults.removeObject(forKey: Self.skippedVersionDefaultsKey) + } + } + + private func storedSkippedVersion() -> AppVersion? { + defaults.string(forKey: Self.skippedVersionDefaultsKey) + .flatMap(AppVersion.init(stableVersionString:)) + } +} diff --git a/JammLab/Views/Components/UpdateAvailableView.swift b/JammLab/Views/Components/UpdateAvailableView.swift new file mode 100644 index 0000000..a507488 --- /dev/null +++ b/JammLab/Views/Components/UpdateAvailableView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct AppUpdateHostModifier: ViewModifier { + let coordinator: AppUpdateCoordinator + + @State private var presentedRelease: AppRelease? + + func body(content: Content) -> some View { + content + .task { + presentedRelease = await coordinator.checkAtLaunch() + } + .sheet(item: $presentedRelease) { release in + UpdateAvailableView( + currentVersion: coordinator.currentVersion, + release: release, + isSkippingVersion: Binding( + get: { coordinator.isSkipping(release) }, + set: { coordinator.setSkipping($0, release: release) } + ), + onDismiss: { + presentedRelease = nil + } + ) + } + } +} + +extension View { + func appUpdateCheckHost(coordinator: AppUpdateCoordinator) -> some View { + modifier(AppUpdateHostModifier(coordinator: coordinator)) + } +} + +struct UpdateAvailableView: View { + let currentVersion: AppVersion? + let release: AppRelease + @Binding var isSkippingVersion: Bool + let onDismiss: () -> Void + private let releaseNotes: AttributedString + + @Environment(\.appColors) private var appColors + @Environment(\.openURL) private var openURL + + init( + currentVersion: AppVersion?, + release: AppRelease, + isSkippingVersion: Binding, + onDismiss: @escaping () -> Void + ) { + self.currentVersion = currentVersion + self.release = release + _isSkippingVersion = isSkippingVersion + self.onDismiss = onDismiss + + let source = release.notes.isEmpty + ? "Release notes are available on GitHub." + : release.notes + releaseNotes = AppReleaseNotesFormatter.nonInteractiveMarkdown(source) + } + + var body: some View { + VStack(alignment: .leading, spacing: AppTheme.Spacing.sectionGap) { + VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { + Text("A New Version Is Available") + .font(.title2.weight(.semibold)) + .foregroundStyle(appColors.primaryText) + + Text(versionSummary) + .font(.body) + .foregroundStyle(appColors.secondaryText) + } + + VStack(alignment: .leading, spacing: AppTheme.Spacing.md) { + Text(release.title) + .font(AppTheme.Typography.sectionTitle) + .foregroundStyle(appColors.primaryText) + + ScrollView { + Text(releaseNotes) + .font(.body) + .foregroundStyle(appColors.secondaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppTheme.Spacing.panelPadding) + } + .frame(height: AppTheme.Window.updateReleaseNotesHeight) + .background(appColors.controlBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.medium)) + } + + Toggle("Skip This Version", isOn: $isSkippingVersion) + .foregroundStyle(appColors.primaryText) + .help("Do not show this update again. A newer release will still be shown.") + .accessibilityLabel("Skip this version") + .accessibilityValue(isSkippingVersion ? "Enabled" : "Disabled") + + HStack(spacing: AppTheme.Spacing.md) { + Spacer() + + Button("Remind Me Later") { + onDismiss() + } + .help("Close this message and show it again the next time JammLab launches.") + .accessibilityLabel("Remind me later") + + Button("Download on GitHub") { + openURL(release.pageURL) + onDismiss() + } + .keyboardShortcut(.defaultAction) + .help("Open the JammLab release page in your web browser.") + .accessibilityLabel("Download JammLab \(release.version.description) on GitHub") + } + } + .padding(AppTheme.Spacing.windowPadding) + .frame(width: AppTheme.Window.updateSheetWidth) + .background(appColors.panelBackground) + } + + private var versionSummary: String { + let installedVersion = currentVersion?.description ?? "Unknown" + return "Installed: \(installedVersion) • Available: \(release.version.description)" + } +} + +enum AppReleaseNotesFormatter { + static func nonInteractiveMarkdown(_ source: String) -> AttributedString { + guard var attributed = try? AttributedString(markdown: source) else { + return AttributedString(source) + } + + let linkRanges = attributed.runs.compactMap { run in + run.link == nil ? nil : run.range + } + for range in linkRanges { + attributed[range].link = nil + } + return attributed + } +} diff --git a/JammLabTests/AppUpdateCheckerTests.swift b/JammLabTests/AppUpdateCheckerTests.swift new file mode 100644 index 0000000..cee0931 --- /dev/null +++ b/JammLabTests/AppUpdateCheckerTests.swift @@ -0,0 +1,537 @@ +import Foundation +import XCTest +@testable import JammLab + +final class AppVersionTests: XCTestCase { + func testStableVersionsUseNumericComparison() throws { + let older = try XCTUnwrap(AppVersion(stableVersionString: "1.9.0")) + let newer = try XCTUnwrap(AppVersion(stableVersionString: "1.10.0")) + + XCTAssertLessThan(older, newer) + XCTAssertEqual(newer.description, "1.10.0") + } + + func testStableVersionAcceptsLeadingV() throws { + XCTAssertEqual( + AppVersion(stableVersionString: "v2.3.4"), + AppVersion(stableVersionString: "2.3.4") + ) + } + + func testStableVersionRequiresExactlyThreeNumericComponents() { + XCTAssertNil(AppVersion(stableVersionString: "1")) + XCTAssertNil(AppVersion(stableVersionString: "1.0")) + XCTAssertNil(AppVersion(stableVersionString: "1.0.0.1")) + XCTAssertNil(AppVersion(stableVersionString: "1.0.x")) + XCTAssertNil(AppVersion(stableVersionString: "1.0.0-beta")) + XCTAssertNil(AppVersion(stableVersionString: "1.0.0-dev.1")) + } + + func testBuildMetadataRequiresStableBuildFlagAndVersion() { + XCTAssertTrue( + AppBuildMetadata( + versionString: "1.2.3", + isStableReleaseBuild: true + ).isStableRelease + ) + XCTAssertFalse( + AppBuildMetadata( + versionString: "1.2.3", + isStableReleaseBuild: false + ).isStableRelease + ) + XCTAssertFalse( + AppBuildMetadata( + versionString: "1.2", + isStableReleaseBuild: true + ).isStableRelease + ) + } + + func testCompiledReleasePolicyMatchesBuildFlags() { + let metadata = AppBuildMetadata( + versionString: "1.2.3", + isStableReleaseBuild: AppBuildMetadata.compiledIsStableReleaseBuild + ) + +#if JAMMLAB_STABLE_RELEASE + XCTAssertTrue(metadata.isStableRelease) +#if DEBUG + XCTAssertFalse(AppUpdateRuntimePolicy.automaticChecksEnabled(for: metadata)) +#else + XCTAssertTrue(AppUpdateRuntimePolicy.automaticChecksEnabled(for: metadata)) +#endif +#else + XCTAssertFalse(metadata.isStableRelease) + XCTAssertFalse(AppUpdateRuntimePolicy.automaticChecksEnabled(for: metadata)) +#endif + } + + func testReleaseNotesFormatterRemovesMarkdownLinks() { + let attributed = AppReleaseNotesFormatter.nonInteractiveMarkdown( + "Read [release details](https://example.com) for **more**." + ) + + XCTAssertFalse(attributed.runs.contains { $0.link != nil }) + XCTAssertTrue(String(attributed.characters).contains("release details")) + } +} + +final class GitHubLatestReleaseClientTests: XCTestCase { + func testRequestTargetsLatestReleaseWithRequiredHeadersAndTimeout() throws { + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + let client = GitHubLatestReleaseClient() + + let request = client.makeRequest(currentVersion: currentVersion) + + XCTAssertEqual(request.url, GitHubLatestReleaseClient.endpoint) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.timeoutInterval, GitHubLatestReleaseClient.timeout) + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/vnd.github+json") + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-GitHub-Api-Version"), + GitHubLatestReleaseClient.apiVersion + ) + XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "JammLab/1.2.3") + } + + func testClientDecodesStableRelease() async throws { + let loader = StubHTTPDataLoader( + data: releaseJSON( + tag: "v1.3.0", + name: "JammLab 1.3", + body: "Release notes", + htmlURL: "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0" + ), + statusCode: 200 + ) + let client = GitHubLatestReleaseClient(dataLoader: loader) + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + + let release = try await client.fetchLatestRelease(currentVersion: currentVersion) + + XCTAssertEqual(release.version.description, "1.3.0") + XCTAssertEqual(release.title, "JammLab 1.3") + XCTAssertEqual(release.notes, "Release notes") + XCTAssertEqual( + release.pageURL.absoluteString, + "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0" + ) + } + + func testClientFallsBackWhenOptionalTextIsMissing() async throws { + let loader = StubHTTPDataLoader( + data: releaseJSON( + tag: "v1.3.0", + name: nil, + body: nil, + htmlURL: "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0" + ), + statusCode: 200 + ) + let client = GitHubLatestReleaseClient(dataLoader: loader) + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + + let release = try await client.fetchLatestRelease(currentVersion: currentVersion) + + XCTAssertEqual(release.title, "JammLab 1.3.0") + XCTAssertEqual(release.notes, "") + } + + func testClientRejectsUnsuccessfulStatus() async throws { + let loader = StubHTTPDataLoader(data: Data(), statusCode: 403) + let client = GitHubLatestReleaseClient(dataLoader: loader) + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + + do { + _ = try await client.fetchLatestRelease(currentVersion: currentVersion) + XCTFail("Expected an HTTP status error") + } catch let error as GitHubLatestReleaseError { + guard case .unsuccessfulStatus(403) = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testClientRejectsDraftPrereleaseAndSuffixedTags() async throws { + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + let payloads = [ + releaseJSON( + tag: "v1.3.0", + htmlURL: "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0", + draft: true + ), + releaseJSON( + tag: "v1.3.0", + htmlURL: "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0", + prerelease: true + ), + releaseJSON( + tag: "v1.3.0-beta", + htmlURL: "https://github.com/cyberflow/JammLab/releases/tag/v1.3.0-beta" + ) + ] + + for payload in payloads { + let client = GitHubLatestReleaseClient( + dataLoader: StubHTTPDataLoader(data: payload, statusCode: 200) + ) + + do { + _ = try await client.fetchLatestRelease(currentVersion: currentVersion) + XCTFail("Expected an unsupported release error") + } catch let error as GitHubLatestReleaseError { + guard case .unsupportedRelease = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + } + + func testClientRejectsUntrustedReleaseURL() async throws { + let loader = StubHTTPDataLoader( + data: releaseJSON( + tag: "v1.3.0", + htmlURL: "https://example.com/cyberflow/JammLab/releases/tag/v1.3.0" + ), + statusCode: 200 + ) + let client = GitHubLatestReleaseClient(dataLoader: loader) + let currentVersion = try XCTUnwrap(AppVersion(stableVersionString: "1.2.3")) + + do { + _ = try await client.fetchLatestRelease(currentVersion: currentVersion) + XCTFail("Expected an invalid release URL error") + } catch let error as GitHubLatestReleaseError { + guard case .invalidReleaseURL = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } + + func testTrustedReleaseURLValidation() { + XCTAssertTrue( + GitHubLatestReleaseClient.isTrustedReleaseURL( + URL(string: "https://github.com/cyberflow/JammLab/releases/tag/v1.2.3")! + ) + ) + XCTAssertFalse( + GitHubLatestReleaseClient.isTrustedReleaseURL( + URL(string: "http://github.com/cyberflow/JammLab/releases/tag/v1.2.3")! + ) + ) + XCTAssertFalse( + GitHubLatestReleaseClient.isTrustedReleaseURL( + URL(string: "https://example.com/cyberflow/JammLab/releases/tag/v1.2.3")! + ) + ) + XCTAssertFalse( + GitHubLatestReleaseClient.isTrustedReleaseURL( + URL(string: "https://github.com/another/project/releases/tag/v1.2.3")! + ) + ) + } + + private func releaseJSON( + tag: String, + name: String? = "JammLab", + body: String? = "Notes", + htmlURL: String, + draft: Bool = false, + prerelease: Bool = false + ) -> Data { + var payload: [String: Any] = [ + "tag_name": tag, + "html_url": htmlURL, + "draft": draft, + "prerelease": prerelease + ] + if let name { + payload["name"] = name + } + if let body { + payload["body"] = body + } + return try! JSONSerialization.data(withJSONObject: payload) + } +} + +@MainActor +final class AppUpdateCoordinatorTests: XCTestCase { + func testDisabledCheckDoesNotCallProvider() async throws { + let provider = StubLatestReleaseProvider(release: try release("1.3.0")) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: try temporaryUserDefaults(), + checksEnabled: false + ) + + let result = await coordinator.checkAtLaunch() + let invocationCount = await provider.count() + + XCTAssertNil(result) + XCTAssertEqual(invocationCount, 0) + } + + func testLaunchCheckRunsOnlyOnce() async throws { + let provider = StubLatestReleaseProvider(release: try release("1.3.0")) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: try temporaryUserDefaults(), + checksEnabled: true + ) + + let firstResult = await coordinator.checkAtLaunch() + let secondResult = await coordinator.checkAtLaunch() + let invocationCount = await provider.count() + + XCTAssertNotNil(firstResult) + XCTAssertNil(secondResult) + XCTAssertEqual(invocationCount, 1) + } + + func testConcurrentLaunchChecksRequestOnceAndReturnOneRelease() async throws { + let targetRelease = try release("1.3.0") + let provider = GatedLatestReleaseProvider(release: targetRelease) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: try temporaryUserDefaults(), + checksEnabled: true + ) + + async let firstResult = coordinator.checkAtLaunch() + await provider.waitUntilRequestStarts() + async let secondResult = coordinator.checkAtLaunch() + await provider.resume() + + let results = await [firstResult, secondResult] + let invocationCount = await provider.count() + + XCTAssertEqual(results.compactMap { $0 }, [targetRelease]) + XCTAssertEqual(invocationCount, 1) + } + + func testNewerReleaseIsPresentedButEqualOrOlderReleaseIsNot() async throws { + let currentVersion = try version("1.2.3") + + for (latestVersion, shouldPresent) in [ + ("1.3.0", true), + ("1.2.3", false), + ("1.2.2", false) + ] { + let provider = StubLatestReleaseProvider(release: try release(latestVersion)) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: currentVersion, + defaults: try temporaryUserDefaults(), + checksEnabled: true + ) + + let result = await coordinator.checkAtLaunch() + XCTAssertEqual(result != nil, shouldPresent, "Latest version: \(latestVersion)") + } + } + + func testProviderFailureIsSilent() async throws { + let provider = StubLatestReleaseProvider(shouldFail: true) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: try temporaryUserDefaults(), + checksEnabled: true + ) + + let result = await coordinator.checkAtLaunch() + + XCTAssertNil(result) + } + + func testSkippedReleaseIsSuppressed() async throws { + let defaults = try temporaryUserDefaults() + defaults.set("1.3.0", forKey: AppUpdateCoordinator.skippedVersionDefaultsKey) + let provider = StubLatestReleaseProvider(release: try release("1.3.0")) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: defaults, + checksEnabled: true + ) + + let result = await coordinator.checkAtLaunch() + + XCTAssertNil(result) + } + + func testNewerReleaseClearsStaleSkipAndIsPresented() async throws { + let defaults = try temporaryUserDefaults() + defaults.set("1.3.0", forKey: AppUpdateCoordinator.skippedVersionDefaultsKey) + let provider = StubLatestReleaseProvider(release: try release("1.4.0")) + let coordinator = AppUpdateCoordinator( + releaseProvider: provider, + currentVersion: try version("1.2.3"), + defaults: defaults, + checksEnabled: true + ) + + let result = await coordinator.checkAtLaunch() + + XCTAssertEqual(result?.version, try version("1.4.0")) + XCTAssertNil(defaults.string(forKey: AppUpdateCoordinator.skippedVersionDefaultsKey)) + } + + func testSkipTogglePersistsAndRemovesExactRelease() async throws { + let defaults = try temporaryUserDefaults() + let targetRelease = try release("1.3.0") + let coordinator = AppUpdateCoordinator( + releaseProvider: StubLatestReleaseProvider(release: targetRelease), + currentVersion: try version("1.2.3"), + defaults: defaults, + checksEnabled: true + ) + + coordinator.setSkipping(true, release: targetRelease) + XCTAssertTrue(coordinator.isSkipping(targetRelease)) + XCTAssertEqual( + defaults.string(forKey: AppUpdateCoordinator.skippedVersionDefaultsKey), + "1.3.0" + ) + + coordinator.setSkipping(false, release: try release("1.4.0")) + XCTAssertTrue(coordinator.isSkipping(targetRelease)) + XCTAssertEqual( + defaults.string(forKey: AppUpdateCoordinator.skippedVersionDefaultsKey), + "1.3.0" + ) + + coordinator.setSkipping(false, release: targetRelease) + XCTAssertFalse(coordinator.isSkipping(targetRelease)) + XCTAssertNil(defaults.string(forKey: AppUpdateCoordinator.skippedVersionDefaultsKey)) + } + + func testRemindLaterBehaviorPresentsAgainWithNewCoordinator() async throws { + let defaults = try temporaryUserDefaults() + let targetRelease = try release("1.3.0") + + let firstCoordinator = AppUpdateCoordinator( + releaseProvider: StubLatestReleaseProvider(release: targetRelease), + currentVersion: try version("1.2.3"), + defaults: defaults, + checksEnabled: true + ) + let firstResult = await firstCoordinator.checkAtLaunch() + XCTAssertNotNil(firstResult) + XCTAssertNil(defaults.string(forKey: AppUpdateCoordinator.skippedVersionDefaultsKey)) + + let nextLaunchCoordinator = AppUpdateCoordinator( + releaseProvider: StubLatestReleaseProvider(release: targetRelease), + currentVersion: try version("1.2.3"), + defaults: defaults, + checksEnabled: true + ) + let nextLaunchResult = await nextLaunchCoordinator.checkAtLaunch() + XCTAssertNotNil(nextLaunchResult) + } + + private func version(_ value: String) throws -> AppVersion { + try XCTUnwrap(AppVersion(stableVersionString: value)) + } + + private func release(_ value: String) throws -> AppRelease { + let version = try version(value) + return AppRelease( + version: version, + title: "JammLab \(value)", + notes: "Notes", + pageURL: URL(string: "https://github.com/cyberflow/JammLab/releases/tag/v\(value)")! + ) + } +} + +private actor StubHTTPDataLoader: HTTPDataLoading { + let data: Data + let response: URLResponse + + init(data: Data, statusCode: Int) { + self.data = data + response = HTTPURLResponse( + url: GitHubLatestReleaseClient.endpoint, + statusCode: statusCode, + httpVersion: "HTTP/2", + headerFields: nil + )! + } + + func loadData(for request: URLRequest) async throws -> (Data, URLResponse) { + (data, response) + } +} + +private actor StubLatestReleaseProvider: LatestReleaseProviding { + let release: AppRelease? + let shouldFail: Bool + private(set) var invocationCount = 0 + + init(release: AppRelease? = nil, shouldFail: Bool = false) { + self.release = release + self.shouldFail = shouldFail + } + + func fetchLatestRelease(currentVersion: AppVersion) async throws -> AppRelease { + invocationCount += 1 + if shouldFail { + throw StubError.failed + } + return try XCTUnwrap(release) + } + + func count() -> Int { + invocationCount + } + + enum StubError: Error { + case failed + } +} + +private actor GatedLatestReleaseProvider: LatestReleaseProviding { + let release: AppRelease + private var invocationCount = 0 + private var requestStarted = false + private var requestStartWaiters: [CheckedContinuation] = [] + private var requestContinuation: CheckedContinuation? + + init(release: AppRelease) { + self.release = release + } + + func fetchLatestRelease(currentVersion: AppVersion) async throws -> AppRelease { + invocationCount += 1 + requestStarted = true + requestStartWaiters.forEach { $0.resume() } + requestStartWaiters.removeAll() + + await withCheckedContinuation { continuation in + requestContinuation = continuation + } + return release + } + + func waitUntilRequestStarts() async { + guard !requestStarted else { return } + await withCheckedContinuation { continuation in + requestStartWaiters.append(continuation) + } + } + + func resume() { + requestContinuation?.resume() + requestContinuation = nil + } + + func count() -> Int { + invocationCount + } +}