[&password=][&coordinator=][&settings=]`,
+// each value percent-encoded; `settings` (sent when there is no coordinator)
+// is packed by ./setup-settings and kept packed here, since unpacking is async.
+// Android reads and writes the same form (android/…/ui/screens/setup/SetupLink.kt).
import { DEEP_LINK_SCHEME } from "./constants";
-export type DeepLink = { kind: "album" | "artist" | "playlist" | "track"; id: string } | { kind: "open" };
+export type SetupLink = { kind: "setup"; server: string; user: string; password?: string; coordinator?: string; settings?: string };
+
+/** A packed settings value: unpadded base64url. */
+const PACKED_RE = /^[A-Za-z0-9_-]+$/;
+
+export type DeepLink = { kind: "album" | "artist" | "playlist" | "track"; id: string } | { kind: "open" } | SetupLink;
const ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
export function parseDeepLink(url: string): DeepLink | undefined {
- if (typeof url !== "string" || url.length > 2048) return undefined;
+ // Room for a setup link's packed settings.
+ if (typeof url !== "string" || url.length > 8192) return undefined;
let u: URL;
try {
u = new URL(url);
@@ -19,6 +31,7 @@ export function parseDeepLink(url: string): DeepLink | undefined {
const kind = u.hostname.toLowerCase();
const id = u.pathname.replace(/^\/+/, "");
if (kind === "open") return id ? undefined : { kind: "open" };
+ if (kind === "setup") return id ? undefined : parseSetup(u.search);
if (kind !== "album" && kind !== "artist" && kind !== "playlist" && kind !== "track") return undefined;
if (!ID_RE.test(id)) return undefined;
return { kind, id };
@@ -26,5 +39,40 @@ export function parseDeepLink(url: string): DeepLink | undefined {
/** Canonical form of a parsed link, the only string main forwards. */
export function formatDeepLink(link: DeepLink): string {
- return link.kind === "open" ? `${DEEP_LINK_SCHEME}://open` : `${DEEP_LINK_SCHEME}://${link.kind}/${link.id}`;
+ if (link.kind === "open") return `${DEEP_LINK_SCHEME}://open`;
+ if (link.kind === "setup") {
+ const params: [string, string | undefined][] = [["server", link.server], ["user", link.user], ["password", link.password], ["coordinator", link.coordinator], ["settings", link.settings]];
+ return `${DEEP_LINK_SCHEME}://setup?${params.filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(v!)}`).join("&")}`;
+ }
+ return `${DEEP_LINK_SCHEME}://${link.kind}/${link.id}`;
+}
+
+/**
+ * A setup link's query: an http(s) server (trimmed, without trailing slashes,
+ * the scheme lower-cased) and a user are required; unknown parameters are ignored.
+ */
+function parseSetup(search: string): SetupLink | undefined {
+ const values = new Map();
+ for (const pair of search.replace(/^\?/, "").split("&")) {
+ const i = pair.indexOf("=");
+ if (i <= 0) continue;
+ try {
+ // Not URLSearchParams: it reads "+" as a space, and the links encode spaces as %20.
+ values.set(pair.slice(0, i), decodeURIComponent(pair.slice(i + 1)));
+ } catch {
+ // A malformed escape: as if the parameter were missing.
+ }
+ }
+ const [, scheme, rest] = /^(https?):\/\/(.+)$/i.exec((values.get("server") ?? "").trim().replace(/\/+$/, "")) ?? [];
+ const user = values.get("user")?.trim();
+ if (!scheme || !rest || !user) return undefined;
+ const link: SetupLink = { kind: "setup", server: `${scheme.toLowerCase()}://${rest}`, user };
+ const password = values.get("password");
+ if (password) link.password = password;
+ const coordinator = values.get("coordinator")?.trim();
+ if (coordinator) link.coordinator = coordinator;
+ // Settings that aren't packed are dropped, not the whole link.
+ const settings = values.get("settings")?.trim();
+ if (settings && PACKED_RE.test(settings)) link.settings = settings;
+ return link;
}
diff --git a/desktop/src/shared/setup-settings.test.ts b/desktop/src/shared/setup-settings.test.ts
new file mode 100644
index 00000000..c731dc5c
--- /dev/null
+++ b/desktop/src/shared/setup-settings.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { packSetupSettings, setupSettingsCount, unpackSetupSettings } from "./setup-settings";
+
+describe("setup settings", () => {
+ it("packs link-safe and unpacks", async () => {
+ const json = JSON.stringify({ "queue.savedCap": 7, "nav.mobileBar": ["home", "search"], note: "ü" });
+ const packed = await packSetupSettings(json);
+ expect(packed).toMatch(/^[A-Za-z0-9_-]+$/);
+ expect(await unpackSetupSettings(packed)).toBe(json);
+ });
+
+ it("reads what Android packs", async () => {
+ // java.util.zip.Deflater(BEST_COMPRESSION) and unpadded base64url, as SetupLink.packSettings writes them.
+ expect(await unpackSetupSettings("eNqrViosTS1N1StOLEtNcU4sULIy11FKySwuyEms1AMRqUVBiSWZeelKViVFpam1AKegEag"))
+ .toBe('{"queue.savedCap":7,"display.playerRating":true}');
+ });
+
+ it("refuses what isn't packed settings", async () => {
+ expect(await unpackSetupSettings("%%%")).toBeUndefined();
+ expect(await unpackSetupSettings("AAAA")).toBeUndefined();
+ expect(await unpackSetupSettings(await packSetupSettings("[1]"))).toBeUndefined();
+ expect(await unpackSetupSettings(await packSetupSettings("x".repeat(70_000)))).toBeUndefined();
+ });
+
+ it("counts settings", () => {
+ expect(setupSettingsCount('{"a":1,"b":2}')).toBe(2);
+ expect(setupSettingsCount("{}")).toBe(0);
+ expect(setupSettingsCount("[1]")).toBe(0);
+ expect(setupSettingsCount(undefined)).toBe(0);
+ });
+});
diff --git a/desktop/src/shared/setup-settings.ts b/desktop/src/shared/setup-settings.ts
new file mode 100644
index 00000000..5216c3be
--- /dev/null
+++ b/desktop/src/shared/setup-settings.ts
@@ -0,0 +1,53 @@
+// The settings a setup link carries when there is no coordinator to sync them
+// (Set up another device): `Query.setupSettings`'s JSON object, zlib-deflated
+// and in unpadded base64url so the code stays small and the value link-safe.
+// Android packs and unpacks the same way (SetupLink.packSettings/unpackSettings).
+
+/** Unpacked settings past this size are refused (a link is a few KiB at most). */
+const MAX_UNPACKED = 64 * 1024;
+
+export async function packSetupSettings(json: string): Promise {
+ const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate"));
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
+ let binary = "";
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+/** [packSetupSettings] undone, or undefined when [packed] is not a packed JSON object. */
+export async function unpackSetupSettings(packed: string): Promise {
+ try {
+ const b64 = packed.replace(/-/g, "+").replace(/_/g, "/");
+ const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
+ const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")).getReader();
+ const decoder = new TextDecoder();
+ let json = "";
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.length;
+ if (size > MAX_UNPACKED) {
+ await reader.cancel();
+ return undefined;
+ }
+ json += decoder.decode(value, { stream: true });
+ }
+ json += decoder.decode();
+ const parsed: unknown = JSON.parse(json);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? json : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** How many settings a `Query.setupSettings` answer holds (0 when it isn't one). */
+export function setupSettingsCount(json: string | undefined): number {
+ try {
+ const parsed: unknown = json ? JSON.parse(json) : undefined;
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? Object.keys(parsed).length : 0;
+ } catch {
+ return 0;
+ }
+}
diff --git a/desktop/src/shared/strings.ts b/desktop/src/shared/strings.ts
index d55ca1eb..195bb519 100644
--- a/desktop/src/shared/strings.ts
+++ b/desktop/src/shared/strings.ts
@@ -781,6 +781,27 @@ const STRINGS = {
"settings.account.syncing": "Syncing…",
"settings.account.stats": "Listening stats",
"settings.account.devices": "Devices",
+ "settings.account.setUpDevice": "Set up another device",
+ "setupDevice.title": "Set up another device",
+ "setupDevice.body": "Scan this code with Hocket on your phone (Scan a setup code, on its first screen) or its camera. On another computer, paste the link into the server address field.",
+ "setupDevice.code": "Setup code for {host}",
+ "setupDevice.server": "Server",
+ "setupDevice.serverValue": "{user} on {host}",
+ "setupDevice.coordinator": "Coordinator",
+ "setupDevice.noCoordinator": "None. The other device connects to your server directly.",
+ "setupDevice.settings": "Settings",
+ "setupDevice.settingsSynced": "Come across through the coordinator",
+ "setupDevice.settingsNone": "All at their defaults, so nothing to bring",
+ "setupDevice.settingsCountOne": "1 changed setting comes along",
+ "setupDevice.settingsCount": "{count} changed settings come along",
+ "setupDevice.settingsTooMany": "Too many to fit in the code. Copy the link to bring them along.",
+ "setupDevice.includePassword": "Include password",
+ "setupDevice.includePasswordOn": "Anyone who sees the code can sign in as you",
+ "setupDevice.includePasswordOff": "The other device will ask for it",
+ "setupDevice.passwordUnavailable": "This device can't read the password right now",
+ "setupDevice.copy": "Copy link",
+ "setupDevice.copied": "Setup link copied",
+ "setupDevice.alreadySetUp": "This device is already set up. Sign out to use another device's setup code.",
"settings.section.general": "Library & queue",
"settings.section.audio": "Audio",
"settings.section.transcoding": "Streaming",
diff --git a/scripts/gen-android-icons.mjs b/scripts/gen-android-icons.mjs
index 18cb3245..9af71ffa 100644
--- a/scripts/gen-android-icons.mjs
+++ b/scripts/gen-android-icons.mjs
@@ -51,6 +51,7 @@ const SYMBOLS = {
NestDisplay: "nest_display", NestDisplayMax: "nest_display_max", Chromecast2: "chromecast_2", ChromecastDevice: "chromecast_device",
NestWifiPoint: "nest_wifi_point",
OpenInNew: "open_in_new", Laptop: "laptop_windows", DesktopWindows: "desktop_windows", Warning: "warning", Equalizer: "equalizer",
+ QrCode2: "qr_code_2", QrCodeScanner: "qr_code_scanner",
};
/** Which glyphs each group emits. AutoMirrored ones flip in right-to-left layouts. */
@@ -76,6 +77,8 @@ const GROUPS = [
// Cast receivers by kind: the connect button and the Connect sheet.
"SpeakerGroup", "NestMini", "HomeMini", "NestAudio", "HomeMax", "NestDisplay", "NestDisplayMax", "Chromecast2",
"ChromecastDevice", "NestWifiPoint",
+ // Set up another device: the account sheet's entry, and scanning a code at setup.
+ "QrCode2", "QrCodeScanner",
] },
{ path: ["Outlined"], fill: false, mirror: false, names: [
"Album", "BarChart", "Category", "CloudOff", "Download", "FilterAlt", "History", "Home", "Info", "LibraryMusic", "MusicNote",