Skip to content
Draft
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
3 changes: 2 additions & 1 deletion apps/ios/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { quality } from './qualityNative';
import { qualityVisibility } from './qualityView';
import { parseFabricProofLink, type FabricProofInput } from './fabricProof';
import { DiagnosticsBoundary } from './DiagnosticsBoundary';
import { ClientControlNative } from './ClientControlNative';

// The chrome is native: one UITabBarController (react-native-screens' tabs, through
// @react-navigation/bottom-tabs' native navigator) holding a UINavigationController per tab
Expand Down Expand Up @@ -244,7 +245,7 @@ function AppContent() {
<NavigationContainer ref={navigationRef} theme={navigationTheme}><Main proofSession /></NavigationContainer>
</StoreProvider>
} /> : <StoreProvider>
<NavigationContainer ref={navigationRef} theme={navigationTheme}><Main /></NavigationContainer>
<NavigationContainer ref={navigationRef} theme={navigationTheme}><ClientControlNative /><Main /></NavigationContainer>
</StoreProvider>}
</SafeAreaProvider>;
}
Expand Down
38 changes: 38 additions & 0 deletions apps/ios/ClientControlIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { StyleSheet, View } from 'react-native';
import { theme } from './theme';
import { Button, T, useTextAppearance } from './ui';

/** The app-internal view of an active client-control grant; not a wire type. */
export type ClientControlGrant = { runId: string };

/**
* Shown while a run may drive this client: what is controlling it, and how to stop it.
* Renders nothing without a grant. Test-mode only until a generated grant source exists.
*/
export function ClientControlIndicator({ grant, onRevoke }: { grant: ClientControlGrant | undefined; onRevoke: () => void }) {
const appearance = useTextAppearance();
if (!grant) return null;
return (
<View
testID="client-control-indicator"
style={[styles.bar, { borderLeftColor: appearance.color(theme.working), backgroundColor: appearance.system ? appearance.colors.grouped : appearance.palette.mantle }]}
>
<View
style={styles.label}
accessible
accessibilityRole="text"
accessibilityLabel={`Remote control active. Run ${grant.runId} can operate this app.`}
>
<T bold color={theme.working}>Controlled by run</T>
<T dim numberOfLines={1}>{grant.runId}</T>
</View>
<Button label="Revoke" color={theme.fault} onPress={onRevoke} style={styles.revoke} />
</View>
);
}

const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', gap: 12, borderLeftWidth: 3, paddingHorizontal: 12, paddingVertical: 8 },
label: { flex: 1, minWidth: 0 },
revoke: { marginTop: 0, alignSelf: 'center' },
});
112 changes: 112 additions & 0 deletions apps/ios/ClientControlNative.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as React from 'react';
import { AppState } from 'react-native';
import Constants from 'expo-constants';
import * as Crypto from 'expo-crypto';
import { base64url } from '@fractal/views';
import type { PhoneNavigateArgs } from '../../clients/typescript/fractal-client/Models.generated';
import type { ClientVisibleState } from '../../clients/typescript/fractal-client/ClientControl.generated';
import { ClientControlController } from './clientControl';
import { ClientControlIndicator } from './ClientControlIndicator';
import { revokeClientControl, signClientResult } from './clientControlWire';
import { committedVisible, subscribeVisible } from './clientControlVisible';
import { signWithDeviceKey } from './modules/fractal-device-key';
import { navigationRef } from './navigation';
import { useStore, type Store } from './store';

// Route keys bind the renderer snapshot to the currently committed navigator leaf.
const visible = (): ClientVisibleState | undefined => {
if (!navigationRef.isReady()) return;
const route = navigationRef.getCurrentRoute();
if (!route || (route.name !== 'NowRoot' && route.name !== 'Attention')) return;
return committedVisible(route.key);
};

const navigationPort = () => {
const pending = new Set<() => void>();
return {
dispose: () => { for (const cancel of pending) cancel(); },
navigate: (args: PhoneNavigateArgs): Promise<void> => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
if (!navigationRef.isReady() || (args.screen !== 'NowRoot' && args.screen !== 'Attention')) {
reject(new Error('Navigation unavailable'));
return promise;
}
const destination = args.screen === 'Attention' ? args.params.id : undefined;
let stopState = () => {};
let stopVisible = () => {};
const finish = (error?: Error) => {
clearTimeout(timeout);
stopState(); stopVisible(); pending.delete(cancel);
if (error) reject(error); else resolve();
};
const cancel = () => finish(new Error('Navigation binding disposed'));
const check = () => {
const state = visible();
if (state?.screen === args.screen && (destination === undefined || state.opened_item?.id === destination)) finish();
};
const timeout = setTimeout(() => finish(new Error('Navigation commit timed out')), 5_000);
pending.add(cancel);
stopState = navigationRef.addListener('state', check);
stopVisible = subscribeVisible(check);
try {
if (args.screen === 'NowRoot') navigationRef.navigate('Now', { screen: 'NowRoot' });
else if (args.screen === 'Attention') navigationRef.navigate('Now', { screen: 'Attention', params: args.params, initial: false });
check();
} catch (error) { finish(error instanceof Error ? error : new Error('Navigation failed')); }
return promise;
},
};
};

type Binding = { controller: ClientControlController; dispose(): void };
type SubscriptionProps = { binding: Binding; feed: NonNullable<Store['feed']>; granted: boolean; reportError: Store['setError'] };
class CommandSubscription extends React.Component<SubscriptionProps> {
private stop: (() => void) | undefined;
private start = () => {
const { binding, feed, granted, reportError } = this.props;
this.stop = feed.followCommands(true, granted, command => {
void binding.controller.handle(command).catch(error => reportError(String(error)));
}, binding.controller.suspend);
};
componentDidMount() { this.start(); }
componentDidUpdate(previous: SubscriptionProps) {
if (previous.binding !== this.props.binding || previous.feed !== this.props.feed || previous.granted !== this.props.granted) {
this.stop?.();
if (previous.binding !== this.props.binding) previous.binding.dispose();
this.start();
}
}
componentWillUnmount() { this.stop?.(); this.props.binding.dispose(); }
render() { return <ControlIndicator controller={this.props.binding.controller} reportError={this.props.reportError} />; }
}
const ControlIndicator = ({ controller, reportError }: { controller: ClientControlController; reportError: Store['setError'] }) => {
const grant = React.useSyncExternalStore(controller.subscribe, controller.getGrant);
return <ClientControlIndicator grant={grant ? { runId: grant.run_id } : undefined} onRevoke={() => { void controller.revoke().catch(error => reportError(String(error))); }} />;
};

export const ClientControlNative = () => {
const { feed, caps, client, controlProfile, setError } = useStore();
const testMode = Constants.expoConfig?.extra?.testClientMode === true;
const granted = caps?.capabilities.some(capability => capability.id === 'phone-commands' && capability.version === 1 && capability.state === 'granted') === true;
const binding = React.useMemo((): Binding | undefined => {
if (!testMode || !feed || !client || !controlProfile) return;
const navigation = navigationPort();
const controller = new ClientControlController({
identity: controlProfile.identity,
foreground: () => AppState.currentState === 'active',
visible,
navigate: navigation.navigate,
result: async (binding, current) => {
if (!current()) return;
if (!globalThis.crypto?.subtle) throw new Error('Client-control signing needs the generated native SHA-256 port');
const result = await signClientResult(binding, controlProfile.signing, signWithDeviceKey, Date.now(), base64url(Crypto.getRandomBytes(16)));
if (!current()) return;
await client.deviceResult(result);
},
revoke: () => revokeClientControl(client, controlProfile.identity.device),
});
return { controller, dispose: () => { navigation.dispose(); controller.dispose(); } };
}, [testMode, feed, client, controlProfile]);
if (!binding || !feed) return null;
return <CommandSubscription binding={binding} feed={feed} granted={granted} reportError={setError} />;
};
18 changes: 18 additions & 0 deletions apps/ios/clientControlProfile.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const file = ts.createSourceFile('store.tsx', readFileSync(new URL('./store.tsx', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const find = (node, predicate) => predicate(node) ? node : ts.forEachChild(node, child => find(child, predicate));
const pairing = find(file, node => ts.isFunctionDeclaration(node) && node.name?.text === 'completePairing');
const declarations = ['testControl', 'signs', 'next'].map(name => find(pairing, node => ts.isVariableDeclaration(node) && node.name.getText(file) === name).getText(file));
const build = new Function('Constants', 'result', 'made', 'pin', 'member', 'pairDraft', 'old', 'gateway', 'routing', ts.transpileModule(declarations.map(value => `const ${value};`).join('\n') + '\nreturn { next, signs };', { compilerOptions: { target: ts.ScriptTarget.ESNext } }).outputText);
for (const flag of [undefined, false, true]) for (const messageScope of [false, true]) test(`pairing key retention: testMode=${flag} messages=${messageScope}`, () => {
const session = { device_id: 'device/fixture', session_actor: 'client-session/fixture', person_id: 'person/fixture', credential: 'fixture-bearer', scopes: messageScope ? ['control.messages'] : ['read.projections'], device_key_chain: [] };
const made = { key: 'p256:fixture', handle: 'fixture-handle', storage: 'secure-enclave' };
const { next, signs } = build({ expoConfig: { extra: { testClientMode: flag } } }, { value: session }, made, 'pin', undefined, null, undefined, 'https://fixture.invalid', {});
assert.equal(signs, messageScope || flag === true);
assert.equal(!!next.signing, messageScope || flag === true);
assert.deepEqual(next.controlIdentity, flag === true ? { device_id: session.device_id, session_actor: session.session_actor } : undefined);
assert.equal(next.credential, session.credential);
});
27 changes: 27 additions & 0 deletions apps/ios/clientControlVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ClientVisibleState } from '../../clients/typescript/fractal-client/ClientControl.generated';
import { report, spaced } from '@fractal/views';
import { markdown } from './markdown';

/** Only report exact text when the normal question renderer preserves it. */
export const observedQuestion = (text: string): string | undefined => {
if (report(text)) return;
const blocks = markdown(spaced(text));
if (blocks.length !== 1 || blocks[0].kind !== 'text') return;
const runs = blocks[0].runs;
return runs.every(run => run.style === 'plain') && runs.map(run => run.text).join('') === text ? text : undefined;
};

// Published only by committed normal renderers, never by command dispatch.
const snapshots = new Map<string, ClientVisibleState>();
const listeners = new Set<() => void>();
const notify = () => { for (const listener of listeners) listener(); };
export const committedVisible = (routeKey: string): ClientVisibleState | undefined => snapshots.get(routeKey);
export const publishVisible = (routeKey: string, state: ClientVisibleState | undefined): void => {
if (state) snapshots.set(routeKey, state);
else snapshots.delete(routeKey);
notify();
};
export const subscribeVisible = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
22 changes: 19 additions & 3 deletions apps/ios/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { AppState, unstable_batchedUpdates } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
import Constants from 'expo-constants';
import type { PersonPairedSession } from '../../clients/typescript/fractal-client';
import { submitAnswer } from './answer';
import { resendBoundMessage, sendBoundMessage, type BoundMessage, type SendOutcome } from './messageSend';
import { API_VERSION, ClientError, FractalClient, isTransient, notApplied, plainError, promptGone, retryTransient, type Arrangement, type ArrangementOperation, type Runtime, type Attention, type AttachmentInput, type Capabilities, type ConversationSearch, type Glass, type Launch, type LaunchVariant, type Mission, type Resource, type ResourceObservation, type Snapshot, type TimelineEntry } from '../../clients/typescript/fractal-client';
Expand Down Expand Up @@ -62,7 +64,7 @@ const SIGNING_KEY = 'st3.device.signing';
// signing key together. Old separate credential/signing entries remain readable until re-pair.
const PROFILE_KEY = 'st3.device.profile';
type PhoneSigning = DeviceKey & { handle?: string };
type PhoneProfile = { url: string; credential: string; signing?: PhoneSigning; personRootFingerprint?: string; routing?: PairingRouting };
type PhoneProfile = { url: string; credential: string; signing?: PhoneSigning; personRootFingerprint?: string; routing?: PairingRouting; controlIdentity?: Pick<PersonPairedSession, 'device_id' | 'session_actor'> };
// Simplified conversations: this phone's own choice, on unless turned off, never synced.
const SIMPLE_KEY = 'st3.conversation.simple';
// Notifications: off by default, stored only on this phone. APNs tokens are never persisted here.
Expand Down Expand Up @@ -204,6 +206,7 @@ function useAppStore(proof?: FabricProfile) {
const [fabricMemberList, setFabricMemberList] = useState<FabricMember[]>([]);
// The members paired on their own, by Keychain key: each one's bearer goes only to that member.
const [ownProfiles, setOwnProfiles] = useState<ReadonlyMap<string, PhoneProfile>>(new Map());
const [controlDeviceProfile, setControlDeviceProfile] = useState<PhoneProfile>();
// The member the person is pairing this phone with, while the pairing form is open.
const [pairingMember, setPairingMember] = useState<FabricMember | null>(null);
const fabricTarget = fabricMemberList.find(member => member.device) ?? fabricMemberList[0] ?? null;
Expand Down Expand Up @@ -247,6 +250,14 @@ function useAppStore(proof?: FabricProfile) {
const clientCredential = credentialFor(selected.route.kind === 'fabric', fabricMember, memberProfile?.credential, credential);
const signingKeyRef = useRef(PROFILE_KEY);
signingKeyRef.current = selected.route.kind === 'fabric' && memberProfile && fabricMember ? memberProfileKey(fabricMember) : PROFILE_KEY;
const controlProfileKey = signingKeyRef.current;
const testClientMode = Constants.expoConfig?.extra?.testClientMode === true;
const controlProfile = useMemo(() => {
const profile = controlProfileKey === PROFILE_KEY ? controlDeviceProfile : ownProfiles.get(controlProfileKey);
if (!testClientMode || proof || profile?.credential !== clientCredential || !profile?.signing
|| !profile.controlIdentity || profile.controlIdentity.session_actor !== caps?.session_actor) return;
return { identity: { device: profile.controlIdentity.device_id, session: profile.controlIdentity.session_actor }, signing: profile.signing };
}, [testClientMode, proof, controlProfileKey, controlDeviceProfile, ownProfiles, clientCredential, caps?.session_actor]);
const routeRef = useRef<Route>(selected.route); routeRef.current = selected.route;
const feedMisses = useRef(0);
// What the phone measures of its own service (see observations.ts): timed where the app already
Expand Down Expand Up @@ -474,6 +485,7 @@ function useAppStore(proof?: FabricProfile) {
try { paired = JSON.parse(saved.value); } catch { setError('The saved device profile could not be read.'); return; }
}
if (saved.status === 'rejected') { setError('Secure credential storage is unavailable on this build.'); return; }
if (Constants.expoConfig?.extra?.testClientMode === true) setControlDeviceProfile(paired);
const gateway = paired?.url ?? (u.status === 'fulfilled' ? u.value : null);
const bearer = paired?.credential ?? (c.status === 'fulfilled' ? c.value : null);
if (paired?.routing) {
Expand Down Expand Up @@ -791,12 +803,14 @@ function useAppStore(proof?: FabricProfile) {
hash: async bytes => new Uint8Array(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes))),
verify: verifyGrantSignature,
});
const signs = result.value.scopes.includes('control.messages');
const testControl = Constants.expoConfig?.extra?.testClientMode === true;
const signs = result.value.scopes.includes('control.messages') || testControl;
const routing = member ? undefined : pairingCommitRouting({ carrier: carrierChoice, members: fabricMemberList }, !!pairDraft?.scanned);
const next: PhoneProfile = { url: gateway, credential: result.value.credential,
...(!member && (pairDraft?.scanned || old?.routing) ? { routing } : {}),
...(pin ? { personRootFingerprint: pin } : {}),
...(signs ? { signing: { key: made.key, storage: made.storage, handle: made.handle, person: result.value.person_id, chain: result.value.device_key_chain! } } : {}),
...(testControl ? { controlIdentity: { device_id: result.value.device_id, session_actor: result.value.session_actor } } : {}),
...(signs ? { signing: { key: made.key, storage: made.storage, handle: made.handle, person: result.value.person_id, chain: result.value.device_key_chain ?? [] } } : {}),
};
// This single secure write is the commit. Before it, the old bearer, key and pin survive.
await SecureStore.setItemAsync(profileKey, JSON.stringify(next), { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY });
Expand All @@ -813,6 +827,7 @@ function useAppStore(proof?: FabricProfile) {
await SecureStore.deleteItemAsync(SIGNING_KEY).catch(() => {});
await clearCachedProjection().catch(() => {});
unstable_batchedUpdates(() => {
if (testControl) setControlDeviceProfile(next);
if (next.routing) {
setFabricMemberList(next.routing.members);
setCarrierChoice(next.routing.carrier);
Expand Down Expand Up @@ -1281,6 +1296,7 @@ function useAppStore(proof?: FabricProfile) {
return {
actor: caps?.session_actor ?? cachedActor.current, cachedAt: cacheSavedAt.current, order, url, urlDraft, setUrlDraft, credential, data, truncated, loadErrors, feed, connectionIssue, caps, snapshot, status, hasSynced, sync, syncStale, missionsLoaded,
error, setError, pairingIssue, setPairingIssue, pairDraft, busy, historicalSessions, conversationCache, draftCache, draftsReady, client,
controlProfile,
renewal: renewals[0]?.offer, renewalIssue, renewalBusy,
gatewayMachineId, gatewayHost, canControlTerminal, loadLists, watchLists, inventoryGranted, actions,
saveDrafts: () => { if (!proof) void localDrafts.save(draftsOwner.current, draftCache.current); },
Expand Down
2 changes: 1 addition & 1 deletion release-notes/ios-client-control.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"compatibility": {"status": "none", "detail": "Normal apps never subscribe to commands; test-client commands fail closed without an authoritative grant source."},
"downtime": {"status": "none", "detail": "No deployment or native installation."},
"manual": {"status": "none", "detail": "No pairing or enrollment performed; test-client flag, lifecycle codegen and native SHA-256 adapter are prerequisites."},
"recovery": {"status": "none", "detail": "No persisted state changes in the command policy layer."}
"recovery": {"status": "changed", "detail": "Test-mode pairing retains its device signing key and optional device/session identity; existing profiles without identity fail closed. Normal pairing is unchanged."}
}
Loading