mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6e9e45c9f0
This pull request lets Argent drive a simulator or emulator another process is already running, attaching to its simulator-server rather than spawning a second one. A provider writes a JSON descriptor to `~/.argent/providers/` listing the devices it offers and the mechanisms Argent may use on each. They appear in `list-devices` with an `ext:` id and work with the existing tools. The file is re-read on every call, so a withdrawal or a narrowed grant applies immediately. `ios.additionalDeviceSets` (#600) already makes such a simulator reachable by UDID. What changes is ownership. `boot-device` and `stop-simulator-server` refuse these devices, anything the provider did not grant is refused with a message naming it and Argent uses only the endpoints its own simulator-server build serves. A grant binds to the device rather than to one of its names, so the real udid or serial is gated exactly like the `ext:` id. Android emulators are covered too and a device visible to both a provider and `adb`/`simctl` is listed once. The contract ships as `schemas/device-provider-v1.json`, validated by `argent providers check`, and is documented in `docs/reference/device-providers.mdx`. Includes unit tests and an e2e phase where the harness acts as its own provider. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for discovering and connecting to externally managed devices. * Added `argent providers` commands for listing, validating, publishing, withdrawing, and pruning providers. * Added provider-aware simulator, debugger, profiler, native tools, and device listings. * Added capability controls, endpoint validation, revocation handling, and provider-specific diagnostics. * Added automatic CLI discovery for provider integrations. * **Bug Fixes** * Improved service recovery and paused-runtime error reporting. * Improved simulator keyboard and paste command reliability. * **Documentation** * Documented provider descriptors, capabilities, lifecycle rules, and CLI usage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
/**
|
|
* The parity rule, enforced at each site that speaks HTTP to a provider's
|
|
* simulator-server. On a server Argent did not spawn, use only the endpoints
|
|
* Argent's own build serves.
|
|
*
|
|
* `external-devices.test.ts` covers the allowlist itself, which endpoints it
|
|
* admits and refuses. That is the list, not the enforcement. This file covers
|
|
* the other half, that every caller actually consults it, because the two fail
|
|
* independently. Deleting `assertAllowedSimServerEndpoint` from all three call
|
|
* sites left the whole suite green.
|
|
*
|
|
* No caller takes its endpoint from the outside, so none can be steered at a
|
|
* refused one. What can be observed is whether the guard was asked. The
|
|
* allowlist is stubbed to refuse everything, and the assertion is that the
|
|
* provider's server never receives the request.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as http from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import type { SimulatorServerApi } from "../src/blueprints/simulator-server";
|
|
|
|
const REFUSAL = /Refusing to call/;
|
|
|
|
const assertAllowedMock = vi.fn();
|
|
|
|
vi.mock("../src/utils/external-devices", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/utils/external-devices")>();
|
|
|
|
return {
|
|
...actual,
|
|
assertAllowedSimServerEndpoint: (endpoint: string) => assertAllowedMock(endpoint),
|
|
};
|
|
});
|
|
|
|
import {
|
|
httpScreenshot,
|
|
setPointerVisible,
|
|
setSimulatorClipboardText,
|
|
} from "../src/utils/simulator-client";
|
|
|
|
/** A provider's server, standing in for one Argent did not spawn. */
|
|
let server: http.Server;
|
|
let requested: string[];
|
|
let apiUrl: string;
|
|
|
|
beforeEach(async () => {
|
|
requested = [];
|
|
server = http.createServer((request, response) => {
|
|
requested.push(request.url ?? "");
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.end(JSON.stringify({ status: "ok", url: "file:///shot.png", path: "/shot.png" }));
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
|
|
assertAllowedMock.mockReset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
function api(external: boolean): SimulatorServerApi {
|
|
return {
|
|
apiUrl,
|
|
external,
|
|
pressKey: async () => {},
|
|
streamUrl: `${apiUrl}/stream.mjpeg`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Each case names the endpoint its site is expected to offer the allowlist.
|
|
* Pinning it means a site that starts posting somewhere else has to say so
|
|
* here, rather than silently widening what Argent asks of a provider.
|
|
*/
|
|
const SITES = [
|
|
{
|
|
endpoint: "/api/screenshot",
|
|
name: "httpScreenshot",
|
|
/** Propagates, so the caller learns the endpoint was refused. */
|
|
refused: (a: SimulatorServerApi) => expect(httpScreenshot(a)).rejects.toThrow(REFUSAL),
|
|
reaches: async (a: SimulatorServerApi) => void (await httpScreenshot(a)),
|
|
},
|
|
{
|
|
endpoint: "/api/pointer",
|
|
name: "setPointerVisible",
|
|
/**
|
|
* Swallows every failure and answers `false`, the pointer being cosmetic.
|
|
* The refusal is therefore only visible as a request that was not made,
|
|
* which is the assertion that matters here anyway.
|
|
*/
|
|
refused: async (a: SimulatorServerApi) => expect(await setPointerVisible(a, true)).toBe(false),
|
|
reaches: async (a: SimulatorServerApi) => void (await setPointerVisible(a, true)),
|
|
},
|
|
{
|
|
endpoint: "/api/clipboard/text",
|
|
name: "setSimulatorClipboardText",
|
|
refused: (a: SimulatorServerApi) =>
|
|
expect(setSimulatorClipboardText(a, "hello")).rejects.toThrow(REFUSAL),
|
|
reaches: async (a: SimulatorServerApi) => void (await setSimulatorClipboardText(a, "hello")),
|
|
},
|
|
] as const;
|
|
|
|
describe("every provider HTTP path consults the endpoint allowlist", () => {
|
|
it.each(SITES)("$name asks about $endpoint and sends nothing when refused", async (site) => {
|
|
assertAllowedMock.mockImplementation((endpoint: string) => {
|
|
throw new Error(`Refusing to call '${endpoint}' on an externally-provided simulator-server.`);
|
|
});
|
|
|
|
await site.refused(api(true));
|
|
|
|
expect(assertAllowedMock).toHaveBeenCalledWith(site.endpoint);
|
|
expect(requested).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The gate belongs to provider-supplied servers only. A server Argent spawned
|
|
* is its own process and needs no restriction, so asking the allowlist about
|
|
* it would be the wrong kind of correct.
|
|
*/
|
|
it.each(SITES)("$name leaves a server argent spawned alone", async (site) => {
|
|
assertAllowedMock.mockImplementation(() => {
|
|
throw new Error("the allowlist must not be consulted for a local server");
|
|
});
|
|
|
|
await site.reaches(api(false));
|
|
|
|
expect(assertAllowedMock).not.toHaveBeenCalled();
|
|
expect(requested).toHaveLength(1);
|
|
});
|
|
});
|