diff --git a/AppResources/Info.plist b/AppResources/Info.plist index 32675e3..0fa2f99 100644 --- a/AppResources/Info.plist +++ b/AppResources/Info.plist @@ -9,8 +9,8 @@ CFBundleIconFileAppIcon CFBundleNameMP4Flow CFBundlePackageTypeAPPL - CFBundleShortVersionString1.2.2 - CFBundleVersion8 + CFBundleShortVersionString1.2.3 + CFBundleVersion9 CFBundleLocalizations enjazh-Hans LSMinimumSystemVersion13.0 diff --git a/AppResources/en.lproj/Localizable.strings b/AppResources/en.lproj/Localizable.strings index 1f36e44..eaea687 100644 --- a/AppResources/en.lproj/Localizable.strings +++ b/AppResources/en.lproj/Localizable.strings @@ -350,7 +350,7 @@ "正在取消…" = "Cancelling…"; "转换组件不可用" = "Conversion tools unavailable"; "FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpeg or ffprobe is missing or could not run. Open Environment Check to see which tool needs attention and how to fix it.\n\nIf not installed, run this in Terminal: brew install ffmpeg\nCheck again after installation."; -"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "Updates are not checked automatically on first launch. After that, checks usually run daily, with a later retry if one fails."; +"每次启动约 60 秒后自动检查一次;不会自动下载或安装。" = "Check once about 60 seconds after each launch. Updates are never downloaded or installed automatically."; "上次未能检查更新,可手动重试。" = "The last update check failed. You can retry manually."; "请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "Check that the file plays. If needed, use Smart Convert to create an MP4, then add the new file."; "后退 5 秒" = "Back 5 seconds"; diff --git a/AppResources/ja.lproj/Localizable.strings b/AppResources/ja.lproj/Localizable.strings index 233093c..3df53d4 100644 --- a/AppResources/ja.lproj/Localizable.strings +++ b/AppResources/ja.lproj/Localizable.strings @@ -351,7 +351,7 @@ "正在取消…" = "キャンセル中…"; "转换组件不可用" = "変換ツールを使用できません"; "FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpegまたはffprobeがインストールされていないか、実行できません。「環境チェック」で不足している項目と修復方法を確認してください。\n\n未インストールの場合はターミナルで実行:brew install ffmpeg\nインストール後に再チェックしてください。"; -"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "初回起動時は更新を自動確認しません。その後は通常1日1回確認し、失敗した場合は時間をおいて再試行します。"; +"每次启动约 60 秒后自动检查一次;不会自动下载或安装。" = "起動から約60秒後に1回確認します。更新のダウンロードやインストールは自動では行いません。"; "上次未能检查更新,可手动重试。" = "前回の更新確認に失敗しました。手動で再試行できます。"; "请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "ファイルが再生できることを確認してください。必要に応じて「スマート変換」でMP4に変換し、新しいファイルを追加し直してください。"; "后退 5 秒" = "5秒戻る"; diff --git a/AppResources/zh-Hans.lproj/Localizable.strings b/AppResources/zh-Hans.lproj/Localizable.strings index a437884..213675d 100644 --- a/AppResources/zh-Hans.lproj/Localizable.strings +++ b/AppResources/zh-Hans.lproj/Localizable.strings @@ -169,7 +169,7 @@ "正在取消…" = "正在取消…"; "转换组件不可用" = "转换组件不可用"; "FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。" = "FFmpeg 或 ffprobe 未安装,或无法运行。请先在「环境体检」查看缺少的组件与修复方法。\n\n如未安装,可在终端执行:brew install ffmpeg\n安装后重新检查。"; -"首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。" = "首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。"; +"每次启动约 60 秒后自动检查一次;不会自动下载或安装。" = "每次启动约 60 秒后自动检查一次;不会自动下载或安装。"; "上次未能检查更新,可手动重试。" = "上次未能检查更新,可手动重试。"; "请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。" = "请确认文件可播放;也可先用「智能转换」转为 MP4,再重新添加。"; "后退 5 秒" = "后退 5 秒"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c0537..015e5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.2.3 — 2026-10-18 + +### English + +- Check for updates once, about 60 seconds after each launch. The footer now shows an update link only when a new version is found, and the update banner follows language changes. +- Manual checks remain in Help; automatic checks can still be turned off in Settings. Updates are never downloaded or installed automatically. + +### 日本語 + +- 起動から約60秒後に更新を1回確認します。新しいバージョンがある場合のみ画面左下にリンクを表示し、更新バナーも言語切替に対応します。 +- 手動確認はヘルプから利用でき、自動確認は設定でオフにできます。更新のダウンロードやインストールは自動では行いません。 + +### 中文 + +- 每次启动约 60 秒后自动检查一次更新;仅在发现新版本时于左下角显示入口,更新横幅也会跟随语言切换。 +- 仍可从“帮助”手动检查,也可在“设置”中关闭自动检查;不会自动下载或安装更新。 + ## 1.2.2 — 2026-10-18 ### English diff --git a/PRIVACY.ja.md b/PRIVACY.ja.md index 5795a79..02e7165 100644 --- a/PRIVACY.ja.md +++ b/PRIVACY.ja.md @@ -5,13 +5,13 @@ MP4Flow は、実行している Mac 上でメディアを処理するように ## MP4Flow が扱うデータ - アプリは、追加または選択した動画ファイルとフォルダを読み取ります。キューの表示と変換に必要な範囲で、メモリ内のサムネイルを作成し、メディアのプロパティを読み取ることがあります。 -- 表示言語、更新設定(通知を非表示にしたバージョンを含む)、更新確認のスケジュール情報を macOS のユーザーデフォルトに保存します。スケジュール情報は、起動済みかどうかと直近の確認成功・失敗時刻です。キュー、動画のパス、変換設定、診断情報、アカウント情報、メディアライブラリは保存しません。 +- 表示言語と更新設定(通知を非表示にしたバージョンを含む)を macOS のユーザーデフォルトに保存します。キュー、動画のパス、変換設定、診断情報、アカウント情報、メディアライブラリは保存しません。 - 通常の変換、解析、トリミング、クロップ、回転、結合は、ローカルの `ffmpeg` と `ffprobe` 実行ファイルを使用します。実験的なスマート高画質拡大は、この Mac 上の Apple の AVFoundation と VideoToolbox フレームワークを使用します。このアプリのリリースには、テレメトリ、分析、広告 SDK、メディアアップロードのコードは含まれません。 ## 更新確認 - MP4Flow は、最新の安定版を確認するために `https://api.github.com/repos/ixiehao/MP4Flow/releases/latest` へアクセスできます。このリクエストには固定の MP4Flow User-Agent と、GitHub およびネットワーク接続で通常扱われるネットワークメタデータだけが含まれます。動画の内容、ファイルパス、変換設定、アカウント情報、利用データは含まれません。 -- 自動確認は初期状態で有効ですが、初回起動時は更新を自動確認しません。以降の起動では、直近の確認成功から24時間以上、直近の確認失敗から1時間以上経過した場合に自動確認します。**設定** で自動確認を無効にしても、初回起動時を含め、いつでも **ヘルプ > アップデートを確認** を利用できます。 +- 自動確認は初期状態で有効で、初回を含む毎回の起動から約60秒後に1回実行します。**設定** で自動確認を無効にしても、いつでも **ヘルプ > アップデートを確認** を利用できます。 - 更新が見つかった場合も、選択したときに公開 GitHub リリースページを開くだけです。MP4Flow が自動で更新をダウンロード、インストール、実行することはありません。 ## ローカルに作成されるファイル diff --git a/PRIVACY.md b/PRIVACY.md index e4d5204..926fac3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,13 +5,13 @@ MP4Flow is designed to process media on the Mac where it is run. This document d ## Data MP4Flow handles - The app reads the video files and folders that you add or choose. It may create an in-memory thumbnail and read media properties needed to show the queue and perform conversion. -- It stores the interface language, update preferences (including the version whose notice was dismissed), and update-check scheduling state in macOS user defaults. Scheduling state records whether the app has launched before and the last successful and failed check times. It does not persist a queue, video paths, conversion settings, diagnostics, account data, or a media library. +- It stores the interface language and update preferences (including the version whose notice was dismissed) in macOS user defaults. It does not persist a queue, video paths, conversion settings, diagnostics, account data, or a media library. - Standard conversion, probing, trimming, cropping, rotation, and merging use the local `ffmpeg` and `ffprobe` executables. Experimental Smart Enhance uses Apple's AVFoundation and VideoToolbox frameworks on this Mac. This app release contains no telemetry, analytics, advertising SDK, or media-upload code. ## Update checks - MP4Flow can check `https://api.github.com/repos/ixiehao/MP4Flow/releases/latest` for the latest stable release. The request includes only a fixed MP4Flow User-Agent and normal network metadata handled by GitHub and the network connection; it does not include video content, file paths, conversion settings, account data, or usage data. -- Automatic checks are enabled by default, but the first launch does not perform an automatic update check. On later launches, automatic checks wait at least 24 hours after the last successful check and at least one hour after the last failed check. You can turn automatic checks off in **Settings** and still use **Help > Check for Updates** at any time, including the first launch. +- Automatic checks are enabled by default and run once about 60 seconds after each launch, including the first launch. You can turn automatic checks off in **Settings** and still use **Help > Check for Updates** at any time. - A found update only opens its public GitHub release page when you choose to view it. MP4Flow never downloads, installs, or executes an update automatically. ## Files created locally diff --git a/Sources/FastConvert/FastConvertApp.swift b/Sources/FastConvert/FastConvertApp.swift index 997b5fc..73c9062 100644 --- a/Sources/FastConvert/FastConvertApp.swift +++ b/Sources/FastConvert/FastConvertApp.swift @@ -28,14 +28,15 @@ struct MP4FlowApp: App { appDelegate.showHelpPanel(updateChecker: updateChecker) } Divider() - Button(updateChecker.hasUpdate ? L10n.format("发现新版本 v%@", updateChecker.availableUpdate?.version ?? "") : (updateChecker.isChecking ? L10n.text("正在检查…") : L10n.text("检查更新…"))) { - if updateChecker.hasUpdate { + Button(updateChecker.isChecking ? L10n.text("正在检查…") : L10n.text("检查更新…")) { + appDelegate.requestManualUpdateCheck?() + } + .disabled(updateChecker.isChecking) + if let update = updateChecker.availableUpdate { + Button(L10n.format("发现新版本 v%@", update.version)) { updateChecker.openDownloadPage() - } else { - appDelegate.requestManualUpdateCheck?() } } - .disabled(updateChecker.isChecking) Divider() Button(L10n.text("在 GitHub 查看项目")) { NSWorkspace.shared.open(ProjectLinks.repository) } Button(L10n.text("反馈问题或建议")) { NSWorkspace.shared.open(ProjectLinks.issues) } @@ -276,7 +277,7 @@ private struct UpdateSettingsView: View { .toggleStyle(.switch) Text(automaticallyCheckForUpdates - ? L10n.text("首次启动不自动检查更新;之后通常每天检查一次,失败后稍后重试。") + ? L10n.text("每次启动约 60 秒后自动检查一次;不会自动下载或安装。") : L10n.text("自动检查已关闭。你仍可随时通过“帮助 > 检查更新”手动检查。")) .font(.system(size: 12)) .foregroundStyle(BrandColor.textSecondary) @@ -299,27 +300,29 @@ private struct UpdateSettingsView: View { private struct UpdateAvailableBanner: View { @ObservedObject var updateChecker: UpdateChecker + @AppStorage("appLanguage") private var appLanguageIdentifier = AppLanguage.systemDefault.rawValue var body: some View { if let update = updateChecker.availableUpdate, updateChecker.shouldShowUpdateBanner { + let language = AppLanguage(rawValue: appLanguageIdentifier) ?? AppLanguage.systemDefault HStack(spacing: 12) { TablerIcon(name: .download, color: BrandColor.blue) .frame(width: 18, height: 18) .padding(7) .background(BrandColor.blue.opacity(0.10), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) VStack(alignment: .leading, spacing: 2) { - Text(L10n.format("发现新版本 v%@", update.version)) + Text(language.format("发现新版本 v%@", arguments: [update.version])) .font(.system(size: 13, weight: .semibold)) - Text(L10n.text("可在 GitHub Release 查看更新说明并下载。")) + Text(language.text("可在 GitHub Release 查看更新说明并下载。")) .font(.system(size: 11)) .foregroundStyle(BrandColor.textSecondary) } Spacer(minLength: 12) - Button(L10n.text("查看更新与下载")) { + Button(language.text("查看更新与下载")) { updateChecker.openDownloadPage() } .buttonStyle(AppActionButtonStyle(primary: true, minWidth: 126, height: 32)) - Button(L10n.text("暂不提醒此版本")) { + Button(language.text("暂不提醒此版本")) { updateChecker.dismissAvailableUpdateBanner() } .buttonStyle(.plain) @@ -372,7 +375,7 @@ struct ContentView: View { .onAppear { appDelegate.openMainWindow = { openWindow(id: "main") } appDelegate.requestManualUpdateCheck = checkForUpdates - Task { _ = await updateChecker.checkAtLaunchIfEligible() } + Task { _ = await updateChecker.checkAfterStartupDelay() } refreshDependencyHealth() } .onChange(of: store.outputDirectory) { _ in refreshDependencyHealth() } @@ -435,7 +438,7 @@ struct ContentView: View { private func checkForUpdates() { guard !updateChecker.isChecking else { return } Task { @MainActor in - let result = await updateChecker.checkForUpdate(force: true) + let result = await updateChecker.checkForUpdate() switch result { case .available(let update): updateNoticeMessage = L10n.format("发现新版本 v%@,可前往 GitHub 下载。", update.version) @@ -743,18 +746,12 @@ struct ContentView: View { if !store.isBusy { HStack(spacing: 7) { Text("v\(appVersion)") - Text("·").foregroundStyle(BrandColor.textSecondary.opacity(0.65)) - if let update = updateChecker.availableUpdate { + if let update = updateChecker.availableUpdate, updateChecker.shouldShowUpdateBanner { + Text("·").foregroundStyle(BrandColor.textSecondary.opacity(0.65)) Button(L10n.format("发现新版本 v%@", update.version)) { updateChecker.openDownloadPage() } .buttonStyle(.plain) .foregroundStyle(BrandColor.blue) .accessibilityLabel(L10n.format("发现新版本 v%@", update.version)) - } else { - Button(updateChecker.isChecking ? L10n.text("正在检查…") : L10n.text("检查更新")) { checkForUpdates() } - .buttonStyle(.plain) - .foregroundStyle(BrandColor.blue) - .accessibilityLabel(L10n.format("检查更新,当前版本 v%@", appVersion)) - .disabled(updateChecker.isChecking) } } .font(AppFont.caption) diff --git a/Sources/FastConvert/UpdateChecker.swift b/Sources/FastConvert/UpdateChecker.swift index 3cd4537..33d3382 100644 --- a/Sources/FastConvert/UpdateChecker.swift +++ b/Sources/FastConvert/UpdateChecker.swift @@ -63,44 +63,12 @@ struct GitHubRelease: Decodable { enum UpdateCheckSchedule { static let automaticChecksEnabledKey = "com.ixiehao.MP4Flow.updates.automaticChecksEnabled" - private static let hasLaunchedKey = "com.ixiehao.MP4Flow.updates.hasLaunched" - private static let lastSuccessfulCheckKey = "com.ixiehao.MP4Flow.updates.lastSuccessfulCheck" - private static let lastFailedCheckKey = "com.ixiehao.MP4Flow.updates.lastFailedCheck" - - static let successfulCheckInterval: TimeInterval = 24 * 60 * 60 - static let failedCheckInterval: TimeInterval = 60 * 60 + static let startupDelay: TimeInterval = 60 static func automaticChecksEnabled(in defaults: UserDefaults) -> Bool { guard defaults.object(forKey: automaticChecksEnabledKey) != nil else { return true } return defaults.bool(forKey: automaticChecksEnabledKey) } - - /// Keeps the very first launch network-free. A later launch may check only - /// when the user has enabled automatic checks and the relevant backoff has elapsed. - static func registerLaunchAndShouldCheck(in defaults: UserDefaults, now: Date) -> Bool { - let hasLaunchedBefore = defaults.bool(forKey: hasLaunchedKey) - defaults.set(true, forKey: hasLaunchedKey) - - guard hasLaunchedBefore, automaticChecksEnabled(in: defaults) else { return false } - if let lastSuccess = defaults.object(forKey: lastSuccessfulCheckKey) as? Date, - now.timeIntervalSince(lastSuccess) < successfulCheckInterval { - return false - } - if let lastFailure = defaults.object(forKey: lastFailedCheckKey) as? Date, - now.timeIntervalSince(lastFailure) < failedCheckInterval { - return false - } - return true - } - - static func recordSuccessfulCheck(in defaults: UserDefaults, now: Date) { - defaults.set(now, forKey: lastSuccessfulCheckKey) - defaults.removeObject(forKey: lastFailedCheckKey) - } - - static func recordFailedCheck(in defaults: UserDefaults, now: Date) { - defaults.set(now, forKey: lastFailedCheckKey) - } } /// Reads only MP4Flow's public GitHub release metadata. It never sends media, @@ -114,7 +82,8 @@ final class UpdateChecker: ObservableObject { private let session: URLSession private let currentVersion: ReleaseVersion private let defaults: UserDefaults - private let now: () -> Date + private let pause: @Sendable (TimeInterval) async throws -> Void + private var hasScheduledStartupCheck = false private static let releaseURL = URL(string: "https://api.github.com/repos/ixiehao/MP4Flow/releases/latest")! private static let releasesPage = URL(string: "https://github.com/ixiehao/MP4Flow/releases/latest")! @@ -124,12 +93,14 @@ final class UpdateChecker: ObservableObject { currentVersion: ReleaseVersion? = nil, session: URLSession = .shared, defaults: UserDefaults = .standard, - now: @escaping () -> Date = Date.init + pause: @escaping @Sendable (TimeInterval) async throws -> Void = { interval in + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } ) { self.currentVersion = currentVersion ?? Self.installedVersion self.session = session self.defaults = defaults - self.now = now + self.pause = pause } var hasUpdate: Bool { availableUpdate != nil } @@ -138,13 +109,20 @@ final class UpdateChecker: ObservableObject { return defaults.string(forKey: Self.dismissedUpdateVersionKey) != version } - /// Automatic checks begin from the second launch so first-run setup stays local. - func checkAtLaunchIfEligible() async -> UpdateCheckResult? { - guard UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now()) else { return nil } - return await checkForUpdate(force: true) + /// The shared checker is also used by reopened windows; schedule only once per app process. + func checkAfterStartupDelay() async -> UpdateCheckResult? { + guard !hasScheduledStartupCheck else { return nil } + hasScheduledStartupCheck = true + do { + try await pause(UpdateCheckSchedule.startupDelay) + } catch { + return nil + } + guard UpdateCheckSchedule.automaticChecksEnabled(in: defaults) else { return nil } + return await checkForUpdate() } - func checkForUpdate(force: Bool = false) async -> UpdateCheckResult { + func checkForUpdate() async -> UpdateCheckResult { guard !isChecking else { return .checking } isChecking = true @@ -161,31 +139,26 @@ final class UpdateChecker: ObservableObject { let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { lastCheckFailed = true - UpdateCheckSchedule.recordFailedCheck(in: defaults, now: now()) return .failed } let release = try JSONDecoder().decode(GitHubRelease.self, from: data) guard ReleaseVersion(tag: release.tagName) != nil else { lastCheckFailed = true - UpdateCheckSchedule.recordFailedCheck(in: defaults, now: now()) return .failed } if let update = release.update(newerThan: currentVersion) { availableUpdate = update lastCheckFailed = false - UpdateCheckSchedule.recordSuccessfulCheck(in: defaults, now: now()) return .available(update) } availableUpdate = nil lastCheckFailed = false - UpdateCheckSchedule.recordSuccessfulCheck(in: defaults, now: now()) return .upToDate } catch { lastCheckFailed = true - UpdateCheckSchedule.recordFailedCheck(in: defaults, now: now()) return .failed } } diff --git a/Tests/FastConvertTests/UpdateCheckerTests.swift b/Tests/FastConvertTests/UpdateCheckerTests.swift index 14971e7..9693606 100644 --- a/Tests/FastConvertTests/UpdateCheckerTests.swift +++ b/Tests/FastConvertTests/UpdateCheckerTests.swift @@ -52,17 +52,63 @@ final class UpdateCheckerTests: XCTestCase { XCTAssertNil(release(url: "https://github.com/ixiehao/other/releases/tag/v1.1.2").update(newerThan: installed)) } - func testAutomaticScheduleSkipsFirstLaunchAndPersistsDailyBackoff() { - let now = Date(timeIntervalSince1970: 1_700_000_000) - XCTAssertFalse(UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now)) - XCTAssertTrue(UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now.addingTimeInterval(30))) + @MainActor + func testAutomaticCheckWaits60SecondsAndRunsOncePerProcessIncludingFirstLaunch() async throws { + let delayGate = StartupDelayGate() + StubURLProtocol.handler = { request in + let data = try JSONSerialization.data(withJSONObject: [ + "tag_name": "v1.1.2", + "html_url": "https://github.com/ixiehao/MP4Flow/releases/tag/v1.1.2", + "draft": false, + "prerelease": false + ]) + return (try Self.response(for: request, statusCode: 200), data) + } + let checker = UpdateChecker( + currentVersion: try XCTUnwrap(ReleaseVersion(tag: "1.1.1")), + session: makeStubSession(), + defaults: defaults, + pause: { delay in try await delayGate.pause(for: delay) } + ) + + let scheduledCheck = Task { await checker.checkAfterStartupDelay() } + let requestedDelay = await delayGate.waitUntilStarted() + XCTAssertEqual(requestedDelay, 60) + XCTAssertFalse(checker.isChecking) + XCTAssertFalse(checker.hasUpdate) + await delayGate.release() - UpdateCheckSchedule.recordSuccessfulCheck(in: defaults, now: now) - XCTAssertFalse(UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now.addingTimeInterval(23 * 60 * 60))) - XCTAssertTrue(UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now.addingTimeInterval(24 * 60 * 60))) + guard case let .available(update)? = await scheduledCheck.value else { + return XCTFail("Expected an update on the first launch") + } + XCTAssertEqual(update.version, "1.1.2") + let repeatedCheck = await checker.checkAfterStartupDelay() + XCTAssertNil(repeatedCheck) + } + @MainActor + func testDisabledAutomaticCheckDoesNotRequestReleaseButManualCheckStillWorks() async throws { defaults.set(false, forKey: UpdateCheckSchedule.automaticChecksEnabledKey) - XCTAssertFalse(UpdateCheckSchedule.registerLaunchAndShouldCheck(in: defaults, now: now.addingTimeInterval(48 * 60 * 60))) + let checker = UpdateChecker( + currentVersion: try XCTUnwrap(ReleaseVersion(tag: "1.1.1")), + session: makeStubSession(), + defaults: defaults, + pause: { delay in XCTAssertEqual(delay, 60) } + ) + let automaticCheck = await checker.checkAfterStartupDelay() + XCTAssertNil(automaticCheck) + + StubURLProtocol.handler = { request in + let data = try JSONSerialization.data(withJSONObject: [ + "tag_name": "v1.1.1", + "html_url": "https://github.com/ixiehao/MP4Flow/releases/tag/v1.1.1", + "draft": false, + "prerelease": false + ]) + return (try Self.response(for: request, statusCode: 200), data) + } + let manualCheck = await checker.checkForUpdate() + XCTAssertEqual(manualCheck, .upToDate) } @MainActor @@ -81,9 +127,9 @@ final class UpdateCheckerTests: XCTestCase { currentVersion: try XCTUnwrap(ReleaseVersion(tag: "1.1.1")), session: makeStubSession(), defaults: defaults, - now: { Date(timeIntervalSince1970: 1_700_000_000) } + pause: { _ in } ) - let result = await checker.checkForUpdate(force: true) + let result = await checker.checkForUpdate() guard case let .available(update) = result else { return XCTFail("Expected an available update, got \(result)") @@ -100,13 +146,13 @@ final class UpdateCheckerTests: XCTestCase { (try Self.response(for: request, statusCode: 429), Data()) } var checker = UpdateChecker(currentVersion: installed, session: makeStubSession(), defaults: defaults) - let httpFailureResult = await checker.checkForUpdate(force: true) + let httpFailureResult = await checker.checkForUpdate() XCTAssertEqual(httpFailureResult, .failed) XCTAssertTrue(checker.lastCheckFailed) StubURLProtocol.handler = { _ in throw URLError(.timedOut) } checker = UpdateChecker(currentVersion: installed, session: makeStubSession(), defaults: defaults) - let timeoutFailureResult = await checker.checkForUpdate(force: true) + let timeoutFailureResult = await checker.checkForUpdate() XCTAssertEqual(timeoutFailureResult, .failed) XCTAssertTrue(checker.lastCheckFailed) } @@ -122,6 +168,29 @@ final class UpdateCheckerTests: XCTestCase { } } +private actor StartupDelayGate { + private var requestedDelay: TimeInterval? + private var started: CheckedContinuation? + private var pending: CheckedContinuation? + + func pause(for delay: TimeInterval) async throws { + requestedDelay = delay + started?.resume(returning: delay) + started = nil + try await withCheckedThrowingContinuation { pending = $0 } + } + + func waitUntilStarted() async -> TimeInterval { + if let requestedDelay { return requestedDelay } + return await withCheckedContinuation { started = $0 } + } + + func release() { + pending?.resume() + pending = nil + } +} + private final class StubURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?