diff --git a/.gitignore b/.gitignore index d5149e8..5a6cc81 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ yarn-error.log /native/src-tauri/gen/apple/ExportOptions.plist /native/src-tauri/gen/apple/**/*.xcuserstate /native/src-tauri/gen/apple/**/xcuserdata/ +!/native/src-tauri/gen/android/ diff --git a/AGENTS.md b/AGENTS.md index 3bebeb7..91897c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,9 @@ +# Working Agreements + +## Missing programs and SDKs + +If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project. + === foundation rules === diff --git a/native/package.json b/native/package.json index 2bbd856..57ed8a7 100644 --- a/native/package.json +++ b/native/package.json @@ -15,7 +15,9 @@ "build:ios:device": "tauri ios build --debug --target aarch64 --export-method debugging --archive-only", "open:ios": "tauri ios dev --open --no-dev-server", "prepare:ios": "xcodegen generate --spec src-tauri/gen/apple/project.yml", - "test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test" + "test:ios": "xcodebuild -project src-tauri/gen/apple/kanbangit-native.xcodeproj -scheme kanbangit-native_iOS -configuration debug -destination \"platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?Set IOS_SIMULATOR_ID to an installed simulator UUID}\" -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test", + "dev:android": "tauri android dev", + "build:android": "tauri android build --debug --apk --target aarch64" }, "devDependencies": { "@tauri-apps/cli": "2.11.4" diff --git a/native/src-tauri/gen/android/.editorconfig b/native/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/native/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/native/src-tauri/gen/android/.gitignore b/native/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..99a913d --- /dev/null +++ b/native/src-tauri/gen/android/.gitignore @@ -0,0 +1,26 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle +/app/tauri.build.gradle.kts +/app/src/main/jniLibs +/app/src/main/java/de/kanbangit/desktop/generated + +.kotlin +/app/tauri.properties diff --git a/native/src-tauri/gen/android/app/.gitignore b/native/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/native/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/native/src-tauri/gen/android/app/build.gradle.kts b/native/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..3bc7e35 --- /dev/null +++ b/native/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,76 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + buildToolsVersion = "36.0.0" + namespace = "de.kanbangit.desktop" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "de.kanbangit.desktop" + minSdk = 24 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Tauri exits the process when its last activity is destroyed; report results first. + testInstrumentationRunnerArguments["waitForActivitiesToComplete"] = "false" + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "false" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { + jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") diff --git a/native/src-tauri/gen/android/app/proguard-rules.pro b/native/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/native/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt b/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt new file mode 100644 index 0000000..9aa4bef --- /dev/null +++ b/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt @@ -0,0 +1,116 @@ +package de.kanbangit.desktop + +import android.content.Context +import android.os.SystemClock +import android.view.InputDevice +import android.view.MotionEvent +import android.view.View +import android.webkit.CookieManager +import android.webkit.WebView +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class PlatformTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + private fun evaluate(view: WebView, script: String): String { + val result = AtomicReference() + val latch = CountDownLatch(1) + instrumentation.runOnMainSync { view.evaluateJavascript(script) { result.set(it); latch.countDown() } } + assertTrue("JavaScript callback timed out", latch.await(10, TimeUnit.SECONDS)) + return result.get() + } + + private fun waitFor(condition: () -> Boolean) { + val end = System.currentTimeMillis() + 25000 + while (System.currentTimeMillis() < end) { + if (condition()) return + Thread.sleep(100) + } + fail("Platform condition timed out") + } + + private fun tapCenter(view: View) { + val position = IntArray(2) + instrumentation.runOnMainSync { + view.getLocationOnScreen(position) + position[0] += view.width / 2 + position[1] += view.height / 2 + } + val downTime = SystemClock.uptimeMillis() + for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, position[0].toFloat(), position[1].toFloat(), 0) + event.source = InputDevice.SOURCE_TOUCHSCREEN + try { + instrumentation.sendPointerSync(event) + } finally { + event.recycle() + } + } + instrumentation.waitForIdleSync() + } + + @Test fun workspacePreservesFormsAndIsolatesServers() { + instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit() + val scenario = ActivityScenario.launch(MainActivity::class.java) + Thread.sleep(1500) + scenario.onActivity { it.openWorkspace("https://kanbangit.de/") } + val remote = AtomicReference() + waitFor { + var visible = false + scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE } + visible && remote.get() != null && evaluate(remote.get(), "document.readyState === 'complete' && document.querySelector('#app')?.innerText.trim().length > 100") == "true" + } + val view = remote.get() + assertEquals("true", evaluate(view, "typeof window.nativeLauncher === 'undefined' && typeof window.__TAURI_INTERNALS__ === 'undefined'")) + assertEquals("true", evaluate(view, """ + (() => { + const input = document.createElement('input'); + input.id = 'kan53-input'; + input.value = 'retained form'; + document.body.append(input); + const button = document.createElement('button'); + button.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:2147483647'; + button.textContent = 'Navigate'; + button.onclick = () => { + history.pushState({}, '', '/kan53-history'); + button.remove(); + }; + document.body.append(button); + return true; + })() + """.trimIndent())) + tapCenter(view) + waitFor { evaluate(view, "location.pathname === '/kan53-history'") == "true" } + waitFor { + var canGoBack = false + scenario.onActivity { canGoBack = view.canGoBack() } + canGoBack + } + scenario.moveToState(Lifecycle.State.CREATED) + scenario.moveToState(Lifecycle.State.RESUMED) + assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value")) + scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + waitFor { evaluate(view, "location.pathname !== '/kan53-history'") == "true" } + assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value")) + evaluate(view, "document.cookie = 'kan53_fixture=secret; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan53_fixture', 'secret')") + scenario.onActivity { it.openWorkspace("https://127.0.0.1:9/") } + waitFor { + val cookie = AtomicReference() + instrumentation.runOnMainSync { cookie.set(CookieManager.getInstance().getCookie("https://kanbangit.de/")) } + cookie.get()?.contains("kan53_fixture") != true + } + scenario.onActivity { assertNotSame(view, it.workspace) } + // Let instrumentation finish the process after reporting, rather than exiting via Tauri. + scenario.moveToState(Lifecycle.State.CREATED) + } +} diff --git a/native/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/native/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4bd66b6 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..f19d5e9 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt @@ -0,0 +1,406 @@ +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 +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 +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +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 +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import org.json.JSONObject + +class MainActivity : TauriActivity() { + private val handler = Handler(Looper.getMainLooper()) + private val preferences by lazy { getSharedPreferences("workspace", MODE_PRIVATE) } + private lateinit var launcher: WebView + private lateinit var root: LinearLayout + private lateinit var content: FrameLayout + private lateinit var settings: Button + internal var workspace: WebView? = null + private set + private var server = NavigationPolicy.DEFAULT_SERVER + private var phase = "idle" + private var ready = false + private var revision = 0 + private var launcherVisible = true + 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) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when (NavigationPolicy.backAction(keyboardVisible, launcherVisible, ready, workspace?.canGoBack() == true)) { + "keyboard" -> WindowCompat.getInsetsController(window, window.decorView).hide(WindowInsetsCompat.Type.ime()) + "resume" -> showWorkspace() + "history" -> workspace?.goBack() + "settings" -> showLauncher() + else -> moveTaskToBack(true) + } + } + }) + } + + override fun onWebViewCreate(webView: WebView) { + launcher = webView + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener(launcher, "nativeLauncher", setOf(NavigationPolicy.LAUNCHER_ORIGIN)) { + _, message, origin, mainFrame, _ -> + if (mainFrame && origin.toString().trimEnd('/') == NavigationPolicy.LAUNCHER_ORIGIN) { + launcher.post { handleAction(message.data.orEmpty()) } + } + } + } + launcher.post { attachShell() } + } + + private fun attachShell() { + if (::root.isInitialized) return + (launcher.parent as? ViewGroup)?.removeView(launcher) + root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + settings = Button(this).apply { + text = getString(R.string.server_settings) + isAllCaps = false + visibility = View.GONE + setOnClickListener { showLauncher() } + } + root.addView(settings, LinearLayout.LayoutParams(-1, -2)) + content = FrameLayout(this) + content.addView(launcher, FrameLayout.LayoutParams(-1, -1)) + root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) + setContentView(root) + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime()) + keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + view.setPadding(safe.left, safe.top, safe.right, maxOf(safe.bottom, keyboard.bottom)) + WindowInsetsCompat.CONSUMED + } + ViewCompat.requestApplyInsets(root) + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + content.removeAllViews() + content.addView(TextView(this).apply { text = getString(R.string.update_webview) }) + } + } + + private fun handleAction(value: String) { + attachShell() + val url = Uri.parse(value) + if (url.scheme != "https" || url.host != "tauri.localhost" || url.port != -1 || url.userInfo != null) return + when (url.path) { + "/action/ready" -> { + if (initialized) return + initialized = true + val saved = preferences.getString("server", null) + server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER + updateLauncher() + if (saved != null) openWorkspace(server) + } + "/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty()) + "/action/resume" -> if (ready) showWorkspace() + } + } + + internal fun openWorkspace(input: String) { + val address = NavigationPolicy.installation(input) + if (address == null) { + phase = "invalidServer" + showLauncher() + return + } + if (phase == "connecting") return + if (address == server && ready) { + if (phase == "connectionError") { + phase = "connecting" + workspace?.let { startTimeout(it); it.reload() } + updateLauncher() + return + } + 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()) { + phase = "storageError" + showLauncher() + return + } + server = address + ready = false + phase = "connecting" + revision++ + workspace?.let { old -> + content.removeView(old) + old.stopLoading() + if (clearSession) old.clearCache(true) + old.destroy() + } + workspace = null + updateLauncher() + if (clearSession) { + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies { + CookieManager.getInstance().flush() + if (!preferences.edit().putBoolean("clearing", false).commit()) { + phase = "storageError" + showLauncher() + } else if (!isDestroyed) { + createWorkspace() + } + } + } else { + createWorkspace() + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun createWorkspace() { + val remote = WebView(this) + workspace = remote + remote.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = true + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(true) + } + CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false) + 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 { + 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) + return + } + phase = "connecting" + startTimeout(view) + updateLauncher() + } + + override fun onPageFinished(view: WebView, url: String) { + if (workspace !== view || phase != "connecting" || !NavigationPolicy.allows(server, url)) return + revision++ + phase = "connected" + val firstLoad = !ready + ready = true + CookieManager.getInstance().flush() + updateLauncher() + if (firstLoad) showWorkspace() + } + + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + if (request.isForMainFrame) fail(view) + } + + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) { + if (request.isForMainFrame && response.statusCode >= 500) fail(view) + } + + override fun onReceivedSslError(view: WebView, sslHandler: SslErrorHandler, error: SslError) { + sslHandler.cancel() + fail(view) + } + } + remote.visibility = View.GONE + content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1)) + startTimeout(remote) + remote.loadUrl(server) + } + + private fun startTimeout(view: WebView) { + val current = ++revision + handler.postDelayed({ + if (current == revision && phase == "connecting" && workspace === view) { + view.stopLoading() + fail(view) + } + }, 15000) + } + + private fun fail(view: WebView) { + if (workspace !== view) return + revision++ + phase = "connectionError" + showLauncher() + } + + private fun updateLauncher() { + val query = Uri.Builder().appendQueryParameter("server", server) + .appendQueryParameter("phase", phase).appendQueryParameter("ready", ready.toString()) + .appendQueryParameter("platform", "android").build().encodedQuery.orEmpty() + launcher.evaluateJavascript("window.updateLauncher?.(${JSONObject.quote(query)})", null) + } + + private fun showLauncher() { + updateLauncher() + launcherVisible = true + launcher.visibility = View.VISIBLE + workspace?.visibility = View.GONE + settings.visibility = View.GONE + launcher.requestFocus() + } + + private fun showWorkspace() { + launcherVisible = false + launcher.visibility = View.GONE + workspace?.visibility = View.VISIBLE + settings.visibility = View.VISIBLE + workspace?.requestFocus() + } + + override fun onPause() { + workspace?.onPause() + CookieManager.getInstance().flush() + super.onPause() + } + + override fun onResume() { + super.onResume() + workspace?.onResume() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + enableEdgeToEdge() + if (::root.isInitialized) ViewCompat.requestApplyInsets(root) + } + + 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 new file mode 100644 index 0000000..fb20ce6 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt @@ -0,0 +1,56 @@ +package de.kanbangit.desktop + +import java.net.URI +import java.util.Locale + +object NavigationPolicy { + const val DEFAULT_SERVER = "https://kanbangit.de/" + const val LAUNCHER_ORIGIN = "https://tauri.localhost" + + fun installation(input: String): String? = runCatching { + val trimmed = input.trim() + require(trimmed.none { it.isISOControl() || it == '\\' }) + val uri = URI(trimmed) + require(uri.scheme.equals("https", ignoreCase = true)) + require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null) + require(uri.rawQuery == null && uri.rawFragment == null) + require(uri.port == -1 || uri.port in 1..65535) + require(!uri.host.equals("tauri.localhost", ignoreCase = true)) + val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}" + val path = uri.rawPath.orEmpty().let { if (it.endsWith('/')) it else "$it/" } + "https://${uri.host.lowercase(Locale.ROOT)}$port$path" + }.getOrNull() + + fun allows(server: String, destination: String): Boolean = runCatching { + val base = URI(server) + val target = URI(destination) + target.scheme.equals("https", ignoreCase = true) && + target.host.equals(base.host, ignoreCase = true) && + (if (target.port == -1) 443 else target.port) == + (if (base.port == -1) 443 else base.port) && + 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" + !launcher && history -> "history" + !launcher -> "settings" + else -> "background" + } +} diff --git a/native/src-tauri/gen/android/app/src/main/res/drawable/kanbangit.xml b/native/src-tauri/gen/android/app/src/main/res/drawable/kanbangit.xml new file mode 100644 index 0000000..1d04b92 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/drawable/kanbangit.xml @@ -0,0 +1,8 @@ + + + + diff --git a/native/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/native/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..f2c50a9 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + 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 new file mode 100644 index 0000000..12f5059 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + "KanbanGit" + "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/main/res/values/themes.xml b/native/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f2c50a9 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/native/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml b/native/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0c65190 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/native/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/native/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/native/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 0000000..8244cb8 --- /dev/null +++ b/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt @@ -0,0 +1,51 @@ +package de.kanbangit.desktop + +import org.junit.Assert.* +import org.junit.Test + +class NavigationPolicyTest { + @Test fun normalizesAddressesAndRejectsCredentialsAndUnsafeSchemes() { + assertEquals("https://example.com/kanban/", NavigationPolicy.installation(" HTTPS://Example.com:443/kanban ")) + assertEquals("https://[::1]:8443/", NavigationPolicy.installation("https://[::1]:8443")) + for (value in listOf("http://example.com", "https:example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\other", "https://example.com:99999", "https://tauri.localhost/", "file:///etc/passwd", "")) { + assertNull(value, NavigationPolicy.installation(value)) + } + } + + @Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() { + val server = "https://example.com:8443/app/" + for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) { + assertTrue(NavigationPolicy.allows(server, "https://example.com:8443$path")) + } + for (value in listOf("https://example.com/", "http://example.com:8443/", "https://other.example.com:8443/", "https://user@example.com:8443/", "https://tauri.localhost/action/open", "javascript:alert(1)", "intent://example.com", "file:///etc/passwd")) { + assertFalse(value, NavigationPolicy.allows(server, value)) + } + 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)) + assertEquals("history", NavigationPolicy.backAction(false, false, true, true)) + assertEquals("settings", NavigationPolicy.backAction(false, false, true, false)) + assertEquals("background", NavigationPolicy.backAction(false, true, false, false)) + } +} diff --git a/native/src-tauri/gen/android/build.gradle.kts b/native/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..9aa238a --- /dev/null +++ b/native/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} diff --git a/native/src-tauri/gen/android/buildSrc/build.gradle.kts b/native/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..09776c6 --- /dev/null +++ b/native/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} diff --git a/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/BuildTask.kt b/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/BuildTask.kt new file mode 100644 index 0000000..bf4dcab --- /dev/null +++ b/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} diff --git a/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/RustPlugin.kt b/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/native/src-tauri/gen/android/buildSrc/src/main/java/de/kanbangit/desktop/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/native/src-tauri/gen/android/gradle.properties b/native/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..b1a3ffc --- /dev/null +++ b/native/src-tauri/gen/android/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false +android.builder.sdkDownload=false diff --git a/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/native/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/native/src-tauri/gen/android/gradlew b/native/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/native/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/native/src-tauri/gen/android/gradlew.bat b/native/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/native/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/native/src-tauri/gen/android/settings.gradle b/native/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/native/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' 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/android.rs b/native/src-tauri/src/android.rs new file mode 100644 index 0000000..0b65935 --- /dev/null +++ b/native/src-tauri/src/android.rs @@ -0,0 +1,22 @@ +use tauri::{WebviewUrl, WebviewWindowBuilder}; + +pub fn context() -> tauri::Context { + tauri::generate_context!() +} + +pub fn builder() -> tauri::Builder { + tauri::Builder::default().setup(|app| { + WebviewWindowBuilder::new(app, "launcher", WebviewUrl::App("index.html".into())) + .use_https_scheme(true) + .on_navigation(|url| { + url.scheme() == "https" + && url.host_str() == Some("tauri.localhost") + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && matches!(url.path(), "/" | "/index.html") + }) + .build()?; + Ok(()) + }) +} 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/lib.rs b/native/src-tauri/src/lib.rs index 34cd168..1d8d55b 100644 --- a/native/src-tauri/src/lib.rs +++ b/native/src-tauri/src/lib.rs @@ -1,13 +1,17 @@ -#[cfg(not(target_os = "ios"))] +#[cfg(target_os = "android")] +mod android; +#[cfg(not(any(target_os = "android", target_os = "ios")))] mod desktop; #[cfg(target_os = "ios")] mod ios; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] mod locale; -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] mod server; -#[cfg(not(target_os = "ios"))] +#[cfg(target_os = "android")] +pub use android::{builder, context}; +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub use desktop::{builder, context}; #[cfg(target_os = "ios")] pub use ios::{builder, context}; 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/native/tests/mobile-launcher.test.js b/native/tests/mobile-launcher.test.js new file mode 100644 index 0000000..9cd1a9d --- /dev/null +++ b/native/tests/mobile-launcher.test.js @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +class Element { + constructor() { + this.events = {}; + this.attributes = {}; + this.dataset = {}; + this.hidden = true; + this.value = ''; + } + addEventListener(name, callback) { + this.events[name] = callback; + } + setAttribute(name, value) { + this.attributes[name] = value; + } + removeAttribute(name) { + delete this.attributes[name]; + } + focus() {} + fire(name) { + this.events[name]({ preventDefault() {}, target: this }); + } +} + +test('Android launcher sends only explicit local actions and preserves switch confirmation', async () => { + const elements = new Map(); + const element = (selector) => { + if (!elements.has(selector)) { + elements.set(selector, new Element()); + } + return elements.get(selector); + }; + const messages = []; + globalThis.document = { + querySelectorAll: () => [], + querySelector: element, + documentElement: new Element(), + }; + globalThis.window = { + nativeLauncher: { + postMessage: (value) => messages.push(new URL(value)), + }, + }; + globalThis.location = { href: 'https://tauri.localhost/index.html' }; + try { + await import('../ui/main.js'); + assert.equal(messages.shift().pathname, '/action/ready'); + window.updateLauncher( + 'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android', + ); + assert.equal(element('#server-url').value, 'https://example.com/'); + element('#server-url').value = 'http://example.com'; + element('#server-form').fire('submit'); + assert.equal(messages.length, 0); + assert.equal(element('#server-url').attributes['aria-invalid'], 'true'); + element('#server-url').value = 'https://example.com/'; + element('#server-form').fire('submit'); + assert.equal(messages[0].pathname, '/action/open'); + assert.equal( + messages.shift().searchParams.get('server'), + 'https://example.com/', + ); + window.updateLauncher( + 'server=https%3A%2F%2Fexample.com%2F&phase=connected&ready=true&platform=android', + ); + assert.match( + element('#connection-status').textContent, + /Use Server settings/, + ); + element('#resume').fire('click'); + assert.equal(messages.shift().pathname, '/action/resume'); + element('#server-url').value = 'https://other.example.com/'; + element('#server-form').fire('submit'); + assert.equal(messages.length, 0); + assert.equal(element('#switch-confirmation').hidden, false); + element('#confirm-switch').fire('click'); + assert.equal( + messages.shift().searchParams.get('server'), + 'https://other.example.com/', + ); + assert.equal(location.href, 'https://tauri.localhost/index.html'); + } finally { + delete globalThis.window; + delete globalThis.document; + delete globalThis.location; + } +}); diff --git a/native/ui/locales/en.js b/native/ui/locales/en.js index 67ce2bb..8a061b8 100644 --- a/native/ui/locales/en.js +++ b/native/ui/locales/en.js @@ -16,6 +16,8 @@ export const en = { connecting: 'Connecting to your server…', connected: 'Your workspace is open. Use KanbanGit → Server settings to change servers.', + connectedMobile: + 'Your workspace is open. Use Server settings to change servers.', connectionError: 'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.', invalidServer: diff --git a/native/ui/main.js b/native/ui/main.js index 2fd84f2..30d9ef0 100644 --- a/native/ui/main.js +++ b/native/ui/main.js @@ -20,7 +20,11 @@ function action(name, server) { if (server) { url.searchParams.set('server', server); } - location.href = url.href; + if (window.nativeLauncher) { + window.nativeLauncher.postMessage(url.href); + } else { + location.href = url.href; + } } window.updateLauncher = (query) => { @@ -34,7 +38,12 @@ window.updateLauncher = (query) => { if (phase === 'connecting' || phase === 'connected') { activeServer = server; } - status.textContent = en[phase] ?? ''; + status.textContent = + en[ + phase === 'connected' && state.get('platform') === 'android' + ? 'connectedMobile' + : phase + ] ?? ''; status.dataset.state = phase; open.disabled = phase === 'connecting'; open.textContent = @@ -85,3 +94,7 @@ resume.addEventListener('click', () => action('resume')); document.querySelector('#appearance').addEventListener('change', (event) => { document.documentElement.dataset.appearance = event.target.value; }); + +if (window.nativeLauncher) { + action('ready'); +} 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,