mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
improve mobile sync security
This commit is contained in:
+30
-6
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user