dsh-apk-vm 0.4.1 → 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,23 @@
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
+
3
21
  ## 0.4.1
4
22
 
5
23
  Privacy cleanup for the published proof assets.
package/README.md CHANGED
@@ -24,7 +24,7 @@ same-origin, loopback-only debug HTTP API and an `apk_vm` Agent tool.
24
24
  > 当前 **Windows x64 与 Linux x86_64(headless)为端到端已验证平台**;macOS 仅做探测/CI
25
25
  > 覆盖,详见 `docs/LINUX-E2E.md`。
26
26
 
27
- **Status:** `0.4.1` · 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.
28
28
 
29
29
  This is an open-source DSH plugin published on npm as **`dsh-apk-vm`**. Repository:
30
30
  [github.com/yuxiaole-bili/dsh-apk-vm](https://github.com/yuxiaole-bili/dsh-apk-vm).
@@ -174,6 +174,10 @@ key {"text":"HOME"} -> returned to launcher
174
174
  (`settings put global http_proxy`) and installs a local `.pem`/`.crt` CA into the rooted
175
175
  Android user CA store with the correct OpenSSL subject-hash filename; the system CA store is
176
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.
177
181
  - **Installed-app management.** The panel's **Installed apps** card lists third-party packages
178
182
  for the active instance and offers force-stop, clear-data and uninstall; it uses the same
179
183
  rooted `debug/packages` + `debug/action` API as the Agent tool.
@@ -237,6 +241,28 @@ key {"text":"HOME"} -> returned to launcher
237
241
  - `POST /apk-vm/debug/ca` accepts a host `.pem`/`.crt` path and installs it under the guest's
238
242
  user CA directory with the correct subject hash.
239
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
+
240
266
  ## Reproducible demo
241
267
 
242
268
  A public, dependency-free demo lives in [examples](examples/README.md):
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/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.1",
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",