Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e | ||
|
|
b124a77cd1 | ||
|
|
2725816273 | ||
|
|
32a6756419 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,5 +25,5 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
},
|
||||
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
|
||||
"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]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.1"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.1"
|
||||
version = "0.2.4"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||
build_navigation();
|
||||
}
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
fn build_navigation() {
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
let object = output.join("navigation.o");
|
||||
let archive = output.join("libkanban_navigation.a");
|
||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x86_64",
|
||||
_ => panic!("Unsupported macOS architecture"),
|
||||
};
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/navigation.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args(["ar", "crs"])
|
||||
.arg(&archive)
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Xcode ar is required")
|
||||
.success());
|
||||
println!("cargo:rustc-link-search=native={}", output.display());
|
||||
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||
}
|
||||
|
||||
+54
@@ -5,6 +5,7 @@ import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -59,6 +60,59 @@ class PlatformTest {
|
||||
instrumentation.waitForIdleSync()
|
||||
}
|
||||
|
||||
@Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() {
|
||||
val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved")
|
||||
val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE)
|
||||
preferences.edit().clear().apply {
|
||||
if (mode == "saved") putString("server", "https://127.0.0.1:9/app/")
|
||||
if (mode == "invalid") putString("server", "http://invalid.example/")
|
||||
}.commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
val launcher = AtomicReference<WebView>()
|
||||
fun findLauncher(view: View): WebView? {
|
||||
if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view
|
||||
if (view is ViewGroup) for (index in 0 until view.childCount) {
|
||||
findLauncher(view.getChildAt(index))?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
waitFor {
|
||||
scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) }
|
||||
launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true"
|
||||
}
|
||||
if (mode == "saved") {
|
||||
waitFor {
|
||||
val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state")
|
||||
if (state == "\"connecting\"") {
|
||||
assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden"))
|
||||
}
|
||||
state == "\"connectionError\""
|
||||
}
|
||||
assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null))
|
||||
assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent"))
|
||||
scenario.onActivity {
|
||||
assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl)
|
||||
assertFalse(it.workspace!!.canGoBack())
|
||||
it.openWorkspace("https://127.0.0.1:9/app/")
|
||||
}
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" }
|
||||
} else {
|
||||
waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" }
|
||||
scenario.onActivity { assertNull(it.workspace) }
|
||||
}
|
||||
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||
val remote = AtomicReference<WebView>()
|
||||
waitFor {
|
||||
var visible = false
|
||||
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||
visible && remote.get() != null
|
||||
}
|
||||
assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname"))
|
||||
assertEquals("1", evaluate(remote.get(), "history.length"))
|
||||
assertEquals("https://kanbangit.de/", preferences.getString("server", null))
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
}
|
||||
|
||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
val saved = preferences.getString("server", null)
|
||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
||||
updateLauncher()
|
||||
if (saved != null) openWorkspace(server)
|
||||
val restored = saved?.let(NavigationPolicy::installation)
|
||||
server = restored ?: NavigationPolicy.DEFAULT_SERVER
|
||||
if (restored != null) openWorkspace(restored) else updateLauncher()
|
||||
}
|
||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||
"/action/resume" -> if (ready) showWorkspace()
|
||||
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
|
||||
val firstLoad = !ready
|
||||
ready = true
|
||||
CookieManager.getInstance().flush()
|
||||
updateLauncher()
|
||||
if (firstLoad) showWorkspace()
|
||||
updateLauncher()
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
|
||||
remote.visibility = View.GONE
|
||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||
startTimeout(remote)
|
||||
remote.loadUrl(server)
|
||||
remote.loadUrl(NavigationPolicy.dashboard(server))
|
||||
}
|
||||
|
||||
private fun startTimeout(view: WebView) {
|
||||
|
||||
+2
@@ -21,6 +21,8 @@ object NavigationPolicy {
|
||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||
}.getOrNull()
|
||||
|
||||
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
|
||||
|
||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||
val base = URI(server)
|
||||
val target = URI(destination)
|
||||
|
||||
+5
@@ -12,6 +12,11 @@ class NavigationPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun startupTargetsDashboardWithinTheInstallation() {
|
||||
assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/"))
|
||||
assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/"))
|
||||
}
|
||||
|
||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||
val server = "https://example.com:8443/app/"
|
||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||
|
||||
@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
|
||||
return components.url
|
||||
}
|
||||
|
||||
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
|
||||
|
||||
func allows(_ destination: URL) -> Bool {
|
||||
destination.scheme?.lowercased() == "https"
|
||||
&& destination.host?.lowercased() == url.host?.lowercased()
|
||||
|
||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private var connection = WorkspaceConnection()
|
||||
private var pendingNavigation: WKNavigation?
|
||||
private var timeout: DispatchWorkItem?
|
||||
private let loading = UIActivityIndicatorView(style: .large)
|
||||
private let launcher = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let address = UITextField()
|
||||
@@ -39,8 +40,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var historyObservation: NSKeyValueObservation?
|
||||
private var backButton: UIBarButtonItem!
|
||||
private var gestureStart = CGPoint.zero
|
||||
private var gestureAllowed = false
|
||||
private var gestureRevision = 0
|
||||
private var appearance: UISegmentedControl!
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -58,6 +60,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1)
|
||||
loading.accessibilityIdentifier = "workspaceLoading"
|
||||
loading.accessibilityLabel = text("connecting")
|
||||
loading.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(loading)
|
||||
NSLayoutConstraint.activate([
|
||||
loading.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loading.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
configureLauncher()
|
||||
configureToolbar()
|
||||
if let data = defaults.data(forKey: serverKey) {
|
||||
@@ -69,8 +79,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
}
|
||||
address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
|
||||
showSettings()
|
||||
if let server { open(server.url) }
|
||||
if let server { open(server.url) } else { showSettings() }
|
||||
}
|
||||
|
||||
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
|
||||
@@ -83,9 +92,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureLauncher() {
|
||||
launcher.isHidden = true
|
||||
launcher.accessibilityIdentifier = "serverSettings"
|
||||
launcher.keyboardDismissMode = .interactive
|
||||
launcher.alwaysBounceVertical = true
|
||||
launcher.bounces = false
|
||||
view.addSubview(launcher)
|
||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -152,13 +162,12 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
private func configureToolbar() {
|
||||
toolbar.isHidden = true
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(toolbar)
|
||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
||||
backButton.accessibilityIdentifier = "workspaceBack"
|
||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||
settings.accessibilityIdentifier = "showServerSettings"
|
||||
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
NSLayoutConstraint.activate([
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
@@ -193,7 +202,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||
defaults.set(data, forKey: serverKey)
|
||||
timeout?.cancel()
|
||||
historyObservation = nil
|
||||
workspace?.stopLoading()
|
||||
workspace?.navigationDelegate = nil
|
||||
workspace?.removeFromSuperview()
|
||||
@@ -201,11 +209,24 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
server = selected
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
||||
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
// Remote content receives no native message handlers or Tauri bridge.
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||
// owns each gesture and traverses the same history that Inertia restores.
|
||||
webview.allowsBackForwardNavigationGestures = false
|
||||
webview.scrollView.bounces = false
|
||||
for edge: UIRectEdge in [.left, .right] {
|
||||
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||
gesture.edges = edge
|
||||
gesture.delegate = self
|
||||
webview.addGestureRecognizer(gesture)
|
||||
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||
}
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -217,13 +238,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
])
|
||||
workspace = webview
|
||||
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
||||
self?.backButton.isEnabled = view.canGoBack
|
||||
}
|
||||
view.endEditing(true)
|
||||
address.text = url.absoluteString
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = true
|
||||
loading.startAnimating()
|
||||
beginNavigation()
|
||||
pendingNavigation = webview.load(URLRequest(url: url))
|
||||
pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
|
||||
}
|
||||
|
||||
private func beginNavigation() {
|
||||
@@ -247,6 +268,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
toolbar.isHidden = true
|
||||
@@ -256,14 +278,51 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
@objc private func showWorkspace() {
|
||||
guard connection.ready else { return }
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = false
|
||||
workspace?.isHidden = false
|
||||
backButton.isEnabled = workspace?.canGoBack == true
|
||||
}
|
||||
|
||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||
gestureStart = touch.location(in: webview)
|
||||
gestureAllowed = false
|
||||
gestureRevision += 1
|
||||
let revision = gestureRevision
|
||||
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||
self.gestureAllowed = result as? Bool == true
|
||||
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||
let velocity = edge.velocity(in: webview)
|
||||
return abs(velocity.x) > abs(velocity.y)
|
||||
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||
}
|
||||
|
||||
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||
guard gesture.state == .ended, let webview = workspace else { return }
|
||||
let direction = gesture.edges == .left ? -1 : 1
|
||||
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||
navigateHistory(direction, from: gestureStart)
|
||||
}
|
||||
|
||||
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||
guard let webview = workspace,
|
||||
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||
@@ -296,7 +355,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard webView === workspace, navigation === pendingNavigation else { return }
|
||||
guard webView === workspace, navigation === pendingNavigation, connection.connecting,
|
||||
let url = webView.url, server?.allows(url) == true else { return }
|
||||
let firstLoad = !connection.ready
|
||||
timeout?.cancel()
|
||||
connection.finish()
|
||||
@@ -304,7 +364,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||
status.text = text("connected")
|
||||
resumeButton.isHidden = false
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
if firstLoad { showWorkspace() }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testDashboardAddressPreservesTheInstallationPath() throws {
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/"))
|
||||
XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString,
|
||||
"https://example.com:8443/kanban/dashboard")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws {
|
||||
let name = "KAN69-startup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID())
|
||||
let data = try JSONEncoder().encode(saved)
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server, saved)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
XCTAssertTrue(webview.backForwardList.backList.isEmpty)
|
||||
webview.stopLoading()
|
||||
let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "<html></html>"))
|
||||
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
||||
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
XCTAssertFalse(loading.isAnimating)
|
||||
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved)
|
||||
controller.webView(webview, didFinish: navigation)
|
||||
XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure")
|
||||
controller.open(saved.url)
|
||||
XCTAssertTrue(launcher.isHidden)
|
||||
XCTAssertTrue(loading.isAnimating)
|
||||
XCTAssertEqual(controller.server?.profile, saved.profile)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws {
|
||||
let name = "KAN69-setup-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] {
|
||||
defaults.set(data, forKey: "ios.activeServer")
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
XCTAssertNil(controller.workspace)
|
||||
let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" })
|
||||
XCTAssertFalse(launcher.isHidden)
|
||||
}
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.open(URL(string: "https://first.invalid/")!)
|
||||
controller.open(URL(string: "https://second.invalid/app/")!)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, controller.server)
|
||||
XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard")
|
||||
XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true)
|
||||
}
|
||||
|
||||
func testExternalLinksStayOutsideTheSelectedServer() throws {
|
||||
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID())
|
||||
for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
|
||||
@@ -30,41 +95,82 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testBackButtonTracksSameDocumentHistory() async throws {
|
||||
let name = "KAN55-history-\(UUID())"
|
||||
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||
let name = "KAN71-history-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
||||
controller.open(url)
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/dashboard")!
|
||||
controller.open(URL(string: "https://example.invalid/")!)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>")
|
||||
var loaded = false
|
||||
webview.frame = controller.view.bounds
|
||||
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 {
|
||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
||||
value as? String == "unsaved" { loaded = true; break }
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertTrue(loaded)
|
||||
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
for _ in 0..<20 {
|
||||
if toolbar.items?.first?.isEnabled == true { break }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
XCTAssertTrue(webview.canGoBack)
|
||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
||||
webview.goBack()
|
||||
var returned = false
|
||||
for _ in 0..<20 {
|
||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
XCTAssertTrue(returned)
|
||||
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
||||
XCTAssertTrue(allowed)
|
||||
edge.testState = .cancelled
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testState = .ended
|
||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<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 {
|
||||
@@ -124,7 +230,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
controller.open(url)
|
||||
let server = try XCTUnwrap(controller.server)
|
||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
@@ -166,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 }
|
||||
}
|
||||
|
||||
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
|
||||
XCTAssertTrue(element.isHittable)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
let address = app.textFields["serverAddress"]
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
address.tap()
|
||||
if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() }
|
||||
address.typeText("https://127.0.0.1:9/")
|
||||
app.buttons["openWorkspace"].tap()
|
||||
let confirmation = app.alerts.buttons["Switch server"]
|
||||
if confirmation.waitForExistence(timeout: 1) { confirmation.tap() }
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20))
|
||||
app.terminate()
|
||||
app.launch()
|
||||
// An unreachable saved server may fail immediately; only recovery may reveal setup.
|
||||
for _ in 0..<30 {
|
||||
if address.exists {
|
||||
XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading"))
|
||||
break
|
||||
}
|
||||
XCTAssertFalse(app.staticTexts["Choose your workspace."].exists)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
XCTAssertTrue(address.waitForExistence(timeout: 20))
|
||||
XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/")
|
||||
XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLauncherKeyboardValidationRotationAndAppearance() {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
defer { XCUIDevice.shared.orientation = .portrait }
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
app.launch()
|
||||
let settings = app.buttons["showServerSettings"]
|
||||
if settings.waitForExistence(timeout: 3) { settings.tap() }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||
@@ -18,6 +19,7 @@
|
||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||
@@ -53,7 +55,9 @@
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -110,6 +114,7 @@
|
||||
8606F3EE5A736385061692EF /* Sources */,
|
||||
335B1262A9019C70C7F887A5 /* src */,
|
||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||
709BE6DF191B953724A55555 /* ui */,
|
||||
3C9F31BB065F210267ECA390 /* UITests */,
|
||||
3028C3786B437115B6486F1C /* Frameworks */,
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||
@@ -149,6 +154,7 @@
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
6610D376CF901AC1733D394E /* updates.rs */,
|
||||
);
|
||||
@@ -183,6 +189,15 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
709BE6DF191B953724A55555 /* ui */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||
);
|
||||
name = ui;
|
||||
path = ../../../ui;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8606F3EE5A736385061692EF /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -251,6 +266,7 @@
|
||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||
buildPhases = (
|
||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||
DC3295DE0893151786FEA37B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -317,6 +333,15 @@
|
||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DC3295DE0893151786FEA37B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -416,7 +441,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -432,7 +457,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.1;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -593,7 +618,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -609,7 +634,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.1;
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
MARKETING_VERSION: "0.2.4"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
@@ -42,6 +42,7 @@ targets:
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: Assets.xcassets
|
||||
- path: kanbangit-native_iOS
|
||||
- path: LaunchScreen.storyboard
|
||||
@@ -114,6 +115,7 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
|
||||
+101
-81
@@ -169,76 +169,87 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
let popup_server = server_url.clone();
|
||||
let popup_handle = app.clone();
|
||||
let popup_label = label.clone();
|
||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(1280.0, 850.0)
|
||||
.min_inner_size(375.0, 600.0)
|
||||
.center()
|
||||
.visible(false)
|
||||
.data_store_identifier(server.profile)
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_server_navigation(&server_url, destination) {
|
||||
open_external(destination);
|
||||
return false;
|
||||
}
|
||||
begin_navigation(&handle, &navigation_label);
|
||||
true
|
||||
})
|
||||
.on_new_window(move |destination, _| {
|
||||
if allows_server_navigation(&popup_server, &destination) {
|
||||
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
||||
let _ = window.navigate(destination);
|
||||
let window =
|
||||
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(1280.0, 850.0)
|
||||
.min_inner_size(375.0, 600.0)
|
||||
.center()
|
||||
.visible(false)
|
||||
.data_store_identifier(server.profile)
|
||||
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_server_navigation(&server_url, destination) {
|
||||
open_external(destination);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
open_external(&destination);
|
||||
}
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
.on_page_load(|window, payload| {
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let should_show = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() != Some(window.label()) {
|
||||
begin_navigation(&handle, &navigation_label);
|
||||
true
|
||||
})
|
||||
.on_new_window(move |destination, _| {
|
||||
if allows_server_navigation(&popup_server, &destination) {
|
||||
if let Some(window) = popup_handle.get_webview_window(&popup_label) {
|
||||
let _ = window.navigate(destination);
|
||||
}
|
||||
} else {
|
||||
open_external(&destination);
|
||||
}
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
.on_page_load(|window, payload| {
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
let first_load = !state.ready;
|
||||
state.ready = true;
|
||||
state.phase = "connected";
|
||||
first_load
|
||||
};
|
||||
update_launcher(app);
|
||||
if should_show {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||
let _ = launcher.hide();
|
||||
let app = window.app_handle();
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let should_show = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() != Some(window.label())
|
||||
|| state.phase != "connecting"
|
||||
|| !state.server.as_ref().is_some_and(|server| {
|
||||
allows_server_navigation(&server.url, payload.url())
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let first_load = !state.ready;
|
||||
state.ready = true;
|
||||
state.phase = "connected";
|
||||
first_load
|
||||
};
|
||||
if should_show {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Some(launcher) = app.get_webview_window("launcher") {
|
||||
let _ = launcher.hide();
|
||||
}
|
||||
}
|
||||
update_launcher(app);
|
||||
})
|
||||
.build()
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
window
|
||||
.with_webview(|webview| unsafe {
|
||||
unsafe extern "C" {
|
||||
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
|
||||
}
|
||||
install_workspace_navigation(webview.inner().cast());
|
||||
})
|
||||
.build()
|
||||
.map_err(|_| "connectionError")?;
|
||||
let handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
let state = handle.state::<Mutex<Connection>>();
|
||||
let current = {
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.label.as_deref() == Some(&label) {
|
||||
state.label = None;
|
||||
state.ready = false;
|
||||
state.revision += 1;
|
||||
state.phase = "idle";
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
let current = handle
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.label
|
||||
.as_deref()
|
||||
== Some(&label);
|
||||
if current {
|
||||
show_launcher(&handle);
|
||||
api.prevent_close();
|
||||
handle.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -298,9 +309,11 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
|
||||
pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default().setup(|app| {
|
||||
let (server, phase) = match Server::load(&configuration_path(app.handle())?) {
|
||||
Ok(server) => (server, "idle"),
|
||||
Ok(Some(server)) => (Some(server), "connecting"),
|
||||
Ok(None) => (None, "idle"),
|
||||
Err(_) => (None, "storageError"),
|
||||
};
|
||||
let startup_server = server.clone();
|
||||
app.manage(Mutex::new(Connection {
|
||||
server,
|
||||
phase,
|
||||
@@ -385,6 +398,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
.title(en::APP_NAME)
|
||||
.inner_size(560.0, 720.0)
|
||||
.min_inner_size(375.0, 640.0)
|
||||
.visible(false)
|
||||
.center()
|
||||
.on_navigation(move |destination| {
|
||||
if !allows_launcher_navigation(destination) {
|
||||
@@ -407,25 +421,15 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
update_launcher(&app);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::updates::refresh(&app);
|
||||
std::thread::spawn(move || {
|
||||
let handle = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let server = handle
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.clone();
|
||||
if let Some(server) = server {
|
||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
||||
{
|
||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
||||
error;
|
||||
show_launcher(&handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let needs_setup = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.is_none();
|
||||
if needs_setup {
|
||||
show_launcher(&app);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
@@ -433,9 +437,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
launcher.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
let has_workspace = window
|
||||
.app_handle()
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.ready;
|
||||
if has_workspace {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(server) = startup_server {
|
||||
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app.handle());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn dashboard_url(&self) -> Url {
|
||||
self.url
|
||||
.join("dashboard")
|
||||
.expect("Validated installation URL")
|
||||
}
|
||||
|
||||
pub fn new(url: Url) -> io::Result<Self> {
|
||||
let mut profile = [0; 16];
|
||||
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?;
|
||||
@@ -111,6 +117,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_targets_dashboard_within_the_installation() {
|
||||
for (base, expected) in [
|
||||
("https://example.com/", "https://example.com/dashboard"),
|
||||
(
|
||||
"https://example.com:8443/kanban/",
|
||||
"https://example.com:8443/kanban/dashboard",
|
||||
),
|
||||
] {
|
||||
let server = Server::new(validate_url(base).unwrap()).unwrap();
|
||||
assert_eq!(server.dashboard_url().as_str(), expected);
|
||||
assert_eq!(server.url.as_str(), base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_round_trip_preserves_only_the_address_and_profile() {
|
||||
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "7",
|
||||
"bundleVersion": "9",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
@@ -40,6 +40,9 @@
|
||||
"y": 170
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 2004
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "1"
|
||||
"bundleVersion": "4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,64 @@ fn main() {
|
||||
};
|
||||
use tauri::{webview::PageLoadEvent, Manager};
|
||||
|
||||
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
|
||||
let online = startup == "online";
|
||||
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
|
||||
let passed = Arc::new(AtomicBool::new(false));
|
||||
let stage = Arc::new(AtomicUsize::new(0));
|
||||
let page_stage = stage.clone();
|
||||
let result_passed = passed.clone();
|
||||
let setup_passed = passed.clone();
|
||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.setup(move |app, _| {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
if needs_setup {
|
||||
if startup == "invalid" {
|
||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
|
||||
std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?;
|
||||
Ok(())
|
||||
})
|
||||
.js_init_script(r#"
|
||||
if (location.protocol === 'tauri:') {
|
||||
window.__selectionFlashed = false;
|
||||
new MutationObserver(() => {
|
||||
const selection = document.querySelector('#workspace-settings');
|
||||
const phase = document.querySelector('#connection-status')?.dataset.state;
|
||||
if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true;
|
||||
}).observe(document, { subtree: true, attributes: true, childList: true });
|
||||
}
|
||||
"#)
|
||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||
.on_page_load(move |webview, payload| {
|
||||
|
||||
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
||||
if payload.event() != PageLoadEvent::Finished {
|
||||
return;
|
||||
}
|
||||
if webview.label() == "launcher" {
|
||||
if needs_setup {
|
||||
let app = webview.app_handle().clone();
|
||||
let passed = setup_passed.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let launcher = handle.get_webview_window("launcher").unwrap();
|
||||
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
|
||||
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
|
||||
passed.store(true, Ordering::SeqCst);
|
||||
launcher.close().unwrap();
|
||||
}).unwrap();
|
||||
});
|
||||
return;
|
||||
}
|
||||
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
|
||||
if online { return; }
|
||||
webview.eval(r#"
|
||||
(async () => {
|
||||
const wait = async predicate => {
|
||||
@@ -28,7 +74,9 @@ fn main() {
|
||||
throw new Error('Timed out waiting for local state');
|
||||
};
|
||||
try {
|
||||
await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace');
|
||||
await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError');
|
||||
if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup');
|
||||
if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded');
|
||||
const input = document.querySelector('#server-url');
|
||||
const form = document.querySelector('#server-form');
|
||||
const status = document.querySelector('#connection-status');
|
||||
@@ -43,6 +91,7 @@ fn main() {
|
||||
}
|
||||
input.value = 'https://127.0.0.1:9/';
|
||||
form.requestSubmit();
|
||||
await wait(() => status.dataset.state === 'connecting');
|
||||
await wait(() => status.dataset.state === 'connectionError');
|
||||
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||
input.value = 'https://kanbangit.de/';
|
||||
@@ -53,7 +102,7 @@ fn main() {
|
||||
form.requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
} catch (error) {
|
||||
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
||||
location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
|
||||
}
|
||||
})();
|
||||
"#).unwrap();
|
||||
@@ -76,7 +125,27 @@ fn main() {
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
localStorage.setItem('kan52_fixture', 'isolated');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined';
|
||||
const initialHistory = history.length;
|
||||
const initialPath = location.pathname;
|
||||
const navigation = window.__kanbanNavigation;
|
||||
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const traverse = direction => new Promise(resolve => {
|
||||
addEventListener('popstate', resolve, { once: true });
|
||||
navigation.step(direction);
|
||||
});
|
||||
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
|
||||
await traverse(1);
|
||||
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
|
||||
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
|
||||
dialog.close(); dialog.remove();
|
||||
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
"#).unwrap();
|
||||
@@ -90,7 +159,8 @@ fn main() {
|
||||
})
|
||||
.on_navigation(move |webview, url| {
|
||||
|
||||
if url.path() != "/__native_smoke_result" {
|
||||
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
|
||||
return true;
|
||||
}
|
||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||
@@ -119,7 +189,17 @@ fn main() {
|
||||
});
|
||||
} else {
|
||||
result_passed.store(success, Ordering::SeqCst);
|
||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
||||
if success {
|
||||
let app = webview.app_handle().clone();
|
||||
let label = webview.label().to_string();
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
|
||||
handle.get_webview_window(&label).unwrap().close().unwrap();
|
||||
}).unwrap();
|
||||
} else {
|
||||
webview.app_handle().exit(1);
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
@@ -130,6 +210,25 @@ fn main() {
|
||||
.plugin(plugin)
|
||||
.build(context)
|
||||
.expect("The macOS application must start");
|
||||
let visibility_handle = app.handle().clone();
|
||||
let visibility_stage = stage.clone();
|
||||
let visibility_check = std::thread::spawn(move || {
|
||||
if needs_setup {
|
||||
return;
|
||||
}
|
||||
for _ in 0..30 {
|
||||
if visibility_stage.load(Ordering::SeqCst) > 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
|
||||
assert!(
|
||||
!launcher.is_visible().unwrap(),
|
||||
"Launcher flashed while the workspace was loading"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
});
|
||||
let config_dir = app.path().app_config_dir().unwrap();
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
@@ -137,7 +236,28 @@ fn main() {
|
||||
eprintln!("macOS smoke test timed out");
|
||||
handle.exit(1);
|
||||
});
|
||||
app.run_return(|_, _| {});
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let closed_result = closed.clone();
|
||||
let exit_code = app.run_return(move |app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
|
||||
closed_result.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
needs_setup
|
||||
|| !app
|
||||
.get_webview_window("launcher")
|
||||
.unwrap()
|
||||
.is_visible()
|
||||
.unwrap(),
|
||||
"Closing the workspace must not reopen settings"
|
||||
);
|
||||
}
|
||||
});
|
||||
visibility_check.join().unwrap();
|
||||
assert_eq!(exit_code, 0);
|
||||
assert!(
|
||||
closed.load(Ordering::SeqCst),
|
||||
"The close button must quit the application"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(config_dir);
|
||||
assert!(
|
||||
passed.load(Ordering::SeqCst),
|
||||
|
||||
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
|
||||
};
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
await state('connecting');
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('#workspace-settings').isVisible(), false);
|
||||
assert.equal(await page.locator('#startup-loading').isVisible(), true);
|
||||
await page.reload();
|
||||
await state('idle');
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -98,12 +104,7 @@ try {
|
||||
'https://first.example.com/',
|
||||
);
|
||||
await state('connecting', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Open workspace', exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.locator('.open-workspace').isDisabled(), true);
|
||||
await state('connectionError', false, 'https://first.example.com/');
|
||||
assert.equal(
|
||||
await page.getByLabel('Server address').inputValue(),
|
||||
|
||||
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
|
||||
|
||||
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8');
|
||||
|
||||
test('workspace selection is hidden before native configuration is available', () => {
|
||||
assert.match(html, /<main id="workspace-settings" hidden>/);
|
||||
assert.match(html, /id="startup-loading" role="status"/);
|
||||
});
|
||||
|
||||
test('every launcher text resolves to a nonempty English translation', () => {
|
||||
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
|
||||
assert.equal(typeof en[key], 'string', key);
|
||||
|
||||
@@ -578,3 +578,46 @@ for (const change of [
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('release versions agree across native manifests', () => {
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
|
||||
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
|
||||
const packageInfo = JSON.parse(read('../package.json'));
|
||||
assert.equal(packageInfo.version, config.version);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/Cargo.lock').match(
|
||||
/name = "kanbangit-native"\nversion = "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/MARKETING_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
config.version,
|
||||
);
|
||||
assert.equal(
|
||||
read('../src-tauri/gen/apple/project.yml').match(
|
||||
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
|
||||
)?.[1],
|
||||
ios.bundle.iOS.bundleVersion,
|
||||
);
|
||||
const project = read(
|
||||
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
|
||||
);
|
||||
for (const [, version] of project.matchAll(
|
||||
/MARKETING_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, config.version);
|
||||
}
|
||||
for (const [, version] of project.matchAll(
|
||||
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
|
||||
)) {
|
||||
assert.equal(version, ios.bundle.iOS.bundleVersion);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
|
||||
try {
|
||||
await import('../ui/main.js');
|
||||
assert.equal(messages.shift().pathname, '/action/ready');
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, true);
|
||||
assert.equal(element('#startup-loading').hidden, false);
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false',
|
||||
);
|
||||
assert.equal(element('#workspace-settings').hidden, false);
|
||||
assert.equal(element('#startup-loading').hidden, true);
|
||||
assert.equal(element('.open-workspace').textContent, 'Try again');
|
||||
window.updateLauncher(
|
||||
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -9,7 +9,8 @@
|
||||
<script type="module" src="./main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
|
||||
<main id="workspace-settings" hidden>
|
||||
<img class="brand" src="./mark.svg" alt="" width="48" height="48" />
|
||||
<p class="eyebrow" data-i18n="appName"></p>
|
||||
<h1 data-i18n="title"></h1>
|
||||
|
||||
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
|
||||
const state = new URLSearchParams(query);
|
||||
const phase = state.get('phase');
|
||||
const server = state.get('server');
|
||||
const loading = phase === 'connecting' && state.get('ready') !== 'true';
|
||||
document.querySelector('#startup-loading').hidden = !loading;
|
||||
document.querySelector('#workspace-settings').hidden = loading;
|
||||
if (!initialized) {
|
||||
input.value = server;
|
||||
initialized = 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -117,6 +117,25 @@ class AuthenticationTest extends TestCase
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_workspace_start_without_a_valid_session_returns_to_dashboard_after_login(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->get(route('dashboard'))
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHas('url.intended', route('dashboard'));
|
||||
|
||||
$this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->get(route('dashboard'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page->component('Dashboard'));
|
||||
}
|
||||
|
||||
public function test_users_are_rate_limited()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Reference in New Issue
Block a user