mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
preliminary support for astra desktop
This commit is contained in:
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -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: () => [],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user