diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index b73aaa0e..0ddd33a0 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -9,15 +9,15 @@ jobs: DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer steps: - uses: actions/checkout@v1 - - name: Clean + - name: Clean McBopomofo run: xcodebuild -scheme McBopomofo -configuration Release clean - - name: Clean + - name: Clean McBopomofoInstaller run: xcodebuild -scheme McBopomofoInstaller -configuration Release clean - - name: Build + - name: Build McBopomofo run: xcodebuild -scheme McBopomofo -configuration Release build - - name: Build + - name: Build McBopomofoInstaller run: xcodebuild -scheme McBopomofoInstaller -configuration Release build - - name: Test + - name: Test McBopomofo App Bundle run: xcodebuild -scheme McBopomofo -configuration Debug test - name: Test CandidateUI run: swift test diff --git a/McBopomofo.xcodeproj/project.pbxproj b/McBopomofo.xcodeproj/project.pbxproj index 948b5557..006cd3d8 100644 --- a/McBopomofo.xcodeproj/project.pbxproj +++ b/McBopomofo.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ D47F7DD0278C0897002F9DD7 /* NonModalAlertWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DCF278C0897002F9DD7 /* NonModalAlertWindowController.swift */; }; D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */; }; D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3B82796A8A000657FF3 /* PreferencesTests.swift */; }; + D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -201,6 +202,7 @@ D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = UserOverrideModel.cpp; sourceTree = ""; }; D485D3B62796A8A000657FF3 /* McBopomofoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = McBopomofoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D485D3B82796A8A000657FF3 /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = ""; }; + D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -454,6 +456,7 @@ isa = PBXGroup; children = ( D485D3B82796A8A000657FF3 /* PreferencesTests.swift */, + D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */, ); path = McBopomofoTests; sourceTree = ""; @@ -688,6 +691,7 @@ buildActionMask = 2147483647; files = ( D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */, + D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/McBopomofoTests/VersionUpdateTests.swift b/McBopomofoTests/VersionUpdateTests.swift new file mode 100644 index 00000000..d2f802f2 --- /dev/null +++ b/McBopomofoTests/VersionUpdateTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import McBopomofo + +class VersionUpdateApiTests: XCTestCase { + func testFetchVersionUpdateInfo() { + let exp = self.expectation(description: "wait for 3 seconds") + _ = VersionUpdateApi.check(forced: true) { result in + exp.fulfill() + switch result { + case .success(_): + break + case .failure(let error): + XCTFail(error.localizedDescription) + } + } + self.wait(for: [exp], timeout: 3.0) + } +} + + diff --git a/Source/AppDelegate.swift b/Source/AppDelegate.swift index f8d348fb..6fb8d9ae 100644 --- a/Source/AppDelegate.swift +++ b/Source/AppDelegate.swift @@ -42,6 +42,119 @@ private let kUpdateInfoSiteKey = "UpdateInfoSite" private let kNextCheckInterval: TimeInterval = 86400.0 private let kTimeoutInterval: TimeInterval = 60.0 +struct VersionUpdateReport { + var siteUrl: URL? + var currentShortVersion: String = "" + var currentVersion: String = "" + var remoteShortVersion: String = "" + var remoteVersion: String = "" + var versionDescription: String = "" +} + +enum VersionUpdateApiResult +{ + case shouldUpdate(report: VersionUpdateReport) + case noNeedToUpdate + case ignored +} + +enum VersionUpdateApiError: Error, LocalizedError { + case connectionError(message:String) + + var errorDescription: String? { + switch self { + case .connectionError(let message): + return String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), message) + } + } +} + +struct VersionUpdateApi { + static func check(forced: Bool, callback: @escaping (Result)->()) -> URLSessionTask? { + guard let infoDict = Bundle.main.infoDictionary, + let updateInfoURLString = infoDict[kUpdateInfoEndpointKey] as? String, + let updateInfoURL = URL(string: updateInfoURLString) else { + return nil + } + + let request = URLRequest(url: updateInfoURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: kTimeoutInterval) + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + forced ? + callback(.failure(VersionUpdateApiError.connectionError(message: error.localizedDescription))) : + callback(.success(.ignored)) + } + return + } + + do { + guard let plist = try PropertyListSerialization.propertyList(from: data ?? Data(), options: [], format: nil) as? [AnyHashable: Any], + let remoteVersion = plist[kCFBundleVersionKey] as? String, + let infoDict = Bundle.main.infoDictionary + else { + DispatchQueue.main.async { + forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored)) + } + return + } + + // TODO: Validate info (e.g. bundle identifier) + // TODO: Use HTML to display change log, need a new key like UpdateInfoChangeLogURL for this + + let currentVersion = infoDict[kCFBundleVersionKey as String] as? String ?? "" + let result = currentVersion.compare(remoteVersion, options: .numeric, range: nil, locale: nil) + + if result != .orderedAscending { + DispatchQueue.main.async { + forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored)) + } + return + } + + guard let siteInfoURLString = plist[kUpdateInfoSiteKey] as? String, + let siteInfoURL = URL(string: siteInfoURLString) + else { + DispatchQueue.main.async { + forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored)) + } + return + } + + var report = VersionUpdateReport(siteUrl:siteInfoURL) + var versionDescription = "" + let versionDescriptions = plist["Description"] as? [AnyHashable: Any] + if let versionDescriptions = versionDescriptions { + var locale = "en" + let supportedLocales = ["en", "zh-Hant", "zh-Hans"] + let preferredTags = Bundle.preferredLocalizations(from: supportedLocales) + if let first = preferredTags.first { + locale = first + } + versionDescription = versionDescriptions[locale] as? String ?? versionDescriptions["en"] as? String ?? "" + if !versionDescription.isEmpty { + versionDescription = "\n\n" + versionDescription + } + } + report.currentShortVersion = infoDict["CFBundleShortVersionString"] as? String ?? "" + report.currentVersion = currentVersion + report.remoteShortVersion = plist["CFBundleShortVersionString"] as? String ?? "" + report.remoteVersion = remoteVersion + report.versionDescription = versionDescription + DispatchQueue.main.async { + callback(.success(.shouldUpdate(report: report))) + } + } catch { + DispatchQueue.main.async { + forced ? callback(.success(.noNeedToUpdate)): callback(.success(.ignored)) + } + } + } + task.resume() + return task + } +} + @objc (AppDelegate) class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControllerDelegate { @@ -99,111 +212,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, NonModalAlertWindowControlle let nextUpdateDate = Date(timeInterval: kNextCheckInterval, since: Date()) UserDefaults.standard.set(nextUpdateDate, forKey: kNextUpdateCheckDateKey) - guard let infoDict = Bundle.main.infoDictionary, - let updateInfoURLString = infoDict[kUpdateInfoEndpointKey] as? String, - let updateInfoURL = URL(string: updateInfoURLString) else { - return - } - - let request = URLRequest(url: updateInfoURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: kTimeoutInterval) - - func showNoUpdateAvailableAlert() { - NonModalAlertWindowController.shared.show(title: NSLocalizedString("Check for Update Completed", comment: ""), content: NSLocalizedString("You are already using the latest version of McBopomofo.", comment: ""), confirmButtonTitle: NSLocalizedString("OK", comment: ""), cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil) - } - - let task = URLSession.shared.dataTask(with: request) { data, response, error in + checkTask = VersionUpdateApi.check(forced: forced) { result in defer { self.checkTask = nil } - - if let error = error { - if forced { - let title = NSLocalizedString("Update Check Failed", comment: "") - let content = String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), error.localizedDescription) - let buttonTitle = NSLocalizedString("Dismiss", comment: "") - - DispatchQueue.main.async { - NonModalAlertWindowController.shared.show(title: title, content: content, confirmButtonTitle: buttonTitle, cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil) - } - } - return - } - - do { - guard let plist = try PropertyListSerialization.propertyList(from: data ?? Data(), options: [], format: nil) as? [AnyHashable: Any], - let remoteVersion = plist[kCFBundleVersionKey] as? String, - let infoDict = Bundle.main.infoDictionary - else { - if forced { - DispatchQueue.main.async { - showNoUpdateAvailableAlert() - } - } - return - } - - // TODO: Validate info (e.g. bundle identifier) - // TODO: Use HTML to display change log, need a new key like UpdateInfoChangeLogURL for this - - let currentVersion = infoDict[kCFBundleVersionKey as String] as? String ?? "" - let result = currentVersion.compare(remoteVersion, options: .numeric, range: nil, locale: nil) - - if result != .orderedAscending { - if forced { - DispatchQueue.main.async { - showNoUpdateAvailableAlert() - } - } - return - } - - guard let siteInfoURLString = plist[kUpdateInfoSiteKey] as? String, - let siteInfoURL = URL(string: siteInfoURLString) else { - if forced { - DispatchQueue.main.async { - showNoUpdateAvailableAlert() - } - } - return - } - - self.updateNextStepURL = siteInfoURL - - var versionDescription = "" - let versionDescriptions = plist["Description"] as? [AnyHashable: Any] - if let versionDescriptions = versionDescriptions { - var locale = "en" - let supportedLocales = ["en", "zh-Hant", "zh-Hans"] - let preferredTags = Bundle.preferredLocalizations(from: supportedLocales) - if let first = preferredTags.first { - locale = first - } - versionDescription = versionDescriptions[locale] as? String ?? versionDescriptions["en"] as? String ?? "" - if !versionDescription.isEmpty { - versionDescription = "\n\n" + versionDescription - } - } - - let content = String(format: NSLocalizedString("You're currently using McBopomofo %@ (%@), a new version %@ (%@) is now available. Do you want to visit McBopomofo's website to download the version?%@", comment: ""), - infoDict["CFBundleShortVersionString"] as? String ?? "", - currentVersion, - plist["CFBundleShortVersionString"] as? String ?? "", - remoteVersion, - versionDescription) - DispatchQueue.main.async { + switch result { + case .success(let apiResult): + switch apiResult { + case .shouldUpdate(let report): + self.updateNextStepURL = report.siteUrl + let content = String(format: NSLocalizedString("You're currently using McBopomofo %@ (%@), a new version %@ (%@) is now available. Do you want to visit McBopomofo's website to download the version?%@", comment: ""), + report.currentShortVersion, + report.currentVersion, + report.remoteShortVersion, + report.remoteVersion, + report.versionDescription) NonModalAlertWindowController.shared.show(title: NSLocalizedString("New Version Available", comment: ""), content: content, confirmButtonTitle: NSLocalizedString("Visit Website", comment: ""), cancelButtonTitle: NSLocalizedString("Not Now", comment: ""), cancelAsDefault: false, delegate: self) + case .noNeedToUpdate, .ignored: + break } - - } catch { - if forced { - DispatchQueue.main.async { - showNoUpdateAvailableAlert() - } + case .failure(let error): + switch error { + case VersionUpdateApiError.connectionError(let message): + let title = NSLocalizedString("Update Check Failed", comment: "") + let content = String(format: NSLocalizedString("There may be no internet connection or the server failed to respond.\n\nError message: %@", comment: ""), message) + let buttonTitle = NSLocalizedString("Dismiss", comment: "") + NonModalAlertWindowController.shared.show(title: title, content: content, confirmButtonTitle: buttonTitle, cancelButtonTitle: nil, cancelAsDefault: false, delegate: nil) + default: + break } } } - checkTask = task - task.resume() } func nonModalAlertWindowControllerDidConfirm(_ controller: NonModalAlertWindowController) {