diff --git a/native/package.json b/native/package.json index a28d172..d69df45 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.2.7", + "version": "0.2.8", "private": true, "type": "module", "scripts": { @@ -25,5 +25,5 @@ "devDependencies": { "@tauri-apps/cli": "2.11.4" }, - "releaseNotes": "Fix blocked navigation after changing project creation options or other forms on macOS by displaying native confirmations for unsaved changes." + "releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces." } diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index 933aaef..ffd802a 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1770,7 +1770,7 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.2.7" +version = "0.2.8" dependencies = [ "semver", "serde", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index 9a70304..b3f4642 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.2.7" +version = "0.2.8" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift index 2935edb..a1708e7 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift @@ -43,6 +43,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU private var settingsAvailable = false private var toolbarHeight: NSLayoutConstraint! private var appearance: UISegmentedControl! + private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)? init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -51,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { javaScriptConfirmation?.completion(false) } + private func text(_ key: String) -> String { NSLocalizedString(key, comment: "KanbanGit iOS workspace") } @@ -199,6 +202,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU func open(_ url: URL) { if server?.url == url, connection.ready { showWorkspace(); return } + completeJavaScriptConfirmation(false) let selected = WorkspaceServer.selecting(url, current: server) guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } defaults.set(data, forKey: serverKey) @@ -265,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } @objc private func showSettings() { + completeJavaScriptConfirmation(false) loading.stopAnimating() view.endEditing(true) launcher.isHidden = false @@ -320,6 +325,39 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } } + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { + guard webView === workspace, viewIfLoaded?.window != nil, + presentedViewController == nil, javaScriptConfirmation == nil else { + completionHandler(false) + return + } + let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"), + message: message, preferredStyle: .alert) + let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in + self?.completeJavaScriptConfirmation(false) + } + alert.addAction(cancel) + alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in + self?.completeJavaScriptConfirmation(true) + }) + alert.preferredAction = cancel + javaScriptConfirmation = (alert, completionHandler) + present(alert, animated: true) + } + + func completeJavaScriptConfirmation(_ accepted: Bool) { + guard let confirmation = javaScriptConfirmation else { return } + javaScriptConfirmation = nil + if confirmation.alert.presentingViewController == nil { + confirmation.completion(accepted) + } else { + confirmation.alert.dismiss(animated: false) { + confirmation.completion(accepted) + } + } + } + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } guard server?.allows(url) == true else { diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/Localizable.strings b/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/Localizable.strings index ddac212..fe5c9b3 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/Localizable.strings +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/Localizable.strings @@ -9,6 +9,7 @@ "switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server."; "confirmSwitch" = "Switch server"; "cancel" = "Cancel"; +"ok" = "OK"; "connecting" = "Connecting to your server…"; "connected" = "Your workspace is open. Use Server settings to change servers."; "connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept."; diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift index 9a00c82..79babca 100644 --- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift +++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift @@ -135,6 +135,94 @@ final class WorkspaceTests: XCTestCase { "Changing workspaces must retain native gesture navigation") } + @MainActor + func testDirtyFormNavigationPresentsNativeConfirmation() async throws { + let animationsEnabled = UIView.areAnimationsEnabled + UIView.setAnimationsEnabled(false) + defer { UIView.setAnimationsEnabled(animationsEnabled) } + let name = "ios-confirmation-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let controller = WorkspaceViewController(defaults: defaults) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil } + let url = URL(string: "https://example.invalid/")! + controller.open(url) + let webview = try XCTUnwrap(controller.workspace) + webview.stopLoading() + webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """ + + Back + """) + try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'") + _ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") + let pristineVisits = try await webview.evaluateJavaScript("window.visits") + XCTAssertEqual(pristineVisits as? Int, 1) + XCTAssertNil(controller.presentedViewController) + _ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true") + var expectedVisits = 1 + for (index, accepted) in [false, true, false, true].enumerated() { + let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") } + let alert = try await waitForConfirmation(controller) + XCTAssertEqual(alert.title, "example.invalid") + XCTAssertEqual(alert.message, "Discard unsaved changes?") + XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default]) + XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft") + if index == 3 { + await withCheckedContinuation { continuation in + alert.dismiss(animated: false) { continuation.resume() } + } + } + controller.completeJavaScriptConfirmation(accepted) + controller.completeJavaScriptConfirmation(!accepted) + _ = try await click.value + if accepted { expectedVisits += 1 } + let visits = try await webview.evaluateJavaScript("window.visits") + XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each") + let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") + XCTAssertEqual(draft as? String, "Unsaved project") + XCTAssertNil(controller.presentedViewController) + } + + let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert) + controller.present(otherAlert, animated: false) + let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')") + XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript") + XCTAssertTrue(controller.presentedViewController === otherAlert) + await withCheckedContinuation { continuation in + otherAlert.dismiss(animated: false) { continuation.resume() } + } + + let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") } + _ = try await waitForConfirmation(controller) + controller.open(URL(string: "https://other.invalid/")!) + _ = try await pendingClick.value + XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation") + let oldVisits = try await webview.evaluateJavaScript("window.visits") + XCTAssertEqual(oldVisits as? Int, expectedVisits) + let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')") + XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations") + XCTAssertNil(controller.presentedViewController) + } + + @MainActor + private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController { + for _ in 0..<100 { + if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert } + try await Task.sleep(for: .milliseconds(50)) + } + return try XCTUnwrap(controller.presentedViewController as? UIAlertController, + "Dirty forms must offer a visible confirmation instead of silently cancelling navigation") + } + func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws { for body in [ #"{"version":2,"id":1,"action":"getState","payload":{}}"#, diff --git a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj index 89a28cf..1b6db75 100644 --- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj +++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj @@ -33,6 +33,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5; + remoteInfo = "kanbangit-native_iOS"; + }; 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */; @@ -48,7 +55,6 @@ 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = ""; }; 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = ""; }; - 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; 31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -67,6 +73,7 @@ 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = ""; }; 9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = ""; }; 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; }; A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = ""; }; BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = ""; }; @@ -189,7 +196,7 @@ 59D2DE9FD94E6D2D64CB0117 /* Products */ = { isa = PBXGroup; children = ( - 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */, + 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */, A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */, 9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */, ); @@ -248,7 +255,7 @@ packageProductDependencies = ( ); productName = "kanbangit-native_iOS"; - productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */; + productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */; productType = "com.apple.product-type.application"; }; 988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = { @@ -279,6 +286,7 @@ buildRules = ( ); dependencies = ( + DEBBEA8683567208E49E40A5 /* PBXTargetDependency */, ); name = WorkspaceTests; packageProductDependencies = ( @@ -418,6 +426,11 @@ target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */; targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */; }; + DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */; + targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -451,7 +464,7 @@ CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -467,7 +480,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - MARKETING_VERSION = 0.2.7; + MARKETING_VERSION = 0.2.8; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; @@ -628,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -644,7 +657,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - MARKETING_VERSION = 0.2.7; + MARKETING_VERSION = 0.2.8; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; @@ -691,6 +704,7 @@ SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit"; }; name = debug; }; @@ -710,6 +724,7 @@ SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit"; }; name = release; }; diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml index 2a040fe..e38ed64 100644 --- a/native/src-tauri/gen/apple/project.yml +++ b/native/src-tauri/gen/apple/project.yml @@ -18,8 +18,8 @@ settingGroups: DEVELOPMENT_TEAM: ZZX2KZGTHU ENABLE_USER_SCRIPT_SANDBOXING: false PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios - MARKETING_VERSION: "0.2.7" - CURRENT_PROJECT_VERSION: "6" + MARKETING_VERSION: "0.2.8" + CURRENT_PROJECT_VERSION: "7" configs: debug: CODE_SIGN_IDENTITY: Apple Development @@ -40,6 +40,7 @@ targets: kanbangit-native_iOS: type: application platform: iOS + productName: KanbanGit sources: - path: Sources - path: ../../../ui/workspace-navigation.js @@ -115,6 +116,8 @@ targets: WorkspaceTests: type: bundle.unit-test platform: iOS + dependencies: + - target: kanbangit-native_iOS sources: - path: ../../../ui/workspace-navigation.js - path: ../../../ui/workspace-settings.js @@ -126,6 +129,7 @@ targets: base: GENERATE_INFOPLIST_FILE: YES PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests + TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit DEVELOPMENT_TEAM: ZZX2KZGTHU CODE_SIGN_STYLE: Manual SWIFT_VERSION: "5.0" diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 132686f..6123337 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KanbanGit", - "version": "0.2.7", + "version": "0.2.8", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" diff --git a/native/src-tauri/tauri.ios.conf.json b/native/src-tauri/tauri.ios.conf.json index fa75dd3..43052cb 100644 --- a/native/src-tauri/tauri.ios.conf.json +++ b/native/src-tauri/tauri.ios.conf.json @@ -5,7 +5,7 @@ "iOS": { "minimumSystemVersion": "17.0", "developmentTeam": "ZZX2KZGTHU", - "bundleVersion": "6" + "bundleVersion": "7" } } }