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}
+ `);
+});
+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);
+ }
+ },
+ });
+})();