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
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:
+2
-2
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
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 Task.sleep(for: .milliseconds(100))
|
||||||
}
|
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||||
XCTAssertTrue(loaded)
|
edge.testState = .ended
|
||||||
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||||
for _ in 0..<20 {
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
if toolbar.items?.first?.isEnabled == true { break }
|
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||||
}
|
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||||
XCTAssertTrue(webview.canGoBack)
|
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||||
webview.goBack()
|
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||||
var returned = false
|
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||||
for _ in 0..<20 {
|
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
|
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
}
|
|
||||||
XCTAssertTrue(returned)
|
|
||||||
let draft = try await webview.evaluateJavaScript("document.getElementById('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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "3"
|
"bundleVersion": "4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user