diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c307fca..fcafada 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,6 +25,8 @@ jobs: packages: 'platform-tools platforms;android-36 build-tools;36.0.0' log-accepted-android-sdk-licenses: false - uses: gradle/actions/setup-gradle@v4 + - name: Install Python test dependencies + run: python -m pip install dulwich==1.2.15 - name: Check Python runtime run: python -m unittest discover -s tests -v - name: Build and test diff --git a/README.md b/README.md index 48bff1b..88977b5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,80 @@ just `source/`, so whatever a script writes into `data/` is visible too. Binary files and files over 512 KB do not open in the editor; they can still be saved out. +## Debugger + +**Editor → bug icon** runs the project's entry point under a Python debugger. + +- Tap a line number in a `.py` file under `source/` to set or clear a breakpoint (a red dot). + Breakpoints can be changed while the program runs. +- When execution reaches one, the line is highlighted, the file opens if needed, and the + debug panel shows locals, user globals and the call stack. +- **Continue**, **Over** (next line), **Into** (into a call), **Out** (finish the function) and + **Stop**. **Eval** evaluates an expression in the paused frame. An expression still running + after 10 seconds, or when Stop is pressed, is interrupted. +- Stepping stays in the project's own files; library code runs without stopping. + +It is built on `bdb`, the base of `pdb`, and its trace hook exists only during a debug run, so +normal runs keep their full speed. Only the script's main thread is debugged. Like any Python +trace hook it acts between lines: a script blocked inside a C call, such as `time.sleep` or a +socket read, pauses on its next line. A debug run is never restarted automatically, whatever +the project's restart policy. + +## Git + +**Git** works on the selected project's `source/` folder, so `data/`, logs and secrets never +end up in commits. It runs on [dulwich](https://www.dulwich.io), a pure-Python git bundled with +the app, because a native git binary cannot be shipped or executed on Android. + +- **Initialise repository** creates one on `main` with a `.gitignore` for Python caches. +- The screen lists staged, changed and new files; commit with or without staging everything + first, and see the last 30 commits. +- **Branch** switches or creates branches. Switching refuses to overwrite uncommitted changes. +- **Remote**, **Push** and **Pull** work against GitHub or any smart-HTTP git server. Pull only + fast-forwards: when histories have diverged it says so instead of merging. +- **Clone** creates a new project from a URL, with the entry point detected as for a zip. +- **Settings** holds the author name and email (GitHub matches commits to accounts by email) + and a GitHub token. Use a fine-grained token with *Contents: read and write*. It is kept in + the Keystore-backed vault, never shown again, scrubbed from errors and never written into the + repository's config. It is only sent to `https://github.com`; other remotes, and GitHub over + plain HTTP, get no credentials. + +Git operations that rewrite files refuse while the editor has unsaved changes. SSH remotes +are not supported; use HTTPS URLs. + +## Backups + +**Backups** creates checksummed archives of a project's `source/` and `data/`. Secrets, logs +and caches are never included, and the checksum is verified before every restore. + +**Choose folder** keeps backups outside the app as well. The system folder picker accepts +device storage, a memory card, or a cloud app that offers folders, such as Google Drive or +OneDrive, with no account or API key in runcode. **Back up to folder** writes a fresh backup, +reads the copy back and compares its SHA-256; a copy that does not match is removed. Backups +already in the folder can be verified and restored from the same screen, including on another +device. + +**Daily automatic backup** is off by default. When on, it backs up every project once a day +while runcode is running, keeps the last 7 automatic copies per project and never deletes +manual ones. + +## Diagnostics + +**System → Diagnostics → Run** checks the device and the network and produces a report to copy +or share: + +- App: Android version and ABI, free storage and memory, notification permission, battery + optimisation, the Python interpreter, whether the foreground service really holds running + work, and the last crash. +- Network: connection type and validation, VPN, LAN address, reserved ports, DNS, HTTPS, and + whether the Telegram API is reachable (bots need it; some networks block it). +- Services: whether each running static site or Python HTTP API accepts connections on its + port. Other project types do not listen on a port and are not judged by one. +- MCP bridge: a `GET /health` self-test, and the public tunnel's state. + +**Share logs** sends the latest report and every log line through the share sheet. Secrets +were already redacted when each line was logged. + ## Project settings Open **Projects → settings icon** to edit the port, restart policy, start on boot, CPU/heap/idle @@ -109,9 +183,14 @@ Authorization: Bearer Content-Type: application/json ``` -Seventeen tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, +Twenty-seven tools: `list_projects`, `get_project`, `update_project_settings`, `list_files`, `read_file`, `write_file`, `create_directory`, `rename_path`, `delete_path`, `set_entry_point`, `start_service`, -`stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`. +`stop_service`, `service_status`, `get_logs`, `run_command`, `run_python`, `sql_query`, +`run_diagnostics`, `backup_project`, `git_status`, `git_commit`, `git_push`, `git_pull`, +`debug_start`, `debug_control`, `debug_status`, `debug_eval`. + +`debug_start` and `debug_control` wait (up to `wait_ms`) for the program to pause or end, and +return the state: file, line, call stack and variables. File changes made over the bridge show up in the app straight away: the file tree refreshes, and a file open in the editor reloads unless it has unsaved edits. @@ -137,6 +216,27 @@ curl -s -X POST http://127.0.0.1:8765/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` +### Connecting from anywhere + +Turn on **Public URL (internet)** under the bridge. The app opens an outbound SSH reverse +tunnel and shows an `https://…/mcp` endpoint with a **Copy** button. No port forwarding, +public IP or extra app is needed, and it works behind carrier NAT and with a VPN on: the +connection starts on the phone, so Android sends it through the VPN like any other traffic. + +- The relay is [Pinggy](https://pinggy.io) over SSH on port 443, with + [localhost.run](https://localhost.run) on port 22 as a fallback. +- A dropped tunnel reconnects with backoff (2 s up to 30 s). Switching between Wi-Fi and + mobile data, or a VPN reconnecting, reconnects it straight away. +- Free relays hand out a new URL on every connection and Pinggy's free tunnels last 60 + minutes, so copy the endpoint again after a reconnect. +- If it keeps retrying with a VPN on, check that runcode is not excluded from the VPN + (split tunneling). + +```bash +claude mcp add --transport http runcode https://.run.pinggy-free.link/mcp \ + --header "Authorization: Bearer $RUNCODE_TOKEN" +``` + ### Security The bridge exposes a shell, arbitrary Python and read/write file access on the device. @@ -146,6 +246,9 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d - The listener binds `127.0.0.1` unless you turn on **Expose on local network**, which makes anyone on the same Wi-Fi with the token able to run commands on the device. Prefer `adb forward` or a tunnel. +- **Public URL** makes the bridge reachable from the whole internet for anyone with both the + URL and the token. The relay terminates HTTPS, so it can read requests, including the + token. Turn it off when you are done and rotate the token after sharing it. - Secret-valued environment variables are returned as ``, never echoed back. - Registered vault values are redacted from logs and text tool results. This is not an isolation boundary: MCP's arbitrary code tools and project scripts run with app privileges. @@ -159,25 +262,36 @@ The bridge exposes a shell, arbitrary Python and read/write file access on the d app/src/main/java/com/runcode/app/ runtime/ PythonEngine (Chaquopy), StaticWebEngine, engine contracts supervisor/ ServiceSupervisor — lifecycle, restart policy, ports, wake lock + git/ GitManager over runcode_git.py (dulwich); token in the vault + backup/ Checksummed archives, backups to a user-chosen folder (SAF), daily runs + diagnostics/ Device and network checks behind the System screen and run_diagnostics terminal/ TerminalSession — interactive sh plus one-shot command runner - mcp/ McpServer (JSON-RPC over HTTP), McpTools, McpToolHost + mcp/ McpServer (JSON-RPC over HTTP), McpTools, McpToolHost, McpTunnel (public URL) storage/ Path-checked project files, zip import/export, entry-point tracking database/ App metadata DB and the project SQLite browser security/ Keystore-backed secret vault, log redaction ui/ Compose screens app/src/main/python/ runcode_runner.py stdout/stderr bridge, cooperative stop, snippet runner + runcode_git.py git on dulwich, JSON in and out, credentials scrubbed + runcode_debugger.py debugger on bdb: breakpoints, stepping, eval in the paused frame ``` ## Tests ```bash +python -m pip install dulwich==1.2.15 python -m unittest discover -s tests -v ./gradlew testDebugUnitTest lintDebug assembleDebug ``` +Git tests in both suites run the real `runcode_git.py`. The Kotlin ones use the Python named by +`RUNCODE_TEST_PYTHON` or `RUNCODE_BUILD_PYTHON`, or `python3`, and are skipped when none of +them can import dulwich. + The JVM tests cover settings validation, vault rollback, secret handling, archive paths, -editor ownership and supervisor restarts. Robolectric tests use API 28; device testing is +editor ownership, supervisor restarts, diagnostics rules and probes, folder backups through a +real `DocumentsProvider`, and the public tunnel against an in-process SSH server. Robolectric tests use API 28; device testing is still needed for Keystore, Compose interaction and foreground-service behavior on API 36. ## License diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 981e4ed..9263231 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.runcode.app" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "1.4.0" + versionCode = 6 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -108,6 +108,8 @@ chaquopy { pip { install("python-telegram-bot==21.9") install("requests==2.32.3") + // Pure-Python git for the Git screen; a native git binary cannot run on Android. + install("dulwich==1.2.15") } } } @@ -135,7 +137,10 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + // SSH client for the MCP bridge's public tunnel. Pure Java, no native code. + implementation("com.github.mwiede:jsch:2.28.7") testImplementation("junit:junit:4.13.2") + testImplementation("org.apache.sshd:sshd-core:2.20.0") testImplementation("org.robolectric:robolectric:4.16.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a1b365a..4043216 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,16 @@ android:value="Local developer runtime and service supervisor" /> + + + + diff --git a/app/src/main/java/com/runcode/app/MainActivity.kt b/app/src/main/java/com/runcode/app/MainActivity.kt index cb05e1b..2ba9491 100644 --- a/app/src/main/java/com/runcode/app/MainActivity.kt +++ b/app/src/main/java/com/runcode/app/MainActivity.kt @@ -55,6 +55,7 @@ import com.runcode.app.ui.navigation.Screen import com.runcode.app.ui.screens.BackupsScreen import com.runcode.app.ui.screens.DatabaseScreen import com.runcode.app.ui.screens.EditorScreen +import com.runcode.app.ui.screens.GitScreen import com.runcode.app.ui.screens.HealthScreen import com.runcode.app.ui.screens.HomeScreen import com.runcode.app.ui.screens.ProjectsScreen @@ -140,6 +141,7 @@ fun MainApp(viewModel: MainViewModel) { Screen.HOME -> HomeScreen(viewModel = viewModel, onNavigate = { currentScreen = it }) Screen.PROJECTS -> ProjectsScreen(viewModel = viewModel, onNavigate = { currentScreen = it }) Screen.EDITOR -> EditorScreen(viewModel = viewModel) + Screen.GIT -> GitScreen(viewModel = viewModel) Screen.TERMINAL -> TerminalScreen(viewModel = viewModel) Screen.SERVICES -> ServicesScreen(viewModel = viewModel) Screen.DATABASE -> DatabaseScreen(viewModel = viewModel) @@ -151,7 +153,7 @@ fun MainApp(viewModel: MainViewModel) { } /** - * Eight destinations do not fit a phone width. Material's NavigationBar distributes its items + * Nine destinations do not fit a phone width. Material's NavigationBar distributes its items * with weights, which collapses under the infinite width a horizontal scroll hands it, so the * bar is laid out by hand instead: fixed-width items in a scrolling row. */ diff --git a/app/src/main/java/com/runcode/app/RuncodeApp.kt b/app/src/main/java/com/runcode/app/RuncodeApp.kt index 1698c2a..75e9c58 100644 --- a/app/src/main/java/com/runcode/app/RuncodeApp.kt +++ b/app/src/main/java/com/runcode/app/RuncodeApp.kt @@ -1,16 +1,30 @@ package com.runcode.app import android.app.Application +import android.net.ConnectivityManager +import android.net.Network +import androidx.annotation.VisibleForTesting import com.chaquo.python.Python +import com.runcode.app.backup.BackupFolderSettings import com.runcode.app.backup.BackupManager +import com.runcode.app.backup.FolderBackups import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager +import com.runcode.app.diagnostics.DiagnosticsRunner +import com.runcode.app.git.ChaquopyGitBackend +import com.runcode.app.git.GitBackend +import com.runcode.app.git.GitManager import com.runcode.app.domain.models.LogLevel +import com.runcode.app.domain.models.Project import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.logging.LogManager import com.runcode.app.mcp.McpServer import com.runcode.app.mcp.McpToolHost +import com.runcode.app.mcp.McpTunnel +import com.runcode.app.mcp.SshTunnelConnector import com.runcode.app.network.PortManager +import com.runcode.app.runtime.ChaquopyDebugBridge +import com.runcode.app.runtime.PythonDebugger import com.runcode.app.runtime.PythonEngine import com.runcode.app.runtime.RuntimeRegistry import com.runcode.app.runtime.StaticWebEngine @@ -23,11 +37,15 @@ import com.runcode.app.system.CompatibilityManager import com.runcode.app.system.CrashReporter import com.runcode.app.system.ProcessMonitor import com.runcode.app.terminal.TerminalSession +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File import java.security.SecureRandom @@ -47,6 +65,10 @@ class RuncodeApp : Application() { private set lateinit var backupManager: BackupManager private set + lateinit var backupFolder: BackupFolderSettings + private set + lateinit var folderBackups: FolderBackups + private set lateinit var compatibilityManager: CompatibilityManager private set lateinit var runtimeRegistry: RuntimeRegistry @@ -57,6 +79,23 @@ class RuncodeApp : Application() { private set lateinit var mcpServer: McpServer private set + lateinit var mcpTunnel: McpTunnel + private set + lateinit var debugger: PythonDebugger + private set + + /** Host keys of the tunnel relays, trusted on first use. */ + val tunnelHostKeys: File get() = File(filesDir, "tunnel_known_hosts") + + val diagnostics: DiagnosticsRunner by lazy { DiagnosticsRunner(this) } + + /** Tests swap this for the host's Python before first use of [git]. */ + @VisibleForTesting + var gitBackend: GitBackend = ChaquopyGitBackend() + + val git: GitManager by lazy { + GitManager(gitBackend, projectStorage, projectArchive, secretStore, getSharedPreferences("git", MODE_PRIVATE)) + } lateinit var processMonitor: ProcessMonitor private set lateinit var projectArchive: ProjectArchive @@ -72,9 +111,21 @@ class RuncodeApp : Application() { */ val projectChanges: SharedFlow = _projectChanges + /** Something other than the editor rewrote a project's files (MCP, git pull, checkout). */ + fun notifyProjectChanged(projectId: String) { + _projectChanges.tryEmit(projectId) + } + var isPythonAvailable: Boolean = false private set + /** + * The project id and path of the file the editor holds unsaved changes to, kept current by + * the view model, so the MCP bridge can refuse to rewrite files under those changes. + */ + @Volatile + var unsavedEditorFile: Pair? = null + override fun onCreate() { super.onCreate() @@ -89,6 +140,8 @@ class RuncodeApp : Application() { appMetaDatabase = AppMetaDatabase(this) projectDatabaseManager = ProjectDatabaseManager() backupManager = BackupManager(this, projectStorage) + backupFolder = BackupFolderSettings(this) + folderBackups = FolderBackups(backupManager, cacheDir) projectArchive = ProjectArchive(this, projectStorage) compatibilityManager = CompatibilityManager(this) terminalSession = TerminalSession(this) @@ -97,7 +150,8 @@ class RuncodeApp : Application() { // The embedded CPython has to be started once per process, before any engine uses it. isPythonAvailable = PythonEngine.ensureStarted(this) - val pythonEngine = PythonEngine(this, secretStore, processMonitor) + debugger = PythonDebugger(ChaquopyDebugBridge()) + val pythonEngine = PythonEngine(this, secretStore, processMonitor, debugger) val staticWebEngine = StaticWebEngine(portManager, processMonitor) runtimeRegistry = RuntimeRegistry(pythonEngine, staticWebEngine) @@ -125,14 +179,31 @@ class RuncodeApp : Application() { terminalSession = terminalSession, runPython = ::runPythonSnippet, projectSettings = projectSettings, - onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) } + onProjectChanged = { projectId -> _projectChanges.tryEmit(projectId) }, + runDiagnostics = { diagnostics.run().toText() }, + backupProject = ::backupProjectForMcp, + git = { git }, + debugger = debugger, + unsavedEditorFile = { projectId -> unsavedEditorFile?.takeIf { it.first == projectId }?.second } ), onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } ) + mcpTunnel = McpTunnel( + connector = SshTunnelConnector(tunnelHostKeys), + onLog = { level, message -> logManager.log(MCP_LOG_ID, "MCP Bridge", level, message) } + ) + watchDefaultNetwork() CoroutineScope(Dispatchers.IO).launch { seedStarterProjectsIfEmpty() } + // Daily backups only happen while the process is alive; the switch in the UI says so. + CoroutineScope(Dispatchers.IO).launch { + while (true) { + runAutoBackupIfDue() + delay(AUTO_BACKUP_CHECK_MS) + } + } } /** Bearer token for the MCP bridge. Generated once and kept in the Keystore-backed vault. */ @@ -154,6 +225,66 @@ class RuncodeApp : Application() { return mcpToken() } + private suspend fun backupProjectForMcp(project: Project, toFolder: Boolean): String { + if (!toFolder) { + val file = backupManager.createProjectBackup(project) + return "Created ${file.name} (${file.length()} bytes) in the project's backups/ folder." + } + val store = backupFolder.store() + ?: throw IllegalStateException("No backup folder is chosen. Pick one under Backups in the app.") + val stored = folderBackups.export(store, project) + return "Created a local backup and copied it to ${backupFolder.state.value.label} as ${stored.name}; the copy was read back and verified." + } + + private val autoBackupLock = Mutex() + + /** Backs every project up to the chosen folder when the daily run is switched on and due. */ + suspend fun runAutoBackupIfDue() = autoBackupLock.withLock { + // Checked under the lock: the hourly loop and the Backups switch can both find a run + // due, and only the first may do it. + val now = System.currentTimeMillis() + if (!backupFolder.isAutoDue(now)) return@withLock + // Never throws: it runs in an endless loop and from the UI, and one failure must not + // end daily backups or crash the app. + val result = try { + val store = backupFolder.store() + if (store == null) { + "Skipped: the backup folder is no longer accessible. Choose it again." + } else { + val projects = appMetaDatabase.getAllProjects() + val (done, error) = folderBackups.exportAll(store, projects) + if (error == null) "Backed up $done project(s)" else "Backed up $done of ${projects.size}. $error" + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "Failed: ${e.message ?: e.javaClass.simpleName}" + } + backupFolder.recordAutoRun(now, result) + logManager.log(BACKUP_LOG_ID, "Backups", LogLevel.SYSTEM, "Automatic backup: $result") + } + + /** + * Reconnects the public tunnel when the default network changes, e.g. Wi-Fi to mobile + * data or a VPN coming up or reconnecting, instead of waiting for keepalives to time out. + */ + private fun watchDefaultNetwork() { + val connectivity = getSystemService(ConnectivityManager::class.java) ?: return + try { + connectivity.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + // The first call reports the network at registration, not a change. + private var registered = false + + override fun onAvailable(network: Network) { + if (registered) mcpTunnel.onNetworkChanged() + registered = true + } + }) + } catch (e: RuntimeException) { + logManager.log(MCP_LOG_ID, "MCP Bridge", LogLevel.WARN, "Network changes are not tracked: ${e.message}") + } + } + private fun runPythonSnippet(code: String, workingDir: File): String { if (!Python.isStarted()) return "Embedded Python interpreter is not available on this device." return try { @@ -188,5 +319,7 @@ class RuncodeApp : Application() { private companion object { const val MCP_TOKEN_KEY = "MCP_BRIDGE_TOKEN" const val MCP_LOG_ID = "__mcp__" + const val BACKUP_LOG_ID = "__backup__" + const val AUTO_BACKUP_CHECK_MS = 60L * 60L * 1000L } } diff --git a/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt new file mode 100644 index 0000000..c8b8f2b --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/BackupFolderSettings.kt @@ -0,0 +1,99 @@ +package com.runcode.app.backup + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.core.content.edit +import androidx.core.net.toUri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class BackupFolderState( + /** Folder name as the provider shows it, or null when none is chosen or access was lost. */ + val label: String? = null, + val autoEnabled: Boolean = false, + val lastAutoRun: Long = 0L, + val lastAutoResult: String? = null +) + +/** The chosen backup folder and the automatic-backup switch, kept across restarts. */ +class BackupFolderSettings(private val context: Context) { + + private val prefs = context.getSharedPreferences("backup_folder", Context.MODE_PRIVATE) + + private val _state = MutableStateFlow(load()) + val state: StateFlow = _state.asStateFlow() + + private val treeUri: Uri? + get() = prefs.getString(KEY_TREE, null)?.toUri() + + /** The folder as a store, or null when none is chosen or Android revoked access. */ + fun store(): BackupStore? = treeUri?.takeIf(::hasAccess)?.let { SafBackupStore(context, it) } + + /** Keeps access to [uri] across reboots and forgets the previous folder. */ + fun choose(uri: Uri) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + treeUri?.takeIf { it != uri }?.let { old -> + runCatching { context.contentResolver.releasePersistableUriPermission(old, flags) } + } + prefs.edit { putString(KEY_TREE, uri.toString()) } + _state.value = load() + } + + fun clear() { + treeUri?.let { old -> + runCatching { + context.contentResolver.releasePersistableUriPermission( + old, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + } + prefs.edit { remove(KEY_TREE).putBoolean(KEY_AUTO, false) } + _state.value = load() + } + + fun setAutoEnabled(enabled: Boolean) { + prefs.edit { putBoolean(KEY_AUTO, enabled) } + _state.value = load() + } + + fun isAutoDue(now: Long): Boolean = + prefs.getBoolean(KEY_AUTO, false) && now - prefs.getLong(KEY_LAST_AUTO, 0L) >= FolderBackups.AUTO_INTERVAL_MS + + fun recordAutoRun(time: Long, result: String) { + prefs.edit { putLong(KEY_LAST_AUTO, time).putString(KEY_LAST_RESULT, result) } + _state.value = load() + } + + private fun hasAccess(uri: Uri): Boolean = + context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission } + + private fun load(): BackupFolderState { + val uri = treeUri?.takeIf(::hasAccess) + return BackupFolderState( + label = uri?.let(::folderName), + autoEnabled = uri != null && prefs.getBoolean(KEY_AUTO, false), + lastAutoRun = prefs.getLong(KEY_LAST_AUTO, 0L), + lastAutoResult = prefs.getString(KEY_LAST_RESULT, null) + ) + } + + private fun folderName(tree: Uri): String { + val document = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + val name = runCatching { + context.contentResolver.query(document, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } + }.getOrNull() + return name ?: DocumentsContract.getTreeDocumentId(tree).substringAfterLast(':').ifEmpty { "Chosen folder" } + } + + private companion object { + const val KEY_TREE = "tree_uri" + const val KEY_AUTO = "auto_enabled" + const val KEY_LAST_AUTO = "last_auto_run" + const val KEY_LAST_RESULT = "last_auto_result" + } +} diff --git a/app/src/main/java/com/runcode/app/backup/BackupManager.kt b/app/src/main/java/com/runcode/app/backup/BackupManager.kt index 20bf1b4..ed5f68e 100644 --- a/app/src/main/java/com/runcode/app/backup/BackupManager.kt +++ b/app/src/main/java/com/runcode/app/backup/BackupManager.kt @@ -7,6 +7,7 @@ import com.runcode.app.domain.models.ProjectProfile import com.runcode.app.storage.ProjectStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -67,18 +68,17 @@ class BackupManager( } val checksum = digest.digest().joinToString("") { "%02x".format(it) } - val manifestContent = """ -{ - "version": 1, - "projectId": "${project.id}", - "projectName": "${project.name}", - "profileId": "${project.profile.id}", - "createdAt": $timestamp, - "fileCount": ${filesToInclude.size}, - "totalSizeBytes": $totalBytes, - "checksum": "$checksum" -} -""".trimIndent() + // Built with JSONObject so a quote or backslash in the project name stays valid JSON. + val manifestContent = JSONObject() + .put("version", 1) + .put("projectId", project.id) + .put("projectName", project.name) + .put("profileId", project.profile.id) + .put("createdAt", timestamp) + .put("fileCount", filesToInclude.size) + .put("totalSizeBytes", totalBytes) + .put("checksum", checksum) + .toString(2) // Create the zip archive ZipOutputStream(BufferedOutputStream(FileOutputStream(backupFile))).use { zos -> @@ -252,24 +252,16 @@ class BackupManager( } private fun parseManifest(json: String): BackupManifest { - // Simple robust JSON extractor without external dependency - val projectId = json.substringAfter("\"projectId\": \"").substringBefore("\"") - val projectName = json.substringAfter("\"projectName\": \"").substringBefore("\"") - val profileId = json.substringAfter("\"profileId\": \"").substringBefore("\"") - val createdAt = json.substringAfter("\"createdAt\": ").substringBefore(",").trim().toLongOrNull() ?: System.currentTimeMillis() - val fileCount = json.substringAfter("\"fileCount\": ").substringBefore(",").trim().toIntOrNull() ?: 0 - val totalSizeBytes = json.substringAfter("\"totalSizeBytes\": ").substringBefore(",").trim().toLongOrNull() ?: 0L - val checksum = json.substringAfter("\"checksum\": \"").substringBefore("\"") - + val manifest = JSONObject(json) return BackupManifest( - version = 1, - projectId = projectId, - projectName = projectName, - profileId = profileId, - createdAt = createdAt, - fileCount = fileCount, - totalSizeBytes = totalSizeBytes, - checksum = checksum + version = manifest.optInt("version", 1), + projectId = manifest.optString("projectId"), + projectName = manifest.optString("projectName"), + profileId = manifest.optString("profileId"), + createdAt = manifest.optLong("createdAt", System.currentTimeMillis()), + fileCount = manifest.optInt("fileCount", 0), + totalSizeBytes = manifest.optLong("totalSizeBytes", 0L), + checksum = manifest.optString("checksum") ) } } diff --git a/app/src/main/java/com/runcode/app/backup/BackupStore.kt b/app/src/main/java/com/runcode/app/backup/BackupStore.kt new file mode 100644 index 0000000..a35e3f6 --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/BackupStore.kt @@ -0,0 +1,86 @@ +package com.runcode.app.backup + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.core.net.toUri +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** A backup archive stored outside the app. [id] is whatever the store needs to find it again. */ +data class StoredBackup(val id: String, val name: String, val sizeBytes: Long, val modifiedAt: Long) + +/** Somewhere backups can be copied to and read back from. */ +interface BackupStore { + fun list(): List + fun write(name: String, source: File): StoredBackup + fun open(id: String): InputStream + fun delete(id: String) +} + +/** + * A folder the user picked with the system folder picker (Storage Access Framework). It can be + * on the device, a memory card, or a cloud app that offers folders — Google Drive, OneDrive and + * the like — with no account or API key in this app. + */ +class SafBackupStore(private val context: Context, private val treeUri: Uri) : BackupStore { + + private val resolver get() = context.contentResolver + + private val folderDocument: Uri + get() = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + + override fun list(): List { + val children = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + val found = mutableListOf() + resolver.query(children, columns, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val name = cursor.getString(1) ?: continue + if (!name.endsWith(BACKUP_EXTENSION)) continue + val document = DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(0)) + found += StoredBackup(document.toString(), name, cursor.getLong(2), cursor.getLong(3)) + } + } ?: throw IOException("The backup folder cannot be read. Choose it again.") + return found + } + + override fun write(name: String, source: File): StoredBackup { + val document = DocumentsContract.createDocument(resolver, folderDocument, MIME_TYPE, name) + ?: throw IOException("The backup folder refused a new file. Choose it again.") + try { + resolver.openOutputStream(document, "w")?.use { output -> + source.inputStream().use { it.copyTo(output) } + } ?: throw IOException("Cannot write to the backup folder") + } catch (e: Exception) { + runCatching { DocumentsContract.deleteDocument(resolver, document) } + throw e + } + // Providers may rename on collision, e.g. "name (1).rcpkg". + val shownName = displayName(document) ?: name + return StoredBackup(document.toString(), shownName, source.length(), System.currentTimeMillis()) + } + + override fun open(id: String): InputStream = + resolver.openInputStream(id.toUri()) ?: throw IOException("Cannot read $id") + + override fun delete(id: String) { + if (!DocumentsContract.deleteDocument(resolver, id.toUri())) throw IOException("Cannot delete $id") + } + + private fun displayName(document: Uri): String? = + resolver.query(document, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null) + ?.use { if (it.moveToFirst()) it.getString(0) else null } + + companion object { + const val BACKUP_EXTENSION = ".rcpkg" + // Not a registered type; octet-stream keeps providers from appending an extension. + const val MIME_TYPE = "application/octet-stream" + } +} diff --git a/app/src/main/java/com/runcode/app/backup/FolderBackups.kt b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt new file mode 100644 index 0000000..129c74d --- /dev/null +++ b/app/src/main/java/com/runcode/app/backup/FolderBackups.kt @@ -0,0 +1,128 @@ +package com.runcode.app.backup + +import com.runcode.app.domain.models.Project +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Backups in a user-chosen folder: export with read-back verification, listing, restore, and + * the optional daily automatic run. + */ +class FolderBackups( + private val backupManager: BackupManager, + private val cacheDir: File, + private val now: () -> Long = System::currentTimeMillis +) { + + /** + * Creates a fresh backup of [project] and copies it to [store]. The copy is read back and + * compared byte for byte by hash, so a truncated upload is caught here, not at restore time. + */ + suspend fun export(store: BackupStore, project: Project, automatic: Boolean = false): StoredBackup = + withContext(Dispatchers.IO) { + val local = backupManager.createProjectBackup(project) + try { + val stored = store.write(fileName(project, automatic), local) + val expected = sha256(local.inputStream()) + val actual = try { + sha256(store.open(stored.id)) + } catch (e: IOException) { + null + } + if (actual != expected) { + runCatching { store.delete(stored.id) } + throw IOException("The copy in the backup folder does not match the original. It was removed; try again.") + } + if (automatic) prune(store, project, KEEP_AUTOMATIC) + stored + } finally { + // Automatic runs leave nothing behind locally; manual ones keep the local copy too. + if (automatic) local.delete() + } + } + + suspend fun list(store: BackupStore): List = withContext(Dispatchers.IO) { + store.list().sortedByDescending { it.modifiedAt } + } + + suspend fun verify(store: BackupStore, backup: StoredBackup): BackupPreview = + withLocalCopy(store, backup) { backupManager.verifyBackup(it) } + + suspend fun restore(store: BackupStore, backup: StoredBackup): Project = + withLocalCopy(store, backup) { backupManager.restoreProjectFromBackup(it) } + + /** Backs up every project. Returns how many succeeded and the first error, if any. */ + suspend fun exportAll(store: BackupStore, projects: List): Pair { + var done = 0 + var firstError: String? = null + projects.forEach { project -> + try { + export(store, project, automatic = true) + done++ + } catch (e: Exception) { + if (firstError == null) firstError = "${project.name}: ${e.message}" + } + } + return done to firstError + } + + /** Deletes this project's oldest automatic backups beyond [keep]. Manual ones are never touched. */ + fun prune(store: BackupStore, project: Project, keep: Int) { + store.list() + .filter { it.name.startsWith(autoPrefix(project)) } + .sortedByDescending { it.name } // names carry a sortable timestamp + .drop(keep) + .forEach { runCatching { store.delete(it.id) } } + } + + private suspend fun withLocalCopy(store: BackupStore, backup: StoredBackup, block: suspend (File) -> T): T = + withContext(Dispatchers.IO) { + val dir = File(cacheDir, "folder_backups").apply { mkdirs() } + val copy = File(dir, "import_${now()}${SafBackupStore.BACKUP_EXTENSION}") + try { + store.open(backup.id).use { input -> copy.outputStream().use { input.copyTo(it) } } + block(copy) + } finally { + copy.delete() + } + } + + fun fileName(project: Project, automatic: Boolean): String { + // UTC, so names sort in creation order across time-zone and daylight-saving changes; + // pruning relies on that order. + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date(now())) + return if (automatic) "${autoPrefix(project)}$stamp${SafBackupStore.BACKUP_EXTENSION}" + else "${safeName(project)}-${project.id}-$stamp${SafBackupStore.BACKUP_EXTENSION}" + } + + private fun autoPrefix(project: Project) = "auto-${safeName(project)}-${project.id}-" + + private fun safeName(project: Project): String = + project.name.replace(Regex("[^A-Za-z0-9._-]+"), "_").trim('_').take(40).ifEmpty { "project" } + + private fun sha256(input: InputStream): String = input.use { stream -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + while (true) { + val read = stream.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + digest.digest().joinToString("") { "%02x".format(it) } + } + + companion object { + const val KEEP_AUTOMATIC = 7 + const val AUTO_INTERVAL_MS = 24L * 60 * 60 * 1000 + } +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt new file mode 100644 index 0000000..1b8e286 --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticReport.kt @@ -0,0 +1,39 @@ +package com.runcode.app.diagnostics + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class CheckStatus { PASS, WARN, FAIL, INFO } + +data class DiagnosticCheck( + val group: String, + val name: String, + val status: CheckStatus, + val detail: String +) + +data class DiagnosticReport( + val generatedAt: Long, + val appVersion: String, + val checks: List +) { + fun count(status: CheckStatus) = checks.count { it.status == status } + + val summary: String + get() = "${count(CheckStatus.PASS)} pass, ${count(CheckStatus.WARN)} warn, ${count(CheckStatus.FAIL)} fail" + + /** Plain text meant to be pasted into an issue or a chat. */ + fun toText(): String = buildString { + val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(generatedAt)) + append("runcode diagnostics — $time — v$appVersion\n") + append("Summary: $summary\n") + val width = checks.maxOfOrNull { it.name.length } ?: 0 + checks.groupBy { it.group }.forEach { (group, items) -> + append("\n[$group]\n") + items.forEach { check -> + append(" ${check.status.name.padEnd(4)} ${check.name.padEnd(width)} ${check.detail}\n") + } + } + } +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt new file mode 100644 index 0000000..4dbb9ae --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticRules.kt @@ -0,0 +1,172 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.diagnostics.CheckStatus.FAIL +import com.runcode.app.diagnostics.CheckStatus.INFO +import com.runcode.app.diagnostics.CheckStatus.PASS +import com.runcode.app.diagnostics.CheckStatus.WARN +import com.runcode.app.domain.models.ProjectProfile + +data class DeviceSnapshot( + val apiLevel: Int, + val release: String, + val abis: List, + val freeStorageMb: Long, + val availableMemoryMb: Long, + val totalMemoryMb: Long, + val lowMemory: Boolean, + val notificationsAllowed: Boolean, + val batteryExempt: Boolean, + val pythonVersion: String?, + val lastCrash: String?, + val inForeground: Boolean, + /** Supervised services running plus the MCP bridge: anything that needs the foreground. */ + val foregroundWork: Int +) + +data class ServiceSnapshot( + val name: String, + val profile: ProjectProfile, + val port: Int, + val probe: ProbeResult? +) + +data class NetworkSnapshot( + /** Null when no network is connected at all. */ + val transports: List?, + val validated: Boolean, + val vpn: Boolean, + val lanIp: String, + val reservedPorts: Map, + val dns: ProbeResult, + val https: ProbeResult, + val telegram: ProbeResult +) + +data class BridgeSnapshot( + val running: Boolean, + val port: Int, + val health: ProbeResult?, + val tunnelEnabled: Boolean, + val tunnelOnline: Boolean, + val tunnelUrl: String?, + val tunnelError: String? +) + +/** Turns observed facts into checks. No Android and no I/O here, so it is unit tested. */ +object DiagnosticRules { + + const val APP = "App" + const val NETWORK = "Network" + const val SERVICES = "Services" + const val BRIDGE = "MCP bridge" + + /** Profiles whose runtime serves HTTP on the project's port. The rest never listen. */ + val LISTENING_PROFILES = setOf(ProjectProfile.STATIC_WEB, ProjectProfile.PYTHON_HTTP) + + fun device(s: DeviceSnapshot): List = buildList { + add(check(APP, "Android", INFO, "${s.release} (API ${s.apiLevel}), ${s.abis.joinToString()}")) + + add(when { + s.freeStorageMb < 50 -> check(APP, "Storage", FAIL, "${s.freeStorageMb} MB free — saves and logs will fail") + s.freeStorageMb < 200 -> check(APP, "Storage", WARN, "${s.freeStorageMb} MB free") + else -> check(APP, "Storage", PASS, "${s.freeStorageMb} MB free") + }) + + val memory = "${s.availableMemoryMb} of ${s.totalMemoryMb} MB available" + add(when { + s.totalMemoryMb <= 0 -> check(APP, "Memory", INFO, "not reported by Android") + s.lowMemory -> check(APP, "Memory", WARN, "$memory — Android reports low memory") + else -> check(APP, "Memory", PASS, memory) + }) + + add(if (s.notificationsAllowed) check(APP, "Notifications", PASS, "allowed") + else check(APP, "Notifications", WARN, "blocked — the running-services notification is hidden")) + + add(if (s.batteryExempt) check(APP, "Battery", PASS, "exempt from battery optimisation") + else check(APP, "Battery", WARN, "optimised — Android may stop services in the background")) + + add(if (s.pythonVersion != null) check(APP, "Python", PASS, "CPython ${s.pythonVersion} started") + else check(APP, "Python", FAIL, "embedded interpreter did not start")) + + add(when { + s.foregroundWork == 0 -> check(APP, "Foreground", INFO, "not needed — nothing is running") + s.inForeground -> check(APP, "Foreground", PASS, "held for ${s.foregroundWork} running item(s)") + else -> check(APP, "Foreground", FAIL, "work is running but the foreground service is not") + }) + + add(if (s.lastCrash == null) check(APP, "Last crash", PASS, "none recorded") + else check(APP, "Last crash", WARN, crashSummary(s.lastCrash))) + } + + /** "time · exception" from CrashReporter's header block and the trace that follows it. */ + fun crashSummary(report: String): String { + val lines = report.lines() + val time = lines.firstOrNull { it.startsWith("time:") }?.substringAfter(':')?.trim() + val headerEnd = lines.indexOfFirst { it.isBlank() } + val exception = lines.drop(headerEnd + 1).firstOrNull { it.isNotBlank() }?.trim()?.take(120) + return listOfNotNull(time, exception).joinToString(" · ").ifEmpty { "recorded" } + } + + fun network(n: NetworkSnapshot): List = buildList { + if (n.transports == null) { + add(check(NETWORK, "Connection", FAIL, "no network connected")) + } else { + val via = n.transports.joinToString(" + ").ifEmpty { "unknown" } + add(if (n.validated) check(NETWORK, "Connection", PASS, "$via, internet validated") + else check(NETWORK, "Connection", WARN, "$via, Android could not validate internet access")) + } + if (n.vpn) add(check(NETWORK, "VPN", INFO, "active — traffic leaves through the VPN")) + + add(if (n.lanIp == "127.0.0.1") check(NETWORK, "LAN address", INFO, "none — not on Wi-Fi, hotspot or Ethernet") + else check(NETWORK, "LAN address", INFO, n.lanIp)) + + add(check(NETWORK, "Reserved ports", INFO, + n.reservedPorts.entries.sortedBy { it.key }.joinToString { "${it.key}" }.ifEmpty { "none" })) + + add(probe(NETWORK, "DNS", n.dns)) + add(probe(NETWORK, "HTTPS", n.https)) + // Blocked on some networks; only bots need it, so it is a warning. + add(if (n.telegram.ok) check(NETWORK, "Telegram API", PASS, "${n.telegram.detail} in ${n.telegram.millis} ms") + else check(NETWORK, "Telegram API", WARN, "unreachable (${n.telegram.detail}) — bots need it; a VPN may be required")) + } + + fun services(list: List): List { + if (list.isEmpty()) return listOf(check(SERVICES, "Running", INFO, "no services running")) + return list.map { s -> + when { + s.profile !in LISTENING_PROFILES -> + check(SERVICES, s.name, INFO, "${s.profile.displayName}, does not serve a port") + s.probe == null -> check(SERVICES, s.name, INFO, "port ${s.port} not probed") + s.probe.ok -> check(SERVICES, s.name, PASS, "port ${s.port} accepts connections") + else -> check(SERVICES, s.name, FAIL, "port ${s.port} did not accept a connection (${s.probe.detail})") + } + } + } + + fun bridge(b: BridgeSnapshot): List = buildList { + if (!b.running) { + add(check(BRIDGE, "Bridge", INFO, "stopped")) + } else { + val health = b.health + add(when { + health == null -> check(BRIDGE, "Bridge", INFO, "port ${b.port}, not probed") + health.ok -> check(BRIDGE, "Bridge", PASS, "port ${b.port}, ${health.detail}") + else -> check(BRIDGE, "Bridge", FAIL, "port ${b.port} did not answer (${health.detail})") + }) + } + if (b.tunnelEnabled) { + add(when { + b.tunnelOnline -> check(BRIDGE, "Public URL", PASS, "${b.tunnelUrl}/mcp") + !b.running -> check(BRIDGE, "Public URL", INFO, "waits for the bridge") + else -> check(BRIDGE, "Public URL", WARN, b.tunnelError ?: "connecting") + }) + } + } + + private fun probe(group: String, name: String, result: ProbeResult) = + if (result.ok) check(group, name, PASS, "${result.detail} in ${result.millis} ms") + else check(group, name, FAIL, result.detail) + + private fun check(group: String, name: String, status: CheckStatus, detail: String) = + DiagnosticCheck(group, name, status, detail) +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt new file mode 100644 index 0000000..abb47bc --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/DiagnosticsRunner.kt @@ -0,0 +1,138 @@ +package com.runcode.app.diagnostics + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.runcode.app.BuildConfig +import com.runcode.app.RuncodeApp +import com.runcode.app.mcp.TunnelStatus +import com.runcode.app.runtime.PythonEngine +import com.runcode.app.service.RuntimeForegroundService +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.Future + +/** + * Collects the facts for a diagnostics report from Android and the running app, runs the + * network probes in parallel, and hands everything to [DiagnosticRules]. + * + * Blocking: call it off the main thread. The slowest probe bounds it at about 8 seconds. + */ +class DiagnosticsRunner(private val app: RuncodeApp) { + + fun run(): DiagnosticReport { + val pool = Executors.newFixedThreadPool(6) { r -> Thread(r, "runcode-diagnostics").apply { isDaemon = true } } + try { + fun async(block: () -> T): Future = pool.submit(Callable(block)) + + val dns = async { NetworkProbes.resolves(DNS_HOST) } + val https = async { firstReachable(HTTPS_TARGETS) } + val telegram = async { NetworkProbes.httpReachable(TELEGRAM_URL) } + + val bridge = app.mcpServer.state.value + val health = if (bridge.isRunning) async { NetworkProbes.mcpHealth(bridge.port) } else null + + val running = app.serviceSupervisor.instances.value.values.filter { it.isRunning } + val serviceProbes = running.map { instance -> + val probe = if (instance.profile in DiagnosticRules.LISTENING_PROFILES && instance.port > 0) { + async { NetworkProbes.tcpAccepts("127.0.0.1", instance.port) } + } else null + instance to probe + } + + val device = deviceSnapshot(foregroundWork = running.size + if (bridge.isRunning) 1 else 0) + val network = networkSnapshot(dns.get(), https.get(), telegram.get()) + val services = serviceProbes.map { (instance, probe) -> + ServiceSnapshot(instance.projectName, instance.profile, instance.port, probe?.get()) + } + val tunnel = app.mcpTunnel.state.value + val bridgeSnapshot = BridgeSnapshot( + running = bridge.isRunning, + port = bridge.port, + health = health?.get(), + tunnelEnabled = tunnel.status != TunnelStatus.OFF, + tunnelOnline = tunnel.status == TunnelStatus.ONLINE, + tunnelUrl = tunnel.url, + tunnelError = tunnel.lastError + ) + + val checks = DiagnosticRules.device(device) + + DiagnosticRules.network(network) + + DiagnosticRules.services(services) + + DiagnosticRules.bridge(bridgeSnapshot) + return DiagnosticReport( + System.currentTimeMillis(), + "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + checks + ) + } finally { + pool.shutdownNow() + } + } + + private fun deviceSnapshot(foregroundWork: Int): DeviceSnapshot { + val caps = app.compatibilityManager.getCapabilities(runningServicesCount = 0, wakeLockActive = false) + return DeviceSnapshot( + apiLevel = caps.androidApi, + release = caps.releaseVersion, + abis = caps.supportedAbis, + freeStorageMb = caps.freeStorageMb, + availableMemoryMb = caps.availableMemoryMb, + totalMemoryMb = caps.totalMemoryMb, + lowMemory = caps.isLowMemory, + notificationsAllowed = caps.notificationsAllowed, + batteryExempt = caps.isIgnoringBatteryOptimizations, + pythonVersion = if (app.isPythonAvailable) PythonEngine.pythonVersion() else null, + lastCrash = app.lastCrashReport(), + inForeground = RuntimeForegroundService.isInForeground, + foregroundWork = foregroundWork + ) + } + + private fun networkSnapshot(dns: ProbeResult, https: ProbeResult, telegram: ProbeResult): NetworkSnapshot { + val connectivity = app.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val caps = connectivity?.activeNetwork?.let { connectivity.getNetworkCapabilities(it) } + val transports = caps?.let { c -> + TRANSPORTS.filter { (transport, _) -> c.hasTransport(transport) }.map { it.second } + } + return NetworkSnapshot( + transports = transports, + validated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true, + vpn = caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true, + lanIp = app.portManager.getLanIp(), + reservedPorts = app.portManager.getActiveReservations(), + dns = dns, + https = https, + telegram = telegram + ) + } + + /** Some networks block one big provider but not another, so either one counts. */ + private fun firstReachable(urls: List): ProbeResult { + var last: ProbeResult? = null + for (url in urls) { + val result = NetworkProbes.httpReachable(url) + if (result.ok) return result.copy(detail = "${result.detail} from ${hostOf(url)}") + last = result + } + return last!!.copy(detail = "no HTTPS site answered (${last.detail})") + } + + private fun hostOf(url: String) = url.substringAfter("://").substringBefore('/') + + private companion object { + const val DNS_HOST = "connectivitycheck.gstatic.com" + const val TELEGRAM_URL = "https://api.telegram.org/" + val HTTPS_TARGETS = listOf( + "https://connectivitycheck.gstatic.com/generate_204", + "https://www.cloudflare.com/cdn-cgi/trace" + ) + val TRANSPORTS = listOf( + NetworkCapabilities.TRANSPORT_WIFI to "Wi-Fi", + NetworkCapabilities.TRANSPORT_CELLULAR to "mobile data", + NetworkCapabilities.TRANSPORT_ETHERNET to "Ethernet", + NetworkCapabilities.TRANSPORT_VPN to "VPN", + NetworkCapabilities.TRANSPORT_BLUETOOTH to "Bluetooth" + ) + } +} diff --git a/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt b/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt new file mode 100644 index 0000000..05bc174 --- /dev/null +++ b/app/src/main/java/com/runcode/app/diagnostics/NetworkProbes.kt @@ -0,0 +1,93 @@ +package com.runcode.app.diagnostics + +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URL +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Outcome of a probe: ok plus what was observed, or why it failed. */ +data class ProbeResult(val ok: Boolean, val detail: String, val millis: Long) + +/** Real network probes. Each one has a hard deadline so a report never hangs. */ +object NetworkProbes { + + /** Does something accept TCP connections on host:port? */ + fun tcpAccepts(host: String, port: Int, timeoutMs: Int = 2_000): ProbeResult = timed { + Socket().use { it.connect(InetSocketAddress(host, port), timeoutMs) } + "accepts connections" + } + + /** DNS lookup with a deadline; InetAddress has no timeout of its own. */ + fun resolves(host: String, timeoutMs: Long = 5_000): ProbeResult = timed { + val pool = Executors.newSingleThreadExecutor { r -> Thread(r, "runcode-dns").apply { isDaemon = true } } + try { + val addresses = pool.submit(Callable { InetAddress.getAllByName(host) }) + .get(timeoutMs, TimeUnit.MILLISECONDS) + addresses.joinToString(", ") { it.hostAddress ?: "?" }.take(80) + } catch (_: TimeoutException) { + throw IllegalStateException("no answer within ${timeoutMs / 1000}s") + } finally { + pool.shutdownNow() + } + } + + /** + * Any HTTP response at all proves the host is reachable: DNS, TCP, TLS and HTTP all worked. + * Redirects are not followed, so a 301 counts as reachable instead of chasing it. + */ + fun httpReachable(url: String, timeoutMs: Int = 6_000): ProbeResult = timed { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + connection.instanceFollowRedirects = false + connection.requestMethod = "GET" + connection.setRequestProperty("User-Agent", "runcode-diagnostics") + "HTTP ${connection.responseCode}" + } finally { + connection.disconnect() + } + } + + /** Self-test of the MCP bridge's unauthenticated health route. */ + fun mcpHealth(port: Int, timeoutMs: Int = 3_000): ProbeResult { + val result = httpBody("http://127.0.0.1:$port/health", timeoutMs) + if (!result.ok) return result + return if (result.detail.contains("runcode-mcp")) { + result.copy(detail = "GET /health answered") + } else { + result.copy(ok = false, detail = "unexpected answer: ${result.detail.take(80)}") + } + } + + private fun httpBody(url: String, timeoutMs: Int): ProbeResult = timed { + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + val code = connection.responseCode + check(code == 200) { "HTTP $code" } + connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + private inline fun timed(block: () -> String): ProbeResult { + val start = System.nanoTime() + return try { + val detail = block() + ProbeResult(true, detail, elapsed(start)) + } catch (e: Exception) { + val reason = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + ProbeResult(false, reason, elapsed(start)) + } + } + + private fun elapsed(start: Long) = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) +} diff --git a/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt b/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt new file mode 100644 index 0000000..d931cdc --- /dev/null +++ b/app/src/main/java/com/runcode/app/git/ChaquopyGitBackend.kt @@ -0,0 +1,14 @@ +package com.runcode.app.git + +import com.chaquo.python.Python +import org.json.JSONObject + +/** Runs `runcode_git.py` in the embedded interpreter. */ +class ChaquopyGitBackend : GitBackend { + override fun call(function: String, vararg args: Any): String { + if (!Python.isStarted()) { + return JSONObject().put("ok", false).put("error", "The embedded Python interpreter is not running.").toString() + } + return Python.getInstance().getModule("runcode_git").callAttr(function, *args).toString() + } +} diff --git a/app/src/main/java/com/runcode/app/git/GitManager.kt b/app/src/main/java/com/runcode/app/git/GitManager.kt new file mode 100644 index 0000000..a5fa832 --- /dev/null +++ b/app/src/main/java/com/runcode/app/git/GitManager.kt @@ -0,0 +1,219 @@ +package com.runcode.app.git + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.runcode.app.domain.models.Project +import com.runcode.app.security.SecretRedactor +import com.runcode.app.security.SecretVault +import com.runcode.app.storage.ProjectArchive +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** Calls one function of `runcode_git.py` and returns its JSON reply. */ +fun interface GitBackend { + fun call(function: String, vararg args: Any): String +} + +data class GitStatus( + val branch: String?, + val head: String?, + val added: List, + val modified: List, + val deleted: List, + val unstaged: List, + val untracked: List, + /** Commits not on the remote yet; null until the branch has been pushed or pulled once. */ + val ahead: Int?, + val behind: Int?, + val remote: String? +) { + val staged: List get() = added + modified + deleted + val isClean: Boolean get() = staged.isEmpty() && unstaged.isEmpty() && untracked.isEmpty() +} + +data class GitCommit(val sha: String, val author: String, val time: Long, val message: String) + +data class GitIdentity(val name: String, val email: String) + +class GitException(message: String) : Exception(message) + +/** + * Git for projects, backed by dulwich in the embedded Python. The repository is the project's + * `source/` folder, so data, logs and caches never end up in commits. + * + * The GitHub token lives in the Keystore-backed vault and is only ever handed to the Python + * call that needs it; the Python side scrubs it from every error message. + */ +class GitManager( + private val backend: GitBackend, + private val storage: ProjectStorage, + private val archive: ProjectArchive, + private val vault: SecretVault, + private val prefs: SharedPreferences +) { + // dulwich is not safe to run twice at once on one repository; one at a time keeps it simple. + private val mutex = Mutex() + + fun repoDir(project: Project): File = storage.getSourceDir(project.id) + + fun isRepository(project: Project): Boolean = File(repoDir(project), ".git").isDirectory + + // ---------------------------------------------------------------- settings + + var identity: GitIdentity + get() = GitIdentity(prefs.getString(KEY_NAME, "") ?: "", prefs.getString(KEY_EMAIL, "") ?: "") + set(value) = prefs.edit { putString(KEY_NAME, value.name.trim()).putString(KEY_EMAIL, value.email.trim()) } + + val hasToken: Boolean get() = vault.getSecret(TOKEN_KEY) != null + + fun setToken(token: String) { + val trimmed = token.trim() + if (trimmed.isEmpty()) vault.removeSecret(TOKEN_KEY) + else if (!vault.setSecret(TOKEN_KEY, trimmed)) throw GitException("The token could not be stored in the vault.") + } + + /** + * Username and token for [url], or empty ones. The token is a GitHub token, so it only + * goes to GitHub over HTTPS: a mistyped or hostile remote, or plain HTTP, never sees it. + */ + private fun credentialsFor(url: String?): Pair { + if (url == null || !sendsToken(url)) return "" to "" + val token = vault.getSecret(TOKEN_KEY)?.also { SecretRedactor.register(it) } ?: return "" to "" + // GitHub accepts any user name with a token; this is the one its docs use. + return TOKEN_USER to token + } + + // ---------------------------------------------------------------- local + + suspend fun init(project: Project) = run { call("init", repoDir(project).path) } + + suspend fun status(project: Project): GitStatus = run { + val r = call("status", repoDir(project).path) + val staged = r.getJSONObject("staged") + GitStatus( + branch = r.optStringOrNull("branch"), + head = r.optStringOrNull("head"), + added = staged.getJSONArray("added").strings(), + modified = staged.getJSONArray("modified").strings(), + deleted = staged.getJSONArray("deleted").strings(), + unstaged = r.getJSONArray("unstaged").strings(), + untracked = r.getJSONArray("untracked").strings(), + ahead = r.optIntOrNull("ahead"), + behind = r.optIntOrNull("behind"), + remote = r.optStringOrNull("remote") + ) + } + + /** Stages [paths], or everything including deletions when empty. */ + suspend fun stage(project: Project, paths: List = emptyList()) = run { + call("add", repoDir(project).path, JSONArray(paths).toString()) + } + + suspend fun commit(project: Project, message: String): String = run { + val who = identity + call("commit", repoDir(project).path, message, who.name, who.email).getString("sha") + } + + suspend fun log(project: Project, limit: Int = 30): List = run { + val commits = call("log", repoDir(project).path, limit.toString()).getJSONArray("commits") + (0 until commits.length()).map { i -> + val c = commits.getJSONObject(i) + GitCommit(c.getString("sha"), c.getString("author"), c.getLong("time"), c.getString("message")) + } + } + + suspend fun branches(project: Project): Pair> = run { + val r = call("branches", repoDir(project).path) + r.optStringOrNull("current") to r.getJSONArray("branches").strings() + } + + suspend fun checkout(project: Project, branch: String, create: Boolean) = run { + require(BRANCH_NAME.matches(branch)) { "Branch names use letters, digits, '.', '_', '-' and '/'." } + call("checkout", repoDir(project).path, branch, create) + } + + suspend fun setRemote(project: Project, url: String) = run { + call("set_remote", repoDir(project).path, url.trim()) + } + + // ---------------------------------------------------------------- network + + suspend fun push(project: Project) = run { + val branch = currentBranch(project) + val (user, token) = credentialsFor(remoteOf(project)) + call("push", repoDir(project).path, branch, user, token) + } + + /** Fast-forward only. Returns the number of new commits. */ + suspend fun pull(project: Project): Int = run { + val branch = currentBranch(project) + val (user, token) = credentialsFor(remoteOf(project)) + call("pull", repoDir(project).path, branch, user, token).getInt("new_commits") + } + + /** + * Clones [url] into a new project and returns it, unsaved. The repository becomes the + * project's source/ folder and the entry point is detected like a zip import. + */ + suspend fun cloneProject(url: String): Project = run { + val name = url.trim().trimEnd('/').substringAfterLast('/').removeSuffix(".git").ifBlank { "Cloned project" } + val id = storage.newProjectId() + storage.initializeProjectDirectories(id) + val source = storage.getSourceDir(id) + try { + // dulwich creates the folder itself and refuses an existing one. + source.deleteRecursively() + val (user, token) = credentialsFor(url.trim()) + call("clone", url.trim(), source.path, user, token) + archive.projectForSource(id, name, "Cloned from ${url.trim()}") + } catch (e: Exception) { + storage.deleteProject(id) + throw e + } + } + + // ---------------------------------------------------------------- plumbing + + private fun remoteOf(project: Project): String? = + call("status", repoDir(project).path).optStringOrNull("remote") + + private fun currentBranch(project: Project): String { + val r = call("branches", repoDir(project).path) + return r.optStringOrNull("current") ?: throw GitException("Check out a branch first.") + } + + private suspend fun run(block: () -> T): T = withContext(Dispatchers.IO) { mutex.withLock { block() } } + + private fun call(function: String, vararg args: Any): JSONObject { + val reply = JSONObject(backend.call(function, *args)) + if (!reply.optBoolean("ok")) throw GitException(SecretRedactor.redact(reply.optString("error", "git failed"))) + return reply + } + + private fun JSONArray.strings() = (0 until length()).map { getString(it) } + private fun JSONObject.optStringOrNull(key: String) = if (isNull(key) || !has(key)) null else getString(key) + private fun JSONObject.optIntOrNull(key: String) = if (isNull(key) || !has(key)) null else getInt(key) + + companion object { + const val TOKEN_KEY = "GIT_GITHUB_TOKEN" + const val TOKEN_USER = "x-access-token" + private const val KEY_NAME = "author_name" + private const val KEY_EMAIL = "author_email" + private val BRANCH_NAME = Regex("[A-Za-z0-9._/-]+") + private val TOKEN_HOSTS = setOf("github.com") + + /** Whether [url] is an HTTPS URL on a host the GitHub token belongs to. */ + fun sendsToken(url: String): Boolean { + val uri = runCatching { java.net.URI(url.trim()) }.getOrNull() ?: return false + return uri.scheme.equals("https", ignoreCase = true) && + uri.userInfo == null && + uri.host?.lowercase() in TOKEN_HOSTS + } + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/McpServer.kt b/app/src/main/java/com/runcode/app/mcp/McpServer.kt index 7a935e1..36a0e5c 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpServer.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpServer.kt @@ -1,5 +1,6 @@ package com.runcode.app.mcp +import com.runcode.app.BuildConfig import com.runcode.app.domain.models.LogLevel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -213,7 +214,7 @@ class McpServer( .put("capabilities", JSONObject().put("tools", JSONObject().put("listChanged", false))) .put( "serverInfo", - JSONObject().put("name", "runcode").put("version", "1.0.0") + JSONObject().put("name", "runcode").put("version", BuildConfig.VERSION_NAME) ) .put("instructions", INSTRUCTIONS) diff --git a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt index 8e66c3f..41f7380 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpToolHost.kt @@ -3,7 +3,9 @@ package com.runcode.app.mcp import com.runcode.app.database.AppMetaDatabase import com.runcode.app.database.ProjectDatabaseManager import com.runcode.app.domain.models.Project +import com.runcode.app.git.GitManager import com.runcode.app.logging.LogManager +import com.runcode.app.runtime.PythonDebugger import com.runcode.app.storage.ProjectStorage import com.runcode.app.settings.ProjectSettingsManager import com.runcode.app.supervisor.ServiceSupervisor @@ -24,7 +26,16 @@ class McpToolHost( val runPython: (code: String, workingDir: File) -> String, val projectSettings: ProjectSettingsManager, /** Tells the UI a project's files or settings changed underneath it. */ - val onProjectChanged: (projectId: String) -> Unit + val onProjectChanged: (projectId: String) -> Unit, + /** Runs the same checks as the System screen and returns the plain-text report. */ + val runDiagnostics: () -> String, + /** Backs a project up locally, and to the chosen folder when asked. Returns a summary. */ + val backupProject: suspend (project: Project, toFolder: Boolean) -> String, + /** Lazy, because building it touches the vault and preferences. */ + val git: () -> GitManager, + val debugger: PythonDebugger, + /** The file of [projectId] open in the app's editor with unsaved changes, if any. */ + val unsavedEditorFile: (projectId: String) -> String? = { null } ) { suspend fun projectOrNull(projectId: String): Project? = appMetaDatabase.getAllProjects().find { it.id == projectId } diff --git a/app/src/main/java/com/runcode/app/mcp/McpTools.kt b/app/src/main/java/com/runcode/app/mcp/McpTools.kt index d0195e4..6e33d3f 100644 --- a/app/src/main/java/com/runcode/app/mcp/McpTools.kt +++ b/app/src/main/java/com/runcode/app/mcp/McpTools.kt @@ -1,6 +1,7 @@ package com.runcode.app.mcp import com.runcode.app.domain.models.Project +import com.runcode.app.git.GitException import com.runcode.app.domain.models.RestartPolicy import com.runcode.app.security.SecretRedactor import com.runcode.app.settings.EnvironmentEdit @@ -224,6 +225,112 @@ object McpTools { ) ) + tools.put( + tool( + "backup_project", + "Create a checksummed backup of a project's source/ and data/ (never secrets). With to_folder, " + + "also copy it to the backup folder chosen in the app and verify the copy.", + properties( + "project_id" to stringProp("Project id"), + "to_folder" to JSONObject().put("type", "boolean") + ), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "debug_start", + "Run a Python project under the debugger. breakpoints is a list of {path, line} with paths " + + "relative to the project root (source/main.py) and 1-based lines; each listed file's breakpoints " + + "replace its previous ones. Waits up to wait_ms (default 10000) for the first pause or the end " + + "and returns the debugger state: file, line, stack, locals, globals.", + properties( + "project_id" to stringProp("Project id"), + "breakpoints" to JSONObject().put("type", "array").put("items", JSONObject().put("type", "object") + .put("properties", JSONObject().put("path", stringProp("source/…")).put("line", intProp("1-based line")))), + "wait_ms" to intProp("How long to wait for a pause, up to 60000") + ), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "debug_control", + "Drive a paused debug run: continue, step (into), next (over), return (out) or stop. Waits up to " + + "wait_ms (default 10000) for the next pause or the end and returns the debugger state.", + properties( + "command" to stringProp("continue, step, next, return or stop"), + "wait_ms" to intProp("How long to wait, up to 60000") + ), + required = listOf("command") + ) + ) + + tools.put(tool("debug_status", "The current debugger state: idle, starting, running, paused (with file, line, stack and variables) or finished (with the outcome).", JSONObject())) + + tools.put( + tool( + "debug_eval", + "Evaluate a Python expression in the paused frame and return its type and value.", + properties("expression" to stringProp("A Python expression")), + required = listOf("expression") + ) + ) + + tools.put( + tool( + "git_status", + "Git state of a project's source/ folder: branch, staged/unstaged/untracked files, " + + "commits ahead of or behind origin, and the remote URL.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "git_commit", + "Commit in a project's source/ folder using the author set in the app. Stages every change " + + "first unless stage_all is false. Initialises the repository if there is none.", + properties( + "project_id" to stringProp("Project id"), + "message" to stringProp("Commit message"), + "stage_all" to JSONObject().put("type", "boolean") + ), + required = listOf("project_id", "message") + ) + ) + + tools.put( + tool( + "git_push", + "Push the current branch to origin with the GitHub token stored in the app. The token is never returned.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "git_pull", + "Fetch and fast-forward the current branch from origin. Refuses when histories have diverged.", + properties("project_id" to stringProp("Project id")), + required = listOf("project_id") + ) + ) + + tools.put( + tool( + "run_diagnostics", + "Check the device and network: storage, memory, notification and battery settings, Python, " + + "the foreground service, the last crash, DNS, HTTPS, Telegram API reachability, whether each " + + "running web service accepts connections, and the bridge's own health. Takes up to ~10 s.", + JSONObject() + ) + ) + return tools } @@ -262,6 +369,17 @@ object McpTools { args.getString("sql"), args.optString("database").ifBlank { null } ) + "run_diagnostics" -> textResult(host.runDiagnostics()) + "debug_start" -> debugStart(host, args) + "debug_control" -> debugControl(host, args.getString("command"), waitMs(args)) + "debug_status" -> textResult(host.debugger.toJson().toString(2)) + "debug_eval" -> host.debugger.evaluate(args.getString("expression")).fold( + onSuccess = { textResult(it) }, onFailure = { errorResult(it.message ?: "evaluation failed") }) + "git_status" -> gitStatus(host, args.getString("project_id")) + "git_commit" -> gitCommit(host, args.getString("project_id"), args.getString("message"), args.optBoolean("stage_all", true)) + "git_push" -> gitPush(host, args.getString("project_id")) + "git_pull" -> gitPull(host, args.getString("project_id")) + "backup_project" -> backupProject(host, args.getString("project_id"), args.optBoolean("to_folder", false)) else -> errorResult("Unknown tool: $name") } } catch (e: Exception) { @@ -490,6 +608,95 @@ object McpTools { return textResult(array.toString(2)) } + private fun waitMs(args: JSONObject) = args.optLong("wait_ms", 10_000L).coerceIn(0L, 60_000L) + + private fun debugStart(host: McpToolHost, args: JSONObject): JSONObject = runBlocking { + val project = host.projectOrNull(args.getString("project_id")) ?: return@runBlocking errorResult("Project not found") + val debugger = host.debugger + args.optJSONArray("breakpoints")?.let { list -> + val byFile = mutableMapOf>() + for (i in 0 until list.length()) { + val entry = list.getJSONObject(i) + val path = entry.getString("path").removePrefix("./") + require(path.startsWith("source/") && path.endsWith(".py")) { "Breakpoints go on .py files under source/: $path" } + byFile.getOrPut(path.removePrefix("source/")) { mutableSetOf() } += entry.getInt("line") + } + byFile.forEach { (file, lines) -> debugger.setBreakpoints(project.id, file, lines) } + } + val before = debugger.state.value + debugger.arm(project.id) + val started = try { + host.serviceSupervisor.startProject(project) + } finally { + debugger.disarm(project.id) + } + if (!started) return@runBlocking errorResult("Could not start the project. Stop it first if it is running.") + textResult(debugger.toJson(debugger.awaitSettled(before, waitMs(args))).toString(2)) + } + + private fun debugControl(host: McpToolHost, command: String, waitMs: Long): JSONObject = runBlocking { + val debugger = host.debugger + val before = debugger.state.value + debugger.command(command)?.let { return@runBlocking errorResult(it) } + textResult(debugger.toJson(debugger.awaitSettled(before, waitMs)).toString(2)) + } + + private fun withRepo(host: McpToolHost, projectId: String, block: suspend (Project) -> T): JSONObject = runBlocking { + val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") + try { + val result = block(project) + if (result is JSONObject) result else textResult(result.toString()) + } catch (e: GitException) { + errorResult(e.message ?: "git failed") + } + } + + private fun gitStatus(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + val git = host.git() + if (!git.isRepository(project)) return@withRepo "No repository yet. git_commit creates one." + val s = git.status(project) + JSONObject() + .put("branch", s.branch ?: JSONObject.NULL) + .put("head", s.head ?: JSONObject.NULL) + .put("staged", JSONArray(s.staged)) + .put("unstaged", JSONArray(s.unstaged)) + .put("untracked", JSONArray(s.untracked)) + .put("ahead", s.ahead ?: JSONObject.NULL) + .put("behind", s.behind ?: JSONObject.NULL) + .put("remote", s.remote ?: JSONObject.NULL) + .toString(2) + } + + private fun gitCommit(host: McpToolHost, projectId: String, message: String, stageAll: Boolean) = + withRepo(host, projectId) { project -> + val git = host.git() + if (!git.isRepository(project)) git.init(project) + if (stageAll) git.stage(project) + val sha = git.commit(project, message) + host.onProjectChanged(project.id) + "Committed $sha on ${git.status(project).branch}" + } + + private fun gitPush(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + host.git().push(project) + "Pushed ${host.git().status(project).branch} to origin" + } + + private fun gitPull(host: McpToolHost, projectId: String) = withRepo(host, projectId) { project -> + // The same rule as the Git screen: saving those edits later would undo the pull. + host.unsavedEditorFile(project.id)?.let { file -> + return@withRepo errorResult("$file has unsaved changes in the app's editor. Save or discard them there, then pull again.") + } + val count = host.git().pull(project) + host.onProjectChanged(project.id) + if (count == 0) "Already up to date" else "Pulled $count new commit(s)" + } + + private fun backupProject(host: McpToolHost, projectId: String, toFolder: Boolean): JSONObject = runBlocking { + val project = host.projectOrNull(projectId) ?: return@runBlocking errorResult("Project not found") + textResult(host.backupProject(project, toFolder)) + } + private fun getLogs(host: McpToolHost, projectId: String?, limit: Int): JSONObject { val capped = limit.coerceIn(1, 1000) val events = host.logManager.eventsFlow.value diff --git a/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt b/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt new file mode 100644 index 0000000..cb35686 --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/McpTunnel.kt @@ -0,0 +1,227 @@ +package com.runcode.app.mcp + +import com.runcode.app.domain.models.LogLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.Closeable + +/** An SSH relay that hands out a public HTTPS URL for a reverse-forwarded port. */ +data class TunnelProvider( + val name: String, + val host: String, + val port: Int, + val user: String, + /** Port requested with tcpip-forward. 0 lets the relay choose. */ + val remotePort: Int, + /** The relay's own sites, which its banner links to. Never the tunnel URL. */ + val siteDomains: List +) { + companion object { + // SSH on 443 gets through networks that only allow web traffic. + val PINGGY = TunnelProvider("Pinggy", "free.pinggy.io", 443, "runcode", 0, listOf("pinggy.io")) + val LOCALHOST_RUN = TunnelProvider("localhost.run", "localhost.run", 22, "nokey", 80, listOf("localhost.run")) + val DEFAULTS = listOf(PINGGY, LOCALHOST_RUN) + } +} + +enum class TunnelStatus { OFF, CONNECTING, ONLINE, RETRYING } + +data class McpTunnelState( + val status: TunnelStatus = TunnelStatus.OFF, + /** Public base URL, without the /mcp path. */ + val url: String? = null, + val provider: String? = null, + val lastError: String? = null, + val failures: Int = 0, + val retryInSeconds: Int = 0 +) + +interface TunnelConnection : Closeable { + val isAlive: Boolean +} + +class OpenTunnel(val connection: TunnelConnection, val url: String) + +fun interface TunnelConnector { + /** Opens the tunnel and returns once the relay has announced its public URL. */ + fun open(provider: TunnelProvider, localPort: Int, timeoutMs: Int): OpenTunnel +} + +object TunnelUrls { + private val ANSI = Regex("\u001B\\[[0-9;?]*[A-Za-z]") + private val HTTPS = Regex("https://([A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+)/?(?=[\\s,;)\"'<>]|$)") + + /** First bare https://host in the relay's output that is not one of its own sites. */ + fun find(output: String, provider: TunnelProvider): String? { + val clean = ANSI.replace(output, "") + return HTTPS.findAll(clean) + .map { it.groupValues[1].lowercase() } + .firstOrNull { host -> provider.siteDomains.none { host == it || host.endsWith(".$it") } } + ?.let { "https://$it" } + } +} + +/** + * Keeps the MCP bridge reachable from the internet through an outbound SSH reverse tunnel. + * + * The connection starts on the device, so it works behind NAT, carrier networks and VPNs: + * with a VPN up, Android routes it through the VPN like any other app traffic. Relays are + * tried in turn; a dropped tunnel reconnects with backoff, and a network change (Wi-Fi to + * mobile data, a VPN reconnecting) reconnects straight away. Free relays hand out a new URL + * on every connection. + */ +class McpTunnel( + private val connector: TunnelConnector, + private val onLog: (LogLevel, String) -> Unit, + private val providers: List = TunnelProvider.DEFAULTS, + private val connectTimeoutMs: Int = 20_000, + private val pollMs: Long = 1_000, + private val backoffMs: (Int) -> Long = { failures -> minOf(30_000L, 2_000L shl minOf(failures - 1, 4)) } +) { + + private val _state = MutableStateFlow(McpTunnelState()) + val state: StateFlow = _state.asStateFlow() + + private val lock = Object() + private var generation = 0 + private var running = false + private var wakeRequested = false + private var reconnectRequested = false + private var current: TunnelConnection? = null + + fun start(localPort: Int) { + val gen: Int + synchronized(lock) { + if (running) return + running = true + reconnectRequested = false + gen = ++generation + } + _state.value = McpTunnelState(status = TunnelStatus.CONNECTING) + Thread({ run(gen, localPort) }, "runcode-mcp-tunnel").apply { isDaemon = true; start() } + } + + fun stop() { + val connection: TunnelConnection? + synchronized(lock) { + if (!running) return + running = false + generation++ + connection = current + current = null + wakeRequested = true + lock.notifyAll() + } + closeQuietly(connection) + _state.value = McpTunnelState() + onLog(LogLevel.SYSTEM, "[MCP] Public tunnel stopped.") + } + + /** The default network changed; the old socket is probably dead, so reconnect now. */ + fun onNetworkChanged() { + synchronized(lock) { + if (!running) return + reconnectRequested = true + wakeRequested = true + lock.notifyAll() + } + } + + private fun run(gen: Int, localPort: Int) { + var failures = 0 + var index = 0 + while (isCurrent(gen)) { + val provider = providers[index % providers.size] + update(gen) { it.copy(status = TunnelStatus.CONNECTING, provider = provider.name, retryInSeconds = 0) } + + val opened = try { + connector.open(provider, localPort, connectTimeoutMs) + } catch (e: Exception) { + failures++ + index++ + val reason = describe(e) + onLog(LogLevel.WARN, "[MCP] Tunnel via ${provider.name} failed: $reason") + update(gen) { it.copy(lastError = "${provider.name}: $reason", failures = failures) } + if (!pause(gen, backoffMs(failures))) break + continue + } + + if (!adopt(gen, opened.connection)) { + closeQuietly(opened.connection) + break + } + failures = 0 + update(gen) { + McpTunnelState(status = TunnelStatus.ONLINE, url = opened.url, provider = provider.name) + } + onLog(LogLevel.SYSTEM, "[MCP] Public endpoint via ${provider.name}: ${opened.url}/mcp") + + val networkChanged = hold(gen, opened.connection) + release(opened.connection) + if (!isCurrent(gen)) break + + if (networkChanged) { + onLog(LogLevel.INFO, "[MCP] Network changed; reconnecting the tunnel.") + continue + } + // Dropped after working: retry the same relay after a short pause. + failures = 1 + onLog(LogLevel.WARN, "[MCP] Tunnel via ${provider.name} dropped.") + update(gen) { it.copy(url = null, lastError = "${provider.name}: connection lost", failures = failures) } + if (!pause(gen, backoffMs(failures))) break + } + } + + /** Waits while the tunnel is up. Returns true when a network change asked for a reconnect. */ + private fun hold(gen: Int, connection: TunnelConnection): Boolean { + synchronized(lock) { + while (generation == gen && running && !reconnectRequested && connection.isAlive) { + if (!wakeRequested) lock.wait(pollMs) + wakeRequested = false + } + val changed = reconnectRequested + reconnectRequested = false + return changed + } + } + + /** Sleeps before the next attempt; a network change cuts it short. Returns false once stopped. */ + private fun pause(gen: Int, delayMs: Long) = synchronized(lock) { + if (generation != gen) return@synchronized false + update(gen) { it.copy(status = TunnelStatus.RETRYING, retryInSeconds = ((delayMs + 999) / 1000).toInt()) } + if (!wakeRequested && !reconnectRequested) lock.wait(delayMs) + wakeRequested = false + reconnectRequested = false + generation == gen && running + } + + private fun adopt(gen: Int, connection: TunnelConnection) = synchronized(lock) { + if (generation != gen || !running) return@synchronized false + current = connection + true + } + + private fun release(connection: TunnelConnection) { + synchronized(lock) { if (current === connection) current = null } + closeQuietly(connection) + } + + private fun isCurrent(gen: Int) = synchronized(lock) { generation == gen && running } + + private fun update(gen: Int, change: (McpTunnelState) -> McpTunnelState) { + synchronized(lock) { + if (generation == gen && running) _state.value = change(_state.value) + } + } + + private fun describe(e: Exception): String = + e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + + private fun closeQuietly(closeable: Closeable?) { + try { + closeable?.close() + } catch (_: Exception) { + } + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt new file mode 100644 index 0000000..c9e98f4 --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/SshTunnelConnector.kt @@ -0,0 +1,134 @@ +package com.runcode.app.mcp + +import com.jcraft.jsch.ChannelShell +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import com.jcraft.jsch.JSchChangedHostKeyException +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Opens a reverse tunnel the way `ssh -p 443 -R0:localhost:8765 free.pinggy.io` does: remote + * forward to the local bridge, then read the public URL from the relay's shell output. + */ +class SshTunnelConnector(private val knownHosts: File) : TunnelConnector { + + override fun open(provider: TunnelProvider, localPort: Int, timeoutMs: Int): OpenTunnel { + val session = JSch().getSession(provider.user, provider.host, provider.port) + // Free relays take no credentials and publish no host keys to pin, so the first key + // each relay presents is trusted and a different one later is refused. + val hostKeys = TofuHostKeys(knownHosts) + session.hostKeyRepository = hostKeys + session.setConfig("StrictHostKeyChecking", "ask") + session.setConfig("PreferredAuthentications", "none,keyboard-interactive,password") + session.userInfo = hostKeys.prompts + session.setPassword("") + try { + try { + session.connect(timeoutMs) + } catch (e: JSchChangedHostKeyException) { + throw IOException( + "${provider.host} presented a different host key than on first use. Someone may be " + + "intercepting the connection. If the relay changed its key, use Forget relay keys." + ) + } + // Keepalives notice a dead socket within about 45 seconds. + session.setServerAliveInterval(KEEPALIVE_MS) + session.setServerAliveCountMax(3) + withoutStrayInterrupt { session.setPortForwardingR(null, provider.remotePort, "127.0.0.1", localPort) } + + val channel = session.openChannel("shell") as ChannelShell + channel.setPty(false) + val output = channel.inputStream + // No timeout here: with one, JSch gives up on the first wake-up of its wait loop, + // which fails about one open in fifty. Without one it polls for up to 20 seconds. + channel.connect() + + val url = awaitUrl(provider, session, output, channel, timeoutMs) + return OpenTunnel(SshConnection(session, channel, output), url) + } catch (e: Exception) { + session.disconnect() + throw e + } + } + + private fun awaitUrl( + provider: TunnelProvider, + session: Session, + output: InputStream, + channel: ChannelShell, + timeoutMs: Int + ): String { + val text = StringBuilder() + val buffer = ByteArray(4096) + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val available = output.available() + if (available > 0) { + val n = output.read(buffer, 0, minOf(available, buffer.size)) + if (n > 0 && text.length < MAX_BANNER) text.append(String(buffer, 0, n, Charsets.UTF_8)) + TunnelUrls.find(text.toString(), provider)?.let { return it } + continue + } + if (channel.isClosed || !session.isConnected) { + throw IOException(closedReason(text)) + } + Thread.sleep(100) + } + throw IOException("No public URL within ${timeoutMs / 1000}s" + tail(text)) + } + + private fun closedReason(text: CharSequence): String = "Relay closed the session" + tail(text) + + private fun tail(text: CharSequence): String { + val last = text.toString().trim().lines().lastOrNull { it.isNotBlank() }?.trim()?.take(160) + return if (last.isNullOrEmpty()) "" else ": $last" + } + + private class SshConnection( + private val session: Session, + private val channel: ChannelShell, + private val output: InputStream + ) : TunnelConnection { + + init { + // Keep reading so the relay's status lines never fill the channel window. + Thread({ + val sink = ByteArray(1024) + try { + while (output.read(sink) >= 0) Unit + } catch (_: IOException) { + } + }, "runcode-mcp-tunnel-drain").apply { isDaemon = true; start() } + } + + override val isAlive: Boolean + get() = session.isConnected && !channel.isClosed + + override fun close() { + channel.disconnect() + session.disconnect() + } + } + + internal companion object { + const val KEEPALIVE_MS = 15_000 + const val MAX_BANNER = 64 * 1024 + + /** + * JSch wakes the thread waiting for a forward's reply with interrupt(). When the reply + * arrives before that thread sleeps, the interrupt is never consumed and the tunnel + * thread's next wait throws, ending the tunnel loop. Clear only an interrupt that + * [block] left behind; one that was already pending is kept. + */ + fun withoutStrayInterrupt(block: () -> T): T { + val pending = Thread.currentThread().isInterrupted + try { + return block() + } finally { + if (!pending) Thread.interrupted() + } + } + } +} diff --git a/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt b/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt new file mode 100644 index 0000000..d1222be --- /dev/null +++ b/app/src/main/java/com/runcode/app/mcp/TofuHostKeys.kt @@ -0,0 +1,98 @@ +package com.runcode.app.mcp + +import com.jcraft.jsch.HostKey +import com.jcraft.jsch.HostKeyRepository +import com.jcraft.jsch.UIKeyboardInteractive +import com.jcraft.jsch.UserInfo +import java.io.File + +/** + * Trust on first use for relay host keys, keyed by "[host]:port" as JSch names them. + * + * The free relays publish no keys to pin, so the first key seen is saved and any later + * connection that presents a different key is refused. Without this, anyone on the network + * path could pose as the relay, hand out a URL of their own and collect the bearer token. + * + * One instance per connection: [lastCheck] tells [prompts] which question JSch is asking. + */ +class TofuHostKeys(private val file: File) : HostKeyRepository { + + @Volatile + private var lastCheck = HostKeyRepository.OK + + /** + * Answers JSch's prompts: accept an unknown host once, never a changed key, and give the + * free relays the empty password they expect, as pressing Enter at the ssh prompt does. + */ + val prompts: UserInfo = object : UserInfo, UIKeyboardInteractive { + override fun promptYesNo(message: String?) = lastCheck == HostKeyRepository.NOT_INCLUDED + override fun promptKeyboardInteractive( + destination: String?, + name: String?, + instruction: String?, + prompt: Array?, + echo: BooleanArray? + ): Array = Array(prompt?.size ?: 0) { "" } + override fun getPassphrase(): String? = null + override fun getPassword(): String = "" + override fun promptPassword(message: String?) = true + override fun promptPassphrase(message: String?) = false + override fun showMessage(message: String?) = Unit + } + + override fun check(host: String, key: ByteArray): Int { + val offered = HostKey(host, key).key + val known = read().filter { it.host == host } + lastCheck = when { + known.isEmpty() -> HostKeyRepository.NOT_INCLUDED + known.any { it.key == offered } -> HostKeyRepository.OK + else -> HostKeyRepository.CHANGED + } + return lastCheck + } + + override fun add(hostkey: HostKey, ui: UserInfo?) { + synchronized(LOCK) { + if (read().any { it.host == hostkey.host && it.key == hostkey.key }) return + file.parentFile?.mkdirs() + file.appendText("${hostkey.host} ${hostkey.type} ${hostkey.key}\n") + } + } + + override fun remove(host: String, type: String?) = remove(host, type, null) + + override fun remove(host: String, type: String?, key: ByteArray?) { + synchronized(LOCK) { + val keep = read().filterNot { it.host == host && (type == null || it.type == type) } + file.writeText(keep.joinToString("") { "${it.host} ${it.type} ${it.key}\n" }) + } + } + + override fun getKnownHostsRepositoryID(): String = file.path + + override fun getHostKey(): Array = getHostKey(null, null) + + override fun getHostKey(host: String?, type: String?): Array = + read().filter { (host == null || it.host == host) && (type == null || it.type == type) } + .mapNotNull { entry -> + runCatching { HostKey(entry.host, java.util.Base64.getDecoder().decode(entry.key)) }.getOrNull() + } + .toTypedArray() + + private data class Entry(val host: String, val type: String, val key: String) + + private fun read(): List = synchronized(LOCK) { + if (!file.isFile) return emptyList() + file.readLines().mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size == 3) Entry(parts[0], parts[1], parts[2]) else null + } + } + + companion object { + private val LOCK = Any() + + /** Forgets every saved relay key, for when a relay really did change its key. */ + fun forgetAll(file: File) = synchronized(LOCK) { file.delete() } + } +} diff --git a/app/src/main/java/com/runcode/app/network/PortManager.kt b/app/src/main/java/com/runcode/app/network/PortManager.kt index 87a0389..fcb0392 100644 --- a/app/src/main/java/com/runcode/app/network/PortManager.kt +++ b/app/src/main/java/com/runcode/app/network/PortManager.kt @@ -56,9 +56,16 @@ class PortManager { throw IllegalStateException("No open developer port found in range 8080-8999") } + /** + * Address other devices on the local network can reach, or 127.0.0.1 when there is none. + * + * Interface order is arbitrary, so taking the first IPv4 address often picked the mobile + * data interface, whose carrier-assigned address nothing on the Wi-Fi can reach. + */ fun getLanIp(): String { return try { - val interfaces = NetworkInterface.getNetworkInterfaces() + val candidates = mutableListOf() + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return LOOPBACK while (interfaces.hasMoreElements()) { val iface = interfaces.nextElement() if (iface.isLoopback || !iface.isUp) continue @@ -66,13 +73,50 @@ class PortManager { while (addresses.hasMoreElements()) { val addr = addresses.nextElement() if (addr is Inet4Address && !addr.isLoopbackAddress) { - return addr.hostAddress ?: "127.0.0.1" + val host = addr.hostAddress ?: continue + candidates += LanCandidate(iface.name, host, addr.isSiteLocalAddress) } } } - "127.0.0.1" + selectLanIp(candidates) ?: LOOPBACK } catch (_: Exception) { - "127.0.0.1" + LOOPBACK + } + } + + data class LanCandidate(val interfaceName: String, val address: String, val isPrivate: Boolean) + + companion object { + private const val LOOPBACK = "127.0.0.1" + + // Wi-Fi, hotspot and Ethernet, then USB/Bluetooth tethering and Wi-Fi Direct. + private val LAN_PREFIXES = listOf("wlan", "swlan", "ap", "softap", "eth") + private val TETHER_PREFIXES = listOf("rndis", "usb", "ncm", "bt-pan", "p2p") + + // Mobile data (rmnet/ccmni/...), 464xlat, VPN tunnels and virtual links. + private val UNREACHABLE_PREFIXES = listOf( + "rmnet", "ccmni", "pdp", "seth", "clat", "v4-", "tun", "ppp", "ipsec", "dummy", "ifb" + ) + + /** Picks the most reachable address, or null when only unreachable ones exist. */ + fun selectLanIp(candidates: List): String? { + return candidates + .mapNotNull { candidate -> rank(candidate)?.let { it to candidate } } + .minWithOrNull(compareBy> { it.first }.thenBy { !it.second.isPrivate }) + ?.second + ?.address + } + + private fun rank(candidate: LanCandidate): Int? { + val name = candidate.interfaceName.lowercase() + return when { + UNREACHABLE_PREFIXES.any { name.startsWith(it) } -> null + LAN_PREFIXES.any { name.startsWith(it) } -> 0 + TETHER_PREFIXES.any { name.startsWith(it) } -> 1 + // Unknown interface names count only with a private address. + candidate.isPrivate -> 2 + else -> null + } } } } diff --git a/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt b/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt new file mode 100644 index 0000000..1f997d6 --- /dev/null +++ b/app/src/main/java/com/runcode/app/runtime/ChaquopyDebugBridge.kt @@ -0,0 +1,17 @@ +package com.runcode.app.runtime + +import com.chaquo.python.Python + +/** [DebugBridge] over runcode_debugger.py in the embedded interpreter. */ +class ChaquopyDebugBridge : DebugBridge { + private val module get() = Python.getInstance().getModule("runcode_debugger") + + override fun command(sessionId: String, name: String): String = + module.callAttr("command", sessionId, name).toString() + + override fun evaluate(sessionId: String, expression: String): String = + module.callAttr("evaluate", sessionId, expression).toString() + + override fun setBreakpoints(sessionId: String, file: String, linesJson: String): String = + module.callAttr("set_breakpoints", sessionId, file, linesJson).toString() +} diff --git a/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt b/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt new file mode 100644 index 0000000..db9d21b --- /dev/null +++ b/app/src/main/java/com/runcode/app/runtime/PythonDebugger.kt @@ -0,0 +1,211 @@ +package com.runcode.app.runtime + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** Calls into runcode_debugger.py. Swapped for a fake in tests. */ +interface DebugBridge { + fun command(sessionId: String, name: String): String + fun evaluate(sessionId: String, expression: String): String + fun setBreakpoints(sessionId: String, file: String, linesJson: String): String +} + +enum class DebugStatus { IDLE, STARTING, RUNNING, PAUSED, FINISHED } + +data class DebugFrame(val file: String, val line: Int, val function: String) + +data class DebugVariable(val name: String, val type: String, val value: String) + +data class DebugState( + val status: DebugStatus = DebugStatus.IDLE, + val projectId: String? = null, + /** Path relative to source/, e.g. "main.py". */ + val file: String? = null, + val line: Int = 0, + val function: String? = null, + /** "breakpoint" or "step". */ + val reason: String? = null, + val stack: List = emptyList(), + val locals: List = emptyList(), + val globals: List = emptyList(), + /** For FINISHED: completed, stopped, failed:… or fatal:… as the runner reports it. */ + val outcome: String? = null +) { + companion object { + fun parse(projectId: String, json: String): DebugState { + val o = JSONObject(json) + val status = when (o.optString("status")) { + "starting" -> DebugStatus.STARTING + "running" -> DebugStatus.RUNNING + "paused" -> DebugStatus.PAUSED + "finished" -> DebugStatus.FINISHED + else -> DebugStatus.IDLE + } + fun variables(key: String) = o.optJSONArray(key).objects().map { + DebugVariable(it.optString("name"), it.optString("type"), it.optString("value")) + } + return DebugState( + status = status, + projectId = projectId, + file = o.optString("file").ifEmpty { null }, + line = o.optInt("line", 0), + function = o.optString("function").ifEmpty { null }, + reason = o.optString("reason").ifEmpty { null }, + stack = o.optJSONArray("stack").objects().map { + DebugFrame(it.optString("file"), it.optInt("line"), it.optString("function")) + }, + locals = variables("locals"), + globals = variables("globals"), + outcome = o.optString("outcome").ifEmpty { null } + ) + } + + private fun JSONArray?.objects(): List = + if (this == null) emptyList() else (0 until length()).map { getJSONObject(it) } + } +} + +/** + * The debugger as the app sees it: breakpoints per project file, the state of the one debug + * run that can exist at a time, and the commands that drive it. + * + * A debug run is an ordinary run of the project through the supervisor; [arm] marks the next + * start of a project as a debug run, and the Python engine [takeLaunch]es it. + */ +class PythonDebugger(private val bridge: DebugBridge) { + + private val _state = MutableStateFlow(DebugState()) + val state: StateFlow = _state.asStateFlow() + + /** projectId -> path under source/ -> 1-based lines. */ + private val _breakpoints = MutableStateFlow>>>(emptyMap()) + val breakpoints: StateFlow>>> = _breakpoints.asStateFlow() + + private val armed = ConcurrentHashMap.newKeySet() + + @Volatile + private var session: Session? = null + + private class Session(val projectId: String, val sessionId: String, val sourceDir: File) + + fun breakpointsFor(projectId: String, file: String): Set = + _breakpoints.value[projectId]?.get(file).orEmpty() + + /** Adds or removes a breakpoint; a running debug session picks the change up at once. */ + fun toggleBreakpoint(projectId: String, file: String, line: Int) { + val forProject = _breakpoints.value[projectId].orEmpty() + val lines = forProject[file].orEmpty().let { if (line in it) it - line else it + line } + val updated = if (lines.isEmpty()) forProject - file else forProject + (file to lines) + _breakpoints.value = if (updated.isEmpty()) _breakpoints.value - projectId else _breakpoints.value + (projectId to updated) + session?.takeIf { it.projectId == projectId }?.let { s -> + runCatching { bridge.setBreakpoints(s.sessionId, File(s.sourceDir, file).absolutePath, JSONArray(lines.sorted()).toString()) } + } + } + + fun setBreakpoints(projectId: String, file: String, lines: Set) { + val forProject = _breakpoints.value[projectId].orEmpty() + val updated = if (lines.isEmpty()) forProject - file else forProject + (file to lines) + _breakpoints.value = _breakpoints.value + (projectId to updated) + } + + // ---------------------------------------------------------------- launching + + /** The next start of [projectId] is a debug run. Call [disarm] after the start attempt. */ + fun arm(projectId: String) { + armed += projectId + _state.value = DebugState(DebugStatus.STARTING, projectId) + } + + fun disarm(projectId: String) { + if (armed.remove(projectId) && _state.value.projectId == projectId && _state.value.status == DebugStatus.STARTING) { + _state.value = DebugState() + } + } + + /** Called by the engine when it starts [projectId]: breakpoints as JSON, or null for a normal run. */ + fun takeLaunch(projectId: String, sessionId: String, sourceDir: File): String? { + if (!armed.remove(projectId)) return null + session = Session(projectId, sessionId, sourceDir) + val byFile = JSONObject() + _breakpoints.value[projectId].orEmpty().forEach { (file, lines) -> + byFile.put(File(sourceDir, file).absolutePath, JSONArray(lines.sorted())) + } + return byFile.toString() + } + + /** Receives state from runcode_debugger.py. */ + inner class Listener(private val projectId: String) { + @Suppress("unused") // invoked from Python + fun onState(json: String) { + val parsed = DebugState.parse(projectId, json) + _state.value = parsed + if (parsed.status == DebugStatus.FINISHED) session = null + } + } + + fun listener(projectId: String) = Listener(projectId) + + // ---------------------------------------------------------------- driving + + /** continue, step, next, return or stop. Returns an error message, or null. */ + fun command(name: String): String? { + val s = session ?: return "No debug session is running." + val reply = JSONObject(bridge.command(s.sessionId, name)) + return if (reply.optBoolean("ok")) null else reply.optString("error") + } + + /** "type: value" on success, or the error text. */ + fun evaluate(expression: String): Result { + val s = session ?: return Result.failure(IllegalStateException("No debug session is running.")) + val reply = JSONObject(bridge.evaluate(s.sessionId, expression)) + return if (reply.optBoolean("ok")) Result.success("${reply.optString("type")}: ${reply.optString("value")}") + else Result.failure(IllegalStateException(reply.optString("error"))) + } + + val isActive: Boolean get() = session != null + + /** Clears a finished run from the screen. */ + fun dismiss() { + if (_state.value.status == DebugStatus.FINISHED) _state.value = DebugState() + } + + /** + * Waits until the program pauses or ends after [since] was the current state, so a caller + * that just sent a command sees its result. Returns the state at that point, or the latest + * one after [timeoutMs]. + */ + suspend fun awaitSettled(since: DebugState, timeoutMs: Long): DebugState = + withTimeoutOrNull(timeoutMs) { + state.first { it !== since && it.status in SETTLED } + } ?: state.value + + fun toJson(s: DebugState = state.value): JSONObject { + fun vars(list: List) = JSONArray(list.map { + JSONObject().put("name", it.name).put("type", it.type).put("value", it.value) + }) + return JSONObject() + .put("status", s.status.name.lowercase()) + .put("project_id", s.projectId ?: JSONObject.NULL) + .apply { + if (s.status == DebugStatus.PAUSED) { + put("file", s.file).put("line", s.line).put("function", s.function).put("reason", s.reason) + put("stack", JSONArray(s.stack.map { + JSONObject().put("file", it.file).put("line", it.line).put("function", it.function) + })) + put("locals", vars(s.locals)).put("globals", vars(s.globals)) + } + s.outcome?.let { put("outcome", it) } + } + } + + private companion object { + val SETTLED = setOf(DebugStatus.PAUSED, DebugStatus.FINISHED, DebugStatus.IDLE) + } +} diff --git a/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt b/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt index 70d4621..6cd5e43 100644 --- a/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/PythonEngine.kt @@ -11,6 +11,7 @@ import com.runcode.app.security.SecretStore import com.runcode.app.system.ProcessMonitor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -27,7 +28,8 @@ import java.util.concurrent.atomic.AtomicReference class PythonEngine( private val context: Context, private val secretStore: SecretStore, - private val monitor: ProcessMonitor + private val monitor: ProcessMonitor, + private val debugger: PythonDebugger? = null ) : RuntimeEngine { override val descriptor = RuntimeDescriptor( @@ -102,6 +104,10 @@ class PythonEngine( } sink.onEvent(LogLevel.SYSTEM, "Entrypoint: ${project.entryPoint} (${scriptFile.length()} bytes)") + // Non-null when this start was armed as a debug run: the breakpoints to begin with. + val debugBreakpoints = debugger?.takeLaunch(project.id, handleServiceId, File(project.projectRoot, "source")) + if (debugBreakpoints != null) sink.onEvent(LogLevel.SYSTEM, "Debugging: the script stops at breakpoints.") + val alive = AtomicBoolean(true) val reason = AtomicReference(ExitReason.RUNNING) // The Linux tid is only knowable from inside the thread itself. @@ -125,19 +131,36 @@ class PythonEngine( "fatal:the embedded Python bridge could not be loaded" } else { try { - runner.callAttr( - "run_script", - handleServiceId, - scriptFile.absolutePath, - workingDir, - envPairs.toTypedArray(), - OutputSink(sink) - ).toString() + if (debugBreakpoints != null) { + Python.getInstance().getModule(DEBUGGER_MODULE).callAttr( + "debug_script", + handleServiceId, + scriptFile.absolutePath, + workingDir, + envPairs.toTypedArray(), + OutputSink(sink), + debugger!!.listener(project.id), + debugBreakpoints + ).toString() + } else { + runner.callAttr( + "run_script", + handleServiceId, + scriptFile.absolutePath, + workingDir, + envPairs.toTypedArray(), + OutputSink(sink) + ).toString() + } } catch (e: Throwable) { sink.onEvent(LogLevel.STDERR, "${e.javaClass.simpleName}: ${e.message}") "failed:${e.javaClass.simpleName}" } } + // The Python side reports the end itself; this covers a bridge that never ran. + if (debugBreakpoints != null && debugger?.isActive == true) { + debugger.listener(project.id).onState(JSONObject().put("status", "finished").put("outcome", outcome).toString()) + } when (outcome) { "completed" -> { @@ -170,6 +193,7 @@ class PythonEngine( override val boundPort = project.network.port override val exitReason get() = reason.get() override val threadId get() = workerTid.get() + override val restartable = debugBreakpoints == null override suspend fun stop() = withContext(Dispatchers.IO) { if (!alive.get()) return@withContext @@ -216,6 +240,7 @@ class PythonEngine( companion object { private const val RUNNER_MODULE = "runcode_runner" + private const val DEBUGGER_MODULE = "runcode_debugger" private const val STOP_GRACE_MS = 3000L /** Starts the interpreter once per process. Safe to call repeatedly. */ diff --git a/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt b/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt index 03505bf..705cbb7 100644 --- a/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt +++ b/app/src/main/java/com/runcode/app/runtime/RuntimeEngine.kt @@ -73,6 +73,13 @@ interface RuntimeHandle { * Null when the runtime has no single thread to point at. */ val threadId: Long? + + /** + * False for runs a person is driving, such as a debug run: restarting one would run the + * project again without its debugger, so the supervisor leaves it stopped. + */ + val restartable: Boolean get() = true + suspend fun stop() suspend fun forceKill() suspend fun checkHealth(): RuntimeHealth diff --git a/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt b/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt index 33f9acf..9dfb6b3 100644 --- a/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt +++ b/app/src/main/java/com/runcode/app/service/RuntimeForegroundService.kt @@ -31,6 +31,11 @@ class RuntimeForegroundService : Service() { const val ACTION_STOP_ALL = "com.runcode.app.action.STOP_ALL" const val EXTRA_RUNNING_COUNT = "extra_running_count" const val EXTRA_BRIDGE_ACTIVE = "extra_bridge_active" + + /** Whether startForeground() last succeeded. Diagnostics reports this, not the intent. */ + @Volatile + var isInForeground: Boolean = false + private set } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -73,6 +78,7 @@ class RuntimeForegroundService : Service() { } else { startForeground(NOTIFICATION_ID, notification) } + isInForeground = true } catch (_: Exception) { // e.g. ForegroundServiceStartNotAllowedException when the app is in the background. // The runtimes keep going; we just cannot show the ongoing notification. @@ -80,11 +86,13 @@ class RuntimeForegroundService : Service() { } private fun shutdown() { + isInForeground = false stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } override fun onDestroy() { + isInForeground = false serviceScope.cancel() super.onDestroy() } diff --git a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt index 68f9528..1d4c660 100644 --- a/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt +++ b/app/src/main/java/com/runcode/app/storage/ProjectArchive.kt @@ -97,6 +97,10 @@ class ProjectArchive( } } + /** A project around files already placed in [id]'s source/, such as a git clone. */ + fun projectForSource(id: String, name: String, description: String): Project = + buildProject(id, storage.getProjectDir(id), null, name).copy(description = description) + private fun buildProject(id: String, root: File, manifest: JSONObject?, fallbackName: String): Project { val sourceDir = storage.getSourceDir(id) val entryPoint = detectEntryPoint(sourceDir, manifest?.optString("entry_point")) diff --git a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt index 897dd7f..7f526e1 100644 --- a/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt +++ b/app/src/main/java/com/runcode/app/supervisor/ServiceSupervisor.kt @@ -269,6 +269,20 @@ class ServiceSupervisor( val instance = _instances.value[project.id] ?: break if (instance.state == ServiceState.STOPPING || instance.state == ServiceState.STOPPED) break + if (!handle.restartable && handle.exitReason != ExitReason.STOPPED) { + // A debug run ended. Restarting it would run without the debugger. + val failed = handle.exitReason != ExitReason.COMPLETED + logManager.log(project.id, project.name, if (failed) LogLevel.WARN else LogLevel.SYSTEM, + if (failed) "Debug run ended with an error; not restarted." else "Debug run finished.") + releaseService(project.id) + updateInstance(project.id) { + it.copy(state = if (failed) ServiceState.FAILED else ServiceState.STOPPED, + lastError = if (failed) "Debug run ended with an error" else null) + } + syncSystemState() + break + } + when (handle.exitReason) { ExitReason.STOPPED -> break diff --git a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt index a324781..de598e2 100644 --- a/app/src/main/java/com/runcode/app/ui/MainViewModel.kt +++ b/app/src/main/java/com/runcode/app/ui/MainViewModel.kt @@ -1,12 +1,16 @@ package com.runcode.app.ui import android.app.Application +import android.content.Intent import android.net.Uri import android.provider.OpenableColumns +import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.runcode.app.RuncodeApp +import com.runcode.app.backup.BackupFolderState import com.runcode.app.backup.BackupPreview +import com.runcode.app.backup.StoredBackup import com.runcode.app.domain.models.DeviceCapabilities import com.runcode.app.domain.models.Project import com.runcode.app.domain.models.ProjectProfile @@ -14,7 +18,16 @@ import com.runcode.app.domain.models.QueryResult import com.runcode.app.domain.models.RuntimeEvent import com.runcode.app.domain.models.RuntimeInstance import com.runcode.app.domain.models.TableInfo +import com.runcode.app.diagnostics.DiagnosticReport +import com.runcode.app.git.GitCommit +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.GitStatus import com.runcode.app.mcp.McpServerState +import com.runcode.app.mcp.McpTunnelState +import com.runcode.app.mcp.TofuHostKeys +import com.runcode.app.mcp.TunnelStatus +import com.runcode.app.runtime.DebugState +import com.runcode.app.runtime.DebugStatus import com.runcode.app.runtime.PythonEngine import com.runcode.app.settings.ProjectSettings import com.runcode.app.storage.EntryPointEffect @@ -27,11 +40,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -61,6 +78,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _isDirty = MutableStateFlow(false) val isDirty: StateFlow = _isDirty.asStateFlow() + /** What this view model last published, so it never clears another activity's marker. */ + private var publishedUnsaved: Pair? = null + // Undo / Redo history for active file private val undoStack = ArrayDeque() private val redoStack = ArrayDeque() @@ -91,6 +111,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _backupPreview = MutableStateFlow(null) val backupPreview: StateFlow = _backupPreview.asStateFlow() + val backupFolderState: StateFlow = app.backupFolder.state + + private val _folderBackups = MutableStateFlow>(emptyList()) + val folderBackups: StateFlow> = _folderBackups.asStateFlow() + + private val _folderBusy = MutableStateFlow(false) + val folderBusy: StateFlow = _folderBusy.asStateFlow() + // System Health private val _capabilities = MutableStateFlow(null) val capabilities: StateFlow = _capabilities.asStateFlow() @@ -109,6 +137,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _mcpAllowLan = MutableStateFlow(false) val mcpAllowLan: StateFlow = _mcpAllowLan.asStateFlow() + val mcpTunnelState: StateFlow = app.mcpTunnel.state + + // The tunnel outlives this view model, so read the switch back from it. + private val _mcpPublic = MutableStateFlow(app.mcpTunnel.state.value.status != TunnelStatus.OFF) + val mcpPublic: StateFlow = _mcpPublic.asStateFlow() + /** Process-wide resource snapshot, refreshed alongside device capabilities. */ private val _processStats = MutableStateFlow(ProcessStats()) val processStats: StateFlow = _processStats.asStateFlow() @@ -121,6 +155,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val threads: Int = 0 ) + /** Everything the Git screen shows for the selected project. */ + data class GitUiState( + val projectId: String? = null, + val isRepository: Boolean = false, + val status: GitStatus? = null, + val branches: List = emptyList(), + val log: List = emptyList(), + val busy: String? = null, + val error: String? = null + ) + + private val _git = MutableStateFlow(GitUiState()) + val git: StateFlow = _git.asStateFlow() + + private val _diagnostics = MutableStateFlow(null) + val diagnostics: StateFlow = _diagnostics.asStateFlow() + + private val _diagnosticsRunning = MutableStateFlow(false) + val diagnosticsRunning: StateFlow = _diagnosticsRunning.asStateFlow() + private val _lastCrash = MutableStateFlow(app.lastCrashReport()) val lastCrash: StateFlow = _lastCrash.asStateFlow() @@ -132,13 +186,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val userMessage: StateFlow = _userMessage.asStateFlow() init { + viewModelScope.launch { app.debugger.state.collect { followDebugger(it) } } loadProjects() refreshCapabilities() viewModelScope.launch { app.projectChanges.collect { projectId -> onExternalProjectChange(projectId) } } + viewModelScope.launch { + combine(_isDirty, _selectedProject, _activeTab) { dirty, project, tab -> + if (dirty && project != null && tab != null) project.id to tab else null + }.collect { marker -> + if (marker == null) { + releaseUnsavedMarker() + } else { + publishedUnsaved = marker + app.unsavedEditorFile = marker + } + } + } + } + + /** Withdraws this view model's marker, never one another activity's view model published since. */ + private fun releaseUnsavedMarker() { + if (publishedUnsaved != null && app.unsavedEditorFile === publishedUnsaved) app.unsavedEditorFile = null + publishedUnsaved = null } + override fun onCleared() = releaseUnsavedMarker() + // ---------------------------------------------------------------- terminal fun startTerminal() { @@ -167,6 +242,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { app.mcpServer.stop() app.mcpServer.start(MCP_PORT, _mcpToken.value, allow) app.serviceSupervisor.setExternalHold(app.mcpServer.state.value.isRunning) + syncMcpTunnel() } } @@ -180,16 +256,39 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _userMessage.value = state.lastError?.let { "MCP bridge failed: $it" } ?: "MCP bridge listening on ${state.boundAddress}:${state.port}" } + syncMcpTunnel() // Hold the process in the foreground while the bridge is up, otherwise Android // reclaims it as soon as the user leaves the app and the client loses its server. app.serviceSupervisor.setExternalHold(app.mcpServer.state.value.isRunning) } + fun setMcpPublic(enabled: Boolean) { + _mcpPublic.value = enabled + syncMcpTunnel() + } + + /** For a relay that really did change its host key: trust the next key it presents. */ + fun forgetRelayKeys() { + TofuHostKeys.forgetAll(app.tunnelHostKeys) + app.mcpTunnel.onNetworkChanged() + _userMessage.value = "Relay keys forgotten; the next ones will be trusted" + } + + /** The tunnel forwards to the bridge, so it runs only while both are switched on. */ + private fun syncMcpTunnel() { + if (_mcpPublic.value && app.mcpServer.state.value.isRunning) { + app.mcpTunnel.start(MCP_PORT) + } else { + app.mcpTunnel.stop() + } + } + fun regenerateMcpToken() { _mcpToken.value = app.regenerateMcpToken() if (app.mcpServer.state.value.isRunning) { app.mcpServer.stop() app.mcpServer.start(MCP_PORT, _mcpToken.value, _mcpAllowLan.value) + syncMcpTunnel() } _userMessage.value = "New MCP token generated. Existing clients must be updated." } @@ -197,6 +296,158 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun mcpLanAddress(): String = app.portManager.getLanIp() + // ---------------------------------------------------------------- git + + val gitIdentity: GitIdentity get() = app.git.identity + val gitHasToken: Boolean get() = app.git.hasToken + + fun refreshGit() { + val project = _selectedProject.value ?: run { _git.value = GitUiState(); return } + gitTask(project, null) { refreshGitState(project) } + } + + private suspend fun refreshGitState(project: Project, error: String? = null) { + val git = app.git + if (!git.isRepository(project)) { + _git.value = GitUiState(projectId = project.id, error = error) + return + } + val status = git.status(project) + val (_, branches) = git.branches(project) + _git.value = GitUiState(project.id, true, status, branches, git.log(project), error = error) + } + + fun gitInit() = gitAction("Initialising") { app.git.init(it); "Repository created on main" } + + fun gitStageAll() = gitAction("Staging") { app.git.stage(it); null } + + fun gitCommit(message: String, stageAll: Boolean) = gitAction("Committing") { project -> + if (stageAll) app.git.stage(project) + "Committed ${app.git.commit(project, message)}" + } + + fun gitPush() = gitAction("Pushing") { app.git.push(it); "Pushed to origin" } + + fun gitPull() = gitAction("Pulling", changesFiles = true) { project -> + val count = app.git.pull(project) + if (count == 0) "Already up to date" else "Pulled $count new commit(s)" + } + + fun gitCheckout(branch: String, create: Boolean) = gitAction("Switching branch", changesFiles = true) { project -> + app.git.checkout(project, branch, create) + "On branch $branch" + } + + fun gitSetRemote(url: String) = gitAction("Saving remote") { app.git.setRemote(it, url); "Remote saved" } + + fun saveGitSettings(name: String, email: String, token: String?) { + app.git.identity = GitIdentity(name, email) + try { + token?.let { app.git.setToken(it) } + _userMessage.value = if (token?.isBlank() == true) "Git settings saved; token removed" else "Git settings saved" + } catch (e: Exception) { + _userMessage.value = e.message + } + } + + /** Clones into a new project, saves it and opens it. */ + fun gitClone(url: String) { + if (_git.value.busy != null) return + _git.value = _git.value.copy(busy = "Cloning", error = null) + viewModelScope.launch { + try { + val project = app.git.cloneProject(url) + app.appMetaDatabase.insertOrUpdateProject(project) + loadProjects() + selectProject(project) + _userMessage.value = "Cloned into '${project.name}'" + _git.value = GitUiState() + refreshGit() + } catch (e: Exception) { + _git.value = _git.value.copy(busy = null, error = e.message) + } + } + } + + /** + * Runs a git operation on the selected project and refreshes the screen. Operations that + * rewrite files refuse while the editor has unsaved changes, which they would otherwise + * be silently overwritten by on the next save. + */ + private fun gitAction(label: String, changesFiles: Boolean = false, block: suspend (Project) -> String?) { + val project = _selectedProject.value ?: return + if (changesFiles && _isDirty.value) { + _git.value = _git.value.copy(error = "Save the file open in the editor first.") + return + } + gitTask(project, label) { + val message = block(project) + if (changesFiles) app.notifyProjectChanged(project.id) + refreshGitState(project) + message?.let { _userMessage.value = it } + } + } + + private fun gitTask(project: Project, label: String?, block: suspend () -> Unit) { + if (_git.value.busy != null) return + _git.value = _git.value.copy(projectId = project.id, busy = label ?: "Loading", error = null) + viewModelScope.launch { + try { + block() + } catch (e: Exception) { + runCatching { refreshGitState(project, e.message ?: e.javaClass.simpleName) } + .onFailure { _git.value = _git.value.copy(error = e.message) } + } finally { + _git.value = _git.value.copy(busy = null) + } + } + } + + // ---------------------------------------------------------------- diagnostics + + fun runDiagnostics() { + if (_diagnosticsRunning.value) return + _diagnosticsRunning.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + _diagnostics.value = app.diagnostics.run() + } catch (e: Exception) { + _userMessage.value = "Diagnostics failed: ${e.message}" + } finally { + _diagnosticsRunning.value = false + } + } + } + + /** + * Writes the latest diagnostics report and every log line in memory to a shareable file. + * Secrets were already redacted when each line was logged. + */ + fun logShareIntent(): Intent? { + return try { + val dir = File(app.cacheDir, "shared").apply { mkdirs() } + dir.listFiles()?.forEach { it.delete() } + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val file = File(dir, "runcode-logs-$stamp.txt") + file.writeText(buildString { + _diagnostics.value?.let { append(it.toText()).append("\n") } + append("---- logs ----\n") + append(app.logManager.exportLogs().ifEmpty { "(no log lines)\n" }) + }) + val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", file) + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "runcode logs $stamp") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + Intent.createChooser(send, "Share runcode logs") + } catch (e: Exception) { + _userMessage.value = "Could not prepare logs: ${e.message}" + null + } + } + fun dismissCrashReport() { app.clearCrashReport() _lastCrash.value = null @@ -723,6 +974,72 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + // ---------------------------------------------------------------- debugger + + val debugState: StateFlow = app.debugger.state + val breakpoints: StateFlow>>> = app.debugger.breakpoints + + private val _debugEvalResult = MutableStateFlow(null) + val debugEvalResult: StateFlow = _debugEvalResult.asStateFlow() + + /** Breakpoints live on Python files under source/; the key is the path below it. */ + fun debugFileKey(tab: String?): String? = + tab?.takeIf { it.startsWith("source/") && it.endsWith(".py") }?.removePrefix("source/") + + fun toggleBreakpoint(line: Int) { + val project = _selectedProject.value ?: return + val file = debugFileKey(_activeTab.value) ?: return + app.debugger.toggleBreakpoint(project.id, file, line) + } + + /** Runs the project under the debugger; it stops at breakpoints in its Python files. */ + fun debugProject(project: Project) { + if (project.profile == ProjectProfile.STATIC_WEB) { + _userMessage.value = "Static websites have no Python to debug." + return + } + viewModelScope.launch { + if (_isDirty.value && !persistCurrentFile(announce = false)) return@launch + _debugEvalResult.value = null + app.debugger.arm(project.id) + try { + if (!app.serviceSupervisor.startProject(project)) { + _userMessage.value = "Could not start the debug run. Stop the running project first." + } + } finally { + app.debugger.disarm(project.id) + } + } + } + + fun debugCommand(name: String) { + viewModelScope.launch(Dispatchers.IO) { + app.debugger.command(name)?.let { _userMessage.value = it } + } + } + + fun debugEvaluate(expression: String) { + if (expression.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + _debugEvalResult.value = app.debugger.evaluate(expression).fold( + onSuccess = { "$expression = $it" }, + onFailure = { "$expression → ${it.message}" } + ) + } + } + + fun dismissDebug() = app.debugger.dismiss() + + /** Shows the paused line: opens its file when the debugger stops in another one. */ + private fun followDebugger(state: DebugState) { + if (state.status != DebugStatus.PAUSED) return + val project = _selectedProject.value ?: return + val file = state.file ?: return + if (state.projectId != project.id || file.startsWith("/")) return + val tab = "source/$file" + if (_activeTab.value != tab) openFile(project.id, tab) + } + fun stopProject(projectId: String) { viewModelScope.launch { app.serviceSupervisor.stopProject(projectId) @@ -812,6 +1129,76 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + // Backup folder (Storage Access Framework) + + fun chooseBackupFolder(uri: Uri) { + try { + app.backupFolder.choose(uri) + refreshFolderBackups() + } catch (e: SecurityException) { + _userMessage.value = "That folder cannot be kept across restarts. Pick another one." + } + } + + fun forgetBackupFolder() { + app.backupFolder.clear() + _folderBackups.value = emptyList() + } + + fun setAutoBackup(enabled: Boolean) { + app.backupFolder.setAutoEnabled(enabled) + if (enabled) viewModelScope.launch(Dispatchers.IO) { app.runAutoBackupIfDue(); refreshFolderBackups() } + } + + fun refreshFolderBackups() { + val store = app.backupFolder.store() ?: run { _folderBackups.value = emptyList(); return } + folderTask("Cannot read the backup folder") { + _folderBackups.value = app.folderBackups.list(store) + } + } + + fun backupToFolder(project: Project) { + val store = app.backupFolder.store() ?: return + folderTask("Backup to folder failed") { + val stored = app.folderBackups.export(store, project) + refreshBackups(project.id) + _folderBackups.value = app.folderBackups.list(store) + _userMessage.value = "Saved and verified ${stored.name} in ${app.backupFolder.state.value.label}" + } + } + + fun verifyFolderBackup(backup: StoredBackup) { + val store = app.backupFolder.store() ?: return + folderTask("Verification failed") { + _backupPreview.value = app.folderBackups.verify(store, backup) + } + } + + fun restoreFolderBackup(backup: StoredBackup) { + val store = app.backupFolder.store() ?: return + folderTask("Restore failed") { + val restored = app.folderBackups.restore(store, backup) + app.appMetaDatabase.insertOrUpdateProject(restored) + loadProjects() + withContext(Dispatchers.Main) { selectProject(restored) } + _userMessage.value = "Restored '${restored.name}' from ${backup.name}" + } + } + + private fun folderTask(failure: String, block: suspend () -> Unit) { + if (_folderBusy.value) return + _folderBusy.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + _userMessage.value = "$failure: ${e.message}" + } finally { + _folderBusy.value = false + } + } + } + fun refreshCapabilities() { viewModelScope.launch { val runningCount = app.serviceSupervisor.instances.value.values.count { it.isRunning } diff --git a/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt b/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt index d600d1f..b28fee4 100644 --- a/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt +++ b/app/src/main/java/com/runcode/app/ui/navigation/Navigation.kt @@ -1,6 +1,7 @@ package com.runcode.app.ui.navigation import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder @@ -15,6 +16,7 @@ enum class Screen(val title: String, val icon: ImageVector) { HOME("Home", Icons.Default.Dashboard), PROJECTS("Projects", Icons.Default.Folder), EDITOR("Editor", Icons.Default.Code), + GIT("Git", Icons.Default.AccountTree), TERMINAL("Shell", Icons.Default.Terminal), SERVICES("Services", Icons.Default.PlayCircle), DATABASE("Data", Icons.Default.Storage), diff --git a/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt index 1a70f31..01867f4 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/BackupsScreen.kt @@ -1,8 +1,11 @@ package com.runcode.app.ui.screens +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +21,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Restore import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.VerifiedUser @@ -31,9 +35,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,11 +49,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.runcode.app.backup.BackupFolderState +import com.runcode.app.backup.FolderBackups +import com.runcode.app.backup.StoredBackup import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -70,198 +79,187 @@ fun BackupsScreen( val project by viewModel.selectedProject.collectAsState() val backups by viewModel.backupList.collectAsState() val preview by viewModel.backupPreview.collectAsState() + val folder by viewModel.backupFolderState.collectAsState() + val folderBackups by viewModel.folderBackups.collectAsState() + val folderBusy by viewModel.folderBusy.collectAsState() var fileToRestore by remember { mutableStateOf(null) } + var folderBackupToRestore by remember { mutableStateOf(null) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - // Top Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Backups & Portability", style = MaterialTheme.typography.titleLarge, color = TextPrimary) - Text("Create verified archives and restore project state", fontSize = 12.sp, color = TextSecondary) - } - - project?.let { currentProj -> - Button( - onClick = { viewModel.createBackup(currentProj) }, - colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.testTag("backup_create_btn") - ) { - Icon(Icons.Default.Archive, contentDescription = null, tint = Color.Black, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("New Backup", color = Color.Black, fontWeight = FontWeight.Bold) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) + val pickFolder = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { viewModel.chooseBackupFolder(it) } + } + LaunchedEffect(folder.label) { viewModel.refreshFolderBackups() } - // Security Notice Card - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated), - border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) - ) { + // One scrolling list: the folder section and two backup lists do not fit a phone otherwise. + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + // Top Header Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.Security, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(10.dp)) Column { - Text("Zero Secret Leak Policy", fontWeight = FontWeight.Bold, fontSize = 12.sp, color = TextPrimary) - Text( - "Backups exclude secrets, logs, and caches by default. Checksums are cryptographically verified before restore.", - fontSize = 11.sp, - color = TextSecondary - ) + Text("Backups & Portability", style = MaterialTheme.typography.titleLarge, color = TextPrimary) + Text("Create verified archives and restore project state", fontSize = 12.sp, color = TextSecondary) } - } - } - Spacer(modifier = Modifier.height(16.dp)) + project?.let { currentProj -> + Button( + onClick = { viewModel.createBackup(currentProj) }, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("backup_create_btn") + ) { + Icon(Icons.Default.Archive, contentDescription = null, tint = Color.Black, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("New Backup", color = Color.Black, fontWeight = FontWeight.Bold) + } + } + } - Text("Available Backup Packages (${backups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - if (backups.isEmpty()) { + // Security Notice Card Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated) + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) ) { - Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - Text("No backups found for '${project?.name}'. Click 'New Backup' to generate one.", color = TextMuted) + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Security, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text("Zero Secret Leak Policy", fontWeight = FontWeight.Bold, fontSize = 12.sp, color = TextPrimary) + Text( + "Backups exclude secrets, logs, and caches by default. Checksums are cryptographically verified before restore.", + fontSize = 11.sp, + color = TextSecondary + ) + } } } - } else { - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - items(backups) { file -> - val sizeKb = file.length() / 1024 - val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(file.lastModified())) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurface), - border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) - ) { - Column(modifier = Modifier.padding(14.dp)) { - // Filename and buttons stacked rather than side by side: a long - // .rcpkg name used to squeeze the actions until "Verify" wrapped - // one letter per line and "Restore" fell off the screen entirely. - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = file.name, - fontWeight = FontWeight.Bold, - color = TextPrimary, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Size: $sizeKb KB • Created: $dateStr", - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = TextMuted - ) + } + + item { + BackupFolderCard( + state = folder, + busy = folderBusy, + projectName = project?.name, + onChoose = { pickFolder.launch(null) }, + onForget = { viewModel.forgetBackupFolder() }, + onBackup = { project?.let { viewModel.backupToFolder(it) } }, + onAutoChange = { viewModel.setAutoBackup(it) } + ) + } - Spacer(modifier = Modifier.height(10.dp)) + // Verification result sits up here so it is visible without scrolling past every backup. + preview?.let { prev -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (prev.isValid) AccentGreen.copy(alpha = 0.5f) else AccentRed.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (prev.isValid) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (prev.isValid) AccentGreen else AccentRed, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (prev.isValid) "Verification Passed" else "Verification Failed", + fontWeight = FontWeight.Bold, + color = if (prev.isValid) AccentGreen else AccentRed + ) + } + + // A failed verification with no reason on screen is useless; show why. + prev.error?.let { reason -> + Spacer(modifier = Modifier.height(6.dp)) + Text(reason, fontSize = 11.sp, color = AccentRed, lineHeight = 16.sp) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { viewModel.verifyBackup(file) }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .weight(1f) - .testTag("verify_backup_${file.name}") - ) { - Text("Verify", color = AccentCyan, maxLines = 1) - } - Button( - onClick = { fileToRestore = file }, - colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .weight(1f) - .testTag("restore_backup_${file.name}") - ) { - Text("Restore", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) - } - } - } + prev.manifest?.let { man -> + Spacer(modifier = Modifier.height(6.dp)) + Text("Project: ${man.projectName} • Files: ${man.fileCount} • SHA-256: ${man.checksum.take(12)}...", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) + Spacer(modifier = Modifier.height(4.dp)) + Text("Payload entries: ${prev.fileList.joinToString(", ")}", fontSize = 11.sp, color = TextMuted, maxLines = 2) } } } } } - // Backup Verification Result Card - preview?.let { prev -> - Spacer(modifier = Modifier.height(12.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkSurface), - border = androidx.compose.foundation.BorderStroke( - 1.dp, - if (prev.isValid) AccentGreen.copy(alpha = 0.5f) else AccentRed.copy(alpha = 0.5f) + if (folder.label != null) { + item { + Text("In the backup folder (${folderBackups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) + } + items(folderBackups, key = { it.id }) { backup -> + BackupRow( + name = backup.name, + sizeBytes = backup.sizeBytes, + modifiedAt = backup.modifiedAt, + tag = backup.name, + onVerify = { viewModel.verifyFolderBackup(backup) }, + onRestore = { folderBackupToRestore = backup } ) - ) { - Column(modifier = Modifier.padding(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (prev.isValid) Icons.Default.CheckCircle else Icons.Default.Error, - contentDescription = null, - tint = if (prev.isValid) AccentGreen else AccentRed, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (prev.isValid) "Verification Passed" else "Verification Failed", - fontWeight = FontWeight.Bold, - color = if (prev.isValid) AccentGreen else AccentRed - ) - } + } + } - // A failed verification with no reason on screen is useless; show why. - prev.error?.let { reason -> - Spacer(modifier = Modifier.height(6.dp)) - Text(reason, fontSize = 11.sp, color = AccentRed, lineHeight = 16.sp) - } + item { + Text("On this device (${backups.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) + } - prev.manifest?.let { man -> - Spacer(modifier = Modifier.height(6.dp)) - Text("Project: ${man.projectName} • Files: ${man.fileCount} • SHA-256: ${man.checksum.take(12)}...", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) - Spacer(modifier = Modifier.height(4.dp)) - Text("Payload entries: ${prev.fileList.joinToString(", ")}", fontSize = 11.sp, color = TextMuted, maxLines = 2) + if (backups.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurfaceElevated) + ) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text("No backups found for '${project?.name}'. Click 'New Backup' to generate one.", color = TextMuted) } } } + } else { + items(backups, key = { it.absolutePath }) { file -> + BackupRow( + name = file.name, + sizeBytes = file.length(), + modifiedAt = file.lastModified(), + tag = file.name, + onVerify = { viewModel.verifyBackup(file) }, + onRestore = { fileToRestore = file } + ) + } } + } // Restore Confirmation Dialog - fileToRestore?.let { file -> + if (fileToRestore != null || folderBackupToRestore != null) { + val dismiss = { fileToRestore = null; folderBackupToRestore = null } AlertDialog( - onDismissRequest = { fileToRestore = null }, + onDismissRequest = dismiss, title = { Text("Restore Project Backup?", color = TextPrimary) }, text = { Text( @@ -272,8 +270,9 @@ fun BackupsScreen( confirmButton = { Button( onClick = { - viewModel.restoreBackup(file) - fileToRestore = null + fileToRestore?.let { viewModel.restoreBackup(it) } + folderBackupToRestore?.let { viewModel.restoreFolderBackup(it) } + dismiss() }, colors = ButtonDefaults.buttonColors(containerColor = AccentGreen) ) { @@ -281,10 +280,156 @@ fun BackupsScreen( } }, dismissButton = { - TextButton(onClick = { fileToRestore = null }) { + TextButton(onClick = dismiss) { Text("Cancel", color = TextSecondary) } } ) } } + +@Composable +private fun BackupRow( + name: String, + sizeBytes: Long, + modifiedAt: Long, + tag: String, + onVerify: () -> Unit, + onRestore: () -> Unit +) { + val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(modifiedAt)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + // Filename and buttons stacked rather than side by side: a long .rcpkg name used to + // squeeze the actions until "Verify" wrapped one letter per line and "Restore" fell + // off the screen entirely. + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = name, + fontWeight = FontWeight.Bold, + color = TextPrimary, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Size: ${sizeBytes / 1024} KB • Created: $dateStr", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = TextMuted + ) + Spacer(modifier = Modifier.height(10.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onVerify, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("verify_backup_$tag") + ) { + Text("Verify", color = AccentCyan, maxLines = 1) + } + Button( + onClick = onRestore, + colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("restore_backup_$tag") + ) { + Text("Restore", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) + } + } + } + } +} + +/** The user-chosen folder: Drive, OneDrive, a memory card or local storage, via the system picker. */ +@Composable +private fun BackupFolderCard( + state: BackupFolderState, + busy: Boolean, + projectName: String?, + onChoose: () -> Unit, + onForget: () -> Unit, + onBackup: () -> Unit, + onAutoChange: (Boolean) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Folder, contentDescription = null, tint = AccentCyan, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("Backup folder", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = TextPrimary) + Text( + text = state.label ?: "Pick any folder, including Google Drive or OneDrive. No account needed here.", + fontSize = 11.sp, + color = if (state.label != null) AccentGreen else TextSecondary + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + if (state.label == null) { + Button( + onClick = onChoose, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("backup_folder_choose_btn") + ) { + Text("Choose folder", color = Color.Black, fontWeight = FontWeight.Bold) + } + return@Column + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = onBackup, + enabled = !busy && projectName != null, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).testTag("backup_to_folder_btn") + ) { + Text( + if (busy) "Working…" else "Back up to folder", + color = Color.Black, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + TextButton(onClick = onChoose) { Text("Change", fontSize = 11.sp, color = AccentCyan) } + TextButton(onClick = onForget) { Text("Forget", fontSize = 11.sp, color = TextMuted) } + } + + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Daily automatic backup", fontSize = 12.sp, color = TextPrimary) + Text( + text = "All projects, once a day while runcode is running. Keeps the last " + + "${FolderBackups.KEEP_AUTOMATIC} automatic copies per project.", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 13.sp + ) + if (state.lastAutoRun > 0) { + val time = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(state.lastAutoRun)) + Text("Last run $time: ${state.lastAutoResult ?: ""}", fontSize = 10.sp, color = TextSecondary) + } + } + Switch( + checked = state.autoEnabled, + onCheckedChange = onAutoChange, + modifier = Modifier.testTag("backup_auto_switch") + ) + } + } + } +} diff --git a/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt b/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt index 113cc4f..402bff6 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/CodeEditor.kt @@ -2,6 +2,7 @@ package com.runcode.app.ui.screens import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -24,8 +25,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -36,12 +40,14 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentRed import com.runcode.app.ui.theme.DarkSurface import com.runcode.app.ui.theme.TextMuted import com.runcode.app.ui.theme.TextPrimary import kotlinx.coroutines.flow.StateFlow private val GUTTER_WIDTH = 44.dp +private val PausedLine = Color(0x40FFB300) private val EDITOR_PADDING = 8.dp /** @@ -61,7 +67,13 @@ fun CodeEditor( documentKey: String, content: StateFlow, onContentChange: (String) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** 1-based lines with a breakpoint, drawn as a dot in the gutter. */ + breakpoints: Set = emptySet(), + /** 1-based line the debugger is paused on, highlighted; null when not paused here. */ + pausedLine: Int? = null, + /** Tapping a line number; null when breakpoints are not available for this file. */ + onLineNumberTap: ((Int) -> Unit)? = null ) { val state = remember(documentKey) { TextFieldState(content.value) } val scroll = remember(documentKey) { ScrollState(0) } @@ -108,6 +120,25 @@ fun CodeEditor( } } + val currentLineStarts by rememberUpdatedState(lineStarts) + val currentLayout by rememberUpdatedState(layout) + val currentOnTap by rememberUpdatedState(onLineNumberTap) + + /** The 1-based logical line at a y position in the gutter, or null below the text. */ + fun lineAt(y: Float, padding: Float): Int? { + val result = currentLayout ?: return null + val visual = result.getLineForVerticalPosition(y - padding + scroll.value) + val offset = result.getLineStart(visual) + val starts = currentLineStarts + var low = 0 + var high = starts.size - 1 + while (low < high) { + val mid = (low + high + 1) / 2 + if (starts[mid] <= offset) low = mid else high = mid - 1 + } + return low + 1 + } + val measurer = rememberTextMeasurer(cacheSize = 512) val numberStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = TextMuted) @@ -118,6 +149,12 @@ fun CodeEditor( .fillMaxHeight() .background(DarkSurface) .clipToBounds() + .pointerInput(Unit) { + detectTapGestures { position -> + val tap = currentOnTap ?: return@detectTapGestures + lineAt(position.y, EDITOR_PADDING.toPx())?.let(tap) + } + } .drawBehind { val result = layout ?: return@drawBehind val starts = lineStarts @@ -139,7 +176,19 @@ fun CodeEditor( for (index in low until starts.size) { val visualLine = result.getLineForOffset(starts[index].coerceAtMost(textLength)) if (result.getLineTop(visualLine) + shift > size.height) break - val number = measurer.measure((index + 1).toString(), numberStyle) + val lineNumber = index + 1 + val baseline = shift + result.getLineBaseline(visualLine) + if (lineNumber == pausedLine) { + drawRect( + color = PausedLine, + topLeft = Offset(0f, shift + result.getLineTop(visualLine)), + size = Size(size.width, result.getLineBottom(visualLine) - result.getLineTop(visualLine)) + ) + } + if (lineNumber in breakpoints) { + drawCircle(color = AccentRed, radius = 4.5.dp.toPx(), center = Offset(7.dp.toPx(), baseline - 4.dp.toPx())) + } + val number = measurer.measure(lineNumber.toString(), numberStyle) drawText( textLayoutResult = number, topLeft = Offset( @@ -171,6 +220,15 @@ fun CodeEditor( modifier = Modifier .weight(1f) .fillMaxHeight() + .drawBehind { + // The paused line, across the code, under the text. + val line = pausedLine ?: return@drawBehind + val result = layout ?: return@drawBehind + val start = lineStarts.getOrNull(line - 1) ?: return@drawBehind + val visual = result.getLineForOffset(start.coerceAtMost(result.layoutInput.text.length)) + val top = EDITOR_PADDING.toPx() - scroll.value + result.getLineTop(visual) + drawRect(PausedLine, Offset(0f, top), Size(size.width, result.getLineBottom(visual) - result.getLineTop(visual))) + } .testTag("editor_text_input") ) } diff --git a/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt b/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt new file mode 100644 index 0000000..3cc658c --- /dev/null +++ b/app/src/main/java/com/runcode/app/ui/screens/DebugPanel.kt @@ -0,0 +1,154 @@ +package com.runcode.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runcode.app.runtime.DebugState +import com.runcode.app.runtime.DebugStatus +import com.runcode.app.runtime.DebugVariable +import com.runcode.app.ui.MainViewModel +import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentGreen +import com.runcode.app.ui.theme.AccentRed +import com.runcode.app.ui.theme.DarkSurface +import com.runcode.app.ui.theme.TextMuted +import com.runcode.app.ui.theme.TextPrimary +import com.runcode.app.ui.theme.TextSecondary + +/** Controls, variables, call stack and expression evaluation for a debug run. */ +@Composable +fun DebugPanel(viewModel: MainViewModel, state: DebugState, modifier: Modifier = Modifier) { + val evalResult by viewModel.debugEvalResult.collectAsState() + var expression by remember { mutableStateOf("") } + var showStack by remember { mutableStateOf(false) } + val paused = state.status == DebugStatus.PAUSED + + Column(modifier = modifier.background(DarkSurface).padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = when (state.status) { + DebugStatus.STARTING -> "Debugger starting…" + DebugStatus.RUNNING -> "Running — stops at the next breakpoint" + DebugStatus.PAUSED -> "Paused at ${state.file}:${state.line} in ${state.function}" + + if (state.reason == "breakpoint") " (breakpoint)" else "" + DebugStatus.FINISHED -> "Debug run ended: ${state.outcome?.substringBefore(':')}" + DebugStatus.IDLE -> "" + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = if (paused) AccentCyan else TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Labels rather than icons: the meaning of step over/into/out is not obvious from glyphs. + Row(modifier = Modifier.horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + DebugButton("Continue", paused, AccentGreen, "debug_continue") { viewModel.debugCommand("continue") } + DebugButton("Over", paused, AccentCyan, "debug_over") { viewModel.debugCommand("next") } + DebugButton("Into", paused, AccentCyan, "debug_into") { viewModel.debugCommand("step") } + DebugButton("Out", paused, AccentCyan, "debug_out") { viewModel.debugCommand("return") } + DebugButton("Stop", state.status == DebugStatus.PAUSED || state.status == DebugStatus.RUNNING, AccentRed, "debug_stop") { + if (paused) viewModel.debugCommand("stop") else state.projectId?.let(viewModel::stopProject) + } + if (state.status == DebugStatus.FINISHED) { + TextButton(onClick = { viewModel.dismissDebug() }) { Text("Close", fontSize = 12.sp, color = TextSecondary) } + } + if (paused) { + TextButton(onClick = { showStack = !showStack }) { + Text(if (showStack) "Variables" else "Stack (${state.stack.size})", fontSize = 11.sp, color = TextSecondary) + } + } + } + + if (paused) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 150.dp)) { + if (showStack) { + items(state.stack) { frame -> + Text("${frame.function} ${frame.file}:${frame.line}", fontFamily = FontFamily.Monospace, + fontSize = 11.sp, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + if (state.locals.isNotEmpty()) item { Section("Locals") } + items(state.locals) { VariableRow(it) } + if (state.globals.isNotEmpty()) item { Section("Globals") } + items(state.globals) { VariableRow(it) } + if (state.locals.isEmpty() && state.globals.isEmpty()) item { Section("No variables yet") } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = expression, + onValueChange = { expression = it }, + placeholder = { Text("Expression, e.g. len(items)", fontSize = 11.sp) }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + modifier = Modifier.weight(1f).testTag("debug_eval_input") + ) + TextButton(onClick = { viewModel.debugEvaluate(expression) }, enabled = expression.isNotBlank()) { + Text("Eval", color = AccentCyan) + } + } + evalResult?.let { + Text(it, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + } else if (state.status == DebugStatus.RUNNING) { + Text( + "A script blocked in a C call, such as time.sleep or a network read, pauses on its next line.", + fontSize = 10.sp, + color = TextMuted + ) + } + } +} + +@Composable +private fun DebugButton( + label: String, + enabled: Boolean, + color: androidx.compose.ui.graphics.Color, + tag: String, + onClick: () -> Unit +) { + TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.testTag(tag)) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = if (enabled) color else TextMuted) + } +} + +@Composable +private fun Section(title: String) { + Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = TextMuted, modifier = Modifier.padding(top = 4.dp)) +} + +@Composable +private fun VariableRow(variable: DebugVariable) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(variable.name, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = AccentCyan) + Text(variable.value, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(variable.type, fontSize = 10.sp, color = TextMuted) + } +} diff --git a/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt index d5f9d55..844b0f2 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/EditorScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Redo import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.PlayArrow @@ -56,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.runcode.app.domain.models.LogLevel +import com.runcode.app.runtime.DebugStatus import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -82,6 +84,8 @@ fun EditorScreen( val projectFiles by viewModel.projectFiles.collectAsState() val instances by viewModel.instances.collectAsState() val logs by viewModel.logs.collectAsState() + val debugState by viewModel.debugState.collectAsState() + val breakpointMap by viewModel.breakpoints.collectAsState() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -182,6 +186,15 @@ fun EditorScreen( Text("Stop", color = AccentRed) } } else { + IconButton( + onClick = { + viewModel.debugProject(currentProj) + showBottomConsole = true + }, + modifier = Modifier.size(40.dp).testTag("editor_debug_btn") + ) { + Icon(Icons.Default.BugReport, contentDescription = "Debug", tint = AccentCyan, modifier = Modifier.size(20.dp)) + } Button( onClick = { viewModel.runProject(currentProj) @@ -258,11 +271,18 @@ fun EditorScreen( .background(DarkBg) ) { if (activeTab != null) { + val debugFile = viewModel.debugFileKey(activeTab) + val projectId = project?.id CodeEditor( documentKey = activeTab ?: "", content = viewModel.editorContent, onContentChange = viewModel::updateEditorContent, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + breakpoints = debugFile?.let { breakpointMap[projectId]?.get(it) }.orEmpty(), + pausedLine = debugState.line.takeIf { + debugState.status == DebugStatus.PAUSED && debugState.projectId == projectId && debugState.file == debugFile + }, + onLineNumberTap = if (debugFile != null) viewModel::toggleBreakpoint else null ) } else { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -271,6 +291,10 @@ fun EditorScreen( } } + if (debugState.projectId == project?.id && debugState.status != DebugStatus.IDLE) { + DebugPanel(viewModel, debugState, modifier = Modifier.fillMaxWidth()) + } + // Bottom Collapsible Console Drawer if (showBottomConsole) { Column( diff --git a/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt new file mode 100644 index 0000000..b0c1439 --- /dev/null +++ b/app/src/main/java/com/runcode/app/ui/screens/GitScreen.kt @@ -0,0 +1,402 @@ +package com.runcode.app.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runcode.app.git.GitStatus +import com.runcode.app.ui.MainViewModel +import com.runcode.app.ui.theme.AccentCyan +import com.runcode.app.ui.theme.AccentGreen +import com.runcode.app.ui.theme.AccentRed +import com.runcode.app.ui.theme.DarkBorder +import com.runcode.app.ui.theme.DarkSurface +import com.runcode.app.ui.theme.TextMuted +import com.runcode.app.ui.theme.TextPrimary +import com.runcode.app.ui.theme.TextSecondary +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val Amber = Color(0xFFFFB300) + +/** Git for the selected project's source/ folder. */ +@Composable +fun GitScreen(viewModel: MainViewModel) { + val project by viewModel.selectedProject.collectAsState() + val state by viewModel.git.collectAsState() + var dialog by remember { mutableStateOf(null) } + + LaunchedEffect(project?.id) { viewModel.refreshGit() } + // A refresh asked for while another project's task runs is dropped, so try again once it + // ends; otherwise the screen would keep showing the previous project's repository. + LaunchedEffect(state.busy) { + if (state.busy == null && state.projectId != project?.id) viewModel.refreshGit() + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Git", style = MaterialTheme.typography.titleLarge, color = TextPrimary) + Text( + text = project?.let { "${it.name} · source/" } ?: "Select a project first", + fontSize = 12.sp, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { dialog = GitDialog.Settings }) { Text("Settings", color = AccentCyan, fontSize = 12.sp) } + TextButton(onClick = { dialog = GitDialog.Clone }, modifier = Modifier.testTag("git_clone_btn")) { + Text("Clone", color = AccentCyan, fontSize = 12.sp) + } + } + } + + state.busy?.let { label -> + item { Text("$label…", fontSize = 12.sp, color = AccentCyan) } + } + state.error?.let { error -> + item { Text(error, fontSize = 12.sp, color = AccentRed, lineHeight = 16.sp) } + } + + if (project == null) return@LazyColumn + + if (!state.isRepository) { + item { + GitCard { + Text("Not a repository yet", fontWeight = FontWeight.Bold, color = TextPrimary, fontSize = 13.sp) + Text( + "Initialising creates a repository in source/ on the main branch, with a .gitignore for " + + "Python caches. data/, logs and secrets stay out of it.", + fontSize = 11.sp, + color = TextSecondary, + lineHeight = 15.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { viewModel.gitInit() }, + enabled = state.busy == null, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + modifier = Modifier.testTag("git_init_btn") + ) { Text("Initialise repository", color = Color.Black, fontWeight = FontWeight.Bold) } + } + } + return@LazyColumn + } + + val status = state.status ?: return@LazyColumn + item { BranchCard(status, state.busy == null, onBranch = { dialog = GitDialog.Branch }, onRemote = { dialog = GitDialog.Remote }, + onPush = { viewModel.gitPush() }, onPull = { viewModel.gitPull() }) } + item { ChangesCard(status, state.busy == null, onStageAll = { viewModel.gitStageAll() }) } + item { CommitCard(status, state.busy == null, onCommit = { message, stageAll -> viewModel.gitCommit(message, stageAll) }) } + + item { Text("History (${state.log.size})", style = MaterialTheme.typography.titleMedium, color = TextPrimary) } + items(state.log, key = { it.sha }) { commit -> + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(commit.message, fontSize = 12.sp, color = TextPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis) + val time = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(commit.time * 1000)) + Text("${commit.sha} · ${commit.author.substringBefore(" <")} · $time", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = TextMuted) + } + } + } + + when (dialog) { + GitDialog.Settings -> SettingsDialog(viewModel) { dialog = null } + GitDialog.Clone -> TextDialog( + title = "Clone a repository", + label = "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/user/repo.git", + hint = "Creates a new project. Private repositories use the token from Settings.", + confirm = "Clone" + ) { url -> dialog = null; url?.let { viewModel.gitClone(it) } } + GitDialog.Remote -> TextDialog( + title = "Remote (origin)", + label = "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/user/repo.git", + initial = state.status?.remote ?: "", + hint = "Do not put a token in the URL; it goes in Settings.", + confirm = "Save" + ) { url -> dialog = null; url?.let { viewModel.gitSetRemote(it) } } + GitDialog.Branch -> BranchDialog(state.branches, state.status?.branch) { name, create -> + dialog = null + name?.let { viewModel.gitCheckout(it, create) } + } + null -> Unit + } +} + +private enum class GitDialog { Settings, Clone, Remote, Branch } + +@Composable +private fun GitCard(content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(14.dp)) { content() } + } +} + +@Composable +private fun BranchCard( + status: GitStatus, + enabled: Boolean, + onBranch: () -> Unit, + onRemote: () -> Unit, + onPush: () -> Unit, + onPull: () -> Unit +) { + GitCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(status.branch ?: "(detached)", fontWeight = FontWeight.Bold, color = AccentGreen, fontSize = 14.sp) + val sync = when { + status.remote == null -> "no remote" + status.ahead == null -> "not pushed yet" + status.ahead == 0 && status.behind == 0 -> "up to date with origin" + else -> "${status.ahead} to push · ${status.behind} to pull" + } + Text("${status.head ?: "no commits"} · $sync", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextSecondary) + } + TextButton(onClick = onBranch, enabled = enabled) { Text("Branch", color = AccentCyan, fontSize = 12.sp) } + } + Text( + text = status.remote ?: "No remote set", + fontSize = 11.sp, + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onRemote, enabled = enabled, modifier = Modifier.weight(1f)) { + Text("Remote", color = AccentCyan, maxLines = 1) + } + OutlinedButton(onClick = onPull, enabled = enabled && status.remote != null, modifier = Modifier.weight(1f)) { + Text("Pull", color = AccentCyan, maxLines = 1) + } + Button( + onClick = onPush, + enabled = enabled && status.remote != null && status.head != null, + colors = ButtonDefaults.buttonColors(containerColor = AccentGreen), + modifier = Modifier.weight(1f).testTag("git_push_btn") + ) { Text("Push", color = Color.Black, fontWeight = FontWeight.Bold, maxLines = 1) } + } + } +} + +@Composable +private fun ChangesCard(status: GitStatus, enabled: Boolean, onStageAll: () -> Unit) { + GitCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (status.isClean) "No changes" else "Changes", + fontWeight = FontWeight.Bold, + color = TextPrimary, + fontSize = 13.sp, + modifier = Modifier.weight(1f) + ) + if (status.unstaged.isNotEmpty() || status.untracked.isNotEmpty()) { + TextButton(onClick = onStageAll, enabled = enabled) { Text("Stage all", color = AccentCyan, fontSize = 12.sp) } + } + } + status.added.forEach { FileLine("A", it, AccentGreen) } + status.modified.forEach { FileLine("M", it, AccentGreen) } + status.deleted.forEach { FileLine("D", it, AccentGreen) } + status.unstaged.forEach { FileLine("M", it, Amber) } + status.untracked.forEach { FileLine("?", it, TextMuted) } + if (!status.isClean) { + Text("Green is staged, amber changed, grey new.", fontSize = 10.sp, color = TextMuted) + } + } +} + +@Composable +private fun FileLine(mark: String, path: String, color: Color) { + Row(modifier = Modifier.padding(vertical = 1.dp)) { + Text(mark, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = color, modifier = Modifier.width(16.dp)) + Text(path, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun CommitCard(status: GitStatus, enabled: Boolean, onCommit: (String, Boolean) -> Unit) { + var message by remember { mutableStateOf("") } + var stageAll by remember { mutableStateOf(true) } + val hasWork = if (stageAll) !status.isClean else status.staged.isNotEmpty() + GitCard { + OutlinedTextField( + value = message, + onValueChange = { message = it }, + label = { Text("Commit message") }, + modifier = Modifier.fillMaxWidth().testTag("git_commit_message"), + minLines = 2 + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = stageAll, onCheckedChange = { stageAll = it }) + Text("Stage all changes first", fontSize = 12.sp, color = TextSecondary, modifier = Modifier.weight(1f)) + Button( + onClick = { onCommit(message, stageAll); message = "" }, + enabled = enabled && hasWork && message.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + modifier = Modifier.testTag("git_commit_btn") + ) { Text("Commit", color = Color.Black, fontWeight = FontWeight.Bold) } + } + } +} + +@Composable +private fun SettingsDialog(viewModel: MainViewModel, onDone: () -> Unit) { + val identity = remember { viewModel.gitIdentity } + var name by remember { mutableStateOf(identity.name) } + var email by remember { mutableStateOf(identity.email) } + var token by remember { mutableStateOf("") } + var clearToken by remember { mutableStateOf(false) } + val hasToken = remember { viewModel.gitHasToken } + + AlertDialog( + onDismissRequest = onDone, + title = { Text("Git settings", color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("GitHub shows commits under the account whose email matches.", fontSize = 11.sp, color = TextSecondary) + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Author name") }, singleLine = true) + OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Author email") }, singleLine = true) + OutlinedTextField( + value = token, + onValueChange = { token = it; clearToken = false }, + label = { Text(if (hasToken) "New token (one is saved)" else "GitHub token") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation() + ) + Text( + "A fine-grained token with Contents read and write. It is kept in the Keystore vault and never shown again.", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 13.sp + ) + if (hasToken) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = clearToken, onCheckedChange = { clearToken = it; if (it) token = "" }) + Text("Remove the saved token", fontSize = 12.sp, color = TextSecondary) + } + } + } + }, + confirmButton = { + Button(onClick = { + viewModel.saveGitSettings(name, email, when { + clearToken -> "" + token.isNotBlank() -> token + else -> null + }) + onDone() + }, colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text("Save", color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = onDone) { Text("Cancel", color = TextSecondary) } } + ) +} + +@Composable +private fun TextDialog( + title: String, + label: String, + hint: String, + confirm: String, + initial: String = "", + onDone: (String?) -> Unit +) { + var value by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = { onDone(null) }, + title = { Text(title, color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text(label) }, singleLine = true) + Text(hint, fontSize = 11.sp, color = TextMuted) + } + }, + confirmButton = { + Button(onClick = { onDone(value.trim()) }, enabled = value.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text(confirm, color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = { onDone(null) }) { Text("Cancel", color = TextSecondary) } } + ) +} + +@Composable +private fun BranchDialog(branches: List, current: String?, onDone: (String?, Boolean) -> Unit) { + var newName by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { onDone(null, false) }, + title = { Text("Branches", color = TextPrimary) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + branches.forEach { branch -> + TextButton(onClick = { onDone(branch, false) }, enabled = branch != current) { + Text(if (branch == current) "● $branch" else branch, + color = if (branch == current) AccentGreen else AccentCyan, fontFamily = FontFamily.Monospace) + } + } + OutlinedTextField(value = newName, onValueChange = { newName = it.trim() }, + label = { Text("New branch from here") }, singleLine = true) + Text("Switching refuses to overwrite uncommitted changes.", fontSize = 10.sp, color = TextMuted) + } + }, + confirmButton = { + Button(onClick = { onDone(newName, true) }, enabled = newName.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan)) { + Text("Create", color = Color.Black, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { TextButton(onClick = { onDone(null, false) }) { Text("Close", color = TextSecondary) } } + ) +} diff --git a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt index 570e9fe..1137d5a 100644 --- a/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt +++ b/app/src/main/java/com/runcode/app/ui/screens/HealthScreen.kt @@ -60,7 +60,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.runcode.app.diagnostics.CheckStatus +import com.runcode.app.diagnostics.DiagnosticCheck import com.runcode.app.mcp.McpTools +import com.runcode.app.mcp.TunnelStatus import com.runcode.app.ui.MainViewModel import com.runcode.app.ui.theme.AccentCyan import com.runcode.app.ui.theme.AccentGreen @@ -206,6 +209,10 @@ fun HealthScreen( CrashReportCard(viewModel) } + item { + DiagnosticsCard(viewModel) + } + // MCP Bridge item { McpBridgeCard(viewModel) @@ -329,6 +336,95 @@ private fun CrashReportCard(viewModel: MainViewModel) { } } +/** Runs every check on demand and turns the result into something a user can paste. */ +@Composable +private fun DiagnosticsCard(viewModel: MainViewModel) { + val context = LocalContext.current + val report by viewModel.diagnostics.collectAsState() + val running by viewModel.diagnosticsRunning.collectAsState() + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + border = androidx.compose.foundation.BorderStroke(1.dp, DarkBorder) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Diagnostics", fontWeight = FontWeight.Bold, color = AccentCyan, fontSize = 14.sp) + Text( + text = report?.summary ?: "Checks settings, storage, Python, network, service ports and the MCP bridge.", + fontSize = 11.sp, + color = TextSecondary + ) + + report?.let { current -> + Spacer(modifier = Modifier.height(8.dp)) + current.checks.groupBy { it.group }.forEach { (group, checks) -> + Text(group, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = TextPrimary, + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp)) + checks.forEach { check -> DiagnosticRow(check) } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { viewModel.runDiagnostics() }, + enabled = !running, + colors = ButtonDefaults.buttonColors(containerColor = AccentCyan), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag("diagnostics_run_btn") + ) { + Text(if (running) "Checking…" else if (report == null) "Run" else "Run again", + color = Color.Black, fontWeight = FontWeight.Bold) + } + report?.let { current -> + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("runcode_diagnostics", current.toText())) + }) { + Text("Copy", fontSize = 11.sp, color = AccentCyan) + } + } + TextButton(onClick = { + viewModel.logShareIntent()?.let { intent -> + try { + context.startActivity(intent) + } catch (_: android.content.ActivityNotFoundException) { + } + } + }) { + Text("Share logs", fontSize = 11.sp, color = AccentCyan) + } + } + } + } +} + +@Composable +private fun DiagnosticRow(check: DiagnosticCheck) { + val color = when (check.status) { + CheckStatus.PASS -> AccentGreen + CheckStatus.WARN -> Color(0xFFFFB300) + CheckStatus.FAIL -> AccentRed + CheckStatus.INFO -> TextMuted + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.Top) { + Text( + text = check.status.name, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.width(36.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text(check.name, fontSize = 11.sp, color = TextPrimary) + Text(check.detail, fontSize = 10.sp, color = TextSecondary, lineHeight = 13.sp) + } + } +} + @Composable private fun McpBridgeCard(viewModel: MainViewModel) { val context = LocalContext.current @@ -431,6 +527,9 @@ private fun McpBridgeCard(viewModel: MainViewModel) { ) } + Spacer(modifier = Modifier.height(8.dp)) + McpPublicUrlSection(viewModel) + Spacer(modifier = Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -469,6 +568,93 @@ private fun McpBridgeCard(viewModel: MainViewModel) { } } +@Composable +private fun McpPublicUrlSection(viewModel: MainViewModel) { + val context = LocalContext.current + val enabled by viewModel.mcpPublic.collectAsState() + val tunnel by viewModel.mcpTunnelState.collectAsState() + val bridge by viewModel.mcpState.collectAsState() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Public URL (internet)", fontSize = 12.sp, color = TextPrimary) + Text( + text = if (enabled) { + "Anyone with the URL and token can run commands here. The relay sees the traffic." + } else { + "HTTPS link through an SSH relay. Works behind NAT and with a VPN on." + }, + fontSize = 10.sp, + color = if (enabled) Color(0xFFFFB300) else TextMuted + ) + } + Switch( + checked = enabled, + onCheckedChange = { viewModel.setMcpPublic(it) }, + modifier = Modifier.testTag("mcp_public_switch") + ) + } + + if (!enabled) return + Spacer(modifier = Modifier.height(4.dp)) + + val status = when { + !bridge.isRunning -> "Starts with the bridge" + tunnel.status == TunnelStatus.ONLINE -> "Online via ${tunnel.provider}" + tunnel.status == TunnelStatus.CONNECTING -> "Connecting via ${tunnel.provider ?: "relay"}…" + tunnel.status == TunnelStatus.RETRYING -> "Retrying in ${tunnel.retryInSeconds}s" + else -> "Off" + } + HealthRow(label = "Tunnel", value = status) + + val url = tunnel.url + if (tunnel.status == TunnelStatus.ONLINE && url != null) { + val endpoint = "$url/mcp" + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = endpoint, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = AccentGreen, + modifier = Modifier.weight(1f).testTag("mcp_public_url") + ) + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("runcode_mcp_url", endpoint)) + }) { + Text("Copy", fontSize = 11.sp, color = AccentCyan) + } + } + Text( + text = "Free relays give a new URL on every reconnect.", + fontSize = 10.sp, + color = TextMuted + ) + } else if (tunnel.status != TunnelStatus.OFF) { + tunnel.lastError?.let { + Text(text = it, fontSize = 10.sp, color = AccentRed, lineHeight = 14.sp) + if (it.contains("host key")) { + TextButton(onClick = { viewModel.forgetRelayKeys() }) { + Text("Forget relay keys", fontSize = 11.sp, color = AccentCyan) + } + } + } + if (tunnel.failures >= 2) { + Text( + text = "Check the internet connection. If a VPN is on, make sure runcode is not " + + "excluded from it (split tunneling).", + fontSize = 10.sp, + color = TextMuted, + lineHeight = 14.sp + ) + } + } +} + @Composable fun HealthRow(label: String, value: String) { Row( diff --git a/app/src/main/python/runcode_debugger.py b/app/src/main/python/runcode_debugger.py new file mode 100644 index 0000000..74d8f27 --- /dev/null +++ b/app/src/main/python/runcode_debugger.py @@ -0,0 +1,395 @@ +""" +Python debugger for runcode projects, built on bdb (the standard library base that pdb uses). + +The trace hook exists only while a script runs under debug_script(). Normal runs never pay +for it: a permanent sys.settrace made every script 25x slower (see runcode_runner). + +Flow: debug_script() runs the project through runcode_runner.run_script with an executor that +wraps runpy in Bdb.runcall. When execution reaches a breakpoint or finishes a step, the script +thread publishes its state to the Kotlin listener and waits for a command from command(), which +the app calls from another thread. Evaluation runs on the paused thread itself, so expressions +see the real frame. An expression that runs past the timeout, or a stop sent meanwhile, +interrupts it with an async exception, the same way the supervisor stops a script. + +Only the script's main thread is traced, and only files under the project root are stepped +into or stopped in; library code runs at full speed. Like any trace hook it acts between Python +lines, so a script blocked inside a C call (time.sleep, a socket read) pauses on the next line. + +All public functions return JSON strings. +""" + +import bdb +import collections +import json +import os +import reprlib +import sys +import threading +import types + +import runcode_runner + +_sessions = {} +_sessions_lock = threading.Lock() + +_COMMANDS = ("continue", "step", "next", "return", "stop") +_NOWHERE = object() # a stop frame no real frame is +_MAX_VARIABLES = 60 +_EVAL_TIMEOUT_S = 10.0 + +_repr = reprlib.Repr() +_repr.maxstring = 160 +_repr.maxother = 160 +_repr.maxlist = _repr.maxtuple = _repr.maxset = _repr.maxdict = 12 +_repr.maxlevel = 3 + + +def _ok(**fields): + return json.dumps(dict(ok=True, **fields)) + + +def _fail(message): + return json.dumps({"ok": False, "error": message}) + + +def _show(value): + try: + return _repr.repr(value) + except Exception as error: # noqa: BLE001 - a broken __repr__ must not stop the debugger + return "" % error.__class__.__name__ + + +class _EvalCancelled(BaseException): + """Raised in the paused thread to abandon an expression that is still running.""" + + +class _EvalRequest: + def __init__(self, expression): + self.expression = expression + self.result = None # JSON reply, set once + self.abandoned = False # the caller stopped waiting + self.interrupted = False # an _EvalCancelled was sent for it; at most one ever is + + +def _checkpoint(): + pass + + +def _absorb_interrupt(): + """ + Lets an interrupt sent for an expression that finished anyway arrive here, inside _serve's + try. CPython raises a pending async exception at the next Python-level call, so the first + iteration normally takes it; the rest are a bound, not a wait. + """ + for _ in range(1000): + _checkpoint() + + +def _variables(namespace, globals_only=False): + shown = [] + for name, value in namespace.items(): + if name.startswith("__") and name.endswith("__"): + continue + if globals_only and isinstance(value, (types.ModuleType, types.FunctionType, type, + types.BuiltinFunctionType)): + continue + shown.append({"name": name, "type": type(value).__name__, "value": _show(value)}) + if len(shown) >= _MAX_VARIABLES: + break + return shown + + +class _Session(bdb.Bdb): + + def __init__(self, session_id, root, listener): + super().__init__() + self.session_id = session_id + self.root = os.path.abspath(root) + os.sep + self.listener = listener + self.cond = threading.Condition() + self.requests = collections.deque() # ("eval", _EvalRequest) or (command, None) + self.evaluating = None # the _EvalRequest the paused thread is running + self.thread_ident = None # the script thread, for interrupting an expression + self.paused = False + self.stopped_by_user = False + self.state = {"status": "starting"} + + # -- where to stop ------------------------------------------------------------------- + + def is_user_frame(self, frame): + return self.canonic(frame.f_code.co_filename).startswith(self.root) + + def stop_here(self, frame): + # Never stop inside the standard library or packages; stepping passes through them. + return self.is_user_frame(frame) and super().stop_here(frame) + + # -- bdb callbacks ------------------------------------------------------------------- + + def user_line(self, frame): + if not self.is_user_frame(frame): + return + filename = self.canonic(frame.f_code.co_filename) + reason = "breakpoint" if self.get_breaks(filename, frame.f_lineno) else "step" + self._pause(frame, reason) + + # Calls, returns and exceptions do not stop on their own: stepping lands on the next line. + def user_call(self, frame, argument_list): + pass + + def user_return(self, frame, return_value): + pass + + def user_exception(self, frame, exc_info): + pass + + # -- pausing ------------------------------------------------------------------------- + + def _pause(self, frame, reason): + # Accept commands before announcing the pause: a client that reacts to the published + # state at once must not be told the program is still running. + with self.cond: + self.requests.clear() + self.thread_ident = threading.get_ident() + self.paused = True + self._publish(self._snapshot(frame, reason)) + try: + command = self._serve(frame) + finally: + with self.cond: + self.paused = False + for kind, request in self.requests: + if kind == "eval": + request.result = _fail("The program resumed before the expression ran.") + self.requests.clear() + self.cond.notify_all() + + if command == "continue": + self.set_continue() + elif command == "step": + self.set_step() + elif command == "next": + self.set_next(frame) + elif command == "return": + self.set_return(frame) + elif command == "stop": + self.stopped_by_user = True + self.set_quit() + self._publish({"status": "running"}) + + def _serve(self, frame): + """Runs expressions until a command arrives, and returns that command.""" + while True: + # The lock is never held while an expression runs, so a caller's timeout and a + # stop command still work while one is slow. + try: + with self.cond: + while not self.requests: + # Short waits, so the async stop exception from the supervisor lands. + self.cond.wait(0.1) + kind, request = self.requests.popleft() + if kind != "eval": + return kind + if request.abandoned: + continue + self.evaluating = request + result = self._evaluate(frame, request.expression) + with self.cond: + self.evaluating = None + request.result = result + late = request.interrupted + self.cond.notify_all() + if late: + # Interrupted as it finished: take the exception here, not in the program. + _absorb_interrupt() + except _EvalCancelled: + # Sent at most once per expression, only while it runs, and absorbed above + # when it finishes first, so it never reaches the program. + with self.cond: + if self.evaluating is not None: + self.evaluating.result = _fail("The expression was interrupted.") + self.evaluating = None + self.cond.notify_all() + + def _interrupt_evaluation(self): + """ + Call with self.cond held. Interrupts the running expression, at most once: a timeout + and a stop for the same expression must not send two exceptions. + """ + request = self.evaluating + if request is None or request.interrupted: + return False + set_async_exc, ctypes = runcode_runner._set_async_exc, runcode_runner.ctypes + if set_async_exc is None or self.thread_ident is None: + return False + ident = ctypes.c_ulong(self.thread_ident) + affected = set_async_exc(ident, ctypes.py_object(_EvalCancelled)) + if affected > 1: + set_async_exc(ident, None) + return False + request.interrupted = affected == 1 + return request.interrupted + + def _evaluate(self, frame, expression): + try: + code = compile(expression, "", "eval") + except SyntaxError as error: + return _fail("SyntaxError: %s" % error.msg) + try: + value = eval(code, frame.f_globals, frame.f_locals) # noqa: S307 - the user's own code + except Exception as error: # noqa: BLE001 - reported to the user + return _fail("%s: %s" % (error.__class__.__name__, error)) + return _ok(type=type(value).__name__, value=_show(value)) + + def _snapshot(self, frame, reason): + stack = [] + walk = frame + while walk is not None: + if self.is_user_frame(walk): + stack.append({ + "file": self._relative(walk.f_code.co_filename), + "line": walk.f_lineno, + "function": walk.f_code.co_name, + }) + walk = walk.f_back + return { + "status": "paused", + "reason": reason, + "file": self._relative(frame.f_code.co_filename), + "line": frame.f_lineno, + "function": frame.f_code.co_name, + "stack": stack, + "locals": _variables(frame.f_locals) if frame.f_locals is not frame.f_globals else [], + "globals": _variables(frame.f_globals, globals_only=True), + } + + def _relative(self, filename): + path = self.canonic(filename) + return path[len(self.root):] if path.startswith(self.root) else path + + def _publish(self, state): + self.state = state + if self.listener is not None: + try: + self.listener.onState(json.dumps(state)) + except Exception: # noqa: BLE001 - a UI problem must not kill the script + pass + + # -- commands from other threads ----------------------------------------------------- + + def send(self, command): + with self.cond: + if not self.paused: + return _fail("The program is running, not paused.") + if any(kind != "eval" for kind, _ in self.requests): + return _fail("Another command is already waiting to run.") + self.requests.append((command, None)) + if command == "stop": + # Do not make a stop wait for a slow expression. + self._interrupt_evaluation() + self.cond.notify_all() + return _ok() + + def evaluate(self, expression): + request = _EvalRequest(expression) + timeout = _EVAL_TIMEOUT_S + with self.cond: + if not self.paused: + return _fail("Pause the program to evaluate expressions.") + self.requests.append(("eval", request)) + self.cond.notify_all() + if self.cond.wait_for(lambda: request.result is not None, timeout): + return request.result + # Late results are dropped with the request; a queued one is skipped. + request.abandoned = True + interrupted = self.evaluating is request and (request.interrupted or self._interrupt_evaluation()) + return _fail("The expression did not finish within %g seconds%s." % ( + timeout, " and was interrupted" if interrupted else "")) + + def run(self, script_path): + # Bdb.runcall starts in single-step mode and would stop on line 1. Start "continue" + # instead: a stop frame that never matches means only breakpoints stop the script. + self.reset() + self._set_stopinfo(_NOWHERE, None, -1) + sys.settrace(self.trace_dispatch) + try: + runcode_runner._run_path(script_path) + except bdb.BdbQuit: + pass + finally: + self.quitting = True + sys.settrace(None) + self.paused = False + if self.stopped_by_user: + # bdb swallows its own quit signal; report the run as stopped, not completed. + raise runcode_runner.ScriptInterrupt() + + +# ----------------------------------------------------------------------------- public API + +def debug_script(session_id, script_path, working_dir, env_pairs, sink, listener, breakpoints_json): + """ + Run a script under the debugger. Blocks until it ends and returns the same outcome + strings as runcode_runner.run_script. [breakpoints_json] maps absolute file paths to + line numbers. + """ + root = os.path.dirname(os.path.abspath(script_path)) + session = _Session(session_id, root, listener) + errors = _apply_breakpoints(session, json.loads(breakpoints_json or "{}")) + for message in errors: + sink.onOutput("stderr", "Breakpoint ignored: " + message) + with _sessions_lock: + _sessions[session_id] = session + try: + outcome = runcode_runner.run_script(session_id, script_path, working_dir, env_pairs, sink, + executor=session.run) + finally: + with _sessions_lock: + _sessions.pop(session_id, None) + session.clear_all_breaks() + session._publish({"status": "finished", "outcome": outcome}) + return outcome + + +def _apply_breakpoints(session, by_file): + errors = [] + for filename, lines in by_file.items(): + canonical = session.canonic(filename) + session.clear_all_file_breaks(canonical) + for line in sorted(set(int(n) for n in lines)): + error = session.set_break(canonical, line) + if error: + errors.append(error) + return errors + + +def set_breakpoints(session_id, filename, lines_json): + """Replaces one file's breakpoints in a running session.""" + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + errors = _apply_breakpoints(session, {filename: json.loads(lines_json)}) + return _ok(errors=errors) + + +def command(session_id, name): + """continue, step (into), next (over), return (out) or stop. Only while paused.""" + if name not in _COMMANDS: + return _fail("Unknown command %r" % name) + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + return session.send(name) + + +def evaluate(session_id, expression): + session = _sessions.get(session_id) + if session is None: + return _fail("No debug session is running.") + return session.evaluate(expression) + + +def state(session_id): + session = _sessions.get(session_id) + if session is None: + return _ok(status="idle") + return json.dumps(dict(ok=True, **session.state)) diff --git a/app/src/main/python/runcode_git.py b/app/src/main/python/runcode_git.py new file mode 100644 index 0000000..302df0c --- /dev/null +++ b/app/src/main/python/runcode_git.py @@ -0,0 +1,303 @@ +""" +Git for runcode projects, on dulwich (pure Python, so it runs inside the embedded CPython; a +native git binary cannot be shipped or executed on Android). + +Every public function takes plain strings and returns a JSON string, so the Kotlin side never +handles Python objects: {"ok": true, ...} on success, {"ok": false, "error": "..."} otherwise. + +Credentials arrive as arguments, are handed to dulwich's HTTP client, and are scrubbed from +every error message. They are never written to the repository config or to any output. +""" + +import io +import json +import re +import time + +from dulwich import porcelain +from dulwich.errors import NotGitRepository +from dulwich.repo import Repo + +DEFAULT_BRANCH = "main" +GITIGNORE = "__pycache__/\n*.pyc\n" +_URL_CREDENTIALS = re.compile(r"(://)[^/@\s]+@") + + +# ----------------------------------------------------------------------------- helpers + +def _result(**fields): + return json.dumps(dict(ok=True, **fields)) + + +def _failure(error, secrets=()): + text = str(error) or error.__class__.__name__ + for secret in secrets: + if secret: + text = text.replace(secret, "[REDACTED]") + text = _URL_CREDENTIALS.sub(r"\1[REDACTED]@", text) + return json.dumps({"ok": False, "error": text}) + + +def _guarded(*secrets): + """Turns any exception into a JSON failure with the given secrets scrubbed out.""" + def wrap(fn): + def inner(*args): + try: + return fn(*args) + except NotGitRepository: + return _failure("Not a git repository. Initialise it first.") + except Exception as error: # noqa: BLE001 - reported to the UI, never raised + found = [args[i] for i in secrets if i < len(args)] + return _failure(error, found) + inner.__name__ = fn.__name__ + inner.__doc__ = fn.__doc__ + return inner + return wrap + + +def _text(value): + return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value) + + +def _quiet(): + """dulwich writes progress to stdout/stderr by default; keep it out of the app log.""" + return {"outstream": io.BytesIO(), "errstream": io.BytesIO()} + + +def _credentials(username, password): + return {"username": username, "password": password} if password else {} + + +def _current_branch(repo): + try: + return _text(porcelain.active_branch(repo)) + except (KeyError, IndexError, ValueError): + return None + + +def _head(repo): + try: + return repo.head() + except KeyError: + return None + + +def _count(repo, include, exclude): + if include is None: + return 0 + return sum(1 for _ in repo.get_walker(include=[include], exclude=[exclude] if exclude else None)) + + +# ----------------------------------------------------------------------------- local + +def is_repo(repo_dir): + try: + Repo(repo_dir).close() + return True + except NotGitRepository: + return False + + +@_guarded() +def init(repo_dir): + """Creates a repository on the main branch with a small .gitignore for Python caches.""" + import os + + repo = porcelain.init(repo_dir) + try: + repo.refs.set_symbolic_ref(b"HEAD", ("refs/heads/" + DEFAULT_BRANCH).encode()) + finally: + repo.close() + ignore = os.path.join(repo_dir, ".gitignore") + if not os.path.exists(ignore): + with open(ignore, "w") as handle: + handle.write(GITIGNORE) + return _result(branch=DEFAULT_BRANCH) + + +@_guarded() +def status(repo_dir): + with Repo(repo_dir) as repo: + state = porcelain.status(repo) + staged = {key: sorted(_text(p) for p in paths) for key, paths in state.staged.items()} + branch = _current_branch(repo) + head = _head(repo) + tracking = repo.refs.as_dict().get(("refs/remotes/origin/" + branch).encode()) if branch else None + return _result( + branch=branch, + head=_text(head)[:7] if head else None, + staged={"added": staged.get("add", []), "modified": staged.get("modify", []), + "deleted": staged.get("delete", [])}, + unstaged=sorted(_text(p) for p in state.unstaged), + untracked=sorted(_text(p) for p in state.untracked), + ahead=_count(repo, head, tracking) if tracking else None, + behind=_count(repo, tracking, head) if tracking else None, + remote=remote_url_of(repo), + ) + + +@_guarded() +def add(repo_dir, paths_json): + """Stages the given paths, or everything (including deletions) when the list is empty.""" + paths = json.loads(paths_json) if paths_json else [] + with Repo(repo_dir) as repo: + if paths: + import os + porcelain.add(repo, [os.path.join(repo_dir, p) for p in paths]) + else: + porcelain.add(repo) + # porcelain.add does not stage removals; mirror `git add -A`. + removed = porcelain.status(repo).unstaged + gone = [p for p in removed if not _exists(repo_dir, p)] + if gone: + porcelain.remove(repo, [_abs(repo_dir, p) for p in gone], cached=True) + return _result() + + +def _abs(repo_dir, path): + import os + return os.path.join(repo_dir, _text(path)) + + +def _exists(repo_dir, path): + import os + return os.path.lexists(_abs(repo_dir, path)) + + +@_guarded() +def commit(repo_dir, message, author_name, author_email): + if not message.strip(): + raise ValueError("Write a commit message first.") + if not author_name.strip() or not author_email.strip(): + raise ValueError("Set your name and email under Git settings; GitHub uses them to show the author.") + identity = ("%s <%s>" % (author_name.strip(), author_email.strip())).encode() + with Repo(repo_dir) as repo: + state = porcelain.status(repo) + if not any(state.staged.values()): + raise ValueError("Nothing is staged. Stage changes before committing.") + # No signing: there is no gpg or ssh-keygen on the device, and a global git config + # asking for it must not make every commit fail. + sha = porcelain.commit(repo, message=message.encode(), author=identity, committer=identity, sign=False) + return _result(sha=_text(sha)[:7]) + + +@_guarded() +def log(repo_dir, limit): + with Repo(repo_dir) as repo: + if _head(repo) is None: + return _result(commits=[]) + entries = [] + for entry in repo.get_walker(max_entries=int(limit)): + c = entry.commit + entries.append({ + "sha": _text(c.id)[:7], + "author": _text(c.author), + "time": c.author_time, + "message": _text(c.message).strip().split("\n")[0], + }) + return _result(commits=entries) + + +@_guarded() +def branches(repo_dir): + with Repo(repo_dir) as repo: + names = sorted(_text(b) for b in porcelain.branch_list(repo)) + return _result(current=_current_branch(repo), branches=names) + + +@_guarded() +def checkout(repo_dir, name, create): + """Switches branch. Refuses when uncommitted changes would be overwritten.""" + with Repo(repo_dir) as repo: + if create: + if _head(repo) is None: + raise ValueError("Make a first commit before creating branches.") + porcelain.branch_create(repo, name) + porcelain.checkout(repo, name) + return _result(branch=name) + + +def remote_url_of(repo): + try: + return _text(repo.get_config().get((b"remote", b"origin"), b"url")) + except KeyError: + return None + + +@_guarded() +def set_remote(repo_dir, url): + if _URL_CREDENTIALS.search(url): + raise ValueError("Leave credentials out of the URL; set a token under Git settings instead.") + with Repo(repo_dir) as repo: + config = repo.get_config() + config.set((b"remote", b"origin"), b"url", url.encode()) + config.set((b"remote", b"origin"), b"fetch", b"+refs/heads/*:refs/remotes/origin/*") + config.write_to_path() + return _result(remote=url) + + +# ----------------------------------------------------------------------------- network +# Arguments 2 and 3 (username, password) are scrubbed from errors by @_guarded. + +@_guarded(2, 3) +def clone(url, target_dir, username, password): + started = time.time() + repo = porcelain.clone(url, target_dir, checkout=True, errstream=io.BytesIO(), + **_credentials(username, password)) + try: + if _head(repo) is None: + _checkout_fallback_branch(repo) + branch = _current_branch(repo) + finally: + repo.close() + return _result(branch=branch, seconds=round(time.time() - started, 1)) + + +def _checkout_fallback_branch(repo): + """The remote's HEAD named a branch that does not exist: check out main, or the first one.""" + prefix = b"refs/remotes/origin/" + remote = sorted(r[len(prefix):] for r in repo.refs.keys() if r.startswith(prefix) and r != prefix + b"HEAD") + if not remote: + return # genuinely empty repository + name = DEFAULT_BRANCH.encode() if DEFAULT_BRANCH.encode() in remote else remote[0] + repo.refs[b"refs/heads/" + name] = repo.refs[prefix + name] + repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/" + name) + porcelain.reset(repo, "hard", b"refs/heads/" + name) + + +@_guarded(2, 3) +def push(repo_dir, branch, username, password): + with Repo(repo_dir) as repo: + url = remote_url_of(repo) + if not url: + raise ValueError("Set a remote URL first.") + head = _head(repo) + if head is None: + raise ValueError("Nothing to push yet. Make a commit first.") + ref = ("refs/heads/" + branch).encode() + porcelain.push(repo, url, ref, **_credentials(username, password), **_quiet()) + # Record what the remote now has, so status can count ahead/behind. + repo.refs[("refs/remotes/origin/" + branch).encode()] = repo.refs[ref] + return _result(branch=branch) + + +@_guarded(2, 3) +def pull(repo_dir, branch, username, password): + """Fetches and fast-forwards. A diverged branch is reported instead of merged.""" + with Repo(repo_dir) as repo: + url = remote_url_of(repo) + if not url: + raise ValueError("Set a remote URL first.") + before = _head(repo) + ref = ("refs/heads/" + branch).encode() + try: + porcelain.pull(repo, url, [ref], fast_forward=True, ff_only=True, + **_credentials(username, password), **_quiet()) + except porcelain.DivergedBranches: + raise ValueError("Local and remote history have diverged. runcode can only fast-forward; " + "push or reset from a computer to reconcile them.") + after = _head(repo) + if after is not None: + repo.refs[("refs/remotes/origin/" + branch).encode()] = after + new = _count(repo, after, before) if before else (_count(repo, after, None) if after else 0) + return _result(branch=branch, new_commits=new) diff --git a/app/src/main/python/runcode_runner.py b/app/src/main/python/runcode_runner.py index bed1bd9..a1ac88c 100644 --- a/app/src/main/python/runcode_runner.py +++ b/app/src/main/python/runcode_runner.py @@ -156,18 +156,27 @@ def tracer(frame, event, arg): return tracer -def run_script(service_id, script_path, working_dir, env_pairs, sink): +def run_script(service_id, script_path, working_dir, env_pairs, sink, executor=None): + """ + Run a project script. [executor], when given, is called with the script path in place of + runpy.run_path; the debugger uses it to run the script under its trace hook while keeping + the environment, working directory, output routing and stop handling identical. + """ # CPython's cwd, environment and import state are shared by all JVM threads. A # second script must not receive the first project's secrets or relative writes. if not _execution_lock.acquire(blocking=False): return "fatal:Another Python task is active. Stop it before starting this project." try: - return _run_script_locked(service_id, script_path, working_dir, env_pairs, sink) + return _run_script_locked(service_id, script_path, working_dir, env_pairs, sink, executor) finally: _execution_lock.release() -def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink): +def _run_path(script_path): + runpy.run_path(script_path, run_name="__main__") + + +def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink, executor=None): """ Execute `script_path` as __main__. @@ -210,13 +219,17 @@ def _run_script_locked(service_id, script_path, working_dir, env_pairs, sink): sys.argv = [script_path] - if using_fallback: - sys.settrace(_fallback_tracer(service_id)) - try: - runpy.run_path(script_path, run_name="__main__") - finally: + if executor is not None: + # The debugger owns the trace hook for the whole run. + executor(script_path) + else: if using_fallback: - sys.settrace(None) + sys.settrace(_fallback_tracer(service_id)) + try: + _run_path(script_path) + finally: + if using_fallback: + sys.settrace(None) return "completed" diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c2dc47e --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt new file mode 100644 index 0000000..fe98cb3 --- /dev/null +++ b/app/src/test/java/com/runcode/app/backup/FolderBackupsTest.kt @@ -0,0 +1,184 @@ +package com.runcode.app.backup + +import android.app.Application +import android.net.Uri +import com.runcode.app.domain.models.Project +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files + +/** A folder on disk standing in for the SAF folder. [truncate] imitates a broken upload. */ +private class DirStore(val dir: File, var truncate: Boolean = false, var refuse: String? = null) : BackupStore { + override fun list() = dir.listFiles()!!.filter { it.name.endsWith(".rcpkg") } + .map { StoredBackup(it.absolutePath, it.name, it.length(), it.lastModified()) } + + override fun write(name: String, source: File): StoredBackup { + refuse?.let { if (name.contains(it)) throw IOException("quota exceeded") } + val target = File(dir, name) + val bytes = source.readBytes() + target.writeBytes(if (truncate) bytes.copyOf(bytes.size / 2) else bytes) + return StoredBackup(target.absolutePath, name, target.length(), target.lastModified()) + } + + override fun open(id: String): InputStream = File(id).inputStream() + + override fun delete(id: String) { + if (!File(id).delete()) throw IOException("cannot delete $id") + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class FolderBackupsTest { + + private lateinit var storage: ProjectStorage + private lateinit var manager: BackupManager + private lateinit var store: DirStore + private var clock = 1_760_000_000_000L + private lateinit var folder: FolderBackups + + @Before fun setup() { + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + manager = BackupManager(app, storage) + store = DirStore(Files.createTempDirectory("saf").toFile()) + folder = FolderBackups(manager, app.cacheDir) { clock } + } + + private fun project(name: String = "Notes App"): Project { + val project = storage.createProjectFromTemplate(name, ProjectProfile.PYTHON_SCRIPT) + File(storage.getDataDir(project.id), "notes.txt").writeText("remember the milk") + return project + } + + @Test fun `export is verified, listed and restorable with identical files`() = runBlocking { + val original = project() + val stored = folder.export(store, original) + + assertTrue(stored.name.startsWith("Notes_App-${original.id}-")) + assertEquals(listOf(stored.name), folder.list(store).map { it.name }) + assertTrue(folder.verify(store, stored).isValid) + + val restored = folder.restore(store, stored) + assertNotEquals(original.id, restored.id) + assertEquals("remember the milk", File(storage.getDataDir(restored.id), "notes.txt").readText()) + assertEquals( + File(storage.getSourceDir(original.id), original.entryPoint).readText(), + File(storage.getSourceDir(restored.id), restored.entryPoint).readText() + ) + // A manual export keeps the local copy too. + assertEquals(1, storage.getBackupsDir(original.id).listFiles()!!.size) + } + + @Test fun `a copy that does not read back identically is removed and reported`() = runBlocking { + store.truncate = true + val error = runCatching { folder.export(store, project()) }.exceptionOrNull() + assertTrue(error is IOException) + assertTrue(store.list().isEmpty()) + } + + @Test fun `automatic runs keep seven copies, never touch manual ones and leave nothing locally`() = runBlocking { + val p = project() + val manual = folder.export(store, p) + repeat(9) { + clock += 24L * 60 * 60 * 1000 + folder.export(store, p, automatic = true) + } + val names = store.list().map { it.name } + assertEquals(FolderBackups.KEEP_AUTOMATIC, names.count { it.startsWith("auto-") }) + assertTrue(manual.name in names) + // Only the manual export's local copy remains. + assertEquals(1, storage.getBackupsDir(p.id).listFiles()!!.size) + } + + @Test fun `automatic names use UTC so they sort by creation time`() { + val original = java.util.TimeZone.getDefault() + try { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Asia/Tehran")) + val name = folder.fileName(project("Zone"), automatic = true) + // 1_760_000_000_000 ms is 2025-10-09 08:53:20 UTC. + assertTrue(name, name.endsWith("-20251009-085320.rcpkg")) + } finally { + java.util.TimeZone.setDefault(original) + } + } + + @Test fun `pruning only applies to the same project`() = runBlocking { + val a = project("A") + val b = project("B") + repeat(3) { clock += 1000; folder.export(store, a, automatic = true) } + folder.export(store, b, automatic = true) + folder.prune(store, a, keep = 1) + val names = store.list().map { it.name } + assertEquals(1, names.count { it.contains(a.id) }) + assertEquals(1, names.count { it.contains(b.id) }) + } + + @Test fun `exportAll keeps going after a failure and reports it`() = runBlocking { + val good = project("Good") + val bad = project("Bad") + store.refuse = "Bad" + val (done, error) = folder.exportAll(store, listOf(bad, good)) + assertEquals(1, done) + assertEquals("Bad: quota exceeded", error) + assertTrue(store.list().single().name.contains(good.id)) + } + + @Test fun `a quote in the project name no longer breaks the manifest`() = runBlocking { + val p = project("The \"best\" app\\v2") + val local = manager.createProjectBackup(p) + val preview = manager.verifyBackup(local) + assertTrue(preview.error, preview.isValid) + assertEquals("The \"best\" app\\v2", preview.manifest!!.projectName) + assertEquals("The \"best\" app\\v2", manager.restoreProjectFromBackup(local).name) + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class BackupFolderSettingsTest { + + private val tree = Uri.parse("content://com.android.externalstorage.documents/tree/primary%3ABackups") + + @Test fun `choosing keeps access, forgetting releases it`() { + val settings = BackupFolderSettings(RuntimeEnvironment.getApplication()) + assertNull(settings.store()) + assertNull(settings.state.value.label) + + settings.choose(tree) + assertNotNull(settings.store()) + assertEquals("Backups", settings.state.value.label) + + // A new instance (app restart) still has it. + assertNotNull(BackupFolderSettings(RuntimeEnvironment.getApplication()).store()) + + settings.clear() + assertNull(settings.store()) + assertTrue(RuntimeEnvironment.getApplication().contentResolver.persistedUriPermissions.isEmpty()) + } + + @Test fun `automatic backup is due once a day and only when switched on`() { + val settings = BackupFolderSettings(RuntimeEnvironment.getApplication()) + settings.choose(tree) + val now = 1_760_000_000_000L + assertFalse(settings.isAutoDue(now)) + + settings.setAutoEnabled(true) + assertTrue(settings.isAutoDue(now)) + settings.recordAutoRun(now, "Backed up 2 project(s)") + assertFalse(settings.isAutoDue(now + 60_000)) + assertTrue(settings.isAutoDue(now + FolderBackups.AUTO_INTERVAL_MS)) + assertEquals("Backed up 2 project(s)", settings.state.value.lastAutoResult) + } +} diff --git a/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt b/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt new file mode 100644 index 0000000..5aa0801 --- /dev/null +++ b/app/src/test/java/com/runcode/app/backup/SafBackupStoreTest.kt @@ -0,0 +1,118 @@ +package com.runcode.app.backup + +import android.app.Application +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsProvider +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files + +/** A minimal file-backed provider, the shape of what Drive or the device's storage expose. */ +class FolderDocumentsProvider : DocumentsProvider() { + companion object { + const val AUTHORITY = "com.runcode.test.documents" + lateinit var root: File + private val COLUMNS = arrayOf( + Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, Document.COLUMN_MIME_TYPE, Document.COLUMN_FLAGS + ) + } + + override fun onCreate() = true + + private fun file(id: String) = if (id == "root") root else File(root, id) + + private fun row(cursor: MatrixCursor, id: String) { + val f = file(id) + cursor.addRow(arrayOf( + id, if (id == "root") "Backups" else f.name, f.length(), f.lastModified(), + if (f.isDirectory) Document.MIME_TYPE_DIR else "application/octet-stream", + Document.FLAG_DIR_SUPPORTS_CREATE or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_WRITE + )) + } + + override fun queryRoots(projection: Array?): Cursor = MatrixCursor(arrayOf("root_id")) + + override fun queryDocument(documentId: String, projection: Array?): Cursor = + MatrixCursor(COLUMNS).also { row(it, documentId) } + + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor = + MatrixCursor(COLUMNS).also { c -> file(parentDocumentId).listFiles()!!.forEach { row(c, it.name) } } + + override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = + ParcelFileDescriptor.open(file(documentId), ParcelFileDescriptor.parseMode(mode)) + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + var name = displayName + var n = 1 + while (File(root, name).exists()) name = displayName.replace(".rcpkg", " (${n++}).rcpkg") + File(root, name).createNewFile() + return name + } + + override fun deleteDocument(documentId: String) { + file(documentId).delete() + } + + override fun isChildDocument(parentDocumentId: String, documentId: String) = parentDocumentId == "root" +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class SafBackupStoreTest { + + private lateinit var store: SafBackupStore + private lateinit var source: File + + @Before fun setup() { + FolderDocumentsProvider.root = Files.createTempDirectory("tree").toFile() + // DocumentsProvider insists on exactly the manifest attributes a real one declares. + val info = ProviderInfo().apply { + authority = FolderDocumentsProvider.AUTHORITY + exported = true + grantUriPermissions = true + readPermission = android.Manifest.permission.MANAGE_DOCUMENTS + writePermission = android.Manifest.permission.MANAGE_DOCUMENTS + } + Robolectric.buildContentProvider(FolderDocumentsProvider::class.java).create(info) + val tree = DocumentsContract.buildTreeDocumentUri(FolderDocumentsProvider.AUTHORITY, "root") + store = SafBackupStore(RuntimeEnvironment.getApplication(), tree) + source = File.createTempFile("backup", ".rcpkg").apply { writeBytes(ByteArray(50_000) { (it % 251).toByte() }) } + } + + @Test fun `write, list, read back and delete through the documents provider`() { + val stored = store.write("one.rcpkg", source) + assertEquals("one.rcpkg", stored.name) + assertTrue(Uri.parse(stored.id).toString().startsWith("content://${FolderDocumentsProvider.AUTHORITY}/tree/root/document/")) + + File(FolderDocumentsProvider.root, "notes.txt").writeText("not a backup") + val listed = store.list() + assertEquals(listOf("one.rcpkg"), listed.map { it.name }) + assertEquals(source.length(), listed.single().sizeBytes) + + assertArrayEquals(source.readBytes(), store.open(stored.id).use { it.readBytes() }) + + store.delete(stored.id) + assertTrue(store.list().isEmpty()) + } + + @Test fun `a provider rename on collision is reported back`() { + store.write("same.rcpkg", source) + val second = store.write("same.rcpkg", source) + assertEquals("same (1).rcpkg", second.name) + } +} diff --git a/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt new file mode 100644 index 0000000..5d73518 --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticRulesTest.kt @@ -0,0 +1,117 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.domain.models.ProjectProfile +import org.junit.Assert.* +import org.junit.Test + +class DiagnosticRulesTest { + + private val ok = ProbeResult(true, "HTTP 204", 40) + private val down = ProbeResult(false, "connect timed out", 6000) + + private val healthyDevice = DeviceSnapshot( + apiLevel = 36, release = "16", abis = listOf("arm64-v8a"), + freeStorageMb = 4000, availableMemoryMb = 3000, totalMemoryMb = 8000, lowMemory = false, + notificationsAllowed = true, batteryExempt = true, pythonVersion = "3.12.4", + lastCrash = null, inForeground = false, foregroundWork = 0 + ) + + private val healthyNetwork = NetworkSnapshot( + transports = listOf("Wi-Fi"), validated = true, vpn = false, lanIp = "192.168.1.5", + reservedPorts = mapOf(8080 to "svc"), dns = ok, https = ok, telegram = ok + ) + + private fun List.status(name: String) = single { it.name == name }.status + + @Test fun `a healthy device has no warnings or failures`() { + val checks = DiagnosticRules.device(healthyDevice) + DiagnosticRules.network(healthyNetwork) + assertTrue(checks.joinToString("\n"), checks.none { it.status == CheckStatus.WARN || it.status == CheckStatus.FAIL }) + } + + @Test fun `blocked notifications and battery optimisation are warnings`() { + val checks = DiagnosticRules.device(healthyDevice.copy(notificationsAllowed = false, batteryExempt = false)) + assertEquals(CheckStatus.WARN, checks.status("Notifications")) + assertEquals(CheckStatus.WARN, checks.status("Battery")) + } + + @Test fun `running work without the foreground service fails`() { + val checks = DiagnosticRules.device(healthyDevice.copy(foregroundWork = 2, inForeground = false)) + assertEquals(CheckStatus.FAIL, checks.status("Foreground")) + assertEquals(CheckStatus.PASS, DiagnosticRules.device(healthyDevice.copy(foregroundWork = 2, inForeground = true)).status("Foreground")) + assertEquals(CheckStatus.INFO, DiagnosticRules.device(healthyDevice).status("Foreground")) + } + + @Test fun `storage thresholds`() { + assertEquals(CheckStatus.WARN, DiagnosticRules.device(healthyDevice.copy(freeStorageMb = 150)).status("Storage")) + assertEquals(CheckStatus.FAIL, DiagnosticRules.device(healthyDevice.copy(freeStorageMb = 20)).status("Storage")) + } + + @Test fun `missing python fails`() { + assertEquals(CheckStatus.FAIL, DiagnosticRules.device(healthyDevice.copy(pythonVersion = null)).status("Python")) + } + + @Test fun `crash summary shows the time and the exception line`() { + val report = """ + runcode crash report + time: 2026-09-30 10:11:12 + thread: main + device: Samsung SM-A546E + android: 16 (API 36) + abi: arm64-v8a + + java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1 + at androidx.compose.material3.TabRowKt.foo(TabRow.kt:12) + """.trimIndent() + assertEquals( + "2026-09-30 10:11:12 · java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1", + DiagnosticRules.crashSummary(report) + ) + assertEquals(CheckStatus.WARN, DiagnosticRules.device(healthyDevice.copy(lastCrash = report)).status("Last crash")) + } + + @Test fun `no network at all fails, unvalidated warns`() { + assertEquals(CheckStatus.FAIL, DiagnosticRules.network(healthyNetwork.copy(transports = null)).status("Connection")) + assertEquals(CheckStatus.WARN, DiagnosticRules.network(healthyNetwork.copy(validated = false)).status("Connection")) + } + + @Test fun `a blocked Telegram API only warns, broken DNS fails`() { + val checks = DiagnosticRules.network(healthyNetwork.copy(telegram = down, dns = down)) + assertEquals(CheckStatus.WARN, checks.status("Telegram API")) + assertEquals(CheckStatus.FAIL, checks.status("DNS")) + } + + @Test fun `vpn is reported`() { + val checks = DiagnosticRules.network(healthyNetwork.copy(vpn = true, transports = listOf("Wi-Fi", "VPN"))) + assertEquals(CheckStatus.INFO, checks.status("VPN")) + assertTrue(checks.single { it.name == "Connection" }.detail.startsWith("Wi-Fi + VPN")) + } + + @Test fun `only web profiles are judged by their port`() { + val checks = DiagnosticRules.services(listOf( + ServiceSnapshot("site", ProjectProfile.STATIC_WEB, 8080, ProbeResult(false, "Connection refused", 1)), + ServiceSnapshot("api", ProjectProfile.PYTHON_HTTP, 8081, ProbeResult(true, "accepts connections", 1)), + ServiceSnapshot("bot", ProjectProfile.TELEGRAM_BOT, 8082, null) + )) + assertEquals(CheckStatus.FAIL, checks.status("site")) + assertEquals(CheckStatus.PASS, checks.status("api")) + assertEquals(CheckStatus.INFO, checks.status("bot")) + } + + @Test fun `bridge health and tunnel state`() { + val bridge = BridgeSnapshot(true, 8765, ProbeResult(false, "Connection refused", 1), true, false, null, "Pinggy: timeout") + val checks = DiagnosticRules.bridge(bridge) + assertEquals(CheckStatus.FAIL, checks.status("Bridge")) + assertEquals(CheckStatus.WARN, checks.status("Public URL")) + val online = DiagnosticRules.bridge(bridge.copy(health = ok, tunnelOnline = true, tunnelUrl = "https://x.run.pinggy-free.link")) + assertEquals("https://x.run.pinggy-free.link/mcp", online.single { it.name == "Public URL" }.detail) + } + + @Test fun `report text groups checks and counts them`() { + val report = DiagnosticReport(0, "1.5.0 (6)", DiagnosticRules.device(healthyDevice.copy(batteryExempt = false))) + val text = report.toText() + assertTrue(text.contains("v1.5.0 (6)")) + assertTrue(text.contains("[App]")) + assertTrue(text.contains("WARN Battery")) + assertEquals(1, report.count(CheckStatus.WARN)) + } +} diff --git a/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt new file mode 100644 index 0000000..496749e --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/DiagnosticsRunnerTest.kt @@ -0,0 +1,53 @@ +package com.runcode.app.diagnostics + +import com.runcode.app.RuncodeApp +import com.runcode.app.mcp.McpTools +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.net.ServerSocket + +/** The whole runner against the real application object and a real bridge listener. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class DiagnosticsRunnerTest { + + private val app get() = RuntimeEnvironment.getApplication() as RuncodeApp + + @After fun tearDown() { + app.mcpServer.stop() + } + + @Test fun `report covers every group and self-tests a running bridge`() { + val port = ServerSocket(0).use { it.localPort } + app.mcpServer.start(port, "test-token", allowLan = false) + assertTrue(app.mcpServer.state.value.isRunning) + + val report = app.diagnostics.run() + println(report.toText()) + val groups = report.checks.map { it.group }.toSet() + assertEquals(setOf(DiagnosticRules.APP, DiagnosticRules.NETWORK, DiagnosticRules.SERVICES, DiagnosticRules.BRIDGE), groups) + + val bridge = report.checks.single { it.name == "Bridge" } + assertEquals(bridge.detail, CheckStatus.PASS, bridge.status) + // Chaquopy does not run on the JVM, and the report must say so rather than pretend. + assertEquals(CheckStatus.FAIL, report.checks.single { it.name == "Python" }.status) + // The bridge is running but nothing put the app in the foreground in this test. + assertEquals(CheckStatus.FAIL, report.checks.single { it.name == "Foreground" }.status) + } + + @Test fun `stopped bridge is reported as information, not failure`() { + val bridge = app.diagnostics.run().checks.single { it.name == "Bridge" } + assertEquals(CheckStatus.INFO, bridge.status) + } + + @Test fun `run_diagnostics is listed and callable over MCP`() { + val names = (0 until McpTools.descriptors().length()).map { McpTools.descriptors().getJSONObject(it).getString("name") } + assertTrue("run_diagnostics" in names) + } +} diff --git a/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt b/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt new file mode 100644 index 0000000..a279ef6 --- /dev/null +++ b/app/src/test/java/com/runcode/app/diagnostics/NetworkProbesTest.kt @@ -0,0 +1,62 @@ +package com.runcode.app.diagnostics + +import org.junit.Assert.* +import org.junit.Test +import java.net.ServerSocket + +class NetworkProbesTest { + + /** Answers every connection with [response] until closed. */ + private fun httpServer(response: String): ServerSocket { + val server = ServerSocket(0) + Thread { + while (!server.isClosed) { + val client = try { server.accept() } catch (_: Exception) { break } + client.use { + it.getInputStream().read(ByteArray(2048)) + it.getOutputStream().write(response.toByteArray()) + } + } + }.apply { isDaemon = true; start() } + return server + } + + private fun closedPort() = ServerSocket(0).use { it.localPort } + + @Test fun `tcp probe sees a listener and a closed port`() { + ServerSocket(0).use { server -> + assertTrue(NetworkProbes.tcpAccepts("127.0.0.1", server.localPort).ok) + } + val closed = NetworkProbes.tcpAccepts("127.0.0.1", closedPort()) + assertFalse(closed.ok) + assertTrue(closed.detail.isNotBlank()) + } + + @Test fun `any HTTP answer counts as reachable, redirects are not followed`() { + httpServer("HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/\r\nContent-Length: 0\r\n\r\n").use { server -> + val result = NetworkProbes.httpReachable("http://127.0.0.1:${server.localPort}/") + assertTrue(result.detail, result.ok) + assertEquals("HTTP 302", result.detail) + } + assertFalse(NetworkProbes.httpReachable("http://127.0.0.1:${closedPort()}/", 2_000).ok) + } + + @Test fun `mcp health accepts only the bridge's own answer`() { + val body = """{"status":"ok","service":"runcode-mcp"}""" + httpServer("HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n$body").use { server -> + assertTrue(NetworkProbes.mcpHealth(server.localPort).ok) + } + httpServer("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").use { server -> + val result = NetworkProbes.mcpHealth(server.localPort) + assertFalse(result.ok) + assertTrue(result.detail.startsWith("unexpected answer")) + } + assertFalse(NetworkProbes.mcpHealth(closedPort()).ok) + } + + @Test fun `unresolvable names fail with a reason`() { + val result = NetworkProbes.resolves("does-not-exist.invalid", 5_000) + assertFalse(result.ok) + assertTrue(result.detail.isNotBlank()) + } +} diff --git a/app/src/test/java/com/runcode/app/git/GitManagerTest.kt b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt new file mode 100644 index 0000000..24086c5 --- /dev/null +++ b/app/src/test/java/com/runcode/app/git/GitManagerTest.kt @@ -0,0 +1,226 @@ +package com.runcode.app.git + +import android.app.Application +import android.content.Context +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.security.SecretVault +import com.runcode.app.storage.ProjectArchive +import com.runcode.app.storage.ProjectStorage +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * Runs the real runcode_git.py in the host's Python, standing in for Chaquopy, so the Kotlin + * side is tested against actual dulwich behaviour rather than a mock. + */ +class HostPythonGitBackend(private val python: String) : GitBackend { + private val moduleDir = File("src/main/python").absoluteFile + + override fun call(function: String, vararg args: Any): String { + val shim = "import json,sys; sys.path.insert(0, sys.argv[1]); import runcode_git as g; " + + "r = json.load(sys.stdin); print(getattr(g, r['f'])(*r['a']))" + val process = ProcessBuilder(python, "-c", shim, moduleDir.path).redirectErrorStream(false).start() + process.outputStream.use { it.write(JSONObject().put("f", function).put("a", JSONArray(args.toList())).toString().toByteArray()) } + val out = process.inputStream.bufferedReader().readText() + val err = process.errorStream.bufferedReader().readText() + check(process.waitFor(60, TimeUnit.SECONDS) && process.exitValue() == 0) { "python failed: $err" } + return out.trim().lines().last() + } + + companion object { + /** A Python 3 that can import dulwich, or null. */ + fun find(): String? = listOfNotNull(System.getenv("RUNCODE_TEST_PYTHON"), System.getenv("RUNCODE_BUILD_PYTHON"), "python3") + .firstOrNull { candidate -> + runCatching { + val p = ProcessBuilder(candidate, "-c", "import dulwich").start() + p.waitFor(30, TimeUnit.SECONDS) && p.exitValue() == 0 + }.getOrDefault(false) + } + } +} + +private class MapVault : SecretVault { + val values = mutableMapOf() + override fun setSecret(key: String, plainText: String): Boolean { values[key] = plainText; return true } + override fun getSecret(key: String) = values[key] + override fun removeSecret(key: String) { values.remove(key) } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class GitManagerTest { + + private lateinit var storage: ProjectStorage + private lateinit var vault: MapVault + private lateinit var git: GitManager + + @Before fun setup() { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich; set RUNCODE_TEST_PYTHON or pip install dulwich", python != null) + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + vault = MapVault() + git = GitManager(HostPythonGitBackend(python!!), storage, ProjectArchive(app, storage), vault, + app.getSharedPreferences("git-test-${System.nanoTime()}", Context.MODE_PRIVATE)) + git.identity = GitIdentity("Ada Lovelace", "ada@example.com") + } + + private fun bareRemote(): File { + val dir = Files.createTempDirectory("remote").toFile() + val p = ProcessBuilder(HostPythonGitBackend.find()!!, "-c", + "import sys; from dulwich.repo import Repo; r = Repo.init_bare(sys.argv[1]); " + + "r.refs.set_symbolic_ref(b'HEAD', b'refs/heads/main'); r.close()", dir.path).start() + assertTrue(p.waitFor(30, TimeUnit.SECONDS) && p.exitValue() == 0) + return dir + } + + @Test fun `init, stage, commit and log in source only`() = runBlocking { + val project = storage.createProjectFromTemplate("Git demo", ProjectProfile.PYTHON_SCRIPT) + File(storage.getDataDir(project.id), "app.db").writeText("not for git") + assertFalse(git.isRepository(project)) + + git.init(project) + assertTrue(git.isRepository(project)) + val before = git.status(project) + assertEquals("main", before.branch) + assertTrue(project.entryPoint in before.untracked) + assertTrue("data/ must stay out of the repository", before.untracked.none { it.contains("app.db") }) + + git.stage(project) + val sha = git.commit(project, "First commit") + assertTrue(git.status(project).isClean) + val log = git.log(project) + assertEquals(sha, log.single().sha) + assertEquals("Ada Lovelace ", log.single().author) + } + + @Test fun `missing identity is a readable error`() = runBlocking { + val project = storage.createProjectFromTemplate("No author", ProjectProfile.PYTHON_SCRIPT) + git.identity = GitIdentity("", "") + git.init(project) + git.stage(project) + val error = runCatching { git.commit(project, "x") }.exceptionOrNull() + assertTrue(error is GitException) + assertTrue(error!!.message!!.contains("name and email")) + } + + @Test fun `push, then clone it back as a new project with its entry point`() = runBlocking { + val remote = bareRemote() + val project = storage.createProjectFromTemplate("Bot", ProjectProfile.TELEGRAM_BOT) + git.init(project) + git.stage(project) + git.commit(project, "bot") + git.setRemote(project, remote.path) + git.push(project) + assertEquals(0, git.status(project).ahead) + + val cloned = git.cloneProject(remote.path + "/") + assertEquals(remote.name, cloned.name) + assertEquals(project.entryPoint, cloned.entryPoint) + assertTrue(git.isRepository(cloned)) + assertEquals( + File(storage.getSourceDir(project.id), project.entryPoint).readText(), + File(storage.getSourceDir(cloned.id), cloned.entryPoint).readText() + ) + } + + @Test fun `a failed clone leaves no project behind`() = runBlocking { + val before = storage.getProjectDir("x").parentFile!!.list()!!.toSet() + val error = runCatching { git.cloneProject("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/nonexistent/repo.git") }.exceptionOrNull() + assertTrue(error is GitException) + assertEquals(before, storage.getProjectDir("x").parentFile!!.list()!!.toSet()) + } + + @Test fun `the token is used for push but never appears in errors`() = runBlocking { + val token = "ghp_secretsecretsecret123" + git.setToken(token) + assertTrue(git.hasToken) + val project = storage.createProjectFromTemplate("Leak check", ProjectProfile.PYTHON_SCRIPT) + git.init(project) + git.stage(project) + git.commit(project, "x") + val closedPort = java.net.ServerSocket(0).use { it.localPort } + git.setRemote(project, "http://127.0.0.1:$closedPort/repo.git") + val error = runCatching { git.push(project) }.exceptionOrNull() + assertTrue(error is GitException) + assertFalse(error!!.message!!.contains(token)) + + git.setToken("") + assertFalse(git.hasToken) + } + + @Test fun `invalid branch names are refused before reaching git`() = runBlocking { + val project = storage.createProjectFromTemplate("Branches", ProjectProfile.PYTHON_SCRIPT) + git.init(project) + assertTrue(runCatching { git.checkout(project, "bad name; rm -rf", true) }.exceptionOrNull() is IllegalArgumentException) + } +} + +/** Records what reaches the backend, to check which calls carry the token. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class GitCredentialsTest { + + private val calls = mutableListOf>>() + private var remote: String? = null + private lateinit var git: GitManager + private lateinit var storage: ProjectStorage + private val token = "ghp_onlyforgithub123456" + + @Before fun setup() { + val app = RuntimeEnvironment.getApplication() + storage = ProjectStorage(app) + val backend = GitBackend { function, args -> + calls += function to args.toList() + when (function) { + "status" -> JSONObject().put("ok", true).put("remote", remote ?: JSONObject.NULL).toString() + "branches" -> JSONObject().put("ok", true).put("current", "main").put("branches", JSONArray()).toString() + "pull" -> JSONObject().put("ok", true).put("new_commits", 0).toString() + else -> JSONObject().put("ok", true).toString() + } + } + git = GitManager(backend, storage, ProjectArchive(app, storage), MapVault(), + app.getSharedPreferences("git-cred-${System.nanoTime()}", Context.MODE_PRIVATE)) + git.setToken(token) + } + + private fun sentToken(function: String) = calls.last { it.first == function }.second.contains(token) + + @Test fun `token goes only to https github`() = runBlocking { + val project = storage.createProjectFromTemplate("Creds", ProjectProfile.PYTHON_SCRIPT) + for ((url, expected) in listOf( + "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/a/b.git" to true, + "https://GitHub.com/a/b" to true, + "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/a/b.git" to false, + "https://gitlab.com/a/b.git" to false, + "https://github.com.evil.example/a/b.git" to false, + "https://user@github.com/a/b.git" to false, + "/local/path/repo.git" to false + )) { + remote = url + git.push(project) + assertEquals("push to $url", expected, sentToken("push")) + git.pull(project) + assertEquals("pull from $url", expected, sentToken("pull")) + } + } + + @Test fun `clone sends the token only for github over https`() = runBlocking { + runCatching { git.cloneProject("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/a/b.git") } + assertTrue(sentToken("clone")) + runCatching { git.cloneProject("http://example.com/a/b.git") } + assertFalse(sentToken("clone")) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt new file mode 100644 index 0000000..6314379 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/McpGitToolsTest.kt @@ -0,0 +1,84 @@ +package com.runcode.app.mcp + +import android.content.Context +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.GitManager +import com.runcode.app.git.HostPythonGitBackend +import com.runcode.app.security.SecretVault +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class McpGitToolsTest { + + @Test fun `commit, status and push through the bridge`() = runBlocking { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich", python != null) + val app = RuntimeEnvironment.getApplication() as RuncodeApp + val vault = object : SecretVault { + override fun setSecret(key: String, plainText: String) = true + override fun getSecret(key: String): String? = null + override fun removeSecret(key: String) = Unit + } + val git = GitManager(HostPythonGitBackend(python!!), app.projectStorage, app.projectArchive, vault, + app.getSharedPreferences("mcp-git-test", Context.MODE_PRIVATE)) + git.identity = GitIdentity("Bridge Bot", "bot@example.com") + val changed = mutableListOf() + var unsaved: String? = null + val host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, + app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "" }, + app.projectSettings, { changed += it }, { "" }, { _, _ -> "" }, { git }, app.debugger, + unsavedEditorFile = { unsaved }) + + val project = app.projectStorage.createProjectFromTemplate("MCP git", ProjectProfile.PYTHON_HTTP) + app.appMetaDatabase.insertOrUpdateProject(project) + + fun call(name: String, args: JSONObject): JSONObject = McpTools.call(host, name, args.put("project_id", project.id)) + fun text(result: JSONObject) = result.getJSONArray("content").getJSONObject(0).getString("text") + + assertTrue(text(call("git_status", JSONObject())).startsWith("No repository yet")) + + val commit = call("git_commit", JSONObject().put("message", "From the bridge")) + assertFalse(text(commit), commit.getBoolean("isError")) + assertTrue(text(commit).startsWith("Committed ")) + assertEquals(listOf(project.id), changed) + + val status = JSONObject(text(call("git_status", JSONObject()))) + assertEquals("main", status.getString("branch")) + assertEquals(0, status.getJSONArray("untracked").length()) + + // No remote yet: a clear tool error, not an exception. + val push = call("git_push", JSONObject()) + assertTrue(push.getBoolean("isError")) + assertTrue(text(push).contains("remote")) + + val remote = Files.createTempDirectory("remote").toFile() + val init = ProcessBuilder(python, "-c", "import sys; from dulwich.repo import Repo; Repo.init_bare(sys.argv[1]).close()", remote.path).start() + assertTrue(init.waitFor(30, TimeUnit.SECONDS)) + git.setRemote(project, remote.path) + val pushed = call("git_push", JSONObject()) + assertFalse(text(pushed), pushed.getBoolean("isError")) + + // Unsaved edits in the app's editor would undo a pull once saved: refused. + unsaved = "main.py" + val refused = call("git_pull", JSONObject()) + assertTrue(refused.getBoolean("isError")) + assertTrue(text(refused), text(refused).startsWith("main.py has unsaved changes")) + assertEquals("a refused pull changes nothing", listOf(project.id), changed) + + unsaved = null + assertEquals("Already up to date", text(call("git_pull", JSONObject()))) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt index 5a1b889..9262b72 100644 --- a/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt +++ b/app/src/test/java/com/runcode/app/mcp/McpSettingsTest.kt @@ -31,7 +31,7 @@ class McpSettingsTest { runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } host = McpToolHost(app.projectStorage, app.appMetaDatabase, app.projectDatabaseManager, app.serviceSupervisor, app.logManager, app.terminalSession, { _, _ -> "unused" }, - app.projectSettings, { notifications.add(it) }) + app.projectSettings, { notifications.add(it) }, { "diagnostics report" }, { _, _ -> "backup" }, { app.git }, app.debugger) } @Test fun `mcp replaces plain variables but preserves secret references`() = runBlocking { diff --git a/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt b/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt new file mode 100644 index 0000000..955c077 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/McpTunnelTest.kt @@ -0,0 +1,151 @@ +package com.runcode.app.mcp + +import com.runcode.app.domain.models.LogLevel +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class McpTunnelTest { + + private val relayA = TunnelProvider("A", "a.example", 443, "u", 0, listOf("a.example")) + private val relayB = TunnelProvider("B", "b.example", 22, "u", 80, listOf("b.example")) + private var tunnel: McpTunnel? = null + + @After fun tearDown() { + tunnel?.stop() + } + + // ------------------------------------------------------------ URL parsing + + @Test fun `pinggy banner yields the tunnel URL, not its own links`() { + val banner = """ + You are not authenticated. + Your tunnel will expire in 60 minutes. Upgrade to Pinggy Pro to get unrestricted tunnels. https://dashboard.pinggy.io + + http://rnxyz-1-2-3-4.run.pinggy-free.link + https://rnxyz-1-2-3-4.run.pinggy-free.link + """.trimIndent() + assertEquals("https://rnxyz-1-2-3-4.run.pinggy-free.link", TunnelUrls.find(banner, TunnelProvider.PINGGY)) + } + + @Test fun `localhost run banner yields the lhr life URL`() { + val banner = """ + ** your connection id is 1234, please mention it if you send me a message about an issue. ** + To set up and manage custom domains go to https://admin.localhost.run/ + More details on custom domains (and how to enable subdomains of your custom domain) at https://localhost.run/docs/custom-domains + + a1b2c3d4e5f6.lhr.life tunneled with tls termination, https://a1b2c3d4e5f6.lhr.life + """.trimIndent() + assertEquals("https://a1b2c3d4e5f6.lhr.life", TunnelUrls.find(banner, TunnelProvider.LOCALHOST_RUN)) + } + + @Test fun `ANSI colour codes around the URL are ignored`() { + val banner = "\u001B[32mhttps://abc.run.pinggy-free.link\u001B[0m\r\n" + assertEquals("https://abc.run.pinggy-free.link", TunnelUrls.find(banner, TunnelProvider.PINGGY)) + } + + @Test fun `no URL until the relay prints one`() { + assertNull(TunnelUrls.find("Connecting…\r\nhttps://pinggy.io/docs/ for help", TunnelProvider.PINGGY)) + assertNull(TunnelUrls.find("https://abc.run.pinggy-fr", TunnelProvider.PINGGY)?.takeIf { it.endsWith(".link") }) + } + + // ------------------------------------------------------------ supervision + + private class FakeConnection : TunnelConnection { + val alive = AtomicBoolean(true) + override val isAlive get() = alive.get() + override fun close() = alive.set(false) + } + + private fun newTunnel(connector: TunnelConnector) = McpTunnel( + connector = connector, + onLog = { _: LogLevel, _: String -> }, + providers = listOf(relayA, relayB), + pollMs = 20, + backoffMs = { 20L } + ).also { tunnel = it } + + private fun awaitState(tunnel: McpTunnel, predicate: (McpTunnelState) -> Boolean): McpTunnelState { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + val state = tunnel.state.value + if (predicate(state)) return state + Thread.sleep(10) + } + fail("state never matched; last = ${tunnel.state.value}") + throw AssertionError() + } + + @Test fun `a failing relay falls through to the next one`() { + val tried = CopyOnWriteArrayList() + val t = newTunnel { provider, _, _ -> + tried += provider.name + if (provider == relayA) throw IOException("port 443 blocked") + OpenTunnel(FakeConnection(), "https://b.tunnel") + } + t.start(8765) + val online = awaitState(t) { it.status == TunnelStatus.ONLINE } + assertEquals("https://b.tunnel", online.url) + assertEquals("B", online.provider) + assertEquals(listOf("A", "B"), tried.take(2)) + } + + @Test fun `a dropped tunnel reconnects and publishes the new URL`() { + val connections = CopyOnWriteArrayList() + val t = newTunnel { _, _, _ -> + val c = FakeConnection().also { connections += it } + OpenTunnel(c, "https://tunnel-${connections.size}") + } + t.start(8765) + awaitState(t) { it.url == "https://tunnel-1" } + connections[0].alive.set(false) + awaitState(t) { it.status == TunnelStatus.ONLINE && it.url == "https://tunnel-2" } + } + + @Test fun `a network change replaces a tunnel that still looks alive`() { + val connections = CopyOnWriteArrayList() + val t = newTunnel { _, _, _ -> + val c = FakeConnection().also { connections += it } + OpenTunnel(c, "https://tunnel-${connections.size}") + } + t.start(8765) + awaitState(t) { it.url == "https://tunnel-1" } + t.onNetworkChanged() + awaitState(t) { it.url == "https://tunnel-2" } + assertFalse("old socket must be closed", connections[0].isAlive) + } + + @Test fun `stop closes the connection and resets the state`() { + val connection = FakeConnection() + val t = newTunnel { _, _, _ -> OpenTunnel(connection, "https://x.tunnel") } + t.start(8765) + awaitState(t) { it.status == TunnelStatus.ONLINE } + t.stop() + assertEquals(McpTunnelState(), t.state.value) + assertFalse(connection.isAlive) + } + + @Test fun `a tunnel that opens after stop is closed, not adopted`() { + val release = Object() + var go = false + val entered = java.util.concurrent.CountDownLatch(1) + val late = FakeConnection() + val t = newTunnel { _, _, _ -> + entered.countDown() + synchronized(release) { while (!go) release.wait() } + OpenTunnel(late, "https://late.tunnel") + } + t.start(8765) + assertTrue(entered.await(5, TimeUnit.SECONDS)) + t.stop() + synchronized(release) { go = true; release.notifyAll() } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (late.isAlive && System.nanoTime() < deadline) Thread.sleep(10) + assertFalse(late.isAlive) + assertEquals(TunnelStatus.OFF, t.state.value.status) + } +} diff --git a/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt new file mode 100644 index 0000000..802da51 --- /dev/null +++ b/app/src/test/java/com/runcode/app/mcp/SshTunnelConnectorTest.kt @@ -0,0 +1,209 @@ +package com.runcode.app.mcp + +import org.apache.sshd.common.forward.PortForwardingEventListener +import org.apache.sshd.common.session.Session +import org.apache.sshd.common.util.net.SshdSocketAddress +import org.apache.sshd.server.Environment +import org.apache.sshd.server.ExitCallback +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.password.PasswordAuthenticator +import org.apache.sshd.server.channel.ChannelSession +import org.apache.sshd.server.command.Command +import org.apache.sshd.server.forward.AcceptAllForwardingFilter +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.apache.sshd.server.shell.ShellFactory +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Runs the real JSch connector against a local SSH server that behaves like Pinggy: any + * credentials, a remote forward on port 0, and the public URL printed on the shell. + */ +class SshTunnelConnectorTest { + + private lateinit var relay: SshServer + private lateinit var bridge: ServerSocket + private val knownHosts = File.createTempFile("known_hosts", "").apply { delete() } + private val forwardedPort = AtomicInteger(-1) + private val shellOpened = CountDownLatch(1) + + @Before fun setUp() { + bridge = ServerSocket(0) + Thread { + while (!bridge.isClosed) { + val client = try { bridge.accept() } catch (_: Exception) { break } + client.use { + it.getInputStream().read(ByteArray(1024)) + it.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong".toByteArray()) + } + } + }.apply { isDaemon = true; start() } + + relay = SshServer.setUpDefaultServer().apply { + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider() + passwordAuthenticator = PasswordAuthenticator { _, _, _ -> true } + forwardingFilter = AcceptAllForwardingFilter.INSTANCE + shellFactory = ShellFactory { BannerShell() } + addPortForwardingEventListener(object : PortForwardingEventListener { + override fun establishedExplicitTunnel( + session: Session, + local: SshdSocketAddress?, + remote: SshdSocketAddress?, + localForwarding: Boolean, + boundAddress: SshdSocketAddress?, + reason: Throwable? + ) { + // A client's -R is a listener on the server, so the flag does not tell us much here. + if (boundAddress != null && boundAddress.port > 0) forwardedPort.set(boundAddress.port) + } + }) + } + relay.start() + } + + @After fun tearDown() { + relay.stop(true) + bridge.close() + } + + private inner class BannerShell : Command { + private var out: OutputStream? = null + override fun setInputStream(`in`: InputStream?) = Unit + override fun setOutputStream(out: OutputStream?) { this.out = out } + override fun setErrorStream(err: OutputStream?) = Unit + override fun setExitCallback(callback: ExitCallback?) = Unit + override fun start(channel: ChannelSession?, env: Environment?) { + out?.apply { + write("Upgrade at https://dashboard.pinggy.io\r\n".toByteArray()) + write("https://test-1-2-3-4.run.pinggy-free.link\r\n".toByteArray()) + flush() + } + shellOpened.countDown() + } + override fun destroy(channel: ChannelSession?) = Unit + } + + private fun relayProvider(remotePort: Int) = + TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = relay.port, remotePort = remotePort) + + private fun requestThroughRelay(): String { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (forwardedPort.get() <= 0 && System.nanoTime() < deadline) Thread.sleep(10) + Socket("127.0.0.1", forwardedPort.get()).use { socket -> + socket.soTimeout = 5_000 + socket.getOutputStream().write("GET /health HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray()) + return socket.getInputStream().readBytes().decodeToString() + } + } + + @Test fun `port 0 forward reaches the local bridge and reports the relay URL`() { + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + try { + assertEquals("https://test-1-2-3-4.run.pinggy-free.link", opened.url) + assertTrue(opened.connection.isAlive) + assertTrue(requestThroughRelay().endsWith("pong")) + } finally { + opened.connection.close() + } + assertFalse(opened.connection.isAlive) + } + + @Test fun `fixed remote port works as localhost run expects`() { + val fixed = ServerSocket(0).use { it.localPort } + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = fixed), bridge.localPort, 10_000) + try { + assertEquals(fixed, forwardedPort.get()) + assertTrue(requestThroughRelay().endsWith("pong")) + } finally { + opened.connection.close() + } + } + + @Test fun `an interrupt left behind by JSch is cleared, a pending one is kept`() { + // What JSch does when the forward's reply arrives before the caller sleeps. + assertEquals(7, SshTunnelConnector.withoutStrayInterrupt { Thread.currentThread().interrupt(); 7 }) + assertFalse(Thread.currentThread().isInterrupted) + + Thread.currentThread().interrupt() + SshTunnelConnector.withoutStrayInterrupt { } + assertTrue("an interrupt from before the call belongs to the caller", Thread.interrupted()) + } + + @Test fun `opening never leaves the calling thread interrupted`() { + repeat(10) { attempt -> + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + val interrupted = Thread.interrupted() + opened.connection.close() + assertFalse("interrupt flag left set after open #$attempt", interrupted) + } + } + + @Test fun `relay stopping is seen as a dead connection`() { + val opened = SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + assertTrue(shellOpened.await(5, TimeUnit.SECONDS)) + relay.stop(true) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (opened.connection.isAlive && System.nanoTime() < deadline) Thread.sleep(20) + assertFalse(opened.connection.isAlive) + opened.connection.close() + } + + @Test fun `unreachable relay fails fast with a reason`() { + val closed = ServerSocket(0).use { it.localPort } + val error = runCatching { + SshTunnelConnector(knownHosts).open(TunnelProvider.PINGGY.copy(host = "127.0.0.1", port = closed), bridge.localPort, 3_000) + }.exceptionOrNull() + assertNotNull(error) + } + + @Test fun `the relay's key is trusted on first use and checked afterwards`() { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + val saved = knownHosts.readText() + assertTrue(saved, saved.startsWith("[127.0.0.1]:${relay.port} ")) + + // Same server, same key: accepted, nothing new stored. + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + assertEquals(saved, knownHosts.readText()) + } + + @Test fun `a different key for a known relay is refused`() { + // Learn some other server's key, then record it as if it belonged to this relay. + val impostor = SshServer.setUpDefaultServer().apply { + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider() + passwordAuthenticator = PasswordAuthenticator { _, _, _ -> true } + forwardingFilter = AcceptAllForwardingFilter.INSTANCE + shellFactory = ShellFactory { BannerShell() } + } + impostor.start() + try { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0).copy(port = impostor.port), bridge.localPort, 10_000) + .connection.close() + } finally { + impostor.stop(true) + } + knownHosts.writeText(knownHosts.readText().replace("[127.0.0.1]:${impostor.port}", "[127.0.0.1]:${relay.port}")) + + val error = runCatching { + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000) + }.exceptionOrNull() + assertTrue(error.toString(), error is IOException) + assertTrue(error!!.message!!.contains("different host key")) + assertEquals("no forward may be set up for an untrusted relay", -1, forwardedPort.get()) + + TofuHostKeys.forgetAll(knownHosts) + SshTunnelConnector(knownHosts).open(relayProvider(remotePort = 0), bridge.localPort, 10_000).connection.close() + } +} diff --git a/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt b/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt new file mode 100644 index 0000000..3329733 --- /dev/null +++ b/app/src/test/java/com/runcode/app/network/PortManagerLanIpTest.kt @@ -0,0 +1,52 @@ +package com.runcode.app.network + +import com.runcode.app.network.PortManager.Companion.selectLanIp +import com.runcode.app.network.PortManager.LanCandidate +import org.junit.Assert.* +import org.junit.Test + +class PortManagerLanIpTest { + private fun c(name: String, address: String, private: Boolean) = LanCandidate(name, address, private) + + @Test fun `wifi wins over mobile data listed first`() { + val picked = selectLanIp(listOf( + c("rmnet_data0", "21.91.237.60", false), + c("wlan0", "192.168.1.23", true) + )) + assertEquals("192.168.1.23", picked) + } + + @Test fun `mobile data alone is not a LAN address`() { + assertNull(selectLanIp(listOf(c("rmnet_data0", "21.91.237.60", false), c("ccmni1", "10.12.0.4", true)))) + } + + @Test fun `vpn and 464xlat interfaces are skipped`() { + assertNull(selectLanIp(listOf(c("tun0", "10.8.0.2", true), c("v4-rmnet_data0", "192.0.0.4", false)))) + } + + @Test fun `hotspot is used when wifi is off`() { + assertEquals("192.168.43.1", selectLanIp(listOf( + c("rmnet_data0", "21.91.237.60", false), + c("swlan0", "192.168.43.1", true) + ))) + } + + @Test fun `wifi beats usb tethering`() { + assertEquals("192.168.1.23", selectLanIp(listOf( + c("rndis0", "192.168.42.129", true), + c("wlan0", "192.168.1.23", true) + ))) + } + + @Test fun `unknown interface counts only with a private address`() { + assertEquals("10.0.0.5", selectLanIp(listOf(c("mystery0", "10.0.0.5", true)))) + assertNull(selectLanIp(listOf(c("mystery0", "21.91.237.60", false)))) + } + + @Test fun `interface names are matched case-insensitively`() { + assertEquals("192.168.1.23", selectLanIp(listOf( + c("RMNET0", "21.91.237.60", false), + c("WLAN0", "192.168.1.23", true) + ))) + } +} diff --git a/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt b/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt new file mode 100644 index 0000000..fd29d6c --- /dev/null +++ b/app/src/test/java/com/runcode/app/runtime/PythonDebuggerTest.kt @@ -0,0 +1,181 @@ +package com.runcode.app.runtime + +import android.app.Application +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import kotlin.concurrent.thread + +private class FakeBridge : DebugBridge { + val calls = mutableListOf>() + var reply = """{"ok": true}""" + override fun command(sessionId: String, name: String): String { calls += listOf("command", sessionId, name); return reply } + override fun evaluate(sessionId: String, expression: String): String { + calls += listOf("evaluate", sessionId, expression) + return """{"ok": true, "type": "int", "value": "42"}""" + } + override fun setBreakpoints(sessionId: String, file: String, linesJson: String): String { + calls += listOf("breakpoints", sessionId, file, linesJson); return reply + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class PythonDebuggerTest { + + private val bridge = FakeBridge() + private val debugger = PythonDebugger(bridge) + private val source = File("/data/projects/p1/source") + + private val paused = JSONObject() + .put("status", "paused").put("reason", "breakpoint") + .put("file", "helper.py").put("line", 3).put("function", "double") + .put("stack", JSONArray().put(JSONObject().put("file", "helper.py").put("line", 3).put("function", "double")) + .put(JSONObject().put("file", "main.py").put("line", 5).put("function", ""))) + .put("locals", JSONArray().put(JSONObject().put("name", "x").put("type", "int").put("value", "2"))) + .put("globals", JSONArray()) + .toString() + + @Test fun `paused state is parsed with stack and variables`() { + val state = DebugState.parse("p1", paused) + assertEquals(DebugStatus.PAUSED, state.status) + assertEquals(Triple("helper.py", 3, "double"), Triple(state.file, state.line, state.function)) + assertEquals(listOf("double", ""), state.stack.map { it.function }) + assertEquals(DebugVariable("x", "int", "2"), state.locals.single()) + assertEquals("completed", DebugState.parse("p1", """{"status":"finished","outcome":"completed"}""").outcome) + } + + @Test fun `only an armed start becomes a debug run, with absolute breakpoint paths`() { + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + debugger.toggleBreakpoint("p1", "main.py", 6) + debugger.toggleBreakpoint("p1", "lib/util.py", 2) + debugger.arm("p1") + assertEquals(DebugStatus.STARTING, debugger.state.value.status) + val launch = JSONObject(debugger.takeLaunch("p1", "svc_py_p1", source)!!) + assertEquals("[6]", launch.getJSONArray(File(source, "main.py").absolutePath).toString()) + assertEquals("[2]", launch.getJSONArray(File(source, "lib/util.py").absolutePath).toString()) + // Consumed: the next start is a normal run again. + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + } + + @Test fun `disarming after a failed start clears the starting state`() { + debugger.arm("p1") + debugger.disarm("p1") + assertEquals(DebugStatus.IDLE, debugger.state.value.status) + assertNull(debugger.takeLaunch("p1", "svc_py_p1", source)) + } + + @Test fun `toggling twice removes the breakpoint and live sessions are updated`() { + debugger.toggleBreakpoint("p1", "main.py", 6) + assertEquals(setOf(6), debugger.breakpointsFor("p1", "main.py")) + assertTrue("no session yet, so nothing is sent", bridge.calls.isEmpty()) + + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + debugger.toggleBreakpoint("p1", "main.py", 9) + assertEquals(listOf("breakpoints", "svc_py_p1", File(source, "main.py").absolutePath, "[6,9]"), bridge.calls.last()) + + debugger.toggleBreakpoint("p1", "main.py", 6) + debugger.toggleBreakpoint("p1", "main.py", 9) + assertTrue(debugger.breakpointsFor("p1", "main.py").isEmpty()) + assertTrue(debugger.breakpoints.value.isEmpty()) + } + + @Test fun `commands go to the running session and errors come back as text`() { + assertEquals("No debug session is running.", debugger.command("continue")) + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + assertNull(debugger.command("next")) + assertEquals(listOf("command", "svc_py_p1", "next"), bridge.calls.last()) + bridge.reply = """{"ok": false, "error": "The program is running, not paused."}""" + assertEquals("The program is running, not paused.", debugger.command("step")) + assertEquals("int: 42", debugger.evaluate("6 * 7").getOrThrow()) + } + + @Test fun `listener updates state and the end closes the session`() { + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + val listener = debugger.listener("p1") + listener.onState(paused) + assertEquals(3, debugger.state.value.line) + listener.onState("""{"status":"finished","outcome":"stopped"}""") + assertFalse(debugger.isActive) + debugger.dismiss() + assertEquals(DebugStatus.IDLE, debugger.state.value.status) + } + + @Test fun `awaitSettled returns the next pause after a command`() = runBlocking { + debugger.arm("p1") + debugger.takeLaunch("p1", "svc_py_p1", source) + val listener = debugger.listener("p1") + listener.onState(paused) + val before = debugger.state.value + thread { + Thread.sleep(100) + listener.onState("""{"status":"running"}""") + Thread.sleep(100) + listener.onState(paused.replace("\"line\":3", "\"line\":4")) + } + val next = debugger.awaitSettled(before, 5_000) + assertEquals(DebugStatus.PAUSED, next.status) + assertNotSame(before, next) + assertEquals("paused", debugger.toJson(next).getString("status")) + } + + /** The JSON the real runcode_debugger.py publishes, parsed by the Kotlin side. */ + @Test fun `parses what the python debugger actually sends`() { + val python = listOfNotNull(System.getenv("RUNCODE_TEST_PYTHON"), System.getenv("RUNCODE_BUILD_PYTHON"), "python3") + .firstOrNull { runCatching { ProcessBuilder(it, "--version").start().waitFor() == 0 }.getOrDefault(false) } + org.junit.Assume.assumeTrue("No Python 3 on this machine", python != null) + + val dir = kotlin.io.path.createTempDirectory("dbg").toFile() + val sourceDir = File(dir, "source").apply { mkdirs() } + File(sourceDir, "main.py").writeText("def area(w, h):\n result = w * h\n return result\n\nprint(area(3, 4))\n") + val driver = """ + import json, sys, threading + sys.path.insert(0, sys.argv[1]) + import runcode_debugger as d + class Sink: + def onOutput(self, level, line): pass + # The runner routes print() on the script thread into the project log, so write to + # the real stdout directly. + def emit(raw): + sys.__stdout__.write("STATE " + raw + "\n") + sys.__stdout__.flush() + class Listener: + def onState(self, raw): + state = json.loads(raw) + if state["status"] == "paused": + emit(raw) + threading.Thread(target=lambda: d.command("svc", "continue")).start() + elif state["status"] == "finished": + emit(raw) + script = sys.argv[2] + "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/source/main.py" + d.debug_script("svc", script, sys.argv[2], [], Sink(), Listener(), json.dumps({script: [3]})) + """.trimIndent() + val process = ProcessBuilder(python!!, "-c", driver, File("src/main/python").absolutePath, dir.absolutePath) + .redirectErrorStream(true).start() + val finished = process.waitFor(30, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) process.destroyForcibly() + val output = process.inputStream.bufferedReader().readText() + assertTrue("debug driver did not finish: $output", finished) + val states = output.lines().filter { it.startsWith("STATE ") }.map { DebugState.parse("p1", it.removePrefix("STATE ")) } + assertEquals(output, 2, states.size) + + val pausedState = states[0] + assertEquals(DebugStatus.PAUSED, pausedState.status) + assertEquals(Triple("main.py", 3, "area"), Triple(pausedState.file, pausedState.line, pausedState.function)) + assertEquals("breakpoint", pausedState.reason) + assertEquals(listOf("area", ""), pausedState.stack.map { it.function }) + assertEquals(mapOf("w" to "3", "h" to "4", "result" to "12"), pausedState.locals.associate { it.name to it.value }) + assertEquals(DebugStatus.FINISHED, states[1].status) + assertEquals("completed", states[1].outcome) + dir.deleteRecursively() + } +} diff --git a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt index dfac65e..5ce6332 100644 --- a/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt +++ b/app/src/test/java/com/runcode/app/supervisor/ServiceSupervisorTest.kt @@ -21,7 +21,11 @@ import org.robolectric.annotation.Config class ServiceSupervisorTest { private val project = Project("test", "Test", "", ProjectProfile.PYTHON_SCRIPT, "/test", "main.py") - private class Handle(var alive: Boolean = false, var reason: ExitReason = ExitReason.CRASHED) : RuntimeHandle { + private class Handle( + var alive: Boolean = false, + var reason: ExitReason = ExitReason.CRASHED, + override val restartable: Boolean = true + ) : RuntimeHandle { override val serviceId = "test" override val projectId = "test" override val isAlive get() = alive @@ -49,6 +53,26 @@ class ServiceSupervisorTest { return ServiceSupervisor(context, RuntimeRegistry(engine, engine), LogManager(context), PortManager(), backgroundScope) } + @Test fun `a crashed debug run is not restarted, even with ALWAYS`() = runTest { + val engine = Engine(Handle(reason = ExitReason.CRASHED, restartable = false)) + val supervisor = supervisor(engine) + supervisor.startProject(project.copy(restartPolicy = RestartPolicy.ALWAYS)) + advanceTimeBy(60_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.FAILED, supervisor.instances.value[project.id]?.state) + } + + @Test fun `a finished debug run stops cleanly`() = runTest { + val engine = Engine(Handle(reason = ExitReason.COMPLETED, restartable = false)) + val supervisor = supervisor(engine) + supervisor.startProject(project.copy(restartPolicy = RestartPolicy.ALWAYS)) + advanceTimeBy(60_000) + runCurrent() + assertEquals(1, engine.starts) + assertEquals(ServiceState.STOPPED, supervisor.instances.value[project.id]?.state) + } + @Test fun `crashing services stop after four automatic retries`() = runTest { val engine = Engine(Handle()) val supervisor = supervisor(engine) diff --git a/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt new file mode 100644 index 0000000..ca9d242 --- /dev/null +++ b/app/src/test/java/com/runcode/app/ui/GitViewModelTest.kt @@ -0,0 +1,106 @@ +package com.runcode.app.ui + +import android.os.Looper +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.ProjectProfile +import com.runcode.app.git.GitIdentity +import com.runcode.app.git.HostPythonGitBackend +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The Git screen's view model against real git (dulwich in the host's Python). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class GitViewModelTest { + + private fun await(what: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + 30_000_000_000 + while (!condition() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertTrue("Timed out waiting for: $what", condition()) + } + + @Test fun `a view model clears only the unsaved-file marker it published`() { + val app = RuntimeEnvironment.getApplication() as RuncodeApp + val project = app.projectStorage.createProjectFromTemplate("VM marker", ProjectProfile.PYTHON_SCRIPT) + runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } + val model = MainViewModel(app) + await("projects") { model.projects.value.isNotEmpty() } + model.selectProject(project) + await("selection") { model.selectedProject.value?.id == project.id && model.editorContent.value.isNotEmpty() } + val onCleared = MainViewModel::class.java.getDeclaredMethod("onCleared").apply { isAccessible = true } + + model.updateEditorContent("unsaved") + await("published") { app.unsavedEditorFile?.first == project.id } + // Another activity's view model published since: not ours to clear. + val other = "another-project" to "main.py" + app.unsavedEditorFile = other + onCleared.invoke(model) + assertSame(other, app.unsavedEditorFile) + // Saving withdraws only this view model's marker, too. + model.saveCurrentFile() + await("saved") { !model.isDirty.value } + shadowOf(Looper.getMainLooper()).idle() // let the collector see the save + assertSame(other, app.unsavedEditorFile) + + app.unsavedEditorFile = null // the other activity saved as well + model.updateEditorContent("unsaved again") + await("published again") { app.unsavedEditorFile?.first == project.id } + onCleared.invoke(model) + assertNull("its own marker is cleared", app.unsavedEditorFile) + } + + @Test fun `initialise, commit, and refuse a pull over unsaved editor changes`() { + val python = HostPythonGitBackend.find() + assumeTrue("No Python with dulwich", python != null) + val app = RuntimeEnvironment.getApplication() as RuncodeApp + app.gitBackend = HostPythonGitBackend(python!!) + app.git.identity = GitIdentity("Ada", "ada@example.com") + + val project = app.projectStorage.createProjectFromTemplate("VM git", ProjectProfile.PYTHON_SCRIPT) + runBlocking { app.appMetaDatabase.insertOrUpdateProject(project) } + val model = MainViewModel(app) + await("projects") { model.projects.value.isNotEmpty() } + model.selectProject(project) + await("selection") { model.selectedProject.value?.id == project.id && model.editorContent.value.isNotEmpty() } + + model.refreshGit() + await("first refresh") { model.git.value.busy == null && model.git.value.projectId == project.id } + assertFalse(model.git.value.isRepository) + + model.gitInit() + await("init") { model.git.value.isRepository && model.git.value.busy == null } + assertEquals("main", model.git.value.status!!.branch) + assertFalse(model.git.value.status!!.isClean) + + model.gitCommit("First", stageAll = true) + await("commit") { model.git.value.log.size == 1 && model.git.value.busy == null } + assertTrue(model.git.value.status!!.isClean) + assertEquals("First", model.git.value.log.single().message) + + // Unsaved edits: a pull would rewrite files underneath the editor. + model.updateEditorContent("unsaved change") + model.gitPull() + assertEquals("Save the file open in the editor first.", model.git.value.error) + // The MCP bridge sees the same unsaved file, so its git_pull refuses too. + await("unsaved file published") { app.unsavedEditorFile == project.id to model.activeTab.value } + + // A failing operation surfaces its reason and leaves the screen usable. + model.saveCurrentFile() + await("save") { !model.isDirty.value } + await("unsaved file cleared") { app.unsavedEditorFile == null } + model.gitPush() + await("push error") { model.git.value.busy == null && model.git.value.error != null } + assertTrue(model.git.value.error!!.contains("remote")) + assertTrue(model.git.value.isRepository) + } +} diff --git a/app/src/test/java/com/runcode/app/ui/LogShareTest.kt b/app/src/test/java/com/runcode/app/ui/LogShareTest.kt new file mode 100644 index 0000000..15d8475 --- /dev/null +++ b/app/src/test/java/com/runcode/app/ui/LogShareTest.kt @@ -0,0 +1,42 @@ +package com.runcode.app.ui + +import android.content.Intent +import android.net.Uri +import com.runcode.app.RuncodeApp +import com.runcode.app.domain.models.LogLevel +import com.runcode.app.security.SecretRedactor +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = RuncodeApp::class) +class LogShareTest { + + @Test fun `shared logs go through the file provider with secrets redacted`() { + val app = RuntimeEnvironment.getApplication() as RuncodeApp + SecretRedactor.register("bot-token-123456:ABCDEF") + app.logManager.log("p1", "Bot", LogLevel.INFO, "connecting with bot-token-123456:ABCDEF") + app.logManager.log("p1", "Bot", LogLevel.WARN, "second line") + + val chooser = MainViewModel(app).logShareIntent() + assertNotNull(chooser) + @Suppress("DEPRECATION") + val send = chooser!!.getParcelableExtra(Intent.EXTRA_INTENT)!! + assertEquals(Intent.ACTION_SEND, send.action) + assertTrue(send.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + + @Suppress("DEPRECATION") + val uri = send.getParcelableExtra(Intent.EXTRA_STREAM)!! + assertEquals("content", uri.scheme) + assertEquals("${app.packageName}.files", uri.authority) + + val text = app.contentResolver.openInputStream(uri)!!.bufferedReader().use { it.readText() } + assertTrue(text.contains("second line")) + assertTrue(text.contains("[REDACTED]")) + assertFalse(text.contains("bot-token-123456:ABCDEF")) + } +} diff --git a/review-notes.md b/review-notes.md index 9c2a07e..20f4ab1 100644 --- a/review-notes.md +++ b/review-notes.md @@ -1,5 +1,40 @@ # Review notes +## v1.5.0 (versionCode 6) — in progress on `ccr-00c06d8d-qqvzlg` + +Roadmap phases 3 (diagnostics), 4 (backups to a chosen folder), 5 (git) and 6 (debugger), plus +a public URL for the MCP bridge. + +| Change | Where | +| --- | --- | +| LAN address prefers Wi-Fi, hotspot and Ethernet; mobile data and VPN interfaces are skipped. | `PortManager.getLanIp` | +| **Public URL**: outbound SSH reverse tunnel (Pinggy on 443, localhost.run fallback) with backoff and reconnect on network change. Works behind NAT and with a VPN on. | `mcp/McpTunnel`, `mcp/SshTunnelConnector` | +| **Diagnostics** card and `run_diagnostics`: app, network, service ports, bridge self-test. **Share logs** through a FileProvider. | `diagnostics/`, System screen | +| **Backup folder** through the Storage Access Framework, export verified by read-back hash, restore from the folder, optional daily run keeping 7 automatic copies. `backup_project` MCP tool. | `backup/`, Backups screen | +| Backup manifests are built with `JSONObject`; a quote in a project name broke restore. | `BackupManager` | +| `serverInfo.version` reports the app version instead of `1.0.0`. | `McpServer` | +| **Git** (phase 5) on dulwich: Git screen, clone into a project, token only sent to GitHub over HTTPS. `git_*` MCP tools. | `git/`, `runcode_git.py` | +| **Debugger** (phase 6) on bdb: breakpoints in the gutter, step over/into/out, variables, call stack, eval. Debug runs are never auto-restarted. `debug_*` MCP tools. | `runcode_debugger.py`, `runtime/PythonDebugger`, Editor | + +Validation in this environment (no Android device or KVM available): + +- 87 JVM/Robolectric tests and 5 Python tests pass; `testDebugUnitTest lintDebug assembleDebug` + succeeds with 0 lint errors and the same 38 warnings as v1.4.0. +- The tunnel runs the real JSch client against an in-process SSH server that behaves like + Pinggy, including an HTTP request through the forward. A JSch race in `channel.connect(timeout)` + failed about 2% of opens; the fix took a 150-run stress loop from 3 failures to 0. +- The SAF store runs against a real `DocumentsProvider` under Robolectric. +- The diagnostics runner runs against the real application object with live DNS, HTTPS and + Telegram probes. + +Not yet verified: + +- On a device: the Pinggy relay itself, the folder picker with Google Drive and OneDrive, the + share sheet, the diagnostics card on a 360dp screen, and notification/battery checks. +- Release signing: `assembleRelease` with the release key and `apksigner verify`. + +## v1.4.0 + Baseline: `929cc23` on `main`. This pass covers the editor, runtime lifecycle, project storage, secrets and MCP, then implements the project-settings step from the supplied roadmap. diff --git a/tests/test_debugger.py b/tests/test_debugger.py new file mode 100644 index 0000000..fa2d5b2 --- /dev/null +++ b/tests/test_debugger.py @@ -0,0 +1,346 @@ +"""Host-side checks for runcode_debugger: real scripts, real trace hook, commands from another thread.""" +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app/src/main/python")) +import runcode_debugger as debugger # noqa: E402 +import runcode_runner as runner # noqa: E402 + +SCRIPT = """\ +import helper + +total = 0 +for n in range(3): + total = helper.double(n) + total +message = "done %d" % total +print(message) +""" + +HELPER = """\ +def double(x): + y = x * 2 + return y +""" + + +class Sink: + def __init__(self): + self.lines = [] + + def onOutput(self, level, line): + self.lines.append((level, line)) + + +class Listener: + def __init__(self): + self.states = [] + self.cond = threading.Condition() + + def onState(self, raw): + with self.cond: + self.states.append(json.loads(raw)) + self.cond.notify_all() + + def mark(self): + with self.cond: + return len(self.states) + + def wait_for(self, predicate, timeout=10, after=0): + """First state published at index >= after that matches.""" + found = [] + + def match(): + for state in self.states[after:]: + if predicate(state): + found.append(state) + return True + return False + + with self.cond: + ok = self.cond.wait_for(match, timeout) + assert ok, "timed out; states %r" % (self.states[after:] or None) + return found[0] + + def paused(self, after=0): + return self.wait_for(lambda s: s["status"] == "paused", after=after) + + +class DebuggerTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.source = os.path.join(self.tmp.name, "source") + os.makedirs(self.source) + Path(self.source, "main.py").write_text(SCRIPT) + Path(self.source, "helper.py").write_text(HELPER) + self.script = os.path.join(self.source, "main.py") + self.helper = os.path.join(self.source, "helper.py") + self.sink = Sink() + self.listener = Listener() + self.outcome = [] + self.session = "svc_py_test_%d" % time.monotonic_ns() + + def tearDown(self): + # A failed test must not leave a paused script holding the interpreter. + if json.loads(debugger.state(self.session))["status"] != "idle": + runner.request_stop(self.session) + deadline = time.monotonic() + 5 + while json.loads(debugger.state(self.session))["status"] != "idle" and time.monotonic() < deadline: + time.sleep(0.05) + self.tmp.cleanup() + sys.modules.pop("helper", None) + + def start(self, breakpoints): + thread = threading.Thread(target=lambda: self.outcome.append(debugger.debug_script( + self.session, self.script, self.tmp.name, [], self.sink, self.listener, json.dumps(breakpoints))), + daemon=True) + thread.start() + return thread + + def send(self, name): + """Sends a command; returns the index after which its effects are published.""" + mark = self.listener.mark() + reply = json.loads(debugger.command(self.session, name)) + self.assertTrue(reply["ok"], reply) + return mark + + def finish(self, thread): + thread.join(10) + self.assertFalse(thread.is_alive()) + return self.listener.wait_for(lambda s: s["status"] == "finished") + + def test_breakpoint_variables_and_continue(self): + thread = self.start({self.script: [6]}) + state = self.listener.paused() + self.assertEqual(("main.py", 6, "breakpoint"), (state["file"], state["line"], state["reason"])) + names = {v["name"]: v["value"] for v in state["globals"]} + self.assertEqual("6", names["total"]) + self.assertNotIn("helper", names, "modules are not shown as variables") + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertIn(("stdout", "done 6"), self.sink.lines) + + def test_breakpoint_in_another_file_shows_the_call_stack(self): + thread = self.start({self.helper: [3]}) + state = self.listener.paused() + self.assertEqual(("helper.py", 3, "double"), (state["file"], state["line"], state["function"])) + self.assertEqual(["double", ""], [f["function"] for f in state["stack"]]) + self.assertEqual({"x": "0", "y": "0"}, {v["name"]: v["value"] for v in state["locals"]}) + # Hit again on the next loop iteration. + mark = self.send("continue") + state = self.listener.paused(after=mark) + self.assertEqual("1", {v["name"]: v["value"] for v in state["locals"]}["x"]) + mark = self.send("continue") + self.listener.paused(after=mark) + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + + def test_step_into_over_and_out(self): + thread = self.start({self.script: [5]}) + self.assertEqual(5, self.listener.paused()["line"]) + + state = self.listener.paused(after=self.send("step")) # into helper.double + self.assertEqual(("helper.py", 2), (state["file"], state["line"])) + + state = self.listener.paused(after=self.send("next")) + self.assertEqual(("helper.py", 3), (state["file"], state["line"])) + + state = self.listener.paused(after=self.send("return")) # back in the caller + self.assertEqual("main.py", state["file"]) + self.assertIn(state["line"], (4, 5)) + + state = self.listener.paused(after=self.send("next")) + self.assertEqual("main.py", state["file"], "next must not descend into helper.double") + # The breakpoint sits in a loop: every remaining stop is on it, until the end. + while True: + state = self.listener.wait_for(lambda s: s["status"] in ("paused", "finished"), + after=self.send("continue")) + if state["status"] == "finished": + break + self.assertEqual((5, "breakpoint"), (state["line"], state["reason"])) + self.assertEqual("completed", state["outcome"]) + + def test_stepping_never_enters_library_code(self): + Path(self.script).write_text("import json\ndata = json.dumps({'a': 1})\nprint(data)\n") + thread = self.start({self.script: [2]}) + self.listener.paused() + state = self.listener.paused(after=self.send("step")) + self.assertEqual(("main.py", 3), (state["file"], state["line"])) + self.send("continue") + self.finish(thread) + + def test_evaluate_in_the_paused_frame(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + self.assertEqual({"ok": True, "type": "int", "value": "10"}, + json.loads(debugger.evaluate(self.session, "y + 10"))) + self.assertIn("NameError", json.loads(debugger.evaluate(self.session, "nope"))["error"]) + self.assertIn("SyntaxError", json.loads(debugger.evaluate(self.session, "1 +"))["error"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.finish(thread) + + def test_a_slow_expression_times_out_and_is_interrupted(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + original = debugger._EVAL_TIMEOUT_S + debugger._EVAL_TIMEOUT_S = 0.5 + try: + started = time.monotonic() + reply = json.loads(debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))")) + self.assertLess(time.monotonic() - started, 3, "the timeout must not wait for the expression") + finally: + debugger._EVAL_TIMEOUT_S = original + self.assertFalse(reply["ok"]) + self.assertIn("interrupted", reply["error"]) + # The paused frame is still usable afterwards. + self.assertEqual("int", json.loads(debugger.evaluate(self.session, "y"))["type"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.assertEqual("completed", self.finish(thread)["outcome"]) + + def test_stop_interrupts_a_running_expression(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + replies = [] + evaluating = threading.Thread(target=lambda: replies.append(json.loads( + debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))"))), daemon=True) + evaluating.start() + time.sleep(0.3) + started = time.monotonic() + self.send("stop") + self.assertEqual("stopped", self.finish(thread)["outcome"]) + self.assertLess(time.monotonic() - started, 3) + evaluating.join(5) + self.assertFalse(replies[0]["ok"]) + + def test_an_expression_is_interrupted_at_most_once(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + session = debugger._sessions[self.session] + replies = [] + evaluating = threading.Thread(target=lambda: replies.append(json.loads( + debugger.evaluate(self.session, "sum(1 for _ in iter(int, 1))"))), daemon=True) + evaluating.start() + deadline = time.monotonic() + 5 + while session.evaluating is None and time.monotonic() < deadline: + time.sleep(0.01) + with session.cond: + # A timeout and a stop racing for the same expression send one exception only; + # a second could land in _serve's handler and make the run end as failed. + self.assertTrue(session._interrupt_evaluation()) + self.assertFalse(session._interrupt_evaluation()) + self.assertTrue(json.loads(session.send("stop"))["ok"]) + self.assertEqual("stopped", self.finish(thread)["outcome"]) + evaluating.join(5) + self.assertFalse(replies[0]["ok"]) + + def test_concurrent_evaluations_each_get_their_own_result(self): + thread = self.start({self.helper: [3]}) + self.listener.paused() + results = {} + + def run(n): + results[n] = json.loads(debugger.evaluate(self.session, "x * 100 + %d" % n)) + + workers = [threading.Thread(target=run, args=(n,)) for n in range(8)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(10) + self.assertEqual({n: str(n) for n in range(8)}, {n: r["value"] for n, r in results.items()}) + self.assertFalse(json.loads(debugger.command(self.session, "jump"))["ok"]) + for _ in range(2): + self.listener.paused(after=self.send("continue")) + self.send("continue") + self.finish(thread) + + def test_a_second_command_is_refused_until_the_first_runs(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + session = debugger._sessions[self.session] + with session.cond: + # Hold the lock so the paused thread cannot take the first command yet. + self.assertTrue(json.loads(session.send("continue"))["ok"]) + self.assertFalse(json.loads(session.send("step"))["ok"]) + self.assertEqual("completed", self.finish(thread)["outcome"]) + + def test_a_command_sent_the_moment_a_pause_is_announced_is_accepted(self): + # The listener reacts synchronously, the way an eager client would. + replies = [] + session = self.session + + class Eager(Listener): + def onState(inner, raw): + Listener.onState(inner, raw) + if json.loads(raw)["status"] == "paused": + replies.append(json.loads(debugger.command(session, "continue"))) + + self.listener = Eager() + thread = self.start({self.script: [6]}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertEqual([{"ok": True}], replies) + + def test_commands_are_refused_while_running(self): + self.assertFalse(json.loads(debugger.command("nobody", "continue"))["ok"]) + self.assertFalse(json.loads(debugger.command(self.session, "jump"))["ok"]) + + def test_stop_command_reports_stopped(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + self.send("stop") + self.assertEqual("stopped", self.finish(thread)["outcome"]) + self.assertNotIn(("stdout", "done 6"), self.sink.lines) + + def test_supervisor_stop_interrupts_a_paused_script(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + runner.request_stop(self.session) + self.assertEqual("stopped", self.finish(thread)["outcome"]) + + def test_breakpoints_can_change_while_running(self): + thread = self.start({self.script: [3]}) + self.listener.paused() + reply = json.loads(debugger.set_breakpoints(self.session, self.script, json.dumps([7]))) + self.assertTrue(reply["ok"], reply) + self.assertEqual(7, self.listener.paused(after=self.send("continue"))["line"]) + self.send("continue") + self.finish(thread) + + def test_invalid_breakpoint_is_reported_not_fatal(self): + thread = self.start({self.script: [999]}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertTrue(any("Breakpoint ignored" in line for _, line in self.sink.lines)) + + def test_without_breakpoints_nothing_pauses(self): + thread = self.start({}) + self.assertEqual("completed", self.finish(thread)["outcome"]) + self.assertFalse(any(s["status"] == "paused" for s in self.listener.states)) + + def test_lines_are_not_traced_in_files_without_breakpoints(self): + # bdb only installs a per-line hook in frames whose file has a breakpoint, so a loop + # in other code runs without per-line callbacks. + Path(self.helper).write_text("def double(x):\n import sys\n return 0 if sys._getframe().f_trace is None else 1\n") + Path(self.script).write_text("import helper\nprint('lines traced: %d' % helper.double(1))\nx = 1\n") + thread = self.start({self.script: [3]}) + self.listener.paused() + self.send("continue") + self.finish(thread) + self.assertIn(("stdout", "lines traced: 0"), self.sink.lines) + + def test_normal_runs_are_unaffected(self): + outcome = runner.run_script("plain_%d" % time.monotonic_ns(), self.script, self.tmp.name, [], self.sink) + self.assertEqual("completed", outcome) + self.assertEqual(json.loads(debugger.state("plain"))["status"], "idle") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..018fb23 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,206 @@ +"""Host-side checks for runcode_git against local repositories and an authenticated HTTP server.""" +import base64 +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "app/src/main/python")) + +try: + import runcode_git as git + from dulwich.repo import Repo + from dulwich.server import DictBackend + from dulwich.web import make_server, make_wsgi_chain, WSGIRequestHandlerLogger, WSGIServerLogger +except ImportError as missing: # pragma: no cover + git = None + MISSING = missing + +TOKEN = "ghp_testtoken1234567890" + + +def ok(raw): + result = json.loads(raw) + if not result["ok"]: + raise AssertionError(result["error"]) + return result + + +def fail(raw): + result = json.loads(raw) + assert not result["ok"], result + return result["error"] + + +def write(directory, name, text): + path = Path(directory, name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +@unittest.skipIf(git is None, "dulwich is not installed") +class LocalGitTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = os.path.join(self.tmp.name, "source") + os.makedirs(self.repo) + write(self.repo, "main.py", "print('hi')\n") + + def tearDown(self): + self.tmp.cleanup() + + def commit(self, message="work"): + ok(git.add(self.repo, "[]")) + return ok(git.commit(self.repo, message, "Ada Lovelace", "ada@example.com")) + + def test_init_status_commit_log(self): + self.assertFalse(git.is_repo(self.repo)) + self.assertEqual("main", ok(git.init(self.repo))["branch"]) + self.assertTrue(git.is_repo(self.repo)) + self.assertTrue(Path(self.repo, ".gitignore").read_text().startswith("__pycache__/")) + + status = ok(git.status(self.repo)) + self.assertEqual([".gitignore", "main.py"], status["untracked"]) + + ok(git.add(self.repo, json.dumps(["main.py"]))) + self.assertEqual(["main.py"], ok(git.status(self.repo))["staged"]["added"]) + + sha = self.commit("First commit")["sha"] + entries = ok(git.log(self.repo, "10"))["commits"] + self.assertEqual(sha, entries[0]["sha"]) + self.assertEqual("First commit", entries[0]["message"]) + self.assertEqual("Ada Lovelace ", entries[0]["author"]) + self.assertEqual([], ok(git.status(self.repo))["untracked"]) + + def test_commit_needs_message_identity_and_staged_changes(self): + ok(git.init(self.repo)) + ok(git.add(self.repo, "[]")) + self.assertIn("commit message", fail(git.commit(self.repo, " ", "A", "a@b.c"))) + self.assertIn("name and email", fail(git.commit(self.repo, "msg", "", "a@b.c"))) + ok(git.commit(self.repo, "msg", "A", "a@b.c")) + self.assertIn("Nothing is staged", fail(git.commit(self.repo, "again", "A", "a@b.c"))) + + def test_add_all_stages_deletions(self): + ok(git.init(self.repo)) + self.commit() + os.remove(os.path.join(self.repo, "main.py")) + ok(git.add(self.repo, "[]")) + self.assertEqual(["main.py"], ok(git.status(self.repo))["staged"]["deleted"]) + + def test_branches_and_checkout(self): + ok(git.init(self.repo)) + self.assertIn("first commit", fail(git.checkout(self.repo, "feature", True))) + self.commit() + ok(git.checkout(self.repo, "feature", True)) + write(self.repo, "feature.py", "x = 1\n") + self.commit("feature work") + self.assertEqual({"current": "feature", "branches": ["feature", "main"]}, + {k: v for k, v in ok(git.branches(self.repo)).items() if k != "ok"}) + ok(git.checkout(self.repo, "main", False)) + self.assertFalse(Path(self.repo, "feature.py").exists()) + + def test_not_a_repository_is_a_clear_error(self): + self.assertIn("Not a git repository", fail(git.status(self.repo))) + + def test_credentials_in_remote_url_are_refused(self): + ok(git.init(self.repo)) + error = fail(git.set_remote(self.repo, "https://user:%s@github.com/a/b.git" % TOKEN)) + self.assertNotIn(TOKEN, error) + + def test_push_clone_pull_and_divergence_with_a_bare_remote(self): + remote = os.path.join(self.tmp.name, "remote.git") + Repo.init_bare(remote, mkdir=True).close() + + ok(git.init(self.repo)) + self.commit("first") + ok(git.set_remote(self.repo, remote)) + self.assertIsNone(ok(git.status(self.repo))["ahead"]) + ok(git.push(self.repo, "main", "", "")) + status = ok(git.status(self.repo)) + self.assertEqual((0, 0), (status["ahead"], status["behind"])) + self.assertEqual(remote, status["remote"]) + + other = os.path.join(self.tmp.name, "other") + self.assertEqual("main", ok(git.clone(remote, other, "", ""))["branch"]) + self.assertEqual("print('hi')\n", Path(other, "main.py").read_text()) + + write(other, "second.py", "y = 2\n") + ok(git.add(other, "[]")) + ok(git.commit(other, "second", "Bob", "bob@example.com")) + ok(git.push(other, "main", "", "")) + + self.assertEqual(1, ok(git.pull(self.repo, "main", "", ""))["new_commits"]) + self.assertTrue(Path(self.repo, "second.py").exists()) + + # Both sides commit: pull must refuse rather than merge. + write(other, "third.py", "z = 3\n") + ok(git.add(other, "[]")) + ok(git.commit(other, "third", "Bob", "bob@example.com")) + ok(git.push(other, "main", "", "")) + write(self.repo, "mine.py", "m = 1\n") + self.commit("mine") + self.assertIn("diverged", fail(git.pull(self.repo, "main", "", ""))) + + +class _BasicAuth: + """Rejects every request that does not carry the token, like GitHub does.""" + + def __init__(self, app): + self.app = app + self.expected = "Basic " + base64.b64encode(("x-access-token:" + TOKEN).encode()).decode() + + def __call__(self, environ, start_response): + if environ.get("HTTP_AUTHORIZATION") != self.expected: + start_response("401 Unauthorized", [("WWW-Authenticate", 'Basic realm="git"'), ("Content-Length", "0")]) + return [b""] + return self.app(environ, start_response) + + +@unittest.skipIf(git is None, "dulwich is not installed") +class HttpGitTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + bare = os.path.join(self.tmp.name, "remote.git") + self.remote = Repo.init_bare(bare, mkdir=True) + app = _BasicAuth(make_wsgi_chain(DictBackend({"/": self.remote}))) + self.server = make_server("127.0.0.1", 0, app, handler_class=WSGIRequestHandlerLogger, + server_class=WSGIServerLogger) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.url = "http://127.0.0.1:%d/" % self.server.server_port + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.remote.close() + self.tmp.cleanup() + + def test_push_and_clone_need_the_token_and_never_echo_it(self): + repo = os.path.join(self.tmp.name, "source") + os.makedirs(repo) + write(repo, "app.py", "print('pushed over http')\n") + ok(git.init(repo)) + ok(git.add(repo, "[]")) + ok(git.commit(repo, "http", "Ada", "ada@example.com")) + ok(git.set_remote(repo, self.url)) + + wrong = "ghp_wrongwrongwrong" + error = fail(git.push(repo, "main", "x-access-token", wrong)) + self.assertNotIn(wrong, error) + self.assertNotIn(TOKEN, error) + + ok(git.push(repo, "main", "x-access-token", TOKEN)) + self.assertIn(b"refs/heads/main", self.remote.refs.keys()) + + target = os.path.join(self.tmp.name, "cloned") + ok(git.clone(self.url, target, "x-access-token", TOKEN)) + self.assertEqual("print('pushed over http')\n", Path(target, "app.py").read_text()) + # The token must not end up in the clone's config. + self.assertNotIn(TOKEN, Path(target, ".git", "config").read_text()) + + +if __name__ == "__main__": + unittest.main()