feat(KAN-70): integrate native workspace and update settings
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
This commit is contained in:
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
||||
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)
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<Mutex<Connection>>();
|
||||
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<serde_json::Value, &'static str> {
|
||||
use crate::settings::Action;
|
||||
match action {
|
||||
Action::GetState {} => {
|
||||
let workspace = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.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::<Mutex<Connection>>().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<tauri::Wry> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
|
||||
typedef void (*SettingsFree)(char *);
|
||||
|
||||
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
|
||||
@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];
|
||||
}
|
||||
@@ -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<AppHandle> = 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<Value, &'static str> {
|
||||
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 {}))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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::<Mutex<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::<Mutex<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<dyn std::error::Error>> {
|
||||
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::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
|
||||
refresh(app);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
|
||||
serde_json::to_value(&app.state::<Mutex<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::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.busy() {
|
||||
return Err("updateBusy");
|
||||
}
|
||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "5"
|
||||
"bundleVersion": "6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user