Merge pull request 'KAN-55: App links, external links and file selection' (#151) from feature/KAN-55-app-links-file-selection into main
Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
+104
-4
@@ -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<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?) {
|
||||
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<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() {
|
||||
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()
|
||||
|
||||
+15
@@ -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"
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
<string name="main_activity_title">"KanbanGit"</string>
|
||||
<string name="server_settings">Server settings</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>
|
||||
+18
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
||||
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 */; };
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
C8C02BC0E757897278EB954A /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<string>0.1.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<tauri::Wry> {
|
||||
.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::<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();
|
||||
@@ -347,6 +400,26 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
await copyText(key);
|
||||
if (isOpen.value && manualSetupKey.value === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
}
|
||||
copyError.value = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
await copyText(key);
|
||||
if (props.applicationKey === key) {
|
||||
copied.value = true;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user