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
54 changes: 50 additions & 4 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type DaemonInfo = {
token: string;
pid: number;
version?: string;
codeSignature?: string;
processStartTime?: string;
};

Expand Down Expand Up @@ -83,9 +84,24 @@ export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<D
async function ensureDaemon(): Promise<DaemonInfo> {
const existing = readDaemonInfo();
const localVersion = readVersion();
const localCodeSignature = resolveLocalDaemonCodeSignature();
const existingReachable = existing ? await canConnect(existing) : false;
if (existing && existing.version === localVersion && existingReachable) return existing;
if (existing && (existing.version !== localVersion || !existingReachable)) {
if (
existing
&& existing.version === localVersion
&& existing.codeSignature === localCodeSignature
&& existingReachable
) {
return existing;
}
if (
existing
&& (
existing.version !== localVersion
|| existing.codeSignature !== localCodeSignature
|| !existingReachable
)
) {
await stopDaemonProcessForTakeover(existing);
removeDaemonInfo();
}
Expand Down Expand Up @@ -224,6 +240,22 @@ async function canConnect(info: DaemonInfo): Promise<boolean> {
}

async function startDaemon(): Promise<void> {
const launchSpec = resolveDaemonLaunchSpec();
const args = launchSpec.useSrc
? ['--experimental-strip-types', launchSpec.srcPath]
: [launchSpec.distPath];

runCmdDetached(process.execPath, args);
}

type DaemonLaunchSpec = {
root: string;
distPath: string;
srcPath: string;
useSrc: boolean;
};

function resolveDaemonLaunchSpec(): DaemonLaunchSpec {
const root = findProjectRoot();
const distPath = path.join(root, 'dist', 'src', 'daemon.js');
const srcPath = path.join(root, 'src', 'daemon.ts');
Expand All @@ -235,9 +267,23 @@ async function startDaemon(): Promise<void> {
}
const runningFromSource = process.execArgv.includes('--experimental-strip-types');
const useSrc = runningFromSource ? hasSrc : !hasDist && hasSrc;
const args = useSrc ? ['--experimental-strip-types', srcPath] : [distPath];
return { root, distPath, srcPath, useSrc };
}

runCmdDetached(process.execPath, args);
function resolveLocalDaemonCodeSignature(): string {
const launchSpec = resolveDaemonLaunchSpec();
const entryPath = launchSpec.useSrc ? launchSpec.srcPath : launchSpec.distPath;
return computeDaemonCodeSignature(entryPath, launchSpec.root);
}

export function computeDaemonCodeSignature(entryPath: string, root: string = findProjectRoot()): string {
try {
const stat = fs.statSync(entryPath);
const relativePath = path.relative(root, entryPath) || entryPath;
return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
} catch {
return 'unknown';
}
}

async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<DaemonResponse> {
Expand Down
29 changes: 27 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import crypto from 'node:crypto';
import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
import { asAppError, AppError, normalizeError } from './utils/errors.ts';
import { readVersion } from './utils/version.ts';
import { findProjectRoot, readVersion } from './utils/version.ts';
import { abortAllIosRunnerSessions, stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
import { SessionStore } from './daemon/session-store.ts';
Expand Down Expand Up @@ -47,6 +47,7 @@ type DaemonLockInfo = {
};

const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;
const daemonCodeSignature = resolveDaemonCodeSignature();

function contextFromFlags(
flags: CommandFlags | undefined,
Expand Down Expand Up @@ -222,13 +223,37 @@ function writeInfo(port: number): void {
fs.writeFileSync(logPath, '');
fs.writeFileSync(
infoPath,
JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
JSON.stringify(
{
port,
token,
pid: process.pid,
version,
codeSignature: daemonCodeSignature,
processStartTime: daemonProcessStartTime,
},
null,
2,
),
{
mode: 0o600,
},
);
}

function resolveDaemonCodeSignature(): string {
const entryPath = process.argv[1];
if (!entryPath) return 'unknown';
try {
const stat = fs.statSync(entryPath);
const root = findProjectRoot();
const relativePath = path.relative(root, entryPath) || entryPath;
return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
} catch {
return 'unknown';
}
}

function removeInfo(): void {
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
}
Expand Down
93 changes: 93 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,99 @@ test('open app on existing iOS session resolves and stores bundle id', async ()
assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
});

test('open app on existing Android session resolves and stores package id', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-session';
sessionStore.set(
sessionName,
{
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
}),
appName: 'Old App',
},
);

let dispatchedContext: Record<string, unknown> | undefined;
const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'open',
positionals: ['RNCLI83'],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
dispatch: async (_device, _command, _positionals, _out, context) => {
dispatchedContext = context as Record<string, unknown> | undefined;
return {};
},
ensureReady: async () => {},
resolveAndroidPackageForOpen: async () => 'org.reactjs.native.example.RNCLI83',
});

assert.ok(response);
assert.equal(response?.ok, true);
const updated = sessionStore.get(sessionName);
assert.equal(updated?.appBundleId, 'org.reactjs.native.example.RNCLI83');
assert.equal(updated?.appName, 'RNCLI83');
assert.equal(dispatchedContext?.appBundleId, 'org.reactjs.native.example.RNCLI83');
});

test('open intent target on existing Android session clears stale package context', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-session';
sessionStore.set(
sessionName,
{
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
}),
appBundleId: 'com.example.old',
appName: 'Old App',
},
);

let dispatchedContext: Record<string, unknown> | undefined;
const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'open',
positionals: ['settings'],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
dispatch: async (_device, _command, _positionals, _out, context) => {
dispatchedContext = context as Record<string, unknown> | undefined;
return {};
},
ensureReady: async () => {},
resolveAndroidPackageForOpen: async () => undefined,
});

assert.ok(response);
assert.equal(response?.ok, true);
const updated = sessionStore.get(sessionName);
assert.equal(updated?.appBundleId, undefined);
assert.equal(updated?.appName, 'settings');
assert.equal(dispatchedContext?.appBundleId, undefined);
});

test('open --relaunch closes and reopens active session app', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-session';
Expand Down
35 changes: 33 additions & 2 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string):
}
}

async function resolveAndroidPackageForOpen(
device: DeviceInfo,
openTarget: string | undefined,
): Promise<string | undefined> {
if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) return undefined;
try {
const { resolveAndroidApp } = await import('../../platforms/android/index.ts');
const resolved = await resolveAndroidApp(device, openTarget);
return resolved.type === 'package' ? resolved.value : undefined;
} catch {
return undefined;
}
}

function shouldPreserveAndroidPackageContext(
device: DeviceInfo,
openTarget: string | undefined,
): boolean {
return device.platform === 'android' && Boolean(openTarget && isDeepLinkTarget(openTarget));
}

async function handleAppStateCommand(params: {
req: DaemonRequest;
sessionName: string;
Expand Down Expand Up @@ -247,6 +268,10 @@ export async function handleSessionCommands(params: {
start: typeof startAppLog;
stop: typeof stopAppLog;
};
resolveAndroidPackageForOpen?: (
device: DeviceInfo,
openTarget: string | undefined,
) => Promise<string | undefined>;
}): Promise<DaemonResponse | null> {
const {
req,
Expand All @@ -263,6 +288,7 @@ export async function handleSessionCommands(params: {
start: startAppLog,
stop: stopAppLog,
},
resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen,
} = params;
const dispatch = dispatchOverride ?? dispatchCommand;
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
Expand Down Expand Up @@ -471,7 +497,10 @@ export async function handleSessionCommands(params: {
};
}
await ensureReady(session.device);
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
const appBundleId =
(await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId))
?? (await resolveAndroidPackageForOpenOverride(session.device, openTarget))
?? (shouldPreserveAndroidPackageContext(session.device, openTarget) ? session.appBundleId : undefined);
const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
if (shouldRelaunch) {
const closeTarget = appBundleId ?? openTarget;
Expand Down Expand Up @@ -530,7 +559,9 @@ export async function handleSessionCommands(params: {
};
}
await ensureReady(device);
const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
const appBundleId =
(await resolveIosBundleIdForOpen(device, openTarget))
?? (await resolveAndroidPackageForOpenOverride(device, openTarget));
if (shouldRelaunch && openTarget) {
const closeTarget = appBundleId ?? openTarget;
await dispatch(device, 'close', [closeTarget], req.flags?.out, {
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export async function handleSnapshotCommands(params: {
error: {
code: 'INVALID_ARGS',
message:
'settings requires <wifi|airplane|location> <on|off>, faceid <match|nonmatch|enroll|unenroll>, or permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]',
'settings requires <wifi|airplane|location> <on|off>, faceid <match|nonmatch|enroll|unenroll>, or permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]',
},
};
}
Expand Down
48 changes: 47 additions & 1 deletion src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ test('setAndroidSetting permission grant camera uses pm grant', async () => {
);
});

test('setAndroidSetting permission deny notifications uses appops', async () => {
test('setAndroidSetting permission deny notifications revokes runtime permission and appops', async () => {
await withMockedAdb(
'agent-device-android-permission-notifications-',
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
Expand All @@ -340,11 +340,35 @@ test('setAndroidSetting permission deny notifications uses appops', async () =>
permissionTarget: 'notifications',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS/);
assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndeny/);
},
);
});

test('setAndroidSetting permission reset notifications clears permission flags for reprompt', async () => {
await withMockedAdb(
'agent-device-android-permission-notifications-reset-',
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
async ({ argsLogPath, device }) => {
await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', {
permissionTarget: 'notifications',
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /shell\npm\nrevoke\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS/);
assert.match(
logged,
/shell\npm\nclear-permission-flags\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS\nuser-set/,
);
assert.match(
logged,
/shell\npm\nclear-permission-flags\ncom\.example\.app\nandroid\.permission\.POST_NOTIFICATIONS\nuser-fixed/,
);
assert.match(logged, /shell\nappops\nset\ncom\.example\.app\nPOST_NOTIFICATION\ndefault/);
},
);
});

test('setAndroidSetting permission reset camera maps to pm revoke', async () => {
await withMockedAdb(
'agent-device-android-permission-reset-',
Expand Down Expand Up @@ -382,6 +406,28 @@ test('setAndroidSetting permission rejects mode argument', async () => {
);
});

test('setAndroidSetting permission rejects iOS-only targets with Android-specific guidance', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
await assert.rejects(
() =>
setAndroidSetting(device, 'permission', 'grant', 'com.example.app', {
permissionTarget: 'calendar',
}),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /Unsupported permission target on Android/i);
return true;
},
);
});

test('setAndroidSetting permission grant photos falls back to legacy permission on older SDK', async () => {
await withMockedAdb(
'agent-device-android-permission-photos-fallback-',
Expand Down
Loading
Loading