Compare commits

..
10 Commits
Author SHA1 Message Date
Twyco 752ba2cd38 Merge pull request 'KAN-71: Use interactive WebKit gestures on iOS and macOS' (#169) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m24s
CI/CD / test_and_check_app (push) Successful in 6m43s
CI/CD / Publish immutable image (push) Successful in 1m20s
Reviewed-on: #169
2026-09-29 06:04:38 +00:00
Twyco 1cf7524a7b fix(KAN-71): use interactive WebKit navigation gestures
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m13s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m44s
CI/CD / test_and_check_app (pull_request) Successful in 7m4s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 08:02:10 +02:00
Twyco 386b25858b Merge pull request 'KAN-71: Correct iOS and macOS navigation gestures and scroll bounds' (#168) from fix/KAN-71-native-navigation-scroll into main
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m26s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m2s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m23s
CI/CD / test_and_check_app (push) Successful in 6m37s
CI/CD / Publish immutable image (push) Successful in 1m17s
Reviewed-on: #168
2026-09-29 05:28:53 +00:00
Twyco aa14068c66 chore(KAN-71): bump native version to 0.2.4 and mobile build numbers
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m32s
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Successful in 6m39s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:27:05 +02:00
Twyco 4afaa79834 fix(KAN-71): correct Apple navigation gestures and scroll bounds
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m31s
CI/CD / test_and_check_app (pull_request) Successful in 6m44s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-29 07:22:45 +02:00
Twyco 0662a6970e Merge pull request 'KAN-69: Fix macOS startup and window closing; release 0.2.3' (#167) from fix/KAN-69-macos-startup-and-close into main
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
CI/CD / Publish immutable image (push) Successful in 1m20s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m8s
CI/CD / preconditions (push) Successful in 1s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m28s
Reviewed-on: #167
2026-09-28 20:01:57 +00:00
Twyco 6820e0b25e fix(KAN-69): hide macOS launcher on startup and quit on close
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m1s
CI/CD / test_and_check_app (pull_request) Canceled after 1m34s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m29s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-28 22:00:06 +02:00
Twyco b124a77cd1 update macos to v0.2.0
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m1s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 3m26s
CI/CD / test_and_check_app (push) Successful in 6m25s
CI/CD / Publish immutable image (push) Successful in 1m15s
2026-09-28 21:28:07 +02:00
Twyco 2725816273 Merge pull request 'KAN-69: Open saved native workspace directly in dashboard' (#166) from feature/KAN-69-open-app-in-dashboard into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 1m0s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m32s
CI/CD / test_and_check_app (push) Successful in 6m17s
CI/CD / Publish immutable image (push) Successful in 1m15s
Reviewed-on: #166
2026-09-28 19:24:03 +00:00
Twyco 32a6756419 feat(KAN-69): open saved native workspace directly in dashboard
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m17s
CI/CD / test_and_check_app (pull_request) Successful in 6m24s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m27s
2026-09-28 21:00:46 +02:00
32 changed files with 913 additions and 161 deletions
+10
View File
@@ -1,5 +1,15 @@
# Working Agreements
## Native app versions and build numbers
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
## Missing programs and SDKs
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
"version": "0.2.1",
"version": "0.2.5",
"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": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
}
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.2.1"
version = "0.2.5"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.2.1"
version = "0.2.5"
description = "KanbanGit native application"
edition = "2021"
publish = false
+48
View File
@@ -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");
}
@@ -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) {
@@ -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)
@@ -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()
@@ -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,6 @@ 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 appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) {
@@ -58,6 +57,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 +76,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 +89,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 +159,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 +199,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 +206,15 @@ 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
webview.scrollView.bounces = false
webview.accessibilityIdentifier = "workspace"
webview.translatesAutoresizingMaskIntoConstraints = false
webview.isHidden = true
@@ -217,13 +226,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 +256,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
}
@objc private func showSettings() {
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = false
toolbar.isHidden = true
@@ -256,15 +266,13 @@ 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 webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
guard server?.allows(url) == true else {
@@ -296,7 +304,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 +313,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,53 @@ final class WorkspaceTests: XCTestCase {
}
@MainActor
func testBackButtonTracksSameDocumentHistory() async throws {
let name = "KAN55-history-\(UUID())"
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() 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
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))
}
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")
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)
XCTAssertNil(webview.goBack())
XCTAssertNil(webview.goForward())
try await waitFor(webview, "location.pathname === '/dashboard'")
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
XCTAssertFalse(webview.scrollView.bounces)
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
XCTAssertEqual(customNavigation as? String, "undefined")
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
XCTAssertEqual(draft as? String, "unsaved")
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")
controller.open(URL(string: "https://other.invalid/")!)
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
"Changing workspaces must retain native gesture navigation")
}
@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 +201,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)
@@ -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 = 5;
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.5;
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 = 5;
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.5;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
+4 -2
View File
@@ -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.5"
CURRENT_PROJECT_VERSION: "5"
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
View File
@@ -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(())
})
}
+6
View File
@@ -0,0 +1,6 @@
#import <WebKit/WebKit.h>
void install_workspace_navigation(void *pointer) {
WKWebView *webview = (__bridge WKWebView *)pointer;
webview.allowsBackForwardNavigationGestures = YES;
}
+21
View File
@@ -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();
+5 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.2.1",
"version": "0.2.5",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
"bundleVersion": "7",
"bundleVersion": "10",
"dmg": {
"windowSize": {
"width": 660,
@@ -40,6 +40,9 @@
"y": 170
}
}
},
"android": {
"versionCode": 2004
}
},
"plugins": {
+1 -1
View File
@@ -5,7 +5,7 @@
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
"bundleVersion": "1"
"bundleVersion": "5"
}
}
}
+109 -7
View File
@@ -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,9 @@ 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';
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})();
"#).unwrap();
@@ -90,7 +141,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 +171,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 +192,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 +218,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),
+7 -6
View File
@@ -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(),
+5
View File
@@ -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);
+43
View File
@@ -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);
}
});
+12
View File
@@ -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',
);
+94
View File
@@ -0,0 +1,94 @@
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);
assert.equal(
await page.evaluate(() => typeof window.__kanbanNavigation),
'undefined',
);
await page.goto(`${root}/full-page`);
await page.goBack();
await page.waitForURL(`${root}/`);
await page.goForward();
await page.waitForURL(`${root}/full-page`);
await page.locator('#draft').fill('unsaved draft');
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
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: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
);
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
+74
View File
@@ -0,0 +1,74 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#import "../src-tauri/src/navigation.m"
static void check(BOOL condition, NSString *message) {
if (!condition) {
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
exit(1);
}
}
static void waitUntil(BOOL (^condition)(void), NSString *message) {
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
while (!condition() && deadline.timeIntervalSinceNow > 0) {
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
}
check(condition(), message);
}
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
@property(nonatomic) NSUInteger finished;
@end
@implementation WorkspacePages
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
dataUsingEncoding:NSUTF8StringEncoding];
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
expectedContentLength:body.length textEncodingName:@"utf-8"];
[task didReceiveResponse:response];
[task didReceiveData:body];
[task didFinish];
}
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
self.finished++;
}
@end
int main(void) {
@autoreleasepool {
[NSApplication sharedApplication];
WorkspacePages *pages = [WorkspacePages new];
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
webview.navigationDelegate = pages;
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
install_workspace_navigation((__bridge void *)webview);
install_workspace_navigation((__bridge void *)webview);
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
check([webview goBack] != nil, @"Native back navigation must start");
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
@"Back must restore the first page and retain forward history");
check([webview goBack] == nil, @"Back at the first page must leave it in place");
check([webview goForward] != nil, @"Native forward navigation must start");
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
@"Forward must restore the second page");
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
}
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
test(
'WebKit owns interactive navigation and preserves native back/forward history',
{ 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: WebKit/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
);
+2 -1
View File
@@ -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>
+3
View File
@@ -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;
+18
View File
@@ -0,0 +1,18 @@
(() => {
if (window.top !== window) {
return;
}
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,
});
})();
+19
View File
@@ -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();