Skip to content
35 changes: 33 additions & 2 deletions packages/platform-apple/src/launch-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,13 @@ test.each([
{ outcome: 'unreadable', step: 'url-owner' },
],
[
'the accept rejects',
'the accept rejects and the confirmation remains',
port(async () => CONFIRMATION, {
acceptAlert: async () => {
throw spawnTimeout();
},
}),
{ outcome: 'unreadable', step: 'alert-accept' },
{ outcome: 'unanswered', reason: 'alert-still-present' },
],
])('an answer attempt where %s reports %j', async (_case, { port: device }, expected) => {
await expect(answerLaunchConfirmation(device)).resolves.toEqual(expected);
Expand Down Expand Up @@ -412,3 +412,34 @@ test('a runner that cannot be resolved reports an unreadable attempt', async ()
),
).resolves.toEqual({ outcome: 'unreadable', step: 'runner' });
});

test('a confirmed persistent prompt reports its observed outcome without an accept-failure diagnostic', async () => {
vi.mocked(emitDiagnostic).mockClear();
const { port: device } = port(async () => CONFIRMATION, {
acceptAlert: async () => {
throw spawnTimeout();
},
});
await expect(answerLaunchConfirmation(device)).resolves.toEqual({
outcome: 'unanswered',
reason: 'alert-still-present',
});
expect(emitDiagnostic).toHaveBeenCalledExactlyOnceWith({
level: 'debug',
phase: 'ios_launch_confirmation_unanswered',
data: { reason: 'alert-still-present' },
});
});

test('a matching launch title with other buttons is left unanswered on the first read', async () => {
const { port: device, acceptAlert } = port(async () => ({
...CONFIRMATION,
items: ['Cancel', 'Continue'],
}));
await expect(answerLaunchConfirmation(device)).resolves.toMatchObject({
outcome: 'unanswered',
reason: 'alert-unrecognized',
});
expect(device.readAlert).toHaveBeenCalledOnce();
expect(acceptAlert).not.toHaveBeenCalled();
});
59 changes: 45 additions & 14 deletions packages/platform-apple/src/launch-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts';
export const LAUNCH_CONFIRMATION_FOREIGN_APP_REASON = 'launch_confirmation_foreign_app';

/**
* How one launch-confirmation answer attempt ended. Two endings mean the launch URL left the
* caller's hands and only that caller knows whether it landed: `accepted`, and the `unreadable`
* whose step is `alert-accept`, where the accept was attempted and its outcome never arrived. Every
* other ending leaves the launch exactly as it was — `absent` found no prompt, `unanswered` found a
* prompt this answer must not accept, and any other `unreadable` step failed before one. A caller
* acts on these outcomes and its own proof about the process, never on text.
* How one launch-confirmation answer attempt ended. `accepted` verifies dismissal; an unreadable
* `alert-accept` has an unknown outcome. `alert-still-present` proves the recognized prompt remains
* after an accept failed. Other endings precede acceptance: no prompt, an unrecognized prompt, an
* unresolved URL owner, or an unreadable prerequisite. A caller acts on these typed outcomes and
* its own proof about the process, never on error text.
*/
export type LaunchConfirmationAttempt =
| Readonly<{ outcome: 'accepted' }>
| Readonly<{ outcome: 'absent' }>
| Readonly<{ outcome: 'unanswered'; reason: 'alert-unrecognized' | 'url-owner-unresolved' }>
| Readonly<{
outcome: 'unanswered';
reason: 'alert-unrecognized' | 'url-owner-unresolved' | 'alert-still-present';
}>
| Readonly<{ outcome: 'unreadable'; step: LaunchConfirmationStep }>;

/** A step of the answer that failed, keyed for diagnostics and tests. */
Expand Down Expand Up @@ -55,7 +57,7 @@ export type LaunchConfirmationPort = Readonly<{
}>;

/**
* Answers a launch confirmation. The title only recognizes the confirmation; the app it opens is
* Answers a launch confirmation. The title and buttons recognize the confirmation; the app it opens is
* the URL scheme's owner. An owner that is the session app is accepted; any other owner is never
* accepted and fails the open, because accepting would hand it the launch URL. Every other ending
* is reported as its typed attempt so the settle can decide what the launch still needs. Each step
Expand All @@ -66,15 +68,16 @@ export async function answerLaunchConfirmation(
): Promise<LaunchConfirmationAttempt> {
const read = await readAlert(port);
if ('outcome' in read) return read;
if (!read.alert) return { outcome: 'absent' };
if (!isLaunchConfirmation(read.alert)) {
const alert = read.alert;
if (!alert) return { outcome: 'absent' };
if (!isLaunchConfirmation(alert)) {
emitDiagnostic({
level: 'warn',
phase: 'ios_launch_confirmation_unanswered',
data: {
reason: 'alert-unrecognized',
title: read.alert['message'],
buttons: read.alert['items'],
title: alert['message'],
buttons: alert['items'],
},
});
return { outcome: 'unanswered', reason: 'alert-unrecognized' };
Expand All @@ -93,7 +96,27 @@ export async function answerLaunchConfirmation(
hint: `iOS is asking whether to open ${owner.owner}. Answer it with alert accept or alert dismiss, and pass a launch URL whose scheme belongs to the session app.`,
});
}
return await port.acceptAlert().then(accepted, unreadable('alert-accept'));
return await port
.acceptAlert()
.then(accepted, async (error: unknown) => await verifyFailedAcceptance(port, alert, error));
}

async function verifyFailedAcceptance(
port: LaunchConfirmationPort,
original: Record<string, unknown>,
error: unknown,
): Promise<LaunchConfirmationAttempt> {
const read = await readAlert(port);
if ('outcome' in read) return unreadable('alert-accept')(error);
if (
read.alert &&
isLaunchConfirmation(read.alert) &&
read.alert['message'] === original['message']
) {
reportUnanswered('alert-still-present', {});
return { outcome: 'unanswered', reason: 'alert-still-present' };
}
return unreadable('alert-accept')(error);
}

async function readAlert(
Expand All @@ -114,7 +137,15 @@ function accepted(): LaunchConfirmationAttempt {

function isLaunchConfirmation(alert: Record<string, unknown>): boolean {
const title = alert['message'];
return typeof title === 'string' && LAUNCH_CONFIRMATION_TITLE.test(title);
const buttons = alert['items'];
return (
typeof title === 'string' &&
LAUNCH_CONFIRMATION_TITLE.test(title) &&
Array.isArray(buttons) &&
buttons.length === 2 &&
buttons.includes('Cancel') &&
buttons.includes('Open')
);
}

/** A failed step leaves the open unanswered; the failure is reported, not thrown. */
Expand Down
94 changes: 92 additions & 2 deletions packages/platform-apple/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,8 +781,13 @@ test.each([

test('an accept that dies with the runner session hands the URL over again and reads once more', async () => {
let accepts = 0;
const readAlert = vi
.fn<() => Promise<Record<string, unknown>>>()
.mockResolvedValueOnce(CONFIRMATION)
.mockRejectedValueOnce(SPAWN_TIMEOUT)
.mockResolvedValue(CONFIRMATION);
const { lifecycle, events } = launchUrlSimulator(
async () => CONFIRMATION,
readAlert,
[UNOBSERVABLE, UNOBSERVABLE, OBSERVABLE],
{
acceptAlert: async () => {
Expand All @@ -804,6 +809,7 @@ test('an accept that dies with the runner session hands the URL over again and r
'observe unobservable',
'alert get',
'alert accept',
'alert get',
'observe unobservable',
'open',
'alert get',
Expand All @@ -812,6 +818,84 @@ test('an accept that dies with the runner session hands the URL over again and r
]);
});

test.each([
['still coming up', COMING_UP],
['observable', OBSERVABLE],
['not running', UNOBSERVABLE],
])(
'a failed accept rejects a persistent launch prompt while the app is %s',
async (_name, afterAnswer) => {
const { lifecycle, interactor, events } = launchUrlSimulator(
async () => CONFIRMATION,
[BRIDGE_CIRCUIT, afterAnswer],
{
acceptAlert: async () => {
throw new AppError('COMMAND_FAILED', 'alert accept exhausted its deadline', {
runnerErrorCode: 'ALERT_DEADLINE_EXCEEDED',
});
},
},
);

await expect(lifecycle.openApplication(launchUrlInput())).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'launch_confirmation_unanswered', appBundleId: 'com.example.app' },
});
expect(interactor.acceptAlert).toHaveBeenCalledOnce();
expect(interactor.readAlert).toHaveBeenCalledTimes(2);
expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([
`open ${LAUNCH_URL}`,
]);
},
);

test.each([
{
name: 'absent',
readAfterFailure: async () => {
throw alertNotFound();
},
},
{
name: 'unreadable',
readAfterFailure: async () => {
throw SPAWN_TIMEOUT;
},
},
{
name: 'replaced by the same title with unrelated buttons',
readAfterFailure: async () => ({ message: CONFIRMATION.message, items: ['Allow', 'Deny'] }),
},
{
name: 'replaced by another launch confirmation',
readAfterFailure: async () => ({ ...CONFIRMATION, message: 'Open in “Other App”?' }),
},
])(
'a failed accept with its prompt $name preserves launch-transition policy',
async ({ readAfterFailure }) => {
const readAlert = vi.fn(readAfterFailure).mockResolvedValueOnce(CONFIRMATION);
const { lifecycle, interactor, events } = launchUrlSimulator(
readAlert,
[BRIDGE_CIRCUIT, COMING_UP],
{
acceptAlert: async () => {
throw SPAWN_TIMEOUT;
},
},
);

const outcome = await lifecycle.openApplication(launchUrlInput());

expect(outcome.launchConfirmation).toBeUndefined();
expect(outcome.timing.postOpenObservation).toBe('unobservable');
expect(interactor.acceptAlert).toHaveBeenCalledOnce();
expect(interactor.readAlert).toHaveBeenCalledTimes(2);
expect(events.filter((event) => event === 'open' || event === `open ${LAUNCH_URL}`)).toEqual([
`open ${LAUNCH_URL}`,
]);
},
);

test('an accept leaving a launch-transition window open stays green and re-hands nothing', async () => {
const { lifecycle, events } = launchUrlSimulator(
async () => CONFIRMATION,
Expand All @@ -834,8 +918,14 @@ test('an accept leaving a launch-transition window open stays green and re-hands
});

test('a launch URL handed over twice that still leaves no process fails the open', async () => {
const readAlert = vi
.fn<() => Promise<Record<string, unknown>>>()
.mockResolvedValueOnce(CONFIRMATION)
.mockRejectedValueOnce(SPAWN_TIMEOUT)
.mockResolvedValueOnce(CONFIRMATION)
.mockRejectedValueOnce(SPAWN_TIMEOUT);
const { lifecycle, events } = launchUrlSimulator(
async () => CONFIRMATION,
readAlert,
Array.from({ length: 3 }, () => UNOBSERVABLE),
{
acceptAlert: async () => {
Expand Down
48 changes: 35 additions & 13 deletions packages/platform-apple/src/open-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from './snapshot-observability.ts';
import type { LaunchConfirmationAttempt } from './launch-confirmation.ts';

/** Why an open failed because its launch URL never reached the app it names. */
/** Why an open failed because its launch URL has not completed for the app it names. */
const LAUNCH_CONFIRMATION_UNANSWERED_REASON = 'launch_confirmation_unanswered';

const POST_OPEN_SETTLE_MS = 300;
Expand All @@ -32,7 +32,7 @@ const LAUNCH_CONFIRMATION_ROUNDS = 2;
type HeldLaunchOutcome = Readonly<{
observed: LaunchObservation | undefined;
launchConfirmation?: LaunchConfirmation;
/** The launch was proven to have no process after every round this open can spend on it. */
/** The launch has no process, or its recognized confirmation remains visible. */
unanswered: boolean;
}>;

Expand All @@ -41,6 +41,7 @@ type HeldLaunchRound = Readonly<{
launchConfirmation?: LaunchConfirmation;
/** Whether an accept has run or been attempted; only an accept can change what the bridge sees. */
acceptAttempted: boolean;
confirmationStillPresent: boolean;
}>;

export type MutableOpenTiming = {
Expand Down Expand Up @@ -117,7 +118,8 @@ export function releaseSpeculativeRunner(
* proven not running, the accept died with the runner session that raised it or the device dropped a
* held URL, and the URL is handed over again and read once more. A launch still proven not running
* afterwards fails the open with `launch_confirmation_unanswered` rather than returning green to a
* session whose every later command then fails `app is not running`. A read that found no prompt, an
* session whose every later command then fails `app is not running`. A recognized prompt still
* visible after a failed accept also fails the open, even if the app is running. A read that found no prompt, an
* unrecognized prompt and an unresolved URL owner all leave the open as green as it was, with only
* the one extra runner command spent.
*/
Expand Down Expand Up @@ -150,11 +152,11 @@ export async function settleAppleOpen(
if (held.unanswered) {
throw new AppError(
'COMMAND_FAILED',
`The launch URL was handed to the Simulator but ${input.appBundleId} never came up.`,
`The Simulator has not completed the launch URL for ${input.appBundleId}.`,
{
reason: LAUNCH_CONFIRMATION_UNANSWERED_REASON,
appBundleId: input.appBundleId,
hint: 'iOS may still be holding an "Open in" prompt, or the answer died with the runner session that raised it. Answer it with alert accept, or re-run open with the same --launch-url.',
hint: 'iOS may still be holding an "Open in" prompt, or the answer died with the runner session that raised it. Inspect the current alert before deciding on another action.',
},
);
}
Expand Down Expand Up @@ -191,20 +193,27 @@ async function answerHeldLaunch(
observe: () => Promise<LaunchObservation | undefined>,
observed: LaunchObservation | undefined,
): Promise<HeldLaunchOutcome> {
let round: HeldLaunchRound = { observed, acceptAttempted: false };
let round: HeldLaunchRound = {
observed,
acceptAttempted: false,
confirmationStillPresent: false,
};
for (let roundIndex = 0; roundIndex < LAUNCH_CONFIRMATION_ROUNDS; roundIndex += 1) {
round = await answerConfirmationOnce(answer, observe, round);
const lastRound = roundIndex === LAUNCH_CONFIRMATION_ROUNDS - 1;
if (lastRound || !acceptLeftTheUrlInFlight(round) || !redispatchLaunchUrl) break;
if (
round.confirmationStillPresent ||
lastRound ||
!acceptLeftTheUrlInFlight(round) ||
!redispatchLaunchUrl
)
break;
await redispatchLaunchUrl();
}
// A second hand-off and another read later, a launch with no process is one this open cannot
// complete. An expired launch-transition window leaves the open green: it proves only that the
// app was still coming up.
return {
observed: round.observed,
launchConfirmation: round.launchConfirmation,
unanswered: acceptLeftTheUrlInFlight(round),
unanswered: acceptLeftTheUrlInFlight(round) || round.confirmationStillPresent,
};
}

Expand All @@ -214,15 +223,27 @@ async function answerConfirmationOnce(
round: HeldLaunchRound,
): Promise<HeldLaunchRound> {
const attempt = await answer();
const confirmationStillPresent =
attempt.outcome === 'unanswered' && attempt.reason === 'alert-still-present';
const acceptAttempted = round.acceptAttempted || acceptWasAttempted(attempt);
const launchConfirmation =
attempt.outcome === 'accepted' ? ('accepted' as const) : round.launchConfirmation;
if (!acceptAttempted) {
// Only an accept changes the device, so a read that found no prompt, an unrecognized prompt or
// an unresolved URL owner leaves the launch as the verdict before it already described it.
return { observed: round.observed, launchConfirmation, acceptAttempted };
return {
observed: round.observed,
launchConfirmation,
acceptAttempted,
confirmationStillPresent,
};
}
return { observed: await observe(), launchConfirmation, acceptAttempted };
return {
observed: await observe(),
launchConfirmation,
acceptAttempted,
confirmationStillPresent,
};
}

/**
Expand All @@ -236,6 +257,7 @@ function acceptLeftTheUrlInFlight(round: HeldLaunchRound): boolean {
function acceptWasAttempted(attempt: LaunchConfirmationAttempt): boolean {
return (
attempt.outcome === 'accepted' ||
(attempt.outcome === 'unanswered' && attempt.reason === 'alert-still-present') ||
(attempt.outcome === 'unreadable' && attempt.step === 'alert-accept')
);
}
Expand Down
Loading
Loading