improve mobile sync security

This commit is contained in:
Boof2015
2026-07-13 19:31:11 -04:00
parent 4e98bd5b8e
commit e496df9bf5
24 changed files with 1489 additions and 313 deletions
+30 -6
View File
@@ -34,6 +34,7 @@ import {
setDesktopRemoteMediaSession,
subscribeDesktopRemoteMediaSessionCommands,
} from '@/services/desktopRemoteMediaSession';
import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import {
getDesktopRemoteConnection,
setDesktopRemoteConnection,
@@ -170,6 +171,8 @@ const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000;
function DesktopSyncAutoTrigger() {
const connectionState = useDesktopRemoteStore((s) => s.connectionState);
const discovered = useDesktopRemoteStore((s) => s.discovered);
const desktopSyncHydrated = useDesktopSyncStore((s) => s.hydrated);
const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
useEffect(() => {
void useDesktopSyncStore.getState().hydrate();
@@ -182,6 +185,8 @@ function DesktopSyncAutoTrigger() {
let startupRetryTimer: ReturnType<typeof setTimeout> | null = null;
const onActive = () => {
void (async () => {
const sync = useDesktopSyncStore.getState();
if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
useDesktopSyncStore.getState().maybeAutoSync('foreground');
@@ -210,31 +215,41 @@ function DesktopSyncAutoTrigger() {
subscription.remove();
if (burstTimer !== null) clearTimeout(burstTimer);
if (startupRetryTimer !== null) clearTimeout(startupRetryTimer);
if (!useDesktopSyncStore.getState().desktopSyncEnabled) {
void useDesktopRemoteStore.getState().stopDiscovery();
}
};
}, []);
}, [desktopSyncEnabled, desktopSyncHydrated]);
// Desktop-initiated "Sync now" pickup: a cheap identity poll while
// foregrounded (the SSE nudge only reaches us while the remote screen's
// stream happens to be connected). fetchDesktopRemoteIdentity swallows
// errors, so a powered-off desktop costs one timed-out request per minute.
useEffect(() => {
if (!desktopSyncHydrated || !desktopSyncEnabled) return;
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
void (async () => {
const sync = useDesktopSyncStore.getState();
if (!sync.hydrated || !sync.desktopSyncEnabled) return;
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
const identity = await fetchDesktopRemoteIdentity(
connection.baseUrl,
connection.certificateFingerprint
);
if (identity?.syncRequestedAt) {
useDesktopSyncStore.getState().handleSyncRequest();
}
})();
}, DESKTOP_SYNC_REQUEST_POLL_MS);
return () => clearInterval(timer);
}, []);
}, [desktopSyncEnabled, desktopSyncHydrated]);
// Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves)
// and trigger a sync.
useEffect(() => {
if (!desktopSyncHydrated || !desktopSyncEnabled) return;
if (discovered.length === 0) return;
void (async () => {
const connection = await getDesktopRemoteConnection();
@@ -242,6 +257,15 @@ function DesktopSyncAutoTrigger() {
const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid);
if (!match) return;
if (match.baseUrl && match.baseUrl !== connection.baseUrl) {
const identity = await fetchDesktopRemoteIdentity(
match.baseUrl,
connection.certificateFingerprint
);
if (!identity || !identityMatchesPinnedConnection(
connection.endpointUuid,
identity.protocolVersion,
identity.endpointUuid
)) return;
const updated = { ...connection, baseUrl: match.baseUrl };
await setDesktopRemoteConnection(updated);
if (useDesktopRemoteStore.getState().connection) {
@@ -250,14 +274,14 @@ function DesktopSyncAutoTrigger() {
}
useDesktopSyncStore.getState().maybeAutoSync('discovery');
})();
}, [discovered]);
}, [desktopSyncEnabled, desktopSyncHydrated, discovered]);
// The remote screen connected — the desktop is definitely reachable.
useEffect(() => {
if (connectionState === 'connected') {
if (desktopSyncHydrated && desktopSyncEnabled && connectionState === 'connected') {
useDesktopSyncStore.getState().maybeAutoSync('connected');
}
}, [connectionState]);
}, [desktopSyncEnabled, desktopSyncHydrated, connectionState]);
return null;
}
+48 -21
View File
@@ -225,7 +225,20 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>();
const pairingParams = useLocalSearchParams<{
pair?: string;
baseUrl?: string;
ticket?: string;
endpointUuid?: string;
fingerprint?: string;
protocolVersion?: string;
}>();
const pairingRoutePair = pairingParams.pair;
const pairingRouteBaseUrl = pairingParams.baseUrl;
const pairingRouteTicket = pairingParams.ticket;
const pairingRouteEndpointUuid = pairingParams.endpointUuid;
const pairingRouteFingerprint = pairingParams.fingerprint;
const pairingRouteProtocolVersion = pairingParams.protocolVersion;
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const initialized = useDesktopRemoteStore((s) => s.initialized);
@@ -253,7 +266,6 @@ export default function DesktopRemoteScreen() {
const [pinInput, setPinInput] = useState('');
const [pinClock, setPinClock] = useState(() => Date.now());
const [manualBaseUrl, setManualBaseUrl] = useState('');
const [manualTicket, setManualTicket] = useState('');
useEffect(() => {
void init();
@@ -267,11 +279,35 @@ export default function DesktopRemoteScreen() {
}, [startDiscovery, stopDiscovery]);
useEffect(() => {
if (typeof pair === 'string' && pair.trim()) {
void pairFromInput(pair);
if (typeof pairingRoutePair === 'string' && pairingRoutePair.trim()) {
void pairFromInput(pairingRoutePair);
router.setParams({ pair: undefined });
return;
}
}, [pair, pairFromInput, router]);
if (
typeof pairingRouteBaseUrl === 'string' && typeof pairingRouteTicket === 'string' &&
typeof pairingRouteEndpointUuid === 'string' && typeof pairingRouteFingerprint === 'string' &&
pairingRouteProtocolVersion === '3'
) {
const url = new URL('astra://desktop-remote');
url.searchParams.set('baseUrl', pairingRouteBaseUrl);
url.searchParams.set('ticket', pairingRouteTicket);
url.searchParams.set('endpointUuid', pairingRouteEndpointUuid);
url.searchParams.set('fingerprint', pairingRouteFingerprint);
url.searchParams.set('protocolVersion', '3');
void pairFromInput(url.toString());
router.setParams({ baseUrl: undefined, ticket: undefined, endpointUuid: undefined, fingerprint: undefined, protocolVersion: undefined });
}
}, [
pairFromInput,
pairingRouteBaseUrl,
pairingRouteEndpointUuid,
pairingRouteFingerprint,
pairingRoutePair,
pairingRouteProtocolVersion,
pairingRouteTicket,
router,
]);
useEffect(() => {
if (!pinPairing) return undefined;
@@ -453,40 +489,31 @@ export default function DesktopRemoteScreen() {
<Text variant="body">Manual fallback</Text>
</View>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Enter the desktop URL and pairing code from Astra Desktop.
Enter the Astra Desktop HTTPS URL, then compare the six-digit code shown on both devices.
</Text>
<TextInput
style={styles.input}
value={manualBaseUrl}
onChangeText={setManualBaseUrl}
placeholder="http://desktop-ip:38402"
placeholder="https://desktop-ip:38402"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TextInput
style={styles.input}
value={manualTicket}
onChangeText={setManualTicket}
placeholder="Pairing code"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
/>
<Pressable android_ripple={ripple.bounded}
style={[
styles.secondaryButton,
(!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled,
!manualBaseUrl.trim() && styles.buttonDisabled,
]}
disabled={!manualBaseUrl.trim() || !manualTicket.trim()}
onPress={() => void pairManual(manualBaseUrl, manualTicket)}
disabled={!manualBaseUrl.trim()}
onPress={() => void pairManual(manualBaseUrl)}
>
<Text
variant="body"
color={manualBaseUrl.trim() && manualTicket.trim() ? colors.textPrimary : colors.textTertiary}
color={manualBaseUrl.trim() ? colors.textPrimary : colors.textTertiary}
>
Pair manually
Request secure PIN
</Text>
</Pressable>
</View>
+9 -9
View File
@@ -140,9 +140,9 @@ export default function DesktopSyncScreen() {
const lastSummary = useDesktopSyncStore((s) => s.lastSummary);
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const errorMessage = useDesktopSyncStore((s) => s.errorMessage);
const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled);
const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled);
const syncNow = useDesktopSyncStore((s) => s.syncNow);
const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled);
const setDesktopSyncEnabled = useDesktopSyncStore((s) => s.setDesktopSyncEnabled);
const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict);
const [connection, setConnection] = useState<DesktopRemoteConnection | null>(null);
@@ -229,8 +229,8 @@ export default function DesktopSyncScreen() {
</Text>
</View>
<Pressable android_ripple={ripple.bounded}
style={[styles.primaryButton, syncing && styles.disabled]}
disabled={syncing}
style={[styles.primaryButton, (syncing || !desktopSyncEnabled) && styles.disabled]}
disabled={syncing || !desktopSyncEnabled}
onPress={() => void syncNow()}
accessibilityLabel="Sync favorites and playlists now"
>
@@ -259,15 +259,15 @@ export default function DesktopSyncScreen() {
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">Sync automatically</Text>
<Text variant="body">Desktop Sync</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
Sync when this desktop appears on the network or the app returns to the
foreground. Manual and desktop-requested syncs always work.
Allow favorites and playlists to sync securely with this desktop on any
network. Turn this off to suppress every sync trigger without forgetting it.
</Text>
</View>
<HapticSwitch
value={autoSyncEnabled}
onValueChange={(value) => void setAutoSyncEnabled(value)}
value={desktopSyncEnabled}
onValueChange={(value) => void setDesktopSyncEnabled(value)}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
+160 -164
View File
@@ -1,4 +1,8 @@
import * as Device from 'expo-device';
import {
AstraDesktopTransport,
desktopPinnedTransportAvailable,
} from '../../modules/astra-desktop-transport';
import type {
DesktopRemoteControlCommand,
DesktopRemoteIdentity,
@@ -26,7 +30,7 @@ interface JsonRequestOptions {
token?: string | null;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
fingerprint: string;
}
export class DesktopRemoteHttpError extends Error {
@@ -40,52 +44,26 @@ export class DesktopRemoteHttpError extends Error {
}
}
function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const abort = () => controller.abort();
if (parentSignal) {
if (parentSignal.aborted) controller.abort();
else parentSignal.addEventListener('abort', abort, { once: true });
}
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
},
{ once: true }
);
return controller.signal;
}
async function fetchJson<T>(
baseUrl: string,
path: string,
options: JsonRequestOptions = {}
options: JsonRequestOptions
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
let body: string | undefined;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json; charset=utf-8';
body = JSON.stringify(options.body);
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
if (options.token) headers.Authorization = `Bearer ${options.token}`;
const response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? (body ? 'POST' : 'GET'),
headers,
const body = options.body === undefined ? null : JSON.stringify(options.body);
const response = await AstraDesktopTransport.requestJson(
baseUrl,
path,
options.method ?? (body ? 'POST' : 'GET'),
body,
cache: 'no-store',
signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
options.token ?? null,
options.fingerprint,
options.timeoutMs ?? REQUEST_TIMEOUT_MS
);
const payload = response.body ? JSON.parse(response.body) as unknown : null;
if (response.status < 200 || response.status >= 300) {
const message =
payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
@@ -127,9 +105,12 @@ export function defaultDesktopRemoteDeviceName(): string {
return model ? `${model} Remote` : 'Astra Mobile Remote';
}
export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<DesktopRemoteIdentity | null> {
export async function fetchDesktopRemoteIdentity(
baseUrl: string,
certificateFingerprint: string
): Promise<DesktopRemoteIdentity | null> {
try {
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity');
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity', { fingerprint: certificateFingerprint });
return normalizeIdentity(payload);
} catch {
return null;
@@ -139,9 +120,11 @@ export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<Deskt
export async function claimDesktopRemotePairingTicket(
baseUrl: string,
ticket: string,
certificateFingerprint: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePairingClaim> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/claim', {
fingerprint: certificateFingerprint,
method: 'POST',
body: {
ticket,
@@ -163,58 +146,67 @@ export async function requestDesktopRemotePinPairing(
baseUrl: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePinPairingRequest> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-request', {
method: 'POST',
body: {
deviceName,
clientLabel: clientLabel(),
},
});
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
const payload = await AstraDesktopTransport.beginPinPairing(baseUrl, deviceName, clientLabel());
return {
requestId: String(payload.requestId ?? ''),
pollToken: String(payload.pollToken ?? ''),
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
deviceName: String(payload.deviceName ?? deviceName),
clientLabel: String(payload.clientLabel ?? clientLabel()),
identity: normalizeIdentity(payload.identity ?? payload),
attemptId: payload.attemptId,
requestId: payload.requestId,
pollToken: '',
expiresAt: payload.expiresAt,
deviceName,
clientLabel: clientLabel(),
identity: { endpointUuid: null, desktopName: payload.desktopName, protocolVersion: 3 },
certificateFingerprint: payload.certificateFingerprint,
protocolVersion: 3,
};
}
export async function confirmDesktopRemotePinPairing(
baseUrl: string,
requestId: string,
attemptId: string,
pin: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/pin-confirm', {
method: 'POST',
body: {
requestId,
pin,
},
});
const state = typeof payload.state === 'string' ? payload.state : 'approved';
if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) {
throw new Error('Secure Desktop Remote transport is unavailable on this device.');
}
const payload = await AstraDesktopTransport.confirmPinPairing(attemptId, pin);
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
state: 'approved',
expiresAt: 0,
token: payload.controlToken,
controlToken: payload.controlToken,
syncToken: payload.syncToken,
issuedAt: payload.issuedAt,
scopes: ['control', 'sync'],
certificateFingerprint: payload.certificateFingerprint,
deviceId: payload.deviceId,
identity: normalizeIdentity(JSON.parse(payload.identityJson)),
};
}
export async function fetchDesktopRemotePairingStatus(
baseUrl: string,
pollToken: string
pollToken: string,
certificateFingerprint: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(
baseUrl,
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`,
{ fingerprint: certificateFingerprint }
);
const state = typeof payload.state === 'string' ? payload.state : 'pending';
return {
state: state as DesktopRemotePairingStatus['state'],
expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
token: typeof payload.token === 'string' ? payload.token : undefined,
controlToken: typeof payload.controlToken === 'string' ? payload.controlToken : undefined,
syncToken: typeof payload.syncToken === 'string' ? payload.syncToken : undefined,
issuedAt: typeof payload.issuedAt === 'number' ? payload.issuedAt : undefined,
scopes: Array.isArray(payload.scopes)
? payload.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
: undefined,
certificateFingerprint: typeof payload.certificateFingerprint === 'string' ? payload.certificateFingerprint : undefined,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
@@ -223,24 +215,27 @@ export async function fetchDesktopRemotePairingStatus(
export async function fetchDesktopRemoteNowPlaying(
baseUrl: string,
token: string,
certificateFingerprint: string,
inlineArtwork = false
): Promise<DesktopRemoteNowPlayingSnapshot> {
return fetchJson<DesktopRemoteNowPlayingSnapshot>(
baseUrl,
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
{ token }
{ token, fingerprint: certificateFingerprint }
);
}
export async function sendDesktopRemoteControl(
baseUrl: string,
token: string,
certificateFingerprint: string,
command: DesktopRemoteControlCommand,
time?: number
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: command === 'seek' ? { command, time } : { command },
});
}
@@ -248,38 +243,87 @@ export async function sendDesktopRemoteControl(
export async function sendDesktopRemotePlayQueueItem(
baseUrl: string,
token: string,
certificateFingerprint: string,
queueId: string
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: { command: 'play-queue-item', queueId },
});
}
export async function fetchDesktopRemoteQueue(
baseUrl: string,
token: string
token: string,
certificateFingerprint: string
): Promise<DesktopRemoteQueueSnapshot> {
return fetchJson<DesktopRemoteQueueSnapshot>(baseUrl, '/v1/queue', { token });
return fetchJson<DesktopRemoteQueueSnapshot>(baseUrl, '/v1/queue', { token, fingerprint: certificateFingerprint });
}
export interface DesktopRemoteSessionInfo {
deviceId: string;
scopes: ('control' | 'sync')[];
issuedAt: number;
rotatedAt: number;
rotateAfter: number;
rotateRequiredAt: number;
expiresAt: number;
rotationRequired: boolean;
usingPreviousCredential: boolean;
}
export interface DesktopRemoteRotatedCredentials {
controlToken: string;
syncToken: string;
issuedAt: number;
previousValidUntil: number;
rotateAfter: number;
}
export async function inspectDesktopRemoteSession(
baseUrl: string,
controlToken: string,
certificateFingerprint: string
): Promise<DesktopRemoteSessionInfo> {
return fetchJson<DesktopRemoteSessionInfo>(baseUrl, '/v1/session', {
token: controlToken,
fingerprint: certificateFingerprint,
});
}
export async function rotateDesktopRemoteCredentials(
baseUrl: string,
controlToken: string,
certificateFingerprint: string
): Promise<DesktopRemoteRotatedCredentials> {
return fetchJson<DesktopRemoteRotatedCredentials>(baseUrl, '/v1/session/rotate', {
method: 'POST',
token: controlToken,
fingerprint: certificateFingerprint,
body: {},
});
}
// ── Favorites/playlists LAN sync (protocolVersion >= 2) ──────────────────────
// Sync payloads can carry thousands of favorites/playlist entries, so both
// calls get generous timeouts compared to the 8 s control default.
export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 });
export async function fetchDesktopSyncState(baseUrl: string, token: string, certificateFingerprint: string): Promise<DesktopSyncState> {
return fetchJson<DesktopSyncState>(baseUrl, '/v1/sync/state', { token, fingerprint: certificateFingerprint, timeoutMs: 30_000 });
}
export async function postDesktopSyncApply(
baseUrl: string,
token: string,
certificateFingerprint: string,
payload: DesktopSyncApplyPayload
): Promise<DesktopSyncApplyResult> {
return fetchJson<DesktopSyncApplyResult>(baseUrl, '/v1/sync/apply', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: payload,
timeoutMs: 60_000,
});
@@ -288,11 +332,13 @@ export async function postDesktopSyncApply(
export async function postDesktopSyncConflicts(
baseUrl: string,
token: string,
certificateFingerprint: string,
payload: DesktopSyncConflictReportPayload
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', {
method: 'POST',
token,
fingerprint: certificateFingerprint,
body: payload,
});
}
@@ -307,103 +353,53 @@ export type DesktopRemoteSseHandlers = {
onError?: (error: unknown) => void;
};
function processSseChunk(
buffer: { value: string },
chunk: string,
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void,
onQueue?: (queue: DesktopRemoteQueueSnapshot) => void,
onSyncRequest?: () => void
): void {
buffer.value += chunk.replace(/\r/g, '');
let boundary = buffer.value.indexOf('\n\n');
while (boundary !== -1) {
const raw = buffer.value.slice(0, boundary);
buffer.value = buffer.value.slice(boundary + 2);
let eventName = 'message';
const data: string[] = [];
for (const line of raw.split('\n')) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (eventName === 'now-playing' && data.length > 0) {
try {
onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot);
} catch {
// Ignore a malformed event; polling/reconnect will correct the UI.
}
} else if (eventName === 'queue' && data.length > 0 && onQueue) {
try {
onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot);
} catch {
// Ignore a malformed event; the on-demand queue fetch will correct it.
}
} else if (eventName === 'sync-request' && onSyncRequest) {
onSyncRequest();
}
boundary = buffer.value.indexOf('\n\n');
}
}
export function startDesktopRemoteEventStream(
baseUrl: string,
token: string,
certificateFingerprint: string,
handlers: DesktopRemoteSseHandlers
): () => void {
const controller = new AbortController();
let closed = false;
let streamId: string | null = null;
const transport = AstraDesktopTransport;
if (!transport) {
handlers.onError?.(new Error('Secure Desktop Remote transport is unavailable on this device.'));
return () => {};
}
void (async () => {
try {
const response = await fetch(`${baseUrl}/v1/events`, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
signal: controller.signal,
});
if (response.status === 401) {
handlers.onUnauthorized();
return;
}
const body = response.body as unknown as {
getReader?: () => {
read: () => Promise<{ done: boolean; value?: Uint8Array }>;
};
} | null;
if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`);
const reader = body.getReader();
const decoder = new TextDecoder();
const buffer = { value: '' };
while (!closed) {
const next = await reader.read();
if (next.done) break;
if (next.value) {
processSseChunk(
buffer,
decoder.decode(next.value, { stream: true }),
handlers.onSnapshot,
handlers.onQueue,
handlers.onSyncRequest
);
}
}
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest);
if (!closed) handlers.onDisconnect();
} catch (error) {
if (closed || controller.signal.aborted) return;
handlers.onError?.(error);
const eventSubscription = transport.addListener('onDesktopTransportSse', (event) => {
if (closed || !streamId || event.streamId !== streamId) return;
if (event.event === 'now-playing') {
try { handlers.onSnapshot(JSON.parse(event.data) as DesktopRemoteNowPlayingSnapshot); } catch { /* ignore */ }
} else if (event.event === 'queue' && handlers.onQueue) {
try { handlers.onQueue(JSON.parse(event.data) as DesktopRemoteQueueSnapshot); } catch { /* ignore */ }
} else if (event.event === 'sync-request') {
handlers.onSyncRequest?.();
}
});
const closedSubscription = transport.addListener('onDesktopTransportClosed', (event) => {
if (closed || !streamId || event.streamId !== streamId) return;
if (event.unauthorized) handlers.onUnauthorized();
else if (event.message) {
handlers.onError?.(new Error(event.message));
handlers.onDisconnect();
}
})();
});
void transport.startEventStream(baseUrl, token, certificateFingerprint).then((id) => {
streamId = id;
if (closed) transport.stopEventStream(id);
}).catch((error) => {
if (closed) return;
handlers.onError?.(error);
handlers.onDisconnect();
});
return () => {
if (closed) return;
closed = true;
controller.abort();
eventSubscription.remove();
closedSubscription.remove();
if (streamId) transport.stopEventStream(streamId);
};
}
+82 -13
View File
@@ -1,25 +1,70 @@
import * as SecureStore from 'expo-secure-store';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
const CONNECTION_KEY = 'desktop_remote_connection_v1';
const TOKEN_KEY = 'desktop_remote_token_v1';
const CONNECTION_KEY = 'desktop_remote_connection_v3';
const CREDENTIALS_KEY = 'desktop_remote_credentials_v3';
const LEGACY_CONNECTION_KEY = 'desktop_remote_connection_v1';
const LEGACY_TOKEN_KEY = 'desktop_remote_token_v1';
const SECURITY_UPGRADE_NOTICE_KEY = 'desktop_remote_security_upgrade_notice_v3';
export interface DesktopRemoteCredentials {
controlToken: string;
syncToken: string;
issuedAt: number;
}
let legacyMigrationPromise: Promise<void> | null = null;
async function migrateLegacyPairing(): Promise<void> {
legacyMigrationPromise ??= (async () => {
const [legacyConnection, legacyToken] = await Promise.all([
SecureStore.getItemAsync(LEGACY_CONNECTION_KEY),
SecureStore.getItemAsync(LEGACY_TOKEN_KEY),
]);
if (!legacyConnection && !legacyToken) return;
let label = 'Astra Desktop';
try {
const parsed = legacyConnection ? JSON.parse(legacyConnection) as Record<string, unknown> : null;
if (parsed && typeof parsed.desktopName === 'string' && parsed.desktopName.trim()) label = parsed.desktopName.trim();
} catch {
// Only a non-secret label is retained.
}
await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CONNECTION_KEY),
SecureStore.deleteItemAsync(LEGACY_TOKEN_KEY),
SecureStore.setItemAsync(SECURITY_UPGRADE_NOTICE_KEY, label),
]);
})();
await legacyMigrationPromise;
}
export async function getDesktopRemoteConnection(): Promise<DesktopRemoteConnection | null> {
await migrateLegacyPairing();
const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DesktopRemoteConnection>;
if (!parsed || typeof parsed !== 'object') return null;
if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null;
if (
typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string' ||
!parsed.baseUrl.startsWith('https://') || typeof parsed.certificateFingerprint !== 'string' ||
parsed.protocolVersion !== 3
) return null;
const scopes = Array.isArray(parsed.scopes)
? parsed.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync')
: [];
if (!scopes.includes('control') || !scopes.includes('sync')) return null;
return {
id: parsed.id,
baseUrl: parsed.baseUrl,
certificateFingerprint: parsed.certificateFingerprint,
scopes,
credentialIssuedAt: typeof parsed.credentialIssuedAt === 'number' ? parsed.credentialIssuedAt : parsed.pairedAt ?? Date.now(),
credentialRotatedAt: typeof parsed.credentialRotatedAt === 'number' ? parsed.credentialRotatedAt : parsed.pairedAt ?? Date.now(),
securityUpgradeState: 'none',
endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null,
desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null,
protocolVersion:
typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion)
? parsed.protocolVersion
: 1,
protocolVersion: 3,
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
pairedAt:
typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
@@ -39,18 +84,42 @@ export async function setDesktopRemoteConnection(connection: DesktopRemoteConnec
await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
}
export async function getDesktopRemoteToken(): Promise<string | null> {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
return token && token.trim() ? token.trim() : null;
export async function getDesktopRemoteCredentials(): Promise<DesktopRemoteCredentials | null> {
const raw = await SecureStore.getItemAsync(CREDENTIALS_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DesktopRemoteCredentials>;
if (!parsed.controlToken?.trim() || !parsed.syncToken?.trim() || !Number.isFinite(parsed.issuedAt)) return null;
return { controlToken: parsed.controlToken.trim(), syncToken: parsed.syncToken.trim(), issuedAt: parsed.issuedAt! };
} catch {
return null;
}
}
export async function setDesktopRemoteToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
export async function setDesktopRemoteCredentials(credentials: DesktopRemoteCredentials): Promise<void> {
await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials));
}
export async function getDesktopRemoteToken(): Promise<string | null> {
return (await getDesktopRemoteCredentials())?.controlToken ?? null;
}
export async function getDesktopRemoteSyncToken(): Promise<string | null> {
return (await getDesktopRemoteCredentials())?.syncToken ?? null;
}
export async function getDesktopRemoteSecurityUpgradeNotice(): Promise<string | null> {
await migrateLegacyPairing();
return SecureStore.getItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
}
export async function clearDesktopRemoteSecurityUpgradeNotice(): Promise<void> {
await SecureStore.deleteItemAsync(SECURITY_UPGRADE_NOTICE_KEY);
}
export async function clearDesktopRemotePairing(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(CONNECTION_KEY),
SecureStore.deleteItemAsync(TOKEN_KEY),
SecureStore.deleteItemAsync(CREDENTIALS_KEY),
]);
}
+27 -12
View File
@@ -6,13 +6,16 @@ import {
parseDesktopRemotePairingInput,
} from './desktopRemotePairing.ts';
test('parses current PWA pairing URL format', () => {
assert.deepEqual(
const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';
test('rejects browser and legacy HTTP pairing links on the native client', () => {
assert.equal(
parseDesktopRemotePairingInput('https://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
null
);
assert.equal(
parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
{
baseUrl: 'http://192.168.1.20:38402',
ticket: 'abcDEF_1234567890',
}
null
);
});
@@ -21,21 +24,33 @@ test('parses native pairing links without accepting missing base URLs', () => {
parseDesktopRemotePairingInput(
'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
),
null
);
assert.deepEqual(
parseDesktopRemotePairingInput(
`astra://desktop-remote?baseUrl=https%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890&endpointUuid=endpoint-1&fingerprint=${encodeURIComponent(fingerprint)}&protocolVersion=3`
),
{
baseUrl: 'http://10.0.0.8:38402',
baseUrl: 'https://10.0.0.8:38402',
ticket: 'abcDEF_1234567890',
endpointUuid: 'endpoint-1',
protocolVersion: 3,
certificateFingerprint: fingerprint,
}
);
assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null);
});
test('manual pairing requires a reachable http base URL and ticket-shaped code', () => {
assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), {
baseUrl: 'http://desktop.local:38402',
test('manual ticket parsing requires HTTPS, endpoint identity, and fingerprint', () => {
assert.deepEqual(parseDesktopRemoteManualInput('https://desktop.local:38402/remote/', 'abcDEF_1234567890', 'endpoint-1', fingerprint), {
baseUrl: 'https://desktop.local:38402',
ticket: 'abcDEF_1234567890',
endpointUuid: 'endpoint-1',
protocolVersion: 3,
certificateFingerprint: fingerprint,
});
assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'abcDEF_1234567890', 'endpoint-1', fingerprint), null);
assert.equal(parseDesktopRemoteManualInput('https://desktop.local:38402', 'short', 'endpoint-1', fingerprint), null);
});
test('PIN pairing accepts only six digits with optional spacing', () => {
+37 -7
View File
@@ -8,13 +8,19 @@ function normalizeBaseUrl(value: string): string | null {
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}
function normalizeFingerprint(value: string): string | null {
const compact = value.trim().toUpperCase().replace(/[^0-9A-F]/g, '');
if (!/^[0-9A-F]{64}$/.test(compact)) return null;
return compact.match(/.{2}/g)?.join(':') ?? null;
}
function extractPairFromUrl(url: URL): string {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
const hashTicket = hashParams.get('pair')?.trim();
@@ -31,22 +37,46 @@ export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemoteP
if (parsed.protocol === 'astra:') {
const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? '');
const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? '';
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
const endpointUuid = parsed.searchParams.get('endpointUuid')?.trim() ?? '';
const fingerprint = normalizeFingerprint(parsed.searchParams.get('fingerprint') ?? '');
const protocolVersion = Number(parsed.searchParams.get('protocolVersion'));
return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
: null;
}
const ticket = extractPairFromUrl(parsed);
const baseUrl = normalizeBaseUrl(parsed.origin);
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
const hashParams = new URLSearchParams(parsed.hash.replace(/^#/, ''));
const endpointUuid = hashParams.get('endpointUuid')?.trim() ?? parsed.searchParams.get('endpointUuid')?.trim() ?? '';
const fingerprint = normalizeFingerprint(hashParams.get('fingerprint') ?? parsed.searchParams.get('fingerprint') ?? '');
const protocolVersion = Number(hashParams.get('protocolVersion') ?? parsed.searchParams.get('protocolVersion'));
return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket)
? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint }
: null;
} catch {
return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
return null;
}
}
export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null {
export function parseDesktopRemoteManualInput(
baseUrl: string,
ticket: string,
endpointUuid: string,
certificateFingerprint: string
): DesktopRemotePairingInput | null {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const normalizedTicket = ticket.trim();
if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket };
const normalizedEndpointUuid = endpointUuid.trim();
const normalizedFingerprint = normalizeFingerprint(certificateFingerprint);
if (!normalizedBaseUrl || !normalizedEndpointUuid || !normalizedFingerprint || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
return {
baseUrl: normalizedBaseUrl,
ticket: normalizedTicket,
endpointUuid: normalizedEndpointUuid,
protocolVersion: 3,
certificateFingerprint: normalizedFingerprint,
};
}
export function normalizeDesktopRemotePinInput(pin: string): string | null {
+58
View File
@@ -0,0 +1,58 @@
import {
DesktopRemoteHttpError,
inspectDesktopRemoteSession,
rotateDesktopRemoteCredentials,
} from '@/services/desktopRemoteClient';
import {
getDesktopRemoteCredentials,
setDesktopRemoteConnection,
setDesktopRemoteCredentials,
type DesktopRemoteCredentials,
} from '@/services/desktopRemoteCredentials';
import type { DesktopRemoteConnection } from '@/types/desktopRemote';
export async function ensureDesktopRemoteCredentialsFresh(
connection: DesktopRemoteConnection,
suppliedCredentials?: DesktopRemoteCredentials
): Promise<{ connection: DesktopRemoteConnection; credentials: DesktopRemoteCredentials }> {
const credentials = suppliedCredentials ?? await getDesktopRemoteCredentials();
if (!credentials) throw new Error('Desktop credentials are unavailable. Pair again.');
let shouldRotate = false;
try {
const session = await inspectDesktopRemoteSession(
connection.baseUrl,
credentials.controlToken,
connection.certificateFingerprint
);
shouldRotate = session.usingPreviousCredential || Date.now() >= session.rotateAfter;
} catch (error) {
if (!(error instanceof DesktopRemoteHttpError) || error.status !== 401) throw error;
shouldRotate = true;
}
if (!shouldRotate) return { connection, credentials };
const rotated = await rotateDesktopRemoteCredentials(
connection.baseUrl,
credentials.controlToken,
connection.certificateFingerprint
);
if (!rotated.controlToken?.trim() || !rotated.syncToken?.trim()) {
throw new Error('Desktop returned incomplete rotated credentials.');
}
const nextCredentials: DesktopRemoteCredentials = {
controlToken: rotated.controlToken,
syncToken: rotated.syncToken,
issuedAt: rotated.issuedAt,
};
const nextConnection: DesktopRemoteConnection = {
...connection,
credentialIssuedAt: rotated.issuedAt,
credentialRotatedAt: rotated.issuedAt,
lastConnectedAt: Date.now(),
};
await Promise.all([
setDesktopRemoteConnection(nextConnection),
setDesktopRemoteCredentials(nextCredentials),
]);
return { connection: nextConnection, credentials: nextCredentials };
}
+20 -14
View File
@@ -62,10 +62,11 @@ import {
postDesktopSyncConflicts,
} from './desktopRemoteClient';
import {
getDesktopRemoteCredentials,
getDesktopRemoteConnection,
getDesktopRemoteToken,
setDesktopRemoteConnection,
getDesktopRemoteSyncToken,
} from './desktopRemoteCredentials';
import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession';
const CLOCK_SKEW_WARN_MS = 5 * 60_000;
@@ -160,10 +161,10 @@ export async function runDesktopSync(): Promise<DesktopSyncSummary> {
// Report remaining conflicts (best-effort — older desktops 404 here).
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
const token = await getDesktopRemoteSyncToken();
if (connection && token) {
try {
await postDesktopSyncConflicts(connection.baseUrl, token, {
await postDesktopSyncConflicts(connection.baseUrl, token, connection.certificateFingerprint, {
syncFormat: DESKTOP_SYNC_FORMAT,
conflicts: summary.conflicts.map((conflict) => ({
kind: conflict.kind,
@@ -194,22 +195,22 @@ async function runDesktopSyncOnce(): Promise<{
pendingResolutions: DesktopSyncPendingResolution[];
}> {
const startedAt = Date.now();
const connection = await getDesktopRemoteConnection();
const token = await getDesktopRemoteToken();
if (!connection || !token) {
let connection = await getDesktopRemoteConnection();
const credentials = await getDesktopRemoteCredentials();
if (!connection || !credentials) {
throw new Error('No paired desktop.');
}
const fresh = await ensureDesktopRemoteCredentialsFresh(connection, credentials);
connection = fresh.connection;
const token = fresh.credentials.syncToken;
// The stored protocolVersion predates any desktop upgrade — re-check live and
// persist the refreshed value before gating.
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl, connection.certificateFingerprint);
if (!identity) {
throw new Error('Desktop is unreachable.');
}
if (identity.protocolVersion !== connection.protocolVersion) {
await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion });
}
if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) {
if (identity.protocolVersion !== DESKTOP_SYNC_MIN_PROTOCOL_VERSION || identity.endpointUuid !== connection.endpointUuid) {
throw new DesktopSyncUnsupportedError();
}
@@ -218,7 +219,7 @@ async function runDesktopSyncOnce(): Promise<{
const index = buildImportIndex(await getAllTracks(db));
await resolvePendingFavorites(db, index);
const local = await getLocalSyncState(db);
const remote = await fetchDesktopSyncState(connection.baseUrl, token);
const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint);
if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) {
throw new DesktopSyncUnsupportedError();
}
@@ -556,7 +557,12 @@ async function runDesktopSyncOnce(): Promise<{
payload.playlistUpserts.length > 0 ||
payload.playlistDeletes.length > 0;
if (hasDiff) {
const result = await postDesktopSyncApply(connection.baseUrl, token, payload);
const result = await postDesktopSyncApply(
connection.baseUrl,
token,
connection.certificateFingerprint,
payload
);
summary.pushedToDesktop = true;
summary.favoritesAdded += result.favorites.added;
summary.favoritesPending += result.favorites.pending;
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canStartDesktopSync,
decideDesktopSyncEnabled,
identityMatchesPinnedConnection,
} from './desktopSyncPolicy.ts';
test('legacy automatic-sync migration preserves explicit off and defaults to on', () => {
assert.equal(decideDesktopSyncEnabled(null, '0'), false);
assert.equal(decideDesktopSyncEnabled(null, '1'), true);
assert.equal(decideDesktopSyncEnabled(null, null), true);
assert.equal(decideDesktopSyncEnabled('0', '1'), false);
});
test('master switch gates manual, automatic, and follow-up starts', () => {
assert.equal(canStartDesktopSync(false, 'idle'), false);
assert.equal(canStartDesktopSync(false, 'error'), false);
assert.equal(canStartDesktopSync(true, 'syncing'), false);
assert.equal(canStartDesktopSync(true, 'idle'), true);
});
test('discovered address requires v3 identity and the paired endpoint UUID', () => {
assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'endpoint-1'), true);
assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'impostor'), false);
assert.equal(identityMatchesPinnedConnection('endpoint-1', 2, 'endpoint-1'), false);
});
+23
View File
@@ -0,0 +1,23 @@
export function decideDesktopSyncEnabled(
masterSetting: string | null,
legacyAutoSetting: string | null
): boolean {
if (masterSetting !== null) return masterSetting !== '0';
// Conservative migration: an explicit legacy 0 stays off; 1 or absence is on.
return legacyAutoSetting !== '0';
}
export function canStartDesktopSync(
enabled: boolean,
status: 'idle' | 'syncing' | 'error'
): boolean {
return enabled && status !== 'syncing';
}
export function identityMatchesPinnedConnection(
expectedEndpointUuid: string | null,
protocolVersion: number,
observedEndpointUuid: string | null
): boolean {
return protocolVersion === 3 && Boolean(expectedEndpointUuid) && observedEndpointUuid === expectedEndpointUuid;
}
+167 -38
View File
@@ -12,7 +12,6 @@ import {
fetchDesktopRemoteNowPlaying,
fetchDesktopRemotePairingStatus,
fetchDesktopRemoteQueue,
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
requestDesktopRemotePinPairing,
sendDesktopRemoteControl,
@@ -22,14 +21,18 @@ import {
import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
import {
clearDesktopRemotePairing,
clearDesktopRemoteSecurityUpgradeNotice,
getDesktopRemoteCredentials,
getDesktopRemoteConnection,
getDesktopRemoteToken,
getDesktopRemoteSecurityUpgradeNotice,
setDesktopRemoteConnection,
setDesktopRemoteToken,
setDesktopRemoteCredentials,
} from '@/services/desktopRemoteCredentials';
import { openLibraryDb } from '@/db/database';
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import type {
DesktopRemoteConnection,
@@ -58,11 +61,14 @@ interface PairingAttempt {
baseUrl: string;
pollToken: string;
expiresAt: number;
certificateFingerprint: string;
endpointUuid: string;
}
interface PinPairingAttempt {
baseUrl: string;
requestId: string;
attemptId: string;
expiresAt: number;
desktopName: string | null;
}
@@ -89,7 +95,7 @@ interface DesktopRemoteStore {
requestPinPairing: (baseUrl: string) => Promise<void>;
confirmPinPairing: (pin: string) => Promise<void>;
pairFromInput: (input: string) => Promise<void>;
pairManual: (baseUrl: string, ticket: string) => Promise<void>;
pairManual: (baseUrl: string) => Promise<void>;
connect: () => Promise<boolean>;
reconnect: () => Promise<void>;
disconnect: () => void;
@@ -148,6 +154,13 @@ function errorMessage(error: unknown): string {
return 'Desktop remote request failed.';
}
function isDesktopCertificateError(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : '';
return message.includes('certificate changed') ||
message.includes('certificate mismatch') ||
message.includes('pinning failure');
}
function mergeSnapshotArtwork(
previous: DesktopRemoteNowPlayingSnapshot | null,
next: DesktopRemoteNowPlayingSnapshot
@@ -168,25 +181,39 @@ function mergeSnapshotArtwork(
async function persistConnectedDesktop(
baseUrl: string,
token: string,
credentials: { controlToken: string; syncToken: string; issuedAt: number },
certificateFingerprint: string,
deviceId: string | null,
identity: DesktopRemoteIdentity | null
identity: DesktopRemoteIdentity | null,
expectedEndpointUuid?: string
): Promise<DesktopRemoteConnection> {
const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl));
const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl, certificateFingerprint));
if (!resolvedIdentity || resolvedIdentity.protocolVersion !== 3) {
throw new Error('Desktop does not support secure protocol v3.');
}
if (expectedEndpointUuid && resolvedIdentity.endpointUuid !== expectedEndpointUuid) {
throw new Error('Desktop identity does not match the pairing QR.');
}
const now = Date.now();
const connection: DesktopRemoteConnection = {
id: stableConnectionId(resolvedIdentity, baseUrl),
baseUrl,
certificateFingerprint,
scopes: ['control', 'sync'],
credentialIssuedAt: credentials.issuedAt,
credentialRotatedAt: credentials.issuedAt,
securityUpgradeState: 'none',
endpointUuid: resolvedIdentity?.endpointUuid ?? null,
desktopName: displayName(resolvedIdentity, baseUrl),
protocolVersion: resolvedIdentity?.protocolVersion ?? 1,
protocolVersion: 3,
deviceId,
pairedAt: now,
lastConnectedAt: now,
};
await Promise.all([
setDesktopRemoteConnection(connection),
setDesktopRemoteToken(token),
setDesktopRemoteCredentials(credentials),
clearDesktopRemoteSecurityUpgradeNotice(),
]);
return connection;
}
@@ -199,7 +226,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const requestKey = `${connection.id}:${track.id}`;
if (inlineArtworkRequestKey === requestKey) return;
inlineArtworkRequestKey = requestKey;
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true).then(
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint, true).then(
(inlineSnapshot) => {
inlineArtworkRequestKey = null;
set((state) => ({
@@ -219,7 +246,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
snapshotPollTimer = setInterval(() => {
const { connection, token, connectionState } = get();
if (!connection || !token || connectionState === 'connecting') return;
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint).then(
(snapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
@@ -233,6 +260,10 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
refreshInlineArtwork();
},
(error) => {
if (isDesktopCertificateError(error)) {
void get().forget().then(() => set({ errorMessage: 'Desktop certificate changed—pair again.' }));
return;
}
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
void get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -262,18 +293,26 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const pairing = get().pairing;
if (!pairing) return;
try {
const status = await fetchDesktopRemotePairingStatus(pairing.baseUrl, pairing.pollToken);
if (status.state === 'approved' && status.token?.trim()) {
const status = await fetchDesktopRemotePairingStatus(
pairing.baseUrl,
pairing.pollToken,
pairing.certificateFingerprint
);
const controlToken = status.controlToken?.trim() || status.token?.trim();
const syncToken = status.syncToken?.trim();
if (status.state === 'approved' && controlToken && syncToken) {
clearPairingPoll();
const connection = await persistConnectedDesktop(
pairing.baseUrl,
status.token.trim(),
{ controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() },
pairing.certificateFingerprint,
status.deviceId ?? null,
status.identity ?? null
status.identity ?? null,
pairing.endpointUuid
);
set({
connection,
token: status.token.trim(),
token: controlToken,
pairing: null,
pinPairing: null,
connectionState: 'connecting',
@@ -321,7 +360,12 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
}
};
const claimPairing = async (baseUrl: string, ticket: string) => {
const claimPairing = async (
baseUrl: string,
ticket: string,
certificateFingerprint: string,
endpointUuid: string
) => {
clearPairingPoll();
stopRealtime();
set({
@@ -336,6 +380,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const claim = await claimDesktopRemotePairingTicket(
baseUrl,
ticket,
certificateFingerprint,
defaultDesktopRemoteDeviceName()
);
if (!claim.pollToken) throw new Error('Desktop did not return a pairing poll token.');
@@ -345,6 +390,8 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
baseUrl,
pollToken: claim.pollToken,
expiresAt: claim.expiresAt,
certificateFingerprint,
endpointUuid,
},
message: 'Approve this phone in Astra on desktop.',
});
@@ -380,6 +427,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
pinPairing: {
baseUrl,
requestId: request.requestId,
attemptId: request.attemptId,
expiresAt: request.expiresAt,
desktopName,
},
@@ -406,19 +454,22 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
}
set({ connectionState: 'pairing', message: 'Confirming PIN...', errorMessage: '' });
try {
const status = await confirmDesktopRemotePinPairing(attempt.baseUrl, attempt.requestId, normalizedPin);
if (status.state !== 'approved' || !status.token?.trim()) {
const status = await confirmDesktopRemotePinPairing(attempt.attemptId, normalizedPin);
const controlToken = status.controlToken?.trim() || status.token?.trim();
const syncToken = status.syncToken?.trim();
if (status.state !== 'approved' || !controlToken || !syncToken || !status.certificateFingerprint) {
throw new Error('Desktop did not approve this PIN pairing.');
}
const connection = await persistConnectedDesktop(
attempt.baseUrl,
status.token.trim(),
{ controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() },
status.certificateFingerprint,
status.deviceId ?? null,
status.identity ?? null
);
set({
connection,
token: status.token.trim(),
token: controlToken,
pairing: null,
pinPairing: null,
connectionState: 'connecting',
@@ -428,7 +479,10 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
void usePlaybackTargetStore.getState().setTarget('desktop');
void get().connect();
} catch (error) {
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
if (
(error instanceof DesktopRemoteHttpError && error.status === 401) ||
errorMessage(error).toLowerCase().includes('wrong pin')
) {
set({
connectionState: 'pinEntry',
message: `Enter the PIN shown on ${attempt.desktopName || 'Astra Desktop'}.`,
@@ -462,15 +516,20 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
init: async () => {
if (get().initialized) return;
const [connection, token] = await Promise.all([
const [connection, credentials, securityUpgradeLabel] = await Promise.all([
getDesktopRemoteConnection(),
getDesktopRemoteToken(),
getDesktopRemoteCredentials(),
getDesktopRemoteSecurityUpgradeNotice(),
]);
const token = credentials?.controlToken ?? null;
set({
initialized: true,
connection,
token,
connectionState: connection && token ? 'connecting' : 'unpaired',
errorMessage: securityUpgradeLabel
? `Security upgrade required—pair again. Previously paired: ${securityUpgradeLabel}.`
: '',
});
if (connection && token) void get().connect();
},
@@ -489,6 +548,22 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
),
};
});
const connection = get().connection;
if (
connection?.endpointUuid && desktop.endpointUuid === connection.endpointUuid &&
desktop.baseUrl !== connection.baseUrl
) {
void fetchDesktopRemoteIdentity(desktop.baseUrl, connection.certificateFingerprint).then(async (identity) => {
if (!identity || !identityMatchesPinnedConnection(
connection.endpointUuid,
identity.protocolVersion,
identity.endpointUuid
)) return;
const nextConnection = { ...connection, baseUrl: desktop.baseUrl };
await setDesktopRemoteConnection(nextConnection);
set({ connection: nextConnection });
});
}
}),
AstraDesktopDiscovery.addListener('onDesktopRemoteLost', (event) => {
set((state) => ({
@@ -520,23 +595,27 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
});
return;
}
await claimPairing(parsed.baseUrl, parsed.ticket);
await claimPairing(
parsed.baseUrl,
parsed.ticket,
parsed.certificateFingerprint,
parsed.endpointUuid
);
},
pairManual: async (baseUrl: string, ticket: string) => {
const parsed = parseDesktopRemoteManualInput(baseUrl, ticket);
if (!parsed) {
pairManual: async (baseUrl: string) => {
if (!baseUrl.trim().toLowerCase().startsWith('https://')) {
set({
connectionState: 'error',
errorMessage: 'Enter a valid desktop URL and pairing code.',
errorMessage: 'Enter the HTTPS desktop URL shown by Astra.',
});
return;
}
await claimPairing(parsed.baseUrl, parsed.ticket);
await requestPinPairing(baseUrl.trim().replace(/\/+$/, ''));
},
connect: async () => {
const { connection, token } = get();
let { connection, token } = get();
if (!connection || !token) {
set({ connectionState: 'unpaired' });
return false;
@@ -544,18 +623,33 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
stopRealtime();
set({ connectionState: 'connecting', message: 'Connecting to desktop...', errorMessage: '' });
try {
const snapshot = await fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true);
const storedCredentials = await getDesktopRemoteCredentials();
if (!storedCredentials) throw new Error('Desktop credentials are unavailable. Pair again.');
const fresh = await ensureDesktopRemoteCredentialsFresh(connection, storedCredentials);
connection = fresh.connection;
token = fresh.credentials.controlToken;
const snapshot = await fetchDesktopRemoteNowPlaying(
connection.baseUrl,
token,
connection.certificateFingerprint,
true
);
const nextConnection = { ...connection, lastConnectedAt: Date.now() };
await setDesktopRemoteConnection(nextConnection);
set({
connection: nextConnection,
token,
snapshot,
connectionState: 'connected',
message: '',
errorMessage: '',
});
useDesktopSyncStore.getState().maybeAutoSync('connected');
stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
stopEventStream = startDesktopRemoteEventStream(
connection.baseUrl,
token,
connection.certificateFingerprint,
{
onSnapshot: (nextSnapshot) => {
const wasConnected = get().connectionState === 'connected';
set((state) => ({
@@ -583,11 +677,17 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
onError: () => {
scheduleSnapshotPoll();
},
});
}
);
scheduleSnapshotPoll();
void get().refreshQueue();
return true;
} catch (error) {
if (isDesktopCertificateError(error)) {
await get().forget();
set({ errorMessage: 'Desktop certificate changed—pair again.' });
return false;
}
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -638,7 +738,13 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
await sendDesktopRemoteControl(connection.baseUrl, token, command, time);
await sendDesktopRemoteControl(
connection.baseUrl,
token,
connection.certificateFingerprint,
command,
time
);
set({ errorMessage: '' });
if (command === 'seek' && typeof time === 'number') {
set((state) => state.snapshot
@@ -646,6 +752,11 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
: {});
}
} catch (error) {
if (isDesktopCertificateError(error)) {
await get().forget();
set({ errorMessage: 'Desktop certificate changed—pair again.' });
return;
}
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
@@ -659,9 +770,17 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
const queue = await fetchDesktopRemoteQueue(connection.baseUrl, token);
const queue = await fetchDesktopRemoteQueue(
connection.baseUrl,
token,
connection.certificateFingerprint
);
set({ queue });
} catch {
} catch (error) {
if (isDesktopCertificateError(error)) {
await get().forget();
set({ errorMessage: 'Desktop certificate changed—pair again.' });
}
// Protocol-1 desktops 404 here; the queue UI simply stays hidden.
}
},
@@ -670,9 +789,19 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
const { connection, token } = get();
if (!connection || !token) return;
try {
await sendDesktopRemotePlayQueueItem(connection.baseUrl, token, queueId);
await sendDesktopRemotePlayQueueItem(
connection.baseUrl,
token,
connection.certificateFingerprint,
queueId
);
set({ errorMessage: '' });
} catch (error) {
if (isDesktopCertificateError(error)) {
await get().forget();
set({ errorMessage: 'Desktop certificate changed—pair again.' });
return;
}
if (error instanceof DesktopRemoteHttpError && error.status === 401) {
await get().forget();
set({ errorMessage: 'Desktop pairing was revoked.' });
+41 -24
View File
@@ -15,6 +15,7 @@ import {
} from '@/services/desktopSync';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { canStartDesktopSync, decideDesktopSyncEnabled } from '@/services/desktopSyncPolicy';
import type {
DesktopSyncConflictResolution,
DesktopSyncPlaylistConflict,
@@ -23,13 +24,15 @@ import type {
const AUTO_SYNC_DEBOUNCE_MS = 5_000;
const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000;
const AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto';
const DESKTOP_SYNC_ENABLED_SETTING_KEY = 'desktop_sync_enabled_v1';
const LEGACY_AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto';
export type DesktopSyncStatus = 'idle' | 'syncing' | 'error';
export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground';
interface DesktopSyncStore {
hydrated: boolean;
status: DesktopSyncStatus;
/** Wall-clock ms of the last successful sync with the paired desktop. */
lastSyncAt: number | null;
@@ -42,14 +45,13 @@ interface DesktopSyncStore {
errorMessage: string;
/** False once the paired desktop reported a pre-sync protocol version. */
supported: boolean;
/** Automatic syncing (foreground/discovery). Manual + desktop-requested
* syncs run regardless. */
autoSyncEnabled: boolean;
/** Master gate for every desktop library-sync trigger. Remote playback is independent. */
desktopSyncEnabled: boolean;
hydrate: () => Promise<void>;
syncNow: () => Promise<void>;
dismissConflictPrompt: () => void;
setAutoSyncEnabled: (enabled: boolean) => Promise<void>;
setDesktopSyncEnabled: (enabled: boolean) => Promise<void>;
resolveConflict: (
conflict: DesktopSyncPlaylistConflict,
resolution: DesktopSyncConflictResolution
@@ -66,6 +68,7 @@ let autoSyncDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const promptedConflictUids = new Set<string>();
export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
hydrated: false,
status: 'idle',
lastSyncAt: null,
lastSummary: null,
@@ -73,39 +76,49 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
conflictPromptVisible: false,
errorMessage: '',
supported: true,
autoSyncEnabled: true,
desktopSyncEnabled: false,
hydrate: async () => {
try {
const db = await openLibraryDb();
const autoSetting = await getSetting(db, AUTO_SYNC_SETTING_KEY);
if (autoSetting !== null) {
set({ autoSyncEnabled: autoSetting !== '0' });
const masterSetting = await getSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY);
const legacySetting = await getSetting(db, LEGACY_AUTO_SYNC_SETTING_KEY);
const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting);
set({ desktopSyncEnabled: enabled });
if (masterSetting === null) {
await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
}
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const stored = await getSetting(db, desktopSyncSettingKey(connection));
const lastSyncAt = stored ? Number(stored) : NaN;
if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
set({ lastSyncAt });
if (connection) {
const stored = await getSetting(db, desktopSyncSettingKey(connection));
const lastSyncAt = stored ? Number(stored) : NaN;
if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
set({ lastSyncAt });
}
}
} catch {
// Hydration is best-effort; the first sync will set lastSyncAt.
} finally {
set({ hydrated: true });
}
},
setAutoSyncEnabled: async (enabled) => {
set({ autoSyncEnabled: enabled });
setDesktopSyncEnabled: async (enabled) => {
if (!enabled && autoSyncDebounceTimer !== null) {
clearTimeout(autoSyncDebounceTimer);
autoSyncDebounceTimer = null;
}
set({ desktopSyncEnabled: enabled });
try {
const db = await openLibraryDb();
await setSetting(db, AUTO_SYNC_SETTING_KEY, enabled ? '1' : '0');
await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
} catch {
// The in-memory value still applies for this session.
}
},
syncNow: async () => {
if (get().status === 'syncing') return;
if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return;
set({ status: 'syncing', errorMessage: '' });
try {
const summary = await runDesktopSync();
@@ -138,7 +151,7 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
},
resolveConflict: async (conflict, resolution) => {
if (get().status === 'syncing') return;
if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return;
try {
await applyDesktopSyncConflictResolution(conflict, resolution);
} catch (error) {
@@ -163,14 +176,14 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
},
handleSyncRequest: () => {
const { status } = get();
if (status === 'syncing') return;
const { status, desktopSyncEnabled } = get();
if (!canStartDesktopSync(desktopSyncEnabled, status)) return;
void get().syncNow();
},
maybeAutoSync: (reason) => {
const { status, lastSyncAt, supported, autoSyncEnabled } = get();
if (!supported || !autoSyncEnabled) return;
const { status, lastSyncAt, supported, desktopSyncEnabled } = get();
if (!supported || !desktopSyncEnabled) return;
if (status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
@@ -181,6 +194,7 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
autoSyncDebounceTimer = null;
void (async () => {
const state = useDesktopSyncStore.getState();
if (!state.desktopSyncEnabled) return;
if (state.status === 'syncing') return;
if (AppState.currentState !== 'active') return;
if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return;
@@ -189,7 +203,10 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
// user never asked for.
const connection = await getDesktopRemoteConnection();
if (!connection) return;
const identity = await fetchDesktopRemoteIdentity(connection.baseUrl);
const identity = await fetchDesktopRemoteIdentity(
connection.baseUrl,
connection.certificateFingerprint
);
if (!identity) return;
void state.syncNow();
})();
+24 -2
View File
@@ -1,4 +1,6 @@
export const DESKTOP_REMOTE_PROTOCOL_VERSION = 2;
export const DESKTOP_REMOTE_PROTOCOL_VERSION = 3;
export type DesktopRemoteCredentialScope = 'control' | 'sync';
export type DesktopRemoteSecurityUpgradeState = 'none' | 'repair-required';
export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all';
@@ -22,8 +24,14 @@ export interface DesktopRemoteIdentity {
}
export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
protocolVersion: 3;
id: string;
baseUrl: string;
certificateFingerprint: string;
scopes: DesktopRemoteCredentialScope[];
credentialIssuedAt: number;
credentialRotatedAt: number;
securityUpgradeState: DesktopRemoteSecurityUpgradeState;
deviceId: string | null;
pairedAt: number;
lastConnectedAt: number | null;
@@ -77,7 +85,11 @@ export interface DesktopRemotePairingClaim {
identity: DesktopRemoteIdentity | null;
}
export type DesktopRemotePinPairingRequest = DesktopRemotePairingClaim;
export interface DesktopRemotePinPairingRequest extends DesktopRemotePairingClaim {
attemptId: string;
certificateFingerprint: string;
protocolVersion: 3;
}
export type DesktopRemotePairingState =
| 'pending'
@@ -90,6 +102,11 @@ export interface DesktopRemotePairingStatus {
state: DesktopRemotePairingState;
expiresAt: number;
token?: string;
controlToken?: string;
syncToken?: string;
issuedAt?: number;
scopes?: DesktopRemoteCredentialScope[];
certificateFingerprint?: string;
deviceId?: string | null;
identity?: DesktopRemoteIdentity | null;
}
@@ -97,11 +114,16 @@ export interface DesktopRemotePairingStatus {
export interface DesktopRemotePairingInput {
baseUrl: string;
ticket: string;
endpointUuid: string;
protocolVersion: 3;
certificateFingerprint: string;
}
export interface DesktopRemoteDiscoveredDesktop extends DesktopRemoteIdentity {
name: string;
baseUrl: string;
certificateFingerprint: string;
transport: 'https';
address: string;
port: number;
lastSeenAt: number;
+1 -1
View File
@@ -9,7 +9,7 @@
export const DESKTOP_SYNC_FORMAT = 1;
/** Desktop protocol version that introduced /v1/sync/* (and queue/shuffle). */
export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 2;
export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 3;
export interface SyncFavorite {
key: string;