From 9d472b5091cdfffbd287a4fb51b25add5b5e0905 Mon Sep 17 00:00:00 2001 From: Twyco Date: Fri, 18 Sep 2026 14:15:26 +0200 Subject: [PATCH] feat(KAN-55): support app links, file selection and clipboard --- .../java/de/kanbangit/desktop/MainActivity.kt | 108 +++++++++++++++++- .../de/kanbangit/desktop/NavigationPolicy.kt | 15 +++ .../app/src/main/res/values/strings.xml | 2 + .../kanbangit/desktop/NavigationPolicyTest.kt | 18 +++ .../kanbangit-native/WorkspacePolicy.swift | 7 ++ .../WorkspaceViewController.swift | 18 ++- .../en.lproj/InfoPlist.strings | 2 + .../gen/apple/Tests/WorkspaceTests.swift | 48 ++++++++ .../project.pbxproj | 14 +++ .../gen/apple/kanbangit-native_iOS/Info.plist | 4 + native/src-tauri/gen/apple/project.yml | 2 + native/src-tauri/src/desktop.rs | 75 +++++++++++- native/src-tauri/src/locale.rs | 3 + .../js/components/TwoFactorSetupModal.vue | 3 +- resources/js/lib/clipboard.ts | 64 +++++++++++ resources/js/pages/settings/Applications.vue | 3 +- tests/Fixtures/clipboard-browser.mjs | 105 +++++++++++++++++ tests/Fixtures/security-browser.mjs | 19 +++ 18 files changed, 501 insertions(+), 9 deletions(-) create mode 100644 native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/InfoPlist.strings create mode 100644 resources/js/lib/clipboard.ts create mode 100644 tests/Fixtures/clipboard-browser.mjs diff --git a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt index 3f24609..f19d5e9 100644 --- a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt @@ -1,6 +1,8 @@ package de.kanbangit.desktop import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri @@ -8,10 +10,12 @@ import android.net.http.SslError import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.Message import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.SslErrorHandler +import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest @@ -24,8 +28,10 @@ import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -50,6 +56,47 @@ class MainActivity : TauriActivity() { private var keyboardVisible = false private var initialized = false + private var fileCallback: ValueCallback>? = null + private var fileWorkspace: WebView? = null + private var filePage: String? = null + private var multipleFiles = false + private var filePickerActive = false + private val popups = mutableSetOf() + private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + filePickerActive = false + val callback = fileCallback + val owner = fileWorkspace + val page = filePage + fileCallback = null + fileWorkspace = null + filePage = null + val files = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data) + val valid = owner != null && owner === workspace && owner.url == page && + NavigationPolicy.allows(server, page.orEmpty()) && files?.isNotEmpty() == true && + (multipleFiles || files.size == 1) && files.all { uri -> + NavigationPolicy.selectedFile(uri.toString(), packageName) && runCatching { + contentResolver.openFileDescriptor(uri, "r")?.use { true } == true + }.getOrDefault(false) + } + callback?.onReceiveValue(if (valid) files else null) + } + + private fun cancelFileSelection() { + fileCallback?.onReceiveValue(null) + fileCallback = null + fileWorkspace = null + filePage = null + } + + private fun openExternal(url: String) { + if (!NavigationPolicy.external(url)) return + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, R.string.external_link_error, Toast.LENGTH_LONG).show() + } + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -144,6 +191,7 @@ class MainActivity : TauriActivity() { showWorkspace() return } + cancelFileSelection() val changed = address != preferences.getString("server", null) val clearSession = changed || preferences.getBoolean("clearing", false) if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) { @@ -187,18 +235,67 @@ class MainActivity : TauriActivity() { javaScriptEnabled = true domStorageEnabled = true allowFileAccess = false - allowContentAccess = false + allowContentAccess = true mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW javaScriptCanOpenWindowsAutomatically = false setSupportMultipleWindows(true) } CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false) - remote.webChromeClient = WebChromeClient() + remote.webChromeClient = object : WebChromeClient() { + override fun onShowFileChooser(view: WebView, callback: ValueCallback>, params: FileChooserParams): Boolean { + if (view !== workspace || !NavigationPolicy.allows(server, view.url.orEmpty()) || + params.mode !in listOf(FileChooserParams.MODE_OPEN, FileChooserParams.MODE_OPEN_MULTIPLE)) return false + // Keep one outstanding picker; stale results must never reach a different page. + if (filePickerActive) return false + fileCallback = callback + fileWorkspace = view + filePage = view.url + multipleFiles = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE + return try { + filePickerActive = true + filePicker.launch(params.createIntent().addCategory(Intent.CATEGORY_OPENABLE)) + true + } catch (_: ActivityNotFoundException) { + filePickerActive = false + cancelFileSelection() + Toast.makeText(this@MainActivity, R.string.file_picker_error, Toast.LENGTH_LONG).show() + true + } + } + + override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, result: Message): Boolean { + if (view !== workspace || !isUserGesture || !NavigationPolicy.allows(server, view.url.orEmpty())) return false + val popup = WebView(this@MainActivity) + popups.add(popup) + fun dispose() { + if (popups.remove(popup)) popup.destroy() + } + popup.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(child: WebView, request: WebResourceRequest): Boolean { + if (request.isForMainFrame && workspace === view) { + val url = request.url.toString() + if (NavigationPolicy.allows(server, url)) view.loadUrl(url) else openExternal(url) + } + child.post { dispose() } + return true + } + } + (result.obj as WebView.WebViewTransport).webView = popup + result.sendToTarget() + handler.postDelayed({ dispose() }, 10000) + return true + } + } remote.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = - !NavigationPolicy.allows(server, request.url.toString()) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url.toString() + if (NavigationPolicy.allows(server, url)) return false + if (workspace === view && request.isForMainFrame && request.hasGesture()) openExternal(url) + return true + } override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + if (workspace === view) cancelFileSelection() if (!NavigationPolicy.allows(server, url)) { view.stopLoading() fail(view) @@ -299,6 +396,9 @@ class MainActivity : TauriActivity() { override fun onDestroy() { handler.removeCallbacksAndMessages(null) + cancelFileSelection() + popups.forEach { it.destroy() } + popups.clear() workspace?.destroy() workspace = null super.onDestroy() diff --git a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt index de9163f..fb20ce6 100644 --- a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt @@ -31,6 +31,21 @@ object NavigationPolicy { target.rawUserInfo == null && !destination.contains('\\') }.getOrDefault(false) + fun external(destination: String): Boolean = runCatching { + val target = URI(destination) + target.scheme.lowercase(Locale.ROOT) in setOf("https", "http") && + !target.host.isNullOrBlank() && target.rawUserInfo == null && + (target.port == -1 || target.port in 1..65535) && + destination.none { it.isISOControl() || it == '\\' } + }.getOrDefault(false) + + fun selectedFile(destination: String, packageName: String): Boolean = runCatching { + val target = URI(destination) + target.scheme == "content" && !target.host.isNullOrBlank() && + target.rawUserInfo == null && target.port == -1 && + target.host != packageName && target.host != "$packageName.fileprovider" + }.getOrDefault(false) + fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when { keyboard -> "keyboard" launcher && ready -> "resume" diff --git a/native/src-tauri/gen/android/app/src/main/res/values/strings.xml b/native/src-tauri/gen/android/app/src/main/res/values/strings.xml index 6e22a22..12f5059 100644 --- a/native/src-tauri/gen/android/app/src/main/res/values/strings.xml +++ b/native/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -3,4 +3,6 @@ "KanbanGit" Server settings Update Android System WebView to connect to your workspace. + No browser is available to open this link. + No file picker is available. Please try again. \ No newline at end of file diff --git a/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt b/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt index 4c477bb..8244cb8 100644 --- a/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt +++ b/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt @@ -23,6 +23,24 @@ class NavigationPolicyTest { assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login")) } + @Test fun externalLinksNeverBecomeServerNavigation() { + val server = "https://example.com/" + for (url in listOf("https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other")) { + assertTrue(url, NavigationPolicy.external(url)) + assertFalse(url, NavigationPolicy.allows(server, url)) + } + for (url in listOf("https://user:password@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "intent://example.com/", "file:///etc/passwd", "content://provider/file", "data:text/html,test", "mailto:user@example.com", "https://example.com\\evil")) { + assertFalse(url, NavigationPolicy.external(url)) + } + } + + @Test fun filePickerAcceptsOnlyExternalContentProviders() { + assertTrue(NavigationPolicy.selectedFile("content://com.android.providers.media.documents/document/image%3A1", "de.kanbangit.desktop")) + for (url in listOf("file:///data/data/de.kanbangit.desktop/session", "https://example.com/avatar.png", "content://de.kanbangit.desktop.fileprovider/private/key", "content://de.kanbangit.desktop/private/key", "content://user@provider/path", "content://provider:123/path", "content:/missing-provider")) { + assertFalse(url, NavigationPolicy.selectedFile(url, "de.kanbangit.desktop")) + } + } + @Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() { assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true)) assertEquals("resume", NavigationPolicy.backAction(false, true, true, true)) diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift index 730d682..f7f05d0 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift @@ -29,6 +29,13 @@ struct WorkspaceServer: Codable, Equatable { && destination.user == nil && destination.password == nil } + static func allowsExternal(_ destination: URL) -> Bool { + ["https", "http"].contains(destination.scheme?.lowercased() ?? "") + && destination.host?.isEmpty == false + && (destination.port.map({ (1...65535).contains($0) }) ?? true) + && destination.user == nil && destination.password == nil + } + static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer { current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID()) } diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift index e8d90a1..285c231 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift @@ -39,6 +39,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU private let connectButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system) private let toolbar = UIToolbar() + private var historyObservation: NSKeyValueObservation? private var backButton: UIBarButtonItem! private var appearance: UISegmentedControl! @@ -192,6 +193,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return } defaults.set(data, forKey: serverKey) timeout?.cancel() + historyObservation = nil workspace?.stopLoading() workspace?.navigationDelegate = nil workspace?.removeFromSuperview() @@ -215,6 +217,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) workspace = webview + historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in + self?.backButton.isEnabled = view.canGoBack + } view.endEditing(true) address.text = url.absoluteString beginNavigation() @@ -261,8 +266,17 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU @objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } } func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard webView === workspace, let url = action.request.url, - server?.allows(url) == true else { decisionHandler(.cancel); return } + guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return } + guard server?.allows(url) == true else { + decisionHandler(.cancel) + if action.navigationType == .linkActivated, + action.sourceFrame.isMainFrame, + action.targetFrame == nil || action.targetFrame?.isMainFrame == true, + WorkspaceServer.allowsExternal(url) { + UIApplication.shared.open(url) + } + return + } if action.targetFrame == nil { decisionHandler(.cancel) webView.load(action.request) diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/InfoPlist.strings b/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..7e1c698 --- /dev/null +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Choose a profile picture using your camera."; +"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library."; diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift index 95d8602..4ee2972 100644 --- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift +++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift @@ -17,6 +17,54 @@ final class WorkspaceTests: XCTestCase { } } + func testExternalLinksStayOutsideTheSelectedServer() throws { + let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID()) + for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] { + let url = try XCTUnwrap(URL(string: value)) + XCTAssertTrue(WorkspaceServer.allowsExternal(url)) + XCTAssertFalse(server.allows(url)) + } + for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] { + XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value) + } + } + + @MainActor + func testBackButtonTracksSameDocumentHistory() async throws { + let name = "KAN55-history-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let controller = WorkspaceViewController(defaults: defaults) + controller.loadViewIfNeeded() + let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/")) + controller.open(url) + let webview = try XCTUnwrap(controller.workspace) + webview.stopLoading() + webview.loadHTMLString("", baseURL: url) + var loaded = false + for _ in 0..<50 { + if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"), + value as? String == "unsaved" { loaded = true; break } + try await Task.sleep(for: .milliseconds(100)) + } + XCTAssertTrue(loaded) + _ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')") + let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first) + for _ in 0..<20 { + if toolbar.items?.first?.isEnabled == true { break } + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertTrue(webview.canGoBack) + XCTAssertTrue(toolbar.items?.first?.isEnabled == true) + webview.goBack() + for _ in 0..<20 { + if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { break } + try await Task.sleep(for: .milliseconds(50)) + } + let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value") + XCTAssertEqual(draft as? String, "unsaved") + } + func testServerChangesNeverReusePreviousProfile() throws { let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/")) let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/")) diff --git a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj index 03f3633..ec3205a 100644 --- a/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj +++ b/native/src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; }; C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; }; C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; }; + 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 */; }; EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; }; @@ -45,6 +46,7 @@ 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; 31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = ""; }; 554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = ""; }; 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; @@ -53,6 +55,7 @@ 91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = ""; }; + 9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = ""; }; 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 = ""; }; @@ -137,6 +140,7 @@ 335B1262A9019C70C7F887A5 /* src */ = { isa = PBXGroup; children = ( + 9C56BE6890F0452F69D64795 /* android.rs */, 05A753935B6CB0316DA56D96 /* desktop.rs */, CAF08AD60D2F29A879CDAEBB /* ios.rs */, 2B96972F58CC158AB3DFF94A /* lib.rs */, @@ -191,6 +195,7 @@ 554AB059B4228085924F4959 /* WorkspacePolicy.swift */, EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */, 2A11A26F0082A5075CD710C6 /* bindings */, + 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */, C8C02BC0E757897278EB954A /* Localizable.strings */, ); path = "kanbangit-native"; @@ -299,6 +304,7 @@ buildActionMask = 2147483647; files = ( 0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */, + C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */, 34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */, A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */, ); @@ -370,6 +376,14 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 47C531812A31207F97A89C5F /* en */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; C8C02BC0E757897278EB954A /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( diff --git a/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist b/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist index e0b1335..d20aab9 100644 --- a/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist +++ b/native/src-tauri/gen/apple/kanbangit-native_iOS/Info.plist @@ -20,6 +20,10 @@ 0.1.0 LSRequiresIPhoneOS + NSCameraUsageDescription + Choose a profile picture using your camera. + NSPhotoLibraryUsageDescription + Choose a profile picture from your photo library. UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/native/src-tauri/gen/apple/project.yml b/native/src-tauri/gen/apple/project.yml index 155ef24..d2d3e82 100644 --- a/native/src-tauri/gen/apple/project.yml +++ b/native/src-tauri/gen/apple/project.yml @@ -41,6 +41,8 @@ targets: info: path: kanbangit-native_iOS/Info.plist properties: + NSCameraUsageDescription: Choose a profile picture using your camera. + NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library. LSRequiresIPhoneOS: true UILaunchStoryboardName: LaunchScreen UIRequiredDeviceCapabilities: [arm64, metal] diff --git a/native/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs index ac50ba1..9aac30c 100644 --- a/native/src-tauri/src/desktop.rs +++ b/native/src-tauri/src/desktop.rs @@ -39,6 +39,26 @@ fn allows_server_navigation(server: &Url, destination: &Url) -> bool { && destination.password().is_none() } +fn allows_external_navigation(destination: &Url) -> bool { + matches!(destination.scheme(), "https" | "http") + && destination.host_str().is_some() + && destination.username().is_empty() + && destination.password().is_none() +} + +fn open_external(destination: &Url) { + if allows_external_navigation(destination) { + // Arguments are passed directly to Launch Services, never through a shell. + let destination = destination.to_string(); + std::thread::spawn(move || { + let _ = std::process::Command::new("/usr/bin/open") + .arg("--") + .arg(destination) + .status(); + }); + } +} + fn allows_launcher_navigation(destination: &Url) -> bool { destination.scheme() == "tauri" && destination.host_str() == Some("localhost") @@ -146,6 +166,9 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { let handle = app.clone(); let navigation_label = label.clone(); let server_url = server.url.clone(); + let popup_server = server_url.clone(); + let popup_handle = app.clone(); + let popup_label = label.clone(); let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) .title(en::APP_NAME) .inner_size(1280.0, 850.0) @@ -155,12 +178,22 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { .data_store_identifier(server.profile) .on_navigation(move |destination| { if !allows_server_navigation(&server_url, destination) { + open_external(destination); return false; } begin_navigation(&handle, &navigation_label); true }) - .on_new_window(|_, _| NewWindowResponse::Deny) + .on_new_window(move |destination, _| { + if allows_server_navigation(&popup_server, &destination) { + if let Some(window) = popup_handle.get_webview_window(&popup_label) { + let _ = window.navigate(destination); + } + } else { + open_external(&destination); + } + NewWindowResponse::Deny + }) .on_page_load(|window, payload| { if payload.event() != PageLoadEvent::Finished { return; @@ -277,11 +310,31 @@ pub fn builder() -> tauri::Builder { .select_all() .build()?, ) + .item( + &SubmenuBuilder::new(app, en::NAVIGATION) + .text("workspace-back", en::BACK) + .text("workspace-forward", en::FORWARD) + .build()?, + ) .build()?; app.set_menu(menu)?; app.on_menu_event(|app, event| { if event.id().as_ref() == "server-settings" { show_launcher(app); + } else if let Some(script) = match event.id().as_ref() { + "workspace-back" => Some("window.history.back()"), + "workspace-forward" => Some("window.history.forward()"), + _ => None, + } { + let label = app + .state::>() + .lock() + .unwrap() + .label + .clone(); + if let Some(window) = label.and_then(|label| app.get_webview_window(&label)) { + let _ = window.eval(script); + } } }); let handle = app.handle().clone(); @@ -347,6 +400,26 @@ pub fn builder() -> tauri::Builder { mod tests { use super::*; + #[test] + fn external_links_accept_only_web_urls_without_credentials() { + for input in [ + "https://git.example.com/org/repo/pulls/1#diff", + "http://git.example.com/repo", + ] { + assert!(allows_external_navigation(&Url::parse(input).unwrap())); + } + for input in [ + "https://user:secret@git.example.com/", + "file:///etc/passwd", + "javascript:alert(1)", + "tauri://localhost/action/open", + "mailto:user@example.com", + "data:text/html,test", + ] { + assert!(!allows_external_navigation(&Url::parse(input).unwrap())); + } + } + #[test] fn navigation_is_limited_to_the_selected_https_origin() { let server = validate_url("https://example.com:8443/app/").unwrap(); diff --git a/native/src-tauri/src/locale.rs b/native/src-tauri/src/locale.rs index 831e604..ce2b18a 100644 --- a/native/src-tauri/src/locale.rs +++ b/native/src-tauri/src/locale.rs @@ -1,5 +1,8 @@ pub mod en { pub const APP_NAME: &str = "KanbanGit"; pub const SERVER_SETTINGS: &str = "Server settings…"; + pub const NAVIGATION: &str = "Navigation"; + pub const BACK: &str = "Back"; + pub const FORWARD: &str = "Forward"; pub const EDIT: &str = "Edit"; } diff --git a/resources/js/components/TwoFactorSetupModal.vue b/resources/js/components/TwoFactorSetupModal.vue index 6832549..5f8c08a 100644 --- a/resources/js/components/TwoFactorSetupModal.vue +++ b/resources/js/components/TwoFactorSetupModal.vue @@ -19,6 +19,7 @@ import { Label } from '@/components/ui/label'; import { useFormProtection } from '@/composables/useFormProtection'; import { useLocalization } from '@/composables/useLocalization'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; +import { copyText } from '@/lib/clipboard'; import { confirm } from '@/routes/two-factor'; const props = defineProps<{ @@ -94,7 +95,7 @@ async function copyKey(): Promise { } copyError.value = false; try { - await navigator.clipboard.writeText(key); + await copyText(key); if (isOpen.value && manualSetupKey.value === key) { copied.value = true; } diff --git a/resources/js/lib/clipboard.ts b/resources/js/lib/clipboard.ts new file mode 100644 index 0000000..54397da --- /dev/null +++ b/resources/js/lib/clipboard.ts @@ -0,0 +1,64 @@ +/** Copy only after a user action; WebViews may not expose the async Clipboard API. */ +export async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Embedded browsers can deny the async API while allowing a selected copy. + } + } + + const active = document.activeElement; + const selection = window.getSelection(); + const ranges = selection + ? Array.from({ length: selection.rangeCount }, (_, index) => + selection.getRangeAt(index).cloneRange(), + ) + : []; + const inputSelection = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement + ? ([ + active.selectionStart, + active.selectionEnd, + active.selectionDirection, + ] as const) + : null; + const field = document.createElement('textarea'); + field.value = text; + field.readOnly = true; + field.style.cssText = + 'position:fixed;opacity:0;pointer-events:none;font-size:16px'; + (active?.closest('[role="dialog"]') ?? document.body).append(field); + try { + field.focus({ preventScroll: true }); + field.select(); + field.setSelectionRange(0, text.length); + if (!document.execCommand('copy')) { + throw new Error('Clipboard access unavailable'); + } + } finally { + field.remove(); + if (active instanceof HTMLElement) { + active.focus({ preventScroll: true }); + } + if (selection) { + selection.removeAllRanges(); + ranges.forEach((range) => selection.addRange(range)); + } + if ( + inputSelection && + inputSelection[0] !== null && + inputSelection[1] !== null && + (active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement) + ) { + active.setSelectionRange( + inputSelection[0], + inputSelection[1], + inputSelection[2] ?? undefined, + ); + } + } +} diff --git a/resources/js/pages/settings/Applications.vue b/resources/js/pages/settings/Applications.vue index 79a4947..e476499 100644 --- a/resources/js/pages/settings/Applications.vue +++ b/resources/js/pages/settings/Applications.vue @@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label'; import { useFormProtection } from '@/composables/useFormProtection'; import { useLocalization } from '@/composables/useLocalization'; import AppLayout from '@/layouts/AppLayout.vue'; +import { copyText } from '@/lib/clipboard'; import SettingsLayout from '@/layouts/settings/Layout.vue'; import { edit } from '@/routes/applications/index'; import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types'; @@ -83,7 +84,7 @@ async function copyKey(): Promise { } copyError.value = false; try { - await navigator.clipboard.writeText(key); + await copyText(key); if (props.applicationKey === key) { copied.value = true; } diff --git a/tests/Fixtures/clipboard-browser.mjs b/tests/Fixtures/clipboard-browser.mjs new file mode 100644 index 0000000..c30cd43 --- /dev/null +++ b/tests/Fixtures/clipboard-browser.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { createServer } from 'vite'; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const server = await createServer({ + configFile: false, + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'custom', +}); +server.middlewares.use((request, response, next) => { + if (request.url !== '/') { + return next(); + } + response.setHeader('Content-Type', 'text/html'); + response.end(` +
+ `); +}); +await server.listen(); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const context = await browser.newContext({ + permissions: ['clipboard-read', 'clipboard-write'], +}); +const page = await context.newPage(); +try { + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/`); + await page.waitForFunction(() => typeof window.copyText === 'function'); + for (const mode of ['modern', 'absent', 'denied']) { + await page.evaluate(async (mode) => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: + mode === 'modern' + ? window.originalClipboard + : mode === 'absent' + ? undefined + : { + writeText: () => + Promise.reject( + new DOMException( + 'Denied', + 'NotAllowedError', + ), + ), + }, + }); + await window.originalClipboard.writeText('previous'); + // Simulate the modal's focus boundary used by the two-factor setup dialog. + document.onfocusin = (event) => { + if (!event.target.closest('[role="dialog"]')) { + document.querySelector('#draft').focus(); + } + }; + }, mode); + await page.getByRole('button', { name: 'Copy', exact: true }).click(); + await page.waitForFunction(() => window.result === 'copied'); + assert.equal( + await page.evaluate(() => window.originalClipboard.readText()), + 'kan55 selected text', + mode, + ); + assert.deepEqual( + await page.evaluate(() => { + const input = document.querySelector('#draft'); + return [ + document.activeElement === input, + input.value, + input.selectionStart, + input.selectionEnd, + document.querySelectorAll('textarea').length, + ]; + }), + [true, 'unsaved draft', 1, 5, 0], + mode, + ); + } + await page.evaluate(() => { + document.execCommand = () => false; + }); + await page.getByRole('button', { name: 'Copy', exact: true }).click(); + await page.waitForFunction(() => window.result === 'failed'); + assert.equal(await page.locator('textarea').count(), 0); + assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft'); + console.log( + 'Clipboard browser checks passed: async API, unavailable/denied API fallback, modal focus, input selection and failure cleanup.', + ); +} finally { + await browser.close(); + await server.close(); +} diff --git a/tests/Fixtures/security-browser.mjs b/tests/Fixtures/security-browser.mjs index 28b3c1b..6a25320 100644 --- a/tests/Fixtures/security-browser.mjs +++ b/tests/Fixtures/security-browser.mjs @@ -82,6 +82,25 @@ try { ), true, ); + await page.evaluate(() => { + window.savedClipboard = navigator.clipboard; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + }); + await button('Copy key').click(); + assert.equal( + await page.evaluate(() => window.savedClipboard.readText()), + 'MANUALSETUPTESTKEY', + ); + assert.equal(await dialog().locator('textarea').count(), 0); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: window.savedClipboard, + }); + }); await button('Continue').click(); const code = page.getByLabel('Authentication code', { exact: true,