diff --git a/README.md b/README.md index a8ad7ca..bb24c6a 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,13 @@ data: [DONE] ## Changelog +### v3.3.0 (2026-05-21) +- **File Manager** — browse device files (App Files, Downloads, Camera, Storage), open/preview any file, install APKs directly +- **SSH / Remote Access** — AI can SSH into external machines, SCP upload/download, curl URLs +- **Approval Gate** — all sensitive actions (SSH, SCP, curl, adb, pip, npm, etc.) require explicit user approval via in-app dialog +- **New Action Tags** — `[SSH_EXEC]`, `[SSH_UPLOAD]`, `[SSH_DOWNLOAD]`, `[REMOTE_EXEC]`, `[CURL_EXEC]` +- **Agentic Mode** — updated system prompt with full external access documentation + ### v3.2.0 (2026-05-20) - **Full Internal Virtual Environment** — `BootstrapPlugin.setupVirtualEnv()` now provisions an app-contained Python venv under app storage, no external Termux app required - **In-App Module Installation** — `BootstrapPlugin.venvPipInstall()` installs Python modules directly into the internal venv diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ffe381..a048e74 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -21,8 +21,8 @@ android { applicationId "ai.z.chat" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 23 - versionName "3.2.0" + versionCode 25 + versionName "3.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' diff --git a/android/app/src/main/java/ai/z/chat/FileManagerPlugin.java b/android/app/src/main/java/ai/z/chat/FileManagerPlugin.java new file mode 100644 index 0000000..483308d --- /dev/null +++ b/android/app/src/main/java/ai/z/chat/FileManagerPlugin.java @@ -0,0 +1,160 @@ +package ai.z.chat; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.webkit.MimeTypeMap; + +import androidx.core.content.FileProvider; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@CapacitorPlugin(name = "FileManager") +public class FileManagerPlugin extends Plugin { + + @PluginMethod + public void openFile(PluginCall call) { + String path = call.getString("path", ""); + String mimeType = call.getString("mimeType", ""); + if (path.isEmpty()) { + call.reject("No path provided"); + return; + } + + File file = new File(path); + if (!file.exists()) { + call.reject("File not found: " + path); + return; + } + + try { + Context context = getContext(); + Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", file); + String resolvedMime = mimeType.isEmpty() ? guessMimeType(file.getName()) : mimeType; + if (resolvedMime == null || resolvedMime.isEmpty()) resolvedMime = "*/*"; + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, resolvedMime); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + + call.resolve(new JSObject().put("opened", true).put("path", file.getAbsolutePath()).put("mimeType", resolvedMime)); + } catch (Exception e) { + call.reject("Open failed: " + e.getMessage()); + } + } + + @PluginMethod + public void listFiles(PluginCall call) { + String rootPath = call.getString("path", ""); + if (rootPath.isEmpty()) { + call.reject("No path provided"); + return; + } + + File root = new File(rootPath); + if (!root.exists()) { + call.reject("Path not found: " + rootPath); + return; + } + + JSObject result = new JSObject(); + result.put("path", root.getAbsolutePath()); + result.put("name", root.getName()); + result.put("directory", root.isDirectory()); + result.put("size", root.isFile() ? root.length() : 0); + result.put("mimeType", root.isFile() ? guessMimeType(root.getName()) : "inode/directory"); + + if (root.isDirectory()) { + File[] children = root.listFiles(); + List items = new ArrayList<>(); + if (children != null) { + Arrays.sort(children, (a, b) -> a.getName().compareToIgnoreCase(b.getName())); + for (File child : children) { + JSObject item = new JSObject(); + item.put("name", child.getName()); + item.put("path", child.getAbsolutePath()); + item.put("directory", child.isDirectory()); + item.put("size", child.isFile() ? child.length() : 0); + item.put("mimeType", child.isFile() ? guessMimeType(child.getName()) : "inode/directory"); + items.add(item); + } + } + result.put("items", items); + } + + call.resolve(result); + } + + @PluginMethod + public void openContainingFolder(PluginCall call) { + String path = call.getString("path", ""); + if (path.isEmpty()) { + call.reject("No path provided"); + return; + } + + File file = new File(path); + File target = file.isDirectory() ? file : file.getParentFile(); + if (target == null || !target.exists()) { + call.reject("Containing folder not found"); + return; + } + + call.resolve(new JSObject().put("opened", true).put("path", target.getAbsolutePath())); + } + + @PluginMethod + public void getRoots(PluginCall call) { + JSObject result = new JSObject(); + List roots = new ArrayList<>(); + + roots.add(rootItem("App Files", getContext().getFilesDir())); + File ext = getContext().getExternalFilesDir(null); + if (ext != null) roots.add(rootItem("External Files", ext)); + File downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + if (downloads != null) roots.add(rootItem("Downloads", downloads)); + File dcim = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); + if (dcim != null) roots.add(rootItem("Camera", dcim)); + File sdcard = Environment.getExternalStorageDirectory(); + if (sdcard != null) roots.add(rootItem("Storage Root", sdcard)); + + result.put("roots", roots); + call.resolve(result); + } + + private JSObject rootItem(String label, File file) { + JSObject item = new JSObject(); + item.put("name", label); + item.put("path", file.getAbsolutePath()); + item.put("directory", true); + item.put("size", 0); + item.put("mimeType", "inode/directory"); + return item; + } + + private String guessMimeType(String name) { + String lower = name.toLowerCase(); + if (lower.endsWith(".apk")) return "application/vnd.android.package-archive"; + if (lower.endsWith(".apks")) return "application/vnd.android.package-archive"; + if (lower.endsWith(".zip")) return "application/zip"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".html") || lower.endsWith(".htm")) return "text/html"; + if (lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".log")) return "text/plain"; + String ext = MimeTypeMap.getFileExtensionFromUrl(name); + if (ext == null || ext.isEmpty()) return "*/*"; + String guess = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.toLowerCase()); + return guess != null ? guess : "*/*"; + } +} diff --git a/android/app/src/main/java/ai/z/chat/InstallerPlugin.java b/android/app/src/main/java/ai/z/chat/InstallerPlugin.java index 835f584..e6f58da 100644 --- a/android/app/src/main/java/ai/z/chat/InstallerPlugin.java +++ b/android/app/src/main/java/ai/z/chat/InstallerPlugin.java @@ -15,6 +15,9 @@ import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.OutputStream; @CapacitorPlugin(name = "Installer") public class InstallerPlugin extends Plugin { @@ -58,6 +61,45 @@ public class InstallerPlugin extends Plugin { } } + @PluginMethod + public void exportApk(PluginCall call) { + String path = call.getString("path", ""); + String name = call.getString("name", "app.apk"); + if (path.isEmpty()) { + call.reject("No path provided"); + return; + } + + File apkFile = new File(path); + if (!apkFile.exists()) { + call.reject("APK file not found: " + path); + return; + } + + try { + Context context = getContext(); + File outRoot = context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOWNLOADS); + if (outRoot == null) { + outRoot = context.getFilesDir(); + } + File outDir = new File(outRoot, "ZAI-Chat"); + if (!outDir.exists()) outDir.mkdirs(); + File outFile = new File(outDir, name); + try (FileInputStream fis = new FileInputStream(apkFile); + FileOutputStream fos = new FileOutputStream(outFile)) { + byte[] buf = new byte[8192]; + int r; + while ((r = fis.read(buf)) > 0) fos.write(buf, 0, r); + } + call.resolve(new JSObject() + .put("exported", true) + .put("path", outFile.getAbsolutePath()) + .put("name", name)); + } catch (Exception e) { + call.reject("Export failed: " + e.getMessage()); + } + } + @PluginMethod public void getDeviceInfo(PluginCall call) { JSObject info = new JSObject(); diff --git a/android/app/src/main/java/ai/z/chat/MainActivity.java b/android/app/src/main/java/ai/z/chat/MainActivity.java index cc589be..702f58e 100644 --- a/android/app/src/main/java/ai/z/chat/MainActivity.java +++ b/android/app/src/main/java/ai/z/chat/MainActivity.java @@ -9,6 +9,7 @@ public class MainActivity extends BridgeActivity { protected void onCreate(Bundle savedInstanceState) { registerPlugin(ShellPlugin.class); registerPlugin(InstallerPlugin.class); + registerPlugin(FileManagerPlugin.class); registerPlugin(WakePlugin.class); registerPlugin(BootstrapPlugin.class); registerPlugin(AutoGLMPlugin.class); diff --git a/package.json b/package.json index 380c878..ef7143d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zai-chat", - "version": "3.2.0", + "version": "3.3.0", "description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan", "main": "index.js", "scripts": { diff --git a/releases/Z.AI-Chat-v3.1.1-release.apk.idsig b/releases/Z.AI-Chat-v3.1.1-release.apk.idsig deleted file mode 100644 index 8b5ab1b..0000000 Binary files a/releases/Z.AI-Chat-v3.1.1-release.apk.idsig and /dev/null differ diff --git a/releases/Z.AI-Chat-v3.1.2-release.apk.idsig b/releases/Z.AI-Chat-v3.1.2-release.apk.idsig deleted file mode 100644 index 7fc6266..0000000 Binary files a/releases/Z.AI-Chat-v3.1.2-release.apk.idsig and /dev/null differ diff --git a/www/index.html b/www/index.html index 2377428..0dfedd7 100644 --- a/www/index.html +++ b/www/index.html @@ -98,7 +98,10 @@

Project Files

- +
+ + +
No files yet.
AI-generated files appear here.
@@ -231,7 +234,7 @@
-
+
@@ -338,13 +341,23 @@

About

-

Z.AI Chat v3.2.0

+

Z.AI Chat v3.3.0

Built with Z.AI SDK & GLM-5.1

Compatible with Android 15/16

Changelog

    +
  • + v3.3.0 + 2026-05-21 +
      +
    • File Manager — browse device files, open/preview any file, install APKs directly
    • +
    • SSH / Remote Access — AI can SSH into external machines, SCP files, curl URLs (user approves each)
    • +
    • Approval Gate — all sensitive actions (SSH, SCP, curl, adb, pip, etc.) require your explicit approval
    • +
    • New Action Tags — [SSH_EXEC], [SSH_UPLOAD], [SSH_DOWNLOAD], [REMOTE_EXEC], [CURL_EXEC]
    • +
    +
  • v3.2.0 2026-05-20 @@ -612,6 +625,21 @@
+ +
diff --git a/www/js/app.js b/www/js/app.js index bd67518..4990893 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -8,7 +8,7 @@ chat: 'You are a helpful, knowledgeable AI assistant. Be concise and accurate.', coding: 'You are an expert coding assistant with internal tool access. Use action tags when execution is needed: [CREATE_FILE ...][/CREATE_FILE], [RUN_COMMAND]...[/RUN_COMMAND], [BUILD_APK project], [INSTALL_APK path], [VENV_SETUP], [VENV_PIP_INSTALL package]. Keep responses concise and complete.', brainstorm: 'You are a creative brainstorming partner. Generate diverse ideas, explore unconventional angles, build on concepts, and help evaluate trade-offs. Think freely and expansively. Present ideas in organized lists or tables when appropriate.', - agentic: 'You are an autonomous agent with full control of this app sandbox: internal Linux bootstrap, terminal, virtual env, build tools, and device UI via AutoGLM.\n\n## File Operations:\n[CREATE_FILE path/to/file.ext]\ncontents\n[/CREATE_FILE]\n\n## Shell:\n[RUN_COMMAND]\ncommand\n[/RUN_COMMAND]\n\n## Build:\n[BUILD_APK project_name]\n[INSTALL_APK /path/to/file.apk]\n\n## In-App Virtual Env:\n[VENV_SETUP]\n[VENV_PIP_INSTALL package_name]\n\n## AutoGLM Device Control:\n[DEVICE_TAP x y]\n[DEVICE_LONG_PRESS x y]\n[DEVICE_SWIPE startX startY endX endY]\n[DEVICE_TYPE text]\n[DEVICE_PRESS_BACK]\n[DEVICE_PRESS_HOME]\n[DEVICE_PRESS_RECENTS]\n[DEVICE_SCREENSHOT]\n[DEVICE_UI_TREE]\n[DEVICE_CLICK_TEXT button text]\n[DEVICE_CLICK_ID com.example:id/viewId]\n[DEVICE_LAUNCH com.example.app]\n[DEVICE_CURRENT_APP]\n\n## Hermes Agent:\n[HERMES_INSTALL]\n[HERMES_EXEC command]\n\n## Rules:\n1. Prefer internal sandbox tools and virtual env first\n2. Use [CREATE_FILE], then [BUILD_APK], then [INSTALL_APK]\n3. Use [DEVICE_*] for device control; start with [DEVICE_UI_TREE]\n4. Generate complete files, never stubs\n5. For Java: package ai.z.app, target SDK 36\n6. Output [TASK_COMPLETE] only when all work is done' + agentic: 'You are an autonomous agent with full control of this app: internal Linux, terminal, virtual env, build tools, device UI, and EXTERNAL network access.\n\n## File Operations:\n[CREATE_FILE path/to/file.ext]\ncontents\n[/CREATE_FILE]\n\n## Shell (any command — runs inside app sandbox):\n[RUN_COMMAND]\ncommand\n[/RUN_COMMAND]\n\n## External SSH — run commands on remote machines:\n[SSH_EXEC user@host command]\n[SSH_UPLOAD local_path user@host:remote_path]\n[SSH_DOWNLOAD user@host:remote_path local_path]\n\n## External network tools:\n[REMOTE_EXEC host command]\n[CURL_EXEC url]\n\n## Build:\n[BUILD_APK project_name]\n[INSTALL_APK /path/to/file.apk]\n\n## In-App Virtual Env:\n[VENV_SETUP]\n[VENV_PIP_INSTALL package_name]\n\n## AutoGLM Device Control:\n[DEVICE_TAP x y]\n[DEVICE_LONG_PRESS x y]\n[DEVICE_SWIPE startX startY endX endY]\n[DEVICE_TYPE text]\n[DEVICE_PRESS_BACK]\n[DEVICE_PRESS_HOME]\n[DEVICE_PRESS_RECENTS]\n[DEVICE_SCREENSHOT]\n[DEVICE_UI_TREE]\n[DEVICE_CLICK_TEXT button text]\n[DEVICE_CLICK_ID com.example:id/viewId]\n[DEVICE_LAUNCH com.example.app]\n[DEVICE_CURRENT_APP]\n\n## Hermes Agent:\n[HERMES_INSTALL]\n[HERMES_EXEC command]\n\n## Rules:\n1. Prefer internal sandbox tools first\n2. Use SSH/REMOTE actions for external machines — user must approve each one\n3. Use [CREATE_FILE], then [BUILD_APK], then [INSTALL_APK]\n4. Use [DEVICE_*] for device control; start with [DEVICE_UI_TREE]\n5. Generate complete files, never stubs\n6. For Java: package ai.z.app, target SDK 36\n7. For SSH: assume openssh-client is available, use key-based auth when possible\n8. Output [TASK_COMPLETE] only when all work is done' }; var BUILD_SCRIPT = [ @@ -118,6 +118,8 @@ terminalOpen: false, keepAwake: false, autoDeploy: true, + autoInstallBuiltApk: true, + lastBuiltApkPath: '', maxRetries: 10, autoContinue: true, maxAutoContinue: 5 @@ -140,6 +142,7 @@ state.terminalOpen = localStorage.getItem(STORAGE_KEY + 'terminalOpen') === 'true'; state.keepAwake = localStorage.getItem(STORAGE_KEY + 'keepAwake') === 'true'; state.autoDeploy = localStorage.getItem(STORAGE_KEY + 'autoDeploy') !== 'false'; + state.autoInstallBuiltApk = localStorage.getItem(STORAGE_KEY + 'autoInstallBuiltApk') !== 'false'; state.maxRetries = parseInt(localStorage.getItem(STORAGE_KEY + 'maxRetries')) || 10; state.autoContinue = localStorage.getItem(STORAGE_KEY + 'autoContinue') !== 'false'; state.maxAutoContinue = parseInt(localStorage.getItem(STORAGE_KEY + 'maxAutoContinue')) || 5; @@ -163,6 +166,7 @@ localStorage.setItem(STORAGE_KEY + 'terminalOpen', state.terminalOpen.toString()); localStorage.setItem(STORAGE_KEY + 'keepAwake', state.keepAwake.toString()); localStorage.setItem(STORAGE_KEY + 'autoDeploy', state.autoDeploy.toString()); + localStorage.setItem(STORAGE_KEY + 'autoInstallBuiltApk', state.autoInstallBuiltApk.toString()); localStorage.setItem(STORAGE_KEY + 'maxRetries', state.maxRetries.toString()); localStorage.setItem(STORAGE_KEY + 'autoContinue', state.autoContinue.toString()); localStorage.setItem(STORAGE_KEY + 'maxAutoContinue', state.maxAutoContinue.toString()); @@ -1180,16 +1184,19 @@ autoglmEnabled: false, commandQueue: [] }; + var approvalState = null; function initShellPlugins() { try { Shell = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Shell; Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer; + FileManager = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.FileManager; Wake = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Wake; Bootstrap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap; } catch(e) {} if (!Shell) console.warn('Shell plugin not available'); if (!Installer) console.warn('Installer plugin not available'); + if (!FileManager) console.warn('FileManager plugin not available'); if (!Wake) console.warn('Wake plugin not available'); if (!Bootstrap) console.warn('Bootstrap plugin not available'); } @@ -1213,6 +1220,11 @@ } } + function needsApprovalForCommand(command) { + var cmd = (command || '').trim(); + return /(^|\s)(ssh|scp|sftp|curl|wget|adb|am\s+start|pm\s+install|install\s+|chmod|chown|rm\s+-rf|mv\s+|cp\s+|python|python3|pip|npm|git\s+push|git\s+pull)/i.test(cmd); + } + async function shellWriteFile(path, content) { if (!Shell) return false; try { @@ -1244,6 +1256,60 @@ } } + async function openFile(path, mimeType) { + if (!FileManager) { termPrint('[FileManager plugin not available]', 'err'); return; } + try { + await FileManager.openFile({ path: path, mimeType: mimeType || '' }); + termPrint('[Open file triggered: ' + path + ']', 'success'); + } catch(e) { + termPrint('[Open failed: ' + e.message + ']', 'err'); + } + } + + function requestApproval(title, body, onApprove) { + approvalState = { onApprove: onApprove }; + var modal = $('#approval-modal'); + var titleEl = $('#approval-title'); + var bodyEl = $('#approval-body'); + if (titleEl) titleEl.textContent = title || 'Approval required'; + if (bodyEl) bodyEl.textContent = body || ''; + if (modal) modal.style.display = 'flex'; + } + + function closeApproval() { + var modal = $('#approval-modal'); + if (modal) modal.style.display = 'none'; + approvalState = null; + } + + async function askApproval(title, body, action) { + return new Promise(function(resolve) { + requestApproval(title, body, async function() { + try { + var result = await action(); + resolve(result); + } catch(e) { + resolve(false); + } finally { + closeApproval(); + } + }); + }); + } + + async function listFiles(path) { + if (!FileManager) return null; + try { return await FileManager.listFiles({ path: path }); } catch(e) { return null; } + } + + function isApkPath(path) { + return !!path && path.toLowerCase().endsWith('.apk'); + } + + function isOpenablePath(path) { + return !!path && /\.(apk|apks|zip|json|txt|log|md|html?|xml|js|java|kt|png|jpg|jpeg|webp)$/i.test(path); + } + async function getDeviceInfo() { if (!Installer) return {}; try { return await Installer.getDeviceInfo(); } catch(e) { return {}; } @@ -1294,6 +1360,18 @@ if (!command.trim()) return; if (termState.isRunning) return; + if (needsApprovalForCommand(command)) { + await askApproval('Run command?', command, function() { + return shellExec(command, termState.cwd, false).then(function(result) { + termPrint('$ ' + command, 'cmd'); + if (result.output) termPrint(result.output.replace(/\n$/, ''), ''); + if (result.exitCode !== 0 && result.exitCode !== undefined) termPrint('[exit code: ' + result.exitCode + ']', 'err'); + return true; + }); + }); + return; + } + termState.history.push(command); termState.historyIndex = termState.history.length; termPrint('$ ' + command, 'cmd'); @@ -1439,6 +1517,11 @@ var deviceClickIdRegex = /\[DEVICE_CLICK_ID\s+([^\]]+)\]/gi; var deviceLaunchRegex = /\[DEVICE_LAUNCH\s+([^\]]+)\]/gi; var deviceCurrentAppRegex = /\[DEVICE_CURRENT_APP\]/gi; + var sshExecRegex = /\[SSH_EXEC\s+([^\s]+)\s+([^\]]+)\]/gi; + var sshUploadRegex = /\[SSH_UPLOAD\s+([^\s]+)\s+([^\]]+)\]/gi; + var sshDownloadRegex = /\[SSH_DOWNLOAD\s+([^\s]+)\s+([^\]]+)\]/gi; + var remoteExecRegex = /\[REMOTE_EXEC\s+([^\s]+)\s+([^\]]+)\]/gi; + var curlExecRegex = /\[CURL_EXEC\s+([^\]]+)\]/gi; var hermesInstallRegex = /\[HERMES_INSTALL\]/gi; var hermesExecRegex = /\[HERMES_EXEC\s+([^\]]+)\]/gi; var venvSetupRegex = /\[VENV_SETUP\]/gi; @@ -1509,6 +1592,21 @@ while ((match = venvPipInstallRegex.exec(content)) !== null) { actions.push({ type: 'venv_pip_install', packages: match[1].trim() }); } + while ((match = sshExecRegex.exec(content)) !== null) { + actions.push({ type: 'ssh_exec', host: match[1].trim(), command: match[2].trim() }); + } + while ((match = sshUploadRegex.exec(content)) !== null) { + actions.push({ type: 'ssh_upload', localPath: match[1].trim(), remotePath: match[2].trim() }); + } + while ((match = sshDownloadRegex.exec(content)) !== null) { + actions.push({ type: 'ssh_download', remotePath: match[1].trim(), localPath: match[2].trim() }); + } + while ((match = remoteExecRegex.exec(content)) !== null) { + actions.push({ type: 'remote_exec', host: match[1].trim(), command: match[2].trim() }); + } + while ((match = curlExecRegex.exec(content)) !== null) { + actions.push({ type: 'curl_exec', url: match[1].trim() }); + } while ((match = codeBlockFileRegex.exec(content)) !== null) { var lang = match[1]; var code = match[2]; @@ -1682,7 +1780,10 @@ content.indexOf('[INSTALL_APK') >= 0 || content.indexOf('[DEVICE_') >= 0 || content.indexOf('[HERMES_') >= 0 || - content.indexOf('[VENV_') >= 0; + content.indexOf('[VENV_') >= 0 || + content.indexOf('[SSH_') >= 0 || + content.indexOf('[REMOTE_EXEC') >= 0 || + content.indexOf('[CURL_EXEC') >= 0; var hasCodeBlock = content.indexOf('```') >= 0; if (!hasCodeBlock && !hasAction && content.length < 300) return true; if ((content.match(/```/g) || []).length % 2 !== 0) return false; @@ -1842,6 +1943,9 @@ html += 'data-conv="' + convId + '" data-path="' + escapeAttr(file.path) + '">'; html += '' + escapeHtml(file.language || '?') + ''; html += '' + escapeHtml(file.name) + ''; + if (isApkPath(file.path)) { + html += 'APK'; + } html += ''; } return html; @@ -1863,6 +1967,62 @@ if (badge) badge.textContent = conv.files.length + ' file' + (conv.files.length !== 1 ? 's' : ''); } + async function renderDeviceFiles(rootPath) { + var body = $('#file-tree-body'); + if (!body) return; + var result = await listFiles(rootPath); + if (!result) { + body.innerHTML = '
File manager unavailable.
'; + return; + } + var items = result.items || []; + if (items.length === 0) { + body.innerHTML = '
Folder is empty.
'; + return; + } + var html = '
'; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + html += '
'; + html += '' + escapeHtml(item.directory ? 'dir' : (item.mimeType || '?')) + ''; + html += '' + escapeHtml(item.name) + ''; + html += item.directory ? 'DIR' : (isApkPath(item.path) ? 'APK' : ''); + html += '
'; + } + html += '
'; + body.innerHTML = html; + } + + async function renderFileManagerHome() { + var body = $('#file-tree-body'); + if (!body) return; + if (!FileManager) { + body.innerHTML = '
File manager unavailable.
'; + return; + } + try { + var result = await FileManager.getRoots(); + var roots = result.roots || []; + var html = '
'; + for (var i = 0; i < roots.length; i++) { + var item = roots[i]; + html += '
'; + html += 'root'; + html += '' + escapeHtml(item.name) + ''; + html += 'OPEN'; + html += '
'; + } + html += '
'; + body.innerHTML = html; + var title = document.querySelector('#file-tree-panel h3'); + if (title) title.textContent = 'File Manager'; + var count = $('#file-tree-count'); + if (count) count.textContent = roots.length + ' roots'; + } catch(e) { + body.innerHTML = '
File manager unavailable.
'; + } + } + function toggleFileTree() { var panel = $('#file-tree-panel'); var overlay = $('#file-tree-overlay'); @@ -1878,6 +2038,28 @@ } } + function toggleDeviceFileManager() { + var panel = $('#file-tree-panel'); + var overlay = $('#file-tree-overlay'); + if (!panel) return; + if (panel.classList.contains('open')) { + panel.classList.remove('open'); + if (overlay) overlay.classList.remove('active'); + return; + } + panel.classList.add('open'); + if (overlay) overlay.classList.add('active'); + renderFileManagerHome(); + } + + function showProjectFiles() { + renderFileTree(); + var title = document.querySelector('#file-tree-panel h3'); + if (title) title.textContent = 'Project Files'; + var count = $('#file-tree-count'); + if (count) count.textContent = (getConversation() && getConversation().files ? getConversation().files.length : 0) + ' files'; + } + function closeFileTree() { var panel = $('#file-tree-panel'); var overlay = $('#file-tree-overlay'); @@ -1920,6 +2102,19 @@ viewer.dataset.path = path; } + async function openFileFromTree(path) { + if (isApkPath(path)) { + await askApproval('Install APK?', path, function() { return installApk(path); }); + return; + } + var fileManagerTitle = document.querySelector('#file-tree-panel h3'); + if (fileManagerTitle && fileManagerTitle.textContent === 'File Manager') { + await renderDeviceFiles(path); + return; + } + await openFile(path, ''); + } + function closeFileViewer() { var viewer = $('#file-viewer'); if (viewer) viewer.style.display = 'none'; @@ -2325,9 +2520,10 @@ var hasDevice = actions.some(function(a) { return a.type && a.type.indexOf('device_') === 0; }); var hasHermes = actions.some(function(a) { return a.type && a.type.indexOf('hermes_') === 0; }); var hasVenv = actions.some(function(a) { return a.type && a.type.indexOf('venv_') === 0; }); + var hasSsh = actions.some(function(a) { return a.type && a.type.indexOf('ssh_') === 0; }); + var hasRemote = actions.some(function(a) { return a.type === 'remote_exec' || a.type === 'curl_exec'; }); - if (!hasFiles && !hasBuild && !hasInstall && !hasCommands && !hasDevice && !hasHermes && !hasVenv) return; - + if (!hasFiles && !hasBuild && !hasInstall && !hasCommands && !hasDevice && !hasHermes && !hasVenv && !hasSsh && !hasRemote) return; _agenticRetryCount = 0; var resultLog = []; @@ -2346,17 +2542,82 @@ if (hasCommands) { for (var c = 0; c < actions.length; c++) { if (actions[c].type === 'run_command') { - showStatusToast('Running: ' + actions[c].command.substring(0, 40), 'info'); - var cmdResult = await shellExec(actions[c].command, termState.cwd, false); - resultLog.push('CMD: ' + actions[c].command.substring(0, 60) + '\nexit: ' + (cmdResult.exitCode !== undefined ? cmdResult.exitCode : '?') + '\n' + (cmdResult.output || '').substring(0, 500)); + var cmd = actions[c].command; + showStatusToast('Running: ' + cmd.substring(0, 40), 'info'); + var cmdApproved = true; + if (needsApprovalForCommand(cmd)) { + cmdApproved = await askApproval('Run command?', cmd, function() { return true; }); + } + if (!cmdApproved) { + termPrint('[DENIED] ' + cmd, 'warning'); + resultLog.push('DENIED: ' + cmd.substring(0, 60)); + continue; + } + var cmdResult = await shellExec(cmd, termState.cwd, false); + resultLog.push('CMD: ' + cmd.substring(0, 60) + '\nexit: ' + (cmdResult.exitCode !== undefined ? cmdResult.exitCode : '?') + '\n' + (cmdResult.output || '').substring(0, 500)); if (cmdResult.output) { - termPrint('$ ' + actions[c].command, 'cmd'); + termPrint('$ ' + cmd, 'cmd'); termPrint(cmdResult.output.replace(/\n$/, ''), cmdResult.exitCode === 0 ? '' : 'err'); } } } } + if (hasSsh) { + for (var s = 0; s < actions.length; s++) { + var sAct = actions[s]; + if (sAct.type.indexOf('ssh_') !== 0) continue; + var sshCmd = ''; + var sshLabel = ''; + if (sAct.type === 'ssh_exec') { + sshCmd = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.host + ' ' + sAct.command; + sshLabel = 'SSH: ' + sAct.host + ' → ' + sAct.command.substring(0, 60); + } else if (sAct.type === 'ssh_upload') { + sshCmd = 'scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.localPath + ' ' + sAct.remotePath; + sshLabel = 'SCP upload: ' + sAct.localPath + ' → ' + sAct.remotePath; + } else if (sAct.type === 'ssh_download') { + sshCmd = 'scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.remotePath + ' ' + sAct.localPath; + sshLabel = 'SCP download: ' + sAct.remotePath + ' → ' + sAct.localPath; + } + var sshOk = await askApproval(sshLabel, sshCmd, function() { return true; }); + if (!sshOk) { + termPrint('[DENIED] ' + sshLabel, 'warning'); + resultLog.push('DENIED: ' + sshLabel); + continue; + } + termPrint('$ ' + sshCmd, 'cmd'); + var sshResult = await shellExec(sshCmd, termState.cwd, false); + resultLog.push('SSH: ' + sshLabel + '\nexit: ' + (sshResult.exitCode !== undefined ? sshResult.exitCode : '?') + '\n' + (sshResult.output || '').substring(0, 500)); + termPrint((sshResult.output || '').replace(/\n$/, ''), sshResult.exitCode === 0 ? '' : 'err'); + if (sshResult.exitCode !== 0 && sshResult.exitCode !== undefined) { + termPrint('[SSH exit: ' + sshResult.exitCode + ']', 'err'); + } + } + } + + if (hasRemote) { + for (var r = 0; r < actions.length; r++) { + var rAct = actions[r]; + if (rAct.type === 'remote_exec') { + var reCmd = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + rAct.host + ' ' + rAct.command; + var reOk = await askApproval('Remote exec: ' + rAct.host, reCmd, function() { return true; }); + if (!reOk) { termPrint('[DENIED] ' + rAct.host, 'warning'); continue; } + termPrint('$ ' + reCmd, 'cmd'); + var reResult = await shellExec(reCmd, termState.cwd, false); + resultLog.push('REMOTE: ' + rAct.host + '\n' + (reResult.output || '').substring(0, 500)); + termPrint((reResult.output || '').replace(/\n$/, ''), reResult.exitCode === 0 ? '' : 'err'); + } else if (rAct.type === 'curl_exec') { + var curlCmd = 'curl -sL -o - ' + rAct.url; + var curlOk = await askApproval('Fetch URL?', rAct.url, function() { return true; }); + if (!curlOk) { termPrint('[DENIED] curl ' + rAct.url, 'warning'); continue; } + termPrint('$ ' + curlCmd, 'cmd'); + var curlResult = await shellExec(curlCmd, termState.cwd, false); + resultLog.push('CURL: ' + rAct.url + '\n' + (curlResult.output || '').substring(0, 500)); + termPrint((curlResult.output || '').replace(/\n$/, ''), ''); + } + } + } + if (hasDevice) { for (var d = 0; d < actions.length; d++) { var act = actions[d]; @@ -2578,8 +2839,28 @@ if (verifyResult.output && verifyResult.output.indexOf('No such file') < 0) { var sizeMatch = verifyResult.output.match(/(\d+)\s+/); var sizeInfo = sizeMatch ? ' (' + Math.round(parseInt(sizeMatch[1]) / 1024) + ' KB)' : ''; - termPrint('\n[VERIFIED] APK built successfully: ' + apkPath + sizeInfo, 'success'); - return '[BUILD OK] ' + apkPath; + var builtName = projectDir.split('/').pop() + '.apk'; + var Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer; + var exportedPath = apkPath; + try { + if (Installer) { + var exportResult = await Installer.exportApk({ path: apkPath, name: builtName }); + exportedPath = exportResult.path || apkPath; + state.lastBuiltApkPath = exportedPath; + } + } catch(e) { + termPrint('[!] Export to real folder failed: ' + e.message, 'warning'); + } + termPrint('\n[VERIFIED] APK built successfully: ' + exportedPath + sizeInfo, 'success'); + if (state.autoInstallBuiltApk && Installer) { + try { + await Installer.installApk({ path: exportedPath }); + termPrint('[OK] Installer opened for built APK', 'success'); + } catch(e) { + termPrint('[!] Auto-install failed: ' + e.message, 'warning'); + } + } + return '[BUILD OK] ' + exportedPath; } else { termPrint('\n[VERIFY FAILED] Build claimed success but APK not found at ' + apkPath, 'err'); return '[BUILD FAILED] APK file not found after build. Output:\n' + output.substring(0, 1000); @@ -2678,55 +2959,14 @@ showScreen('terminal'); termPrint('\n--- Building APK ---', 'info'); - var projectDir = termState.projectsDir || (termState.homeDir + '/projects'); - - for (var i = 0; i < actions.length; i++) { - var action = actions[i]; - if (action.type === 'create_file') { - var path = action.path; - if (!path.startsWith('/')) path = projectDir + '/' + path; - var dir = path.substring(0, path.lastIndexOf('/')); - await shellMkdirs(dir); - await shellWriteFile(path, action.content); - termPrint(' [+] ' + path, 'success'); + var buildResult = await autoBuildApk(actions); + termPrint(buildResult, buildResult.indexOf('[BUILD FAILED]') >= 0 ? 'err' : 'success'); + if (state.autoInstallBuiltApk && buildResult.indexOf('[BUILD OK]') >= 0) { + var Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer; + if (Installer && state.lastBuiltApkPath) { + await Installer.installApk({ path: state.lastBuiltApkPath }); } } - - termPrint('\nBuilding with aapt2 + d8...', 'info'); - - var buildCmd = 'cd ' + projectDir + ' && ' + - 'if [ -d "app/src/main" ]; then ' + - ' AAPT2=$(which aapt2 2>/dev/null || echo "") && ' + - ' D8=$(which d8 2>/dev/null || echo "") && ' + - ' ECJ=$(which ecj 2>/dev/null || echo "") && ' + - ' if [ -z "$AAPT2" ]; then echo "[!] aapt2 not found. Run Setup Dev Tools first."; exit 1; fi && ' + - ' echo "[*] Compiling resources..." && ' + - ' $AAPT2 compile --dir app/src/main/res -o build/compiled_resources.zip 2>&1 && ' + - ' echo "[*] Linking..." && ' + - ' $AAPT2 link -o build/app.unsigned.apk ' + - ' -I tools/android.jar ' + - ' --manifest app/src/main/AndroidManifest.xml ' + - ' -R build/compiled_resources.zip ' + - ' --java build/gen 2>&1 && ' + - ' echo "[*] Compiling Java..." && ' + - ' find app/src/main/java -name "*.java" > build/sources.txt 2>/dev/null && ' + - ' $ECJ -source 11 -target 11 -classpath tools/android.jar -d build/classes @build/sources.txt 2>&1 && ' + - ' echo "[*] Converting to DEX..." && ' + - ' $D8 --output build/ build/classes/**/*.class 2>&1 && ' + - ' echo "[*] Packaging..." && ' + - ' cd build && cp app.unsigned.apk app.unaligned.apk && ' + - ' mkdir -p app.unaligned.apk.tmp && cd app.unaligned.apk.tmp && ' + - ' unzip -o ../app.unaligned.apk && ' + - ' cp ../classes.dex . && ' + - ' zip -r ../app.unaligned.apk . && cd .. && rm -rf app.unaligned.apk.tmp && ' + - ' echo "[*] Signing..." && ' + - ' java -jar tools/uber-apk-signer.jar -a app.unaligned.apk --overwrite 2>&1 || ' + - ' cp app.unaligned.apk app-signed.apk && ' + - ' echo "[OK] APK built: ' + projectDir + '/build/app-signed.apk" && ' + - ' echo "Size: $(du -h app-signed.apk | cut -f1)" ; ' + - 'else echo "[!] No app/src/main found. Deploy files first."; fi'; - - await termExec(buildCmd); } // ---- Dev Tools Setup ---- @@ -2952,7 +3192,7 @@ termState.projectsDir = env.PROJECTS; termState.cwd = env.CWD || env.HOME; updateCwdDisplay(); - termPrint('Z.AI Terminal v1.3.0', 'info'); + termPrint('Z.AI Terminal v3.3.0', 'info'); termPrint('Home: ' + termState.homeDir, 'info'); termPrint('Type "help" for commands, "setup" for dev tools\n', 'info'); }).catch(function() {}); @@ -3238,13 +3478,30 @@ saveState(); }); - $('#file-tree-btn').addEventListener('click', toggleFileTree); + $('#file-tree-btn').addEventListener('click', toggleDeviceFileManager); + $('#device-files-btn').addEventListener('click', function() { + var panel = $('#file-tree-panel'); + if (panel && panel.classList.contains('open')) { + showProjectFiles(); + } else { + toggleDeviceFileManager(); + } + }); $('#file-tree-close').addEventListener('click', closeFileTree); $('#file-tree-overlay').addEventListener('click', closeFileTree); $('#file-tree-body').addEventListener('click', function(e) { var node = e.target.closest('.ftree-file'); if (node) { - openFileViewer(node.dataset.conv, node.dataset.path); + if (node.dataset.conv) { + openFileViewer(node.dataset.conv, node.dataset.path); + } else { + var title = document.querySelector('#file-tree-panel h3'); + if (title && title.textContent === 'File Manager') { + renderDeviceFiles(node.dataset.path); + } else { + openFileFromTree(node.dataset.path); + } + } } else { node = e.target.closest('.ftree-dir'); if (node) { @@ -3258,6 +3515,10 @@ $('#file-viewer-close').addEventListener('click', closeFileViewer); $('#file-viewer-edit').addEventListener('click', toggleFileEdit); $('#file-viewer-save').addEventListener('click', saveFileEdit); + $('#approval-allow').addEventListener('click', function() { + if (approvalState && approvalState.onApprove) approvalState.onApprove(); + }); + $('#approval-deny').addEventListener('click', closeApproval); $('#theme-toggle-header').addEventListener('click', toggleTheme);