Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions scripts/windows-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 30 additions & 3 deletions src/app-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
11 changes: 10 additions & 1 deletion src/app-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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),
Expand All @@ -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 }),
});
}

Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions src/setup-config.js
Original file line number Diff line number Diff line change
@@ -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"]);
Expand All @@ -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,
Expand All @@ -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 } : {}) }),
} : {}),
});
}

Expand Down
63 changes: 63 additions & 0 deletions test/hosted-app-wiring.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading