diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cf3a291..758ef71b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,12 @@ jobs: steps: - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + - uses: actions/setup-java@v5 with: distribution: temurin diff --git a/.github/workflows/relay.yml b/.github/workflows/relay.yml index 061dc889..b4bb5cb3 100644 --- a/.github/workflows/relay.yml +++ b/.github/workflows/relay.yml @@ -43,6 +43,12 @@ jobs: CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }} run: node relay/scripts/configure-deploy.mjs + - name: Reclaim expired relay deliveries before migrating + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: npx --prefix relay wrangler d1 execute DB --remote --config relay/wrangler.deploy.json --command="$(cat relay/scripts/prune-expired.sql)" + - name: Apply D1 migrations env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 718e37ab..52964d10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -373,6 +373,12 @@ jobs: steps: - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + - uses: actions/setup-java@v5 with: distribution: temurin diff --git a/.gitignore b/.gitignore index ed7f91fa..9145cada 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ relay/node_modules relay/.wrangler relay/wrangler.deploy.json android/.gradle +android/.kotlin android/local.properties android/**/build android/app/google-services.json diff --git a/README.md b/README.md index 7d36e54a..3ccac229 100644 --- a/README.md +++ b/README.md @@ -461,8 +461,24 @@ tab over the internet. Share the frontend once: browser calls to localhost APIs, WebSockets and event streams go through that same HTTPS link. Start the frontend and backend in panes of the same tab before sharing. Public links are intended for development and testing, not production. Duckweed shows the link only after an -end-to-end readiness check reaches its local proxy. It stops the tunnel when you -stop sharing, close the owning process, or exit the app. +browser-style HTTPS check reaches its local proxy using normal DNS, without +bypassing provider warning pages. It also checks that the selected local server +responds over HTTP. Responses, uploads, WebSockets, and event streams are forwarded +without buffering the whole body. HTML adaptation preserves the document structure +and handles gzip, deflate, and Brotli responses, local asset URLs, redirects, and +cookies. A server restart within the same pane has a 30-second grace period to +keep the existing link. Ownership is checked again for incoming requests. + +Keep Duckweed and the servers running, and the computer awake and online. Stop +sharing revokes access and closes existing connections. Closing the process from +the Ports tool or exiting Duckweed also stops its tunnel. Links are temporary and +depend on the tunnel provider and the visitor's network. Cloudflare Quick Tunnels +[do not support SSE](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/); +Duckweed displays a notice when using that fallback. No tunnel can make every +development app portable automatically: OAuth redirect allowlists, local HTTPS +servers, URLs embedded in worker scripts or stylesheets, and apps with strict +deployment-origin settings can require configuration for the public URL. Only +ports belonging to panes included in the shared tab are authorized. ## Keyboard shortcuts @@ -558,6 +574,20 @@ GitHub CI runs the TypeScript check, Bun test suite, and `cargo check` on Linux, macOS, and Windows. Native release packaging is also performed on the matching operating system before a release can be published. +Port-sharing regression tests run with `cargo test --manifest-path +src-tauri/Cargo.toml ports::`. Two opt-in tests create temporary public tunnels: +`public_tunnel_is_reachable_end_to_end` checks HTTPS and backend routing; +`public_browser_app_works_end_to_end` uses Node and an isolated headless browser +to check uploads, cookies, redirects, XHR, SSE, WebSockets, assets, and compressed +HTML. Set `DUCKWEED_TEST_BROWSER` to a Chrome or Edge executable, then run: + +```bash +cargo test --manifest-path src-tauri/Cargo.toml public_browser_app_works_end_to_end -- --ignored --nocapture +``` + +These tests serve synthetic fixtures only and stop their servers and tunnels +when finished. They require internet access and an available tunnel provider. + Release builds come from two branches: `main` publishes stable releases and `testing` publishes beta releases. The full versioning, signing, and updater setup is documented in [docs/releases.md](docs/releases.md). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 42c51703..26235bbd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,6 +61,16 @@ android { buildConfig = true } + val mobileAssets = layout.buildDirectory.dir("generated/agentAssets") + sourceSets["main"].assets.srcDir(mobileAssets) + val buildMobileExperience by tasks.registering(Exec::class) { + workingDir(rootProject.projectDir.resolve("..")) + commandLine(if (System.getProperty("os.name").startsWith("Windows")) "bun.exe" else "bun", "run", "build:mobile") + inputs.files(fileTree(rootProject.projectDir.resolve("../src")), rootProject.projectDir.resolve("../vite.mobile.config.ts"), rootProject.projectDir.resolve("../mobile.html"), rootProject.projectDir.resolve("../bun.lock"), rootProject.projectDir.resolve("../package.json")) + outputs.dir(mobileAssets) + } + tasks.named("preBuild").configure { dependsOn(buildMobileExperience) } + val generatedSounds = layout.buildDirectory.dir("generated/res/completionSounds") sourceSets["main"].res.srcDir(generatedSounds) val syncCompletionSounds by tasks.registering(Sync::class) { @@ -80,6 +90,7 @@ dependencies { implementation("com.google.firebase:firebase-messaging") implementation("com.google.android.gms:play-services-code-scanner:16.1.0") implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.recyclerview:recyclerview:1.4.0") diff --git a/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobilePolishTest.kt b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobilePolishTest.kt new file mode 100644 index 00000000..9a96ae3c --- /dev/null +++ b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobilePolishTest.kt @@ -0,0 +1,133 @@ +package dev.slop.duckweed.companion + +import android.content.Intent +import android.graphics.Bitmap +import android.view.View +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class MobilePolishTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + private val pairId = "00000000-0000-4000-8000-000000000001" + private val terminalId = "polish-terminal" + private var now = 0L + private val commands = listOf( + RemoteSlashCommand("/new", "Start a new conversation"), + RemoteSlashCommand("/model", "Choose a model", listOf( + RemoteCommandOption("provider/fast", "Fast", "Quick everyday work", true), + RemoteCommandOption("provider/deep", "Deep", "Complex coding tasks"), + )), + RemoteSlashCommand("/effort", "Change reasoning effort", listOf( + RemoteCommandOption("low", "Low", "Faster responses"), + RemoteCommandOption("high", "High", "More detailed reasoning", true), + )), + ) + private fun snapshot(at: Long = now, closed: Boolean = false) = WorkspaceSnapshot(pairId, at, listOf( + RemoteProject("mobile", "Mobile experience", "H:/duckweed", "testing", "#45cec4", if (closed) emptyList() else listOf( + RemoteTerminal(terminalId, "Codex", "PowerShell", "Codex", "Fast", "idle", mode = "conversation", commands = commands, + conversation = listOf(RemoteConversationMessage("answer", now - 1000, "assistant", "The mobile sync update is ready to review."))), + )), + RemoteProject("api", "API improvements", "H:/api", "main", "#c98bf0", listOf( + RemoteTerminal("api-terminal", "Claude", "PowerShell", "Claude", "Sonnet", "waiting", mode = "conversation"), + )), + RemoteProject("release", "Release checklist", "H:/duckweed", "release", terminals = listOf( + RemoteTerminal("release-terminal", "Terminal", "PowerShell", null, null, "idle"), + )), + )) + + @Before fun seed() { + if (android.os.Build.VERSION.SDK_INT >= 33) { + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, android.Manifest.permission.POST_NOTIFICATIONS) + } + now = System.currentTimeMillis() + context.deleteDatabase("duckweed-messages.db") + WorkspaceStore(context).remove(pairId) + DraftStore(context).save(pairId, terminalId, ConversationDraft("", null)) + SecretStore.save(context, PairCredentials(pairId, "https://127.0.0.1:1", "A".repeat(43), "B".repeat(43), "test-device", "Test device")) + WorkspaceStore(context).put(snapshot()) + } + + @Test fun searchableListsAndFiltersKeepDesktopTitles() { + ActivityScenario.launch(Intent(context, MainActivity::class.java)).use { scenario -> + scenario.onActivity { it.findViewById(R.id.nav_projects).performClick() } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.project_list).adapter?.itemCount == 3 }; ready } + instrumentation.waitForIdleSync() + screenshot("mobile-tabs-polished.png") + // A query cleared before its background diff finishes must not reappear. + scenario.onActivity { + it.findViewById(R.id.project_search).setText("api") + it.findViewById(R.id.project_search).text.clear() + } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.project_list).adapter?.itemCount == 3 }; ready } + scenario.onActivity { it.findViewById(R.id.project_search).setText("mobile codex") } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.project_list).adapter?.itemCount == 1 }; ready } + scenario.onActivity { activity -> + val list = activity.findViewById(R.id.project_list) + assertEquals("Mobile experience", list.findViewHolderForAdapterPosition(0)!!.itemView.findViewById(R.id.project_name).text.toString()) + activity.findViewById(R.id.project_search).setText("does not exist") + assertEquals(View.VISIBLE, activity.findViewById(R.id.projects_empty).visibility) + activity.findViewById(R.id.projects_empty_action).performClick() + assertEquals("", activity.findViewById(R.id.project_search).text.toString()) + activity.findViewById(R.id.nav_conversations).performClick() + } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversations_page).let { page -> page.isShown && page.alpha == 1f } }; ready } + screenshot("mobile-conversations-polished.png") + scenario.onActivity { it.findViewById(R.id.conversation_filters).getChildAt(1).performClick() } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversations_list).adapter?.itemCount == 1 }; ready } + } + } + + @Test fun slashChoicesPersistAndStreamingDoesNotRebuildThePicker() { + val message = CompletionRecord(id = "polish-message", pairId = pairId, sentAt = now, agent = "Codex", + project = "Mobile experience", kind = "completed", response = "Ready", durationMs = null, + projectId = "mobile", terminalId = terminalId) + MessageStore(context).use { it.put(message); it.markNotified(message.id) } + ActivityScenario.launch(Intent(context, MainActivity::class.java).putExtra("message_id", message.id)).use { scenario -> + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversation_input).let { view -> view.isShown && view.isEnabled } }; ready } + scenario.onActivity { activity -> + assertEquals("Mobile experience", activity.findViewById(R.id.conversation_title).text.toString()) + activity.findViewById(R.id.conversation_command_button).performClick() + val input = activity.findViewById(R.id.conversation_input) + input.setText("/model ") + val rows = activity.findViewById(R.id.conversation_commands) + assertEquals(2, rows.childCount) + val first = rows.getChildAt(0) + MainActivity::class.java.getDeclaredMethod("refreshConversation", Boolean::class.javaPrimitiveType).apply { isAccessible = true }.invoke(activity, false) + assertSame(first, rows.getChildAt(0)) + rows.getChildAt(1).performClick() + assertEquals("/model provider/deep ", input.text.toString()) + assertEquals(View.GONE, activity.findViewById(R.id.conversation_commands_scroll).visibility) + input.setText("/effort ") + } + instrumentation.waitForIdleSync() + screenshot("mobile-command-choices.png") + WorkspaceStore(context).put(snapshot(now + 1000, closed = true)) + NotificationTools.announceChanged(context) + await { var closed = false; scenario.onActivity { closed = it.findViewById(R.id.conversation_unavailable).text.contains("closed") }; closed } + scenario.onActivity { assertFalse(it.findViewById(R.id.conversation_send).isEnabled) } + } + } + + private fun screenshot(name: String) { + instrumentation.uiAutomation.takeScreenshot()?.let { bitmap -> + File(context.getExternalFilesDir(null), name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + } + } + private fun await(predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + 15_000 + while (!predicate()) { check(System.currentTimeMillis() < deadline) { "UI did not settle" }; Thread.sleep(80) } + } +} diff --git a/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileRedesignTest.kt b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileRedesignTest.kt new file mode 100644 index 00000000..c1067f0e --- /dev/null +++ b/android/app/src/androidTest/java/dev/slop/duckweed/companion/MobileRedesignTest.kt @@ -0,0 +1,143 @@ +package dev.slop.duckweed.companion + +import android.content.Intent +import android.graphics.Bitmap +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class MobileRedesignTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + private val pairId = "00000000-0000-4000-8000-000000000011" + private val terminalId = "redesign-terminal" + private var now = 0L + + @Before fun seed() { + now = System.currentTimeMillis() + if (android.os.Build.VERSION.SDK_INT >= 33) instrumentation.uiAutomation.grantRuntimePermission(context.packageName, android.Manifest.permission.POST_NOTIFICATIONS) + SecretStore.loadAll(context).forEach { WorkspaceStore(context).remove(it.pairId) } + context.deleteDatabase("duckweed-messages.db") + SecretStore.save(context, PairCredentials(pairId, "https://127.0.0.1:1", "A".repeat(43), "B".repeat(43), "redesign", "Test desktop")) + DraftStore(context).save(pairId, terminalId, ConversationDraft("", null)) + WorkspaceStore(context).put(snapshot("codex")) + } + + @After fun removeFixture() { + WorkspaceStore(context).remove(pairId) + SecretStore.remove(context, pairId) + DraftStore(context).save(pairId, terminalId, ConversationDraft("", null)) + } + + private fun snapshot(provider: String, text: String = "Reviewing the synchronization paths.", at: Long = now): WorkspaceSnapshot { + val items = JSONArray() + .put(JSONObject().put("id", "user").put("at", now - 2000).put("kind", "user").put("text", "Improve mobile sync and agent controls.")) + .put(JSONObject().put("id", "plan").put("at", now - 1500).put("kind", "plan").put("steps", JSONArray() + .put(JSONObject().put("text", "Inspect the relay").put("status", "done")) + .put(JSONObject().put("text", "Connect the mobile controls").put("status", "running")))) + .put(JSONObject().put("id", "reasoning").put("at", now - 1000).put("kind", "thinking").put("text", text).put("streaming", true)) + .put(JSONObject().put("id", "tool").put("at", now - 500).put("kind", "tool").put("name", "read").put("tool", "read") + .put("callId", "read-call").put("title", "Read relay/index.ts").put("output", "Workspace delivery inspected").put("status", "done").put("changes", JSONArray())) + .put(JSONObject().put("id", "answer").put("at", now).put("kind", "assistant").put("text", if (text == "Reviewing the synchronization paths.") "The conversation now follows the desktop agent." else text).put("streaming", false)) + val experience = JSONObject().put("termId", terminalId).put("agent", provider).put("program", provider).put("label", provider.replaceFirstChar { it.uppercase() }) + .put("mark", "CX").put("accent", "#9aa5b1").put("status", "working").put("started", true) + .put("sessionId", "fixture-$provider").put("conversationEpoch", "user").put("cwd", "C:/duckweed") + .put("model", "Fast").put("effort", "high").put("workStartedAt", now - 2000).put("items", items).toString() + return WorkspaceSnapshot(pairId, at, listOf(RemoteProject("project", "Mobile redesign", "C:/duckweed", "testing", "#45cec4", + listOf(RemoteTerminal(terminalId, "Codex", "PowerShell", provider, "Fast", "working", mode = "conversation", experience = experience, + commands = listOf(RemoteSlashCommand("/model", "Model", listOf(RemoteCommandOption("fast", "Fast", "", true))), + RemoteSlashCommand("/effort", "Effort", listOf(RemoteCommandOption("high", "High", "", true)))), + conversation = listOf(RemoteConversationMessage("answer", now, "assistant", "The conversation now follows the desktop agent."))))))) + } + + @Test fun conversationsOpenFirstAndKeyboardClosesWhenLeaving() { + ActivityScenario.launch(Intent(context, MainActivity::class.java)).use { scenario -> + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversations_list).adapter?.itemCount == 1 }; ready } + scenario.onActivity { + assertTrue(it.findViewById(R.id.nav_conversations).isSelected) + assertTrue(it.findViewById(R.id.conversations_page).isShown) + it.findViewById(R.id.conversations_list).findViewHolderForAdapterPosition(0)!!.itemView.performClick() + } + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversation_input).isEnabled }; ready } + scenario.onActivity { + val input = it.findViewById(R.id.conversation_input) + input.setText("Keep this draft") + input.requestFocus() + it.getSystemService(InputMethodManager::class.java).showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + } + await { var visible = false; scenario.onActivity { visible = ViewCompat.getRootWindowInsets(it.findViewById(R.id.app_root))?.isVisible(WindowInsetsCompat.Type.ime()) == true }; visible } + scenario.onActivity { it.findViewById(R.id.conversation_back).performClick() } + await { var hidden = false; scenario.onActivity { hidden = ViewCompat.getRootWindowInsets(it.findViewById(R.id.app_root))?.isVisible(WindowInsetsCompat.Type.ime()) == false }; hidden } + scenario.onActivity { assertTrue(it.findViewById(R.id.conversations_page).isShown) } + } + } + + @Test fun allProvidersUseTheBundledDesktopRendererAndStreamUpdates() { + ActivityScenario.launch(Intent(context, MainActivity::class.java)).use { scenario -> + await { var ready = false; scenario.onActivity { ready = it.findViewById(R.id.conversations_list).adapter?.itemCount == 1 }; ready } + scenario.onActivity { it.findViewById(R.id.conversations_list).findViewHolderForAdapterPosition(0)!!.itemView.performClick() } + for ((index, provider) in listOf("codex", "claude", "grok", "cursor", "opencode").withIndex()) { + if (index > 0) { + WorkspaceStore(context).put(snapshot(provider, at = now + index * 1000)) + NotificationTools.announceChanged(context) + } + try { + await { js(scenario, "document.body.innerText.includes('The conversation now follows the desktop agent.') && document.querySelector('[data-agent=$provider]') !== null") == "true" } + } catch (error: AssertionError) { + fail("Renderer $provider: ${js(scenario, "JSON.stringify({text:document.body.innerText,url:location.href,update:typeof window.duckweedUpdate,html:document.body.innerHTML.slice(0,300)})")}") + } + scenario.onActivity { + assertEquals(View.VISIBLE, it.findViewById(R.id.conversation_experience).visibility) + assertEquals(View.GONE, it.findViewById(R.id.conversation_list).visibility) + assertEquals("Fast", it.findViewById(R.id.conversation_model).text.toString()) + assertEquals("High", it.findViewById(R.id.conversation_effort).text.toString()) + assertTrue(it.findViewById(R.id.conversation_stop).isShown) + } + assertFalse(js(scenario, "document.body.innerText.includes('needs to recover')").toBoolean()) + screenshot("mobile-redesign-$provider.png") + } + WorkspaceStore(context).put(snapshot("opencode", "Checking the updated stream.", now + 6000)) + NotificationTools.announceChanged(context) + await { js(scenario, "document.body.innerText.includes('Checking the updated stream.')") == "true" } + // Transcript content cannot open a network resource or navigate the renderer. + assertEquals("true", js(scenario, "window.location.hostname === 'appassets.androidplatform.net'")) + } + } + + private fun js(scenario: ActivityScenario, script: String): String { + var result = "" + val latch = CountDownLatch(1) + scenario.onActivity { it.findViewById(R.id.conversation_experience).evaluateJavascript(script) { value -> result = value; latch.countDown() } } + assertTrue(latch.await(3, TimeUnit.SECONDS)) + return result + } + private fun await(condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + 12_000 + while (System.currentTimeMillis() < deadline) { if (condition()) return; Thread.sleep(100) } + fail("Mobile UI did not settle") + } + private fun screenshot(name: String) { + instrumentation.waitForIdleSync() + val bitmap = instrumentation.uiAutomation.takeScreenshot() + File(context.getExternalFilesDir(null), name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 82498fa4..19770dcd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/AgentExperienceView.kt b/android/app/src/main/java/dev/slop/duckweed/companion/AgentExperienceView.kt new file mode 100644 index 00000000..0bf3f15c --- /dev/null +++ b/android/app/src/main/java/dev/slop/duckweed/companion/AgentExperienceView.kt @@ -0,0 +1,81 @@ +package dev.slop.duckweed.companion + +import android.content.Context +import android.util.AttributeSet +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.WebViewAssetLoader +import org.json.JSONObject + +/** Local, bundled desktop renderer. It receives data, never credentials or network access. */ +class AgentExperienceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : WebView(context, attrs) { + private var ready = false + private var lastUpdate: String? = null + private var renderedUpdate: String? = null + var onUnavailable: (() -> Unit)? = null + + init { + setBackgroundColor(context.getColor(R.color.duckweed_background)) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.blockNetworkLoads = true + val loader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context)) + .build() + webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse = + loader.shouldInterceptRequest(request.url) + ?: WebResourceResponse("text/plain", "UTF-8", java.io.ByteArrayInputStream(ByteArray(0))) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true + override fun onPageFinished(view: WebView, url: String) { + ready = true + flush() + } + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: android.webkit.WebResourceError) { + if (request.isForMainFrame) onUnavailable?.invoke() + } + override fun onRenderProcessGone(view: WebView, detail: android.webkit.RenderProcessGoneDetail): Boolean { + ready = false + onUnavailable?.invoke() + (parent as? android.view.ViewGroup)?.removeView(this@AgentExperienceView) + destroy() + return true + } + } + loadUrl("https://appassets.androidplatform.net/assets/mobile.html") + } + + fun render(key: String, experience: String, online: Boolean, outgoing: org.json.JSONArray = org.json.JSONArray()) { + lastUpdate = JSONObject().put("key", key).put("experience", JSONObject(experience)).put("online", online).put("outgoing", outgoing).toString() + flush() + } + + fun setOnline(online: Boolean) { + val update = lastUpdate?.let { JSONObject(it) } ?: return + if (update.optBoolean("online") == online) return + lastUpdate = update.put("online", online).toString() + flush() + } + + private fun flush() { + val value = lastUpdate ?: return + if (!ready || value == renderedUpdate) return + // Encode as a JSON string, never interpolate transcript text as executable code. + evaluateJavascript("typeof window.duckweedUpdate === 'function'", { available -> + if (available == "true") { + renderedUpdate = value + evaluateJavascript("window.duckweedUpdate(JSON.parse(${JSONObject.quote(value)}))", null) + } else postDelayed({ flush() }, 50) + }) + } + + fun pauseConversation() { + if (ready) evaluateJavascript("window.duckweedPause?.()", null) + renderedUpdate = null + onPause() + } +} diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/ConversationAdapter.kt b/android/app/src/main/java/dev/slop/duckweed/companion/ConversationAdapter.kt index 27c4ff91..538aa185 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/ConversationAdapter.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/ConversationAdapter.kt @@ -93,9 +93,9 @@ class ConversationAdapter( bubble.layoutParams = this } author.text = when { - outgoing -> "YOU" - message.streaming -> "${message.agent.uppercase()} · WRITING" - else -> message.agent.uppercase() + outgoing -> "You" + message.streaming -> "${message.agent} · Writing" + else -> message.agent } author.setTextColor( ContextCompat.getColor( @@ -335,11 +335,11 @@ class ConversationAdapter( (value * itemView.resources.displayMetrics.density).toInt() } - private fun relativeTime(at: Long): CharSequence = DateUtils.getRelativeTimeSpanString( - at, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - ) + private fun relativeTime(at: Long): CharSequence { + val now = System.currentTimeMillis() + return if (now - at in 0 until DateUtils.MINUTE_IN_MILLIS) "Just now" + else DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS) + } companion object { private const val VIEW_MESSAGE = 0 diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/Crypto.kt b/android/app/src/main/java/dev/slop/duckweed/companion/Crypto.kt index 652139cf..c97d9004 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/Crypto.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/Crypto.kt @@ -137,9 +137,7 @@ object Crypto { readCompletionSeq = if (terminal.isNull("readCompletionSeq")) null else terminal.optLong("readCompletionSeq"), commands = (0 until commandsJson.length()).mapNotNull { commandIndex -> val command = commandsJson.optJSONObject(commandIndex) ?: return@mapNotNull null - val name = command.optString("name").trim() - if (!name.startsWith("/")) return@mapNotNull null - RemoteSlashCommand(name, command.optString("description").trim()) + SlashCommandJson.read(command) }, activity = parseAgentActivities(activityJson, json.optLong("sentAt")), conversation = (0 until conversationJson.length()).mapNotNull { messageIndex -> @@ -159,6 +157,11 @@ object Crypto { }, permission = parseRemotePermission(permissionJson), terminalOutput = terminalOutput, + experience = terminal.optJSONObject("experience")?.toString(), + history = terminal.optJSONArray("history")?.toString(), + historyError = terminal.optString("historyError").takeIf { it.isNotBlank() && it != "null" }, + historyRequestId = terminal.optString("historyRequestId").takeIf { it.isNotBlank() && it != "null" }, + scheduled = terminal.optJSONObject("scheduled")?.toString(), ) }.filter { it.id.isNotBlank() }, ) diff --git a/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt b/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt index 66a65ae5..51ef97ec 100644 --- a/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt +++ b/android/app/src/main/java/dev/slop/duckweed/companion/MainActivity.kt @@ -1,6 +1,16 @@ package dev.slop.duckweed.companion +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.view.inputmethod.InputMethodManager import android.Manifest +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import org.json.JSONArray +import org.json.JSONObject +import java.util.Calendar import android.app.DownloadManager import android.content.BroadcastReceiver import android.content.Context @@ -97,7 +107,12 @@ class MainActivity : AppCompatActivity() { private lateinit var conversationAttachmentPreview: View private lateinit var conversationAttachmentImage: ImageView private lateinit var conversationAttachmentName: TextView + private lateinit var conversationExperience: AgentExperienceView + private var experienceUnavailable = false + private var historyRequestedFor: Triple? = null + private lateinit var conversationsRefresh: SwipeRefreshLayout private lateinit var conversationList: RecyclerView + private lateinit var conversationLatest: View private lateinit var conversationTerminal: TextView private lateinit var conversationPlan: View private lateinit var conversationPlanHead: View @@ -150,7 +165,7 @@ class MainActivity : AppCompatActivity() { private var appUnlocked = false private var appLockPromptVisible = false private var updateAvailable: AndroidUpdateManifest? = null - private var selectedPage = Page.ACTIVITY + private var selectedPage = Page.CONVERSATIONS private val pageHistory = ArrayDeque() private var selectedProject: ProjectRow? = null private var selectedTarget: ConversationTarget? = null @@ -171,10 +186,42 @@ class MainActivity : AppCompatActivity() { private val deliveryChecks = mutableSetOf() private val draftPersistRunnable = Runnable { writeCurrentDraft() } private var refreshRequestedAt = 0L - private var refreshSnapshotVersion = 0L + private var requestedFocus: Pair? = null + private var refreshBaselines: Map = emptyMap() + private var refreshPending: Set = emptySet() + private var refreshHadFailure = false + private var refreshShowsFeedback = false + private var refreshGeneration = 0L + private var networkCallbackRegistered = false + private var foreground = false + private var projectQuery = "" + private var conversationQuery = "" + private var conversationFilter = ConversationFilter.ALL + private var renderedSuggestions: List? = null + private var renderedCommandEmpty: String? = null + private val refreshTimeout = Runnable { + if (refreshRequestedAt > 0) finishRemoteRefresh(timedOut = true) + } + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { runOnUiThread { + if (foreground && !isDestroyed) { + recoverPendingRelayMessages() + requestRemoteRefresh(showSpinner = false) + } + } } + override fun onLost(network: Network) { runOnUiThread { + if (foreground && !isDestroyed) { + refreshConnectionHealth() + refreshWorkspaces() + refreshConversationAvailability() + } + } } + } private var cachedSnapshots: List = emptyList() private var unreadConversationKeys: Set> = emptySet() private var remoteStateLoading = false + private var remoteStateReady = false + private var refreshOnLoad = false private var remoteStateReloadPending = false private val relayRecoveryRunning = AtomicBoolean(false) private val connectionTicker = object : Runnable { @@ -184,7 +231,7 @@ class MainActivity : AppCompatActivity() { refreshWorkspaces() refreshUsageLimits() refreshConversationAvailability() - connectionDot.postDelayed(this, if (conversationDetail.visibility == View.VISIBLE) 3_000 else 15_000) + connectionDot.postDelayed(this, if (conversationDetail.visibility == View.VISIBLE) 1_000 else 3_000) } } @@ -293,6 +340,7 @@ class MainActivity : AppCompatActivity() { conversationDetail = findViewById(R.id.conversation_detail) conversationCommandsScroll = findViewById(R.id.conversation_commands_scroll) conversationCommands = findViewById(R.id.conversation_commands) + (conversationCommandsScroll as MaxHeightScrollView).maxHeightPx = dp(220) conversationComposer = findViewById(R.id.conversation_composer) conversationUnavailable = findViewById(R.id.conversation_unavailable) conversationInput = findViewById(R.id.conversation_input) @@ -301,7 +349,18 @@ class MainActivity : AppCompatActivity() { conversationAttachmentPreview = findViewById(R.id.conversation_attachment_preview) conversationAttachmentImage = findViewById(R.id.conversation_attachment_image) conversationAttachmentName = findViewById(R.id.conversation_attachment_name) + conversationExperience = findViewById(R.id.conversation_experience) + conversationExperience.onUnavailable = { + experienceUnavailable = true + conversationExperience.visibility = View.GONE + refreshConversation(reloadHistory = false) + } + findViewById(R.id.conversation_model).setOnClickListener { showAgentChoice("model") } + findViewById(R.id.conversation_effort).setOnClickListener { showAgentChoice("effort") } + findViewById(R.id.conversation_stop).setOnClickListener { sendAgentControl("interrupt") } + findViewById(R.id.conversation_more).setOnClickListener { showConversationActions() } conversationList = findViewById(R.id.conversation_list) + conversationLatest = findViewById(R.id.conversation_latest) conversationTerminal = findViewById(R.id.conversation_terminal) conversationPlan = findViewById(R.id.conversation_plan) conversationPlanHead = findViewById(R.id.conversation_plan_head) @@ -319,6 +378,9 @@ class MainActivity : AppCompatActivity() { approvalActions = findViewById(R.id.approval_actions) responsesRefresh = findViewById(R.id.responses_page) projectsRefresh = findViewById(R.id.projects_page) + conversationsRefresh = findViewById(R.id.conversations_page) + conversationsRefresh.setOnChildScrollUpCallback { _, _ -> findViewById(R.id.conversations_list).canScrollVertically(-1) } + conversationsRefresh.setOnRefreshListener { requestRemoteRefresh() } findViewById(R.id.message_list).apply { layoutManager = LinearLayoutManager(this@MainActivity) @@ -347,6 +409,8 @@ class MainActivity : AppCompatActivity() { addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { conversationShouldStickToBottom = !recyclerView.canScrollVertically(1) + conversationLatest.visibility = + if (conversationShouldStickToBottom) View.GONE else View.VISIBLE } }) } @@ -379,6 +443,23 @@ class MainActivity : AppCompatActivity() { renderPlanDetails() } conversationSend.setOnClickListener { sendConversationMessage() } + findViewById(R.id.conversation_latest).setOnClickListener { + conversationShouldStickToBottom = true + if (conversationAdapter.itemCount > 0) conversationList.scrollToPosition(conversationAdapter.itemCount - 1) + it.visibility = View.GONE + } + findViewById(R.id.conversation_command_button).setOnClickListener { + if (draftLoading) return@setOnClickListener + if (conversationInput.text.isNotBlank() && !conversationInput.text.startsWith("/")) { + showCommandBrowser() + } else { + conversationInput.setText("/") + conversationInput.setSelection(1) + conversationInput.requestFocus() + getSystemService(InputMethodManager::class.java).showSoftInput(conversationInput, InputMethodManager.SHOW_IMPLICIT) + } + } + configureWorkspaceTools() conversationAttach.setOnClickListener { it.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) imagePicker.launch("image/*") @@ -431,7 +512,7 @@ class MainActivity : AppCompatActivity() { updateButton.setOnClickListener { updateAvailable?.let(::downloadUpdate) ?: checkForUpdates() } - retryConnectionButton.setOnClickListener { requestRemoteRefresh(showSpinner = false) } + retryConnectionButton.setOnClickListener { requestRemoteRefresh() } requestNotificationPermissionIfEnabled() refreshPairingStatus() refreshPushRegistration() @@ -440,6 +521,14 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() + foreground = true + if (!networkCallbackRegistered) { + runCatching { + getSystemService(ConnectivityManager::class.java).registerNetworkCallback( + NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(), networkCallback) + networkCallbackRegistered = true + } + } syncNotificationVisibility() connectionDot.removeCallbacks(connectionTicker) connectionDot.post(connectionTicker) @@ -461,6 +550,14 @@ class MainActivity : AppCompatActivity() { } override fun onStop() { + if (::conversationExperience.isInitialized && !experienceUnavailable) conversationExperience.pauseConversation() + foreground = false + if (networkCallbackRegistered) { + runCatching { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) } + networkCallbackRegistered = false + } + refreshShowsFeedback = false + finishRemoteRefresh() persistCurrentDraft() connectionDot.removeCallbacks(connectionTicker) MobileNotificationVisibility.activityStopped() @@ -503,6 +600,8 @@ class MainActivity : AppCompatActivity() { } override fun onDestroy() { + if (::conversationExperience.isInitialized && !experienceUnavailable) conversationExperience.destroy() + if (::conversationList.isInitialized) conversationList.clearOnScrollListeners() if (::connectionDot.isInitialized) connectionDot.removeCallbacks(connectionTicker) executor.shutdownNow() // Let already accepted submissions finish when the Activity is recreated. @@ -525,7 +624,7 @@ class MainActivity : AppCompatActivity() { bind(R.id.nav_projects, Page.PROJECTS) bind(R.id.nav_conversations, Page.CONVERSATIONS) bind(R.id.settings_button, Page.SETTINGS) - showPage(Page.ACTIVITY) + showPage(Page.CONVERSATIONS) } private fun navigateToPage(page: Page) { @@ -612,6 +711,7 @@ class MainActivity : AppCompatActivity() { private fun showPage(page: Page) { persistCurrentDraft() + dismissKeyboard() if (page == Page.SETTINGS) refreshNotificationHealth() val previousPage = selectedPage selectedProject = null @@ -619,6 +719,7 @@ class MainActivity : AppCompatActivity() { selectedDraftAttachment = null legacyResponse = null MobileNotificationVisibility.hideConversation() + if (!experienceUnavailable) conversationExperience.pauseConversation() projectDetail.visibility = View.GONE conversationDetail.visibility = View.GONE setDetailChrome(false) @@ -670,6 +771,88 @@ class MainActivity : AppCompatActivity() { findViewById(R.id.settings_button).isSelected = page == Page.SETTINGS } + private fun configureWorkspaceTools() { + fun bindSearch(id: Int, update: (String) -> Unit) { + findViewById(id).apply { + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { update(s.toString()); refreshWorkspaces() } + }) + setOnEditorActionListener { _, _, _ -> + getSystemService(InputMethodManager::class.java).hideSoftInputFromWindow(windowToken, 0) + clearFocus(); true + } + } + } + bindSearch(R.id.project_search) { projectQuery = it } + bindSearch(R.id.conversation_search) { conversationQuery = it } + val filters = findViewById(R.id.conversation_filters) + for ((filter, label) in listOf(ConversationFilter.ALL to "All", ConversationFilter.NEEDS_YOU to "Needs you", ConversationFilter.UNREAD to "Unread")) { + filters.addView(Button(this).apply { + text = label; textSize = 12f; isAllCaps = false + minWidth = 0; minimumWidth = 0 + background = ContextCompat.getDrawable(this@MainActivity, R.drawable.nav_item) + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.duckweed_text_dim)) + isSelected = conversationFilter == filter + setPadding(dp(16), 0, dp(16), 0) + setOnClickListener { + conversationFilter = filter + for (i in 0 until filters.childCount) filters.getChildAt(i).isSelected = filters.getChildAt(i) === this + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + refreshWorkspaces() + } + }, LinearLayout.LayoutParams(-2, dp(48)).apply { marginEnd = dp(4) }) + } + headerConnectionStatus.apply { + minHeight = dp(48); gravity = Gravity.CENTER_VERTICAL + isFocusable = true + contentDescription = "Connection status. Tap to sync with desktop." + setOnClickListener { requestRemoteRefresh() } + } + for (refresh in listOf(responsesRefresh, projectsRefresh)) { + refresh.setColorSchemeColors(ContextCompat.getColor(this, R.color.duckweed_accent)) + refresh.setProgressBackgroundColorSchemeColor(ContextCompat.getColor(this, R.color.duckweed_surface_high)) + } + } + + private fun updateEmptyStates(noTabs: Boolean, noConversations: Boolean) { + val paired = SecretStore.loadAll(this).isNotEmpty() + fun update(container: View, actionId: Int, title: String, detail: String, action: String, run: () -> Unit) { + val texts = (container as? ViewGroup)?.let { group -> + (0 until group.childCount).map { group.getChildAt(it) }.filterIsInstance().filterNot { it is Button } + }.orEmpty() + texts.getOrNull(0)?.text = title + texts.getOrNull(1)?.text = detail + findViewById )} {!isConfirming ? ( - ) : ( @@ -284,7 +290,7 @@ export function PortsTool({ ownerNames, onSnapshot }: Props) { +