Merge pull request 'KAN-71: Correct iOS and macOS navigation gestures and scroll bounds' (#168) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m26s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m37s
CI/CD / Publish immutable image (push) Successful in 1m17s

Reviewed-on: #168
This commit was merged in pull request #168.
This commit is contained in:
2026-09-29 05:28:53 +00:00
17 changed files with 799 additions and 56 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.2.3", "version": "0.2.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,5 +25,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@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."
} }
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.2.3" version = "0.2.4"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
+48
View File
@@ -1,3 +1,51 @@
fn main() { fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
build_navigation();
}
tauri_build::build(); 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");
}
@@ -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 defaults: UserDefaults
private let serverKey = "ios.activeServer" private let serverKey = "ios.activeServer"
private(set) var server: WorkspaceServer? private(set) var server: WorkspaceServer?
@@ -40,8 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system) private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar() private let toolbar = UIToolbar()
private var historyObservation: NSKeyValueObservation? private var gestureStart = CGPoint.zero
private var backButton: UIBarButtonItem! private var gestureAllowed = false
private var gestureRevision = 0
private var appearance: UISegmentedControl! private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@@ -94,7 +95,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
launcher.isHidden = true launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings" launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true launcher.bounces = false
view.addSubview(launcher) view.addSubview(launcher)
launcher.translatesAutoresizingMaskIntoConstraints = false launcher.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -164,11 +165,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
toolbar.isHidden = true toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar) 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)) let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings" settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings] toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 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 } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
timeout?.cancel() timeout?.cancel()
historyObservation = nil
workspace?.stopLoading() workspace?.stopLoading()
workspace?.navigationDelegate = nil workspace?.navigationDelegate = nil
workspace?.removeFromSuperview() workspace?.removeFromSuperview()
@@ -211,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
server = selected server = selected
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile) 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) let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self webview.navigationDelegate = self
webview.uiDelegate = 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.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true webview.isHidden = true
@@ -227,9 +238,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
]) ])
workspace = webview workspace = webview
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
self?.backButton.isEnabled = view.canGoBack
}
view.endEditing(true) view.endEditing(true)
address.text = url.absoluteString address.text = url.absoluteString
launcher.isHidden = true launcher.isHidden = true
@@ -275,10 +283,46 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
launcher.isHidden = true launcher.isHidden = true
toolbar.isHidden = false toolbar.isHidden = false
workspace?.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) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } 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) connectButton.setTitle(text("openWorkspace"), for: .normal)
status.text = text("connected") status.text = text("connected")
resumeButton.isHidden = false resumeButton.isHidden = false
backButton.isEnabled = webView.canGoBack
if firstLoad { showWorkspace() } if firstLoad { showWorkspace() }
} }
@@ -95,41 +95,82 @@ final class WorkspaceTests: XCTestCase {
} }
@MainActor @MainActor
func testBackButtonTracksSameDocumentHistory() async throws { func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
let name = "KAN55-history-\(UUID())" let name = "KAN71-history-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) } defer { defaults.removePersistentDomain(forName: name) }
let controller = WorkspaceViewController(defaults: defaults) let controller = WorkspaceViewController(defaults: defaults)
controller.loadViewIfNeeded() controller.loadViewIfNeeded()
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.open(url) let url = URL(string: "https://example.invalid/dashboard")!
controller.open(URL(string: "https://example.invalid/")!)
let webview = try XCTUnwrap(controller.workspace) let webview = try XCTUnwrap(controller.workspace)
webview.stopLoading() webview.stopLoading()
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>") webview.frame = controller.view.bounds
var loaded = false webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
""")
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 { for _ in 0..<50 {
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
value as? String == "unsaved" { loaded = true; break } try await Task.sleep(for: .milliseconds(20))
try await Task.sleep(for: .milliseconds(100))
} }
XCTAssertTrue(loaded) XCTAssertTrue(allowed)
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')") edge.testState = .cancelled
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) edge.testTranslation = CGPoint(x: 200, y: 0)
for _ in 0..<20 { controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
if toolbar.items?.first?.isEnabled == true { break } try await Task.sleep(for: .milliseconds(100))
try await Task.sleep(for: .milliseconds(50)) try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
} edge.testState = .ended
XCTAssertTrue(webview.canGoBack) edge.testTranslation = CGPoint(x: 10, y: 0)
XCTAssertTrue(toolbar.items?.first?.isEnabled == true) controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
webview.goBack() try await Task.sleep(for: .milliseconds(100))
var returned = false try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
for _ in 0..<20 { edge.testTranslation = CGPoint(x: 200, y: 0)
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break } controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
try await Task.sleep(for: .milliseconds(50)) try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
} controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
XCTAssertTrue(returned) try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value") 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") 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', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
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 { func testServerChangesNeverReusePreviousProfile() throws {
@@ -189,7 +230,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url) controller.open(url)
let server = try XCTUnwrap(controller.server) let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile) 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) let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded() restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server) 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 }
}
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; }; 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 */; }; 0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; }; 205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; }; 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 */; }; 7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; }; 73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; }; 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 */; }; A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; }; B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.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; }; 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 = "<group>"; }; 6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; }; 7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 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 = "<group>"; };
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; }; 91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 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 = "<group>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
@@ -110,6 +114,7 @@
8606F3EE5A736385061692EF /* Sources */, 8606F3EE5A736385061692EF /* Sources */,
335B1262A9019C70C7F887A5 /* src */, 335B1262A9019C70C7F887A5 /* src */,
140A7DB7E2461E64B1FBFA70 /* Tests */, 140A7DB7E2461E64B1FBFA70 /* Tests */,
709BE6DF191B953724A55555 /* ui */,
3C9F31BB065F210267ECA390 /* UITests */, 3C9F31BB065F210267ECA390 /* UITests */,
3028C3786B437115B6486F1C /* Frameworks */, 3028C3786B437115B6486F1C /* Frameworks */,
59D2DE9FD94E6D2D64CB0117 /* Products */, 59D2DE9FD94E6D2D64CB0117 /* Products */,
@@ -149,6 +154,7 @@
AEBAD9ABE758897C50BC1F2E /* locale.rs */, AEBAD9ABE758897C50BC1F2E /* locale.rs */,
7440C7F0426E6B3977DD1D94 /* main.rs */, 7440C7F0426E6B3977DD1D94 /* main.rs */,
5F71F06AB639140AB9714425 /* metadata.rs */, 5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */, E8BA104C81071623E1FF367D /* server.rs */,
6610D376CF901AC1733D394E /* updates.rs */, 6610D376CF901AC1733D394E /* updates.rs */,
); );
@@ -183,6 +189,15 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
709BE6DF191B953724A55555 /* ui */ = {
isa = PBXGroup;
children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
);
name = ui;
path = ../../../ui;
sourceTree = "<group>";
};
8606F3EE5A736385061692EF /* Sources */ = { 8606F3EE5A736385061692EF /* Sources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -251,6 +266,7 @@
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */; buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
buildPhases = ( buildPhases = (
2A0467365B852D44D1A9FD5B /* Sources */, 2A0467365B852D44D1A9FD5B /* Sources */,
DC3295DE0893151786FEA37B /* Resources */,
); );
buildRules = ( buildRules = (
); );
@@ -317,6 +333,15 @@
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */, C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */, 34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings 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; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -416,7 +441,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=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)"; "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_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
@@ -593,7 +618,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = ZZX2KZGTHU; DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = 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=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)"; "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_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative; PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit; PRODUCT_NAME = KanbanGit;
+4 -2
View File
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
MARKETING_VERSION: "0.2.3" MARKETING_VERSION: "0.2.4"
CURRENT_PROJECT_VERSION: "3" CURRENT_PROJECT_VERSION: "4"
configs: configs:
debug: debug:
CODE_SIGN_IDENTITY: Apple Development CODE_SIGN_IDENTITY: Apple Development
@@ -42,6 +42,7 @@ targets:
platform: iOS platform: iOS
sources: sources:
- path: Sources - path: Sources
- path: ../../../ui/workspace-navigation.js
- path: Assets.xcassets - path: Assets.xcassets
- path: kanbangit-native_iOS - path: kanbangit-native_iOS
- path: LaunchScreen.storyboard - path: LaunchScreen.storyboard
@@ -114,6 +115,7 @@ targets:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
sources: sources:
- path: ../../../ui/workspace-navigation.js
- path: Tests - path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift - path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift - path: Sources/kanbangit-native/WorkspaceViewController.swift
+10
View File
@@ -177,6 +177,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
.center() .center()
.visible(false) .visible(false)
.data_store_identifier(server.profile) .data_store_identifier(server.profile)
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
.on_navigation(move |destination| { .on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) { if !allows_server_navigation(&server_url, destination) {
open_external(destination); open_external(destination);
@@ -227,6 +228,15 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
}) })
.build() .build()
.map_err(|_| "connectionError")?; .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(); let handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
+102
View File
@@ -0,0 +1,102 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import <objc/runtime.h>
// 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);
}
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.2.3", "version": "0.2.4",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -40,6 +40,9 @@
"y": 170 "y": 170
} }
} }
},
"android": {
"versionCode": 2004
} }
}, },
"plugins": { "plugins": {
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": { "iOS": {
"minimumSystemVersion": "17.0", "minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU", "developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "3" "bundleVersion": "4"
} }
} }
} }
+21 -1
View File
@@ -125,7 +125,27 @@ fn main() {
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated'); 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; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})(); })();
"#).unwrap(); "#).unwrap();
+151
View File
@@ -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(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
});
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));
}
+142
View File
@@ -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<NSString *> *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;
}
+58
View File
@@ -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 });
}
},
);
+82
View File
@@ -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);
}
},
});
})();