diff --git a/app/Http/Middleware/RequirePasswordChange.php b/app/Http/Middleware/RequirePasswordChange.php
index baef01e..c462bfa 100644
--- a/app/Http/Middleware/RequirePasswordChange.php
+++ b/app/Http/Middleware/RequirePasswordChange.php
@@ -23,6 +23,7 @@ class RequirePasswordChange
if ($request->routeIs([
'logout',
+ 'native-app.edit',
'password.force-change.edit',
'user-password.update',
])) {
diff --git a/native/package.json b/native/package.json
index d12fbe9..2f88839 100644
--- a/native/package.json
+++ b/native/package.json
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
- "version": "0.2.5",
+ "version": "0.2.6",
"private": true,
"type": "module",
"scripts": {
@@ -25,5 +25,5 @@
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
},
- "releaseNotes": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
+ "releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
}
diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock
index b6f5dfe..723cf59 100644
--- a/native/src-tauri/Cargo.lock
+++ b/native/src-tauri/Cargo.lock
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
-version = "0.2.5"
+version = "0.2.6"
dependencies = [
"semver",
"serde",
diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml
index 99561d6..eb243a9 100644
--- a/native/src-tauri/Cargo.toml
+++ b/native/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
-version = "0.2.5"
+version = "0.2.6"
description = "KanbanGit native application"
edition = "2021"
publish = false
diff --git a/native/src-tauri/build.rs b/native/src-tauri/build.rs
index 315fddc..a315239 100644
--- a/native/src-tauri/build.rs
+++ b/native/src-tauri/build.rs
@@ -9,8 +9,10 @@ fn build_navigation() {
use std::{env, path::PathBuf, process::Command};
println!("cargo:rerun-if-changed=src/navigation.m");
+ println!("cargo:rerun-if-changed=src/settings.m");
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let object = output.join("navigation.o");
+ let settings_object = output.join("settings.o");
let archive = output.join("libkanban_navigation.a");
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
"aarch64" => "arm64",
@@ -37,10 +39,31 @@ fn build_navigation() {
.status()
.expect("Xcode clang is required")
.success());
+ assert!(Command::new("xcrun")
+ .args([
+ "--sdk",
+ "macosx",
+ "clang",
+ "-fobjc-arc",
+ "-fblocks",
+ "-Wall",
+ "-Werror",
+ "-mmacosx-version-min=14.0",
+ "-arch",
+ arch,
+ "-c",
+ "src/settings.m",
+ "-o"
+ ])
+ .arg(&settings_object)
+ .status()
+ .expect("Xcode clang is required")
+ .success());
assert!(Command::new("xcrun")
.args(["ar", "crs"])
.arg(&archive)
.arg(&object)
+ .arg(&settings_object)
.status()
.expect("Xcode ar is required")
.success());
diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift
index 77d8130..b981013 100644
--- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift
+++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift
@@ -65,3 +65,32 @@ struct WorkspaceConnection {
return true
}
}
+
+
+enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
+
+struct NativeSettingsRequest {
+ let id: Int
+ let action: String
+ let payload: [String: Any]
+
+ static func parse(_ body: String) throws -> NativeSettingsRequest {
+ guard body.utf8.count <= 8192,
+ let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
+ Set(value.keys) == Set(["version", "id", "action", "payload"]),
+ let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
+ version == 1 else { throw NativeSettingsError.incompatibleVersion }
+ guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
+ id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
+ let action = value["action"] as? String,
+ let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
+ switch action {
+ case "getState", "settingsReady", "openSettings":
+ guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
+ case "setWorkspace":
+ guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
+ default: throw NativeSettingsError.invalidRequest
+ }
+ return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
+ }
+}
diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift
index 4f38590..2935edb 100644
--- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift
+++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift
@@ -40,6 +40,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar()
+ private var settingsAvailable = false
+ private var toolbarHeight: NSLayoutConstraint!
private var appearance: UISegmentedControl!
init(defaults: UserDefaults = .standard) {
@@ -165,7 +167,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
settings.accessibilityIdentifier = "showServerSettings"
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
+ toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
NSLayoutConstraint.activate([
+ toolbarHeight,
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
@@ -201,6 +205,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
timeout?.cancel()
workspace?.stopLoading()
workspace?.navigationDelegate = nil
+ workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
workspace?.removeFromSuperview()
connection = WorkspaceConnection()
server = selected
@@ -209,7 +214,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
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 settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
+ let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
+ configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
+ configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
+ setSettingsAvailable(false)
let webview = WKWebView(frame: .zero, configuration: configuration)
webview.navigationDelegate = self
webview.uiDelegate = self
@@ -269,10 +278,48 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
loading.stopAnimating()
view.endEditing(true)
launcher.isHidden = true
- toolbar.isHidden = false
+ toolbar.isHidden = settingsAvailable
workspace?.isHidden = false
}
+ private func setSettingsAvailable(_ available: Bool) {
+ settingsAvailable = available
+ toolbarHeight.constant = available ? 0 : 44
+ toolbar.isHidden = available || !launcher.isHidden || !connection.ready
+ }
+
+ func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
+ guard message.webView === workspace, message.frameInfo.isMainFrame,
+ let source = message.frameInfo.request.url, server?.allows(source) == true,
+ let current = workspace?.url, server?.allows(current) == true,
+ let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
+ do {
+ let request = try NativeSettingsRequest.parse(body)
+ var result: Any = NSNull()
+ switch request.action {
+ case "getState":
+ result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
+ "version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
+ "capabilities": ["workspace"]] as [String: Any]
+ case "settingsReady": setSettingsAvailable(true)
+ case "openSettings": showSettings()
+ case "setWorkspace":
+ guard let address = request.payload["server"] as? String,
+ let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
+ let owner = workspace
+ DispatchQueue.main.async { [weak self] in
+ guard let self, self.workspace === owner else { return }
+ self.open(url)
+ }
+ default: throw NativeSettingsError.invalidRequest
+ }
+ let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
+ reply(String(decoding: data, as: UTF8.self), nil)
+ } catch {
+ reply(nil, String(describing: error))
+ }
+ }
+
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 {
@@ -299,6 +346,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
guard webView === workspace else { return }
+ setSettingsAvailable(false)
pendingNavigation = navigation
beginNavigation()
}
@@ -338,3 +386,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
showSettings()
}
}
+
+private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
+ weak var controller: WorkspaceViewController?
+ init(controller: WorkspaceViewController) { self.controller = controller }
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
+ replyHandler: @escaping (Any?, String?) -> Void) {
+ guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
+ controller.handleSettings(message, reply: replyHandler)
+ }
+}
diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift
index 0022285..9a00c82 100644
--- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift
+++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift
@@ -135,6 +135,83 @@ final class WorkspaceTests: XCTestCase {
"Changing workspaces must retain native gesture navigation")
}
+ func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
+ for body in [
+ #"{"version":2,"id":1,"action":"getState","payload":{}}"#,
+ #"{"version":true,"id":1,"action":"getState","payload":{}}"#,
+ #"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
+ #"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
+ #"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
+ #"{"version":1,"id":1,"action":"shell","payload":{}}"#,
+ ] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
+ XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
+ XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
+ }
+
+ @MainActor
+ func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
+ let name = "KAN70-settings-\(UUID())"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
+ defer { defaults.removePersistentDomain(forName: name) }
+ let controller = WorkspaceViewController(defaults: defaults)
+ controller.loadViewIfNeeded()
+ controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
+ let url = URL(string: "https://example.invalid/")!
+ controller.open(url)
+ let originalProfile = controller.server!.profile
+ let webview = try XCTUnwrap(controller.workspace)
+ webview.stopLoading()
+ webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "
Login")
+ try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
+ let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
+ XCTAssertEqual(state?["platform"] as? String, "ios")
+ XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
+ XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
+ XCTAssertNil(state?["updates"])
+ let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
+ XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
+ let rejected = try await callScript(webview, """
+ try {
+ await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
+ return false;
+ } catch { return true; }
+ """)
+ XCTAssertEqual(rejected as? Bool, true)
+ XCTAssertFalse(toolbar.isHidden)
+ _ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
+ controller.view.layoutIfNeeded()
+ XCTAssertTrue(toolbar.isHidden)
+ XCTAssertEqual(toolbar.frame.height, 0)
+ XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
+ let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
+ XCTAssertEqual(invalid as? Bool, true)
+ XCTAssertEqual(controller.server?.url, url)
+ _ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
+ XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
+ _ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
+ for _ in 0..<50 {
+ if controller.workspace !== webview { break }
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ XCTAssertFalse(controller.workspace === webview)
+ XCTAssertNotEqual(controller.server?.profile, originalProfile)
+ let restored = WorkspaceViewController(defaults: defaults)
+ restored.loadViewIfNeeded()
+ XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
+ let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
+ XCTAssertEqual(stale as? Bool, true)
+ XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
+ }
+
+ @MainActor
+ private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
+ try await withCheckedThrowingContinuation { continuation in
+ webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
+ continuation.resume(with: result)
+ }
+ }
+ }
+
@MainActor
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
for _ in 0..<100 {
@@ -201,7 +278,7 @@ final class WorkspaceTests: XCTestCase {
controller.open(url)
let server = try XCTUnwrap(controller.server)
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
- XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
+ XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
let restored = WorkspaceViewController(defaults: defaults)
restored.loadViewIfNeeded()
XCTAssertEqual(restored.server, server)
diff --git a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj
index a9886e9..c6933ea 100644
--- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj
+++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj
@@ -20,6 +20,7 @@
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 */; };
+ 99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.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 */; };
@@ -27,6 +28,7 @@
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
+ EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
/* End PBXBuildFile section */
@@ -45,12 +47,14 @@
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = ""; };
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = ""; };
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
+ 1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = ""; };
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; };
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = ""; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = ""; };
+ 5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = ""; };
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = ""; };
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 = ""; };
@@ -65,6 +69,7 @@
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = ""; };
+ BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = ""; };
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = ""; };
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
@@ -156,6 +161,8 @@
5F71F06AB639140AB9714425 /* metadata.rs */,
8C0F963909020429B3D257D5 /* navigation.m */,
E8BA104C81071623E1FF367D /* server.rs */,
+ 5F196E34D1B9E14FBD7191F4 /* settings.m */,
+ 1CDE5C303A96423E2E6A194D /* settings.rs */,
6610D376CF901AC1733D394E /* updates.rs */,
);
name = src;
@@ -193,6 +200,7 @@
isa = PBXGroup;
children = (
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
+ BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
);
name = ui;
path = ../../../ui;
@@ -334,6 +342,7 @@
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
+ 99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -342,6 +351,7 @@
buildActionMask = 2147483647;
files = (
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
+ EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -441,7 +451,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 5;
+ CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -457,7 +467,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.5;
+ MARKETING_VERSION = 0.2.6;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
@@ -618,7 +628,7 @@
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 5;
+ CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = ZZX2KZGTHU;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -634,7 +644,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.5;
+ MARKETING_VERSION = 0.2.6;
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
PRODUCT_MODULE_NAME = KanbanGitNative;
PRODUCT_NAME = KanbanGit;
diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml
index 544fe47..37e093b 100644
--- a/native/src-tauri/gen/apple/project.yml
+++ b/native/src-tauri/gen/apple/project.yml
@@ -18,8 +18,8 @@ settingGroups:
DEVELOPMENT_TEAM: ZZX2KZGTHU
ENABLE_USER_SCRIPT_SANDBOXING: false
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
- MARKETING_VERSION: "0.2.5"
- CURRENT_PROJECT_VERSION: "5"
+ MARKETING_VERSION: "0.2.6"
+ CURRENT_PROJECT_VERSION: "6"
configs:
debug:
CODE_SIGN_IDENTITY: Apple Development
@@ -43,6 +43,7 @@ targets:
sources:
- path: Sources
- path: ../../../ui/workspace-navigation.js
+ - path: ../../../ui/workspace-settings.js
- path: Assets.xcassets
- path: kanbangit-native_iOS
- path: LaunchScreen.storyboard
@@ -116,6 +117,7 @@ targets:
platform: iOS
sources:
- path: ../../../ui/workspace-navigation.js
+ - path: ../../../ui/workspace-settings.js
- path: Tests
- path: Sources/kanbangit-native/WorkspacePolicy.swift
- path: Sources/kanbangit-native/WorkspaceViewController.swift
diff --git a/native/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs
index 181735b..0bd8509 100644
--- a/native/src-tauri/src/desktop.rs
+++ b/native/src-tauri/src/desktop.rs
@@ -17,6 +17,15 @@ struct Connection {
}
impl Connection {
+ #[cfg(target_os = "macos")]
+ fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
+ main && self.label.as_deref() == Some(label)
+ && self
+ .server
+ .as_ref()
+ .is_some_and(|server| allows_server_navigation(&server.url, url))
+ }
+
fn begin_navigation(&mut self) -> u64 {
self.revision += 1;
self.phase = "connecting";
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
install_workspace_navigation(webview.inner().cast());
})
.map_err(|_| "connectionError")?;
+ #[cfg(target_os = "macos")]
+ crate::settings::install(app, &window).map_err(|_| "connectionError")?;
let handle = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
Ok(())
}
+#[cfg(target_os = "macos")]
+pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
+ let state = app.state::>();
+ let state = state.lock().unwrap();
+ state.allows_settings(label, url, main)
+}
+
+#[cfg(target_os = "macos")]
+pub fn settings_action(
+ app: &AppHandle,
+ action: crate::settings::Action,
+) -> Result {
+ use crate::settings::Action;
+ match action {
+ Action::GetState {} => {
+ let workspace = app
+ .state::>()
+ .lock()
+ .unwrap()
+ .server
+ .as_ref()
+ .map(|server| server.url.to_string())
+ .ok_or("bridgeUnavailable")?;
+ return Ok(
+ serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
+ "version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
+ "updates":crate::updates::snapshot(app)}),
+ );
+ }
+ Action::SettingsReady {} => {}
+ Action::OpenSettings {} => show_launcher(app),
+ Action::SetWorkspace { server } => {
+ if let Err(error) = open_workspace(app, &server) {
+ app.state::>().lock().unwrap().phase = error;
+ show_launcher(app);
+ return Err(error);
+ }
+ }
+ Action::CheckForUpdates {} => crate::updates::check_inline(app),
+ Action::SetUpdatePreferences {
+ automatic,
+ interval_hours,
+ } => crate::updates::save_preferences(app, automatic, interval_hours)?,
+ Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
+ }
+ Ok(serde_json::Value::Null)
+}
+
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
let result = match destination.path() {
"/action/updates" => {
@@ -464,6 +523,30 @@ pub fn builder() -> tauri::Builder {
mod tests {
use super::*;
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn settings_only_accept_the_current_workspace_main_frame() {
+ let mut state = Connection {
+ server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
+ label: Some("workspace-1".into()),
+ ..Default::default()
+ };
+ let url = Url::parse("https://example.com/settings/app").unwrap();
+ assert!(state.allows_settings("workspace-1", &url, true));
+ assert!(!state.allows_settings("workspace-1", &url, false));
+ assert!(!state.allows_settings("workspace-0", &url, true));
+ for address in [
+ "https://evil.test",
+ "http://example.com",
+ "https://example.com:444/",
+ "file:///tmp/page",
+ ] {
+ assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
+ }
+ state.label = Some("workspace-2".into());
+ assert!(!state.allows_settings("workspace-1", &url, true));
+ }
+
#[cfg(target_os = "macos")]
#[test]
fn about_shows_app_version_followed_by_the_actual_build_number() {
diff --git a/native/src-tauri/src/lib.rs b/native/src-tauri/src/lib.rs
index 0fa255f..fbd51d6 100644
--- a/native/src-tauri/src/lib.rs
+++ b/native/src-tauri/src/lib.rs
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
pub fn run() {
builder().run(context()).expect("Could not start KanbanGit");
}
+
+#[cfg(target_os = "macos")]
+mod settings;
diff --git a/native/src-tauri/src/settings.m b/native/src-tauri/src/settings.m
new file mode 100644
index 0000000..eecd705
--- /dev/null
+++ b/native/src-tauri/src/settings.m
@@ -0,0 +1,47 @@
+#import
+
+typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
+typedef void (*SettingsFree)(char *);
+
+@interface KanbanSettingsHandler : NSObject
+@property(nonatomic, weak) WKWebView *webview;
+@property(nonatomic, copy) NSString *label;
+@property(nonatomic) SettingsCallback callback;
+@property(nonatomic) SettingsFree releaseReply;
+@end
+
+@implementation KanbanSettingsHandler
+- (void)userContentController:(WKUserContentController *)controller
+ didReceiveScriptMessage:(WKScriptMessage *)message
+ replyHandler:(void (^)(id, NSString *))replyHandler {
+ if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
+ ![message.body isKindOfClass:NSString.class]) {
+ replyHandler(nil, @"untrustedFrame");
+ return;
+ }
+ char *reply = self.callback(self.label.UTF8String,
+ message.frameInfo.request.URL.absoluteString.UTF8String,
+ [(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
+ if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
+ NSString *value = [NSString stringWithUTF8String:reply];
+ self.releaseReply(reply);
+ replyHandler(value, nil);
+}
+@end
+
+void install_workspace_settings(void *pointer, const char *label, const char *script,
+ SettingsCallback callback, SettingsFree releaseReply) {
+ WKWebView *webview = (__bridge WKWebView *)pointer;
+ KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
+ handler.webview = webview;
+ handler.label = [NSString stringWithUTF8String:label];
+ handler.callback = callback;
+ handler.releaseReply = releaseReply;
+ [webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
+ contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
+ [webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
+ initWithSource:[NSString stringWithUTF8String:script]
+ injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
+ // The initial document may already have started loading before the handler is attached.
+ [webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
+}
diff --git a/native/src-tauri/src/settings.rs b/native/src-tauri/src/settings.rs
new file mode 100644
index 0000000..187fbd1
--- /dev/null
+++ b/native/src-tauri/src/settings.rs
@@ -0,0 +1,147 @@
+use serde::Deserialize;
+use serde_json::{json, Value};
+use std::ffi::{c_char, CStr, CString};
+use std::sync::OnceLock;
+use tauri::{AppHandle, Url};
+
+static APP: OnceLock = OnceLock::new();
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Request {
+ pub version: u8,
+ pub id: u64,
+ pub action: String,
+ pub payload: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(
+ tag = "action",
+ content = "payload",
+ rename_all = "camelCase",
+ deny_unknown_fields
+)]
+pub enum Action {
+ GetState {},
+ SettingsReady {},
+ OpenSettings {},
+ SetWorkspace {
+ server: String,
+ },
+ CheckForUpdates {},
+ SetUpdatePreferences {
+ automatic: bool,
+ #[serde(rename = "intervalHours")]
+ interval_hours: u64,
+ },
+ InstallUpdate {
+ version: String,
+ },
+}
+
+pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
+ if value.len() > 8192 {
+ return Err("invalidRequest");
+ }
+ let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
+ if request.version != 1 {
+ return Err("incompatibleVersion");
+ }
+ let action =
+ serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
+ .map_err(|_| "invalidRequest")?;
+ Ok((request.id, action))
+}
+
+pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
+ let _ = APP.set(app.clone());
+ let label = CString::new(window.label()).unwrap();
+ let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
+ window.with_webview(move |view| unsafe {
+ unsafe extern "C" {
+ fn install_workspace_settings(
+ view: *mut std::ffi::c_void,
+ label: *const c_char,
+ script: *const c_char,
+ callback: extern "C" fn(
+ *const c_char,
+ *const c_char,
+ *const c_char,
+ bool,
+ ) -> *mut c_char,
+ free: extern "C" fn(*mut c_char),
+ );
+ }
+ install_workspace_settings(
+ view.inner().cast(),
+ label.as_ptr(),
+ script.as_ptr(),
+ receive,
+ release,
+ );
+ })
+}
+
+extern "C" fn release(value: *mut c_char) {
+ if !value.is_null() {
+ unsafe {
+ drop(CString::from_raw(value));
+ }
+ }
+}
+
+extern "C" fn receive(
+ label: *const c_char,
+ url: *const c_char,
+ body: *const c_char,
+ main: bool,
+) -> *mut c_char {
+ let result = std::panic::catch_unwind(|| -> Result {
+ if label.is_null() || url.is_null() || body.is_null() {
+ return Err("invalidRequest");
+ }
+ let label = unsafe { CStr::from_ptr(label) }
+ .to_str()
+ .map_err(|_| "invalidRequest")?;
+ let url = unsafe { CStr::from_ptr(url) }
+ .to_str()
+ .map_err(|_| "invalidRequest")?;
+ let body = unsafe { CStr::from_ptr(body) }
+ .to_str()
+ .map_err(|_| "invalidRequest")?;
+ let app = APP.get().ok_or("bridgeUnavailable")?;
+ let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
+ if !crate::desktop::allows_settings(app, label, &url, main) {
+ return Err("untrustedFrame");
+ }
+ let (id, action) = parse(body)?;
+ let result = crate::desktop::settings_action(app, action)?;
+ Ok(json!({"id": id, "result": result}))
+ })
+ .unwrap_or(Err("bridgeUnavailable"));
+ let value = result.unwrap_or_else(|error| json!({"error": error}));
+ CString::new(value.to_string()).unwrap().into_raw()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
+ for value in [
+ r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
+ r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
+ r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
+ r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
+ r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
+ ] {
+ assert!(parse(value).is_err(), "{value}");
+ }
+ assert!(parse(&"x".repeat(8193)).is_err());
+ assert!(matches!(
+ parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
+ Ok((1, Action::GetState {}))
+ ));
+ }
+}
diff --git a/native/src-tauri/src/updates.rs b/native/src-tauri/src/updates.rs
index b7dd173..24f432b 100644
--- a/native/src-tauri/src/updates.rs
+++ b/native/src-tauri/src/updates.rs
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
if manual {
show(app);
}
+ check_with_mode(app, manual);
+}
+pub fn check_inline(app: &AppHandle) {
+ check_with_mode(app, true);
+}
+fn check_with_mode(app: &AppHandle, manual: bool) {
let begin = app
.state::>()
.lock()
@@ -297,7 +303,7 @@ fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(),
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
Ok(())
}
-fn install(app: &AppHandle, confirmed_version: Option<&str>) {
+pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
let update = {
let state = app.state::>();
let mut state = state.lock().unwrap();
@@ -400,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
fail(app, "preferencesError");
return;
}
- let saved = (|| -> Result<(), Box> {
- let directory = app.path().app_config_dir()?;
- fs::create_dir_all(&directory)?;
- let pending = directory.join("update-preferences.pending");
- fs::write(&pending, serde_json::to_vec(&preferences)?)?;
- fs::rename(pending, directory.join("update-preferences.json"))?;
- Ok(())
- })();
- if saved.is_err() {
- fail(app, "preferencesError");
- return;
+ if let Err(error) =
+ save_preferences(app, preferences.automatic, preferences.interval_hours)
+ {
+ fail(app, error);
}
- app.state::>().lock().unwrap().view.preferences = preferences;
- refresh(app);
}
_ => {}
}
}
+pub fn snapshot(app: &AppHandle) -> serde_json::Value {
+ serde_json::to_value(&app.state::>().lock().unwrap().view).unwrap()
+}
+pub fn save_preferences(
+ app: &AppHandle,
+ automatic: bool,
+ interval_hours: u64,
+) -> Result<(), &'static str> {
+ let preferences = Preferences {
+ automatic,
+ interval_hours,
+ };
+ if !preferences.valid() {
+ return Err("preferencesError");
+ }
+ let state = app.state::>();
+ let mut state = state.lock().unwrap();
+ if state.busy() {
+ return Err("updateBusy");
+ }
+ let saved = (|| -> Result<(), Box> {
+ let directory = app.path().app_config_dir()?;
+ fs::create_dir_all(&directory)?;
+ let pending = directory.join("update-preferences.pending");
+ fs::write(&pending, serde_json::to_vec(&preferences)?)?;
+ fs::rename(pending, directory.join("update-preferences.json"))?;
+ Ok(())
+ })();
+ saved.map_err(|_| "preferencesError")?;
+ state.view.preferences = preferences;
+ drop(state);
+ refresh(app);
+ Ok(())
+}
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.plugin(
tauri_plugin_updater::Builder::new()
diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json
index aec8423..370c611 100644
--- a/native/src-tauri/tauri.conf.json
+++ b/native/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
- "version": "0.2.5",
+ "version": "0.2.6",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
- "bundleVersion": "10",
+ "bundleVersion": "11",
"dmg": {
"windowSize": {
"width": 660,
diff --git a/native/src-tauri/tauri.ios.conf.json b/native/src-tauri/tauri.ios.conf.json
index 420fbf2..fa75dd3 100644
--- a/native/src-tauri/tauri.ios.conf.json
+++ b/native/src-tauri/tauri.ios.conf.json
@@ -5,7 +5,7 @@
"iOS": {
"minimumSystemVersion": "17.0",
"developmentTeam": "ZZX2KZGTHU",
- "bundleVersion": "5"
+ "bundleVersion": "6"
}
}
}
diff --git a/native/src-tauri/tests/macos_smoke.rs b/native/src-tauri/tests/macos_smoke.rs
index a3ca2b5..d9f0c09 100644
--- a/native/src-tauri/tests/macos_smoke.rs
+++ b/native/src-tauri/tests/macos_smoke.rs
@@ -18,6 +18,7 @@ fn main() {
.setup(move |app, _| {
let directory = app.path().app_config_dir()?;
std::fs::create_dir_all(&directory)?;
+ std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
if needs_setup {
if startup == "invalid" {
std::fs::write(directory.join("active-server"), "invalid configuration")?;
@@ -118,9 +119,25 @@ fn main() {
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
let denied = true;
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
- try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
+ try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
catch (error) { denied = denied && String(error).includes('not allowed'); }
}
+ const api = window.kanbanNative;
+ if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
+ const initial = await api.request('getState');
+ if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
+ await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
+ const changed = await api.request('getState');
+ if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
+ const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
+ if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
+ const frame = document.createElement('iframe');
+ document.body.append(frame);
+ if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
+ let frameDenied = false;
+ try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
+ catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
+ if (!frameDenied) throw new Error('Subframe settings access accepted');
const input = document.createElement('input');
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
@@ -129,7 +146,7 @@ fn main() {
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;
- })();
+ })().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
"#).unwrap();
} else {
webview.eval(r#"
@@ -161,12 +178,10 @@ fn main() {
location.href = '/__native_smoke_result?passed=false&stage=preservation';
}
"#).unwrap();
- let launcher = app.get_webview_window("launcher").unwrap();
- launcher.show().unwrap();
- launcher.eval(r#"
- document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
- document.querySelector('#server-form').requestSubmit();
- document.querySelector('#confirm-switch').click();
+ window.eval(r#"
+ window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
+ location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
+ });
"#).unwrap();
});
} else {
@@ -240,6 +255,19 @@ fn main() {
closed.load(Ordering::SeqCst),
"The close button must quit the application"
);
+ if online {
+ assert!(std::fs::read_to_string(config_dir.join("active-server"))
+ .unwrap()
+ .starts_with("https://kanbangit.de/login/\n"));
+ let preferences: serde_json::Value = serde_json::from_str(
+ &std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
+ )
+ .unwrap();
+ assert_eq!(
+ preferences,
+ serde_json::json!({"automatic": false, "intervalHours": 24})
+ );
+ }
let _ = std::fs::remove_dir_all(config_dir);
assert!(
passed.load(Ordering::SeqCst),
diff --git a/native/tests/workspace-settings.test.js b/native/tests/workspace-settings.test.js
new file mode 100644
index 0000000..464948a
--- /dev/null
+++ b/native/tests/workspace-settings.test.js
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import test from 'node:test';
+
+const script = readFileSync(
+ new URL('../ui/workspace-settings.js', import.meta.url),
+ 'utf8',
+);
+function context(platform = 'apple', frame = false, protocol = 'https:') {
+ const calls = [];
+ const window = { dispatchEvent() {} };
+ window.top = frame ? {} : window;
+ if (platform === 'apple') {
+ window.webkit = {
+ messageHandlers: {
+ kanbanSettings: {
+ postMessage: async (body) => {
+ const request = JSON.parse(body);
+ calls.push(request);
+ return JSON.stringify({
+ id: request.id,
+ result: { workspace: 'https://example.test/' },
+ });
+ },
+ },
+ },
+ };
+ }
+ vm.runInNewContext(script, {
+ window,
+ location: { protocol },
+ Event,
+ setTimeout,
+ clearTimeout,
+ });
+ return { window, calls };
+}
+test('Apple WebViews expose a versioned request/reply API without Tauri', async () => {
+ const { window, calls } = context('apple');
+ assert.equal(window.kanbanNative.version, 1);
+ assert.equal(
+ (await window.kanbanNative.request('getState')).workspace,
+ 'https://example.test/',
+ );
+ assert.deepEqual(calls[0], {
+ version: 1,
+ id: 1,
+ action: 'getState',
+ payload: {},
+ });
+ await assert.rejects(
+ window.kanbanNative.request('shell'),
+ /unsupportedAction/,
+ );
+ assert.equal(calls.length, 1);
+ assert.equal(window.__TAURI_INTERNALS__, undefined);
+ assert.equal(context('apple', true).window.kanbanNative, undefined);
+ assert.equal(
+ context('apple', false, 'http:').window.kanbanNative,
+ undefined,
+ );
+});
+test('normal browsers receive no native settings API', () => {
+ assert.equal(context('browser').window.kanbanNative, undefined);
+});
+test('native errors reject the caller without losing future requests', async () => {
+ const { window } = context();
+ window.webkit.messageHandlers.kanbanSettings.postMessage = async () =>
+ JSON.stringify({ error: 'invalidServer' });
+ await assert.rejects(
+ window.kanbanNative.request('setWorkspace', { server: 'bad' }),
+ /invalidServer/,
+ );
+});
diff --git a/native/ui/workspace-settings.js b/native/ui/workspace-settings.js
new file mode 100644
index 0000000..ff572aa
--- /dev/null
+++ b/native/ui/workspace-settings.js
@@ -0,0 +1,58 @@
+(() => {
+ if (
+ window !== window.top ||
+ location.protocol !== 'https:' ||
+ window.kanbanNative
+ ) {
+ return;
+ }
+ const handler = window.webkit?.messageHandlers?.kanbanSettings;
+ if (!handler) {
+ return;
+ }
+ let sequence = 0;
+ const actions = new Set([
+ 'getState',
+ 'settingsReady',
+ 'openSettings',
+ 'setWorkspace',
+ 'checkForUpdates',
+ 'setUpdatePreferences',
+ 'installUpdate',
+ ]);
+ Object.defineProperty(window, 'kanbanNative', {
+ value: Object.freeze({
+ version: 1,
+ async request(action, payload = {}) {
+ if (!actions.has(action)) {
+ throw new Error('unsupportedAction');
+ }
+ const message = JSON.stringify({
+ version: 1,
+ id: ++sequence,
+ action,
+ payload,
+ });
+ let timer;
+ try {
+ const response = await Promise.race([
+ handler.postMessage(message).then(JSON.parse),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error('bridgeTimeout')),
+ 10000,
+ );
+ }),
+ ]);
+ if (response.error) {
+ throw new Error(response.error);
+ }
+ return response.result;
+ } finally {
+ clearTimeout(timer);
+ }
+ },
+ }),
+ });
+ window.dispatchEvent(new Event('kanban-native-ready'));
+})();
diff --git a/resources/js/app.ts b/resources/js/app.ts
index 1048d74..963daac 100644
--- a/resources/js/app.ts
+++ b/resources/js/app.ts
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import '../css/app.css';
+import {
+ initializeNativeApp,
+ registerNativeRecovery,
+} from '@/composables/useNativeApp';
import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
@@ -21,6 +25,11 @@ createInertiaApp({
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el);
+ registerNativeRecovery();
+ window.addEventListener('kanban-native-ready', () => {
+ void initializeNativeApp();
+ });
+ void initializeNativeApp();
},
progress: {
color: 'var(--primary)',
diff --git a/resources/js/composables/useNativeApp.ts b/resources/js/composables/useNativeApp.ts
new file mode 100644
index 0000000..a310395
--- /dev/null
+++ b/resources/js/composables/useNativeApp.ts
@@ -0,0 +1,136 @@
+import { computed, readonly, ref } from 'vue';
+import { isNativeAppState, updateIsBusy } from '@/lib/native-app';
+import type { NativeAction, NativeAppState } from '@/types/native-app';
+
+const state = ref(null);
+const pending = ref(false);
+const error = ref(null);
+let initializing = false;
+let refreshing: Promise | null = null;
+
+async function request(
+ action: NativeAction,
+ payload: Record = {},
+) {
+ if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
+ throw new Error('bridgeUnavailable');
+ }
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ window.kanbanNative.request(action, payload),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error('bridgeTimeout')),
+ 11000,
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function refresh() {
+ if (refreshing) {
+ return refreshing;
+ }
+ refreshing = (async () => {
+ const result = await request('getState');
+ if (!isNativeAppState(result)) {
+ throw new Error('incompatibleVersion');
+ }
+ state.value = result;
+ })();
+ try {
+ await refreshing;
+ } finally {
+ refreshing = null;
+ }
+}
+
+export async function initializeNativeApp() {
+ if (
+ initializing ||
+ state.value ||
+ typeof window === 'undefined' ||
+ window.kanbanNative?.version !== 1
+ ) {
+ return;
+ }
+ initializing = true;
+ try {
+ await refresh();
+ await request('settingsReady');
+ } catch {
+ state.value = null;
+ } finally {
+ initializing = false;
+ }
+}
+
+export function useNativeApp() {
+ const busy = computed(
+ () => pending.value || updateIsBusy(state.value?.updates),
+ );
+ async function act(
+ action: NativeAction,
+ payload: Record = {},
+ ) {
+ if (
+ pending.value ||
+ (updateIsBusy(state.value?.updates) && action !== 'openSettings')
+ ) {
+ return false;
+ }
+ pending.value = true;
+ error.value = null;
+ try {
+ await request(action, payload);
+ if (action !== 'setWorkspace') {
+ await refresh();
+ }
+ return true;
+ } catch (cause) {
+ error.value =
+ cause instanceof Error ? cause.message : 'bridgeUnavailable';
+ return false;
+ } finally {
+ pending.value = false;
+ }
+ }
+ async function poll() {
+ try {
+ await refresh();
+ } catch (cause) {
+ error.value =
+ cause instanceof Error ? cause.message : 'bridgeUnavailable';
+ }
+ }
+ return { state: readonly(state), busy, error: readonly(error), act, poll };
+}
+
+export async function recoverNativeWorkspace() {
+ if (!state.value) {
+ return;
+ }
+ try {
+ await request('openSettings');
+ } catch {
+ /* Native recovery remains available from the app menu or toolbar. */
+ }
+}
+
+export function registerNativeRecovery() {
+ window.addEventListener('offline', () => {
+ void recoverNativeWorkspace();
+ });
+ document.addEventListener('inertia:exception', () => {
+ void recoverNativeWorkspace();
+ });
+ document.addEventListener('inertia:invalid', (event) => {
+ if (event.detail.response.status >= 400) {
+ void recoverNativeWorkspace();
+ }
+ });
+}
diff --git a/resources/js/layouts/auth/AuthFlowLayout.vue b/resources/js/layouts/auth/AuthFlowLayout.vue
index d3ce40e..707cd97 100644
--- a/resources/js/layouts/auth/AuthFlowLayout.vue
+++ b/resources/js/layouts/auth/AuthFlowLayout.vue
@@ -6,11 +6,14 @@ import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
+import { useNativeApp } from '@/composables/useNativeApp';
+import { edit as editNativeApp } from '@/routes/native-app';
import { useMacDownload } from '@/composables/useMacDownload';
import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization();
+const { state: nativeApp } = useNativeApp();
const { macDownloadUrl, showMacDownload } = useMacDownload();
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
>
+