From 912cfc28c2606d594b0b1944a283d038ec7e2e0b Mon Sep 17 00:00:00 2001 From: Twyco Date: Tue, 29 Sep 2026 17:11:10 +0200 Subject: [PATCH] feat(KAN-70): integrate native workspace and update settings --- app/Http/Middleware/RequirePasswordChange.php | 1 + native/package.json | 4 +- native/src-tauri/Cargo.lock | 2 +- native/src-tauri/Cargo.toml | 2 +- native/src-tauri/build.rs | 23 ++ .../kanbangit-native/WorkspacePolicy.swift | 29 ++ .../WorkspaceViewController.swift | 62 +++- .../gen/apple/Tests/WorkspaceTests.swift | 79 ++++- .../project.pbxproj | 18 +- native/src-tauri/gen/apple/project.yml | 6 +- native/src-tauri/src/desktop.rs | 83 +++++ native/src-tauri/src/lib.rs | 3 + native/src-tauri/src/settings.m | 47 +++ native/src-tauri/src/settings.rs | 147 ++++++++ native/src-tauri/src/updates.rs | 59 +++- native/src-tauri/tauri.conf.json | 4 +- native/src-tauri/tauri.ios.conf.json | 2 +- native/src-tauri/tests/macos_smoke.rs | 44 ++- native/tests/workspace-settings.test.js | 75 +++++ native/ui/workspace-settings.js | 58 ++++ resources/js/app.ts | 9 + resources/js/composables/useNativeApp.ts | 136 ++++++++ resources/js/layouts/auth/AuthFlowLayout.vue | 6 + resources/js/layouts/settings/Layout.vue | 13 + resources/js/lib/native-app.ts | 69 ++++ resources/js/locales/en.ts | 59 ++++ resources/js/pages/settings/App.vue | 313 ++++++++++++++++++ resources/js/types/native-app-global.d.ts | 7 + resources/js/types/native-app.ts | 44 +++ routes/web/settings.php | 4 + .../Settings/NativeAppSettingsTest.php | 33 ++ tests/Fixtures/native-app.mjs | 248 ++++++++++++++ 32 files changed, 1651 insertions(+), 38 deletions(-) create mode 100644 native/src-tauri/src/settings.m create mode 100644 native/src-tauri/src/settings.rs create mode 100644 native/tests/workspace-settings.test.js create mode 100644 native/ui/workspace-settings.js create mode 100644 resources/js/composables/useNativeApp.ts create mode 100644 resources/js/lib/native-app.ts create mode 100644 resources/js/pages/settings/App.vue create mode 100644 resources/js/types/native-app-global.d.ts create mode 100644 resources/js/types/native-app.ts create mode 100644 tests/Feature/Settings/NativeAppSettingsTest.php create mode 100644 tests/Fixtures/native-app.mjs 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" > +