preliminary support for astra desktop

This commit is contained in:
Boof2015
2026-07-01 00:43:51 -04:00
parent 8f23c6a1e2
commit 76a5183dfb
25 changed files with 3145 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
import * as Device from 'expo-device';
import type {
DesktopRemoteControlCommand,
DesktopRemoteIdentity,
DesktopRemoteNowPlayingSnapshot,
DesktopRemotePairingClaim,
DesktopRemotePairingStatus,
DesktopRemotePinPairingRequest,
} from '@/types/desktopRemote';
export {
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
} from './desktopRemotePairing';
const REQUEST_TIMEOUT_MS = 8000;
interface JsonRequestOptions {
method?: 'GET' | 'POST';
token?: string | null;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
export class DesktopRemoteHttpError extends Error {
status: number;
payload: unknown;
constructor(status: number, message: string, payload: unknown) {
super(message);
this.status = status;
this.payload = payload;
}
}
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 = {}
): 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 (options.token) headers.Authorization = `Bearer ${options.token}`;
const response = await fetch(`${baseUrl}${path}`, {
method: options.method ?? (body ? 'POST' : 'GET'),
headers,
body,
cache: 'no-store',
signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
? payload.error
: `Desktop remote request failed (${response.status}).`;
throw new DesktopRemoteHttpError(response.status, message, payload);
}
return payload as T;
}
function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null {
if (!payload || typeof payload !== 'object') return null;
const candidate = payload as Record<string, unknown>;
return {
endpointUuid: typeof candidate.endpointUuid === 'string' && candidate.endpointUuid.trim()
? candidate.endpointUuid.trim()
: null,
desktopName: typeof candidate.desktopName === 'string' && candidate.desktopName.trim()
? candidate.desktopName.trim()
: null,
protocolVersion:
typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
? candidate.protocolVersion
: 1,
};
}
function clientLabel(): string {
if (Device.osName === 'Android') return 'Android Phone';
if (Device.osName === 'iOS') return Device.modelName?.includes('iPad') ? 'iPad' : 'iPhone';
return 'Astra Mobile';
}
export function defaultDesktopRemoteDeviceName(): string {
const model = Device.modelName?.trim();
return model ? `${model} Remote` : 'Astra Mobile Remote';
}
export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise<DesktopRemoteIdentity | null> {
try {
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity');
return normalizeIdentity(payload);
} catch {
return null;
}
}
export async function claimDesktopRemotePairingTicket(
baseUrl: string,
ticket: string,
deviceName: string = defaultDesktopRemoteDeviceName()
): Promise<DesktopRemotePairingClaim> {
const payload = await fetchJson<Record<string, unknown>>(baseUrl, '/v1/pairing/claim', {
method: 'POST',
body: {
ticket,
deviceName,
clientLabel: 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),
};
}
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(),
},
});
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),
};
}
export async function confirmDesktopRemotePinPairing(
baseUrl: string,
requestId: 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';
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),
};
}
export async function fetchDesktopRemotePairingStatus(
baseUrl: string,
pollToken: string
): Promise<DesktopRemotePairingStatus> {
const payload = await fetchJson<Record<string, unknown>>(
baseUrl,
`/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
);
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,
deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
identity: normalizeIdentity(payload.identity ?? payload),
};
}
export async function fetchDesktopRemoteNowPlaying(
baseUrl: string,
token: string,
inlineArtwork = false
): Promise<DesktopRemoteNowPlayingSnapshot> {
return fetchJson<DesktopRemoteNowPlayingSnapshot>(
baseUrl,
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
{ token }
);
}
export async function sendDesktopRemoteControl(
baseUrl: string,
token: string,
command: DesktopRemoteControlCommand,
time?: number
): Promise<void> {
await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
method: 'POST',
token,
body: command === 'seek' ? { command, time } : { command },
});
}
export type DesktopRemoteSseHandlers = {
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void;
onUnauthorized: () => void;
onDisconnect: () => void;
onError?: (error: unknown) => void;
};
function processSseChunk(
buffer: { value: string },
chunk: string,
onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => 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.
}
}
boundary = buffer.value.indexOf('\n\n');
}
}
export function startDesktopRemoteEventStream(
baseUrl: string,
token: string,
handlers: DesktopRemoteSseHandlers
): () => void {
const controller = new AbortController();
let closed = false;
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);
}
processSseChunk(buffer, decoder.decode(), handlers.onSnapshot);
if (!closed) handlers.onDisconnect();
} catch (error) {
if (closed || controller.signal.aborted) return;
handlers.onError?.(error);
handlers.onDisconnect();
}
})();
return () => {
closed = true;
controller.abort();
};
}
+56
View File
@@ -0,0 +1,56 @@
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';
export async function getDesktopRemoteConnection(): Promise<DesktopRemoteConnection | null> {
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;
return {
id: parsed.id,
baseUrl: parsed.baseUrl,
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,
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
pairedAt:
typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
? parsed.pairedAt
: Date.now(),
lastConnectedAt:
typeof parsed.lastConnectedAt === 'number' && Number.isFinite(parsed.lastConnectedAt)
? parsed.lastConnectedAt
: null,
};
} catch {
return null;
}
}
export async function setDesktopRemoteConnection(connection: DesktopRemoteConnection): Promise<void> {
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 setDesktopRemoteToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function clearDesktopRemotePairing(): Promise<void> {
await Promise.all([
SecureStore.deleteItemAsync(CONNECTION_KEY),
SecureStore.deleteItemAsync(TOKEN_KEY),
]);
}
+26
View File
@@ -0,0 +1,26 @@
import { Platform } from 'react-native';
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
type DiscoveryEvents = {
onDesktopRemoteFound: (desktop: DesktopRemoteDiscoveredDesktop) => void;
onDesktopRemoteLost: (event: { name: string }) => void;
};
declare class AstraDesktopDiscoveryModuleType extends NativeModule<DiscoveryEvents> {
start(): Promise<void>;
stop(): Promise<void>;
getCached(): DesktopRemoteDiscoveredDesktop[];
}
const native = requireOptionalNativeModule<AstraDesktopDiscoveryModuleType>('AstraDesktopDiscovery');
export const desktopRemoteDiscoveryAvailable = Platform.OS === 'android' && native != null;
export const AstraDesktopDiscovery = native ?? {
addListener: () => ({ remove: () => {} }),
removeAllListeners: () => {},
start: async () => {},
stop: async () => {},
getCached: () => [],
};
+42
View File
@@ -0,0 +1,42 @@
import {
AstraDesktopRemoteSession,
type AstraDesktopRemoteSessionCommand,
} from '../../modules/astra-desktop-remote-session';
import type {
DesktopRemoteConnection,
DesktopRemoteNowPlayingSnapshot,
} from '@/types/desktopRemote';
export function setDesktopRemoteMediaSession(
snapshot: DesktopRemoteNowPlayingSnapshot | null,
connection: DesktopRemoteConnection | null
): void {
if (!snapshot?.currentTrack || !connection) {
AstraDesktopRemoteSession.clear();
return;
}
const track = snapshot.currentTrack;
AstraDesktopRemoteSession.setNowPlaying({
title: track.title,
artist: track.artist,
album: track.album,
desktopName: connection.desktopName,
artworkDataUrl: track.artworkDataUrl,
playbackState: snapshot.playbackState,
hasTrack: true,
duration: snapshot.duration,
position: snapshot.currentTime,
updatedAt: snapshot.updatedAt,
isFavorite: track.isFavorite,
});
}
export function clearDesktopRemoteMediaSession(): void {
AstraDesktopRemoteSession.clear();
}
export function subscribeDesktopRemoteMediaSessionCommands(
handler: (command: AstraDesktopRemoteSessionCommand) => void
): { remove: () => void } {
return AstraDesktopRemoteSession.addListener('onDesktopRemoteCommand', handler);
}
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeDesktopRemotePinInput,
parseDesktopRemoteManualInput,
parseDesktopRemotePairingInput,
} from './desktopRemotePairing.ts';
test('parses current PWA pairing URL format', () => {
assert.deepEqual(
parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
{
baseUrl: 'http://192.168.1.20:38402',
ticket: 'abcDEF_1234567890',
}
);
});
test('parses native pairing links without accepting missing base URLs', () => {
assert.deepEqual(
parseDesktopRemotePairingInput(
'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
),
{
baseUrl: 'http://10.0.0.8:38402',
ticket: 'abcDEF_1234567890',
}
);
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',
ticket: 'abcDEF_1234567890',
});
assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
});
test('PIN pairing accepts only six digits with optional spacing', () => {
assert.equal(normalizeDesktopRemotePinInput('123456'), '123456');
assert.equal(normalizeDesktopRemotePinInput('123 456'), '123456');
assert.equal(normalizeDesktopRemotePinInput('12345'), null);
assert.equal(normalizeDesktopRemotePinInput('12345x'), null);
});
+55
View File
@@ -0,0 +1,55 @@
import type { DesktopRemotePairingInput } from '@/types/desktopRemote';
const PAIRING_TICKET_PATTERN = /^[A-Za-z0-9_-]{16,}$/;
const PAIRING_PIN_PATTERN = /^\d{6}$/;
function normalizeBaseUrl(value: string): string | null {
const trimmed = value.trim().replace(/\/+$/, '');
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
return parsed.origin;
} catch {
return null;
}
}
function extractPairFromUrl(url: URL): string {
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
const hashTicket = hashParams.get('pair')?.trim();
if (hashTicket) return hashTicket;
return url.searchParams.get('pair')?.trim() ?? '';
}
export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemotePairingInput | null {
const input = rawInput.trim();
if (!input) return null;
try {
const parsed = new URL(input);
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 ticket = extractPairFromUrl(parsed);
const baseUrl = normalizeBaseUrl(parsed.origin);
return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
} catch {
return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
}
}
export function parseDesktopRemoteManualInput(baseUrl: string, ticket: 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 };
}
export function normalizeDesktopRemotePinInput(pin: string): string | null {
const normalizedPin = pin.replace(/\s+/g, '');
return PAIRING_PIN_PATTERN.test(normalizedPin) ? normalizedPin : null;
}