KAN-71: Use interactive WebKit gestures on iOS and macOS #169
@@ -1,5 +1,15 @@
|
|||||||
# Working Agreements
|
# Working Agreements
|
||||||
|
|
||||||
|
## Native app versions and build numbers
|
||||||
|
|
||||||
|
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
|
||||||
|
|
||||||
|
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
|
||||||
|
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
|
||||||
|
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
|
||||||
|
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
|
||||||
|
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
|
||||||
|
|
||||||
## Missing programs and SDKs
|
## Missing programs and SDKs
|
||||||
|
|
||||||
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"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": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll."
|
"releaseNotes": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||||
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,9 +40,6 @@ 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 gestureStart = CGPoint.zero
|
|
||||||
private var gestureAllowed = false
|
|
||||||
private var gestureRevision = 0
|
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@@ -216,17 +213,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
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
|
||||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
webview.allowsBackForwardNavigationGestures = true
|
||||||
// owns each gesture and traverses the same history that Inertia restores.
|
|
||||||
webview.allowsBackForwardNavigationGestures = false
|
|
||||||
webview.scrollView.bounces = 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
|
||||||
@@ -285,45 +273,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
workspace?.isHidden = false
|
workspace?.isHidden = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
guard server?.allows(url) == true else {
|
guard server?.allows(url) == true else {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||||
let name = "KAN71-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) }
|
||||||
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
""")
|
""")
|
||||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||||
XCTAssertFalse(webview.canGoBack)
|
XCTAssertFalse(webview.canGoBack)
|
||||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
XCTAssertNil(webview.goBack())
|
||||||
|
XCTAssertNil(webview.goForward())
|
||||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||||
for index in 1...3 {
|
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||||
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
XCTAssertFalse(webview.scrollView.bounces)
|
||||||
}
|
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||||
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||||
let edge = TestEdgeGesture()
|
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||||
edge.edges = .left
|
|
||||||
edge.testVelocity = CGPoint(x: 800, y: 0)
|
|
||||||
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
|
||||||
var allowed = false
|
|
||||||
for _ in 0..<50 {
|
|
||||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = 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 waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
|
||||||
edge.testState = .ended
|
|
||||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
|
||||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
|
||||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
|
||||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
|
||||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
|
||||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
|
||||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
|
||||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
|
||||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
|
||||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
|
||||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
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)
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||||
@@ -158,10 +130,9 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
XCTAssertEqual(scroll as? String, "none")
|
XCTAssertEqual(scroll as? String, "none")
|
||||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||||
XCTAssertEqual(horizontal as? String, "auto")
|
XCTAssertEqual(horizontal as? String, "auto")
|
||||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
controller.open(URL(string: "https://other.invalid/")!)
|
||||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||||
try await Task.sleep(for: .milliseconds(200))
|
"Changing workspaces must retain native gesture navigation")
|
||||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -272,19 +243,3 @@ 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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -441,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 = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -457,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.4;
|
MARKETING_VERSION = 0.2.5;
|
||||||
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;
|
||||||
@@ -618,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 = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -634,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.4;
|
MARKETING_VERSION = 0.2.5;
|
||||||
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.4"
|
MARKETING_VERSION: "0.2.5"
|
||||||
CURRENT_PROJECT_VERSION: "4"
|
CURRENT_PROJECT_VERSION: "5"
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_IDENTITY: Apple Development
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
|
|||||||
@@ -1,102 +1,6 @@
|
|||||||
#import <AppKit/AppKit.h>
|
|
||||||
#import <WebKit/WebKit.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) {
|
void install_workspace_navigation(void *pointer) {
|
||||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
webview.allowsBackForwardNavigationGestures = NO;
|
webview.allowsBackForwardNavigationGestures = YES;
|
||||||
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.4",
|
"version": "0.2.5",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "9",
|
"bundleVersion": "10",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "4"
|
"bundleVersion": "5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,27 +125,9 @@ 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 initialHistory = history.length;
|
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
|
||||||
const initialPath = location.pathname;
|
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||||
const navigation = window.__kanbanNavigation;
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||||
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();
|
||||||
|
|||||||
@@ -25,74 +25,17 @@ await page.addInitScript(script);
|
|||||||
const root = `http://127.0.0.1:${server.address().port}`;
|
const root = `http://127.0.0.1:${server.address().port}`;
|
||||||
try {
|
try {
|
||||||
await page.goto(root);
|
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(
|
assert.equal(
|
||||||
await page.evaluate(() => history.state.rememberedState.draft),
|
await page.evaluate(() => typeof window.__kanbanNavigation),
|
||||||
'retained',
|
'undefined',
|
||||||
);
|
);
|
||||||
await page.goto(`${root}/full-page`);
|
await page.goto(`${root}/full-page`);
|
||||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
await page.goBack();
|
||||||
await page.waitForURL(`${root}/ticket`);
|
await page.waitForURL(`${root}/`);
|
||||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
await page.goForward();
|
||||||
await page.waitForURL(`${root}/full-page`);
|
await page.waitForURL(`${root}/full-page`);
|
||||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
await page.locator('#draft').fill('unsaved draft');
|
||||||
assert.equal(
|
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||||
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(
|
assert.equal(
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
@@ -143,7 +86,7 @@ try {
|
|||||||
() => document.querySelector('dialog').scrollTop > 0,
|
() => document.querySelector('dialog').scrollTop > 0,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards',
|
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
+59
-127
@@ -1,142 +1,74 @@
|
|||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <WebKit/WebKit.h>
|
||||||
#import "../src-tauri/src/navigation.m"
|
#import "../src-tauri/src/navigation.m"
|
||||||
|
|
||||||
// Exercise the AppKit event adapter deterministically; macos_smoke separately
|
static void check(BOOL condition, NSString *message) {
|
||||||
// runs the shared history script against a real WKWebView.
|
if (!condition) {
|
||||||
@interface TestWindow : NSWindow
|
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
|
||||||
@end
|
exit(1);
|
||||||
@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
|
static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
||||||
@property(nonatomic, strong) NSWindow *testWindow;
|
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
|
||||||
@property(nonatomic) NSEventPhase testPhase;
|
while (!condition() && deadline.timeIntervalSinceNow > 0) {
|
||||||
@property(nonatomic) NSEventPhase testMomentum;
|
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
||||||
@property(nonatomic) CGFloat dx;
|
}
|
||||||
@property(nonatomic) CGFloat dy;
|
check(condition(), message);
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||||
|
@property(nonatomic) NSUInteger finished;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@interface TestNavigation : KanbanNavigation
|
@implementation WorkspacePages
|
||||||
@property(nonatomic) BOOL enabled;
|
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
|
||||||
@end
|
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
|
||||||
@implementation TestNavigation
|
dataUsingEncoding:NSUTF8StringEncoding];
|
||||||
- (BOOL)swipeTrackingEnabled { return self.enabled; }
|
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
|
||||||
|
expectedContentLength:body.length textEncodingName:@"utf-8"];
|
||||||
|
[task didReceiveResponse:response];
|
||||||
|
[task didReceiveData:body];
|
||||||
|
[task didFinish];
|
||||||
|
}
|
||||||
|
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
|
||||||
|
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
|
||||||
|
self.finished++;
|
||||||
|
}
|
||||||
@end
|
@end
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
[NSApplication sharedApplication];
|
[NSApplication sharedApplication];
|
||||||
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
WorkspacePages *pages = [WorkspacePages new];
|
||||||
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)];
|
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
|
||||||
webview.testWindow = window;
|
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
|
||||||
webview.testURL = [NSURL URLWithString:@"https://example.com/board"];
|
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||||
webview.back = YES;
|
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||||
webview.forward = YES;
|
webview.navigationDelegate = pages;
|
||||||
webview.allowsSwipe = YES;
|
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||||
webview.steps = [NSMutableArray new];
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
TestNavigation *navigation = [TestNavigation new];
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
navigation.webview = webview;
|
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||||
navigation.enabled = YES;
|
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||||
TestEvent *(^event)(void) = ^{
|
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||||
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++) {
|
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
|
||||||
TestEvent *ignored = event();
|
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
|
||||||
navigation.enabled = test != 0;
|
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
|
||||||
webview.allowsSwipe = test != 1;
|
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
|
||||||
webview.back = webview.forward = test != 2;
|
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
|
||||||
if (test == 3) ignored.precise = NO;
|
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
|
||||||
if (test == 4) ignored.dy = 30;
|
check([webview goBack] != nil, @"Native back navigation must start");
|
||||||
if (test == 5) ignored.testMomentum = NSEventPhaseChanged;
|
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
|
||||||
if (test == 6) ignored.testWindow = nil;
|
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
|
||||||
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling");
|
@"Back must restore the first page and retain forward history");
|
||||||
}
|
check([webview goBack] == nil, @"Back at the first page must leave it in place");
|
||||||
navigation.enabled = YES;
|
check([webview goForward] != nil, @"Native forward navigation must start");
|
||||||
webview.allowsSwipe = YES;
|
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
|
||||||
webview.back = YES;
|
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||||
webview.deferPermission = YES;
|
@"Forward must restore the second page");
|
||||||
TestEvent *stale = event();
|
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||||
[navigation handleEvent:stale];
|
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership',
|
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||||
{ skip: process.platform !== 'darwin' },
|
{ skip: process.platform !== 'darwin' },
|
||||||
() => {
|
() => {
|
||||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||||
@@ -50,7 +50,7 @@ test(
|
|||||||
0,
|
0,
|
||||||
result.stderr || result.error?.message,
|
result.stderr || result.error?.message,
|
||||||
);
|
);
|
||||||
assert.match(result.stdout, /PASS: AppKit/);
|
assert.match(result.stdout, /PASS: WebKit/);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(directory, { recursive: true, force: true });
|
rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
(() => {
|
(() => {
|
||||||
if (window.top !== window || window.__kanbanNavigation) {
|
if (window.top !== window) {
|
||||||
return;
|
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 = () => {
|
const constrainScroll = () => {
|
||||||
if (document.documentElement) {
|
if (document.documentElement) {
|
||||||
document.documentElement.style.overscrollBehaviorY = 'none';
|
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||||
@@ -28,55 +15,4 @@
|
|||||||
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||||
once: true,
|
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