feat(KAN-55): support app links, file selection and clipboard
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-26 20:58:29 +02:00
parent 8bed02a484
commit 9d472b5091
18 changed files with 501 additions and 9 deletions
@@ -1,6 +1,8 @@
package de.kanbangit.desktop package de.kanbangit.desktop
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
@@ -8,10 +10,12 @@ import android.net.http.SslError
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
@@ -24,8 +28,10 @@ import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -50,6 +56,47 @@ class MainActivity : TauriActivity() {
private var keyboardVisible = false private var keyboardVisible = false
private var initialized = false private var initialized = false
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var fileWorkspace: WebView? = null
private var filePage: String? = null
private var multipleFiles = false
private var filePickerActive = false
private val popups = mutableSetOf<WebView>()
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?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -144,6 +191,7 @@ class MainActivity : TauriActivity() {
showWorkspace() showWorkspace()
return return
} }
cancelFileSelection()
val changed = address != preferences.getString("server", null) val changed = address != preferences.getString("server", null)
val clearSession = changed || preferences.getBoolean("clearing", false) val clearSession = changed || preferences.getBoolean("clearing", false)
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) { if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
@@ -187,18 +235,67 @@ class MainActivity : TauriActivity() {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
allowFileAccess = false allowFileAccess = false
allowContentAccess = false allowContentAccess = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true) setSupportMultipleWindows(true)
} }
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false) CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
remote.webChromeClient = WebChromeClient() remote.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, 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() { remote.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
!NavigationPolicy.allows(server, request.url.toString()) 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?) { override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (workspace === view) cancelFileSelection()
if (!NavigationPolicy.allows(server, url)) { if (!NavigationPolicy.allows(server, url)) {
view.stopLoading() view.stopLoading()
fail(view) fail(view)
@@ -299,6 +396,9 @@ class MainActivity : TauriActivity() {
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
cancelFileSelection()
popups.forEach { it.destroy() }
popups.clear()
workspace?.destroy() workspace?.destroy()
workspace = null workspace = null
super.onDestroy() super.onDestroy()
@@ -31,6 +31,21 @@ object NavigationPolicy {
target.rawUserInfo == null && !destination.contains('\\') target.rawUserInfo == null && !destination.contains('\\')
}.getOrDefault(false) }.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 { fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
keyboard -> "keyboard" keyboard -> "keyboard"
launcher && ready -> "resume" launcher && ready -> "resume"
@@ -3,4 +3,6 @@
<string name="main_activity_title">"KanbanGit"</string> <string name="main_activity_title">"KanbanGit"</string>
<string name="server_settings">Server settings</string> <string name="server_settings">Server settings</string>
<string name="update_webview">Update Android System WebView to connect to your workspace.</string> <string name="update_webview">Update Android System WebView to connect to your workspace.</string>
<string name="external_link_error">No browser is available to open this link.</string>
<string name="file_picker_error">No file picker is available. Please try again.</string>
</resources> </resources>
@@ -23,6 +23,24 @@ class NavigationPolicyTest {
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login")) 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() { @Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true)) assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true)) assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
@@ -29,6 +29,13 @@ struct WorkspaceServer: Codable, Equatable {
&& destination.user == nil && destination.password == nil && 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 { static func selecting(_ url: URL, current: WorkspaceServer?) -> WorkspaceServer {
current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID()) current?.url == url ? current! : WorkspaceServer(url: url, profile: UUID())
} }
@@ -39,6 +39,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private let connectButton = UIButton(type: .system) private let connectButton = UIButton(type: .system)
private let resumeButton = UIButton(type: .system) private let resumeButton = UIButton(type: .system)
private let toolbar = UIToolbar() private let toolbar = UIToolbar()
private var historyObservation: NSKeyValueObservation?
private var backButton: UIBarButtonItem! private var backButton: UIBarButtonItem!
private var appearance: UISegmentedControl! 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 } guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
defaults.set(data, forKey: serverKey) defaults.set(data, forKey: serverKey)
timeout?.cancel() timeout?.cancel()
historyObservation = nil
workspace?.stopLoading() workspace?.stopLoading()
workspace?.navigationDelegate = nil workspace?.navigationDelegate = nil
workspace?.removeFromSuperview() workspace?.removeFromSuperview()
@@ -215,6 +217,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
]) ])
workspace = webview workspace = webview
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
self?.backButton.isEnabled = view.canGoBack
}
view.endEditing(true) view.endEditing(true)
address.text = url.absoluteString address.text = url.absoluteString
beginNavigation() beginNavigation()
@@ -261,8 +266,17 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } } @objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard webView === workspace, let url = action.request.url, guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
server?.allows(url) == true 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 { if action.targetFrame == nil {
decisionHandler(.cancel) decisionHandler(.cancel)
webView.load(action.request) webView.load(action.request)
@@ -0,0 +1,2 @@
"NSCameraUsageDescription" = "Choose a profile picture using your camera.";
"NSPhotoLibraryUsageDescription" = "Choose a profile picture from your photo library.";
@@ -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("<html><body><input id='draft' value='unsaved'></body></html>", 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 { func testServerChangesNeverReusePreviousProfile() throws {
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/")) let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/")) let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
@@ -22,6 +22,7 @@
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; }; B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; }; C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
C44BDCDEA454DBD106D51C9C /* WorkspaceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */; }; 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 */; }; CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; }; D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; }; 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; }; 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>"; }; 2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; }; 4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; }; 554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; 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 = "<group>"; }; 91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 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 = "<group>"; }; 946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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>"; }; AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
@@ -137,6 +140,7 @@
335B1262A9019C70C7F887A5 /* src */ = { 335B1262A9019C70C7F887A5 /* src */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
9C56BE6890F0452F69D64795 /* android.rs */,
05A753935B6CB0316DA56D96 /* desktop.rs */, 05A753935B6CB0316DA56D96 /* desktop.rs */,
CAF08AD60D2F29A879CDAEBB /* ios.rs */, CAF08AD60D2F29A879CDAEBB /* ios.rs */,
2B96972F58CC158AB3DFF94A /* lib.rs */, 2B96972F58CC158AB3DFF94A /* lib.rs */,
@@ -191,6 +195,7 @@
554AB059B4228085924F4959 /* WorkspacePolicy.swift */, 554AB059B4228085924F4959 /* WorkspacePolicy.swift */,
EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */, EE63F98563975EF4FF2A10FB /* WorkspaceViewController.swift */,
2A11A26F0082A5075CD710C6 /* bindings */, 2A11A26F0082A5075CD710C6 /* bindings */,
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */,
C8C02BC0E757897278EB954A /* Localizable.strings */, C8C02BC0E757897278EB954A /* Localizable.strings */,
); );
path = "kanbangit-native"; path = "kanbangit-native";
@@ -299,6 +304,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */, 0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */,
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */, 34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */, A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
); );
@@ -370,6 +376,14 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
47C531812A31207F97A89C5F /* en */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
C8C02BC0E757897278EB954A /* Localizable.strings */ = { C8C02BC0E757897278EB954A /* Localizable.strings */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
children = ( children = (
@@ -20,6 +20,10 @@
<string>0.1.0</string> <string>0.1.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Choose a profile picture using your camera.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose a profile picture from your photo library.</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
+2
View File
@@ -41,6 +41,8 @@ targets:
info: info:
path: kanbangit-native_iOS/Info.plist path: kanbangit-native_iOS/Info.plist
properties: properties:
NSCameraUsageDescription: Choose a profile picture using your camera.
NSPhotoLibraryUsageDescription: Choose a profile picture from your photo library.
LSRequiresIPhoneOS: true LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal] UIRequiredDeviceCapabilities: [arm64, metal]
+74 -1
View File
@@ -39,6 +39,26 @@ fn allows_server_navigation(server: &Url, destination: &Url) -> bool {
&& destination.password().is_none() && 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 { fn allows_launcher_navigation(destination: &Url) -> bool {
destination.scheme() == "tauri" destination.scheme() == "tauri"
&& destination.host_str() == Some("localhost") && destination.host_str() == Some("localhost")
@@ -146,6 +166,9 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let handle = app.clone(); let handle = app.clone();
let navigation_label = label.clone(); let navigation_label = label.clone();
let server_url = server.url.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)) let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url))
.title(en::APP_NAME) .title(en::APP_NAME)
.inner_size(1280.0, 850.0) .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) .data_store_identifier(server.profile)
.on_navigation(move |destination| { .on_navigation(move |destination| {
if !allows_server_navigation(&server_url, destination) { if !allows_server_navigation(&server_url, destination) {
open_external(destination);
return false; return false;
} }
begin_navigation(&handle, &navigation_label); begin_navigation(&handle, &navigation_label);
true 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| { .on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished { if payload.event() != PageLoadEvent::Finished {
return; return;
@@ -277,11 +310,31 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
.select_all() .select_all()
.build()?, .build()?,
) )
.item(
&SubmenuBuilder::new(app, en::NAVIGATION)
.text("workspace-back", en::BACK)
.text("workspace-forward", en::FORWARD)
.build()?,
)
.build()?; .build()?;
app.set_menu(menu)?; app.set_menu(menu)?;
app.on_menu_event(|app, event| { app.on_menu_event(|app, event| {
if event.id().as_ref() == "server-settings" { if event.id().as_ref() == "server-settings" {
show_launcher(app); 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::<Mutex<Connection>>()
.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(); let handle = app.handle().clone();
@@ -347,6 +400,26 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
mod tests { mod tests {
use super::*; 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] #[test]
fn navigation_is_limited_to_the_selected_https_origin() { fn navigation_is_limited_to_the_selected_https_origin() {
let server = validate_url("https://example.com:8443/app/").unwrap(); let server = validate_url("https://example.com:8443/app/").unwrap();
+3
View File
@@ -1,5 +1,8 @@
pub mod en { pub mod en {
pub const APP_NAME: &str = "KanbanGit"; pub const APP_NAME: &str = "KanbanGit";
pub const SERVER_SETTINGS: &str = "Server settings…"; 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"; pub const EDIT: &str = "Edit";
} }
@@ -19,6 +19,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { copyText } from '@/lib/clipboard';
import { confirm } from '@/routes/two-factor'; import { confirm } from '@/routes/two-factor';
const props = defineProps<{ const props = defineProps<{
@@ -94,7 +95,7 @@ async function copyKey(): Promise<void> {
} }
copyError.value = false; copyError.value = false;
try { try {
await navigator.clipboard.writeText(key); await copyText(key);
if (isOpen.value && manualSetupKey.value === key) { if (isOpen.value && manualSetupKey.value === key) {
copied.value = true; copied.value = true;
} }
+64
View File
@@ -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<void> {
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,
);
}
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization'; import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { copyText } from '@/lib/clipboard';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/applications/index'; import { edit } from '@/routes/applications/index';
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
@@ -83,7 +84,7 @@ async function copyKey(): Promise<void> {
} }
copyError.value = false; copyError.value = false;
try { try {
await navigator.clipboard.writeText(key); await copyText(key);
if (props.applicationKey === key) { if (props.applicationKey === key) {
copied.value = true; copied.value = true;
} }
+105
View File
@@ -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(`<!doctype html><html><body>
<div role="dialog"><input id="draft" value="unsaved draft"><button id="copy">Copy</button></div>
<script type="module">
import { copyText } from '/resources/js/lib/clipboard.ts';
window.originalClipboard = navigator.clipboard;
window.copyText = copyText;
document.querySelector('#copy').onclick = async () => {
window.result = 'pending';
const input = document.querySelector('#draft');
input.focus(); input.setSelectionRange(1, 5);
try { await copyText('kan55 selected text'); window.result = 'copied'; }
catch { window.result = 'failed'; }
};
</script></body></html>`);
});
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();
}
+19
View File
@@ -82,6 +82,25 @@ try {
), ),
true, 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(); await button('Continue').click();
const code = page.getByLabel('Authentication code', { const code = page.getByLabel('Authentication code', {
exact: true, exact: true,