diff --git a/native/package.json b/native/package.json index beba36b..40ef68d 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "scripts": { @@ -25,5 +25,5 @@ "devDependencies": { "@tauri-apps/cli": "2.11.4" }, - "releaseNotes": "Open saved workspaces directly in the dashboard on macOS and quit cleanly when closing the workspace window." + "releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll." } diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index 7723ab3..d73c432 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.3" +version = "0.2.4" dependencies = [ "semver", "serde", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index 2f8edd1..855fda8 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.2.3" +version = "0.2.4" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/build.rs b/native/src-tauri/build.rs index 261851f..315fddc 100644 --- a/native/src-tauri/build.rs +++ b/native/src-tauri/build.rs @@ -1,3 +1,51 @@ fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + build_navigation(); + } tauri_build::build(); } + +fn build_navigation() { + use std::{env, path::PathBuf, process::Command}; + + println!("cargo:rerun-if-changed=src/navigation.m"); + let output = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let object = output.join("navigation.o"); + let archive = output.join("libkanban_navigation.a"); + let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() { + "aarch64" => "arm64", + "x86_64" => "x86_64", + _ => panic!("Unsupported macOS architecture"), + }; + assert!(Command::new("xcrun") + .args([ + "--sdk", + "macosx", + "clang", + "-fobjc-arc", + "-fblocks", + "-Wall", + "-Werror", + "-mmacosx-version-min=14.0", + "-arch", + arch, + "-c", + "src/navigation.m", + "-o" + ]) + .arg(&object) + .status() + .expect("Xcode clang is required") + .success()); + assert!(Command::new("xcrun") + .args(["ar", "crs"]) + .arg(&archive) + .arg(&object) + .status() + .expect("Xcode ar is required") + .success()); + println!("cargo:rustc-link-search=native={}", output.display()); + println!("cargo:rustc-link-lib=static=kanban_navigation"); + println!("cargo:rustc-link-lib=framework=AppKit"); + println!("cargo:rustc-link-lib=framework=WebKit"); +} 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 2d8055b..af97465 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift @@ -24,7 +24,7 @@ func installIOSWorkspace() { } } -final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate { +final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate { private let defaults: UserDefaults private let serverKey = "ios.activeServer" private(set) var server: WorkspaceServer? @@ -40,8 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU private let connectButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system) private let toolbar = UIToolbar() - private var historyObservation: NSKeyValueObservation? - private var backButton: UIBarButtonItem! + private var gestureStart = CGPoint.zero + private var gestureAllowed = false + private var gestureRevision = 0 private var appearance: UISegmentedControl! init(defaults: UserDefaults = .standard) { @@ -94,7 +95,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU launcher.isHidden = true launcher.accessibilityIdentifier = "serverSettings" launcher.keyboardDismissMode = .interactive - launcher.alwaysBounceVertical = true + launcher.bounces = false view.addSubview(launcher) launcher.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -164,11 +165,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU toolbar.isHidden = true toolbar.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toolbar) - backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack)) - backButton.accessibilityIdentifier = "workspaceBack" let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings)) settings.accessibilityIdentifier = "showServerSettings" - toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings] + toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings] NSLayoutConstraint.activate([ toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), @@ -203,7 +202,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } defaults.set(data, forKey: serverKey) timeout?.cancel() - historyObservation = nil workspace?.stopLoading() workspace?.navigationDelegate = nil workspace?.removeFromSuperview() @@ -211,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU server = selected let configuration = WKWebViewConfiguration() configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile) - // Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge. + let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")! + let script = try! String(contentsOf: scriptURL, encoding: .utf8) + configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + // Remote content receives no native message handlers or Tauri bridge. let webview = WKWebView(frame: .zero, configuration: configuration) webview.navigationDelegate = self webview.uiDelegate = self - webview.allowsBackForwardNavigationGestures = true + // Native WebKit swipes can skip async pushState entries. One edge recognizer + // owns each gesture and traverses the same history that Inertia restores. + webview.allowsBackForwardNavigationGestures = false + webview.scrollView.bounces = false + for edge: UIRectEdge in [.left, .right] { + let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:))) + gesture.edges = edge + gesture.delegate = self + webview.addGestureRecognizer(gesture) + webview.scrollView.panGestureRecognizer.require(toFail: gesture) + } webview.accessibilityIdentifier = "workspace" webview.translatesAutoresizingMaskIntoConstraints = false webview.isHidden = true @@ -227,9 +238,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) workspace = webview - historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in - self?.backButton.isEnabled = view.canGoBack - } view.endEditing(true) address.text = url.absoluteString launcher.isHidden = true @@ -275,10 +283,46 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU launcher.isHidden = true toolbar.isHidden = false workspace?.isHidden = false - backButton.isEnabled = workspace?.canGoBack == true } - @objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false } + gestureStart = touch.location(in: webview) + gestureAllowed = false + gestureRevision += 1 + let revision = gestureRevision + webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in + guard let self, self.gestureRevision == revision, self.workspace === webview else { return } + self.gestureAllowed = result as? Bool == true + // Release the scroll view immediately when a board, field or overlay owns the touch. + if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed } + } + return true + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer, + let webview = workspace, !webview.isHidden, gestureAllowed else { return false } + let velocity = edge.velocity(in: webview) + return abs(velocity.x) > abs(velocity.y) + && (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0) + } + + @objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) { + guard gesture.state == .ended, let webview = workspace else { return } + let direction = gesture.edges == .left ? -1 : 1 + let distance = gesture.translation(in: webview).x * CGFloat(-direction) + let velocity = gesture.velocity(in: webview).x * CGFloat(-direction) + guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return } + navigateHistory(direction, from: gestureStart) + } + + func navigateHistory(_ direction: Int, from point: CGPoint) { + guard let webview = workspace, + direction == -1 ? webview.canGoBack : webview.canGoForward else { return } + let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))" + webview.evaluateJavaScript(script, completionHandler: nil) + } func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } @@ -320,7 +364,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU connectButton.setTitle(text("openWorkspace"), for: .normal) status.text = text("connected") resumeButton.isHidden = false - backButton.isEnabled = webView.canGoBack if firstLoad { showWorkspace() } } diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift index e89c60b..5c521a3 100644 --- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift +++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift @@ -95,41 +95,82 @@ final class WorkspaceTests: XCTestCase { } @MainActor - func testBackButtonTracksSameDocumentHistory() async throws { - let name = "KAN55-history-\(UUID())" + func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws { + let name = "KAN71-history-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) defer { defaults.removePersistentDomain(forName: name) } let controller = WorkspaceViewController(defaults: defaults) controller.loadViewIfNeeded() - let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) - controller.open(url) + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let url = URL(string: "https://example.invalid/dashboard")! + controller.open(URL(string: "https://example.invalid/")!) let webview = try XCTUnwrap(controller.workspace) webview.stopLoading() - webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "") - var loaded = false + webview.frame = controller.view.bounds + webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """ + +

Workspace

+ """) + try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'") + XCTAssertFalse(webview.canGoBack) + controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) + try await waitFor(webview, "location.pathname === '/dashboard'") + for index in 1...3 { + _ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')") + } + _ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)") + let edge = TestEdgeGesture() + edge.edges = .left + edge.testVelocity = CGPoint(x: 800, y: 0) + XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch())) + var allowed = false for _ in 0..<50 { - if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), - value as? String == "unsaved" { loaded = true; break } - try await Task.sleep(for: .milliseconds(100)) + if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break } + try await Task.sleep(for: .milliseconds(20)) } - XCTAssertTrue(loaded) - _ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')") - let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) - for _ in 0..<20 { - if toolbar.items?.first?.isEnabled == true { break } - try await Task.sleep(for: .milliseconds(50)) - } - XCTAssertTrue(webview.canGoBack) - XCTAssertTrue(toolbar.items?.first?.isEnabled == true) - webview.goBack() - var returned = false - for _ in 0..<20 { - if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break } - try await Task.sleep(for: .milliseconds(50)) - } - XCTAssertTrue(returned) - let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value") + XCTAssertTrue(allowed) + edge.testState = .cancelled + edge.testTranslation = CGPoint(x: 200, y: 0) + controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) + try await Task.sleep(for: .milliseconds(100)) + try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0") + edge.testState = .ended + edge.testTranslation = CGPoint(x: 10, y: 0) + controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) + try await Task.sleep(for: .milliseconds(100)) + try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0") + edge.testTranslation = CGPoint(x: 200, y: 0) + controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) + try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1") + controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) + try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2") + controller.navigateHistory(1, from: CGPoint(x: 1, y: 1)) + try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3") + let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") XCTAssertEqual(draft as? String, "unsaved") + XCTAssertFalse(webview.scrollView.bounces) + XCTAssertFalse(webview.allowsBackForwardNavigationGestures) + XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2) + let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) + XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" }) + XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" }) + let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY") + XCTAssertEqual(scroll as? String, "none") + let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX") + XCTAssertEqual(horizontal as? String, "auto") + _ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '
Dialog
')") + controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) + try await Task.sleep(for: .milliseconds(200)) + try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3") + } + + @MainActor + private func waitFor(_ webview: WKWebView, _ expression: String) async throws { + for _ in 0..<100 { + if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("WebView condition timed out: \(expression)") } func testServerChangesNeverReusePreviousProfile() throws { @@ -189,7 +230,7 @@ final class WorkspaceTests: XCTestCase { controller.open(url) let server = try XCTUnwrap(controller.server) XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile) - XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true) + XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1) let restored = WorkspaceViewController(defaults: defaults) restored.loadViewIfNeeded() XCTAssertEqual(restored.server, server) @@ -231,3 +272,19 @@ final class WorkspaceTests: XCTestCase { } } + +private final class TestTouch: UITouch { + override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) } +} + +private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer { + var testState: UIGestureRecognizer.State = .possible + var testVelocity = CGPoint.zero + var testTranslation = CGPoint.zero + override var state: UIGestureRecognizer.State { + get { testState } + set { testState = newValue } + } + override func velocity(in view: UIView?) -> CGPoint { testVelocity } + override func translation(in view: UIView?) -> CGPoint { testTranslation } +} 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 c339003..81896c5 100644 --- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj +++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; }; + 0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; }; 0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; }; 205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; }; 333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; }; @@ -18,6 +19,7 @@ 7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; }; 73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; }; 98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; }; + 99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; }; A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; }; B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; }; C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; }; @@ -53,7 +55,9 @@ 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; 6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = ""; }; 7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; + 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = ""; }; 8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = ""; }; 91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = ""; }; @@ -110,6 +114,7 @@ 8606F3EE5A736385061692EF /* Sources */, 335B1262A9019C70C7F887A5 /* src */, 140A7DB7E2461E64B1FBFA70 /* Tests */, + 709BE6DF191B953724A55555 /* ui */, 3C9F31BB065F210267ECA390 /* UITests */, 3028C3786B437115B6486F1C /* Frameworks */, 59D2DE9FD94E6D2D64CB0117 /* Products */, @@ -149,6 +154,7 @@ AEBAD9ABE758897C50BC1F2E /* locale.rs */, 7440C7F0426E6B3977DD1D94 /* main.rs */, 5F71F06AB639140AB9714425 /* metadata.rs */, + 8C0F963909020429B3D257D5 /* navigation.m */, E8BA104C81071623E1FF367D /* server.rs */, 6610D376CF901AC1733D394E /* updates.rs */, ); @@ -183,6 +189,15 @@ name = Products; sourceTree = ""; }; + 709BE6DF191B953724A55555 /* ui */ = { + isa = PBXGroup; + children = ( + 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */, + ); + name = ui; + path = ../../../ui; + sourceTree = ""; + }; 8606F3EE5A736385061692EF /* Sources */ = { isa = PBXGroup; children = ( @@ -251,6 +266,7 @@ buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */; buildPhases = ( 2A0467365B852D44D1A9FD5B /* Sources */, + DC3295DE0893151786FEA37B /* Resources */, ); buildRules = ( ); @@ -317,6 +333,15 @@ C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */, 34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */, A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */, + 99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC3295DE0893151786FEA37B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -416,7 +441,7 @@ CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -432,7 +457,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.3; + MARKETING_VERSION = 0.2.4; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; @@ -593,7 +618,7 @@ CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ZZX2KZGTHU; ENABLE_BITCODE = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -609,7 +634,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.3; + MARKETING_VERSION = 0.2.4; PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios; PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_NAME = KanbanGit; diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml index 3213bfb..b0a61a0 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.3" - CURRENT_PROJECT_VERSION: "3" + MARKETING_VERSION: "0.2.4" + CURRENT_PROJECT_VERSION: "4" configs: debug: CODE_SIGN_IDENTITY: Apple Development @@ -42,6 +42,7 @@ targets: platform: iOS sources: - path: Sources + - path: ../../../ui/workspace-navigation.js - path: Assets.xcassets - path: kanbangit-native_iOS - path: LaunchScreen.storyboard @@ -114,6 +115,7 @@ targets: type: bundle.unit-test platform: iOS sources: + - path: ../../../ui/workspace-navigation.js - path: Tests - path: Sources/kanbangit-native/WorkspacePolicy.swift - path: Sources/kanbangit-native/WorkspaceViewController.swift diff --git a/native/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs index 8387925..181735b 100644 --- a/native/src-tauri/src/desktop.rs +++ b/native/src-tauri/src/desktop.rs @@ -177,6 +177,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { .center() .visible(false) .data_store_identifier(server.profile) + .initialization_script(include_str!("../../ui/workspace-navigation.js")) .on_navigation(move |destination| { if !allows_server_navigation(&server_url, destination) { open_external(destination); @@ -227,6 +228,15 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { }) .build() .map_err(|_| "connectionError")?; + #[cfg(target_os = "macos")] + window + .with_webview(|webview| unsafe { + unsafe extern "C" { + fn install_workspace_navigation(webview: *mut std::ffi::c_void); + } + install_workspace_navigation(webview.inner().cast()); + }) + .map_err(|_| "connectionError")?; let handle = app.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { diff --git a/native/src-tauri/src/navigation.m b/native/src-tauri/src/navigation.m new file mode 100644 index 0000000..b5aee4b --- /dev/null +++ b/native/src-tauri/src/navigation.m @@ -0,0 +1,102 @@ +#import +#import +#import + +// Keep AppKit's system swipe preferences and cancellation thresholds, but use +// script history traversal so asynchronously created Inertia entries aren't skipped. +@interface KanbanNavigation : NSObject +@property(nonatomic, weak) WKWebView *webview; +@property(nonatomic, strong) id monitor; +@property(nonatomic) BOOL pending; +@property(nonatomic) BOOL tracking; +@property(nonatomic) BOOL blocked; +@property(nonatomic) NSUInteger sequence; +@end + +@implementation KanbanNavigation +- (BOOL)swipeTrackingEnabled { + return NSEvent.isSwipeTrackingFromScrollEventsEnabled; +} + +- (void)dealloc { + if (_monitor) [NSEvent removeMonitor:_monitor]; +} + +- (NSEvent *)handleEvent:(NSEvent *)event { + WKWebView *webview = self.webview; + if (!webview || event.window != webview.window || !webview.window.isKeyWindow || + webview.hidden || ![self swipeTrackingEnabled]) return event; + if (event.phase == NSEventPhaseBegan) { + self.sequence++; + self.blocked = NO; + self.pending = NO; + self.tracking = NO; + } + if (self.tracking) return nil; + if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) { + self.sequence++; + self.pending = NO; + return event; + } + if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone || + !(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event; + if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) { + if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES; + return event; + } + NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil]; + if (!NSPointInRect(point, webview.bounds)) return event; + if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y; + NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y]; + self.pending = YES; + NSUInteger sequence = self.sequence; + NSURL *page = webview.URL; + __weak KanbanNavigation *weakSelf = self; + [webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) { + KanbanNavigation *owner = weakSelf; + if (!owner || owner.sequence != sequence) return; + owner.pending = NO; + owner.blocked = YES; + if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] || + (!webview.canGoBack && !webview.canGoForward)) return; + owner.tracking = YES; + [event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount + dampenAmountThresholdMin:webview.canGoForward ? -1 : 0 + max:webview.canGoBack ? 1 : 0 + usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) { + KanbanNavigation *current = weakSelf; + if (current.sequence != sequence) { + *stop = YES; + return; + } + if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) { + current.tracking = NO; + *stop = YES; + return; + } + if (!complete) return; + current.tracking = NO; + if (phase == NSEventPhaseCancelled) return; + NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0; + if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) { + NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction]; + [webview evaluateJavaScript:navigate completionHandler:nil]; + } + }]; + }]; + return event; +} +@end + +void install_workspace_navigation(void *pointer) { + WKWebView *webview = (__bridge WKWebView *)pointer; + webview.allowsBackForwardNavigationGestures = NO; + KanbanNavigation *navigation = [KanbanNavigation new]; + navigation.webview = webview; + __weak KanbanNavigation *weakNavigation = navigation; + navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) { + KanbanNavigation *owner = weakNavigation; + return owner ? [owner handleEvent:event] : event; + }]; + objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 2a19ad2..ff4bebe 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.3", + "version": "0.2.4", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" @@ -40,6 +40,9 @@ "y": 170 } } + }, + "android": { + "versionCode": 2004 } }, "plugins": { diff --git a/native/src-tauri/tauri.ios.conf.json b/native/src-tauri/tauri.ios.conf.json index 882cb3e..785e6db 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": "3" + "bundleVersion": "4" } } } diff --git a/native/src-tauri/tests/macos_smoke.rs b/native/src-tauri/tests/macos_smoke.rs index 6a0f028..b0d1f43 100644 --- a/native/src-tauri/tests/macos_smoke.rs +++ b/native/src-tauri/tests/macos_smoke.rs @@ -125,7 +125,27 @@ fn main() { input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan52_fixture', 'isolated'); - const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1; + const initialHistory = history.length; + const initialPath = location.pathname; + const navigation = window.__kanbanNavigation; + if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing'); + const traverse = direction => new Promise(resolve => { + addEventListener('popstate', resolve, { once: true }); + navigation.step(direction); + }); + for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash); + await traverse(-1); + if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry'); + await traverse(-1); + if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry'); + await traverse(1); + if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost'); + const dialog = document.createElement('dialog'); + dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal(); + if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation'); + dialog.close(); dialog.remove(); + if (input.value !== 'unsaved input') throw new Error('History discarded form state'); + const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; })(); "#).unwrap(); diff --git a/native/tests/navigation-browser.mjs b/native/tests/navigation-browser.mjs new file mode 100644 index 0000000..26ee14d --- /dev/null +++ b/native/tests/navigation-browser.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import process from 'node:process'; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const script = await readFile( + new URL('../ui/workspace-navigation.js', import.meta.url), + 'utf8', +); +const server = createServer((request, response) => { + response.setHeader('Content-Type', 'text/html'); + response.end(`

${request.url}

Board columns
+
Long dialog
`); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +await page.addInitScript(script); +const root = `http://127.0.0.1:${server.address().port}`; +try { + await page.goto(root); + const length = await page.evaluate(() => history.length); + for (const path of ['/board', '/ticket', '/settings']) { + await page.evaluate( + (path) => + history.pushState( + { + page: { url: path }, + rememberedState: { draft: 'retained' }, + }, + '', + path, + ), + path, + ); + } + await page.evaluate(() => { + window.steps = 0; + addEventListener('popstate', () => window.steps++); + }); + await page.evaluate(() => window.__kanbanNavigation.step(-1)); + await page.waitForFunction(() => location.pathname === '/ticket'); + assert.equal(await page.evaluate(() => window.steps), 1); + await page.evaluate(() => window.__kanbanNavigation.step(-1)); + await page.waitForFunction(() => location.pathname === '/board'); + await page.evaluate(() => window.__kanbanNavigation.step(1)); + await page.waitForFunction(() => location.pathname === '/ticket'); + assert.equal(await page.evaluate(() => window.steps), 3); + assert.equal(await page.evaluate(() => history.length), length + 3); + assert.equal(await page.locator('#draft').inputValue(), 'retained'); + assert.equal( + await page.evaluate(() => history.state.rememberedState.draft), + 'retained', + ); + await page.goto(`${root}/full-page`); + await page.evaluate(() => window.__kanbanNavigation.step(-1)); + await page.waitForURL(`${root}/ticket`); + await page.evaluate(() => window.__kanbanNavigation.step(1)); + await page.waitForURL(`${root}/full-page`); + await page.locator('dialog').evaluate((dialog) => dialog.showModal()); + assert.equal( + await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)), + false, + ); + await page.locator('dialog').evaluate((dialog) => dialog.close()); + for (const selector of ['#board', '#draft']) { + const point = await page.locator(selector).boundingBox(); + assert.equal( + await page.evaluate( + ({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5), + point, + ), + false, + ); + } + assert.equal( + await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)), + true, + ); + await page.dispatchEvent('#board', 'dragstart'); + assert.equal( + await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)), + false, + ); + await page.dispatchEvent('#board', 'dragend'); + assert.equal( + await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)), + true, + ); + assert.equal( + await page.evaluate( + () => + getComputedStyle(document.documentElement).overscrollBehaviorY, + ), + 'none', + ); + assert.equal( + await page.evaluate( + () => + getComputedStyle(document.documentElement).overscrollBehaviorX, + ), + 'auto', + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollHeight), + 844, + ); + await page.mouse.move(380, 400); + await page.mouse.wheel(0, -1000); + await page.waitForTimeout(200); + assert.equal(await page.evaluate(() => scrollY), 0); + await page + .locator('#long') + .evaluate((element) => (element.style.height = '3000px')); + await page.mouse.wheel(0, 1000); + await page.waitForFunction(() => scrollY > 0); + await page.evaluate(() => + scrollTo(0, document.documentElement.scrollHeight), + ); + await page.mouse.wheel(0, 5000); + await page.waitForTimeout(200); + assert.equal( + await page.evaluate(() => scrollY + innerHeight), + await page.evaluate(() => document.documentElement.scrollHeight), + ); + await page.evaluate(() => scrollTo(0, 0)); + await page.locator('#board').hover(); + await page.mouse.wheel(500, 0); + await page.waitForFunction( + () => document.querySelector('#board').scrollLeft > 0, + ); + assert.equal(new URL(page.url()).pathname, '/full-page'); + await page.locator('dialog').evaluate((dialog) => dialog.showModal()); + await page.locator('dialog').hover(); + await page.mouse.wheel(0, 500); + await page.waitForFunction( + () => document.querySelector('dialog').scrollTop > 0, + ); + console.log( + 'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards', + ); +} finally { + await browser.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/native/tests/navigation-macos.m b/native/tests/navigation-macos.m new file mode 100644 index 0000000..bbb0539 --- /dev/null +++ b/native/tests/navigation-macos.m @@ -0,0 +1,142 @@ +#import "../src-tauri/src/navigation.m" + +// Exercise the AppKit event adapter deterministically; macos_smoke separately +// runs the shared history script against a real WKWebView. +@interface TestWindow : NSWindow +@end +@implementation TestWindow +- (BOOL)isKeyWindow { return YES; } +@end + +@interface TestWebView : WKWebView +@property(nonatomic, strong) NSWindow *testWindow; +@property(nonatomic, strong) NSURL *testURL; +@property(nonatomic) BOOL back; +@property(nonatomic) BOOL forward; +@property(nonatomic) BOOL allowsSwipe; +@property(nonatomic) BOOL deferPermission; +@property(nonatomic, copy) void (^permission)(id, NSError *); +@property(nonatomic, strong) NSMutableArray *steps; +@end +@implementation TestWebView +- (NSWindow *)window { return self.testWindow; } +- (NSURL *)URL { return self.testURL; } +- (BOOL)canGoBack { return self.back; } +- (BOOL)canGoForward { return self.forward; } +- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; } +- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler { + if ([script containsString:@".step("]) [self.steps addObject:script]; + else if (self.deferPermission) self.permission = handler; + else handler(@(self.allowsSwipe), nil); +} +@end + +@interface TestEvent : NSEvent +@property(nonatomic, strong) NSWindow *testWindow; +@property(nonatomic) NSEventPhase testPhase; +@property(nonatomic) NSEventPhase testMomentum; +@property(nonatomic) CGFloat dx; +@property(nonatomic) CGFloat dy; +@property(nonatomic) BOOL precise; +@property(nonatomic) NSInteger trackCount; +@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *); +@end +@implementation TestEvent +- (NSWindow *)window { return self.testWindow; } +- (NSEventPhase)phase { return self.testPhase; } +- (NSEventPhase)momentumPhase { return self.testMomentum; } +- (CGFloat)scrollingDeltaX { return self.dx; } +- (CGFloat)scrollingDeltaY { return self.dy; } +- (BOOL)hasPreciseScrollingDeltas { return self.precise; } +- (NSPoint)locationInWindow { return NSMakePoint(20, 20); } +- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler { + NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked"); + self.trackCount++; + self.tracking = handler; +} +@end + +@interface TestNavigation : KanbanNavigation +@property(nonatomic) BOOL enabled; +@end +@implementation TestNavigation +- (BOOL)swipeTrackingEnabled { return self.enabled; } +@end + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)]; + webview.testWindow = window; + webview.testURL = [NSURL URLWithString:@"https://example.com/board"]; + webview.back = YES; + webview.forward = YES; + webview.allowsSwipe = YES; + webview.steps = [NSMutableArray new]; + TestNavigation *navigation = [TestNavigation new]; + navigation.webview = webview; + navigation.enabled = YES; + TestEvent *(^event)(void) = ^{ + TestEvent *value = [TestEvent new]; + value.testWindow = window; + value.testPhase = NSEventPhaseBegan; + value.precise = YES; + value.dx = 15; + return value; + }; + BOOL stop = NO; + TestEvent *back = event(); + [navigation handleEvent:back]; + NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking"); + back.tracking(0.5, NSEventPhaseChanged, NO, &stop); + NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate"); + back.tracking(1, NSEventPhaseNone, YES, &stop); + NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step"); + TestEvent *forward = event(); + forward.dx = -15; + [navigation handleEvent:forward]; + forward.tracking(-1, NSEventPhaseNone, YES, &stop); + NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step"); + TestEvent *cancelled = event(); + [navigation handleEvent:cancelled]; + cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop); + NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate"); + TestEvent *interrupted = event(); + [navigation handleEvent:interrupted]; + webview.allowsSwipe = NO; + [navigation handleEvent:event()]; + interrupted.tracking(1, NSEventPhaseNone, YES, &stop); + NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback"); + + for (NSInteger test = 0; test < 7; test++) { + TestEvent *ignored = event(); + navigation.enabled = test != 0; + webview.allowsSwipe = test != 1; + webview.back = webview.forward = test != 2; + if (test == 3) ignored.precise = NO; + if (test == 4) ignored.dy = 30; + if (test == 5) ignored.testMomentum = NSEventPhaseChanged; + if (test == 6) ignored.testWindow = nil; + NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling"); + } + navigation.enabled = YES; + webview.allowsSwipe = YES; + webview.back = YES; + webview.deferPermission = YES; + TestEvent *stale = event(); + [navigation handleEvent:stale]; + TestEvent *ended = event(); + ended.testPhase = NSEventPhaseEnded; + [navigation handleEvent:ended]; + webview.permission(@YES, nil); + NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures"); + TestEvent *changedPage = event(); + [navigation handleEvent:changedPage]; + webview.testURL = [NSURL URLWithString:@"https://example.com/settings"]; + webview.permission(@YES, nil); + NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture"); + puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks"); + } + return 0; +} diff --git a/native/tests/navigation-macos.test.js b/native/tests/navigation-macos.test.js new file mode 100644 index 0000000..880f9f4 --- /dev/null +++ b/native/tests/navigation-macos.test.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test( + 'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership', + { skip: process.platform !== 'darwin' }, + () => { + const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-')); + const binary = join(directory, 'navigation-test'); + try { + const compile = spawnSync( + 'xcrun', + [ + '--sdk', + 'macosx', + 'clang', + '-fobjc-arc', + '-fblocks', + '-Wall', + '-Werror', + '-framework', + 'AppKit', + '-framework', + 'WebKit', + fileURLToPath( + new URL('./navigation-macos.m', import.meta.url), + ), + '-o', + binary, + ], + { encoding: 'utf8', timeout: 30000 }, + ); + assert.equal( + compile.status, + 0, + compile.stderr || compile.error?.message, + ); + const result = spawnSync(binary, [], { + encoding: 'utf8', + timeout: 30000, + }); + assert.equal( + result.status, + 0, + result.stderr || result.error?.message, + ); + assert.match(result.stdout, /PASS: AppKit/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); diff --git a/native/ui/workspace-navigation.js b/native/ui/workspace-navigation.js new file mode 100644 index 0000000..5b7a34e --- /dev/null +++ b/native/ui/workspace-navigation.js @@ -0,0 +1,82 @@ +(() => { + if (window.top !== window || window.__kanbanNavigation) { + return; + } + + let dragging = false; + const overlay = () => + [ + ...document.querySelectorAll( + 'dialog[open], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-reka-popper-content-wrapper]', + ), + ].some( + (element) => + element.getAttribute('data-state') !== 'closed' && + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== 'hidden', + ); + + const constrainScroll = () => { + if (document.documentElement) { + document.documentElement.style.overscrollBehaviorY = 'none'; + } + if (document.body) { + document.body.style.overscrollBehaviorY = 'none'; + } + }; + constrainScroll(); + document.addEventListener('DOMContentLoaded', constrainScroll, { + once: true, + }); + document.addEventListener('dragstart', () => { + dragging = true; + }); + document.addEventListener('dragend', () => { + dragging = false; + }); + document.addEventListener('drop', () => { + dragging = false; + }); + + window.__kanbanNavigation = Object.freeze({ + canSwipe(x, y) { + if (dragging || overlay()) { + return false; + } + for ( + let element = document.elementFromPoint(x, y); + element; + element = element.parentElement + ) { + if ( + element.matches( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [draggable="true"]', + ) + ) { + return false; + } + const style = getComputedStyle(element); + if ( + /(auto|scroll)/.test(style.overflowX) && + element.scrollWidth > element.clientWidth + ) { + return false; + } + if ( + style.overscrollBehaviorX === 'contain' || + style.overscrollBehaviorX === 'none' + ) { + return false; + } + } + return true; + }, + step(direction) { + // The host checks canGoBack/canGoForward. JS traversal preserves SPA entries + // that WebKit's user-initiated history traversal is allowed to skip. + if (direction === -1 || direction === 1) { + window.history.go(direction); + } + }, + }); +})();