fix: restore iOS navigation with unsaved forms
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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: """
|
||||
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
|
||||
<a id="back" href="#back">Back</a><script>
|
||||
window.visits = 0;
|
||||
document.querySelector('#back').onclick = event => {
|
||||
event.preventDefault();
|
||||
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
|
||||
};
|
||||
</script></body></html>
|
||||
""")
|
||||
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":{}}"#,
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -67,6 +73,7 @@
|
||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user