From 0d18d5dfa9fc3c172e930cffd7d279e0ada19e2e Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:59:37 +0200 Subject: [PATCH] refactor(orbit): switch share link to psysonic2- magic string format Aligns orbit invites with the existing magic-string family (psysonic1- for server invites, psysonic2- for library shares) by folding orbit into the psysonic2- payload as a new k:'orbit' variant. The JSON body is intentionally extendable so future layers (passwords, permissions, invite expiry) can be added without a format migration. - SharePayloadV1 split into EntitySharePayloadV1 + OrbitSharePayloadV1 - decodeSharePayloadFromText filters orbit out; orbit has its own decoder - applySharePastePayload param narrowed to EntitySharePayloadV1 - buildOrbitShareLink / parseOrbitShareLink kept as thin wrappers - slug parameter dropped (magic string is opaque, so the slug was cosmetic-only in the old URL form); slugifyOrbitName removed as dead code - joinModalLinkPlaceholder updated in all 8 locales Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitStartModal.tsx | 5 +- src/locales/de.ts | 2 +- src/locales/en.ts | 2 +- src/locales/es.ts | 2 +- src/locales/fr.ts | 2 +- src/locales/nb.ts | 2 +- src/locales/nl.ts | 2 +- src/locales/ru.ts | 2 +- src/locales/zh.ts | 2 +- src/utils/applySharePaste.ts | 4 +- src/utils/orbit.ts | 63 ++++++------------------- src/utils/shareLink.ts | 75 +++++++++++++++++++++++------- 12 files changed, 84 insertions(+), 79 deletions(-) diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx index 62eb0335..95c13b9a 100644 --- a/src/components/OrbitStartModal.tsx +++ b/src/components/OrbitStartModal.tsx @@ -9,7 +9,6 @@ import { startOrbitSession, buildOrbitShareLink, generateSessionId, - slugifyOrbitName, } from '../utils/orbit'; import { randomOrbitSessionName } from '../utils/orbitNames'; import { useAuthStore } from '../store/authStore'; @@ -44,8 +43,8 @@ export default function OrbitStartModal({ onClose }: Props) { const onLan = isLanUrl(serverBase); const shareLink = useMemo( - () => buildOrbitShareLink(serverBase, sid, slugifyOrbitName(name)), - [serverBase, sid, name], + () => buildOrbitShareLink(serverBase, sid), + [serverBase, sid], ); const writeLinkToClipboard = async (): Promise => { diff --git a/src/locales/de.ts b/src/locales/de.ts index 2f373e27..4adb34e0 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1501,7 +1501,7 @@ export const deTranslation = { joinModalTitle: 'Einer Orbit-Session beitreten', joinModalSub: 'Füge den Einladungslink ein, den dir der Host geschickt hat.', joinModalLinkLabel: 'Einladungslink', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Akzeptiert jeden gültigen Orbit-Link. Der Link muss zu dem Server gehören, auf dem du gerade angemeldet bist.', joinModalPasteTooltip: 'Aus Zwischenablage einfügen', joinModalSubmit: 'Beitreten', diff --git a/src/locales/en.ts b/src/locales/en.ts index 57e49a47..7f6728f3 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1505,7 +1505,7 @@ export const enTranslation = { joinModalTitle: 'Join an Orbit session', joinModalSub: 'Paste the invite link your host sent you.', joinModalLinkLabel: 'Invite link', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Works with any valid Orbit link. Must point to the server you\'re currently signed into.', joinModalPasteTooltip: 'Paste from clipboard', joinModalSubmit: 'Join', diff --git a/src/locales/es.ts b/src/locales/es.ts index f14d62e5..a58bd45d 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1488,7 +1488,7 @@ export const esTranslation = { joinModalTitle: 'Unirse a una sesión Orbit', joinModalSub: 'Pega el enlace de invitación que te envió tu anfitrión.', joinModalLinkLabel: 'Enlace de invitación', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Funciona con cualquier enlace Orbit válido. Debe apuntar al servidor al que estás conectado actualmente.', joinModalPasteTooltip: 'Pegar desde el portapapeles', joinModalSubmit: 'Unirse', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 4a2ee265..6f31b302 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1483,7 +1483,7 @@ export const frTranslation = { joinModalTitle: 'Rejoindre une session Orbit', joinModalSub: 'Colle le lien d\'invitation que ton hôte t\'a envoyé.', joinModalLinkLabel: 'Lien d\'invitation', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Fonctionne avec tout lien Orbit valide. Doit pointer vers le serveur auquel tu es actuellement connecté.', joinModalPasteTooltip: 'Coller depuis le presse-papiers', joinModalSubmit: 'Rejoindre', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d4b0831f..3a89544c 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1482,7 +1482,7 @@ export const nbTranslation = { joinModalTitle: 'Bli med i en Orbit-økt', joinModalSub: 'Lim inn invitasjonslenken verten sendte deg.', joinModalLinkLabel: 'Invitasjonslenke', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Fungerer med enhver gyldig Orbit-lenke. Må peke til serveren du er innlogget på.', joinModalPasteTooltip: 'Lim inn fra utklippstavlen', joinModalSubmit: 'Bli med', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index b6383b79..533d93f4 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1482,7 +1482,7 @@ export const nlTranslation = { joinModalTitle: 'Deelnemen aan een Orbit-sessie', joinModalSub: 'Plak de uitnodigingslink die je host je gestuurd heeft.', joinModalLinkLabel: 'Uitnodigingslink', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Werkt met elke geldige Orbit-link. Moet verwijzen naar de server waarop je nu bent aangemeld.', joinModalPasteTooltip: 'Plakken vanaf klembord', joinModalSubmit: 'Deelnemen', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 834e7d12..4c5a529c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1562,7 +1562,7 @@ export const ruTranslation = { joinModalTitle: 'Присоединиться к сессии Orbit', joinModalSub: 'Вставь ссылку-приглашение от хоста.', joinModalLinkLabel: 'Ссылка-приглашение', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: 'Работает с любой действительной ссылкой Orbit. Должна указывать на сервер, на котором ты сейчас авторизован.', joinModalPasteTooltip: 'Вставить из буфера обмена', joinModalSubmit: 'Присоединиться', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 7ad1bf1d..217777f4 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1475,7 +1475,7 @@ export const zhTranslation = { joinModalTitle: '加入 Orbit 会话', joinModalSub: '粘贴主持人发给你的邀请链接。', joinModalLinkLabel: '邀请链接', - joinModalLinkPlaceholder: 'psysonic2://orbit/…', + joinModalLinkPlaceholder: 'psysonic2-…', joinModalLinkHelper: '适用于任何有效的 Orbit 链接。必须指向你当前登录的服务器。', joinModalPasteTooltip: '从剪贴板粘贴', joinModalSubmit: '加入', diff --git a/src/utils/applySharePaste.ts b/src/utils/applySharePaste.ts index d2aa1ef1..3d097aad 100644 --- a/src/utils/applySharePaste.ts +++ b/src/utils/applySharePaste.ts @@ -3,7 +3,7 @@ import type { TFunction } from 'i18next'; import { getAlbum, getArtist, getSong, type SubsonicSong } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { songToTrack, usePlayerStore } from '../store/playerStore'; -import { findServerIdForShareUrl, type SharePayloadV1 } from './shareLink'; +import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink'; import { showToast } from './toast'; const RESOLVE_QUEUE_CHUNK = 12; @@ -14,7 +14,7 @@ const RESOLVE_QUEUE_CHUNK = 12; * the payload was already decoded successfully. */ export async function applySharePastePayload( - payload: SharePayloadV1, + payload: EntitySharePayloadV1, navigate: NavigateFunction, t: TFunction, ): Promise { diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 8f9cc8c8..68d8be37 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -10,6 +10,7 @@ import { import { useAuthStore } from '../store/authStore'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { encodeSharePayload, decodeOrbitSharePayloadFromText } from './shareLink'; import { makeInitialOrbitState, orbitOutboxPlaylistName, @@ -44,21 +45,6 @@ export function generateSessionId(): string { return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); } -/** - * Turn a human session name into a URL-safe slug. Ignores non-ASCII - * characters so the output is stable across locales and safe in a - * `psysonic2://` link. Returns an empty string for names that slugify - * to nothing — callers should fall back to a slug-less link in that case. - */ -export function slugifyOrbitName(name: string): string { - return name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40); -} - // ── Serialisation ─────────────────────────────────────────────────────── /** @@ -307,8 +293,6 @@ export async function updateOrbitSettings(patch: Partial/` link. Returns null on any - * shape mismatch — the caller decides what to do (show error toast etc.). - * Accepts both the `psysonic2://` prefix and a bare string if the OS-level - * handler has already stripped the scheme. + * Parse an orbit invite from pasted text. Accepts the magic-string format + * `psysonic2-` (same prefix family as library shares and + * server invites). The caller decides what to do on null (show toast, etc.). */ -export function parseOrbitShareLink(url: string): OrbitShareLink | null { - if (!url) return null; - const stripped = url.startsWith(ORBIT_SHARE_SCHEME) - ? url.slice(ORBIT_SHARE_SCHEME.length) - : url.startsWith('orbit/') ? url.slice('orbit/'.length) : null; - if (stripped == null) return null; - const slash = stripped.indexOf('/'); - if (slash <= 0) return null; - const serverB64 = stripped.slice(0, slash); - const tail = stripped.slice(slash + 1).replace(/\/+$/, ''); - // Tail is either `` or `-` — the SID is always the - // terminal 8-hex group. The slug is purely cosmetic for the sender. - const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i); - if (!m) return null; - const sid = m[1].toLowerCase(); - let serverBase: string; - try { - serverBase = atob(serverB64); - } catch { return null; } - try { new URL(serverBase); } catch { return null; } - return { serverBase, sid }; +export function parseOrbitShareLink(text: string): OrbitShareLink | null { + if (!text) return null; + const payload = decodeOrbitSharePayloadFromText(text); + if (!payload) return null; + try { new URL(payload.srv); } catch { return null; } + return { serverBase: payload.srv, sid: payload.sid }; } -/** - * Build a share link for a live session. When `slug` is provided (and - * non-empty) it is prepended to the SID for a friendlier-looking URL - * — the parser strips it on the receiving side. - */ -export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string { - const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid; - return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`; +/** Build an orbit invite magic string for a live session. */ +export function buildOrbitShareLink(serverBase: string, sid: string): string { + return encodeSharePayload({ srv: serverBase, k: 'orbit', sid }); } // ── Playlist lookup ───────────────────────────────────────────────────── diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts index ea7feded..36d04a36 100644 --- a/src/utils/shareLink.ts +++ b/src/utils/shareLink.ts @@ -5,10 +5,17 @@ export const PSYSONIC_SHARE_PREFIX = 'psysonic2-'; export type EntityShareKind = 'track' | 'album' | 'artist'; -export type SharePayloadV1 = +/** Entity / queue shares — what {@link applySharePastePayload} dispatches on. */ +export type EntitySharePayloadV1 = | { srv: string; k: EntityShareKind; id: string } | { srv: string; k: 'queue'; ids: string[] }; +/** Orbit invite — session id + originating server. Decoded separately so that + * entity-share consumers can't accidentally receive an orbit payload. */ +export type OrbitSharePayloadV1 = { srv: string; k: 'orbit'; sid: string }; + +export type SharePayloadV1 = EntitySharePayloadV1 | OrbitSharePayloadV1; + export function normalizeShareServerUrl(url: string): string { const t = url.trim(); if (!t) return ''; @@ -38,24 +45,38 @@ function isEntityKind(k: unknown): k is EntityShareKind { export function encodeSharePayload(payload: SharePayloadV1): string { const srvNorm = normalizeShareServerUrl(payload.srv); - const body = - payload.k === 'queue' - ? JSON.stringify({ - v: 1, - srv: srvNorm, - k: 'queue', - ids: payload.ids.map(id => String(id).trim()).filter(Boolean), - }) - : JSON.stringify({ - v: 1, - srv: srvNorm, - k: payload.k, - id: String(payload.id).trim(), - }); + let body: string; + if (payload.k === 'queue') { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: 'queue', + ids: payload.ids.map(id => String(id).trim()).filter(Boolean), + }); + } else if (payload.k === 'orbit') { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: 'orbit', + sid: String(payload.sid).trim(), + }); + } else { + body = JSON.stringify({ + v: 1, + srv: srvNorm, + k: payload.k, + id: String(payload.id).trim(), + }); + } return PSYSONIC_SHARE_PREFIX + utf8ToBase64Url(body); } -export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null { +/** + * Decode an entity / queue share from pasted text. Returns null for orbit + * payloads (use {@link decodeOrbitSharePayloadFromText}) — so entity-share + * consumers can't be fed an orbit invite by accident. + */ +export function decodeSharePayloadFromText(text: string): EntitySharePayloadV1 | null { const idx = text.indexOf(PSYSONIC_SHARE_PREFIX); if (idx < 0) return null; const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length); @@ -67,6 +88,7 @@ export function decodeSharePayloadFromText(text: string): SharePayloadV1 | null const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : ''; if (!srv) return null; const k = raw.k; + if (k === 'orbit') return null; if (k === 'queue') { const idsRaw = raw.ids; if (!Array.isArray(idsRaw) || idsRaw.length === 0) return null; @@ -87,3 +109,24 @@ export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: stri const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm); return hit?.id ?? null; } + +/** Decode an orbit invite from pasted text. Returns null for entity / queue shares. */ +export function decodeOrbitSharePayloadFromText(text: string): OrbitSharePayloadV1 | null { + const idx = text.indexOf(PSYSONIC_SHARE_PREFIX); + if (idx < 0) return null; + const after = text.slice(idx + PSYSONIC_SHARE_PREFIX.length); + const token = after.match(/^([A-Za-z0-9_-]+)/)?.[1]; + if (!token) return null; + try { + const raw = JSON.parse(base64UrlToUtf8(token)) as Record; + if (raw.v !== 1) return null; + if (raw.k !== 'orbit') return null; + const srv = typeof raw.srv === 'string' ? normalizeShareServerUrl(raw.srv) : ''; + if (!srv) return null; + const sid = typeof raw.sid === 'string' ? raw.sid.trim().toLowerCase() : ''; + if (!/^[0-9a-f]{8}$/.test(sid)) return null; + return { srv, k: 'orbit', sid }; + } catch { + return null; + } +}