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 = "