From 13c2606d680c833ce2973e3e82cd7cda38e4ee45 Mon Sep 17 00:00:00 2001 From: rowan kavanagh Date: Wed, 23 Sep 2026 23:35:04 +0100 Subject: [PATCH] feat(hosted): run hosted upload from the app config (#140) --- scripts/windows-entry.js | 7 ++-- src/app-config.js | 33 ++++++++++++++++-- src/app-status.js | 11 +++++- src/setup-config.js | 12 +++++++ test/hosted-app-wiring.test.js | 63 ++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 test/hosted-app-wiring.test.js diff --git a/scripts/windows-entry.js b/scripts/windows-entry.js index 316a0a0a..a33422f2 100644 --- a/scripts/windows-entry.js +++ b/scripts/windows-entry.js @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { createAppLogger } from "../src/app-log.js"; import { StartupError, resolveAppPort, startAppFromConfig } from "../src/app-config.js"; import { createCredentialStore } from "../src/credential-store.js"; +import { createHostedCredentials } from "../src/hosted-credentials.js"; import { loadOrCreateDeviceId, openSetupUrl, runNativeSetup, startSetupApp, windowsConfigPath, windowsSetupDraftPath } from "../src/setup-app.js"; import { createWindowsCredentialAdapter } from "../src/windows-credential-adapter.js"; import { createWindowsStartup } from "../src/windows-startup.js"; @@ -134,13 +135,15 @@ async function openSetupWindow() { } async function startFromWizardConfig(configFile) { - const credentialStore = createCredentialStore({ adapter: createWindowsCredentialAdapter() }); + const adapter = createWindowsCredentialAdapter(); + const credentialStore = createCredentialStore({ adapter }); + const hostedCredentials = createHostedCredentials({ adapter }); const manifest = JSON.parse(await readFile(resolve("app", "package.json"), "utf8").catch(() => "{}")); // build-info.json is written by build-windows.ps1; a source checkout has none. let build = null; try { build = JSON.parse(await readFile(resolve("app", "build-info.json"), "utf8")); } catch { build = null; } const packageType = existsSync(resolve("unins000.exe")) ? "installer" : "portable"; - const app = await startAppFromConfig({ configFile, credentialStore, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType }); + const app = await startAppFromConfig({ configFile, credentialStore, hostedCredentials, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType }); console.log(`NowPlaying is running. Card: ${app.url}/card.svg`); return app; } diff --git a/src/app-config.js b/src/app-config.js index e3aa7408..1c271d48 100644 --- a/src/app-config.js +++ b/src/app-config.js @@ -16,13 +16,16 @@ import { createDiscordClient } from "./discord-client.js"; import { createDiscordIpcClient } from "./discord-ipc.js"; import { createDiscordPresenceLoop } from "./discord-presence.js"; import { createDiscordRpcTransport } from "./discord-rpc.js"; +import { createHostedLoop } from "./hosted-loop.js"; +import { createHostedUploader, DEFAULT_HOSTED_URL } from "./hosted-uploader.js"; // Runs nowplaying from the config the setup wizard writes (config.json). The // file never holds a secret: the sign-in is read from the credential store by // credentialRef at start-up. const MAX_CONFIG_BYTES = 16 * 1024; -const CONFIG_KEYS = new Set(["version", "provider", "serverUrl", "identity", "credentialRef", "discord"]); +const CONFIG_KEYS = new Set(["version", "provider", "serverUrl", "identity", "credentialRef", "discord", "hosted"]); +const HOSTED_KEYS = new Set(["enabled", "url"]); export class StartupError extends Error { constructor(startupCode, message) { @@ -44,6 +47,7 @@ export function parseAppConfig(text) { // Only the fields the wizard writes; anything else (a pasted token, say) means // the file was edited by hand and is not trusted. if (Object.keys(parsed).some((key) => !CONFIG_KEYS.has(key))) throw invalidConfig(); + if (parsed.hosted !== undefined && (!parsed.hosted || typeof parsed.hosted !== "object" || Array.isArray(parsed.hosted) || Object.keys(parsed.hosted).some((key) => !HOSTED_KEYS.has(key)))) throw invalidConfig(); let config; try { config = createSetupConfig({ @@ -54,6 +58,7 @@ export function parseAppConfig(text) { discordEnabled: parsed.discord?.enabled, discordIdleBehavior: parsed.discord?.idleBehavior, discordArtworkLookup: parsed.discord?.artworkLookup, + ...(parsed.hosted ? { hostedEnabled: parsed.hosted.enabled, hostedUrl: parsed.hosted.url } : {}), }); } catch { throw invalidConfig(); @@ -120,6 +125,18 @@ export function startDiscordFromConfig(config, provider, { env = process.env, bu return Object.freeze({ status: "on", connection: () => loop.status(), stop: () => loop.stop() }); } +// Hosted card upload (#140) runs only when the config turns it on. It pushes +// privacy-filtered state to the hosted service and is independent of Discord: +// a host that's down or unreachable never affects presence. +export function startHostedFromConfig(config, provider, { credentials, fetchImpl = fetch, settings = {}, intervalMs, createUploader = createHostedUploader } = {}) { + if (!config.hosted?.enabled) return Object.freeze({ status: "off", stop: async () => {}, cardUrl: async () => null, connection: () => null }); + if (typeof credentials?.load !== "function") return Object.freeze({ status: "no_credentials", stop: async () => {}, cardUrl: async () => null, connection: () => null }); + const uploader = createUploader({ baseUrl: config.hosted.url ?? DEFAULT_HOSTED_URL, credentials, fetchImpl, settings }); + const loop = createHostedLoop({ getPresence: () => provider.getPresence(), uploader, ...(intervalMs ? { intervalMs } : {}) }); + loop.start(); + return Object.freeze({ status: "on", stop: () => loop.stop(), cardUrl: () => uploader.cardUrl(), connection: () => uploader.status() }); +} + // 3000 clashes with most dev servers, so the local app uses a rarely used port. // Override with NOWPLAYING_PORT (see resolveAppPort). export const DEFAULT_APP_PORT = 47832; @@ -134,7 +151,7 @@ export function resolveAppPort(env = process.env) { return port; } -export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null } = {}) { +export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {} } = {}) { if (typeof credentialStore?.read !== "function") throw new TypeError("credentialStore.read is required"); const config = await loadAppConfig(configFile); let secret; @@ -173,11 +190,21 @@ export async function startAppFromConfig({ configFile, credentialStore, host = " status.setDiscord(() => discord.status === "on" ? { enabled: true, ...discord.connection() } : { enabled: discord.status !== "off", state: discord.status }); + let hosted; + try { + hosted = startHostedFromConfig(config, provider, { credentials: hostedCredentials, fetchImpl, ...hostedOptions }); + } catch { + hosted = Object.freeze({ status: "failed", stop: async () => {}, cardUrl: async () => null, connection: () => null }); + } + status.setHosted(() => hosted.status === "on" + ? { enabled: true, ...hosted.connection() } + : { enabled: hosted.status !== "off", state: hosted.status }); const close = async () => { + await hosted.stop().catch(() => {}); await discord.stop().catch(() => {}); await server.close(); }; - return Object.freeze({ config, url: `http://${authority}:${address.port}`, discord: discord.status, status, close }); + return Object.freeze({ config, url: `http://${authority}:${address.port}`, discord: discord.status, hosted: hosted.status, hostedCardUrl: () => hosted.cardUrl(), status, close }); } function invalidConfig() { diff --git a/src/app-status.js b/src/app-status.js index ea08948b..dce9e562 100644 --- a/src/app-status.js +++ b/src/app-status.js @@ -13,6 +13,7 @@ export function createAppStatus({ config, version = null, now = () => Date.now() const startedAt = now(); let provider = null; let discord = () => ({ enabled: false, state: "off" }); + let hosted = () => ({ enabled: false, state: "off" }); let lastPollAt = null; let lastOkAt = null; let failure = null; @@ -43,6 +44,11 @@ export function createAppStatus({ config, version = null, now = () => Date.now() discord = read; } + function setHosted(read) { + if (typeof read !== "function") throw new TypeError("hosted: expected a function"); + hosted = read; + } + // Polls the server once when nothing else has recently (for example when // the card isn't embedded anywhere and Discord is off). async function refresh() { @@ -54,6 +60,8 @@ export function createAppStatus({ config, version = null, now = () => Date.now() function snapshot() { let discordState; try { discordState = discord(); } catch { discordState = { enabled: true, state: "unknown" }; } + let hostedState; + try { hostedState = hosted(); } catch { hostedState = { enabled: true, state: "unknown" }; } return Object.freeze({ version: typeof version === "string" ? version : null, build: buildLabel(build), @@ -68,6 +76,7 @@ export function createAppStatus({ config, version = null, now = () => Date.now() }), playing: failure ? null : playing, discord: Object.freeze({ enabled: Boolean(discordState?.enabled), state: word(discordState?.state), lastPublishedAt: iso(discordState?.lastPublishedAt ?? null), error: code(discordState?.lastError) }), + hosted: Object.freeze({ enabled: Boolean(hostedState?.enabled), state: word(hostedState?.state), lastSuccessAt: iso(hostedState?.lastSuccessAt ?? null), error: hostedState?.lastError ? word(hostedState.lastError) : null }), }); } @@ -92,7 +101,7 @@ export function createAppStatus({ config, version = null, now = () => Date.now() }); } - return Object.freeze({ wrapProvider, setDiscord, refresh, snapshot, diagnostics }); + return Object.freeze({ wrapProvider, setDiscord, setHosted, refresh, snapshot, diagnostics }); } function buildLabel(value) { diff --git a/src/setup-config.js b/src/setup-config.js index 97aece2f..f711dd66 100644 --- a/src/setup-config.js +++ b/src/setup-config.js @@ -1,5 +1,6 @@ import { createProviderIdentity } from "./provider-identity.js"; import { isServerUrl } from "./setup.js"; +import { normalizeHostedUrl } from "./hosted-uploader.js"; const PROVIDERS = new Set(["plex", "jellyfin", "navidrome", "emby"]); const ARTWORK_LOOKUPS = new Set(["off", "musicbrainz"]); @@ -17,6 +18,13 @@ export function createSetupConfig(input = {}) { if (input.discordArtworkLookup !== undefined && !ARTWORK_LOOKUPS.has(input.discordArtworkLookup)) { throw new TypeError("setup config.discordArtworkLookup must be off or musicbrainz"); } + if (input.hostedEnabled !== undefined && typeof input.hostedEnabled !== "boolean") { + throw new TypeError("setup config.hostedEnabled must be a boolean"); + } + let hostedUrl = null; + if (input.hostedUrl !== undefined && input.hostedUrl !== null) { + try { hostedUrl = normalizeHostedUrl(input.hostedUrl); } catch { throw new TypeError("setup config.hostedUrl is invalid"); } + } if (input.serverUrl !== undefined && !isServerUrl(input.serverUrl)) throw new TypeError("setup config.serverUrl is invalid"); return Object.freeze({ version: 1, @@ -31,6 +39,10 @@ export function createSetupConfig(input = {}) { // sent until the user runs setup again or turns it on. artworkLookup: input.discordArtworkLookup ?? "off", }), + // Hosted card upload (#140) is off unless the user turns it on. + ...(input.hostedEnabled !== undefined || hostedUrl ? { + hosted: Object.freeze({ enabled: input.hostedEnabled ?? false, ...(hostedUrl ? { url: hostedUrl } : {}) }), + } : {}), }); } diff --git a/test/hosted-app-wiring.test.js b/test/hosted-app-wiring.test.js new file mode 100644 index 00000000..f2f0a999 --- /dev/null +++ b/test/hosted-app-wiring.test.js @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseAppConfig, startHostedFromConfig } from "../src/app-config.js"; +import { createAppStatus } from "../src/app-status.js"; +import { createSetupConfig, serializeSetupConfig } from "../src/setup-config.js"; + +const JELLYFIN = { provider: "jellyfin", serverUrl: "http://127.0.0.1:8096", identity: { id: "u1", displayName: "Rowan" }, credentialStored: true }; + +test("hosted upload is absent from configs unless set, and off by default", () => { + assert.equal("hosted" in createSetupConfig(JELLYFIN), false); + assert.deepEqual({ ...createSetupConfig({ ...JELLYFIN, hostedEnabled: false }).hosted }, { enabled: false }); +}); + +test("hosted settings round-trip through config.json", () => { + const text = serializeSetupConfig({ ...JELLYFIN, hostedEnabled: true, hostedUrl: "https://cards.example.test/" }); + assert.deepEqual({ ...parseAppConfig(text).hosted }, { enabled: true, url: "https://cards.example.test" }); +}); + +test("bad hosted settings make the config invalid", () => { + const good = JSON.parse(serializeSetupConfig(JELLYFIN)); + for (const hosted of [{ enabled: "yes" }, { enabled: true, url: "http://cards.example.test" }, { enabled: true, token: "x" }, [], null]) { + assert.throws(() => parseAppConfig(JSON.stringify({ ...good, hosted })), { startupCode: "CONFIG_INVALID" }, JSON.stringify(hosted)); + } + assert.throws(() => createSetupConfig({ ...JELLYFIN, hostedEnabled: 1 }), TypeError); +}); + +test("hosted upload stays off when the config doesn't enable it", async () => { + const hosted = startHostedFromConfig(createSetupConfig(JELLYFIN), { getPresence: async () => null }); + assert.equal(hosted.status, "off"); + assert.equal(await hosted.cardUrl(), null); +}); + +test("hosted upload needs a credential store", () => { + const hosted = startHostedFromConfig(createSetupConfig({ ...JELLYFIN, hostedEnabled: true }), { getPresence: async () => null }); + assert.equal(hosted.status, "no_credentials"); +}); + +test("hosted upload starts an uploader against the configured URL", async () => { + let options; + const pushed = []; + const config = createSetupConfig({ ...JELLYFIN, hostedEnabled: true, hostedUrl: "https://cards.example.test" }); + const hosted = startHostedFromConfig(config, { getPresence: async () => ({ state: "playing" }) }, { + credentials: { load: async () => null, save: async () => {}, clear: async () => {} }, + createUploader: (opts) => { options = opts; return { push: async (p) => { pushed.push(p); return { sent: true }; }, cardUrl: async () => "https://cards.example.test/card/x.svg", status: () => ({ state: "connected" }) }; }, + }); + await new Promise((resolve) => setImmediate(resolve)); + await hosted.stop(); + assert.equal(hosted.status, "on"); + assert.equal(options.baseUrl, "https://cards.example.test"); + assert.equal(pushed.length, 1); + assert.equal(await hosted.cardUrl(), "https://cards.example.test/card/x.svg"); + assert.deepEqual(hosted.connection(), { state: "connected" }); +}); + +test("status page reports hosted upload state without secrets", () => { + const status = createAppStatus({ config: createSetupConfig(JELLYFIN) }); + assert.deepEqual({ ...status.snapshot().hosted }, { enabled: false, state: "off", lastSuccessAt: null, error: null }); + status.setHosted(() => ({ enabled: true, state: "retrying", lastSuccessAt: 1_800_000_000_000, lastError: "network_error", token: "secret" })); + const hosted = status.snapshot().hosted; + assert.deepEqual({ ...hosted }, { enabled: true, state: "retrying", lastSuccessAt: new Date(1_800_000_000_000).toISOString(), error: "network_error" }); + assert.equal(JSON.stringify(status.snapshot()).includes("secret"), false); +});