dsh-apk-vm 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ Storage management.
6
+
7
+ ### Added
8
+
9
+ - Storage panel: AVD / snapshot / SDK / plugin temp / free-disk footprint, with guarded
10
+ cleanup actions (two-click confirmation for destructive actions).
11
+ - `GET /apk-vm/storage` and `POST /apk-vm/storage` (`temp`, `snapshots`, `orphan-avds`).
12
+ - `apk_vm` actions `storage` and `clean_storage`.
13
+ - `lib/storage.js` with dependency-free size accounting and cleanup helpers; unit coverage for
14
+ temp cleanup, orphan AVD detection and snapshot-aware accounting.
15
+
16
+ ### Notes
17
+
18
+ - Snapshot cleanup only targets a stopped plugin-managed AVD; the active VM is never deleted.
19
+ - Orphan cleanup only matches `dsh_apk_*` AVDs that are not registered to an instance.
20
+
21
+ ## 0.4.1
22
+
23
+ Privacy cleanup for the published proof assets.
24
+
25
+ ### Changed
26
+
27
+ - Cropped `docs/screenshot-sidebar.png` to the plugin panel only (removes the center session
28
+ area and workspace label; the APK paths were already redacted).
29
+ - Removed host account names from `docs/LINUX-E2E.md`; the document now describes the KVM
30
+ group requirement without identifying accounts.
31
+
3
32
  ## 0.4.0
4
33
 
5
34
  Substantive feature release: inspection, replay, package bundles, network debugging,
package/README.md CHANGED
@@ -21,9 +21,10 @@ same-origin, loopback-only debug HTTP API and an `apk_vm` Agent tool.
21
21
  > 点击、滑动、按键、文本输入送回虚拟机。支持拖拽 APK 上传、环境自检卡片、root 调试
22
22
  > API 与 `apk_vm` Agent 工具;多个 Agent 可各自使用独立的隔离虚拟机实例(多开)。
23
23
  > 参考机上冷启动约 29.5 s、快照快速启动约 8.4–10.5 s。
24
- > 当前 **Windows x64 为端到端已验证平台**;SDK/模拟器探测已覆盖 Windows / macOS / Linux。
24
+ > 当前 **Windows x64 与 Linux x86_64(headless)为端到端已验证平台**;macOS 仅做探测/CI
25
+ > 覆盖,详见 `docs/LINUX-E2E.md`。
25
26
 
26
- **Status:** `0.4.0` · Apache-2.0 · no npm runtime dependencies · no build step.
27
+ **Status:** `0.5.0` · Apache-2.0 · no npm runtime dependencies · no build step.
27
28
 
28
29
  This is an open-source DSH plugin published on npm as **`dsh-apk-vm`**. Repository:
29
30
  [github.com/yuxiaole-bili/dsh-apk-vm](https://github.com/yuxiaole-bili/dsh-apk-vm).
@@ -173,6 +174,10 @@ key {"text":"HOME"} -> returned to launcher
173
174
  (`settings put global http_proxy`) and installs a local `.pem`/`.crt` CA into the rooted
174
175
  Android user CA store with the correct OpenSSL subject-hash filename; the system CA store is
175
176
  updated when the guest allows a remount.
177
+ - **Storage panel + guarded cleanup.** A Storage card reports the AVD, snapshot, SDK, temp-file
178
+ and free-disk footprint and offers explicit cleanups: plugin temp files, quick-boot
179
+ snapshots for a stopped AVD, and orphan `dsh_apk_*` AVDs. Destructive actions need a second
180
+ click and never touch the active VM.
176
181
  - **Installed-app management.** The panel's **Installed apps** card lists third-party packages
177
182
  for the active instance and offers force-stop, clear-data and uninstall; it uses the same
178
183
  rooted `debug/packages` + `debug/action` API as the Agent tool.
@@ -236,6 +241,28 @@ key {"text":"HOME"} -> returned to launcher
236
241
  - `POST /apk-vm/debug/ca` accepts a host `.pem`/`.crt` path and installs it under the guest's
237
242
  user CA directory with the correct subject hash.
238
243
 
244
+ ## Storage management
245
+
246
+ `GET /apk-vm/storage` reports the logical size of every AVD, its quick-boot snapshots, the
247
+ Android SDK, plugin temp files and the free space on the AVD volume. The panel's **Storage**
248
+ card uses the same endpoint.
249
+
250
+ `POST /apk-vm/storage` accepts:
251
+
252
+ ```json
253
+ { "action": "temp" }
254
+ { "action": "snapshots", "avdName": "dsh_apk", "confirm": true }
255
+ { "action": "orphan-avds", "confirm": true }
256
+ ```
257
+
258
+ - `temp` removes only screenshots, MP4 recordings, uploaded APKs and flow JSON created by this
259
+ plugin.
260
+ - `snapshots` deletes the quick-boot snapshots of a **stopped** plugin-managed AVD; the next
261
+ boot is a cold boot.
262
+ - `orphan-avds` removes only `dsh_apk_*` AVDs that are not registered to any instance and are
263
+ not active.
264
+ - `apk_vm` tool actions: `storage`, `clean_storage` (`storageAction`, `avdName`, `confirm`).
265
+
239
266
  ## Reproducible demo
240
267
 
241
268
  A public, dependency-free demo lives in [examples](examples/README.md):
@@ -849,9 +876,12 @@ before pointing the DSH Web UI at an untrusted network.
849
876
 
850
877
  - **The plugin manages processes on your machine.** It spawns the Android emulator, `adb`,
851
878
  `sdkmanager` and `avdmanager` with `child_process.spawn(exe, argv)` — never through a shell —
852
- so user-supplied strings are not shell-interpreted. Paths are validated (APK paths must exist
853
- and end in `.apk`; push/pull paths are restricted to `/data/local/tmp`, `/sdcard`,
854
- `/storage/emulated/0` and allowed local roots).
879
+ so user-supplied strings are not shell-interpreted. Paths are validated (APK/bundle paths must
880
+ exist and end in `.apk`/`.apks`/`.xapk`; push/pull paths are restricted to `/data/local/tmp`,
881
+ `/sdcard`, `/storage/emulated/0` and allowed local roots).
882
+ - **Only emulators spawned by this plugin are stopped.** `stop()` sends `adb emu kill` only when
883
+ this process owns the emulator; a pre-existing/attached device (physical, remote adb or a
884
+ manually started emulator) is detached and left running.
855
885
  - **The guest is deliberately rooted and debuggable.** `adb root`, `ro.debuggable=1` and
856
886
  `ro.adb.secure=0` mean anything you install inside the VM is not sandboxed from root. Do not
857
887
  put real secrets or personal data in the AVD.
package/client.js CHANGED
@@ -200,6 +200,21 @@ window.__ModuleLoader__.load({
200
200
  'flow.saved': 'Saved {name}',
201
201
  'flow.done': 'Replayed {name}',
202
202
  'flow.count': '{n} step(s)',
203
+ 'storage.title': 'Storage',
204
+ 'storage.refresh': 'Refresh',
205
+ 'storage.cleanTemp': 'Clean temp',
206
+ 'storage.cleanSnapshots': 'Clean snapshots',
207
+ 'storage.cleanOrphans': 'Clean orphan AVDs',
208
+ 'storage.confirm': 'Click again to confirm',
209
+ 'storage.freed': 'Freed {size}',
210
+ 'storage.avds': '{size} AVDs',
211
+ 'storage.snapshots': 'Snapshots',
212
+ 'storage.sdk': 'SDK',
213
+ 'storage.temp': 'Temp',
214
+ 'storage.free': 'Free disk',
215
+ 'storage.active': 'active',
216
+ 'storage.orphan': 'orphan',
217
+ 'storage.empty': 'No AVDs found',
203
218
  'apk.bundle': 'Bundle',
204
219
  'setup.title': 'Environment',
205
220
  'setup.ok': 'Ready',
@@ -398,6 +413,21 @@ window.__ModuleLoader__.load({
398
413
  'flow.saved': '已保存 {name}',
399
414
  'flow.done': '已回放 {name}',
400
415
  'flow.count': '{n} 步',
416
+ 'storage.title': '存储',
417
+ 'storage.refresh': '刷新',
418
+ 'storage.cleanTemp': '清理临时文件',
419
+ 'storage.cleanSnapshots': '清理快照',
420
+ 'storage.cleanOrphans': '清理孤立 AVD',
421
+ 'storage.confirm': '再点一次确认',
422
+ 'storage.freed': '已释放 {size}',
423
+ 'storage.avds': '{size} 个 AVD',
424
+ 'storage.snapshots': '快照',
425
+ 'storage.sdk': 'SDK',
426
+ 'storage.temp': '临时文件',
427
+ 'storage.free': '磁盘剩余',
428
+ 'storage.active': '运行中',
429
+ 'storage.orphan': '孤立',
430
+ 'storage.empty': '没有找到 AVD',
401
431
  'apk.bundle': '安装包',
402
432
  'setup.title': '环境',
403
433
  'setup.ok': '就绪',
@@ -1207,6 +1237,19 @@ window.__ModuleLoader__.load({
1207
1237
  var flowBusy = flowBusyState[0];
1208
1238
  var setFlowBusy = flowBusyState[1];
1209
1239
 
1240
+ var storageState = React.useState(null);
1241
+ var storage = storageState[0];
1242
+ var setStorage = storageState[1];
1243
+ var storageBusyState = React.useState('');
1244
+ var storageBusy = storageBusyState[0];
1245
+ var setStorageBusy = storageBusyState[1];
1246
+ var storageErrorState = React.useState('');
1247
+ var storageError = storageErrorState[0];
1248
+ var setStorageError = storageErrorState[1];
1249
+ var storageConfirmState = React.useState('');
1250
+ var storageConfirm = storageConfirmState[0];
1251
+ var setStorageConfirm = storageConfirmState[1];
1252
+
1210
1253
  var proxyState = React.useState({ host: '', port: '' });
1211
1254
  var proxy = proxyState[0];
1212
1255
  var setProxy = proxyState[1];
@@ -1392,6 +1435,7 @@ window.__ModuleLoader__.load({
1392
1435
  loadInstances();
1393
1436
  loadDevices();
1394
1437
  loadFlows();
1438
+ loadStorage();
1395
1439
  var pollId = setInterval(function () {
1396
1440
  if (stateBusyRef.current) return;
1397
1441
  stateBusyRef.current = true;
@@ -2173,6 +2217,67 @@ window.__ModuleLoader__.load({
2173
2217
  });
2174
2218
  }
2175
2219
 
2220
+ function loadStorage() {
2221
+ setStorageBusy('load');
2222
+ setStorageError('');
2223
+ return requestJson('/apk-vm/storage', null, T).then(function (result) {
2224
+ if (!mountedRef.current) return result;
2225
+ setStorageBusy('');
2226
+ if (!result.ok) {
2227
+ setStorageError(result.error || T('err.action'));
2228
+ return result;
2229
+ }
2230
+ setStorage(isObject(result.data) ? result.data : null);
2231
+ return result;
2232
+ });
2233
+ }
2234
+
2235
+ function storageSnapshotAvd() {
2236
+ var list = storage && Array.isArray(storage.avds) ? storage.avds : [];
2237
+ var current = snapshot && snapshot.vm && snapshot.vm.avd ? String(snapshot.vm.avd) : '';
2238
+ var match = list.find(function (item) {
2239
+ return item.name === current;
2240
+ });
2241
+ if (match) return match.name;
2242
+ var active = list.find(function (item) {
2243
+ return item.active && item.managed;
2244
+ });
2245
+ if (active) return active.name;
2246
+ var managed = list.find(function (item) {
2247
+ return item.managed;
2248
+ });
2249
+ return managed ? managed.name : 'dsh_apk';
2250
+ }
2251
+
2252
+ function cleanStorage(action) {
2253
+ if (storageBusy) return;
2254
+ var destructive = action === 'snapshots' || action === 'orphan-avds';
2255
+ if (destructive && storageConfirm !== action) {
2256
+ setStorageConfirm(action);
2257
+ pushToast('success', T('storage.confirm'));
2258
+ return;
2259
+ }
2260
+ setStorageConfirm('');
2261
+ setStorageBusy(action);
2262
+ var payload = { action: action };
2263
+ if (action === 'snapshots') {
2264
+ payload.avdName = storageSnapshotAvd();
2265
+ payload.confirm = true;
2266
+ }
2267
+ if (action === 'orphan-avds') payload.confirm = true;
2268
+ requestJson('/apk-vm/storage', { method: 'POST', body: JSON.stringify(payload) }, T).then(function (result) {
2269
+ if (!mountedRef.current) return;
2270
+ setStorageBusy('');
2271
+ if (!result.ok) {
2272
+ pushToast('error', T('err.action') + ': ' + result.error);
2273
+ return;
2274
+ }
2275
+ var freed = result.data && Number(result.data.freedBytes) ? Number(result.data.freedBytes) : 0;
2276
+ pushToast('success', T('storage.freed', { size: formatBytes(freed) }));
2277
+ loadStorage();
2278
+ });
2279
+ }
2280
+
2176
2281
  function refreshAll() {
2177
2282
  loadState();
2178
2283
  loadApks();
@@ -3814,6 +3919,124 @@ window.__ModuleLoader__.load({
3814
3919
  )
3815
3920
  );
3816
3921
 
3922
+ var storageCard = h(
3923
+ 'details',
3924
+ { className: 'apkvm-card apkvm-debug' },
3925
+ h(
3926
+ 'summary',
3927
+ { className: 'apkvm-debug-summary' },
3928
+ h('span', { className: 'apkvm-card-title' }, T('storage.title')),
3929
+ h(
3930
+ 'span',
3931
+ { className: 'apkvm-dim', style: { marginLeft: 'auto' } },
3932
+ storage ? formatBytes((storage.totals && storage.totals.avdBytes) || 0) : '…'
3933
+ ),
3934
+ h('span', { className: 'apkvm-chevron', 'aria-hidden': 'true' }, '▸')
3935
+ ),
3936
+ h(
3937
+ 'div',
3938
+ { className: 'apkvm-debug-body' },
3939
+ !storage
3940
+ ? h('div', { className: 'apkvm-dim' }, storageError || T('storage.empty'))
3941
+ : h(
3942
+ 'div',
3943
+ { style: { display: 'flex', flexDirection: 'column', gap: '5px', minWidth: 0 } },
3944
+ h(
3945
+ 'div',
3946
+ { className: 'apkvm-dim', style: { display: 'flex', flexWrap: 'wrap', gap: '8px' } },
3947
+ h('span', null, T('storage.avds', { size: (storage.avds || []).length }) + ': ' + formatBytes((storage.totals && storage.totals.avdBytes) || 0)),
3948
+ h('span', null, T('storage.snapshots') + ': ' + formatBytes((storage.totals && storage.totals.snapshotBytes) || 0)),
3949
+ h('span', null, T('storage.sdk') + ': ' + formatBytes((storage.totals && storage.totals.sdkBytes) || 0)),
3950
+ h('span', null, T('storage.temp') + ': ' + formatBytes((storage.totals && storage.totals.tempBytes) || 0)),
3951
+ storage.disk
3952
+ ? h('span', null, T('storage.free') + ': ' + formatBytes(storage.disk.freeBytes))
3953
+ : null
3954
+ ),
3955
+ (storage.avds || []).slice(0, 4).map(function (avd) {
3956
+ return h(
3957
+ 'div',
3958
+ {
3959
+ key: 'storage-' + avd.name,
3960
+ className: 'apkvm-apk',
3961
+ style: { padding: '5px 7px', gap: '3px' },
3962
+ },
3963
+ h(
3964
+ 'div',
3965
+ { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
3966
+ h('span', { className: 'apkvm-apk-name', title: avd.path }, avd.name),
3967
+ avd.active ? h('span', { className: 'apkvm-tag ok' }, T('storage.active')) : null,
3968
+ avd.orphan ? h('span', { className: 'apkvm-tag' }, T('storage.orphan')) : null
3969
+ ),
3970
+ h(
3971
+ 'div',
3972
+ { className: 'apkvm-dim' },
3973
+ formatBytes(avd.totalBytes || 0) +
3974
+ ' · ' +
3975
+ T('storage.snapshots') +
3976
+ ' ' +
3977
+ formatBytes(avd.snapshotBytes || 0)
3978
+ )
3979
+ );
3980
+ }),
3981
+ h(
3982
+ 'div',
3983
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
3984
+ actionBtn(storageBusy === 'load' ? '…' : T('storage.refresh'), {
3985
+ key: 'storage-refresh',
3986
+ small: true,
3987
+ disabled: Boolean(storageBusy),
3988
+ onClick: loadStorage,
3989
+ }),
3990
+ actionBtn(storageBusy === 'temp' ? '…' : T('storage.cleanTemp'), {
3991
+ key: 'storage-temp',
3992
+ small: true,
3993
+ disabled: Boolean(storageBusy),
3994
+ onClick: function () {
3995
+ cleanStorage('temp');
3996
+ },
3997
+ }),
3998
+ actionBtn(
3999
+ storageConfirm === 'snapshots'
4000
+ ? T('storage.confirm')
4001
+ : storageBusy === 'snapshots'
4002
+ ? '…'
4003
+ : T('storage.cleanSnapshots'),
4004
+ {
4005
+ key: 'storage-snapshots',
4006
+ small: true,
4007
+ disabled: Boolean(storageBusy),
4008
+ onClick: function () {
4009
+ cleanStorage('snapshots');
4010
+ },
4011
+ }
4012
+ ),
4013
+ (storage.recommendations || []).some(function (item) {
4014
+ return item.code === 'orphan-avds';
4015
+ }) || (storage.avds || []).some(function (item) {
4016
+ return item.orphan;
4017
+ })
4018
+ ? actionBtn(
4019
+ storageConfirm === 'orphan-avds'
4020
+ ? T('storage.confirm')
4021
+ : storageBusy === 'orphan-avds'
4022
+ ? '…'
4023
+ : T('storage.cleanOrphans'),
4024
+ {
4025
+ key: 'storage-orphans',
4026
+ small: true,
4027
+ disabled: Boolean(storageBusy),
4028
+ onClick: function () {
4029
+ cleanStorage('orphan-avds');
4030
+ },
4031
+ }
4032
+ )
4033
+ : null
4034
+ ),
4035
+ storageError ? h('div', { className: 'apkvm-dim' }, storageError) : null
4036
+ )
4037
+ )
4038
+ );
4039
+
3817
4040
  var primaryAction =
3818
4041
  ready || busyState
3819
4042
  ? actionBtn(vmBusy ? T('action.stopping') : T('action.stop'), {
@@ -4149,6 +4372,7 @@ window.__ModuleLoader__.load({
4149
4372
  debugCard,
4150
4373
  flowCard,
4151
4374
  networkCard,
4375
+ storageCard,
4152
4376
  appsCard,
4153
4377
  diagCard,
4154
4378
  h('div', { className: 'apkvm-footnote', title: T('hint.boot') }, T('hint.short')),
package/docs/LINUX-E2E.md CHANGED
@@ -18,10 +18,10 @@ CI-only check.
18
18
  | System image | `system-images;android-34;default;x86_64` |
19
19
  | AVD | `dsh_linux_test` (1080×2340, pixel_5) |
20
20
 
21
- The regular `OPENCODE` account is **not in the `kvm` group** and has no passwordless sudo;
22
- `/dev/kvm` is denied for it. The emulator portion was therefore run as the provided
23
- `OPENCODE_100` account (same SSH key), which is in the `kvm` group. The plugin itself never
24
- requires sudo or system-wide changes.
21
+ The regular login account is **not in the `kvm` group** and has no passwordless sudo;
22
+ `/dev/kvm` is denied for it. The emulator portion was therefore run as a second account on
23
+ the same host (authenticated with the same SSH key), which is in the `kvm` group. The plugin
24
+ itself never requires sudo or system-wide changes.
25
25
 
26
26
  ## Emulator launch
27
27
 
@@ -66,7 +66,7 @@ The final tree should be re-run in CI for the release tag.
66
66
  screenshots; the screenshots still succeed.
67
67
  - The emulator prints a metrics-collection warning; the launch args now include
68
68
  `-no-metrics`.
69
- - `OPENCODE` without `kvm` group membership gets the documented "No usable CPU hypervisor"
69
+ - An account without `kvm` group membership gets the documented "No usable CPU hypervisor"
70
70
  error instead of a hung boot. Add the user to `kvm` (or use an account that has it) for
71
71
  hardware acceleration.
72
72
  - The platform still requires an Android SDK and AVD; only the SDK install and test harness
Binary file
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-apk-vm — Host half.
2
+ * dsh-apk-vm 鈥?Host half.
3
3
  *
4
4
  * Mounts the APK Virtual Machine bridge behind the Web server:
5
5
  * GET /apk-vm/state VM lifecycle snapshot (adb/emulator state, display size)
@@ -28,6 +28,7 @@ import { detectEnvironment, createAvd, installSystemImage, installSdkPackages, b
28
28
  import { acceptWebSocket } from './lib/ws.js';
29
29
  import { parseUiHierarchy, decorateUiNodes, buildInspectorPayload } from './lib/ui-inspector.js';
30
30
  import { FlowRecorder } from './lib/flows.js';
31
+ import { collectStorage, cleanTempFiles, cleanSnapshots, cleanOrphanAvds, formatBytes } from './lib/storage.js';
31
32
 
32
33
  export const name = 'dsh-apk-vm';
33
34
 
@@ -298,7 +299,7 @@ export function apply(ctx, config = {}) {
298
299
  /* ---- H.264 video stream (scrcpy) ---------------------------------- *
299
300
  * The PNG screencap path is capped near 1-2 fps by adb. For real frame
300
301
  * rates the plugin runs the Apache-2.0 scrcpy server (vendored jar), parses
301
- * its H.264 stream with lib/scrcpy.js, converts Annex-B → AVCC, and relays
302
+ * its H.264 stream with lib/scrcpy.js, converts Annex-B 鈫?AVCC, and relays
302
303
  * the packets over a no-dependency WebSocket (/apk-vm/ws) to the panel,
303
304
  * which decodes them with WebCodecs VideoDecoder. Input still goes through
304
305
  * the existing /apk-vm/input routes. */
@@ -868,6 +869,75 @@ export function apply(ctx, config = {}) {
868
869
  });
869
870
  });
870
871
 
872
+ function storageSdkRoot() {
873
+ if (vm.sdkRoot) return vm.sdkRoot;
874
+ if (vm.adbPath) return path.dirname(path.dirname(vm.adbPath));
875
+ return process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || null;
876
+ }
877
+
878
+ register('/apk-vm/storage', async (req, res) => {
879
+ const known = () => {
880
+ const names = new Set();
881
+ if (vm.avdName) names.add(vm.avdName);
882
+ if (instanceManager) {
883
+ for (const item of instanceManager.list()) if (item.avdName) names.add(item.avdName);
884
+ }
885
+ return [...names];
886
+ };
887
+ const active = () => {
888
+ const names = new Set();
889
+ if (vm.avdName && ['ready', 'booting', 'starting'].includes(vm._state)) names.add(vm.avdName);
890
+ if (instanceManager) {
891
+ for (const item of instanceManager.list()) {
892
+ if (item.avdName && ['ready', 'booting', 'starting'].includes(item.state)) names.add(item.avdName);
893
+ }
894
+ }
895
+ return [...names];
896
+ };
897
+ if (req.method === 'GET') {
898
+ try {
899
+ sendJson(
900
+ res,
901
+ 200,
902
+ await collectStorage({
903
+ sdkRoot: storageSdkRoot(),
904
+ avdRoot: undefined,
905
+ knownAvds: known(),
906
+ activeAvds: active(),
907
+ }),
908
+ );
909
+ } catch (error) {
910
+ sendError(res, 500, error.message);
911
+ }
912
+ return;
913
+ }
914
+ if (req.method !== 'POST') {
915
+ sendError(res, 405, 'GET or POST only');
916
+ return;
917
+ }
918
+ const body = await readJsonBody(req);
919
+ const action = String(body.action || '').toLowerCase();
920
+ try {
921
+ let result;
922
+ if (action === 'temp') {
923
+ result = await cleanTempFiles();
924
+ } else if (action === 'snapshots') {
925
+ if (body.confirm !== true) throw new Error('snapshot cleanup requires confirm:true');
926
+ const name = String(body.avdName || vm.avdName || 'dsh_apk');
927
+ if (active().includes(name)) throw new Error(`AVD ${name} is active; stop it before deleting snapshots`);
928
+ result = await cleanSnapshots({ avdName: name });
929
+ } else if (action === 'orphan-avds') {
930
+ if (body.confirm !== true) throw new Error('orphan AVD cleanup requires confirm:true');
931
+ result = await cleanOrphanAvds({ knownAvds: known(), activeAvds: active() });
932
+ } else {
933
+ throw new Error(`unsupported storage action: ${action || '(missing)'}`);
934
+ }
935
+ sendJson(res, 200, result);
936
+ } catch (error) {
937
+ sendError(res, 400, error.message);
938
+ }
939
+ });
940
+
871
941
  register('/apk-vm/state', async (req, res) => {
872
942
  if (req.method !== 'GET' && req.method !== 'HEAD') {
873
943
  sendError(res, 405, 'GET only');
@@ -2369,7 +2439,7 @@ export function apply(ctx, config = {}) {
2369
2439
  'The VM is usually already running (warm start); adbd runs as root once ready. ' +
2370
2440
  'Actions: info, shell, logcat, getprop, setprop, packages, install, push, pull, reboot, root, ' +
2371
2441
  'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2372
- 'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance, flow_start, flow_stop, flow_list, flow_replay. ' +
2442
+ 'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance, flow_start, flow_stop, flow_list, flow_replay, storage, clean_storage. ' +
2373
2443
  'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
2374
2444
  'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
2375
2445
  'Use shell for low-level Android commands, logcat for logs, ' +
@@ -2382,7 +2452,7 @@ export function apply(ctx, config = {}) {
2382
2452
  properties: {
2383
2453
  action: {
2384
2454
  type: 'string',
2385
- description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay',
2455
+ description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay|storage|clean_storage',
2386
2456
  },
2387
2457
  instance: { type: 'string', description: 'Target VM instance id (default or an extra instance from list_instances). Omit for the calling Agent\'s assigned instance.' },
2388
2458
  instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
@@ -2418,6 +2488,9 @@ export function apply(ctx, config = {}) {
2418
2488
  port: { type: 'integer', description: 'proxy: HTTP proxy port (1-65535).' },
2419
2489
  bypass: { type: 'string', description: 'proxy: optional no-proxy/exclusion list.' },
2420
2490
  flowId: { type: 'string', description: 'flow_replay: saved flow id from flow_list.' },
2491
+ storageAction: { type: 'string', description: 'clean_storage: temp|snapshots|orphan-avds.' },
2492
+ avdName: { type: 'string', description: 'clean_storage snapshots: target AVD (defaults to the active AVD).' },
2493
+ confirm: { type: 'boolean', description: 'clean_storage: required true for snapshot or orphan AVD deletion.' },
2421
2494
  },
2422
2495
  required: ['action'],
2423
2496
  },
@@ -2544,6 +2617,65 @@ export function apply(ctx, config = {}) {
2544
2617
  });
2545
2618
  return { ok: true, summary: `replayed ${replayed.steps} step(s) from ${replayed.name}`, result: replayed };
2546
2619
  }
2620
+ if (action === 'storage') {
2621
+ const report = await collectStorage({
2622
+ sdkRoot: storageSdkRoot(),
2623
+ knownAvds: [
2624
+ vm.avdName,
2625
+ ...(instanceManager ? instanceManager.list().map((item) => item.avdName) : []),
2626
+ ].filter(Boolean),
2627
+ activeAvds: [
2628
+ ...(['ready', 'booting', 'starting'].includes(vm._state) ? [vm.avdName] : []),
2629
+ ...(instanceManager
2630
+ ? instanceManager
2631
+ .list()
2632
+ .filter((item) => ['ready', 'booting', 'starting'].includes(item.state))
2633
+ .map((item) => item.avdName)
2634
+ : []),
2635
+ ].filter(Boolean),
2636
+ });
2637
+ return {
2638
+ ok: true,
2639
+ summary:
2640
+ `AVD ${formatBytes(report.totals.avdBytes)}, snapshots ` +
2641
+ `${formatBytes(report.totals.snapshotBytes)}, SDK ${formatBytes(report.totals.sdkBytes)}, ` +
2642
+ `temp ${formatBytes(report.totals.tempBytes)}, free ${formatBytes(report.totals.freeBytes)}`,
2643
+ result: report,
2644
+ };
2645
+ }
2646
+ if (action === 'clean_storage') {
2647
+ if (!args.storageAction) throw new Error('action=clean_storage requires storageAction');
2648
+ const storageAction = String(args.storageAction).toLowerCase();
2649
+ const knownAvds = [
2650
+ vm.avdName,
2651
+ ...(instanceManager ? instanceManager.list().map((item) => item.avdName) : []),
2652
+ ].filter(Boolean);
2653
+ const activeAvds = [
2654
+ ...(['ready', 'booting', 'starting'].includes(vm._state) ? [vm.avdName] : []),
2655
+ ...(instanceManager
2656
+ ? instanceManager
2657
+ .list()
2658
+ .filter((item) => ['ready', 'booting', 'starting'].includes(item.state))
2659
+ .map((item) => item.avdName)
2660
+ : []),
2661
+ ].filter(Boolean);
2662
+ let result;
2663
+ if (storageAction === 'temp') result = await cleanTempFiles();
2664
+ else if (storageAction === 'snapshots') {
2665
+ if (args.confirm !== true) throw new Error('clean_storage snapshots requires confirm=true');
2666
+ const target = String(args.avdName || vm.avdName || 'dsh_apk');
2667
+ if (activeAvds.includes(target)) throw new Error(`AVD ${target} is active; stop it first`);
2668
+ result = await cleanSnapshots({ avdName: target });
2669
+ } else if (storageAction === 'orphan-avds') {
2670
+ if (args.confirm !== true) throw new Error('clean_storage orphan-avds requires confirm=true');
2671
+ result = await cleanOrphanAvds({ knownAvds, activeAvds });
2672
+ } else throw new Error(`unsupported storageAction: ${storageAction}`);
2673
+ return {
2674
+ ok: true,
2675
+ summary: `${storageAction}: freed ${formatBytes(result.freedBytes)}`,
2676
+ result,
2677
+ };
2678
+ }
2547
2679
  if (action === 'start') {
2548
2680
  const snapshot = await api.start();
2549
2681
  return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
@@ -2766,3 +2898,4 @@ export function apply(ctx, config = {}) {
2766
2898
  }, 'dsh-apk-vm: apk_vm tool');
2767
2899
  });
2768
2900
  }
2901
+
package/lib/storage.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Storage accounting and housekeeping for the Android VM.
3
+ *
4
+ * All reported sizes are logical bytes (sparse files are counted by length), so
5
+ * the panel distinguishes apparent AVD size from actual free space. Cleanup is
6
+ * explicit: this module never deletes an AVD or snapshot unless the caller asks
7
+ * for that exact action.
8
+ */
9
+ import { promises as fsp } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const MAX_FILES = 200000;
14
+
15
+ async function walkSize(root, options = {}) {
16
+ const result = { bytes: 0, files: 0, skipped: false };
17
+ const stack = [String(root)];
18
+ while (stack.length) {
19
+ const dir = stack.pop();
20
+ let entries;
21
+ try {
22
+ entries = await fsp.readdir(dir, { withFileTypes: true });
23
+ } catch {
24
+ continue;
25
+ }
26
+ for (const entry of entries) {
27
+ if (options.stopAt && result.bytes > options.stopAt) {
28
+ result.skipped = true;
29
+ return result;
30
+ }
31
+ if (result.files >= MAX_FILES) {
32
+ result.skipped = true;
33
+ return result;
34
+ }
35
+ const full = path.join(dir, entry.name);
36
+ if (entry.isSymbolicLink()) continue;
37
+ if (entry.isDirectory()) {
38
+ stack.push(full);
39
+ continue;
40
+ }
41
+ if (!entry.isFile()) continue;
42
+ try {
43
+ const stat = await fsp.stat(full);
44
+ result.bytes += stat.size;
45
+ result.files += 1;
46
+ } catch {
47
+ /* vanished */
48
+ }
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ async function readAvdIni(avdRoot, name) {
55
+ try {
56
+ const text = await fsp.readFile(path.join(avdRoot, `${name}.ini`), 'utf8');
57
+ const match = /^path=(.+)$/m.exec(text);
58
+ return match ? match[1].trim() : path.join(avdRoot, `${name}.avd`);
59
+ } catch {
60
+ return path.join(avdRoot, `${name}.avd`);
61
+ }
62
+ }
63
+
64
+ function formatBytes(bytes) {
65
+ const value = Number(bytes) || 0;
66
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
67
+ let index = 0;
68
+ let size = value;
69
+ while (size >= 1024 && index < units.length - 1) {
70
+ size /= 1024;
71
+ index += 1;
72
+ }
73
+ return `${size >= 10 || index === 0 ? Math.round(size) : size.toFixed(1)} ${units[index]}`;
74
+ }
75
+
76
+ async function fileStatOrNull(file) {
77
+ try {
78
+ return await fsp.stat(file);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** Collect AVD sizes, SDK sizes, plugin temp files and free disk space. */
85
+ export async function collectStorage(options = {}) {
86
+ const home = options.home || os.homedir();
87
+ const avdRoot = path.resolve(options.avdRoot || path.join(home, '.android', 'avd'));
88
+ const tempRoot = path.resolve(options.tempRoot || os.tmpdir());
89
+ const sdkRoot = options.sdkRoot ? path.resolve(options.sdkRoot) : null;
90
+ const activeAvds = new Set((options.activeAvds || []).map(String));
91
+ const knownAvds = new Set((options.knownAvds || []).map(String));
92
+
93
+ const avds = [];
94
+ let avdEntries = [];
95
+ try {
96
+ avdEntries = await fsp.readdir(avdRoot, { withFileTypes: true });
97
+ } catch {
98
+ avdEntries = [];
99
+ }
100
+ for (const entry of avdEntries) {
101
+ if (!entry.isDirectory() || !entry.name.endsWith('.avd')) continue;
102
+ const name = entry.name.slice(0, -4);
103
+ const avdPath = await readAvdIni(avdRoot, name);
104
+ const total = await walkSize(avdPath);
105
+ const snapshotsDir = path.join(avdPath, 'snapshots');
106
+ const snapshots = await walkSize(snapshotsDir);
107
+ avds.push({
108
+ name,
109
+ path: avdPath,
110
+ totalBytes: total.bytes,
111
+ totalFiles: total.files,
112
+ snapshotBytes: snapshots.bytes,
113
+ userdataBytes: Math.max(0, total.bytes - snapshots.bytes),
114
+ active: activeAvds.has(name),
115
+ registered: knownAvds.has(name),
116
+ managed: name === 'dsh_apk' || name.startsWith('dsh_apk_'),
117
+ orphan: name.startsWith('dsh_apk_') && !knownAvds.has(name) && !activeAvds.has(name),
118
+ });
119
+ }
120
+ avds.sort((a, b) => b.totalBytes - a.totalBytes);
121
+
122
+ const temp = {
123
+ uploadsBytes: (await walkSize(path.join(tempRoot, 'dsh-apk-vm-uploads'))).bytes,
124
+ flowsBytes: (await walkSize(path.join(tempRoot, 'dsh-apk-vm-flows'))).bytes,
125
+ screenshotsBytes: 0,
126
+ recordingsBytes: 0,
127
+ files: 0,
128
+ };
129
+ try {
130
+ const entries = await fsp.readdir(tempRoot, { withFileTypes: true });
131
+ for (const entry of entries) {
132
+ if (!entry.isFile()) continue;
133
+ if (!/^(apk-vm-shot-|dsh-apk-vm-record-)/.test(entry.name)) continue;
134
+ const stat = await fileStatOrNull(path.join(tempRoot, entry.name));
135
+ if (!stat) continue;
136
+ temp.files += 1;
137
+ if (entry.name.startsWith('apk-vm-shot-')) temp.screenshotsBytes += stat.size;
138
+ else temp.recordingsBytes += stat.size;
139
+ }
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+
144
+ let sdk = null;
145
+ if (sdkRoot) {
146
+ const total = await walkSize(sdkRoot);
147
+ const parts = {};
148
+ for (const part of ['system-images', 'emulator', 'build-tools', 'platform-tools', 'platforms']) {
149
+ parts[part] = (await walkSize(path.join(sdkRoot, part))).bytes;
150
+ }
151
+ sdk = { root: sdkRoot, totalBytes: total.bytes, totalFiles: total.files, parts };
152
+ }
153
+
154
+ let disk = null;
155
+ try {
156
+ const stats = await fsp.statfs(avdRoot || home);
157
+ disk = {
158
+ totalBytes: Number(stats.blocks) * Number(stats.bsize),
159
+ freeBytes: Number(stats.bavail) * Number(stats.bsize),
160
+ availableBytes: Number(stats.bavail) * Number(stats.bsize),
161
+ path: avdRoot || home,
162
+ };
163
+ } catch {
164
+ disk = null;
165
+ }
166
+ let sdkDisk = null;
167
+ if (sdkRoot) {
168
+ try {
169
+ const stats = await fsp.statfs(sdkRoot);
170
+ sdkDisk = {
171
+ totalBytes: Number(stats.blocks) * Number(stats.bsize),
172
+ freeBytes: Number(stats.bavail) * Number(stats.bsize),
173
+ path: sdkRoot,
174
+ };
175
+ } catch {
176
+ sdkDisk = null;
177
+ }
178
+ }
179
+
180
+ const snapshotTotal = avds.reduce((sum, item) => sum + item.snapshotBytes, 0);
181
+ const tempTotal = temp.uploadsBytes + temp.flowsBytes + temp.screenshotsBytes + temp.recordingsBytes;
182
+ const recommendations = [];
183
+ if (snapshotTotal > 1024 * 1024 * 1024) {
184
+ recommendations.push({
185
+ code: 'snapshots',
186
+ bytes: snapshotTotal,
187
+ text: `Quick-boot snapshots use ${formatBytes(snapshotTotal)}; disable quickBoot/saveSnapshotAfterBoot or delete a snapshot to free it.`,
188
+ });
189
+ }
190
+ if (tempTotal > 0) {
191
+ recommendations.push({ code: 'temp', bytes: tempTotal, text: `Plugin temp files use ${formatBytes(tempTotal)}; safe to clean.` });
192
+ }
193
+ const orphans = avds.filter((item) => item.orphan);
194
+ if (orphans.length) {
195
+ recommendations.push({
196
+ code: 'orphan-avds',
197
+ bytes: orphans.reduce((sum, item) => sum + item.totalBytes, 0),
198
+ text: `${orphans.length} orphan dsh_apk_* AVD(s) are not registered to a running instance.`,
199
+ });
200
+ }
201
+
202
+ return {
203
+ ok: true,
204
+ sampledAt: new Date().toISOString(),
205
+ home,
206
+ avdRoot,
207
+ tempRoot,
208
+ sdk,
209
+ sdkDisk,
210
+ avds,
211
+ temp,
212
+ disk,
213
+ totals: {
214
+ avdBytes: avds.reduce((sum, item) => sum + item.totalBytes, 0),
215
+ snapshotBytes: snapshotTotal,
216
+ sdkBytes: sdk ? sdk.totalBytes : 0,
217
+ tempBytes: tempTotal,
218
+ freeBytes: disk ? disk.freeBytes : 0,
219
+ },
220
+ recommendations,
221
+ formatter: 'bytes',
222
+ };
223
+ }
224
+
225
+ async function removeTree(target) {
226
+ const existed = await fsp.stat(target).catch(() => null);
227
+ if (!existed) return 0;
228
+ const measured = await walkSize(target);
229
+ await fsp.rm(target, { recursive: true, force: true }).catch(() => {});
230
+ return measured.bytes;
231
+ }
232
+
233
+ /** Delete only the plugin's own temporary artifacts. */
234
+ export async function cleanTempFiles(options = {}) {
235
+ const tempRoot = path.resolve(options.tempRoot || os.tmpdir());
236
+ let freed = 0;
237
+ let files = 0;
238
+ for (const name of ['dsh-apk-vm-uploads', 'dsh-apk-vm-flows']) {
239
+ const dir = path.join(tempRoot, name);
240
+ const measured = await walkSize(dir);
241
+ await removeTree(dir);
242
+ freed += measured.bytes;
243
+ files += measured.files;
244
+ }
245
+ const entries = await fsp.readdir(tempRoot, { withFileTypes: true }).catch(() => []);
246
+ for (const entry of entries) {
247
+ if (!entry.isFile() || !/^(apk-vm-shot-|dsh-apk-vm-record-)/.test(entry.name)) continue;
248
+ const full = path.join(tempRoot, entry.name);
249
+ const stat = await fileStatOrNull(full);
250
+ if (!stat) continue;
251
+ await fsp.unlink(full).catch(() => {});
252
+ freed += stat.size;
253
+ files += 1;
254
+ }
255
+ return { ok: true, action: 'temp', freedBytes: freed, files };
256
+ }
257
+
258
+ /** Delete snapshots for one AVD by name. Refuses active or non-managed AVDs unless forced. */
259
+ export async function cleanSnapshots(options = {}) {
260
+ const avdRoot = path.resolve(options.avdRoot || path.join(os.homedir(), '.android', 'avd'));
261
+ const name = String(options.avdName || '').trim();
262
+ if (!/^[A-Za-z0-9._-]{1,80}$/.test(name)) throw new Error('invalid AVD name');
263
+ if (name !== 'dsh_apk' && !name.startsWith('dsh_apk_')) {
264
+ throw new Error('only plugin-managed dsh_apk* AVDs can have snapshots cleaned');
265
+ }
266
+ const avdPath = await readAvdIni(avdRoot, name);
267
+ if (!avdPath.startsWith(avdRoot + path.sep)) throw new Error('AVD path escapes the AVD root');
268
+ const snapshotsDir = path.join(avdPath, 'snapshots');
269
+ const measured = await walkSize(snapshotsDir);
270
+ await removeTree(snapshotsDir);
271
+ return { ok: true, action: 'snapshots', avdName: name, freedBytes: measured.bytes, files: measured.files };
272
+ }
273
+
274
+ /** Delete only orphan dsh_apk_* AVDs that are not registered/active. */
275
+ export async function cleanOrphanAvds(options = {}) {
276
+ const avdRoot = path.resolve(options.avdRoot || path.join(os.homedir(), '.android', 'avd'));
277
+ const known = new Set((options.knownAvds || []).map(String));
278
+ const active = new Set((options.activeAvds || []).map(String));
279
+ const entries = await fsp.readdir(avdRoot, { withFileTypes: true }).catch(() => []);
280
+ const removed = [];
281
+ let freed = 0;
282
+ for (const entry of entries) {
283
+ if (!entry.isDirectory() || !entry.name.endsWith('.avd')) continue;
284
+ const name = entry.name.slice(0, -4);
285
+ if (!name.startsWith('dsh_apk_') || known.has(name) || active.has(name)) continue;
286
+ const avdPath = path.join(avdRoot, entry.name);
287
+ const measured = await walkSize(avdPath);
288
+ await fsp.rm(avdPath, { recursive: true, force: true }).catch(() => {});
289
+ await fsp.unlink(path.join(avdRoot, `${name}.ini`)).catch(() => {});
290
+ freed += measured.bytes;
291
+ removed.push({ name, bytes: measured.bytes });
292
+ }
293
+ return { ok: true, action: 'orphan-avds', freedBytes: freed, removed };
294
+ }
295
+
296
+ export { formatBytes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-apk-vm",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Local AOSP Android VM for DeepSeek Harness: preview rooted APKs at up to 60 fps, automate UIAutomator flows, and give each Agent an isolated VM.",
6
6
  "license": "Apache-2.0",
@@ -25,7 +25,7 @@
25
25
  "registry": "https://registry.npmjs.org/"
26
26
  },
27
27
  "scripts": {
28
- "check": "node --check index.js && node --check client.js && node --check lib/android.js && node --check lib/apk-bundle.js && node --check lib/flows.js && node --check lib/ui-inspector.js && node --check lib/x509.js && node --check lib/scrcpy.js && node --check lib/ws.js && node --check lib/setup.js && node --check lib/instances.js && node --check lib/video-stream.js",
28
+ "check": "node --check index.js && node --check client.js && node --check lib/android.js && node --check lib/apk-bundle.js && node --check lib/flows.js && node --check lib/storage.js && node --check lib/ui-inspector.js && node --check lib/x509.js && node --check lib/scrcpy.js && node --check lib/ws.js && node --check lib/setup.js && node --check lib/instances.js && node --check lib/video-stream.js",
29
29
  "privacy": "node scripts/privacy-scan.mjs",
30
30
  "test": "node spike/ui-smoke.cjs && node spike/ui-better-sidebar-smoke.cjs && node spike/setup-smoke.mjs && node spike/android/mock-device.mjs && node spike/android/launch-plan.mjs && node spike/instances-smoke.mjs && node spike/features.mjs"
31
31
  },
@@ -44,6 +44,7 @@
44
44
  "lib/apk-bundle.js",
45
45
  "lib/flows.js",
46
46
  "lib/scrcpy.js",
47
+ "lib/storage.js",
47
48
  "lib/ui-inspector.js",
48
49
  "lib/ws.js",
49
50
  "lib/setup.js",