Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693f61659e | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 |
@@ -9,6 +9,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- fix/dependency-cves
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
Generated
+21
-17
@@ -1451,20 +1451,20 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.26.1",
|
||||
"version": "v13.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -1576,18 +1576,20 @@
|
||||
"league/flysystem-sftp-v3": "^3.25.1",
|
||||
"mockery/mockery": "^1.6.10",
|
||||
"opis/json-schema": "^2.4.1",
|
||||
"orchestra/testbench-core": "^11.0.0",
|
||||
"orchestra/testbench-core": "^11.5.0",
|
||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||
"php-http/discovery": "^1.15",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan": "2.2.15",
|
||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.3",
|
||||
"rector/rector": "2.6.3",
|
||||
"resend/resend-php": "^1.0",
|
||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||
"symfony/http-client": "^7.4.0 || ^8.0.0",
|
||||
"symfony/mercure": "^0.8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0"
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0",
|
||||
"web-token/jwt-library": "^4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||
@@ -1623,8 +1625,10 @@
|
||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
||||
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
|
||||
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
|
||||
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
|
||||
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
|
||||
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1674,7 +1678,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-08-18T20:31:28+00:00"
|
||||
"time": "2026-09-29T12:46:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -2192,16 +2196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.35.2",
|
||||
"version": "3.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2269,9 +2273,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
|
||||
+2
-2
@@ -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."
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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'));
|
||||
})();
|
||||
@@ -48,6 +48,11 @@
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.18",
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@tiptap/core": "^3.31.3",
|
||||
"@tiptap/markdown": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/vue-3": "^3.31.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+493
-12
@@ -213,6 +213,21 @@ importers:
|
||||
'@inertiajs/vue3':
|
||||
specifier: ^2.3.7
|
||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
|
||||
'@tiptap/core':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/markdown':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3
|
||||
'@tiptap/vue-3':
|
||||
specifier: ^3.31.3
|
||||
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.8.2
|
||||
version: 12.8.2(typescript@5.9.3)
|
||||
@@ -725,6 +740,160 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@tiptap/core@3.31.3':
|
||||
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-blockquote@3.31.3':
|
||||
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bold@3.31.3':
|
||||
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.31.3':
|
||||
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bullet-list@3.31.3':
|
||||
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-code-block@3.31.3':
|
||||
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-code@3.31.3':
|
||||
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-document@3.31.3':
|
||||
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-dropcursor@3.31.3':
|
||||
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.31.3
|
||||
|
||||
'@tiptap/extension-floating-menu@3.31.3':
|
||||
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-gapcursor@3.31.3':
|
||||
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.31.3
|
||||
|
||||
'@tiptap/extension-hard-break@3.31.3':
|
||||
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-heading@3.31.3':
|
||||
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.31.3':
|
||||
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-italic@3.31.3':
|
||||
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-link@3.31.3':
|
||||
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-list-item@3.31.3':
|
||||
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-list-keymap@3.31.3':
|
||||
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-list@3.31.3':
|
||||
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-ordered-list@3.31.3':
|
||||
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.31.3
|
||||
|
||||
'@tiptap/extension-paragraph@3.31.3':
|
||||
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-strike@3.31.3':
|
||||
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-text@3.31.3':
|
||||
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extension-underline@3.31.3':
|
||||
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
|
||||
'@tiptap/extensions@3.31.3':
|
||||
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/markdown@3.31.3':
|
||||
resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/pm@3.31.3':
|
||||
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
|
||||
|
||||
'@tiptap/starter-kit@3.31.3':
|
||||
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
|
||||
|
||||
'@tiptap/vue-3@3.31.3':
|
||||
resolution: {integrity: sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.31.3
|
||||
'@tiptap/pm': 3.31.3
|
||||
vue: ^3.0.0
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -1088,14 +1257,14 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -1914,6 +2083,9 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1936,6 +2108,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
marked@17.0.6:
|
||||
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2023,6 +2200,9 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2150,6 +2330,45 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
prosemirror-changeset@2.4.3:
|
||||
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
|
||||
|
||||
prosemirror-commands@1.7.2:
|
||||
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
|
||||
|
||||
prosemirror-dropcursor@1.8.3:
|
||||
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-model@1.25.12:
|
||||
resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-transform@1.12.1:
|
||||
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
|
||||
|
||||
prosemirror-view@1.42.5:
|
||||
resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2203,6 +2422,9 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -2506,6 +2728,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2888,6 +3113,178 @@ snapshots:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
|
||||
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
optional: true
|
||||
|
||||
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
linkifyjs: 4.3.3
|
||||
|
||||
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
marked: 17.0.6
|
||||
|
||||
'@tiptap/pm@3.31.3':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.3
|
||||
prosemirror-commands: 1.7.2
|
||||
prosemirror-dropcursor: 1.8.3
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
'@tiptap/starter-kit@3.31.3':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
|
||||
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||
'@tiptap/pm': 3.31.3
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3303,16 +3700,16 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
brace-expansion@1.1.21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.4:
|
||||
brace-expansion@2.1.7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -4171,6 +4568,8 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
linkifyjs@4.3.3: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -4189,6 +4588,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@17.0.6: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -4206,15 +4607,15 @@ snapshots:
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -4276,6 +4677,8 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -4333,6 +4736,80 @@ snapshots:
|
||||
|
||||
prettier@3.8.2: {}
|
||||
|
||||
prosemirror-changeset@2.4.3:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-commands@1.7.2:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-dropcursor@1.8.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-model@1.25.12:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
prosemirror-view: 1.42.5
|
||||
|
||||
prosemirror-transform@1.12.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
|
||||
prosemirror-view@1.42.5:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.12
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
@@ -4415,6 +4892,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -4760,6 +5239,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
minimumReleaseAge: 10080
|
||||
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@1.1.21
|
||||
- brace-expansion@2.1.7
|
||||
- brace-expansion@5.0.12
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './theme.css';
|
||||
@import './ticket-description.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.ticket-description {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.ticket-description > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ticket-description h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h3,
|
||||
.ticket-description h4,
|
||||
.ticket-description h5,
|
||||
.ticket-description h6 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket-description ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description li > ul,
|
||||
.ticket-description li > ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.ticket-description blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ticket-description a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.ticket-description code {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.ticket-description pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.75rem;
|
||||
white-space: pre;
|
||||
}
|
||||
.ticket-description pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.ticket-description hr {
|
||||
border-color: var(--color-border);
|
||||
margin-block: 1rem;
|
||||
}
|
||||
.ticket-description:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h } from 'vue';
|
||||
import {
|
||||
parseDescription,
|
||||
renderDescriptionNode,
|
||||
supportsVisualDescription,
|
||||
} from '@/lib/ticket-description';
|
||||
|
||||
const props = defineProps<{ description: string }>();
|
||||
const document = computed(() =>
|
||||
supportsVisualDescription(props.description)
|
||||
? parseDescription(props.description)
|
||||
: null,
|
||||
);
|
||||
const Content = defineComponent(
|
||||
() => () =>
|
||||
document.value
|
||||
? h('div', { class: 'ticket-description' }, [
|
||||
renderDescriptionNode(document.value),
|
||||
])
|
||||
: h(
|
||||
'p',
|
||||
{
|
||||
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
|
||||
},
|
||||
props.description,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Content />
|
||||
</template>
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
SquareCode,
|
||||
Link,
|
||||
Unlink,
|
||||
Undo2,
|
||||
Redo2,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
descriptionExtensions,
|
||||
parseDescription,
|
||||
safeDescriptionLink,
|
||||
supportsVisualDescription,
|
||||
} from '@/lib/ticket-description';
|
||||
import type {
|
||||
DescriptionToolbarAction,
|
||||
TicketDescriptionEditorProps,
|
||||
} from '@/types/ticket-description';
|
||||
|
||||
const props = defineProps<TicketDescriptionEditorProps>();
|
||||
const model = defineModel<string>({ required: true });
|
||||
const { t } = useLocalization();
|
||||
const sourceMode = ref(!supportsVisualDescription(model.value));
|
||||
const supported = computed(() => supportsVisualDescription(model.value));
|
||||
const visual = computed(() => !sourceMode.value && supported.value);
|
||||
const linkOpen = ref(false);
|
||||
const linkUrl = ref('');
|
||||
const linkError = ref('');
|
||||
const linkInput = ref<InstanceType<typeof Input> | null>(null);
|
||||
let currentMarkdown = model.value;
|
||||
const editor = useEditor({
|
||||
extensions: descriptionExtensions(),
|
||||
content: supported.value ? parseDescription(model.value) : '',
|
||||
editable: !props.disabled,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
id: props.id,
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-labelledby': `${props.id}-label`,
|
||||
'aria-describedby': `${props.id}-hint ${props.id}-error`,
|
||||
'aria-invalid': String(Boolean(props.error)),
|
||||
'aria-disabled': String(Boolean(props.disabled)),
|
||||
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
|
||||
model.value = currentMarkdown;
|
||||
},
|
||||
});
|
||||
|
||||
watch(model, (value) => {
|
||||
if (value === currentMarkdown) {
|
||||
return;
|
||||
}
|
||||
currentMarkdown = value;
|
||||
if (supportsVisualDescription(value)) {
|
||||
editor.value?.commands.setContent(parseDescription(value), {
|
||||
emitUpdate: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
editor.value?.setEditable(!disabled, false);
|
||||
if (disabled) {
|
||||
linkOpen.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch([() => props.error, () => props.disabled], () => {
|
||||
editor.value?.setOptions({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
...editor.value.options.editorProps.attributes,
|
||||
'aria-invalid': String(Boolean(props.error)),
|
||||
'aria-disabled': String(Boolean(props.disabled)),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const actions: DescriptionToolbarAction[] = [
|
||||
{
|
||||
label: 'descriptionEditor.bold',
|
||||
icon: Bold,
|
||||
active: 'bold',
|
||||
run: (c) => c.toggleBold(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.italic',
|
||||
icon: Italic,
|
||||
active: 'italic',
|
||||
run: (c) => c.toggleItalic(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.strike',
|
||||
icon: Strikethrough,
|
||||
active: 'strike',
|
||||
run: (c) => c.toggleStrike(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading1',
|
||||
icon: Heading1,
|
||||
active: 'heading',
|
||||
attributes: { level: 1 },
|
||||
run: (c) => c.toggleHeading({ level: 1 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading2',
|
||||
icon: Heading2,
|
||||
active: 'heading',
|
||||
attributes: { level: 2 },
|
||||
run: (c) => c.toggleHeading({ level: 2 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.heading3',
|
||||
icon: Heading3,
|
||||
active: 'heading',
|
||||
attributes: { level: 3 },
|
||||
run: (c) => c.toggleHeading({ level: 3 }),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.bulletList',
|
||||
icon: List,
|
||||
active: 'bulletList',
|
||||
run: (c) => c.toggleBulletList(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.orderedList',
|
||||
icon: ListOrdered,
|
||||
active: 'orderedList',
|
||||
run: (c) => c.toggleOrderedList(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.quote',
|
||||
icon: Quote,
|
||||
active: 'blockquote',
|
||||
run: (c) => c.toggleBlockquote(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.code',
|
||||
icon: Code,
|
||||
active: 'code',
|
||||
run: (c) => c.toggleCode(),
|
||||
},
|
||||
{
|
||||
label: 'descriptionEditor.codeBlock',
|
||||
icon: SquareCode,
|
||||
active: 'codeBlock',
|
||||
run: (c) => c.toggleCodeBlock(),
|
||||
},
|
||||
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
|
||||
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
|
||||
];
|
||||
function runAction(action: DescriptionToolbarAction): void {
|
||||
if (editor.value && !props.disabled) {
|
||||
action.run(editor.value.chain().focus()).run();
|
||||
}
|
||||
}
|
||||
function switchMode(): void {
|
||||
linkOpen.value = false;
|
||||
sourceMode.value = !sourceMode.value;
|
||||
}
|
||||
async function editLink(): Promise<void> {
|
||||
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
|
||||
linkError.value = '';
|
||||
linkOpen.value = true;
|
||||
await nextTick();
|
||||
linkInput.value?.$el?.focus();
|
||||
}
|
||||
function saveLink(): void {
|
||||
if (!editor.value || props.disabled) {
|
||||
return;
|
||||
}
|
||||
const href = safeDescriptionLink(linkUrl.value.trim());
|
||||
if (!href) {
|
||||
linkError.value = t('descriptionEditor.invalidLink');
|
||||
return;
|
||||
}
|
||||
const chain = editor.value.chain().focus().extendMarkRange('link');
|
||||
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
|
||||
chain
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: href,
|
||||
marks: [{ type: 'link', attrs: { href } }],
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
chain.setLink({ href }).run();
|
||||
}
|
||||
linkOpen.value = false;
|
||||
}
|
||||
function closeLink(): void {
|
||||
linkOpen.value = false;
|
||||
editor.value?.commands.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label
|
||||
:id="`${id}-label`"
|
||||
:for="id"
|
||||
@click="visual && editor?.commands.focus()"
|
||||
>{{ label }}</Label
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="disabled || (!supported && !visual)"
|
||||
:aria-pressed="sourceMode"
|
||||
@click="switchMode"
|
||||
>
|
||||
{{
|
||||
visual
|
||||
? t('descriptionEditor.source')
|
||||
: t('descriptionEditor.visual')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
|
||||
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
|
||||
>
|
||||
<div
|
||||
v-if="visual"
|
||||
role="group"
|
||||
:aria-label="t('descriptionEditor.formatting')"
|
||||
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
|
||||
>
|
||||
<Button
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
:variant="
|
||||
action.active &&
|
||||
editor?.isActive(action.active, action.attributes)
|
||||
? 'secondary'
|
||||
: 'ghost'
|
||||
"
|
||||
:title="t(action.label)"
|
||||
:aria-label="t(action.label)"
|
||||
:aria-pressed="
|
||||
action.active
|
||||
? Boolean(
|
||||
editor?.isActive(
|
||||
action.active,
|
||||
action.attributes,
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
"
|
||||
:disabled="
|
||||
disabled ||
|
||||
!editor ||
|
||||
!action.run(editor.can().chain()).run()
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click="runAction(action)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:title="t('descriptionEditor.link')"
|
||||
:aria-label="t('descriptionEditor.link')"
|
||||
:aria-pressed="Boolean(editor?.isActive('link'))"
|
||||
:disabled="
|
||||
disabled ||
|
||||
!editor ||
|
||||
!editor.can().setLink({ href: 'https://example.com' })
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click="editLink"
|
||||
><Link class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
:title="t('descriptionEditor.unlink')"
|
||||
:aria-label="t('descriptionEditor.unlink')"
|
||||
:disabled="disabled || !editor?.isActive('link')"
|
||||
@mousedown.prevent
|
||||
@click="
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.unsetLink()
|
||||
.run()
|
||||
"
|
||||
><Unlink class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="linkOpen && visual"
|
||||
class="grid gap-2 border-b p-3"
|
||||
@keydown.esc.prevent.stop="closeLink"
|
||||
>
|
||||
<Label :for="`${id}-link`">{{
|
||||
t('descriptionEditor.linkUrl')
|
||||
}}</Label>
|
||||
<Input
|
||||
:id="`${id}-link`"
|
||||
ref="linkInput"
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:invalid="Boolean(linkError)"
|
||||
:aria-describedby="`${id}-link-error`"
|
||||
placeholder="https://"
|
||||
@keydown.enter.prevent.stop="saveLink"
|
||||
/>
|
||||
<InputError :id="`${id}-link-error`" :message="linkError" />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
@click="saveLink"
|
||||
>{{ t('descriptionEditor.applyLink') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="closeLink"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<EditorContent v-if="visual && editor" :editor="editor" />
|
||||
<textarea
|
||||
v-else
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:aria-labelledby="`${id}-label`"
|
||||
:aria-describedby="`${id}-hint ${id}-error`"
|
||||
:aria-invalid="Boolean(error)"
|
||||
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||
{{
|
||||
supported
|
||||
? t('descriptionEditor.hint')
|
||||
: t('descriptionEditor.unsupported')
|
||||
}}
|
||||
</p>
|
||||
<InputError :id="`${id}-error`" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
|
||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
@@ -72,10 +73,9 @@ const statusOptions = computed(
|
||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
<TicketDescriptionEditor
|
||||
id="description"
|
||||
v-model="description"
|
||||
kind="textarea"
|
||||
:label="t('ticketDetail.description')"
|
||||
:error="errors.description"
|
||||
:disabled="disabled"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
watch,
|
||||
} from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import TicketDescription from '@/components/tickets/TicketDescription.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
@@ -272,16 +273,12 @@ const submit = (): void => {
|
||||
</h2></CardHeader
|
||||
>
|
||||
<CardContent>
|
||||
<p
|
||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
||||
:class="{
|
||||
'text-muted-foreground': !ticket.description,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
ticket.description ||
|
||||
t('ticketDetail.noDescription')
|
||||
}}
|
||||
<TicketDescription
|
||||
v-if="ticket.description"
|
||||
:description="ticket.description"
|
||||
/>
|
||||
<p v-else class="text-muted-foreground text-sm leading-7">
|
||||
{{ t('ticketDetail.noDescription') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<NativeAppState | null>(null);
|
||||
const pending = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let initializing = false;
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
async function request(
|
||||
action: NativeAction,
|
||||
payload: Record<string, unknown> = {},
|
||||
) {
|
||||
if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
|
||||
throw new Error('bridgeUnavailable');
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
window.kanbanNative.request(action, payload),
|
||||
new Promise<never>((_, 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<string, unknown> = {},
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
<Button v-if="nativeApp" as-child variant="outline" size="sm">
|
||||
<Link :href="editNativeApp()">{{ t('nativeApp.title') }}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showMacDownload"
|
||||
as-child
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
AppWindow,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import { edit as editNativeApp } from '@/routes/native-app';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||
@@ -20,6 +23,7 @@ import { edit as editSecurity } from '@/routes/security/index';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const { state: nativeApp } = useNativeApp();
|
||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: t('navigation.profile'),
|
||||
@@ -41,6 +45,15 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
href: editApplications(),
|
||||
icon: KeyRound,
|
||||
},
|
||||
...(nativeApp.value
|
||||
? [
|
||||
{
|
||||
title: t('nativeApp.title'),
|
||||
href: editNativeApp(),
|
||||
icon: AppWindow,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('navigation.appearance'),
|
||||
href: editAppearance(),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { NativeAppState, NativeUpdates } from '@/types/native-app';
|
||||
|
||||
export function isNativeAppState(value: unknown): value is NativeAppState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const state = value as NativeAppState;
|
||||
if (
|
||||
state.apiVersion !== 1 ||
|
||||
!['macos', 'ios'].includes(state.platform) ||
|
||||
typeof state.version !== 'string' ||
|
||||
typeof state.workspace !== 'string' ||
|
||||
!Array.isArray(state.capabilities) ||
|
||||
!state.capabilities.includes('workspace')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (new URL(state.workspace).protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!state.capabilities.includes('updates')) {
|
||||
return state.updates === undefined;
|
||||
}
|
||||
if (state.platform !== 'macos' || !state.updates) {
|
||||
return false;
|
||||
}
|
||||
const updates = state.updates;
|
||||
return (
|
||||
[
|
||||
'idle',
|
||||
'checking',
|
||||
'available',
|
||||
'current',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
'error',
|
||||
].includes(updates.phase) &&
|
||||
typeof updates.currentVersion === 'string' &&
|
||||
typeof updates.build === 'string' &&
|
||||
(updates.version === null || typeof updates.version === 'string') &&
|
||||
(updates.notes === null || typeof updates.notes === 'string') &&
|
||||
(updates.error === null || typeof updates.error === 'string') &&
|
||||
Number.isFinite(updates.downloaded) &&
|
||||
updates.downloaded >= 0 &&
|
||||
(updates.total === null ||
|
||||
(Number.isFinite(updates.total) && updates.total >= 0)) &&
|
||||
typeof updates.preferences?.automatic === 'boolean' &&
|
||||
[1, 6, 24].includes(updates.preferences.intervalHours)
|
||||
);
|
||||
}
|
||||
|
||||
export function updateIsBusy(updates?: NativeUpdates): boolean {
|
||||
return (
|
||||
!!updates &&
|
||||
[
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
].includes(updates.phase)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { h } from 'vue';
|
||||
import type { VNodeChild } from 'vue';
|
||||
|
||||
export function safeDescriptionLink(value: unknown): string | null {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
/[\s\\]/u.test(value) ||
|
||||
[...value].some(
|
||||
(character) =>
|
||||
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
||||
? value
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function descriptionExtensions() {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
underline: false,
|
||||
trailingNode: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
|
||||
HTMLAttributes: {
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
}),
|
||||
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
||||
];
|
||||
}
|
||||
|
||||
const markdown = new MarkdownManager({
|
||||
extensions: descriptionExtensions(),
|
||||
markedOptions: { gfm: true, breaks: true },
|
||||
});
|
||||
|
||||
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
||||
export function supportsVisualDescription(value: string): boolean {
|
||||
let supported = true;
|
||||
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
|
||||
if (
|
||||
['html', 'table', 'image'].includes(token.type) ||
|
||||
(token.type === 'list_item' && token.task) ||
|
||||
(token.type === 'link' && !safeDescriptionLink(token.href))
|
||||
) {
|
||||
supported = false;
|
||||
}
|
||||
});
|
||||
return supported;
|
||||
}
|
||||
|
||||
export function parseDescription(value: string): JSONContent {
|
||||
return markdown.parse(value);
|
||||
}
|
||||
|
||||
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
|
||||
export function renderDescriptionNode(node: JSONContent): VNodeChild {
|
||||
if (node.type === 'text') {
|
||||
let content: VNodeChild = node.text ?? '';
|
||||
for (const mark of node.marks ?? []) {
|
||||
const tag = {
|
||||
bold: 'strong',
|
||||
italic: 'em',
|
||||
strike: 's',
|
||||
code: 'code',
|
||||
}[mark.type];
|
||||
if (tag) {
|
||||
content = h(tag, {}, [content]);
|
||||
} else if (mark.type === 'link') {
|
||||
const href = safeDescriptionLink(mark.attrs?.href);
|
||||
if (href) {
|
||||
content = h(
|
||||
'a',
|
||||
{ href, target: '_blank', rel: 'noopener noreferrer' },
|
||||
[content],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
const children = (node.content ?? []).map(renderDescriptionNode);
|
||||
switch (node.type) {
|
||||
case 'doc':
|
||||
return children;
|
||||
case 'paragraph':
|
||||
return h('p', {}, children);
|
||||
case 'heading':
|
||||
return h(
|
||||
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
|
||||
{},
|
||||
children,
|
||||
);
|
||||
case 'bulletList':
|
||||
return h('ul', {}, children);
|
||||
case 'orderedList':
|
||||
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
|
||||
case 'listItem':
|
||||
return h('li', {}, children);
|
||||
case 'blockquote':
|
||||
return h('blockquote', {}, children);
|
||||
case 'codeBlock':
|
||||
return h('pre', {}, [h('code', {}, children)]);
|
||||
case 'hardBreak':
|
||||
return h('br');
|
||||
case 'horizontalRule':
|
||||
return h('hr');
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,90 @@
|
||||
export const en = {
|
||||
'descriptionEditor.bold': 'Bold',
|
||||
'descriptionEditor.italic': 'Italic',
|
||||
'descriptionEditor.strike': 'Strikethrough',
|
||||
'descriptionEditor.heading1': 'Heading 1',
|
||||
'descriptionEditor.heading2': 'Heading 2',
|
||||
'descriptionEditor.heading3': 'Heading 3',
|
||||
'descriptionEditor.bulletList': 'Bullet list',
|
||||
'descriptionEditor.orderedList': 'Numbered list',
|
||||
'descriptionEditor.quote': 'Quote',
|
||||
'descriptionEditor.code': 'Inline code',
|
||||
'descriptionEditor.codeBlock': 'Code block',
|
||||
'descriptionEditor.undo': 'Undo',
|
||||
'descriptionEditor.redo': 'Redo',
|
||||
'descriptionEditor.link': 'Add or edit link',
|
||||
'descriptionEditor.unlink': 'Remove link',
|
||||
'descriptionEditor.linkUrl': 'Link address',
|
||||
'descriptionEditor.applyLink': 'Apply link',
|
||||
'descriptionEditor.invalidLink':
|
||||
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
|
||||
'descriptionEditor.formatting': 'Description formatting',
|
||||
'descriptionEditor.source': 'Markdown',
|
||||
'descriptionEditor.visual': 'Visual editor',
|
||||
'descriptionEditor.hint':
|
||||
'Format your description using the toolbar or edit its Markdown source.',
|
||||
'descriptionEditor.unsupported':
|
||||
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
|
||||
|
||||
'nativeApp.title': 'App',
|
||||
'nativeApp.description': 'Manage the KanbanGit app on this device.',
|
||||
'nativeApp.workspace': 'Workspace',
|
||||
'nativeApp.server': 'Server address',
|
||||
'nativeApp.serverHelp':
|
||||
'Use an HTTPS installation address without login details, a query or a fragment. This choice is saved on this device.',
|
||||
'nativeApp.switch': 'Change workspace',
|
||||
'nativeApp.confirmSwitch': 'Switch workspace',
|
||||
'nativeApp.switchWarning':
|
||||
'Switching closes your current workspace and discards unsaved inputs. You will sign in separately on the new server.',
|
||||
'nativeApp.cancel': 'Cancel',
|
||||
'nativeApp.unavailable':
|
||||
'App settings are available in a compatible KanbanGit app.',
|
||||
'nativeApp.updates': 'Software updates',
|
||||
'nativeApp.version': 'Installed version {version} (build {build})',
|
||||
'nativeApp.mobileVersion': 'App version {version}',
|
||||
'nativeApp.availableVersion': 'Available version: {version}',
|
||||
'nativeApp.automatic': 'Automatically check for updates',
|
||||
'nativeApp.interval': 'Check interval',
|
||||
'nativeApp.hours': 'Every {hours} hours',
|
||||
'nativeApp.updateHelp':
|
||||
'Checks run at startup and at the selected interval. Installation and restart require your confirmation.',
|
||||
'nativeApp.check': 'Check for updates now',
|
||||
'nativeApp.install': 'Install update',
|
||||
'nativeApp.installRestart': 'Install and restart',
|
||||
'nativeApp.installWarning':
|
||||
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your workspace settings and stored sessions are kept.',
|
||||
'nativeApp.progress': 'Update progress',
|
||||
'nativeApp.recovery': 'Open native server settings',
|
||||
'nativeApp.bridgeError':
|
||||
'The app connection is unavailable or incompatible. Open the native server settings to reconnect, or restart the app.',
|
||||
'nativeApp.invalidServer':
|
||||
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
|
||||
'nativeApp.storageError':
|
||||
'The workspace could not be saved. Check local storage permissions and try again.',
|
||||
'nativeApp.networkError':
|
||||
'The update service or download is unreachable. Check your connection and try again.',
|
||||
'nativeApp.metadataError':
|
||||
'The update information is invalid or does not match this Mac. Try again later.',
|
||||
'nativeApp.signatureError':
|
||||
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
|
||||
'nativeApp.verificationError':
|
||||
'The downloaded app does not match its announced version or publisher. Nothing was installed.',
|
||||
'nativeApp.installationError':
|
||||
'The update could not be installed. Move KanbanGit to Applications and check disk space and permissions.',
|
||||
'nativeApp.preferencesError':
|
||||
'Update preferences could not be saved. Check local storage permissions and try again.',
|
||||
'nativeApp.updateBusy':
|
||||
'An update operation is already running. Please wait.',
|
||||
'nativeApp.phase.idle': 'Check for the latest stable version of KanbanGit.',
|
||||
'nativeApp.phase.checking': 'Checking for updates…',
|
||||
'nativeApp.phase.available': 'A new version of KanbanGit is available.',
|
||||
'nativeApp.phase.current': 'KanbanGit is up to date.',
|
||||
'nativeApp.phase.downloading': 'Downloading update…',
|
||||
'nativeApp.phase.verifying': 'Verifying update…',
|
||||
'nativeApp.phase.installing': 'Installing update…',
|
||||
'nativeApp.phase.restarting': 'Restarting KanbanGit…',
|
||||
'nativeApp.phase.error': 'The update could not be completed.',
|
||||
|
||||
'projectAccess.pageTitle': '{project} · Project access',
|
||||
'projectAccess.title': 'Project access',
|
||||
'projectAccess.description':
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useNativeApp } from '@/composables/useNativeApp';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { home } from '@/routes';
|
||||
import { edit } from '@/routes/native-app';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const { state, busy, error, act, poll } = useNativeApp();
|
||||
const page = usePage();
|
||||
const signedIn = computed(() => !!page.props.auth.user);
|
||||
const address = ref('');
|
||||
const confirmSwitch = ref(false);
|
||||
const confirmInstall = ref(false);
|
||||
const updates = computed(() => state.value?.updates);
|
||||
watch(
|
||||
() => updates.value?.version,
|
||||
() => {
|
||||
confirmInstall.value = false;
|
||||
},
|
||||
);
|
||||
const breadcrumbs = computed(() => [
|
||||
{ title: t('nativeApp.title'), href: edit() },
|
||||
]);
|
||||
watch(
|
||||
() => state.value?.workspace,
|
||||
(workspace) => {
|
||||
if (workspace) {
|
||||
address.value = workspace;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(address, () => {
|
||||
confirmSwitch.value = false;
|
||||
});
|
||||
const knownErrors: Record<string, TranslationKey> = {
|
||||
invalidServer: 'nativeApp.invalidServer',
|
||||
storageError: 'nativeApp.storageError',
|
||||
networkError: 'nativeApp.networkError',
|
||||
metadataError: 'nativeApp.metadataError',
|
||||
signatureError: 'nativeApp.signatureError',
|
||||
verificationError: 'nativeApp.verificationError',
|
||||
installationError: 'nativeApp.installationError',
|
||||
preferencesError: 'nativeApp.preferencesError',
|
||||
updateBusy: 'nativeApp.updateBusy',
|
||||
};
|
||||
const errorMessage = computed(() => {
|
||||
const value = error.value ?? updates.value?.error;
|
||||
return value ? t(knownErrors[value] ?? 'nativeApp.bridgeError') : null;
|
||||
});
|
||||
const progress = computed(() =>
|
||||
updates.value?.total
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(updates.value.downloaded / updates.value.total) * 100,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let active = true;
|
||||
async function refresh() {
|
||||
if (state.value) {
|
||||
await poll();
|
||||
}
|
||||
if (active) {
|
||||
timer = setTimeout(refresh, 1500);
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
async function switchWorkspace() {
|
||||
if (await act('setWorkspace', { server: address.value })) {
|
||||
confirmSwitch.value = false;
|
||||
}
|
||||
}
|
||||
async function installUpdate() {
|
||||
const version = updates.value?.version;
|
||||
if (version && (await act('installUpdate', { version }))) {
|
||||
confirmInstall.value = false;
|
||||
}
|
||||
}
|
||||
function setAutomatic(event: Event) {
|
||||
void act('setUpdatePreferences', {
|
||||
automatic: (event.target as HTMLInputElement).checked,
|
||||
intervalHours: updates.value?.preferences.intervalHours,
|
||||
});
|
||||
}
|
||||
function setIntervalHours(event: Event) {
|
||||
void act('setUpdatePreferences', {
|
||||
automatic: updates.value?.preferences.automatic,
|
||||
intervalHours: Number((event.target as HTMLSelectElement).value),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="signedIn ? AppLayout : 'main'"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
class="min-h-dvh"
|
||||
>
|
||||
<Head :title="t('nativeApp.title')" />
|
||||
<component
|
||||
:is="signedIn ? SettingsLayout : 'div'"
|
||||
:class="signedIn ? '' : 'mx-auto grid max-w-3xl gap-6 p-6'"
|
||||
>
|
||||
<Link
|
||||
v-if="!signedIn"
|
||||
:href="home()"
|
||||
class="text-primary underline"
|
||||
>{{ t('auth.home') }}</Link
|
||||
>
|
||||
<header class="grid gap-2">
|
||||
<h2 class="text-title">{{ t('nativeApp.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('nativeApp.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<p v-if="!state" role="status">{{ t('nativeApp.unavailable') }}</p>
|
||||
<template v-else>
|
||||
<form class="grid gap-4" @submit.prevent="confirmSwitch = true">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('nativeApp.workspace') }}
|
||||
</h3>
|
||||
<Label for="native-server">{{
|
||||
t('nativeApp.server')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="native-server"
|
||||
v-model="address"
|
||||
type="url"
|
||||
required
|
||||
autocomplete="url"
|
||||
:disabled="busy"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('nativeApp.serverHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="busy || address === state.workspace"
|
||||
class="justify-self-start"
|
||||
>{{ t('nativeApp.switch') }}</Button
|
||||
>
|
||||
<div
|
||||
v-if="confirmSwitch"
|
||||
class="grid gap-3 rounded-lg border p-4"
|
||||
role="group"
|
||||
:aria-label="t('nativeApp.switch')"
|
||||
>
|
||||
<p>{{ t('nativeApp.switchWarning') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="switchWorkspace"
|
||||
>{{ t('nativeApp.confirmSwitch') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="confirmSwitch = false"
|
||||
>{{ t('nativeApp.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<section
|
||||
v-if="updates"
|
||||
class="grid gap-4 border-t pt-6"
|
||||
aria-labelledby="native-updates"
|
||||
>
|
||||
<h3 id="native-updates" class="text-lg font-semibold">
|
||||
{{ t('nativeApp.updates') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
t('nativeApp.version', {
|
||||
version: updates.currentVersion,
|
||||
build: updates.build,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p role="status" aria-live="polite">
|
||||
{{ t(`nativeApp.phase.${updates.phase}`) }}
|
||||
</p>
|
||||
<template
|
||||
v-if="
|
||||
['downloading', 'verifying', 'installing'].includes(
|
||||
updates.phase,
|
||||
)
|
||||
"
|
||||
>
|
||||
<progress
|
||||
:value="progress"
|
||||
max="100"
|
||||
class="w-full"
|
||||
:aria-label="t('nativeApp.progress')"
|
||||
/>
|
||||
<span v-if="progress !== undefined"
|
||||
>{{ progress }}%</span
|
||||
>
|
||||
</template>
|
||||
<Label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="updates.preferences.automatic"
|
||||
:disabled="busy"
|
||||
@change="setAutomatic"
|
||||
/>
|
||||
{{ t('nativeApp.automatic') }}
|
||||
</Label>
|
||||
<Label for="native-update-interval">{{
|
||||
t('nativeApp.interval')
|
||||
}}</Label>
|
||||
<select
|
||||
id="native-update-interval"
|
||||
:value="updates.preferences.intervalHours"
|
||||
:disabled="busy"
|
||||
class="bg-background rounded-md border p-2"
|
||||
@change="setIntervalHours"
|
||||
>
|
||||
<option
|
||||
v-for="hours in [1, 6, 24]"
|
||||
:key="hours"
|
||||
:value="hours"
|
||||
>
|
||||
{{ t('nativeApp.hours', { hours }) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('nativeApp.updateHelp') }}
|
||||
</p>
|
||||
<Button
|
||||
:disabled="busy"
|
||||
class="justify-self-start"
|
||||
@click="act('checkForUpdates')"
|
||||
>{{ t('nativeApp.check') }}</Button
|
||||
>
|
||||
<template v-if="updates.version">
|
||||
<p>
|
||||
{{
|
||||
t('nativeApp.availableVersion', {
|
||||
version: updates.version,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="updates.notes" class="whitespace-pre-wrap">
|
||||
{{ updates.notes }}
|
||||
</p>
|
||||
<Button
|
||||
:disabled="busy"
|
||||
class="justify-self-start"
|
||||
@click="confirmInstall = true"
|
||||
>{{ t('nativeApp.install') }}</Button
|
||||
>
|
||||
<div
|
||||
v-if="confirmInstall"
|
||||
class="grid gap-3 rounded-lg border p-4"
|
||||
role="group"
|
||||
:aria-label="t('nativeApp.install')"
|
||||
>
|
||||
<p>{{ t('nativeApp.installWarning') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
:disabled="busy"
|
||||
@click="installUpdate"
|
||||
>{{ t('nativeApp.installRestart') }}</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="confirmInstall = false"
|
||||
>{{ t('nativeApp.cancel') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('nativeApp.mobileVersion', { version: state.version })
|
||||
}}
|
||||
</p>
|
||||
<p v-if="errorMessage" class="text-destructive" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="act('openSettings')"
|
||||
>{{ t('nativeApp.recovery') }}</Button
|
||||
>
|
||||
</template>
|
||||
</component>
|
||||
</component>
|
||||
</template>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { NativeBridge } from './native-app';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kanbanNative?: NativeBridge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type NativeUpdatePhase =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'current'
|
||||
| 'downloading'
|
||||
| 'verifying'
|
||||
| 'installing'
|
||||
| 'restarting'
|
||||
| 'error';
|
||||
export type NativeUpdates = {
|
||||
phase: NativeUpdatePhase;
|
||||
currentVersion: string;
|
||||
build: string;
|
||||
version: string | null;
|
||||
notes: string | null;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
error: string | null;
|
||||
preferences: { automatic: boolean; intervalHours: 1 | 6 | 24 };
|
||||
};
|
||||
export type NativeAppState = {
|
||||
apiVersion: 1;
|
||||
platform: 'macos' | 'ios';
|
||||
version: string;
|
||||
workspace: string;
|
||||
capabilities: string[];
|
||||
updates?: NativeUpdates;
|
||||
};
|
||||
export type NativeAction =
|
||||
| 'getState'
|
||||
| 'settingsReady'
|
||||
| 'openSettings'
|
||||
| 'setWorkspace'
|
||||
| 'checkForUpdates'
|
||||
| 'setUpdatePreferences'
|
||||
| 'installUpdate';
|
||||
export type NativeBridge = {
|
||||
version: number;
|
||||
request: (
|
||||
action: NativeAction,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ChainedCommands } from '@tiptap/core';
|
||||
import type { Component } from 'vue';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
|
||||
export type TicketDescriptionEditorProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type DescriptionToolbarAction = {
|
||||
label: TranslationKey;
|
||||
icon: Component;
|
||||
active?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
run: (chain: ChainedCommands) => ChainedCommands;
|
||||
};
|
||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Settings\SecurityController;
|
||||
use App\Http\Controllers\Settings\UserApplicationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('settings')->group(function (): void {
|
||||
Route::inertia('app', 'settings/App')->name('native-app.edit');
|
||||
});
|
||||
|
||||
Route::middleware('auth')
|
||||
->prefix('settings')
|
||||
->group(function (): void {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NativeAppSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_app_settings_are_accessible_before_and_after_login_without_server_side_device_state(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$this->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/App')->where('auth.user', null)->missing('workspace')->missing('updates'));
|
||||
$this->actingAs(User::factory()->unverified()->create(['must_change_password_on_next_login' => true]))
|
||||
->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('settings/App')->missing('workspace')->missing('updates'));
|
||||
}
|
||||
|
||||
public function test_native_capabilities_rendering_and_actions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/native-app.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Native app settings checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketDescriptionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_description_formatting_and_safe_rendering(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
|
||||
}
|
||||
|
||||
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$this->syncProjectPermissions($project, $user, ['view', 'write']);
|
||||
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
|
||||
$payload = [
|
||||
'title' => 'Formatted description',
|
||||
'description' => $description,
|
||||
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
|
||||
];
|
||||
|
||||
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
|
||||
->assertSessionHasNoErrors()->assertRedirect();
|
||||
$ticket = $project->tickets()->sole();
|
||||
$this->assertSame($description, $ticket->description);
|
||||
$this->get(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
|
||||
|
||||
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => $updated, 'is_blocked' => false,
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertSame($updated, $ticket->fresh()->description);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
|
||||
])->assertSessionHasErrors('description');
|
||||
$this->assertSame($updated, $ticket->fresh()->description);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => '', 'is_blocked' => false,
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertNull($ticket->fresh()->description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { initializeNativeApp, useNativeApp, registerNativeRecovery } =
|
||||
await server.ssrLoadModule('/resources/js/composables/useNativeApp.ts');
|
||||
const { isNativeAppState } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/native-app.ts',
|
||||
);
|
||||
const App = (
|
||||
await server.ssrLoadModule('/resources/js/pages/settings/App.vue')
|
||||
).default;
|
||||
const Appearance = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/settings/Appearance.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (signedIn = false, component = App) => {
|
||||
const result = await createInertiaApp({
|
||||
page: {
|
||||
component: 'settings/App',
|
||||
url: '/settings/app',
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: signedIn
|
||||
? { name: 'Test', email: 'test@example.test' }
|
||||
: null,
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
},
|
||||
},
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance('system')),
|
||||
});
|
||||
return result.body;
|
||||
};
|
||||
await initializeNativeApp();
|
||||
assert.match(await render(), /compatible KanbanGit app/);
|
||||
assert.doesNotMatch(
|
||||
await render(true, Appearance),
|
||||
/href="\/settings\/app"/,
|
||||
);
|
||||
let calls = [];
|
||||
let current = {
|
||||
apiVersion: 1,
|
||||
platform: 'ios',
|
||||
version: '1.0.0',
|
||||
workspace: 'https://example.test/app/',
|
||||
capabilities: ['workspace'],
|
||||
};
|
||||
const bridge = {
|
||||
version: 2,
|
||||
request: async (action, payload) => {
|
||||
calls.push([action, payload]);
|
||||
return structuredClone(current);
|
||||
},
|
||||
};
|
||||
globalThis.window = { kanbanNative: bridge };
|
||||
await initializeNativeApp();
|
||||
assert.equal(calls.length, 0);
|
||||
bridge.version = 1;
|
||||
current.apiVersion = 2;
|
||||
await initializeNativeApp();
|
||||
assert.equal(useNativeApp().state.value, null);
|
||||
current.apiVersion = 1;
|
||||
await initializeNativeApp();
|
||||
assert.equal(calls.at(-1)[0], 'settingsReady');
|
||||
delete globalThis.window;
|
||||
let html = await render();
|
||||
assert.match(html, /value="https:\/\/example.test\/app\/"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/Check for updates now|Install update|native-update-interval/,
|
||||
);
|
||||
assert.match(await render(true, Appearance), /href="\/settings\/app"/);
|
||||
current = {
|
||||
...current,
|
||||
platform: 'macos',
|
||||
capabilities: ['workspace', 'updates'],
|
||||
updates: {
|
||||
phase: 'available',
|
||||
currentVersion: '1.0.0',
|
||||
build: '1',
|
||||
version: '1.1.0',
|
||||
notes: '<script>bad()</script>',
|
||||
downloaded: 0,
|
||||
total: null,
|
||||
error: null,
|
||||
preferences: { automatic: true, intervalHours: 6 },
|
||||
},
|
||||
};
|
||||
globalThis.window = { kanbanNative: bridge };
|
||||
await useNativeApp().poll();
|
||||
delete globalThis.window;
|
||||
html = await render(true);
|
||||
assert.match(html, /Check for updates now/);
|
||||
assert.match(html, /Install update/);
|
||||
assert.match(html, /<script>bad/);
|
||||
assert.doesNotMatch(html, /<script>bad/);
|
||||
assert.match(html, /id="native-update-interval"[^>]*value="6"/);
|
||||
for (const phase of [
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
'error',
|
||||
'current',
|
||||
]) {
|
||||
current.updates.phase = phase;
|
||||
current.updates.downloaded = 40;
|
||||
current.updates.total = 100;
|
||||
globalThis.window = { kanbanNative: bridge };
|
||||
await useNativeApp().poll();
|
||||
if (
|
||||
[
|
||||
'checking',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'installing',
|
||||
'restarting',
|
||||
].includes(phase)
|
||||
) {
|
||||
const before = calls.length;
|
||||
assert.equal(await useNativeApp().act('checkForUpdates'), false);
|
||||
assert.equal(calls.length, before);
|
||||
}
|
||||
delete globalThis.window;
|
||||
html = await render();
|
||||
if (phase === 'downloading') {
|
||||
assert.match(html, /40%/);
|
||||
}
|
||||
}
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
{ ...current, apiVersion: 2 },
|
||||
{ ...current, platform: 'unsupported' },
|
||||
{ ...current, updates: { ...current.updates, phase: 'madeUp' } },
|
||||
]) {
|
||||
assert.equal(isNativeAppState(invalid), false);
|
||||
}
|
||||
globalThis.window = { kanbanNative: bridge };
|
||||
calls = [];
|
||||
const listeners = {};
|
||||
globalThis.window.addEventListener = (name, callback) => {
|
||||
listeners[name] = callback;
|
||||
};
|
||||
globalThis.document = {
|
||||
addEventListener: (name, callback) => {
|
||||
listeners[name] = callback;
|
||||
},
|
||||
};
|
||||
registerNativeRecovery();
|
||||
for (const name of ['offline', 'inertia:exception', 'inertia:invalid']) {
|
||||
listeners[name]({ detail: { response: { status: 503 } } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.at(-1)[0], 'openSettings');
|
||||
}
|
||||
const count = calls.length;
|
||||
listeners['inertia:invalid']({ detail: { response: { status: 200 } } });
|
||||
assert.equal(calls.length, count);
|
||||
delete globalThis.document;
|
||||
calls = [];
|
||||
await useNativeApp().act('setUpdatePreferences', {
|
||||
automatic: false,
|
||||
intervalHours: 24,
|
||||
});
|
||||
assert.deepEqual(calls[0], [
|
||||
'setUpdatePreferences',
|
||||
{ automatic: false, intervalHours: 24 },
|
||||
]);
|
||||
await useNativeApp().act('setWorkspace', { server: 'https://new.test/' });
|
||||
assert.deepEqual(calls.at(-1), [
|
||||
'setWorkspace',
|
||||
{ server: 'https://new.test/' },
|
||||
]);
|
||||
let resolve;
|
||||
bridge.request = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const first = useNativeApp().act('setWorkspace', {
|
||||
server: 'https://new.test/',
|
||||
});
|
||||
assert.equal(
|
||||
await useNativeApp().act('setWorkspace', {
|
||||
server: 'https://duplicate.test/',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
resolve(null);
|
||||
await first;
|
||||
bridge.request = async () => {
|
||||
throw new Error('invalidServer');
|
||||
};
|
||||
assert.equal(
|
||||
await useNativeApp().act('setWorkspace', {
|
||||
server: 'http://invalid.test',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
delete globalThis.window;
|
||||
assert.match(await render(), /Enter a valid HTTPS/);
|
||||
console.log('Native app settings checks passed.');
|
||||
} finally {
|
||||
delete globalThis.window;
|
||||
await server.close();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createApp, h, nextTick, reactive, ref } from 'vue';
|
||||
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
|
||||
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
import '../../resources/css/app.css';
|
||||
|
||||
const original = 'First line\nSecond line\n\n**Existing bold**';
|
||||
const state = reactive({
|
||||
value: original,
|
||||
disabled: false,
|
||||
error: '',
|
||||
result: 'Running browser checks…',
|
||||
updates: 0,
|
||||
});
|
||||
const component = ref(null);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
|
||||
h(
|
||||
'h1',
|
||||
{ class: 'text-xl font-semibold' },
|
||||
'Ticket description editor',
|
||||
),
|
||||
h('p', { id: 'test-result', role: 'status' }, state.result),
|
||||
h(TicketDescriptionEditor, {
|
||||
ref: component,
|
||||
id: 'description',
|
||||
label: 'Description',
|
||||
modelValue: state.value,
|
||||
disabled: state.disabled,
|
||||
error: state.error,
|
||||
'onUpdate:modelValue': (value) => {
|
||||
state.value = value;
|
||||
state.updates++;
|
||||
},
|
||||
}),
|
||||
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
|
||||
h(TicketDescription, { description: state.value }),
|
||||
]),
|
||||
}).use(createLocalization());
|
||||
app.mount('#app');
|
||||
const tick = async () => {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
};
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
const button = (name) =>
|
||||
[...document.querySelectorAll('button')].find(
|
||||
(element) =>
|
||||
element.getAttribute('aria-label') === name ||
|
||||
element.textContent.trim() === name,
|
||||
);
|
||||
const click = async (name) => {
|
||||
const element = button(name);
|
||||
assert(element && !element.disabled, `Button available: ${name}`);
|
||||
element.click();
|
||||
await tick();
|
||||
};
|
||||
try {
|
||||
await tick();
|
||||
const editor = component.value.$.setupState.editor;
|
||||
assert(editor, 'Editor mounted');
|
||||
assert(
|
||||
state.value === original && state.updates === 0,
|
||||
'Mount preserves exact source',
|
||||
);
|
||||
assert(
|
||||
document
|
||||
.querySelector('#description')
|
||||
.getAttribute('contenteditable') === 'true',
|
||||
'Visual textbox is editable',
|
||||
);
|
||||
assert(
|
||||
editor.getText().includes('First line\nSecond line'),
|
||||
'Legacy newlines retained',
|
||||
);
|
||||
state.value = 'Format this';
|
||||
await tick();
|
||||
editor.commands.selectAll();
|
||||
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
|
||||
await click(name);
|
||||
assert(
|
||||
button(name).getAttribute('aria-pressed') === 'true',
|
||||
`${name} active`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
state.value.includes('**') && state.value.includes('~~'),
|
||||
'Formatting emits Markdown',
|
||||
);
|
||||
await click('Undo');
|
||||
assert(!editor.isActive('strike'), 'Undo restores prior state');
|
||||
await click('Redo');
|
||||
assert(editor.isActive('strike'), 'Redo restores formatting');
|
||||
await click('Markdown');
|
||||
const source = document.querySelector('textarea#description');
|
||||
assert(source.value === state.value, 'Source matches the editor output');
|
||||
source.value = '## From source\n\n- One\n- Two';
|
||||
source.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await tick();
|
||||
await click('Visual editor');
|
||||
assert(
|
||||
document.querySelector('#description h2')?.textContent ===
|
||||
'From source',
|
||||
'Source updates visual editor',
|
||||
);
|
||||
assert(
|
||||
document.querySelectorAll('#description li').length === 2,
|
||||
'Lists render after source switch',
|
||||
);
|
||||
for (const [name, selector] of [
|
||||
['Bullet list', 'ul li'],
|
||||
['Numbered list', 'ol li'],
|
||||
['Quote', 'blockquote'],
|
||||
['Inline code', 'code'],
|
||||
['Code block', 'pre code'],
|
||||
['Heading 2', 'h2'],
|
||||
['Heading 3', 'h3'],
|
||||
]) {
|
||||
state.value = `Check ${name}`;
|
||||
await tick();
|
||||
editor.commands.selectAll();
|
||||
await click(name);
|
||||
assert(
|
||||
document.querySelector(`#description ${selector}`),
|
||||
`${name} renders`,
|
||||
);
|
||||
}
|
||||
state.value = 'Link text';
|
||||
await tick();
|
||||
editor.commands.selectAll();
|
||||
await click('Add or edit link');
|
||||
assert(
|
||||
document.activeElement.id === 'description-link',
|
||||
'Link field receives focus',
|
||||
);
|
||||
const input = document.querySelector('#description-link');
|
||||
input.value = 'javascript:alert(1)';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await tick();
|
||||
await click('Apply link');
|
||||
assert(
|
||||
document
|
||||
.querySelector('#description-link-error')
|
||||
.textContent.includes('Enter a valid'),
|
||||
'Unsafe link rejected',
|
||||
);
|
||||
input.value = 'https://example.test/docs';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await tick();
|
||||
await click('Apply link');
|
||||
assert(
|
||||
state.value === '[Link text](https://example.test/docs)',
|
||||
'Selection becomes a Markdown link',
|
||||
);
|
||||
await click('Remove link');
|
||||
assert(state.value === 'Link text', 'Link can be removed');
|
||||
state.disabled = true;
|
||||
state.error = 'Description rejected.';
|
||||
await tick();
|
||||
assert(
|
||||
document
|
||||
.querySelector('#description')
|
||||
.getAttribute('contenteditable') === 'false',
|
||||
'Processing disables editing',
|
||||
);
|
||||
assert(
|
||||
document.querySelector('#description').getAttribute('aria-invalid') ===
|
||||
'true',
|
||||
'Server error reflected on editor',
|
||||
);
|
||||
assert(
|
||||
button('Bold').disabled && button('Markdown').disabled,
|
||||
'Processing disables controls',
|
||||
);
|
||||
assert(state.value === 'Link text', 'Validation retains draft');
|
||||
state.disabled = false;
|
||||
state.error = '';
|
||||
state.value = 'Reset draft';
|
||||
await tick();
|
||||
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
|
||||
editor.commands.selectAll();
|
||||
await click('Heading 1');
|
||||
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
|
||||
editor.commands.clearContent();
|
||||
await tick();
|
||||
assert(state.value === '', 'Clearing editor saves an empty description');
|
||||
state.value = '| A | B |\n| - | - |\n| a | b |';
|
||||
await tick();
|
||||
assert(
|
||||
document.querySelector('textarea#description').value === state.value,
|
||||
'Unsupported content retained as source',
|
||||
);
|
||||
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
|
||||
state.value =
|
||||
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
|
||||
await tick();
|
||||
state.result =
|
||||
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
||||
} catch (error) {
|
||||
state.result = `FAILED: ${error.message}`;
|
||||
console.error(error);
|
||||
}
|
||||
window.addEventListener('pagehide', () => app.unmount(), { once: true });
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Description fixture ready on port 4186');
|
||||
} else {
|
||||
try {
|
||||
const {
|
||||
safeDescriptionLink,
|
||||
supportsVisualDescription,
|
||||
parseDescription,
|
||||
renderDescriptionNode,
|
||||
} = await server.ssrLoadModule(
|
||||
'/resources/js/lib/ticket-description.ts',
|
||||
);
|
||||
const { default: Description } = await server.ssrLoadModule(
|
||||
'/resources/js/components/tickets/TicketDescription.vue',
|
||||
);
|
||||
const { default: Editor } = await server.ssrLoadModule(
|
||||
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
|
||||
);
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const render = (description) =>
|
||||
renderToString(
|
||||
createSSRApp({ render: () => h(Description, { description }) }),
|
||||
);
|
||||
const markdown =
|
||||
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
||||
assert.equal(supportsVisualDescription(markdown), true);
|
||||
const rendered = await render(markdown);
|
||||
for (const snippet of [
|
||||
'<h1>Title</h1>',
|
||||
'<strong>Bold</strong>',
|
||||
'<em>italic</em>',
|
||||
'<s>strike</s>',
|
||||
'<ul>',
|
||||
'Nested',
|
||||
'<ol start="3">',
|
||||
'<blockquote>',
|
||||
'<code>inline</code>',
|
||||
'<tag>',
|
||||
'rel="noopener noreferrer"',
|
||||
'<hr>',
|
||||
]) {
|
||||
assert.ok(
|
||||
rendered.includes(snippet),
|
||||
`Missing formatted output: ${snippet}`,
|
||||
);
|
||||
}
|
||||
assert.match(await render('First\nSecond'), /First<br>Second/);
|
||||
assert.match(
|
||||
await render('München 🚀 & details'),
|
||||
/München 🚀 & details/,
|
||||
);
|
||||
assert.match(
|
||||
await render('<img src=x onerror=alert(1)>'),
|
||||
/<img/,
|
||||
);
|
||||
for (const value of [
|
||||
'https://example.test/a?b=1&c=2',
|
||||
'http://example.test',
|
||||
'mailto:test@example.test',
|
||||
'/projects/1',
|
||||
'#section',
|
||||
]) {
|
||||
assert.equal(safeDescriptionLink(value), value);
|
||||
}
|
||||
for (const value of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'data:text/html,test',
|
||||
'vbscript:test',
|
||||
'//evil.test',
|
||||
'/\\evil.test',
|
||||
'\u0000https://example.test',
|
||||
'https://',
|
||||
'https://exa mple.test',
|
||||
'java\nscript:alert(1)',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(safeDescriptionLink(value), null);
|
||||
}
|
||||
for (const value of [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'| A | B |\n| - | - |\n| a | b |',
|
||||
'',
|
||||
'- [x] Done',
|
||||
'[unsafe](javascript:alert%281%29)',
|
||||
]) {
|
||||
assert.equal(supportsVisualDescription(value), false, value);
|
||||
const html = await render(value);
|
||||
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
|
||||
assert.ok(
|
||||
html.includes(
|
||||
value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>'),
|
||||
),
|
||||
'Original source is retained',
|
||||
);
|
||||
}
|
||||
const maliciousNode = {
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<script>bad</script>',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'javascript:bad()' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const safe = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h('div', {}, [renderDescriptionNode(maliciousNode)]),
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(safe, /<script|<a/);
|
||||
assert.match(safe, /<script>/);
|
||||
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
|
||||
let updates = 0;
|
||||
const initial = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(Editor, {
|
||||
id: 'description',
|
||||
label: 'Description',
|
||||
modelValue: markdown,
|
||||
error: 'Invalid description.',
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': () => updates++,
|
||||
}),
|
||||
}).use(createLocalization()),
|
||||
);
|
||||
assert.match(initial, /id="description"/);
|
||||
assert.match(initial, /aria-invalid="true"/);
|
||||
assert.match(initial, /id="description-error"/);
|
||||
assert.match(initial, /disabled/);
|
||||
assert.equal(
|
||||
updates,
|
||||
0,
|
||||
'Mounting must not rewrite existing descriptions',
|
||||
);
|
||||
console.log('Ticket description checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use League\Flysystem\CorruptedPathDetected;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FilesystemPathSecurityTest extends TestCase
|
||||
{
|
||||
#[DataProvider('corruptedPaths')]
|
||||
public function test_corrupted_paths_are_rejected(string $path): void
|
||||
{
|
||||
$this->expectException(CorruptedPathDetected::class);
|
||||
|
||||
(new WhitespacePathNormalizer)->normalizePath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function corruptedPaths(): array
|
||||
{
|
||||
return [
|
||||
'control character' => ["avatars/user\x00.svg"],
|
||||
'malformed UTF-8' => ["avatars/user\xFF.svg"],
|
||||
'control character hidden by malformed UTF-8' => ["avatars/user\xFF\x00.svg"],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_valid_unicode_paths_remain_supported(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'avatars/benützer.svg',
|
||||
(new WhitespacePathNormalizer)->normalizePath('./avatars//benützer.svg'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user