From 92e8d0b2c41e3a2d23e6ec8372269013684216ea Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:21:18 -0400 Subject: [PATCH] changes to how astra handles integration --- package.json | 2 + scripts/run-mac-spotify-provider-tests.mjs | 44 ++ scripts/run-secret-vault-tests.mjs | 44 ++ src/main/index.ts | 26 +- src/main/services/astraIntegration.ts | 179 ++++- src/main/services/macSpotifyProvider.ts | 675 ++++++++++++++++++ src/main/services/nowPlayingManager.ts | 150 ++-- src/main/services/nowPlayingProvider.ts | 22 + src/main/services/secretVault.ts | 130 ++++ src/preload/index.ts | 4 +- src/renderer/App.tsx | 16 + src/renderer/components/AstraScopeModule.tsx | 49 +- .../components/NowPlayingConfigWindow.tsx | 121 +++- src/renderer/env.d.ts | 3 +- src/renderer/stores/nowPlayingStore.ts | 6 +- src/types/astra.ts | 13 +- src/types/nowPlaying.ts | 24 +- test/astra-integration.test.ts | 111 ++- test/mac-spotify-provider.test.ts | 210 ++++++ test/now-playing-manager.test.ts | 363 ++++++---- test/secret-vault.test.ts | 80 +++ 21 files changed, 1993 insertions(+), 279 deletions(-) create mode 100644 scripts/run-mac-spotify-provider-tests.mjs create mode 100644 scripts/run-secret-vault-tests.mjs create mode 100644 src/main/services/macSpotifyProvider.ts create mode 100644 src/main/services/nowPlayingProvider.ts create mode 100644 src/main/services/secretVault.ts create mode 100644 test/mac-spotify-provider.test.ts create mode 100644 test/secret-vault.test.ts diff --git a/package.json b/package.json index c2e9338..027948c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:capture-support": "node scripts/run-capture-support-tests.mjs", + "test:spotify-provider": "node scripts/run-mac-spotify-provider-tests.mjs", "test:now-playing": "node scripts/run-now-playing-manager-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", "test:themes": "node scripts/run-theme-library-tests.mjs", + "test:secret-vault": "node scripts/run-secret-vault-tests.mjs", "test:window-state": "node scripts/run-window-state-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", diff --git a/scripts/run-mac-spotify-provider-tests.mjs b/scripts/run-mac-spotify-provider-tests.mjs new file mode 100644 index 0000000..6b7689f --- /dev/null +++ b/scripts/run-mac-spotify-provider-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-mac-spotify-provider-tests-')) +const bundledTestPath = join(tempDir, 'mac-spotify-provider.test.mjs') +const entryPoint = join(rootDir, 'test', 'mac-spotify-provider.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/scripts/run-secret-vault-tests.mjs b/scripts/run-secret-vault-tests.mjs new file mode 100644 index 0000000..98ccda8 --- /dev/null +++ b/scripts/run-secret-vault-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-secret-vault-tests-')) +const bundledTestPath = join(tempDir, 'secret-vault.test.mjs') +const entryPoint = join(rootDir, 'test', 'secret-vault.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 9b7ac5b..95d05da 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, screen, session, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, safeStorage, screen, session, shell } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' import { extname, join, resolve } from 'path' import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying' @@ -28,6 +28,9 @@ import { import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' import { NowPlayingManager } from './services/nowPlayingManager' +import { AstraIntegrationService } from './services/astraIntegration' +import { MacSpotifyProvider } from './services/macSpotifyProvider' +import { SecretVault } from './services/secretVault' import { FileBackedThemeLibrary } from './themeLibrary' import { FileBackedWindowStateStore } from './windowStateStore' @@ -62,6 +65,7 @@ let profileLibrary: FileBackedProfileLibrary | null = null let themeLibrary: FileBackedThemeLibrary | null = null let nowPlayingManager: NowPlayingManager | null = null let windowStateStore: FileBackedWindowStateStore | null = null +let secretVault: SecretVault | null = null const WINDOW_DEFAULTS = { width: 900, @@ -126,11 +130,29 @@ function broadcastNowPlayingState(state: NowPlayingState): void { } } +function getSecretVault(): SecretVault { + if (!secretVault) { + secretVault = new SecretVault({ + path: join(app.getPath('userData'), 'secret-vault.json'), + platform: process.platform, + safeStorage, + }) + } + + return secretVault +} + function getNowPlayingManager(): NowPlayingManager { if (!nowPlayingManager) { nowPlayingManager = new NowPlayingManager({ - astraConfigPath: join(app.getPath('userData'), 'astra-integration.json'), localStatePath: join(app.getPath('userData'), 'now-playing-state.json'), + providerServices: [ + new AstraIntegrationService({ + configPath: join(app.getPath('userData'), 'astra-integration.json'), + secretVault: getSecretVault(), + }), + new MacSpotifyProvider(), + ], }) nowPlayingManager.subscribe((state) => { broadcastNowPlayingState(state) diff --git a/src/main/services/astraIntegration.ts b/src/main/services/astraIntegration.ts index 1921f7c..6b48829 100644 --- a/src/main/services/astraIntegration.ts +++ b/src/main/services/astraIntegration.ts @@ -1,9 +1,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import type { NowPlayingProviderState } from '../../types/nowPlaying' import { DEFAULT_ASTRA_BASE_URL, type AstraControlCommand, type AstraIntegrationConfig, + type AstraIntegrationConfigMutation, + type AstraIntegrationPublicConfig, type AstraIntegrationState, type AstraNowPlayingSnapshot, type AstraPlaybackState, @@ -11,10 +14,17 @@ import { } from '../../types/astra' const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000] +const DEFAULT_ASTRA_SECRET_KEY = 'now-playing.astra.token' type FetchLike = typeof fetch type TimerHandle = ReturnType +interface SecretVaultLike { + deleteSecret(key: string): Promise + getSecret(key: string): Promise + setSecret(key: string, value: string): Promise +} + interface RemoteTrackSnapshot { id: string title: string @@ -37,12 +47,19 @@ interface RemoteNowPlayingSnapshot { interface AstraIntegrationServiceOptions { configPath: string + secretKey?: string + secretVault: SecretVaultLike fetchImpl?: FetchLike now?: () => number setTimeoutImpl?: typeof setTimeout clearTimeoutImpl?: typeof clearTimeout } +interface PersistedAstraConfig { + baseUrl: string + token: string +} + function cloneTrackSnapshot(track: AstraTrackSnapshot | null): AstraTrackSnapshot | null { if (!track) return null return { ...track } @@ -118,9 +135,38 @@ export function normalizeAstraIntegrationConfig(raw: unknown): AstraIntegrationC } } +export function normalizeAstraIntegrationConfigMutation(raw: unknown): AstraIntegrationConfigMutation { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + const normalizedToken = typeof parsed.token === 'string' + ? parsed.token.trim() + : '' + + return { + baseUrl: normalizeBaseUrl(parsed.baseUrl), + token: normalizedToken.length > 0 ? normalizedToken : undefined, + clearToken: parsed.clearToken === true, + } +} + +function toPublicConfig(config: PersistedAstraConfig): AstraIntegrationPublicConfig { + return { + baseUrl: config.baseUrl, + hasToken: config.token.trim().length > 0, + } +} + +function createDefaultRuntimeConfig(): PersistedAstraConfig { + return { + baseUrl: DEFAULT_ASTRA_BASE_URL, + token: '', + } +} + function createDefaultState(): AstraIntegrationState { return { - config: normalizeAstraIntegrationConfig(null), + config: toPublicConfig(createDefaultRuntimeConfig()), connectionState: 'disabled', lastError: null, lastControlError: null, @@ -229,7 +275,11 @@ function appendBasePath(baseUrl: string, path: string): string { } export class AstraIntegrationService { + readonly providerId = 'astra' + private readonly configPath: string + private readonly secretKey: string + private readonly secretVault: SecretVaultLike private readonly fetchImpl: FetchLike private readonly now: () => number private readonly setTimeoutImpl: typeof setTimeout @@ -237,6 +287,7 @@ export class AstraIntegrationService { private readonly listeners = new Set<(state: AstraIntegrationState) => void>() private readonly activeConsumers = new Set() + private config = createDefaultRuntimeConfig() private state = createDefaultState() private remoteSnapshot: RemoteNowPlayingSnapshot | null = null private currentArtworkKey: string | null = null @@ -250,6 +301,8 @@ export class AstraIntegrationService { constructor(options: AstraIntegrationServiceOptions) { this.configPath = options.configPath + this.secretKey = options.secretKey ?? DEFAULT_ASTRA_SECRET_KEY + this.secretVault = options.secretVault this.fetchImpl = options.fetchImpl ?? fetch this.now = options.now ?? (() => Date.now()) this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout @@ -257,12 +310,16 @@ export class AstraIntegrationService { } async initialize(): Promise { - const config = await this.loadConfigFile() + const { config, migrationError } = await this.loadConfigFile() this.initialized = true + this.config = config this.state = { ...this.state, - config, + config: toPublicConfig(config), connectionState: 'disabled', + lastError: migrationError, + lastControlError: null, + snapshot: null, } this.emitState() if (this.isScopeActive()) { @@ -288,10 +345,23 @@ export class AstraIntegrationService { return cloneState(this.state) } - getConfig(): AstraIntegrationConfig { + getPublicConfig(): AstraIntegrationPublicConfig { return { ...this.state.config } } + getProviderState(): NowPlayingProviderState { + return { + providerId: 'astra', + connectionState: this.state.connectionState, + lastError: this.state.lastError, + lastControlError: this.state.lastControlError, + snapshot: cloneSnapshot(this.state.snapshot), + isConfigured: this.state.config.hasToken, + available: true, + supportsTransportControls: true, + } + } + async setConsumerActive(consumerId: number, active: boolean): Promise { const wasActive = this.isScopeActive() if (active) { @@ -311,19 +381,54 @@ export class AstraIntegrationService { return this.getState() } - async saveConfig(rawConfig: unknown): Promise { - const config = normalizeAstraIntegrationConfig(rawConfig) + async saveConfig(rawConfig: unknown): Promise { + const configMutation = normalizeAstraIntegrationConfigMutation(rawConfig) + let secretError: Error | null = null + + this.config = { + ...this.config, + baseUrl: configMutation.baseUrl, + } + await this.persistConfigFile(this.config) + + try { + if (configMutation.clearToken) { + await this.secretVault.deleteSecret(this.secretKey) + this.config = { + ...this.config, + token: '', + } + } else if (configMutation.token) { + await this.secretVault.setSecret(this.secretKey, configMutation.token) + this.config = { + ...this.config, + token: configMutation.token, + } + } + } catch (error) { + secretError = new Error(getErrorMessage(error, 'Prism could not save the Astra token securely.')) + } + this.state = { ...this.state, - config, + config: toPublicConfig(this.config), connectionState: this.isScopeActive() ? 'connecting' : 'disabled', lastError: null, lastControlError: null, + snapshot: null, } this.emitState() - await this.persistConfigFile(config) await this.restartConnection() - return { ...config } + + if (secretError) { + throw secretError + } + + return this.getPublicConfig() + } + + async retry(): Promise { + await this.restartConnection() } async sendControl(command: AstraControlCommand): Promise { @@ -337,7 +442,7 @@ export class AstraIntegrationService { throw new Error(errorMessage) } - if (!this.state.config.token) { + if (!this.config.token) { const errorMessage = 'An Astra API token is required before sending controls.' this.state = { ...this.state, @@ -401,7 +506,7 @@ export class AstraIntegrationService { return } - if (!this.state.config.token) { + if (!this.config.token) { this.state = { ...this.state, connectionState: 'error', @@ -415,6 +520,7 @@ export class AstraIntegrationService { ...this.state, connectionState: 'connecting', lastError: null, + snapshot: null, } this.failedArtworkKey = null this.emitState() @@ -681,7 +787,7 @@ export class AstraIntegrationService { private buildAuthHeaders(extraHeaders?: Record): HeadersInit { return { - Authorization: `Bearer ${this.state.config.token}`, + Authorization: `Bearer ${this.config.token}`, ...extraHeaders, } } @@ -691,20 +797,57 @@ export class AstraIntegrationService { } private buildEndpoint(path: string): string { - return appendBasePath(this.state.config.baseUrl, path) + return appendBasePath(this.config.baseUrl, path) } - private async loadConfigFile(): Promise { + private async loadConfigFile(): Promise<{ + config: PersistedAstraConfig + migrationError: string | null + }> { + let persistedConfig = createDefaultRuntimeConfig() + try { const raw = await readFile(this.configPath, 'utf8') - return normalizeAstraIntegrationConfig(JSON.parse(raw)) + persistedConfig = normalizeAstraIntegrationConfig(JSON.parse(raw)) } catch { - return normalizeAstraIntegrationConfig(null) + persistedConfig = createDefaultRuntimeConfig() + } + + let token = '' + let migrationError: string | null = null + + if (persistedConfig.token) { + try { + await this.secretVault.setSecret(this.secretKey, persistedConfig.token) + token = persistedConfig.token + } catch (error) { + migrationError = getErrorMessage(error, 'Prism could not migrate the Astra token into secure storage.') + } + + persistedConfig = { + ...persistedConfig, + token: '', + } + await this.persistConfigFile(persistedConfig) + } else { + try { + token = await this.secretVault.getSecret(this.secretKey) ?? '' + } catch (error) { + migrationError = getErrorMessage(error, 'Prism could not read the stored Astra token.') + } + } + + return { + config: { + baseUrl: persistedConfig.baseUrl, + token, + }, + migrationError, } } - private async persistConfigFile(config: AstraIntegrationConfig): Promise { + private async persistConfigFile(config: PersistedAstraConfig): Promise { await mkdir(dirname(this.configPath), { recursive: true }) - await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8') + await writeFile(this.configPath, `${JSON.stringify({ baseUrl: config.baseUrl }, null, 2)}\n`, 'utf8') } } diff --git a/src/main/services/macSpotifyProvider.ts b/src/main/services/macSpotifyProvider.ts new file mode 100644 index 0000000..4c1893c --- /dev/null +++ b/src/main/services/macSpotifyProvider.ts @@ -0,0 +1,675 @@ +import { access } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { promisify } from 'node:util' +import type { + NowPlayingControlCommand, + NowPlayingProviderState, + NowPlayingSnapshot, +} from '../../types/nowPlaying' +import type { NowPlayingProviderService } from './nowPlayingProvider' + +type FetchLike = typeof fetch +type AccessLike = typeof access +type AppleScriptRunner = (scriptLines: string[]) => Promise + +interface MacSpotifyProviderOptions { + accessImpl?: AccessLike + appPathCandidates?: string[] + fetchImpl?: FetchLike + now?: () => number + platform?: NodeJS.Platform + runner?: AppleScriptRunner +} + +interface LocalSpotifyTrackSnapshot { + id: string + title: string + artist: string + album: string + isFavorite: boolean + artworkUrl: string | null +} + +interface LocalSpotifySnapshot { + playbackState: 'stopped' | 'playing' | 'paused' + currentTime: number + duration: number + currentTrack: LocalSpotifyTrackSnapshot | null + updatedAt: number +} + +const FAST_POLL_MS = 1500 +const SLOW_POLL_MS = 5000 +const SPOTIFY_ARTWORK_COLOR = '#1ed760' +const SPOTIFY_DELIMITER = '\u001f' +const SPOTIFY_APP_BUNDLE_ID = 'com.spotify.client' +const execFileAsync = promisify(execFile) + +function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null { + if (!snapshot) { + return null + } + + return { + ...snapshot, + currentTrack: snapshot.currentTrack ? { ...snapshot.currentTrack } : null, + } +} + +function cloneProviderState(state: NowPlayingProviderState): NowPlayingProviderState { + return { + ...state, + snapshot: cloneSnapshot(state.snapshot), + } +} + +function getDefaultSpotifyAppCandidates(): string[] { + return [ + '/Applications/Spotify.app', + join(homedir(), 'Applications', 'Spotify.app'), + ] +} + +function createDefaultState(available: boolean): NowPlayingProviderState { + return { + providerId: 'spotify', + connectionState: available ? 'disabled' : 'unavailable', + lastError: null, + lastControlError: null, + snapshot: null, + isConfigured: available, + available, + supportsTransportControls: available, + } +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message + ? error.message + : fallback +} + +function normalizeSpotifyError(error: unknown, fallback: string): Error { + const message = getErrorMessage(error, fallback) + + if (message.includes('(-1743)') || /not authorized|not permitted|automation/i.test(message)) { + return new Error('Prism needs macOS Automation permission to control Spotify.') + } + + if (message.includes('(-2700)') || message.includes('(-1728)') || /application can.?t be found/i.test(message)) { + return new Error('Spotify.app is not installed.') + } + + if (message.includes('(-128)')) { + return new Error('Spotify did not allow Prism to complete that request.') + } + + return new Error(message) +} + +function normalizeString(value: string | undefined): string { + return (value ?? '').trim() +} + +function toSafeNumber(value: string | undefined): number { + const numeric = Number.parseFloat((value ?? '').trim()) + if (!Number.isFinite(numeric)) { + return 0 + } + + return Math.max(0, numeric) +} + +function toOptionalUrl(value: string | undefined): string | null { + const normalized = normalizeString(value) + if (!normalized) { + return null + } + + try { + const parsed = new URL(normalized) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString() + } + } catch { + return null + } + + return null +} + +function createTrackId(title: string, artist: string, album: string): string { + return `spotify-local:${title}\n${artist}\n${album}` +} + +function parseSpotifyStatusOutput(output: string, now: () => number): LocalSpotifySnapshot | null { + const trimmed = output.trim() + if (!trimmed || trimmed === 'not_running') { + return null + } + + const parts = trimmed.split(SPOTIFY_DELIMITER) + if (parts[0] !== 'ok') { + throw new Error('Prism received an invalid Spotify status payload.') + } + + const playbackState = parts[1] === 'playing' || parts[1] === 'paused' + ? parts[1] + : 'stopped' + const currentTime = toSafeNumber(parts[2]) + const duration = toSafeNumber(parts[3]) + const trackId = normalizeString(parts[4]) + const title = normalizeString(parts[5]) + const artist = normalizeString(parts[6]) + const album = normalizeString(parts[7]) + const artworkUrl = toOptionalUrl(parts[8]) + const isFavorite = normalizeString(parts[9]).toLowerCase() === 'true' + + const currentTrack = title && artist + ? { + id: trackId || createTrackId(title, artist, album), + title, + artist, + album, + isFavorite, + artworkUrl, + } satisfies LocalSpotifyTrackSnapshot + : null + + return { + playbackState, + currentTime, + duration, + currentTrack, + updatedAt: now(), + } +} + +function getArtworkKey(trackId: string | null, artworkUrl: string | null): string | null { + if (!trackId || !artworkUrl) { + return null + } + + return `${trackId}\n${artworkUrl}` +} + +function toPublicSnapshot( + snapshot: LocalSpotifySnapshot, + artworkDataUrl: string | null, +): NowPlayingSnapshot { + return { + playbackState: snapshot.playbackState, + currentTime: snapshot.currentTime, + duration: snapshot.duration, + queueLength: 0, + outputDeviceLabel: null, + visualizerLineColor: SPOTIFY_ARTWORK_COLOR, + currentTrack: snapshot.currentTrack + ? { + id: snapshot.currentTrack.id, + title: snapshot.currentTrack.title, + artist: snapshot.currentTrack.artist, + album: snapshot.currentTrack.album, + isFavorite: snapshot.currentTrack.isFavorite, + artworkDataUrl, + } + : null, + updatedAt: snapshot.updatedAt, + } +} + +function buildSpotifyStatusScript(): string[] { + return [ + 'on replace_text(source_text, search_text, replacement_text)', + ' set AppleScript\'s text item delimiters to search_text', + ' set text_items to every text item of source_text', + ' set AppleScript\'s text item delimiters to replacement_text', + ' set next_text to text_items as text', + ' set AppleScript\'s text item delimiters to ""', + ' return next_text', + 'end replace_text', + 'on sanitize(source_text)', + ` set delimiter_character to "${SPOTIFY_DELIMITER}"`, + ' set next_text to source_text as text', + ' set next_text to my replace_text(next_text, return, " ")', + ' set next_text to my replace_text(next_text, linefeed, " ")', + ' set next_text to my replace_text(next_text, delimiter_character, " ")', + ' return next_text', + 'end sanitize', + `if application id "${SPOTIFY_APP_BUNDLE_ID}" is not running then`, + ' return "not_running"', + 'end if', + `tell application id "${SPOTIFY_APP_BUNDLE_ID}"`, + ' set state_label to "stopped"', + ' if player state is playing then set state_label to "playing"', + ' if player state is paused then set state_label to "paused"', + ' set track_duration to "0"', + ' set track_id to ""', + ' set track_title to ""', + ' set track_artist to ""', + ' set track_album to ""', + ' set track_artwork_url to ""', + ' set track_starred to "false"', + ' try', + ' set track_ref to current track', + ' try', + ' set track_duration to duration of track_ref as text', + ' end try', + ' try', + ' set track_id to my sanitize(id of track_ref)', + ' end try', + ' try', + ' set track_title to my sanitize(name of track_ref)', + ' end try', + ' try', + ' set track_artist to my sanitize(artist of track_ref)', + ' end try', + ' try', + ' set track_album to my sanitize(album of track_ref)', + ' end try', + ' try', + ' set track_artwork_url to my sanitize(artwork url of track_ref)', + ' end try', + ' try', + ' if starred of track_ref is true then set track_starred to "true"', + ' end try', + ' end try', + ` set AppleScript's text item delimiters to "${SPOTIFY_DELIMITER}"`, + ' set payload to {"ok", state_label, (player position as text), track_duration, track_id, track_title, track_artist, track_album, track_artwork_url, track_starred} as text', + ' set AppleScript\'s text item delimiters to ""', + ' return payload', + 'end tell', + ] +} + +function buildSpotifyCommandScript(command: NowPlayingControlCommand): string[] { + const commandLine = (() => { + switch (command) { + case 'play': + return 'play' + case 'pause': + return 'pause' + case 'next': + return 'next track' + case 'previous': + return 'previous track' + } + })() + + return [ + `if application id "${SPOTIFY_APP_BUNDLE_ID}" is not running then`, + ' error "Spotify is not running." number 7001', + 'end if', + `tell application id "${SPOTIFY_APP_BUNDLE_ID}"`, + ` ${commandLine}`, + 'end tell', + ] +} + +async function defaultAppleScriptRunner(scriptLines: string[]): Promise { + const args = scriptLines.flatMap((line) => ['-e', line]) + const { stdout } = await execFileAsync('osascript', args) + return stdout.trim() +} + +export class MacSpotifyProvider implements NowPlayingProviderService<'spotify'> { + readonly providerId = 'spotify' + + private readonly accessImpl: AccessLike + private readonly appPathCandidates: string[] + private readonly fetchImpl: FetchLike + private readonly now: () => number + private readonly platform: NodeJS.Platform + private readonly runner: AppleScriptRunner + private readonly listeners = new Set<() => void>() + private readonly activeConsumers = new Set() + + private state = createDefaultState(false) + private initialized = false + private disposed = false + private currentSnapshot: LocalSpotifySnapshot | null = null + private currentArtworkKey: string | null = null + private currentArtworkDataUrl: string | null = null + private failedArtworkKey: string | null = null + private pollAbortController: AbortController | null = null + private refreshChain = Promise.resolve() + + constructor(options: MacSpotifyProviderOptions = {}) { + this.accessImpl = options.accessImpl ?? access + this.appPathCandidates = options.appPathCandidates ?? getDefaultSpotifyAppCandidates() + this.fetchImpl = options.fetchImpl ?? fetch + this.now = options.now ?? (() => Date.now()) + this.platform = options.platform ?? process.platform + this.runner = options.runner ?? defaultAppleScriptRunner + } + + async initialize(): Promise { + if (this.initialized) { + return + } + + await this.refreshAvailability() + this.initialized = true + this.emitState() + if (this.isScopeActive()) { + this.startPolling() + } + } + + async dispose(): Promise { + this.disposed = true + this.stopPolling() + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + getPublicConfig(): Record { + return {} + } + + getProviderState(): NowPlayingProviderState { + return cloneProviderState(this.state) + } + + async setConsumerActive(consumerId: number, active: boolean): Promise { + if (active) { + this.activeConsumers.add(consumerId) + } else { + this.activeConsumers.delete(consumerId) + } + + if (!this.initialized) { + return + } + + if (!this.state.available) { + this.resetInactiveState() + return + } + + if (this.isScopeActive()) { + this.startPolling() + return + } + + this.stopPolling() + this.resetInactiveState() + } + + async saveConfig(_rawConfig: Record): Promise { + throw new Error('Spotify does not require configuration.') + } + + async retry(): Promise { + await this.refreshAvailability() + if (!this.state.available) { + this.resetInactiveState() + this.emitState() + throw new Error(this.platform === 'darwin' + ? 'Install Spotify.app to enable the local Spotify integration.' + : 'Local Spotify integration is only available on macOS.') + } + + if (!this.isScopeActive()) { + this.emitState() + return + } + + this.startPolling(true) + await this.queueRefresh() + } + + async sendControl(command: NowPlayingControlCommand): Promise { + if (!this.state.available) { + throw new Error(this.platform === 'darwin' + ? 'Install Spotify.app to enable the local Spotify integration.' + : 'Local Spotify integration is only available on macOS.') + } + + try { + await this.runner(buildSpotifyCommandScript(command)) + this.state = { + ...this.state, + lastControlError: null, + } + this.emitState() + if (this.isScopeActive()) { + await this.queueRefresh() + } + } catch (error) { + const normalizedError = normalizeSpotifyError(error, 'Prism could not control Spotify.') + this.state = { + ...this.state, + lastControlError: normalizedError.message, + } + this.emitState() + throw normalizedError + } + } + + private emitState(): void { + for (const listener of this.listeners) { + listener() + } + } + + private isScopeActive(): boolean { + return this.activeConsumers.size > 0 + } + + private async refreshAvailability(): Promise { + if (this.platform !== 'darwin') { + this.state = createDefaultState(false) + return + } + + for (const candidatePath of this.appPathCandidates) { + try { + await this.accessImpl(candidatePath) + this.state = { + ...createDefaultState(true), + lastError: this.state.lastError, + lastControlError: this.state.lastControlError, + snapshot: cloneSnapshot(this.state.snapshot), + } + return + } catch { + continue + } + } + + this.state = createDefaultState(false) + } + + private resetInactiveState(): void { + this.currentSnapshot = null + this.currentArtworkKey = null + this.currentArtworkDataUrl = null + this.failedArtworkKey = null + this.state = { + ...createDefaultState(this.state.available), + available: this.state.available, + isConfigured: this.state.available, + supportsTransportControls: this.state.available, + connectionState: this.state.available ? 'disabled' : 'unavailable', + } + } + + private startPolling(immediate = false): void { + if (!this.state.available || !this.isScopeActive() || this.disposed) { + return + } + + if (this.pollAbortController) { + if (immediate) { + void this.queueRefresh() + } + return + } + + this.state = { + ...this.state, + connectionState: 'connecting', + lastError: null, + } + this.emitState() + + const controller = new AbortController() + this.pollAbortController = controller + + if (immediate) { + void this.queueRefresh() + } + + void this.runPollLoop(controller.signal).finally(() => { + if (this.pollAbortController === controller) { + this.pollAbortController = null + } + }) + } + + private stopPolling(): void { + if (!this.pollAbortController) { + return + } + + this.pollAbortController.abort() + this.pollAbortController = null + } + + private async runPollLoop(signal: AbortSignal): Promise { + while (!signal.aborted && this.isScopeActive() && this.state.available && !this.disposed) { + try { + await this.queueRefresh() + } catch { + // Refresh errors are already reflected in provider state. + } + + const nextDelay = this.state.snapshot?.playbackState === 'playing' + ? FAST_POLL_MS + : SLOW_POLL_MS + + try { + await delay(nextDelay, undefined, { signal }) + } catch { + break + } + } + } + + private queueRefresh(): Promise { + const nextRefresh = this.refreshChain + .catch(() => undefined) + .then(() => this.refreshNow()) + this.refreshChain = nextRefresh + return nextRefresh + } + + private async refreshNow(): Promise { + try { + const snapshot = await this.readSpotifySnapshot() + this.currentSnapshot = snapshot + this.failedArtworkKey = snapshot === null ? null : this.failedArtworkKey + + if (!snapshot) { + this.currentArtworkKey = null + this.currentArtworkDataUrl = null + this.state = { + ...this.state, + connectionState: 'disabled', + lastError: null, + snapshot: null, + } + this.emitState() + return + } + + const artworkKey = getArtworkKey(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null) + const artworkDataUrl = artworkKey && artworkKey === this.currentArtworkKey + ? this.currentArtworkDataUrl + : null + + this.state = { + ...this.state, + connectionState: 'connected', + lastError: null, + snapshot: toPublicSnapshot(snapshot, artworkDataUrl), + } + this.emitState() + + await this.refreshArtwork(snapshot.currentTrack?.id ?? null, snapshot.currentTrack?.artworkUrl ?? null) + } catch (error) { + const normalizedError = normalizeSpotifyError(error, 'Prism could not read Spotify now-playing state.') + this.state = { + ...this.state, + connectionState: 'error', + lastError: normalizedError.message, + snapshot: null, + } + this.emitState() + throw normalizedError + } + } + + private async readSpotifySnapshot(): Promise { + const output = await this.runner(buildSpotifyStatusScript()) + return parseSpotifyStatusOutput(output, this.now) + } + + private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise { + const artworkKey = getArtworkKey(trackId, artworkUrl) + const snapshotArtworkKey = getArtworkKey( + this.currentSnapshot?.currentTrack?.id ?? null, + this.currentSnapshot?.currentTrack?.artworkUrl ?? null, + ) + + if (!artworkKey || artworkKey !== snapshotArtworkKey) { + this.currentArtworkKey = null + this.currentArtworkDataUrl = null + this.failedArtworkKey = null + return + } + + if (artworkKey === this.currentArtworkKey && this.currentArtworkDataUrl) { + return + } + + if (artworkKey === this.failedArtworkKey || !artworkUrl) { + return + } + + const response = await this.fetchImpl(artworkUrl).catch(() => null) + if (!response || !response.ok) { + this.failedArtworkKey = artworkKey + return + } + + const mimeType = response.headers.get('content-type') ?? 'image/jpeg' + const bytes = Buffer.from(await response.arrayBuffer()) + const artworkDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}` + const currentSnapshot = this.currentSnapshot + if (!currentSnapshot || getArtworkKey( + currentSnapshot.currentTrack?.id ?? null, + currentSnapshot.currentTrack?.artworkUrl ?? null, + ) !== artworkKey) { + return + } + + this.currentArtworkKey = artworkKey + this.currentArtworkDataUrl = artworkDataUrl + this.failedArtworkKey = null + this.state = { + ...this.state, + snapshot: toPublicSnapshot(currentSnapshot, artworkDataUrl), + } + this.emitState() + } +} diff --git a/src/main/services/nowPlayingManager.ts b/src/main/services/nowPlayingManager.ts index af61b7d..a0e4f0e 100644 --- a/src/main/services/nowPlayingManager.ts +++ b/src/main/services/nowPlayingManager.ts @@ -1,6 +1,5 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import type { AstraIntegrationConfig, AstraIntegrationState } from '../../types/astra' import { NOW_PLAYING_PROVIDER_DEFINITIONS, NOW_PLAYING_PROVIDER_IDS, @@ -12,29 +11,22 @@ import { type NowPlayingSnapshot, type NowPlayingState, } from '../../types/nowPlaying' -import { AstraIntegrationService } from './astraIntegration' +import type { ManagedNowPlayingProviderId, NowPlayingProviderService } from './nowPlayingProvider' interface NowPlayingManagerOptions { - astraConfigPath: string localStatePath: string - astraService?: AstraServiceLike + providerServices: NowPlayingProviderService[] } +type ProviderServiceMap = Partial<{ + astra: NowPlayingProviderService<'astra'> + spotify: NowPlayingProviderService<'spotify'> +}> + interface NowPlayingLocalState { providerPriority: NowPlayingProviderId[] } -interface AstraServiceLike { - initialize(): Promise - dispose(): Promise - subscribe(listener: () => void): () => void - getState(): AstraIntegrationState - getConfig(): AstraIntegrationConfig - setConsumerActive(consumerId: number, active: boolean): Promise - saveConfig(rawConfig: unknown): Promise - sendControl(command: NowPlayingControlCommand): Promise -} - function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null { if (!snapshot) return null return { @@ -104,29 +96,7 @@ function normalizeLocalState(raw: unknown): NowPlayingLocalState { } } -function isConfiguredAstraProvider(config: AstraIntegrationConfig): boolean { - return typeof config.token === 'string' && config.token.trim().length > 0 -} - -function toAstraProviderState(state: AstraIntegrationState): NowPlayingProviderState { - return { - providerId: 'astra', - connectionState: state.connectionState, - lastError: state.lastError, - lastControlError: state.lastControlError, - snapshot: state.snapshot ? { - ...state.snapshot, - currentTrack: state.snapshot.currentTrack - ? { ...state.snapshot.currentTrack } - : null, - } : null, - isConfigured: isConfiguredAstraProvider(state.config), - available: true, - supportsTransportControls: true, - } -} - -function createUnavailableProviderState(providerId: Exclude): NowPlayingProviderState { +function createPlaceholderProviderState(providerId: Exclude): NowPlayingProviderState { const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId] return { providerId, @@ -141,36 +111,45 @@ function createUnavailableProviderState(providerId: Exclude void>() + private readonly providerServices: ProviderServiceMap private providerPriority = [...NOW_PLAYING_PROVIDER_IDS] private initialized = false constructor(options: NowPlayingManagerOptions) { - this.astraService = options.astraService ?? new AstraIntegrationService({ - configPath: options.astraConfigPath, - }) this.localStatePath = options.localStatePath - this.astraService.subscribe(() => { - if (!this.initialized) { - return + this.providerServices = options.providerServices.reduce((acc, providerService) => { + if (providerService.providerId === 'astra') { + acc.astra = providerService as NowPlayingProviderService<'astra'> + } else if (providerService.providerId === 'spotify') { + acc.spotify = providerService as NowPlayingProviderService<'spotify'> } - this.emitState() - }) + providerService.subscribe(() => { + if (!this.initialized) { + return + } + this.emitState() + }) + return acc + }, {} as ProviderServiceMap) } async initialize(): Promise { if (this.initialized) return const localState = await this.readLocalState() this.providerPriority = localState.providerPriority - await this.astraService.initialize() + await Promise.all(Object.values(this.providerServices).map(async (providerService) => { + await providerService?.initialize() + })) this.initialized = true this.emitState() } async dispose(): Promise { - await this.astraService.dispose() + await Promise.all(Object.values(this.providerServices).map(async (providerService) => { + await providerService?.dispose() + })) } subscribe(listener: (state: NowPlayingState) => void): () => void { @@ -187,7 +166,9 @@ export class NowPlayingManager { async setConsumerActive(consumerId: number, active: boolean): Promise { await this.ensureInitialized() - await this.astraService.setConsumerActive(consumerId, active) + await Promise.all(Object.values(this.providerServices).map(async (providerService) => { + await providerService?.setConsumerActive(consumerId, active) + })) return this.getState() } @@ -197,28 +178,24 @@ export class NowPlayingManager { ): Promise { await this.ensureInitialized() - switch (providerId) { - case 'astra': - await this.astraService.saveConfig(rawConfig) - break - default: - throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not configurable yet.`) + const providerService = this.providerServices[providerId as ManagedNowPlayingProviderId] + if (!providerService) { + throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not configurable yet.`) } + await providerService.saveConfig(rawConfig as never) return this.getState() } async retryProvider(providerId: NowPlayingProviderId): Promise { await this.ensureInitialized() - switch (providerId) { - case 'astra': - await this.astraService.saveConfig(this.astraService.getConfig()) - break - default: - throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not available yet.`) + const providerService = this.providerServices[providerId as ManagedNowPlayingProviderId] + if (!providerService) { + throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not available yet.`) } + await providerService.retry() return this.getState() } @@ -234,31 +211,52 @@ export class NowPlayingManager { await this.ensureInitialized() const activeProviderId = this.buildState().activeProviderId - switch (activeProviderId) { - case 'astra': - await this.astraService.sendControl(command) - break - case null: - throw new Error('No active now-playing provider is available.') - default: - throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[activeProviderId].label} controls are not available yet.`) + if (!activeProviderId) { + throw new Error('No active now-playing provider is available.') } + const providerService = this.providerServices[activeProviderId as ManagedNowPlayingProviderId] + if (!providerService) { + throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[activeProviderId].label} controls are not available yet.`) + } + + await providerService.sendControl(command) return this.getState() } private buildState(): NowPlayingState { - const astraState = this.astraService.getState() const configs: NowPlayingProviderConfigMap = { - astra: { ...astraState.config }, - spotify: {}, + astra: this.providerServices.astra?.getPublicConfig() ?? { + baseUrl: 'http://127.0.0.1:38401', + hasToken: false, + }, + spotify: this.providerServices.spotify?.getPublicConfig() ?? {}, tidal: {}, } const providers: NowPlayingProviderStateMap = { - astra: toAstraProviderState(astraState), - spotify: createUnavailableProviderState('spotify'), - tidal: createUnavailableProviderState('tidal'), + astra: this.providerServices.astra?.getProviderState() ?? { + providerId: 'astra', + connectionState: 'disabled', + lastError: null, + lastControlError: null, + snapshot: null, + isConfigured: false, + available: true, + supportsTransportControls: true, + }, + spotify: this.providerServices.spotify?.getProviderState() ?? { + providerId: 'spotify', + connectionState: 'unavailable', + lastError: null, + lastControlError: null, + snapshot: null, + isConfigured: false, + available: false, + supportsTransportControls: true, + }, + tidal: createPlaceholderProviderState('tidal'), } + const hasConfiguredProvider = this.providerPriority.some((providerId) => { const provider = providers[providerId] return provider.available && provider.isConfigured diff --git a/src/main/services/nowPlayingProvider.ts b/src/main/services/nowPlayingProvider.ts new file mode 100644 index 0000000..6a500ef --- /dev/null +++ b/src/main/services/nowPlayingProvider.ts @@ -0,0 +1,22 @@ +import type { + NowPlayingControlCommand, + NowPlayingProviderConfigMap, + NowPlayingProviderConfigMutationMap, + NowPlayingProviderId, + NowPlayingProviderState, +} from '../../types/nowPlaying' + +export type ManagedNowPlayingProviderId = Exclude + +export interface NowPlayingProviderService { + readonly providerId: K + initialize(): Promise + dispose(): Promise + subscribe(listener: () => void): () => void + getPublicConfig(): NowPlayingProviderConfigMap[K] + getProviderState(): NowPlayingProviderState + setConsumerActive(consumerId: number, active: boolean): Promise + saveConfig(rawConfig: NowPlayingProviderConfigMutationMap[K]): Promise + retry(): Promise + sendControl(command: NowPlayingControlCommand): Promise +} diff --git a/src/main/services/secretVault.ts b/src/main/services/secretVault.ts new file mode 100644 index 0000000..6ed5035 --- /dev/null +++ b/src/main/services/secretVault.ts @@ -0,0 +1,130 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +interface SafeStorageLike { + decryptString(encrypted: Buffer): string + encryptString(plainText: string): Buffer + getSelectedStorageBackend?: () => string + isEncryptionAvailable(): boolean +} + +interface SecretVaultOptions { + path: string + platform: NodeJS.Platform + safeStorage: SafeStorageLike +} + +interface SecretVaultFile { + version: 1 + secrets: Record +} + +const EMPTY_VAULT: SecretVaultFile = { + version: 1, + secrets: {}, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function normalizeVaultFile(raw: unknown): SecretVaultFile { + if (!isRecord(raw)) { + return { ...EMPTY_VAULT, secrets: {} } + } + + const rawSecrets = isRecord(raw.secrets) ? raw.secrets : {} + const secrets = Object.entries(rawSecrets).reduce((acc, [key, value]) => { + if (typeof value === 'string' && value.trim()) { + acc[key] = value.trim() + } + return acc + }, {} as Record) + + return { + version: 1, + secrets, + } +} + +function getVaultUnavailableMessage(platform: NodeJS.Platform): string { + if (platform === 'linux') { + return 'Secure secret storage is unavailable because Prism could not access a supported Linux keyring.' + } + + return 'Secure secret storage is unavailable on this device.' +} + +export class SecretVault { + private readonly path: string + private readonly platform: NodeJS.Platform + private readonly safeStorage: SafeStorageLike + + constructor(options: SecretVaultOptions) { + this.path = options.path + this.platform = options.platform + this.safeStorage = options.safeStorage + } + + async getSecret(key: string): Promise { + const vault = await this.readVaultFile() + const encoded = vault.secrets[key] + if (!encoded) { + return null + } + + this.assertEncryptionAvailable() + return this.safeStorage.decryptString(Buffer.from(encoded, 'base64')) + } + + async setSecret(key: string, value: string): Promise { + this.assertEncryptionAvailable() + + const normalized = value.trim() + if (!normalized) { + await this.deleteSecret(key) + return + } + + const vault = await this.readVaultFile() + vault.secrets[key] = this.safeStorage.encryptString(normalized).toString('base64') + await this.writeVaultFile(vault) + } + + async deleteSecret(key: string): Promise { + const vault = await this.readVaultFile() + if (!vault.secrets[key]) { + return + } + + delete vault.secrets[key] + await this.writeVaultFile(vault) + } + + private assertEncryptionAvailable(): void { + if (!this.safeStorage.isEncryptionAvailable()) { + throw new Error(getVaultUnavailableMessage(this.platform)) + } + + if (this.platform === 'linux' && typeof this.safeStorage.getSelectedStorageBackend === 'function') { + const backend = this.safeStorage.getSelectedStorageBackend() + if (backend === 'basic_text' || backend === 'unknown') { + throw new Error(getVaultUnavailableMessage(this.platform)) + } + } + } + + private async readVaultFile(): Promise { + try { + const raw = await readFile(this.path, 'utf8') + return normalizeVaultFile(JSON.parse(raw) as unknown) + } catch { + return { ...EMPTY_VAULT, secrets: {} } + } + } + + private async writeVaultFile(vault: SecretVaultFile): Promise { + await mkdir(dirname(this.path), { recursive: true }) + await writeFile(this.path, `${JSON.stringify(vault, null, 2)}\n`, 'utf8') + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 21f099c..6e26151 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,7 +3,7 @@ import type { CaptureBackendSupport } from '../types/capture' import type { NativeCaptureAPI } from '../types/nativeCapture' import type { NowPlayingControlCommand, - NowPlayingProviderConfigMap, + NowPlayingProviderConfigMutationMap, NowPlayingProviderId, NowPlayingState, } from '../types/nowPlaying' @@ -51,7 +51,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise, setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise, - saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => { + saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => { return ipcRenderer.invoke('now-playing:save-provider-config', providerId, config) as Promise }, setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 178008a..6c323e9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -29,11 +29,18 @@ export default function App(): JSX.Element { const initializeThemes = useThemeStore((s) => s.initializeThemes) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const initializeNowPlaying = useNowPlayingStore((s) => s.initialize) + const setNowPlayingConsumerActive = useNowPlayingStore((s) => s.setConsumerActive) + const scopeOrder = useSettingsStore((s) => s.scopeOrder) + const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) + const scopePopouts = useSettingsStore((s) => s.scopePopouts) const settingsOpen = useUiStore((s) => s.settingsOpen) const toggleSettings = useUiStore((s) => s.toggleSettings) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) const showBanner = useUiStore((s) => s.showBanner) + const isNowPlayingVisible = !hiddenScopes.has('nowPlaying') + && (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true) + // Auto-capture on launch useEffect(() => { const { isCapturing, captureStatus, startCapture } = useAudioStore.getState() @@ -135,6 +142,15 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) + useEffect(() => { + void initializeNowPlaying() + .then(() => setNowPlayingConsumerActive(isNowPlayingVisible)) + + return () => { + void setNowPlayingConsumerActive(false) + } + }, [initializeNowPlaying, isNowPlayingVisible, setNowPlayingConsumerActive]) + const settingsHeight = resolveMainWindowSettingsHeight( settingsOpen, settingsPanelHeight, diff --git a/src/renderer/components/AstraScopeModule.tsx b/src/renderer/components/AstraScopeModule.tsx index a0c53c7..02cfa93 100644 --- a/src/renderer/components/AstraScopeModule.tsx +++ b/src/renderer/components/AstraScopeModule.tsx @@ -39,7 +39,26 @@ function getFallbackTitle( providerId: NowPlayingProviderId | null, connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null, ): string { - if (providerId !== 'astra' || connectionState === null) { + if (connectionState === null) { + return 'Nothing playing' + } + + if (providerId === 'spotify') { + switch (connectionState) { + case 'disabled': + return 'Spotify is idle' + case 'connecting': + return 'Checking Spotify' + case 'error': + return 'Spotify connection failed' + case 'connected': + return 'Nothing playing' + case 'unavailable': + return 'Spotify unavailable' + } + } + + if (providerId !== 'astra') { return 'Nothing playing' } @@ -61,7 +80,26 @@ function getFallbackDetail( providerId: NowPlayingProviderId | null, connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null, ): string { - if (providerId !== 'astra' || connectionState === null) { + if (connectionState === null) { + return '' + } + + if (providerId === 'spotify') { + switch (connectionState) { + case 'disabled': + return 'Open Spotify on this Mac to show local playback here.' + case 'connecting': + return 'Waiting for the local Spotify app.' + case 'error': + return 'Check Spotify access in System Settings > Privacy & Security > Automation.' + case 'connected': + return '' + case 'unavailable': + return 'Install Spotify.app to enable this provider.' + } + } + + if (providerId !== 'astra') { return '' } @@ -84,7 +122,6 @@ export default function AstraScopeModule({ settings, }: AstraScopeModuleProps): JSX.Element { const initialize = useNowPlayingStore((s) => s.initialize) - const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive) const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState) const isSendingControl = useNowPlayingStore((s) => s.isSendingControl) const sendControl = useNowPlayingStore((s) => s.sendControl) @@ -95,11 +132,7 @@ export default function AstraScopeModule({ useEffect(() => { void initialize() - void setConsumerActive(true) - return () => { - void setConsumerActive(false) - } - }, [initialize, setConsumerActive]) + }, [initialize]) const configuredProviderId = useMemo( () => getConfiguredProviderId(nowPlayingState), diff --git a/src/renderer/components/NowPlayingConfigWindow.tsx b/src/renderer/components/NowPlayingConfigWindow.tsx index 6114ef9..ac16482 100644 --- a/src/renderer/components/NowPlayingConfigWindow.tsx +++ b/src/renderer/components/NowPlayingConfigWindow.tsx @@ -103,6 +103,10 @@ function getProviderStatusLabel( return 'Coming Soon' } + if (!provider.available) { + return 'Unavailable' + } + if (!provider.isConfigured) { return 'Not Set Up' } @@ -117,7 +121,7 @@ function getProviderStatusLabel( case 'error': return 'Error' case 'unavailable': - return 'Coming Soon' + return 'Unavailable' } } @@ -126,13 +130,36 @@ function getProviderMetaText( provider: NowPlayingProviderState, ): string { if (definition.comingSoon) { - return 'OAuth login coming later' + return 'Local integration coming later' + } + + if (!provider.available) { + return provider.providerId === 'spotify' + ? 'Local macOS app unavailable' + : 'Unavailable on this device' } if (!provider.isConfigured) { return 'Local API · Paste base URL and token' } + if (definition.authMode === 'local') { + switch (provider.connectionState) { + case 'disabled': + return 'Local macOS app · Waiting for Spotify' + case 'connecting': + return 'Local macOS app · Checking playback' + case 'connected': + return provider.snapshot?.playbackState === 'playing' + ? 'Local macOS app · Playing now' + : 'Local macOS app · Ready' + case 'error': + return 'Local macOS app · Needs attention' + case 'unavailable': + return 'Local macOS app unavailable' + } + } + switch (provider.connectionState) { case 'disabled': return 'Local API · Waiting for use' @@ -192,7 +219,6 @@ export default function NowPlayingConfigWindow(): JSX.Element { const initializeThemes = useThemeStore((s) => s.initializeThemes) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const initializeNowPlaying = useNowPlayingStore((s) => s.initialize) - const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive) const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState) const saveProviderConfig = useNowPlayingStore((s) => s.saveProviderConfig) const setProviderPriority = useNowPlayingStore((s) => s.setProviderPriority) @@ -210,7 +236,6 @@ export default function NowPlayingConfigWindow(): JSX.Element { void (async () => { await initializeThemes() await initializeNowPlaying() - await setConsumerActive(true) })().catch((error) => { if (disposed) return showBanner({ @@ -227,21 +252,19 @@ export default function NowPlayingConfigWindow(): JSX.Element { return () => { disposed = true unsubscribeTheme() - void setConsumerActive(false) window.electronAPI.stopWindowMove() } }, [ applyExternalThemeSnapshot, initializeNowPlaying, initializeThemes, - setConsumerActive, showBanner, ]) useEffect(() => { setAstraBaseUrlInput(nowPlayingState.configs.astra.baseUrl) - setAstraTokenInput(nowPlayingState.configs.astra.token) - }, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.token]) + setAstraTokenInput('') + }, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.hasToken]) const handleToolbarDragStart = useCallback((event: ReactPointerEvent): void => { if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return @@ -263,6 +286,7 @@ export default function NowPlayingConfigWindow(): JSX.Element { baseUrl: astraBaseUrlInput, token: astraTokenInput, }) + setAstraTokenInput('') } catch (error) { showBanner({ tone: 'error', @@ -272,6 +296,22 @@ export default function NowPlayingConfigWindow(): JSX.Element { } }, [astraBaseUrlInput, astraTokenInput, saveProviderConfig, showBanner]) + const handleClearAstraToken = useCallback(async (): Promise => { + try { + await saveProviderConfig('astra', { + baseUrl: astraBaseUrlInput, + clearToken: true, + }) + setAstraTokenInput('') + } catch (error) { + showBanner({ + tone: 'error', + message: getErrorMessage(error, 'Could not clear the Astra token.'), + actions: [], + }) + } + }, [astraBaseUrlInput, saveProviderConfig, showBanner]) + const orderedProviders = useMemo( () => nowPlayingState.providerPriority.map((providerId) => ({ providerId, @@ -328,7 +368,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
Now Playing
- Drag providers to set priority. Expand a row to configure keys or logins. + Drag providers to set priority. Expand a row to configure local integrations or tokens.
@@ -358,7 +398,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
{nowPlayingState.onboardingRequired - ? 'Start with Astra. Spotify and TIDAL stay visible here for ordering, but their login flows are still coming later.' + ? 'Start with Astra or the local Spotify macOS app. TIDAL stays visible here for future priority.' : 'The highest configured provider that starts playing takes over immediately.'}
@@ -468,12 +508,18 @@ export default function NowPlayingConfigWindow(): JSX.Element { className="bottom-bar__text-input now-playing-config__input" type="password" value={astraTokenInput} - placeholder="Astra API Token" + placeholder={nowPlayingState.configs.astra.hasToken ? 'Leave blank to keep the stored token' : 'Astra API Token'} onChange={(event) => setAstraTokenInput(event.target.value)} />
+
+ {nowPlayingState.configs.astra.hasToken + ? 'A token is already stored securely. Save with a blank field to keep it, or enter a new token to replace it.' + : 'No Astra token is stored yet.'} +
+
+
- ) : ( + ) : definition.comingSoon ? (
{definition.description}
- Login and connection flow land here later. For now, this row exists so you can set future priority. + This row stays visible so you can set future priority before the integration lands.
+ ) : ( +
+
+ {definition.description} +
+
+ No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly. +
+
+ +
+ {!provider.available ? ( +
+ {window.electronAPI.platform === 'darwin' + ? 'Install Spotify.app in /Applications to enable this provider.' + : 'This provider is currently available on macOS only.'} +
+ ) : null} + {provider.lastError || provider.lastControlError ? ( +
+ {provider.lastError ?? provider.lastControlError} +
+ ) : null} +
) ) : null} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index d1efda4..b3dfcee 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -6,6 +6,7 @@ import type { NativeCaptureAPI } from '../types/nativeCapture' import type { NowPlayingControlCommand, NowPlayingProviderConfigMap, + NowPlayingProviderConfigMutationMap, NowPlayingProviderId, NowPlayingState, } from '../types/nowPlaying' @@ -52,7 +53,7 @@ declare global { getCaptureBackendSupport: () => Promise getNowPlayingState: () => Promise setNowPlayingConsumerActive: (active: boolean) => Promise - saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise + saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise retryNowPlayingProvider: (providerId: NowPlayingProviderId) => Promise sendNowPlayingControl: (command: NowPlayingControlCommand) => Promise diff --git a/src/renderer/stores/nowPlayingStore.ts b/src/renderer/stores/nowPlayingStore.ts index 03b5c98..4addc35 100644 --- a/src/renderer/stores/nowPlayingStore.ts +++ b/src/renderer/stores/nowPlayingStore.ts @@ -3,7 +3,7 @@ import { NOW_PLAYING_PROVIDER_DEFINITIONS, NOW_PLAYING_PROVIDER_IDS, type NowPlayingControlCommand, - type NowPlayingProviderConfigMap, + type NowPlayingProviderConfigMutationMap, type NowPlayingProviderId, type NowPlayingProviderStateMap, type NowPlayingState, @@ -15,7 +15,7 @@ interface NowPlayingStoreState { isSendingControl: boolean initialize: () => Promise setConsumerActive: (active: boolean) => Promise - saveProviderConfig: (providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise + saveProviderConfig: (providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise setProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise retryProvider: (providerId: NowPlayingProviderId) => Promise sendControl: (command: NowPlayingControlCommand) => Promise @@ -46,7 +46,7 @@ function createDefaultNowPlayingState(): NowPlayingState { configs: { astra: { baseUrl: 'http://127.0.0.1:38401', - token: '', + hasToken: false, }, spotify: {}, tidal: {}, diff --git a/src/types/astra.ts b/src/types/astra.ts index 2dd10e3..f5eede4 100644 --- a/src/types/astra.ts +++ b/src/types/astra.ts @@ -29,8 +29,19 @@ export interface AstraIntegrationConfig { token: string } +export interface AstraIntegrationPublicConfig { + baseUrl: string + hasToken: boolean +} + +export interface AstraIntegrationConfigMutation { + baseUrl: string + token?: string + clearToken?: boolean +} + export interface AstraIntegrationState { - config: AstraIntegrationConfig + config: AstraIntegrationPublicConfig connectionState: AstraConnectionState lastError: string | null lastControlError: string | null diff --git a/src/types/nowPlaying.ts b/src/types/nowPlaying.ts index 18397f1..18338ad 100644 --- a/src/types/nowPlaying.ts +++ b/src/types/nowPlaying.ts @@ -1,10 +1,10 @@ -import type { AstraIntegrationConfig } from './astra' +import type { AstraIntegrationConfigMutation, AstraIntegrationPublicConfig } from './astra' export type NowPlayingProviderId = 'astra' | 'spotify' | 'tidal' export type NowPlayingPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading' export type NowPlayingProviderConnectionState = 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' export type NowPlayingControlCommand = 'play' | 'pause' | 'next' | 'previous' -export type NowPlayingProviderAuthMode = 'token' | 'oauth' | 'none' +export type NowPlayingProviderAuthMode = 'token' | 'oauth' | 'local' | 'none' export interface NowPlayingTrackSnapshot { id: string @@ -40,7 +40,13 @@ export interface UnsupportedNowPlayingProviderConfig { } export interface NowPlayingProviderConfigMap { - astra: AstraIntegrationConfig + astra: AstraIntegrationPublicConfig + spotify: UnsupportedNowPlayingProviderConfig + tidal: UnsupportedNowPlayingProviderConfig +} + +export interface NowPlayingProviderConfigMutationMap { + astra: AstraIntegrationConfigMutation spotify: UnsupportedNowPlayingProviderConfig tidal: UnsupportedNowPlayingProviderConfig } @@ -84,17 +90,17 @@ export const NOW_PLAYING_PROVIDER_DEFINITIONS: NowPlayingProviderDefinitionMap = spotify: { id: 'spotify', label: 'Spotify', - description: 'Spotify integration is planned but not implemented yet.', - authMode: 'oauth', - available: false, - comingSoon: true, - supportsTransportControls: false, + description: 'Read track data and transport controls directly from the local Spotify macOS app.', + authMode: 'local', + available: true, + comingSoon: false, + supportsTransportControls: true, }, tidal: { id: 'tidal', label: 'TIDAL', description: 'TIDAL integration is planned but not implemented yet.', - authMode: 'oauth', + authMode: 'none', available: false, comingSoon: true, supportsTransportControls: false, diff --git a/test/astra-integration.test.ts b/test/astra-integration.test.ts index c0229b3..92ac746 100644 --- a/test/astra-integration.test.ts +++ b/test/astra-integration.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -7,6 +7,27 @@ import test from 'node:test' import { AstraIntegrationService, normalizeAstraIntegrationConfig } from '../src/main/services/astraIntegration' import { DEFAULT_ASTRA_BASE_URL, type AstraIntegrationConfig } from '../src/types/astra' +class MemorySecretVault { + readonly secrets = new Map() + setError: Error | null = null + + async getSecret(key: string): Promise { + return this.secrets.get(key) ?? null + } + + async setSecret(key: string, value: string): Promise { + if (this.setError) { + throw this.setError + } + + this.secrets.set(key, value) + } + + async deleteSecret(key: string): Promise { + this.secrets.delete(key) + } +} + function createJsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -140,6 +161,7 @@ test('service initializes from config, hydrates artwork, and applies SSE updates baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token', }) + const secretVault = new MemorySecretVault() const sse = createSseStream() const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1` const calls: Array<{ init?: RequestInit; url: string }> = [] @@ -187,11 +209,19 @@ test('service initializes from config, hydrates artwork, and applies SSE updates configPath: harness.configPath, fetchImpl, now: () => 1000, + secretVault, }) try { await service.initialize() assert.equal(service.getState().connectionState, 'disabled') + assert.deepEqual(service.getState().config, { + baseUrl: DEFAULT_ASTRA_BASE_URL, + hasToken: true, + }) + assert.equal(secretVault.secrets.get('now-playing.astra.token'), 'secret-token') + const rawConfig = await readFile(harness.configPath, 'utf8') + assert.equal(rawConfig.includes('secret-token'), false) await service.setConsumerActive(1, true) await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state') @@ -237,6 +267,7 @@ test('service emits a single state update when reusing cached artwork on SSE upd baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token', }) + const secretVault = new MemorySecretVault() const sse = createSseStream() const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1` let stateUpdateCount = 0 @@ -282,6 +313,7 @@ test('service emits a single state update when reusing cached artwork on SSE upd configPath: harness.configPath, fetchImpl, now: () => 1000, + secretVault, }) try { @@ -327,6 +359,7 @@ test('service does not refetch identical artwork after a failed attempt', async baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token', }) + const secretVault = new MemorySecretVault() const sse = createSseStream() const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-2` let artworkRequests = 0 @@ -365,6 +398,7 @@ test('service does not refetch identical artwork after a failed attempt', async const service = new AstraIntegrationService({ configPath: harness.configPath, fetchImpl, + secretVault, }) try { @@ -424,6 +458,7 @@ test('service schedules reconnect when the SSE stream closes', async () => { baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token', }) + const secretVault = new MemorySecretVault() const timers = createFakeTimers() const steadyStream = createSseStream() let eventStreamRequests = 0 @@ -471,6 +506,7 @@ test('service schedules reconnect when the SSE stream closes', async () => { fetchImpl, setTimeoutImpl: timers.setTimeoutImpl, clearTimeoutImpl: timers.clearTimeoutImpl, + secretVault, }) try { @@ -495,6 +531,7 @@ test('service surfaces 401 and 403 control errors and clears them after success' baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token', }) + const secretVault = new MemorySecretVault() const sse = createSseStream() let controlRequests = 0 @@ -545,6 +582,7 @@ test('service surfaces 401 and 403 control errors and clears them after success' const service = new AstraIntegrationService({ configPath: harness.configPath, fetchImpl, + secretVault, }) try { @@ -565,3 +603,74 @@ test('service surfaces 401 and 403 control errors and clears them after success' await harness.cleanup() } }) + +test('saveConfig preserves, replaces, and clears the stored Astra token explicitly', async () => { + const harness = await createConfigFile({ + baseUrl: DEFAULT_ASTRA_BASE_URL, + token: '', + }) + const secretVault = new MemorySecretVault() + secretVault.secrets.set('now-playing.astra.token', 'stored-token') + const service = new AstraIntegrationService({ + configPath: harness.configPath, + fetchImpl: async () => createJsonResponse({}), + secretVault, + }) + + try { + await service.initialize() + assert.equal(service.getState().config.hasToken, true) + + await service.saveConfig({ + baseUrl: 'http://127.0.0.1:49000', + token: '', + }) + assert.equal(secretVault.secrets.get('now-playing.astra.token'), 'stored-token') + assert.deepEqual(service.getState().config, { + baseUrl: 'http://127.0.0.1:49000', + hasToken: true, + }) + + await service.saveConfig({ + baseUrl: 'http://127.0.0.1:49000', + token: 'replacement-token', + }) + assert.equal(secretVault.secrets.get('now-playing.astra.token'), 'replacement-token') + + await service.saveConfig({ + baseUrl: 'http://127.0.0.1:49000', + clearToken: true, + }) + assert.equal(secretVault.secrets.has('now-playing.astra.token'), false) + assert.equal(service.getState().config.hasToken, false) + } finally { + await service.dispose() + await harness.cleanup() + } +}) + +test('service strips plaintext tokens from legacy config even when secure storage migration fails', async () => { + const harness = await createConfigFile({ + baseUrl: DEFAULT_ASTRA_BASE_URL, + token: 'legacy-token', + }) + const secretVault = new MemorySecretVault() + secretVault.setError = new Error('Secure storage unavailable.') + const service = new AstraIntegrationService({ + configPath: harness.configPath, + fetchImpl: async () => createJsonResponse({}), + secretVault, + }) + + try { + await service.initialize() + assert.equal(service.getState().config.hasToken, false) + assert.match(service.getState().lastError ?? '', /Secure storage unavailable\./) + + const rawConfig = await readFile(harness.configPath, 'utf8') + assert.equal(rawConfig.includes('legacy-token'), false) + } finally { + await service.dispose() + await harness.cleanup() + } +}) diff --git a/test/mac-spotify-provider.test.ts b/test/mac-spotify-provider.test.ts new file mode 100644 index 0000000..da1d895 --- /dev/null +++ b/test/mac-spotify-provider.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { MacSpotifyProvider } from '../src/main/services/macSpotifyProvider' + +const DELIMITER = '\u001f' + +function createStatusPayload(options: { + album?: string + artist?: string + artworkUrl?: string + duration?: number + id?: string + isFavorite?: boolean + playbackState: 'playing' | 'paused' | 'stopped' + position?: number + title?: string +}): string { + return [ + 'ok', + options.playbackState, + String(options.position ?? 0), + String(options.duration ?? 0), + options.id ?? '', + options.title ?? '', + options.artist ?? '', + options.album ?? '', + options.artworkUrl ?? '', + String(options.isFavorite ?? false), + ].join(DELIMITER) +} + +async function waitFor(predicate: () => boolean, message: string): Promise { + const deadline = Date.now() + 2000 + while (Date.now() < deadline) { + if (predicate()) { + return + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + assert.fail(message) +} + +class StubSpotifyRunner { + commandCalls: string[] = [] + commandError: Error | null = null + statusCalls = 0 + statusError: Error | null = null + + constructor(private readonly statusResponses: string[]) {} + + async run(scriptLines: string[]): Promise { + const script = scriptLines.join('\n') + if (script.includes('next track')) { + return this.handleCommand('next') + } + if (script.includes('previous track')) { + return this.handleCommand('previous') + } + if (script.includes('\n pause\n')) { + return this.handleCommand('pause') + } + if (script.includes('\n play\n')) { + return this.handleCommand('play') + } + + this.statusCalls += 1 + if (this.statusError) { + throw this.statusError + } + + return this.statusResponses[Math.min(this.statusCalls - 1, this.statusResponses.length - 1)] ?? 'not_running' + } + + private handleCommand(command: string): string { + this.commandCalls.push(command) + if (this.commandError) { + throw this.commandError + } + return '' + } +} + +test('provider stays unavailable when Spotify.app is not installed or not on macOS', async () => { + const provider = new MacSpotifyProvider({ + accessImpl: async () => { + throw new Error('missing') + }, + platform: 'linux', + runner: async () => 'not_running', + }) + + try { + await provider.initialize() + assert.equal(provider.getProviderState().available, false) + assert.equal(provider.getProviderState().connectionState, 'unavailable') + } finally { + await provider.dispose() + } +}) + +test('provider reads local Spotify playback and hydrates artwork while active', async () => { + const runner = new StubSpotifyRunner([ + createStatusPayload({ + playbackState: 'playing', + position: 42, + duration: 180, + id: 'spotify:track:123', + title: 'Song One', + artist: 'Artist One', + album: 'Album One', + artworkUrl: 'https://i.scdn.co/image/cover-one', + isFavorite: true, + }), + ]) + + const provider = new MacSpotifyProvider({ + accessImpl: async () => undefined, + fetchImpl: async () => new Response(Buffer.from('cover-one'), { + status: 200, + headers: { + 'content-type': 'image/png', + }, + }), + now: () => 1000, + platform: 'darwin', + runner: (scriptLines) => runner.run(scriptLines), + }) + + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Spotify state') + + const state = provider.getProviderState() + assert.equal(state.snapshot?.currentTrack?.title, 'Song One') + assert.equal(state.snapshot?.currentTrack?.isFavorite, true) + assert.match(state.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/) + assert.equal(state.snapshot?.playbackState, 'playing') + } finally { + await provider.dispose() + } +}) + +test('provider treats a non-running Spotify app as idle instead of erroring', async () => { + const provider = new MacSpotifyProvider({ + accessImpl: async () => undefined, + platform: 'darwin', + runner: async () => 'not_running', + }) + + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await waitFor(() => provider.getProviderState().connectionState === 'disabled', 'expected disabled Spotify state') + assert.equal(provider.getProviderState().lastError, null) + } finally { + await provider.dispose() + } +}) + +test('provider surfaces macOS Automation permission failures during polling', async () => { + const provider = new MacSpotifyProvider({ + accessImpl: async () => undefined, + platform: 'darwin', + runner: async () => { + throw new Error('Not authorized to send Apple events to Spotify. (-1743)') + }, + }) + + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await waitFor(() => provider.getProviderState().connectionState === 'error', 'expected Spotify error state') + assert.match(provider.getProviderState().lastError ?? '', /Automation permission/) + } finally { + await provider.dispose() + } +}) + +test('provider routes transport controls through AppleScript and records control failures', async () => { + const runner = new StubSpotifyRunner([ + createStatusPayload({ + playbackState: 'paused', + position: 0, + duration: 180, + id: 'spotify:track:123', + title: 'Song One', + artist: 'Artist One', + album: 'Album One', + }), + ]) + const provider = new MacSpotifyProvider({ + accessImpl: async () => undefined, + platform: 'darwin', + runner: (scriptLines) => runner.run(scriptLines), + }) + + try { + await provider.initialize() + await provider.sendControl('next') + assert.deepEqual(runner.commandCalls, ['next']) + + runner.commandError = new Error('Not authorized to send Apple events to Spotify. (-1743)') + await assert.rejects(() => provider.sendControl('play'), /Automation permission/) + assert.match(provider.getProviderState().lastControlError ?? '', /Automation permission/) + } finally { + await provider.dispose() + } +}) diff --git a/test/now-playing-manager.test.ts b/test/now-playing-manager.test.ts index 38f3567..f7aa9e0 100644 --- a/test/now-playing-manager.test.ts +++ b/test/now-playing-manager.test.ts @@ -4,39 +4,68 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' import { NowPlayingManager } from '../src/main/services/nowPlayingManager' +import type { NowPlayingProviderService } from '../src/main/services/nowPlayingProvider' import { DEFAULT_ASTRA_BASE_URL, - type AstraIntegrationConfig, - type AstraIntegrationState, + type AstraIntegrationConfigMutation, + type AstraIntegrationPublicConfig, } from '../src/types/astra' +import type { + NowPlayingControlCommand, + NowPlayingProviderConfigMap, + NowPlayingProviderConfigMutationMap, + NowPlayingProviderId, + NowPlayingProviderState, +} from '../src/types/nowPlaying' -function createAstraState(overrides: Partial = {}): AstraIntegrationState { - const config = overrides.config ?? { - baseUrl: DEFAULT_ASTRA_BASE_URL, - token: '', - } - +function createProviderState( + providerId: NowPlayingProviderId, + overrides: Partial = {}, +): NowPlayingProviderState { return { + providerId, connectionState: 'disabled', lastError: null, lastControlError: null, snapshot: null, + isConfigured: false, + available: providerId !== 'tidal', + supportsTransportControls: providerId !== 'tidal', ...overrides, - config, } } -class StubAstraService { - state: AstraIntegrationState - consumerCalls: Array<{ consumerId: number, active: boolean }> = [] - saveConfigCalls: unknown[] = [] - controlCalls: string[] = [] +function cloneProviderState(state: NowPlayingProviderState): NowPlayingProviderState { + return { + ...state, + snapshot: state.snapshot + ? { + ...state.snapshot, + currentTrack: state.snapshot.currentTrack ? { ...state.snapshot.currentTrack } : null, + } + : null, + } +} + +class StubProviderService implements NowPlayingProviderService { + readonly providerId: K + publicConfig: NowPlayingProviderConfigMap[K] + providerState: NowPlayingProviderState + consumerCalls: Array<{ consumerId: number; active: boolean }> = [] + saveConfigCalls: Array = [] + controlCalls: NowPlayingControlCommand[] = [] + retryCalls = 0 initializeCalls = 0 disposeCalls = 0 private readonly listeners = new Set<() => void>() - constructor(state: AstraIntegrationState) { - this.state = state + constructor(providerId: K, options: { + publicConfig: NowPlayingProviderConfigMap[K] + providerState: NowPlayingProviderState + }) { + this.providerId = providerId + this.publicConfig = options.publicConfig + this.providerState = options.providerState } subscribe(listener: () => void): () => void { @@ -60,59 +89,94 @@ class StubAstraService { this.disposeCalls += 1 } - getState(): AstraIntegrationState { - return this.state + getPublicConfig(): NowPlayingProviderConfigMap[K] { + return structuredClone(this.publicConfig) } - getConfig(): AstraIntegrationConfig { - return { ...this.state.config } + getProviderState(): NowPlayingProviderState { + return cloneProviderState(this.providerState) } async setConsumerActive(consumerId: number, active: boolean): Promise { this.consumerCalls.push({ consumerId, active }) } - async saveConfig(rawConfig: unknown): Promise { - this.saveConfigCalls.push(rawConfig) - const patch = typeof rawConfig === 'object' && rawConfig !== null - ? rawConfig as Partial - : {} - this.state = { - ...this.state, - config: { - ...this.state.config, - ...patch, - }, + async saveConfig(rawConfig: NowPlayingProviderConfigMutationMap[K]): Promise { + this.saveConfigCalls.push(structuredClone(rawConfig)) + + if (this.providerId === 'astra') { + const mutation = rawConfig as AstraIntegrationConfigMutation + const currentConfig = this.publicConfig as AstraIntegrationPublicConfig + this.publicConfig = { + baseUrl: mutation.baseUrl, + hasToken: mutation.clearToken + ? false + : mutation.token + ? true + : currentConfig.hasToken, + } as NowPlayingProviderConfigMap[K] + this.providerState = { + ...this.providerState, + isConfigured: (this.publicConfig as AstraIntegrationPublicConfig).hasToken, + } + this.emit() } - this.emit() } - async sendControl(command: 'play' | 'pause' | 'next' | 'previous'): Promise { + async retry(): Promise { + this.retryCalls += 1 + } + + async sendControl(command: NowPlayingControlCommand): Promise { this.controlCalls.push(command) } } -async function createHarness(state: AstraIntegrationState): Promise<{ +async function createHarness(options?: { + astraConfig?: AstraIntegrationPublicConfig + astraState?: Partial + spotifyState?: Partial +}): Promise<{ + astra: StubProviderService<'astra'> cleanup: () => Promise manager: NowPlayingManager - stub: StubAstraService + spotify: StubProviderService<'spotify'> }> { const rootDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-manager-')) - const stub = new StubAstraService(state) + const astra = new StubProviderService('astra', { + publicConfig: options?.astraConfig ?? { + baseUrl: DEFAULT_ASTRA_BASE_URL, + hasToken: false, + }, + providerState: createProviderState('astra', { + isConfigured: options?.astraConfig?.hasToken ?? false, + ...options?.astraState, + }), + }) + const spotify = new StubProviderService('spotify', { + publicConfig: {}, + providerState: createProviderState('spotify', { + available: false, + supportsTransportControls: false, + isConfigured: false, + connectionState: 'unavailable', + ...options?.spotifyState, + }), + }) return { + astra, cleanup: () => rm(rootDir, { recursive: true, force: true }), manager: new NowPlayingManager({ - astraConfigPath: join(rootDir, 'astra-integration.json'), localStatePath: join(rootDir, 'now-playing-state.json'), - astraService: stub, + providerServices: [astra, spotify], }), - stub, + spotify, } } test('manager starts in onboarding mode until a supported provider is configured', async () => { - const harness = await createHarness(createAstraState()) + const harness = await createHarness() try { await harness.manager.initialize() @@ -122,142 +186,151 @@ test('manager starts in onboarding mode until a supported provider is configured assert.equal(state.hasConfiguredProvider, false) assert.equal(state.onboardingRequired, true) assert.equal(state.activeProviderId, null) - assert.equal(state.providers.astra.connectionState, 'disabled') + assert.deepEqual(state.configs.astra, { + baseUrl: DEFAULT_ASTRA_BASE_URL, + hasToken: false, + }) assert.equal(state.providers.spotify.connectionState, 'unavailable') - assert.equal(state.providers.tidal.connectionState, 'unavailable') } finally { await harness.cleanup() } }) -test('coming-soon providers never outrank a configured Astra provider in arbitration', async () => { - const harness = await createHarness(createAstraState({ - config: { - baseUrl: DEFAULT_ASTRA_BASE_URL, - token: 'secret-token', +test('local Spotify can satisfy configuration without any OAuth setup', async () => { + const harness = await createHarness({ + spotifyState: { + available: true, + supportsTransportControls: true, + isConfigured: true, + connectionState: 'disabled', }, - connectionState: 'connected', - snapshot: { - playbackState: 'paused', - currentTime: 12, - duration: 120, - queueLength: 4, - outputDeviceLabel: 'Studio', - visualizerLineColor: '#38bdf8', - currentTrack: { - id: 'track-1', - title: 'Track', - artist: 'Artist', - album: 'Album', - isFavorite: false, - artworkDataUrl: null, - }, - updatedAt: 1000, - }, - })) + }) try { await harness.manager.initialize() - await harness.manager.setProviderPriority(['spotify', 'tidal', 'astra']) const state = harness.manager.getState() - assert.deepEqual(state.providerPriority, ['spotify', 'tidal', 'astra']) assert.equal(state.hasConfiguredProvider, true) assert.equal(state.onboardingRequired, false) - assert.equal(state.activeProviderId, 'astra') + assert.equal(state.activeProviderId, null) + assert.equal(state.providers.spotify.isConfigured, true) } finally { await harness.cleanup() } }) -test('manager forwards consumer activity for multiple now-playing surfaces', async () => { - const harness = await createHarness(createAstraState()) - - try { - await harness.manager.initialize() - await harness.manager.setConsumerActive(101, true) - await harness.manager.setConsumerActive(202, true) - await harness.manager.setConsumerActive(101, false) - - assert.deepEqual(harness.stub.consumerCalls, [ - { consumerId: 101, active: true }, - { consumerId: 202, active: true }, - { consumerId: 101, active: false }, - ]) - } finally { - await harness.cleanup() - } -}) - -test('manager routes config retry, controls, and service updates through Astra', async () => { - const harness = await createHarness(createAstraState({ - config: { +test('manager prefers a playing Spotify provider over Astra when Spotify has higher priority', async () => { + const harness = await createHarness({ + astraConfig: { baseUrl: DEFAULT_ASTRA_BASE_URL, - token: 'secret-token', + hasToken: true, }, - connectionState: 'connected', - snapshot: { - playbackState: 'playing', - currentTime: 24, - duration: 180, - queueLength: 8, - outputDeviceLabel: 'Main Out', - visualizerLineColor: '#38bdf8', - currentTrack: { - id: 'track-2', - title: 'Playing Track', - artist: 'Artist', - album: 'Album', - isFavorite: true, - artworkDataUrl: null, - }, - updatedAt: 2000, - }, - })) - - try { - await harness.manager.initialize() - - const snapshots: Array = [] - const unsubscribe = harness.manager.subscribe((state) => { - snapshots.push(state.activeProviderId) - }) - - await harness.manager.retryProvider('astra') - await harness.manager.sendControl('next') - - harness.stub.state = createAstraState({ - config: { - baseUrl: DEFAULT_ASTRA_BASE_URL, - token: 'secret-token', - }, + astraState: { + isConfigured: true, connectionState: 'connected', snapshot: { playbackState: 'paused', - currentTime: 48, - duration: 180, - queueLength: 8, - outputDeviceLabel: 'Main Out', + currentTime: 12, + duration: 120, + queueLength: 2, + outputDeviceLabel: 'Studio', visualizerLineColor: '#38bdf8', currentTrack: { - id: 'track-3', - title: 'Paused Track', - artist: 'Artist', - album: 'Album', + id: 'astra-track', + title: 'Astra Track', + artist: 'Astra Artist', + album: 'Astra Album', isFavorite: false, artworkDataUrl: null, }, - updatedAt: 3000, + updatedAt: 1000, }, - }) - harness.stub.emit() - unsubscribe() + }, + spotifyState: { + available: true, + supportsTransportControls: true, + isConfigured: true, + connectionState: 'connected', + snapshot: { + playbackState: 'playing', + currentTime: 30, + duration: 180, + queueLength: 0, + outputDeviceLabel: null, + visualizerLineColor: '#1ed760', + currentTrack: { + id: 'spotify-track', + title: 'Spotify Track', + artist: 'Spotify Artist', + album: 'Spotify Album', + isFavorite: true, + artworkDataUrl: null, + }, + updatedAt: 2000, + }, + }, + }) - assert.deepEqual(harness.stub.saveConfigCalls, [ - { baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token' }, - ]) - assert.deepEqual(harness.stub.controlCalls, ['next']) - assert.deepEqual(snapshots, ['astra', 'astra', 'astra']) + try { + await harness.manager.initialize() + await harness.manager.setProviderPriority(['spotify', 'astra', 'tidal']) + + const state = harness.manager.getState() + assert.equal(state.activeProviderId, 'spotify') + } finally { + await harness.cleanup() + } +}) + +test('manager forwards save, retry, and controls to the active provider services', async () => { + const harness = await createHarness({ + astraConfig: { + baseUrl: DEFAULT_ASTRA_BASE_URL, + hasToken: true, + }, + astraState: { + isConfigured: true, + }, + spotifyState: { + available: true, + supportsTransportControls: true, + isConfigured: true, + connectionState: 'connected', + snapshot: { + playbackState: 'playing', + currentTime: 5, + duration: 200, + queueLength: 0, + outputDeviceLabel: null, + visualizerLineColor: '#1ed760', + currentTrack: { + id: 'spotify-track', + title: 'Spotify Track', + artist: 'Spotify Artist', + album: 'Spotify Album', + isFavorite: false, + artworkDataUrl: null, + }, + updatedAt: 500, + }, + }, + }) + + try { + await harness.manager.initialize() + await harness.manager.saveProviderConfig('astra', { + baseUrl: 'http://127.0.0.1:5000', + token: 'replacement-token', + }) + await harness.manager.retryProvider('spotify') + await harness.manager.sendControl('next') + + assert.deepEqual(harness.astra.saveConfigCalls, [{ + baseUrl: 'http://127.0.0.1:5000', + token: 'replacement-token', + }]) + assert.equal(harness.spotify.retryCalls, 1) + assert.deepEqual(harness.spotify.controlCalls, ['next']) } finally { await harness.cleanup() } diff --git a/test/secret-vault.test.ts b/test/secret-vault.test.ts new file mode 100644 index 0000000..e12831d --- /dev/null +++ b/test/secret-vault.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { SecretVault } from '../src/main/services/secretVault' + +function createSafeStorageMock(options?: { + available?: boolean + backend?: string +}) { + return { + decryptString(encrypted: Buffer): string { + return encrypted.toString('utf8').replace(/^encrypted:/, '') + }, + encryptString(plainText: string): Buffer { + return Buffer.from(`encrypted:${plainText}`, 'utf8') + }, + getSelectedStorageBackend(): string { + return options?.backend ?? 'gnome_libsecret' + }, + isEncryptionAvailable(): boolean { + return options?.available ?? true + }, + } +} + +async function createVault(options?: { + available?: boolean + backend?: string + platform?: NodeJS.Platform +}): Promise<{ + cleanup: () => Promise + path: string + vault: SecretVault +}> { + const rootDir = await mkdtemp(join(tmpdir(), 'prism-secret-vault-tests-')) + const path = join(rootDir, 'secret-vault.json') + return { + cleanup: () => rm(rootDir, { recursive: true, force: true }), + path, + vault: new SecretVault({ + path, + platform: options?.platform ?? 'darwin', + safeStorage: createSafeStorageMock(options), + }), + } +} + +test('SecretVault encrypts persisted secrets and can read them back', async () => { + const harness = await createVault() + + try { + await harness.vault.setSecret('astra', 'secret-token') + const rawFile = await readFile(harness.path, 'utf8') + assert.equal(rawFile.includes('secret-token'), false) + assert.equal(await harness.vault.getSecret('astra'), 'secret-token') + + await harness.vault.deleteSecret('astra') + assert.equal(await harness.vault.getSecret('astra'), null) + } finally { + await harness.cleanup() + } +}) + +test('SecretVault fails closed on Linux basic_text storage backends', async () => { + const harness = await createVault({ + backend: 'basic_text', + platform: 'linux', + }) + + try { + await assert.rejects( + () => harness.vault.setSecret('astra', 'secret-token'), + /supported Linux keyring/, + ) + } finally { + await harness.cleanup() + } +})