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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,10 @@ file names are never sent to Sentry. Filtered table names and cell values from
are returned to the host application; this is separate from telemetry. See
[storage result filtering](storage-privacy.md). Since 0.1.161 the Changes tab reads storage again every
5 seconds while it is open and the page is visible; each read is one `ui.storage.read`
sample, and the interval stops when the tab, panel, or page is hidden.
sample, and the interval stops when the tab, panel, or page is hidden. The panel
lists installed apps each time it opens, when the selected device boots or reconnects,
and on Refresh; each listing is one `ui.storage.list_apps` sample, and a listing
already in flight is reused rather than repeated.

Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same
app control coverage under `app_control.language` and `app_control.set_language`.
Expand Down
16 changes: 8 additions & 8 deletions packages/shared/test/android-startup-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,23 @@ function completion(messages: StartupMessage[]) {
return message;
}

test("successful startup aggregates stage timings and emits no content or per-poll events", async () => {
test("successful startup aggregates stage timings and emits no content or per-poll events", async t => {
// A valid duration can have the same digits as the fixture's forwarded port.
let now = 0;
t.mock.method(performance, "now", () => now += 28000);
const f = fixture();
const session = await startScrcpy({ serial: "private-device-id", onStartupDiagnostics: f.report }, f.dependencies);
t.after(() => session.close());
const summary = completion(f.messages);
assert.equal(summary.outcome, "ready");
const poll = summary.stages.find(value => value.stage === "socket-poll");
assert.equal(poll?.timedSamples, 1);
assert.equal(poll?.queueMs, 3);
assert.equal(poll?.executionMs, 7);
assert.equal(poll?.spawnedSamples, 1);
const parsed = androidStartupMessageSchema.safeParse(summary);
assert.equal(parsed.success, true);
const serialized = JSON.stringify(f.messages);
for (const content of ["private-device-id", "/private", "12345678", "Private device name", "@scrcpy", "28000"]) {
const included = serialized.includes(content);
assert.equal(included, false);
}
assert.equal(summary.stages[0].totalMs, 28000);
// The strict schema permits only known categories and numeric measurements, never raw content.
for (const message of f.messages) androidStartupMessageSchema.parse(message);
const count = f.messages.length;
await session.close();
assert.equal(f.messages.length, count, "Later session cleanup is outside startup diagnostics.");
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/storage-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const StorageView = memo(function StorageView({ panel }: { panel: Storage
</Tabs>
<InputGroup size="sm" className="min-w-16 flex-1"><InputGroupInput aria-label={state.tab === "browse" ? "Filter keys and values" : "Filter changes"} type="search" placeholder={state.tab === "browse" ? "Filter keys and values…" : "Filter changes…"} maxLength={256} autoComplete="off" spellCheck={false} value={query} onChange={event => setQuery(event.target.value)} /><InputGroupAddon><SearchIcon /></InputGroupAddon></InputGroup>
<ButtonGroup className="shrink-0">
<Button variant="outline" size="icon-sm" aria-label="Refresh" title={snapshot ? `Read again · last read ${clockTime.format(snapshot.readAt)}` : "Read again"} disabled={!state.bundleId || busy} onClick={() => void panel.refresh()}>
<Button variant="outline" size="icon-sm" aria-label="Refresh" title={snapshot ? `Read again · last read ${clockTime.format(snapshot.readAt)}` : "Read again"} disabled={busy || !state.available || state.unsupported !== ""} onClick={() => void panel.reload()}>
<RefreshCwIcon className={state.busy === "read" ? "animate-spin" : undefined} />
</Button>
<ConfirmButton variant="outline" size="sm" title="Reset app data" action="Reset app data" disabled={!state.bundleId || busy || state.unsupported !== ""}
Expand Down
35 changes: 24 additions & 11 deletions packages/ui/src/storage-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export class StoragePanel {
private revision = 0;
private nextChange = 1;
private poll?: ReturnType<typeof setInterval>;
private appsFor = "";
// Whether the app listing in flight still applies; a listing for an older device or app is left to finish unused.
private listing?: () => boolean;
private selections = new Map<string, string>();
private state: StorageState = { open: false, available: false, platform: "ios", deviceId: "", deviceLabel: "", unsupported: "", apps: [], bundleId: "", tab: "browse", changes: [], showSdkChanges: false, busy: null, error: "", status: "" };

Expand Down Expand Up @@ -134,17 +135,18 @@ export class StoragePanel {
const unsupported = device === undefined ? "Choose a simulator or Android device."
: platform === "ios" && device.kind === "physical" ? "Reading app data on a physical iPhone isn't supported yet. Use an iOS simulator."
: running ? "" : `${device.name} isn't running.`;
const changed = deviceId !== this.state.deviceId || platform !== this.state.platform;
// A device that boots, reconnects, or stops starts over like a newly chosen one, keeping the app picked on it.
const changed = deviceId !== this.state.deviceId || platform !== this.state.platform || (unsupported === "") !== (this.state.unsupported === "");
if (!changed) {
if (unsupported !== this.state.unsupported || device?.name !== this.state.deviceLabel) this.update({ unsupported, deviceLabel: device?.name ?? "" });
return;
}
this.revision++;
this.appsFor = "";
this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], detail: undefined, watchingSince: undefined, busy: null, error: "", status: "" });
if (this.state.open) void this.loadApps();
}

// Each open lists the apps again, so apps installed since the last open appear.
show() {
this.update({ open: true });
void this.loadApps();
Expand Down Expand Up @@ -230,23 +232,34 @@ export class StoragePanel {
catch (error) { if (current()) this.update({ busy: null, error: errorMessage(error) }); }
}

async loadApps(force = false) {
if (!this.ready() || this.state.busy === "apps") return;
const key = `${this.state.platform}:${this.state.deviceId}`;
if (!force && this.appsFor === key) return;
await this.run("apps", {}, async current => {
// Lists the installed apps, keeping the current list until the new one arrives. The selected app stays while it is
// installed; otherwise the newest running app is picked. A listing already in flight is shared, and it runs beside a
// read or action without taking over its busy state.
async loadApps() {
if (!this.ready() || this.listing?.()) return;
const current = this.current();
this.listing = current;
const idle = () => this.state.busy === "apps" ? null : this.state.busy;
this.update({ busy: this.state.busy ?? "apps", error: "" });
try {
const data = installedAppsSchema.parse(await measureUiOperation("ui.storage.list_apps", "ui.storage.failure", () => this.call("mobile_list_apps", {})));
if (!current()) return;
this.appsFor = key;
const installed = new Set(data.apps.map(app => app.bundleId));
const bundleId = [this.state.bundleId, data.runningBundleIds[0]].find(id => id && installed.has(id)) ?? "";
this.update({ apps: data.apps, busy: null });
this.update({ apps: data.apps, busy: idle() });
void appIcons.load((name, args) => this.call(name, args), this.state.platform, this.state.deviceId, data.apps.map(app => app.bundleId),
{ measure: work => measureUiOperation("ui.storage.icons", "ui.storage.failure", work) });
if (bundleId !== this.state.bundleId || this.state.snapshot === undefined) this.selectApp(bundleId);
});
} catch (error) {
if (current()) this.update({ busy: idle(), error: errorMessage(error) });
} finally {
if (this.listing === current) this.listing = undefined;
}
}

// Refresh lists the apps again, so newly installed ones appear, and reads the selected app again.
async reload() { await Promise.all([this.loadApps(), this.refresh()]); }

selectApp(bundleId: string) {
if (bundleId !== this.state.bundleId) {
this.revision++;
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/test/react-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,9 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim
await act(async () => { (storageDrawer.querySelector('[role="tab"]:nth-child(2)') as HTMLElement).click(); });
assert.match(storageDrawer.textContent ?? "", /No changes yet/);
await act(async () => { (storageDrawer.querySelector('[role="tab"]:nth-child(1)') as HTMLElement).click(); });
const beforeRefresh = storageCalls.length;
await act(async () => { (storageDrawer.querySelector('button[aria-label="Refresh"]') as HTMLButtonElement).click(); await new Promise(resolve => setTimeout(resolve, 0)); });
assert.deepEqual(storageCalls.slice(beforeRefresh).filter(name => name !== "mobile_app_icons"), ["mobile_list_apps", "mobile_app_storage"], "Refresh lists the apps again and rereads the selected app.");
assert.equal(dom.window.document.querySelector("canvas"), canvas, "The storage tool preserves the device DOM.");
await selectTool("logs");
assert.equal(dom.window.document.getElementById("storage-drawer"), null);
Expand Down
146 changes: 146 additions & 0 deletions packages/ui/test/storage-panel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import test from "node:test";
import type { TestContext } from "node:test";
import assert from "node:assert/strict";
import { setImmediate } from "node:timers/promises";
import type { App } from "@modelcontextprotocol/ext-apps";
import type { InstalledApp } from "../../shared/src/app-control.ts";
import type { PhysicalAndroidDevice } from "../../shared/src/android-devices.ts";
import { createDeviceApps } from "../../test-support/device-apps-fixtures.ts";
import { UDID } from "../../test-support/fixtures.ts";
import { StoragePanel } from "../src/storage-panel.ts";

const simulator = { udid: UDID, name: "iPhone", state: "Booted", runtime: "iOS" };
const installed = (bundleId: string): InstalledApp => ({ bundleId, name: bundleId });

// A device whose installed and running apps change between listings. Held listings wait until released.
function fixture(t: TestContext) {
const device = { apps: [] as InstalledApp[], running: [] as string[], hold: false };
const held: (() => void)[] = [];
const calls: { name: string; bundleId?: unknown }[] = [];
const app = {
async callServerTool({ name, arguments: args }: { name: string; arguments: Record<string, unknown> }) {
calls.push({ name, bundleId: args.bundleId });
if (name === "mobile_list_apps") {
const data = { apps: [...device.apps], foregroundBundleId: null, runningBundleIds: [...device.running] };
if (!device.hold) return { content: [], structuredContent: data };
await new Promise<void>(resolve => held.push(resolve));
return { isError: true, content: [{ type: "text", text: "Unable to lookup in current state: Shutdown" }] };
}
if (name === "mobile_app_icons") return { content: [], structuredContent: { apps: {} } };
return { content: [], structuredContent: { platform: "ios", bundleId: args.bundleId, readAt: Date.now(), stores: [], files: [], fileCount: 0, totalBytes: 0 } };
},
};
const deviceApps = createDeviceApps(app as unknown as App);
const panel = new StoragePanel(app as unknown as App, deviceApps);
t.after(() => { panel.dispose(); deviceApps.dispose(); });
panel.setAvailable(true);
return {
device, deviceApps, panel,
listings: () => calls.filter(call => call.name === "mobile_list_apps").length,
reads: () => calls.filter(call => call.name === "mobile_app_storage").map(call => call.bundleId),
apps: () => panel.getSnapshot().apps.map(app => app.bundleId),
release: () => { for (const resolve of held.splice(0)) resolve(); },
};
}

test("Storage lists apps when the open panel's simulator boots, and again after a restart, keeping the picked app", async t => {
const { device, deviceApps, panel, listings, reads, apps } = fixture(t);
device.apps = [installed("com.example.a"), installed("com.example.b")];
device.running = ["com.example.a"];
deviceApps.selectDevice({ ...simulator, state: "Shutdown" });
panel.show();
await setImmediate();
assert.equal(panel.getSnapshot().unsupported, "iPhone isn't running.");
assert.equal(listings(), 0);

deviceApps.selectDevice(simulator);
await setImmediate();
assert.equal(panel.getSnapshot().unsupported, "");
assert.equal(listings(), 1);
assert.deepEqual(apps(), ["com.example.a", "com.example.b"]);
assert.equal(panel.getSnapshot().snapshot?.bundleId, "com.example.a", "The running app is picked and read.");

panel.selectApp("com.example.b");
await setImmediate();
deviceApps.selectDevice({ ...simulator, state: "Shutdown" });
assert.equal(panel.getSnapshot().snapshot, undefined, "A stopped device's data is not kept as current.");
deviceApps.selectDevice(simulator);
await setImmediate();
assert.equal(listings(), 2);
assert.equal(panel.getSnapshot().bundleId, "com.example.b");
assert.deepEqual(reads(), ["com.example.a", "com.example.b", "com.example.b"]);
});

test("Storage lists apps again when the open panel's Android device reconnects", async t => {
const { device, deviceApps, panel, listings, apps } = fixture(t);
const phone: PhysicalAndroidDevice = { udid: "R5CT1234567", name: "Pixel", state: "connected", runtime: "Android 16", platform: "android", kind: "physical", transportType: "wired" };
deviceApps.selectDevice(phone);
panel.show();
await setImmediate();
assert.equal(listings(), 1);
deviceApps.selectDevice({ ...phone, state: "disconnected" });
device.apps = [installed("com.example.new")];
deviceApps.selectDevice(phone);
await setImmediate();
assert.equal(listings(), 2);
assert.deepEqual(apps(), ["com.example.new"]);
});

test("reopening Storage and Refresh list newly installed apps and keep the selection while it is installed", async t => {
const { device, deviceApps, panel, listings, reads, apps } = fixture(t);
deviceApps.selectDevice(simulator);
panel.show();
await setImmediate();
assert.equal(listings(), 1);
assert.deepEqual(apps(), []);

device.apps = [installed("com.example.new")];
deviceApps.selectDevice({ ...simulator });
panel.hide();
panel.show();
await setImmediate();
assert.equal(listings(), 2);
assert.deepEqual(apps(), ["com.example.new"]);
panel.selectApp("com.example.new");
await setImmediate();

device.apps.push(installed("com.example.other"));
await panel.reload();
await setImmediate();
assert.equal(listings(), 3);
assert.deepEqual(apps(), ["com.example.new", "com.example.other"]);
assert.equal(panel.getSnapshot().bundleId, "com.example.new");
assert.deepEqual(reads(), ["com.example.new", "com.example.new"], "Refresh also reads the selected app again.");

device.apps = [installed("com.example.other")];
await panel.reload();
await setImmediate();
assert.equal(panel.getSnapshot().bundleId, "", "An uninstalled app is no longer selected.");
assert.equal(panel.getSnapshot().snapshot, undefined);
assert.equal(panel.getSnapshot().busy, null);
});

test("Storage shares a listing in flight and drops one that finishes after its device stopped", async t => {
const { device, deviceApps, panel, listings, apps, release } = fixture(t);
device.hold = true;
deviceApps.selectDevice(simulator);
panel.show();
panel.hide();
panel.show();
void panel.reload();
assert.equal(listings(), 1);
assert.equal(panel.getSnapshot().busy, "apps");

deviceApps.selectDevice({ ...simulator, state: "Shutdown" });
release();
await setImmediate();
assert.equal(panel.getSnapshot().error, "", "The stopped device's late failure is not shown.");

device.hold = false;
device.apps = [installed("com.example.a")];
deviceApps.selectDevice(simulator);
await setImmediate();
assert.equal(listings(), 2);
assert.deepEqual(apps(), ["com.example.a"]);
assert.equal(panel.getSnapshot().busy, null);
});
Loading