Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.yugahashimoto.andcode.feature.browser

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File

private const val COMMAND_FILE_RELATIVE_PATH = ".and-code/browser-command.json"
private const val POLL_INTERVAL_MILLIS = 1000L

/**
* Watches the active workspace for a browser command written by the in-guest agent
* (`.and-code/browser-command.json`, e.g. `{"action":"open","url":"http://127.0.0.1:8080/"}`)
* and opens the guest browser at the requested URL so the user can watch and join in.
*/
@Composable
fun GuestBrowserCommandWatcher(
workspacePath: String?,
onOpenUrl: (String) -> Unit,
) {
LaunchedEffect(workspacePath) {
val path = workspacePath ?: return@LaunchedEffect
val commandFile = File(path, COMMAND_FILE_RELATIVE_PATH)
while (true) {
delay(POLL_INTERVAL_MILLIS)
val url = withContext(Dispatchers.IO) { consumeOpenCommand(commandFile) }
if (url != null) {
onOpenUrl(url)
}
}
}
}

private fun consumeOpenCommand(commandFile: File): String? =
runCatching {
if (!commandFile.exists()) {
return null
}
Comment on lines +39 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
runCatching は inline 関数のため、この return null は非ローカル return となり consumeOpenCommand 全体から抜けます(getOrNull() もスキップ)。動作はしますが意図が分かりにくく、将来このブロックを inline でない関数に抽出した際にコンパイルエラーになる等、壊れやすい構造です。return null ではなく return@runCatching null を使うか、明示的な return を持つ関数本体に書き換えると読みやすくなります。

val text = commandFile.readText()
commandFile.delete()
JSONObject(text).optString("url").takeIf { it.isNotBlank() }
Comment on lines +42 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
commandFile.delete() の戻り値が無視されています。削除に失敗した場合(権限不足・ファイルロック等)は同じコマンドファイルが残るため、ポーリングのたびに同じ URL が再処理され onOpenUrl(および navController.navigate)が毎秒繰り返し呼ばれます。また、readText → delete → パースの順序のため、エージェントの書き込み途中(JSON 未完成)を読み込んだケースではパース前にファイルが削除され、コマンドが静かに失われます(エージェントには「要求を受け付けた」と返っているのにブラウザが開かない)。パースを先に行い、パース成功かつ delete() が true を返した場合のみ消費する形にすると、両方の問題を回避できます。

Suggestion:

Suggested change
val text = commandFile.readText()
commandFile.delete()
JSONObject(text).optString("url").takeIf { it.isNotBlank() }
private fun consumeOpenCommand(commandFile: File): String? {
if (!commandFile.exists()) return null
return runCatching {
val text = commandFile.readText()
val url = JSONObject(text).optString("url").takeIf { it.isNotBlank() }
if (url != null && commandFile.delete()) url else null
}.getOrNull()
}

}.getOrNull()
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ fun GuestBrowserScreen(
AndroidView(
modifier = Modifier.weight(1f),
factory = { context ->
// Lets the in-guest agent drive this WebView over CDP (the devtools abstract
// socket is reachable from the guest, which shares the app's UID), so a page
// can be shown to the user and operated by human and agent at the same time.
WebView.setWebContentsDebuggingEnabled(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security · medium]
WebView.setWebContentsDebuggingEnabled(true) はプロセス全体に効くグローバル設定です。有効にすると、webview_devtools_remote_<pid> 抽象ソケットへ接続できるすべてのプロセスが、アプリ内の全 WebView の内容を CDP 経由で読み取り・書き換え・JS インジェクション・フォーム入力/認証情報の傍受ができるようになり、Android 公式ガイダンスでも本番ビルドでの有効化は推奨されていません。ゲストエージェントからの CDP 接続が本機能の意図だとしても、BuildConfig のビルドタイプや設定フラグで有効/無効を切り替えられるようにし、リリース配布時に意図せずデバッグ機能が有効にならないよう制御することを推奨します。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
この設定はプロセスグローバルな静的設定であり、WebView インスタンスごとに呼ぶ必要はありません。AndroidView の factory は再コンポジションや画面再生成のたびに再実行され得るため、この場所に置くと冗長で、グローバル設定が UI の詳細実装に埋もれてしまいます。Application.onCreate() などで一度だけ呼び出す方が、設定の意図と適用タイミングが明確になります。

WebView(context).apply {
layoutParams =
ViewGroup.LayoutParams(
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/yugahashimoto/andcode/ui/AndCodeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import com.yugahashimoto.andcode.feature.assistant.SpeechResult
import com.yugahashimoto.andcode.feature.assistant.SpeechTranscriptAccumulator
import com.yugahashimoto.andcode.feature.assistant.VoiceDictationOutcome
import com.yugahashimoto.andcode.feature.assistant.VoiceDictationPolicy
import com.yugahashimoto.andcode.feature.browser.GuestBrowserCommandWatcher
import com.yugahashimoto.andcode.feature.chat.ChatHomeScreen
import com.yugahashimoto.andcode.feature.chat.ChatViewModel
import com.yugahashimoto.andcode.feature.chat.SubagentInfo
Expand Down Expand Up @@ -105,6 +106,7 @@ import com.yugahashimoto.andcode.ui.navigation.SCHEDULE_DETAIL_ROUTE_PATTERN
import com.yugahashimoto.andcode.ui.navigation.SCHEDULE_EDIT_ARG_ID
import com.yugahashimoto.andcode.ui.navigation.SCHEDULE_EDIT_ROUTE_PATTERN
import com.yugahashimoto.andcode.ui.navigation.decodeRouteArg
import com.yugahashimoto.andcode.ui.navigation.guestBrowserRoute
import com.yugahashimoto.andcode.ui.navigation.scheduleDetailRoute
import com.yugahashimoto.andcode.ui.navigation.scheduleEditRoute
import com.yugahashimoto.andcode.ui.navigation.settingsNavGraph
Expand Down Expand Up @@ -563,6 +565,15 @@ fun AndCodeApp(
}
}

// Lets the in-guest agent pop the guest browser open for the user by dropping a command
// file into the active workspace (see GuestBrowserCommandWatcher).
GuestBrowserCommandWatcher(
workspacePath = chatState.selectedWorkspacePath,
onOpenUrl = { url ->
navController.navigate(guestBrowserRoute(url)) { launchSingleTop = true }
},
)
Comment on lines +570 to +575

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · low]
このウォッチャーはアプリのルート AndCodeApp に常時マウントされ、BrowserCommandWatcher 内の while(true) ループがワークスペース選択中は 1 秒間隔のファイルポーリングをアプリのライフサイクル全体にわたって実行し続けます。ゲストブラウザを実際に使っていない時やアプリがバックグラウンドにある間も継続するため、定常的なディスクI/O(バッテリー・CPU消費)になります。

機能上はエージェントがバックグラウンドで動作している前提なので常時監視自体は意図的と思われますが、ローカルランタイムが起動中の時だけ監視する、コマンドファイルを置くディレクトリの変更を FileObserver 等でイベント駆動に切り替える、といった形でポーリングを限定することを検討してください。


val onHandoff: (String) -> Unit = { targetRuntimeId ->
val prompt = buildHandoffPrompt(chatState.messages)
pendingHandoffPrompt = targetRuntimeId to prompt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const val SCHEDULE_EDIT_ROUTE_PATTERN = "$ROUTE_SCHEDULE_EDIT?$SCHEDULE_EDIT_ARG
const val ROUTE_CODE_VIEWER = "code-viewer"
const val ROUTE_TERMINAL = "terminal"
const val ROUTE_GUEST_BROWSER = "guest-browser"
const val GUEST_BROWSER_ARG_URL = "url"
const val GUEST_BROWSER_ROUTE_PATTERN = "$ROUTE_GUEST_BROWSER?$GUEST_BROWSER_ARG_URL={$GUEST_BROWSER_ARG_URL}"

fun guestBrowserRoute(url: String): String = "$ROUTE_GUEST_BROWSER?$GUEST_BROWSER_ARG_URL=${encodeRouteArg(url)}"

const val CODE_VIEWER_ROUTE_PATTERN = "$ROUTE_CODE_VIEWER/{runtimeId}/{workspacePath}/{filePath}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,12 @@ fun NavGraphBuilder.workspaceNavGraph(
)
}

composable(ROUTE_GUEST_BROWSER) {
composable(GUEST_BROWSER_ROUTE_PATTERN) { backStack ->
val requestedUrl = backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { decodeRouteArg(it) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
decodeRouteArg は内部で Base64.getUrlDecoder().decode() を実行するため、base64url として不正な文字列が渡されると IllegalArgumentException を投げてコンポーズ時にクラッシュします。同じファイルの CODE_VIEWER ルート(runCatching で包んでいる)や他サイトと異なり、この新規コードは防御なしで呼び出しています。url 引数が不正な場合(例: ディープリンクや非エンコード経路からの遷移)にクラッシュし得るため、runCatching { ... }.getOrNull() で包んで不正引数を吸収することを推奨します。

Suggestion:

Suggested change
val requestedUrl = backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { decodeRouteArg(it) }
val requestedUrl =
backStack.arguments?.getString(GUEST_BROWSER_ARG_URL)?.let { arg ->
runCatching { decodeRouteArg(arg) }.getOrNull()
}

GuestBrowserScreen(
initialUrl = app.localRuntimeManager.installedPort()?.let { "http://127.0.0.1:$it/" }.orEmpty(),
initialUrl =
requestedUrl
?: app.localRuntimeManager.installedPort()?.let { "http://127.0.0.1:$it/" }.orEmpty(),
onBack = { navController.popBackStack() },
)
}
Expand Down
Loading
Loading