changes to how astra handles integration

This commit is contained in:
Boof2015
2026-04-12 20:21:18 -04:00
parent 71fd59c710
commit 92e8d0b2c4
21 changed files with 1993 additions and 279 deletions
+2
View File
@@ -13,9 +13,11 @@
"test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:audio-store": "node scripts/run-audio-store-tests.mjs",
"test:astra": "node scripts/run-astra-integration-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:capture-support": "node scripts/run-capture-support-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:now-playing": "node scripts/run-now-playing-manager-tests.mjs",
"test:profiles": "node scripts/run-profile-library-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs",
"test:themes": "node scripts/run-theme-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:window-state": "node scripts/run-window-state-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"build:native": "cd native && node-gyp rebuild", "build:native": "cd native && node-gyp rebuild",
@@ -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)
+44
View File
@@ -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)
+24 -2
View File
@@ -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 type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { extname, join, resolve } from 'path' import { extname, join, resolve } from 'path'
import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying' import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying'
@@ -28,6 +28,9 @@ import {
import { calculateResizedWindowBounds } from '../shared/windowResize' import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary' import { FileBackedProfileLibrary } from './profileLibrary'
import { NowPlayingManager } from './services/nowPlayingManager' 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 { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore' import { FileBackedWindowStateStore } from './windowStateStore'
@@ -62,6 +65,7 @@ let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null let themeLibrary: FileBackedThemeLibrary | null = null
let nowPlayingManager: NowPlayingManager | null = null let nowPlayingManager: NowPlayingManager | null = null
let windowStateStore: FileBackedWindowStateStore | null = null let windowStateStore: FileBackedWindowStateStore | null = null
let secretVault: SecretVault | null = null
const WINDOW_DEFAULTS = { const WINDOW_DEFAULTS = {
width: 900, 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 { function getNowPlayingManager(): NowPlayingManager {
if (!nowPlayingManager) { if (!nowPlayingManager) {
nowPlayingManager = new NowPlayingManager({ nowPlayingManager = new NowPlayingManager({
astraConfigPath: join(app.getPath('userData'), 'astra-integration.json'),
localStatePath: join(app.getPath('userData'), 'now-playing-state.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) => { nowPlayingManager.subscribe((state) => {
broadcastNowPlayingState(state) broadcastNowPlayingState(state)
+161 -18
View File
@@ -1,9 +1,12 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import type { NowPlayingProviderState } from '../../types/nowPlaying'
import { import {
DEFAULT_ASTRA_BASE_URL, DEFAULT_ASTRA_BASE_URL,
type AstraControlCommand, type AstraControlCommand,
type AstraIntegrationConfig, type AstraIntegrationConfig,
type AstraIntegrationConfigMutation,
type AstraIntegrationPublicConfig,
type AstraIntegrationState, type AstraIntegrationState,
type AstraNowPlayingSnapshot, type AstraNowPlayingSnapshot,
type AstraPlaybackState, type AstraPlaybackState,
@@ -11,10 +14,17 @@ import {
} from '../../types/astra' } from '../../types/astra'
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000] const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000]
const DEFAULT_ASTRA_SECRET_KEY = 'now-playing.astra.token'
type FetchLike = typeof fetch type FetchLike = typeof fetch
type TimerHandle = ReturnType<typeof setTimeout> type TimerHandle = ReturnType<typeof setTimeout>
interface SecretVaultLike {
deleteSecret(key: string): Promise<void>
getSecret(key: string): Promise<string | null>
setSecret(key: string, value: string): Promise<void>
}
interface RemoteTrackSnapshot { interface RemoteTrackSnapshot {
id: string id: string
title: string title: string
@@ -37,12 +47,19 @@ interface RemoteNowPlayingSnapshot {
interface AstraIntegrationServiceOptions { interface AstraIntegrationServiceOptions {
configPath: string configPath: string
secretKey?: string
secretVault: SecretVaultLike
fetchImpl?: FetchLike fetchImpl?: FetchLike
now?: () => number now?: () => number
setTimeoutImpl?: typeof setTimeout setTimeoutImpl?: typeof setTimeout
clearTimeoutImpl?: typeof clearTimeout clearTimeoutImpl?: typeof clearTimeout
} }
interface PersistedAstraConfig {
baseUrl: string
token: string
}
function cloneTrackSnapshot(track: AstraTrackSnapshot | null): AstraTrackSnapshot | null { function cloneTrackSnapshot(track: AstraTrackSnapshot | null): AstraTrackSnapshot | null {
if (!track) return null if (!track) return null
return { ...track } 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<AstraIntegrationConfigMutation>
: {}
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 { function createDefaultState(): AstraIntegrationState {
return { return {
config: normalizeAstraIntegrationConfig(null), config: toPublicConfig(createDefaultRuntimeConfig()),
connectionState: 'disabled', connectionState: 'disabled',
lastError: null, lastError: null,
lastControlError: null, lastControlError: null,
@@ -229,7 +275,11 @@ function appendBasePath(baseUrl: string, path: string): string {
} }
export class AstraIntegrationService { export class AstraIntegrationService {
readonly providerId = 'astra'
private readonly configPath: string private readonly configPath: string
private readonly secretKey: string
private readonly secretVault: SecretVaultLike
private readonly fetchImpl: FetchLike private readonly fetchImpl: FetchLike
private readonly now: () => number private readonly now: () => number
private readonly setTimeoutImpl: typeof setTimeout private readonly setTimeoutImpl: typeof setTimeout
@@ -237,6 +287,7 @@ export class AstraIntegrationService {
private readonly listeners = new Set<(state: AstraIntegrationState) => void>() private readonly listeners = new Set<(state: AstraIntegrationState) => void>()
private readonly activeConsumers = new Set<number>() private readonly activeConsumers = new Set<number>()
private config = createDefaultRuntimeConfig()
private state = createDefaultState() private state = createDefaultState()
private remoteSnapshot: RemoteNowPlayingSnapshot | null = null private remoteSnapshot: RemoteNowPlayingSnapshot | null = null
private currentArtworkKey: string | null = null private currentArtworkKey: string | null = null
@@ -250,6 +301,8 @@ export class AstraIntegrationService {
constructor(options: AstraIntegrationServiceOptions) { constructor(options: AstraIntegrationServiceOptions) {
this.configPath = options.configPath this.configPath = options.configPath
this.secretKey = options.secretKey ?? DEFAULT_ASTRA_SECRET_KEY
this.secretVault = options.secretVault
this.fetchImpl = options.fetchImpl ?? fetch this.fetchImpl = options.fetchImpl ?? fetch
this.now = options.now ?? (() => Date.now()) this.now = options.now ?? (() => Date.now())
this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout
@@ -257,12 +310,16 @@ export class AstraIntegrationService {
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
const config = await this.loadConfigFile() const { config, migrationError } = await this.loadConfigFile()
this.initialized = true this.initialized = true
this.config = config
this.state = { this.state = {
...this.state, ...this.state,
config, config: toPublicConfig(config),
connectionState: 'disabled', connectionState: 'disabled',
lastError: migrationError,
lastControlError: null,
snapshot: null,
} }
this.emitState() this.emitState()
if (this.isScopeActive()) { if (this.isScopeActive()) {
@@ -288,10 +345,23 @@ export class AstraIntegrationService {
return cloneState(this.state) return cloneState(this.state)
} }
getConfig(): AstraIntegrationConfig { getPublicConfig(): AstraIntegrationPublicConfig {
return { ...this.state.config } 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<AstraIntegrationState> { async setConsumerActive(consumerId: number, active: boolean): Promise<AstraIntegrationState> {
const wasActive = this.isScopeActive() const wasActive = this.isScopeActive()
if (active) { if (active) {
@@ -311,19 +381,54 @@ export class AstraIntegrationService {
return this.getState() return this.getState()
} }
async saveConfig(rawConfig: unknown): Promise<AstraIntegrationConfig> { async saveConfig(rawConfig: unknown): Promise<AstraIntegrationPublicConfig> {
const config = normalizeAstraIntegrationConfig(rawConfig) 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 = {
...this.state, ...this.state,
config, config: toPublicConfig(this.config),
connectionState: this.isScopeActive() ? 'connecting' : 'disabled', connectionState: this.isScopeActive() ? 'connecting' : 'disabled',
lastError: null, lastError: null,
lastControlError: null, lastControlError: null,
snapshot: null,
} }
this.emitState() this.emitState()
await this.persistConfigFile(config)
await this.restartConnection() await this.restartConnection()
return { ...config }
if (secretError) {
throw secretError
}
return this.getPublicConfig()
}
async retry(): Promise<void> {
await this.restartConnection()
} }
async sendControl(command: AstraControlCommand): Promise<void> { async sendControl(command: AstraControlCommand): Promise<void> {
@@ -337,7 +442,7 @@ export class AstraIntegrationService {
throw new Error(errorMessage) throw new Error(errorMessage)
} }
if (!this.state.config.token) { if (!this.config.token) {
const errorMessage = 'An Astra API token is required before sending controls.' const errorMessage = 'An Astra API token is required before sending controls.'
this.state = { this.state = {
...this.state, ...this.state,
@@ -401,7 +506,7 @@ export class AstraIntegrationService {
return return
} }
if (!this.state.config.token) { if (!this.config.token) {
this.state = { this.state = {
...this.state, ...this.state,
connectionState: 'error', connectionState: 'error',
@@ -415,6 +520,7 @@ export class AstraIntegrationService {
...this.state, ...this.state,
connectionState: 'connecting', connectionState: 'connecting',
lastError: null, lastError: null,
snapshot: null,
} }
this.failedArtworkKey = null this.failedArtworkKey = null
this.emitState() this.emitState()
@@ -681,7 +787,7 @@ export class AstraIntegrationService {
private buildAuthHeaders(extraHeaders?: Record<string, string>): HeadersInit { private buildAuthHeaders(extraHeaders?: Record<string, string>): HeadersInit {
return { return {
Authorization: `Bearer ${this.state.config.token}`, Authorization: `Bearer ${this.config.token}`,
...extraHeaders, ...extraHeaders,
} }
} }
@@ -691,20 +797,57 @@ export class AstraIntegrationService {
} }
private buildEndpoint(path: string): string { private buildEndpoint(path: string): string {
return appendBasePath(this.state.config.baseUrl, path) return appendBasePath(this.config.baseUrl, path)
} }
private async loadConfigFile(): Promise<AstraIntegrationConfig> { private async loadConfigFile(): Promise<{
config: PersistedAstraConfig
migrationError: string | null
}> {
let persistedConfig = createDefaultRuntimeConfig()
try { try {
const raw = await readFile(this.configPath, 'utf8') const raw = await readFile(this.configPath, 'utf8')
return normalizeAstraIntegrationConfig(JSON.parse(raw)) persistedConfig = normalizeAstraIntegrationConfig(JSON.parse(raw))
} catch { } 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<void> { private async persistConfigFile(config: PersistedAstraConfig): Promise<void> {
await mkdir(dirname(this.configPath), { recursive: true }) 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')
} }
} }
+675
View File
@@ -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<string>
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<string> {
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<number>()
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<void> {
if (this.initialized) {
return
}
await this.refreshAvailability()
this.initialized = true
this.emitState()
if (this.isScopeActive()) {
this.startPolling()
}
}
async dispose(): Promise<void> {
this.disposed = true
this.stopPolling()
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
getPublicConfig(): Record<string, never> {
return {}
}
getProviderState(): NowPlayingProviderState {
return cloneProviderState(this.state)
}
async setConsumerActive(consumerId: number, active: boolean): Promise<void> {
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<string, never>): Promise<void> {
throw new Error('Spotify does not require configuration.')
}
async retry(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const nextRefresh = this.refreshChain
.catch(() => undefined)
.then(() => this.refreshNow())
this.refreshChain = nextRefresh
return nextRefresh
}
private async refreshNow(): Promise<void> {
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<LocalSpotifySnapshot | null> {
const output = await this.runner(buildSpotifyStatusScript())
return parseSpotifyStatusOutput(output, this.now)
}
private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise<void> {
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()
}
}
+74 -76
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import type { AstraIntegrationConfig, AstraIntegrationState } from '../../types/astra'
import { import {
NOW_PLAYING_PROVIDER_DEFINITIONS, NOW_PLAYING_PROVIDER_DEFINITIONS,
NOW_PLAYING_PROVIDER_IDS, NOW_PLAYING_PROVIDER_IDS,
@@ -12,29 +11,22 @@ import {
type NowPlayingSnapshot, type NowPlayingSnapshot,
type NowPlayingState, type NowPlayingState,
} from '../../types/nowPlaying' } from '../../types/nowPlaying'
import { AstraIntegrationService } from './astraIntegration' import type { ManagedNowPlayingProviderId, NowPlayingProviderService } from './nowPlayingProvider'
interface NowPlayingManagerOptions { interface NowPlayingManagerOptions {
astraConfigPath: string
localStatePath: string localStatePath: string
astraService?: AstraServiceLike providerServices: NowPlayingProviderService[]
} }
type ProviderServiceMap = Partial<{
astra: NowPlayingProviderService<'astra'>
spotify: NowPlayingProviderService<'spotify'>
}>
interface NowPlayingLocalState { interface NowPlayingLocalState {
providerPriority: NowPlayingProviderId[] providerPriority: NowPlayingProviderId[]
} }
interface AstraServiceLike {
initialize(): Promise<void>
dispose(): Promise<void>
subscribe(listener: () => void): () => void
getState(): AstraIntegrationState
getConfig(): AstraIntegrationConfig
setConsumerActive(consumerId: number, active: boolean): Promise<unknown>
saveConfig(rawConfig: unknown): Promise<unknown>
sendControl(command: NowPlayingControlCommand): Promise<unknown>
}
function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null { function cloneSnapshot(snapshot: NowPlayingSnapshot | null): NowPlayingSnapshot | null {
if (!snapshot) return null if (!snapshot) return null
return { return {
@@ -104,29 +96,7 @@ function normalizeLocalState(raw: unknown): NowPlayingLocalState {
} }
} }
function isConfiguredAstraProvider(config: AstraIntegrationConfig): boolean { function createPlaceholderProviderState(providerId: Exclude<NowPlayingProviderId, ManagedNowPlayingProviderId>): NowPlayingProviderState {
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<NowPlayingProviderId, 'astra'>): NowPlayingProviderState {
const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId] const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId]
return { return {
providerId, providerId,
@@ -141,36 +111,45 @@ function createUnavailableProviderState(providerId: Exclude<NowPlayingProviderId
} }
export class NowPlayingManager { export class NowPlayingManager {
private readonly astraService: AstraServiceLike
private readonly localStatePath: string private readonly localStatePath: string
private readonly listeners = new Set<(state: NowPlayingState) => void>() private readonly listeners = new Set<(state: NowPlayingState) => void>()
private readonly providerServices: ProviderServiceMap
private providerPriority = [...NOW_PLAYING_PROVIDER_IDS] private providerPriority = [...NOW_PLAYING_PROVIDER_IDS]
private initialized = false private initialized = false
constructor(options: NowPlayingManagerOptions) { constructor(options: NowPlayingManagerOptions) {
this.astraService = options.astraService ?? new AstraIntegrationService({
configPath: options.astraConfigPath,
})
this.localStatePath = options.localStatePath this.localStatePath = options.localStatePath
this.astraService.subscribe(() => { this.providerServices = options.providerServices.reduce((acc, providerService) => {
if (!this.initialized) { if (providerService.providerId === 'astra') {
return 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<void> { async initialize(): Promise<void> {
if (this.initialized) return if (this.initialized) return
const localState = await this.readLocalState() const localState = await this.readLocalState()
this.providerPriority = localState.providerPriority 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.initialized = true
this.emitState() this.emitState()
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
await this.astraService.dispose() await Promise.all(Object.values(this.providerServices).map(async (providerService) => {
await providerService?.dispose()
}))
} }
subscribe(listener: (state: NowPlayingState) => void): () => void { subscribe(listener: (state: NowPlayingState) => void): () => void {
@@ -187,7 +166,9 @@ export class NowPlayingManager {
async setConsumerActive(consumerId: number, active: boolean): Promise<NowPlayingState> { async setConsumerActive(consumerId: number, active: boolean): Promise<NowPlayingState> {
await this.ensureInitialized() 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() return this.getState()
} }
@@ -197,28 +178,24 @@ export class NowPlayingManager {
): Promise<NowPlayingState> { ): Promise<NowPlayingState> {
await this.ensureInitialized() await this.ensureInitialized()
switch (providerId) { const providerService = this.providerServices[providerId as ManagedNowPlayingProviderId]
case 'astra': if (!providerService) {
await this.astraService.saveConfig(rawConfig) throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not configurable yet.`)
break
default:
throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not configurable yet.`)
} }
await providerService.saveConfig(rawConfig as never)
return this.getState() return this.getState()
} }
async retryProvider(providerId: NowPlayingProviderId): Promise<NowPlayingState> { async retryProvider(providerId: NowPlayingProviderId): Promise<NowPlayingState> {
await this.ensureInitialized() await this.ensureInitialized()
switch (providerId) { const providerService = this.providerServices[providerId as ManagedNowPlayingProviderId]
case 'astra': if (!providerService) {
await this.astraService.saveConfig(this.astraService.getConfig()) throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not available yet.`)
break
default:
throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} is not available yet.`)
} }
await providerService.retry()
return this.getState() return this.getState()
} }
@@ -234,31 +211,52 @@ export class NowPlayingManager {
await this.ensureInitialized() await this.ensureInitialized()
const activeProviderId = this.buildState().activeProviderId const activeProviderId = this.buildState().activeProviderId
switch (activeProviderId) { if (!activeProviderId) {
case 'astra': throw new Error('No active now-playing provider is available.')
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.`)
} }
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() return this.getState()
} }
private buildState(): NowPlayingState { private buildState(): NowPlayingState {
const astraState = this.astraService.getState()
const configs: NowPlayingProviderConfigMap = { const configs: NowPlayingProviderConfigMap = {
astra: { ...astraState.config }, astra: this.providerServices.astra?.getPublicConfig() ?? {
spotify: {}, baseUrl: 'http://127.0.0.1:38401',
hasToken: false,
},
spotify: this.providerServices.spotify?.getPublicConfig() ?? {},
tidal: {}, tidal: {},
} }
const providers: NowPlayingProviderStateMap = { const providers: NowPlayingProviderStateMap = {
astra: toAstraProviderState(astraState), astra: this.providerServices.astra?.getProviderState() ?? {
spotify: createUnavailableProviderState('spotify'), providerId: 'astra',
tidal: createUnavailableProviderState('tidal'), 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 hasConfiguredProvider = this.providerPriority.some((providerId) => {
const provider = providers[providerId] const provider = providers[providerId]
return provider.available && provider.isConfigured return provider.available && provider.isConfigured
+22
View File
@@ -0,0 +1,22 @@
import type {
NowPlayingControlCommand,
NowPlayingProviderConfigMap,
NowPlayingProviderConfigMutationMap,
NowPlayingProviderId,
NowPlayingProviderState,
} from '../../types/nowPlaying'
export type ManagedNowPlayingProviderId = Exclude<NowPlayingProviderId, 'tidal'>
export interface NowPlayingProviderService<K extends ManagedNowPlayingProviderId = ManagedNowPlayingProviderId> {
readonly providerId: K
initialize(): Promise<void>
dispose(): Promise<void>
subscribe(listener: () => void): () => void
getPublicConfig(): NowPlayingProviderConfigMap[K]
getProviderState(): NowPlayingProviderState
setConsumerActive(consumerId: number, active: boolean): Promise<unknown>
saveConfig(rawConfig: NowPlayingProviderConfigMutationMap[K]): Promise<unknown>
retry(): Promise<void>
sendControl(command: NowPlayingControlCommand): Promise<void>
}
+130
View File
@@ -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<string, string>
}
const EMPTY_VAULT: SecretVaultFile = {
version: 1,
secrets: {},
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, string>)
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<string | null> {
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<void> {
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<void> {
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<SecretVaultFile> {
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<void> {
await mkdir(dirname(this.path), { recursive: true })
await writeFile(this.path, `${JSON.stringify(vault, null, 2)}\n`, 'utf8')
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture' import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { import type {
NowPlayingControlCommand, NowPlayingControlCommand,
NowPlayingProviderConfigMap, NowPlayingProviderConfigMutationMap,
NowPlayingProviderId, NowPlayingProviderId,
NowPlayingState, NowPlayingState,
} from '../types/nowPlaying' } from '../types/nowPlaying'
@@ -51,7 +51,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport,
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>, getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>, setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>,
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => { saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => {
return ipcRenderer.invoke('now-playing:save-provider-config', providerId, config) as Promise<NowPlayingState> return ipcRenderer.invoke('now-playing:save-provider-config', providerId, config) as Promise<NowPlayingState>
}, },
setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => { setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => {
+16
View File
@@ -29,11 +29,18 @@ export default function App(): JSX.Element {
const initializeThemes = useThemeStore((s) => s.initializeThemes) const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize) 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 settingsOpen = useUiStore((s) => s.settingsOpen)
const toggleSettings = useUiStore((s) => s.toggleSettings) const toggleSettings = useUiStore((s) => s.toggleSettings)
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
const showBanner = useUiStore((s) => s.showBanner) const showBanner = useUiStore((s) => s.showBanner)
const isNowPlayingVisible = !hiddenScopes.has('nowPlaying')
&& (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true)
// Auto-capture on launch // Auto-capture on launch
useEffect(() => { useEffect(() => {
const { isCapturing, captureStatus, startCapture } = useAudioStore.getState() const { isCapturing, captureStatus, startCapture } = useAudioStore.getState()
@@ -135,6 +142,15 @@ export default function App(): JSX.Element {
updateMainWindowBounds, updateMainWindowBounds,
]) ])
useEffect(() => {
void initializeNowPlaying()
.then(() => setNowPlayingConsumerActive(isNowPlayingVisible))
return () => {
void setNowPlayingConsumerActive(false)
}
}, [initializeNowPlaying, isNowPlayingVisible, setNowPlayingConsumerActive])
const settingsHeight = resolveMainWindowSettingsHeight( const settingsHeight = resolveMainWindowSettingsHeight(
settingsOpen, settingsOpen,
settingsPanelHeight, settingsPanelHeight,
+41 -8
View File
@@ -39,7 +39,26 @@ function getFallbackTitle(
providerId: NowPlayingProviderId | null, providerId: NowPlayingProviderId | null,
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null, connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
): string { ): 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' return 'Nothing playing'
} }
@@ -61,7 +80,26 @@ function getFallbackDetail(
providerId: NowPlayingProviderId | null, providerId: NowPlayingProviderId | null,
connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null, connectionState: 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' | null,
): string { ): 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 '' return ''
} }
@@ -84,7 +122,6 @@ export default function AstraScopeModule({
settings, settings,
}: AstraScopeModuleProps): JSX.Element { }: AstraScopeModuleProps): JSX.Element {
const initialize = useNowPlayingStore((s) => s.initialize) const initialize = useNowPlayingStore((s) => s.initialize)
const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState) const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const isSendingControl = useNowPlayingStore((s) => s.isSendingControl) const isSendingControl = useNowPlayingStore((s) => s.isSendingControl)
const sendControl = useNowPlayingStore((s) => s.sendControl) const sendControl = useNowPlayingStore((s) => s.sendControl)
@@ -95,11 +132,7 @@ export default function AstraScopeModule({
useEffect(() => { useEffect(() => {
void initialize() void initialize()
void setConsumerActive(true) }, [initialize])
return () => {
void setConsumerActive(false)
}
}, [initialize, setConsumerActive])
const configuredProviderId = useMemo( const configuredProviderId = useMemo(
() => getConfiguredProviderId(nowPlayingState), () => getConfiguredProviderId(nowPlayingState),
@@ -103,6 +103,10 @@ function getProviderStatusLabel(
return 'Coming Soon' return 'Coming Soon'
} }
if (!provider.available) {
return 'Unavailable'
}
if (!provider.isConfigured) { if (!provider.isConfigured) {
return 'Not Set Up' return 'Not Set Up'
} }
@@ -117,7 +121,7 @@ function getProviderStatusLabel(
case 'error': case 'error':
return 'Error' return 'Error'
case 'unavailable': case 'unavailable':
return 'Coming Soon' return 'Unavailable'
} }
} }
@@ -126,13 +130,36 @@ function getProviderMetaText(
provider: NowPlayingProviderState, provider: NowPlayingProviderState,
): string { ): string {
if (definition.comingSoon) { 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) { if (!provider.isConfigured) {
return 'Local API · Paste base URL and token' 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) { switch (provider.connectionState) {
case 'disabled': case 'disabled':
return 'Local API · Waiting for use' return 'Local API · Waiting for use'
@@ -192,7 +219,6 @@ export default function NowPlayingConfigWindow(): JSX.Element {
const initializeThemes = useThemeStore((s) => s.initializeThemes) const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize) const initializeNowPlaying = useNowPlayingStore((s) => s.initialize)
const setConsumerActive = useNowPlayingStore((s) => s.setConsumerActive)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState) const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const saveProviderConfig = useNowPlayingStore((s) => s.saveProviderConfig) const saveProviderConfig = useNowPlayingStore((s) => s.saveProviderConfig)
const setProviderPriority = useNowPlayingStore((s) => s.setProviderPriority) const setProviderPriority = useNowPlayingStore((s) => s.setProviderPriority)
@@ -210,7 +236,6 @@ export default function NowPlayingConfigWindow(): JSX.Element {
void (async () => { void (async () => {
await initializeThemes() await initializeThemes()
await initializeNowPlaying() await initializeNowPlaying()
await setConsumerActive(true)
})().catch((error) => { })().catch((error) => {
if (disposed) return if (disposed) return
showBanner({ showBanner({
@@ -227,21 +252,19 @@ export default function NowPlayingConfigWindow(): JSX.Element {
return () => { return () => {
disposed = true disposed = true
unsubscribeTheme() unsubscribeTheme()
void setConsumerActive(false)
window.electronAPI.stopWindowMove() window.electronAPI.stopWindowMove()
} }
}, [ }, [
applyExternalThemeSnapshot, applyExternalThemeSnapshot,
initializeNowPlaying, initializeNowPlaying,
initializeThemes, initializeThemes,
setConsumerActive,
showBanner, showBanner,
]) ])
useEffect(() => { useEffect(() => {
setAstraBaseUrlInput(nowPlayingState.configs.astra.baseUrl) setAstraBaseUrlInput(nowPlayingState.configs.astra.baseUrl)
setAstraTokenInput(nowPlayingState.configs.astra.token) setAstraTokenInput('')
}, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.token]) }, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.hasToken])
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => { const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
@@ -263,6 +286,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
baseUrl: astraBaseUrlInput, baseUrl: astraBaseUrlInput,
token: astraTokenInput, token: astraTokenInput,
}) })
setAstraTokenInput('')
} catch (error) { } catch (error) {
showBanner({ showBanner({
tone: 'error', tone: 'error',
@@ -272,6 +296,22 @@ export default function NowPlayingConfigWindow(): JSX.Element {
} }
}, [astraBaseUrlInput, astraTokenInput, saveProviderConfig, showBanner]) }, [astraBaseUrlInput, astraTokenInput, saveProviderConfig, showBanner])
const handleClearAstraToken = useCallback(async (): Promise<void> => {
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( const orderedProviders = useMemo(
() => nowPlayingState.providerPriority.map((providerId) => ({ () => nowPlayingState.providerPriority.map((providerId) => ({
providerId, providerId,
@@ -328,7 +368,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
<div className="now-playing-config__toolbar-copy"> <div className="now-playing-config__toolbar-copy">
<div className="now-playing-config__toolbar-title">Now Playing</div> <div className="now-playing-config__toolbar-title">Now Playing</div>
<div className="now-playing-config__toolbar-subtitle"> <div className="now-playing-config__toolbar-subtitle">
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.
</div> </div>
</div> </div>
@@ -358,7 +398,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
<div className="now-playing-config__stack"> <div className="now-playing-config__stack">
<div className="now-playing-config__intro"> <div className="now-playing-config__intro">
{nowPlayingState.onboardingRequired {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.'} : 'The highest configured provider that starts playing takes over immediately.'}
</div> </div>
@@ -468,12 +508,18 @@ export default function NowPlayingConfigWindow(): JSX.Element {
className="bottom-bar__text-input now-playing-config__input" className="bottom-bar__text-input now-playing-config__input"
type="password" type="password"
value={astraTokenInput} 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)} onChange={(event) => setAstraTokenInput(event.target.value)}
/> />
</label> </label>
</div> </div>
<div className="settings-info-text">
{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.'}
</div>
<div className="settings-inline-actions now-playing-config__provider-actions"> <div className="settings-inline-actions now-playing-config__provider-actions">
<button <button
type="button" type="button"
@@ -484,6 +530,16 @@ export default function NowPlayingConfigWindow(): JSX.Element {
> >
Save Save
</button> </button>
<button
type="button"
className="settings-chip"
disabled={!nowPlayingState.configs.astra.hasToken}
onClick={() => {
void handleClearAstraToken()
}}
>
Clear Token
</button>
<button <button
type="button" type="button"
className="settings-chip" className="settings-chip"
@@ -508,15 +564,54 @@ export default function NowPlayingConfigWindow(): JSX.Element {
</div> </div>
) : null} ) : null}
</div> </div>
) : ( ) : definition.comingSoon ? (
<div className="now-playing-config__provider-body"> <div className="now-playing-config__provider-body">
<div className="now-playing-config__coming-soon"> <div className="now-playing-config__coming-soon">
{definition.description} {definition.description}
</div> </div>
<div className="settings-info-text"> <div className="settings-info-text">
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.
</div> </div>
</div> </div>
) : (
<div className="now-playing-config__provider-body">
<div className="now-playing-config__provider-body-copy">
{definition.description}
</div>
<div className="settings-info-text">
No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly.
</div>
<div className="settings-inline-actions now-playing-config__provider-actions">
<button
type="button"
className="settings-chip"
disabled={!provider.available}
onClick={() => {
void retryProvider('spotify').catch((error) => {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not reconnect to Spotify.'),
actions: [],
})
})
}}
>
Retry
</button>
</div>
{!provider.available ? (
<div className="settings-info-text">
{window.electronAPI.platform === 'darwin'
? 'Install Spotify.app in /Applications to enable this provider.'
: 'This provider is currently available on macOS only.'}
</div>
) : null}
{provider.lastError || provider.lastControlError ? (
<div className="settings-error-text now-playing-config__provider-error">
{provider.lastError ?? provider.lastControlError}
</div>
) : null}
</div>
) )
) : null} ) : null}
</section> </section>
+2 -1
View File
@@ -6,6 +6,7 @@ import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { import type {
NowPlayingControlCommand, NowPlayingControlCommand,
NowPlayingProviderConfigMap, NowPlayingProviderConfigMap,
NowPlayingProviderConfigMutationMap,
NowPlayingProviderId, NowPlayingProviderId,
NowPlayingState, NowPlayingState,
} from '../types/nowPlaying' } from '../types/nowPlaying'
@@ -52,7 +53,7 @@ declare global {
getCaptureBackendSupport: () => Promise<CaptureBackendSupport> getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
getNowPlayingState: () => Promise<NowPlayingState> getNowPlayingState: () => Promise<NowPlayingState>
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState> setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise<NowPlayingState> saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<NowPlayingState>
setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<NowPlayingState> setNowPlayingProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<NowPlayingState>
retryNowPlayingProvider: (providerId: NowPlayingProviderId) => Promise<NowPlayingState> retryNowPlayingProvider: (providerId: NowPlayingProviderId) => Promise<NowPlayingState>
sendNowPlayingControl: (command: NowPlayingControlCommand) => Promise<NowPlayingState> sendNowPlayingControl: (command: NowPlayingControlCommand) => Promise<NowPlayingState>
+3 -3
View File
@@ -3,7 +3,7 @@ import {
NOW_PLAYING_PROVIDER_DEFINITIONS, NOW_PLAYING_PROVIDER_DEFINITIONS,
NOW_PLAYING_PROVIDER_IDS, NOW_PLAYING_PROVIDER_IDS,
type NowPlayingControlCommand, type NowPlayingControlCommand,
type NowPlayingProviderConfigMap, type NowPlayingProviderConfigMutationMap,
type NowPlayingProviderId, type NowPlayingProviderId,
type NowPlayingProviderStateMap, type NowPlayingProviderStateMap,
type NowPlayingState, type NowPlayingState,
@@ -15,7 +15,7 @@ interface NowPlayingStoreState {
isSendingControl: boolean isSendingControl: boolean
initialize: () => Promise<void> initialize: () => Promise<void>
setConsumerActive: (active: boolean) => Promise<void> setConsumerActive: (active: boolean) => Promise<void>
saveProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMap[K]) => Promise<void> saveProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<void>
setProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<void> setProviderPriority: (providerPriority: NowPlayingProviderId[]) => Promise<void>
retryProvider: (providerId: NowPlayingProviderId) => Promise<void> retryProvider: (providerId: NowPlayingProviderId) => Promise<void>
sendControl: (command: NowPlayingControlCommand) => Promise<void> sendControl: (command: NowPlayingControlCommand) => Promise<void>
@@ -46,7 +46,7 @@ function createDefaultNowPlayingState(): NowPlayingState {
configs: { configs: {
astra: { astra: {
baseUrl: 'http://127.0.0.1:38401', baseUrl: 'http://127.0.0.1:38401',
token: '', hasToken: false,
}, },
spotify: {}, spotify: {},
tidal: {}, tidal: {},
+12 -1
View File
@@ -29,8 +29,19 @@ export interface AstraIntegrationConfig {
token: string token: string
} }
export interface AstraIntegrationPublicConfig {
baseUrl: string
hasToken: boolean
}
export interface AstraIntegrationConfigMutation {
baseUrl: string
token?: string
clearToken?: boolean
}
export interface AstraIntegrationState { export interface AstraIntegrationState {
config: AstraIntegrationConfig config: AstraIntegrationPublicConfig
connectionState: AstraConnectionState connectionState: AstraConnectionState
lastError: string | null lastError: string | null
lastControlError: string | null lastControlError: string | null
+15 -9
View File
@@ -1,10 +1,10 @@
import type { AstraIntegrationConfig } from './astra' import type { AstraIntegrationConfigMutation, AstraIntegrationPublicConfig } from './astra'
export type NowPlayingProviderId = 'astra' | 'spotify' | 'tidal' export type NowPlayingProviderId = 'astra' | 'spotify' | 'tidal'
export type NowPlayingPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading' export type NowPlayingPlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'
export type NowPlayingProviderConnectionState = 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable' export type NowPlayingProviderConnectionState = 'disabled' | 'connecting' | 'connected' | 'error' | 'unavailable'
export type NowPlayingControlCommand = 'play' | 'pause' | 'next' | 'previous' export type NowPlayingControlCommand = 'play' | 'pause' | 'next' | 'previous'
export type NowPlayingProviderAuthMode = 'token' | 'oauth' | 'none' export type NowPlayingProviderAuthMode = 'token' | 'oauth' | 'local' | 'none'
export interface NowPlayingTrackSnapshot { export interface NowPlayingTrackSnapshot {
id: string id: string
@@ -40,7 +40,13 @@ export interface UnsupportedNowPlayingProviderConfig {
} }
export interface NowPlayingProviderConfigMap { export interface NowPlayingProviderConfigMap {
astra: AstraIntegrationConfig astra: AstraIntegrationPublicConfig
spotify: UnsupportedNowPlayingProviderConfig
tidal: UnsupportedNowPlayingProviderConfig
}
export interface NowPlayingProviderConfigMutationMap {
astra: AstraIntegrationConfigMutation
spotify: UnsupportedNowPlayingProviderConfig spotify: UnsupportedNowPlayingProviderConfig
tidal: UnsupportedNowPlayingProviderConfig tidal: UnsupportedNowPlayingProviderConfig
} }
@@ -84,17 +90,17 @@ export const NOW_PLAYING_PROVIDER_DEFINITIONS: NowPlayingProviderDefinitionMap =
spotify: { spotify: {
id: 'spotify', id: 'spotify',
label: 'Spotify', label: 'Spotify',
description: 'Spotify integration is planned but not implemented yet.', description: 'Read track data and transport controls directly from the local Spotify macOS app.',
authMode: 'oauth', authMode: 'local',
available: false, available: true,
comingSoon: true, comingSoon: false,
supportsTransportControls: false, supportsTransportControls: true,
}, },
tidal: { tidal: {
id: 'tidal', id: 'tidal',
label: 'TIDAL', label: 'TIDAL',
description: 'TIDAL integration is planned but not implemented yet.', description: 'TIDAL integration is planned but not implemented yet.',
authMode: 'oauth', authMode: 'none',
available: false, available: false,
comingSoon: true, comingSoon: true,
supportsTransportControls: false, supportsTransportControls: false,
+110 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict' 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 { dirname } from 'node:path'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@@ -7,6 +7,27 @@ import test from 'node:test'
import { AstraIntegrationService, normalizeAstraIntegrationConfig } from '../src/main/services/astraIntegration' import { AstraIntegrationService, normalizeAstraIntegrationConfig } from '../src/main/services/astraIntegration'
import { DEFAULT_ASTRA_BASE_URL, type AstraIntegrationConfig } from '../src/types/astra' import { DEFAULT_ASTRA_BASE_URL, type AstraIntegrationConfig } from '../src/types/astra'
class MemorySecretVault {
readonly secrets = new Map<string, string>()
setError: Error | null = null
async getSecret(key: string): Promise<string | null> {
return this.secrets.get(key) ?? null
}
async setSecret(key: string, value: string): Promise<void> {
if (this.setError) {
throw this.setError
}
this.secrets.set(key, value)
}
async deleteSecret(key: string): Promise<void> {
this.secrets.delete(key)
}
}
function createJsonResponse(body: unknown, status = 200): Response { function createJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
@@ -140,6 +161,7 @@ test('service initializes from config, hydrates artwork, and applies SSE updates
baseUrl: DEFAULT_ASTRA_BASE_URL, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', token: 'secret-token',
}) })
const secretVault = new MemorySecretVault()
const sse = createSseStream() const sse = createSseStream()
const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1` const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1`
const calls: Array<{ init?: RequestInit; url: string }> = [] 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, configPath: harness.configPath,
fetchImpl, fetchImpl,
now: () => 1000, now: () => 1000,
secretVault,
}) })
try { try {
await service.initialize() await service.initialize()
assert.equal(service.getState().connectionState, 'disabled') 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 service.setConsumerActive(1, true)
await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state') 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, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', token: 'secret-token',
}) })
const secretVault = new MemorySecretVault()
const sse = createSseStream() const sse = createSseStream()
const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1` const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1`
let stateUpdateCount = 0 let stateUpdateCount = 0
@@ -282,6 +313,7 @@ test('service emits a single state update when reusing cached artwork on SSE upd
configPath: harness.configPath, configPath: harness.configPath,
fetchImpl, fetchImpl,
now: () => 1000, now: () => 1000,
secretVault,
}) })
try { try {
@@ -327,6 +359,7 @@ test('service does not refetch identical artwork after a failed attempt', async
baseUrl: DEFAULT_ASTRA_BASE_URL, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', token: 'secret-token',
}) })
const secretVault = new MemorySecretVault()
const sse = createSseStream() const sse = createSseStream()
const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-2` const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-2`
let artworkRequests = 0 let artworkRequests = 0
@@ -365,6 +398,7 @@ test('service does not refetch identical artwork after a failed attempt', async
const service = new AstraIntegrationService({ const service = new AstraIntegrationService({
configPath: harness.configPath, configPath: harness.configPath,
fetchImpl, fetchImpl,
secretVault,
}) })
try { try {
@@ -424,6 +458,7 @@ test('service schedules reconnect when the SSE stream closes', async () => {
baseUrl: DEFAULT_ASTRA_BASE_URL, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', token: 'secret-token',
}) })
const secretVault = new MemorySecretVault()
const timers = createFakeTimers() const timers = createFakeTimers()
const steadyStream = createSseStream() const steadyStream = createSseStream()
let eventStreamRequests = 0 let eventStreamRequests = 0
@@ -471,6 +506,7 @@ test('service schedules reconnect when the SSE stream closes', async () => {
fetchImpl, fetchImpl,
setTimeoutImpl: timers.setTimeoutImpl, setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl, clearTimeoutImpl: timers.clearTimeoutImpl,
secretVault,
}) })
try { try {
@@ -495,6 +531,7 @@ test('service surfaces 401 and 403 control errors and clears them after success'
baseUrl: DEFAULT_ASTRA_BASE_URL, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', token: 'secret-token',
}) })
const secretVault = new MemorySecretVault()
const sse = createSseStream() const sse = createSseStream()
let controlRequests = 0 let controlRequests = 0
@@ -545,6 +582,7 @@ test('service surfaces 401 and 403 control errors and clears them after success'
const service = new AstraIntegrationService({ const service = new AstraIntegrationService({
configPath: harness.configPath, configPath: harness.configPath,
fetchImpl, fetchImpl,
secretVault,
}) })
try { try {
@@ -565,3 +603,74 @@ test('service surfaces 401 and 403 control errors and clears them after success'
await harness.cleanup() 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()
}
})
+210
View File
@@ -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<void> {
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<string> {
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()
}
})
+218 -145
View File
@@ -4,39 +4,68 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { NowPlayingManager } from '../src/main/services/nowPlayingManager' import { NowPlayingManager } from '../src/main/services/nowPlayingManager'
import type { NowPlayingProviderService } from '../src/main/services/nowPlayingProvider'
import { import {
DEFAULT_ASTRA_BASE_URL, DEFAULT_ASTRA_BASE_URL,
type AstraIntegrationConfig, type AstraIntegrationConfigMutation,
type AstraIntegrationState, type AstraIntegrationPublicConfig,
} from '../src/types/astra' } from '../src/types/astra'
import type {
NowPlayingControlCommand,
NowPlayingProviderConfigMap,
NowPlayingProviderConfigMutationMap,
NowPlayingProviderId,
NowPlayingProviderState,
} from '../src/types/nowPlaying'
function createAstraState(overrides: Partial<AstraIntegrationState> = {}): AstraIntegrationState { function createProviderState(
const config = overrides.config ?? { providerId: NowPlayingProviderId,
baseUrl: DEFAULT_ASTRA_BASE_URL, overrides: Partial<NowPlayingProviderState> = {},
token: '', ): NowPlayingProviderState {
}
return { return {
providerId,
connectionState: 'disabled', connectionState: 'disabled',
lastError: null, lastError: null,
lastControlError: null, lastControlError: null,
snapshot: null, snapshot: null,
isConfigured: false,
available: providerId !== 'tidal',
supportsTransportControls: providerId !== 'tidal',
...overrides, ...overrides,
config,
} }
} }
class StubAstraService { function cloneProviderState(state: NowPlayingProviderState): NowPlayingProviderState {
state: AstraIntegrationState return {
consumerCalls: Array<{ consumerId: number, active: boolean }> = [] ...state,
saveConfigCalls: unknown[] = [] snapshot: state.snapshot
controlCalls: string[] = [] ? {
...state.snapshot,
currentTrack: state.snapshot.currentTrack ? { ...state.snapshot.currentTrack } : null,
}
: null,
}
}
class StubProviderService<K extends 'astra' | 'spotify'> implements NowPlayingProviderService<K> {
readonly providerId: K
publicConfig: NowPlayingProviderConfigMap[K]
providerState: NowPlayingProviderState
consumerCalls: Array<{ consumerId: number; active: boolean }> = []
saveConfigCalls: Array<NowPlayingProviderConfigMutationMap[K]> = []
controlCalls: NowPlayingControlCommand[] = []
retryCalls = 0
initializeCalls = 0 initializeCalls = 0
disposeCalls = 0 disposeCalls = 0
private readonly listeners = new Set<() => void>() private readonly listeners = new Set<() => void>()
constructor(state: AstraIntegrationState) { constructor(providerId: K, options: {
this.state = state publicConfig: NowPlayingProviderConfigMap[K]
providerState: NowPlayingProviderState
}) {
this.providerId = providerId
this.publicConfig = options.publicConfig
this.providerState = options.providerState
} }
subscribe(listener: () => void): () => void { subscribe(listener: () => void): () => void {
@@ -60,59 +89,94 @@ class StubAstraService {
this.disposeCalls += 1 this.disposeCalls += 1
} }
getState(): AstraIntegrationState { getPublicConfig(): NowPlayingProviderConfigMap[K] {
return this.state return structuredClone(this.publicConfig)
} }
getConfig(): AstraIntegrationConfig { getProviderState(): NowPlayingProviderState {
return { ...this.state.config } return cloneProviderState(this.providerState)
} }
async setConsumerActive(consumerId: number, active: boolean): Promise<void> { async setConsumerActive(consumerId: number, active: boolean): Promise<void> {
this.consumerCalls.push({ consumerId, active }) this.consumerCalls.push({ consumerId, active })
} }
async saveConfig(rawConfig: unknown): Promise<void> { async saveConfig(rawConfig: NowPlayingProviderConfigMutationMap[K]): Promise<void> {
this.saveConfigCalls.push(rawConfig) this.saveConfigCalls.push(structuredClone(rawConfig))
const patch = typeof rawConfig === 'object' && rawConfig !== null
? rawConfig as Partial<AstraIntegrationConfig> if (this.providerId === 'astra') {
: {} const mutation = rawConfig as AstraIntegrationConfigMutation
this.state = { const currentConfig = this.publicConfig as AstraIntegrationPublicConfig
...this.state, this.publicConfig = {
config: { baseUrl: mutation.baseUrl,
...this.state.config, hasToken: mutation.clearToken
...patch, ? 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<void> { async retry(): Promise<void> {
this.retryCalls += 1
}
async sendControl(command: NowPlayingControlCommand): Promise<void> {
this.controlCalls.push(command) this.controlCalls.push(command)
} }
} }
async function createHarness(state: AstraIntegrationState): Promise<{ async function createHarness(options?: {
astraConfig?: AstraIntegrationPublicConfig
astraState?: Partial<NowPlayingProviderState>
spotifyState?: Partial<NowPlayingProviderState>
}): Promise<{
astra: StubProviderService<'astra'>
cleanup: () => Promise<void> cleanup: () => Promise<void>
manager: NowPlayingManager manager: NowPlayingManager
stub: StubAstraService spotify: StubProviderService<'spotify'>
}> { }> {
const rootDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-manager-')) 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 { return {
astra,
cleanup: () => rm(rootDir, { recursive: true, force: true }), cleanup: () => rm(rootDir, { recursive: true, force: true }),
manager: new NowPlayingManager({ manager: new NowPlayingManager({
astraConfigPath: join(rootDir, 'astra-integration.json'),
localStatePath: join(rootDir, 'now-playing-state.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 () => { test('manager starts in onboarding mode until a supported provider is configured', async () => {
const harness = await createHarness(createAstraState()) const harness = await createHarness()
try { try {
await harness.manager.initialize() 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.hasConfiguredProvider, false)
assert.equal(state.onboardingRequired, true) assert.equal(state.onboardingRequired, true)
assert.equal(state.activeProviderId, null) 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.spotify.connectionState, 'unavailable')
assert.equal(state.providers.tidal.connectionState, 'unavailable')
} finally { } finally {
await harness.cleanup() await harness.cleanup()
} }
}) })
test('coming-soon providers never outrank a configured Astra provider in arbitration', async () => { test('local Spotify can satisfy configuration without any OAuth setup', async () => {
const harness = await createHarness(createAstraState({ const harness = await createHarness({
config: { spotifyState: {
baseUrl: DEFAULT_ASTRA_BASE_URL, available: true,
token: 'secret-token', 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 { try {
await harness.manager.initialize() await harness.manager.initialize()
await harness.manager.setProviderPriority(['spotify', 'tidal', 'astra'])
const state = harness.manager.getState() const state = harness.manager.getState()
assert.deepEqual(state.providerPriority, ['spotify', 'tidal', 'astra'])
assert.equal(state.hasConfiguredProvider, true) assert.equal(state.hasConfiguredProvider, true)
assert.equal(state.onboardingRequired, false) assert.equal(state.onboardingRequired, false)
assert.equal(state.activeProviderId, 'astra') assert.equal(state.activeProviderId, null)
assert.equal(state.providers.spotify.isConfigured, true)
} finally { } finally {
await harness.cleanup() await harness.cleanup()
} }
}) })
test('manager forwards consumer activity for multiple now-playing surfaces', async () => { test('manager prefers a playing Spotify provider over Astra when Spotify has higher priority', async () => {
const harness = await createHarness(createAstraState()) const harness = await createHarness({
astraConfig: {
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: {
baseUrl: DEFAULT_ASTRA_BASE_URL, baseUrl: DEFAULT_ASTRA_BASE_URL,
token: 'secret-token', hasToken: true,
}, },
connectionState: 'connected', astraState: {
snapshot: { isConfigured: true,
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<string | null> = []
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',
},
connectionState: 'connected', connectionState: 'connected',
snapshot: { snapshot: {
playbackState: 'paused', playbackState: 'paused',
currentTime: 48, currentTime: 12,
duration: 180, duration: 120,
queueLength: 8, queueLength: 2,
outputDeviceLabel: 'Main Out', outputDeviceLabel: 'Studio',
visualizerLineColor: '#38bdf8', visualizerLineColor: '#38bdf8',
currentTrack: { currentTrack: {
id: 'track-3', id: 'astra-track',
title: 'Paused Track', title: 'Astra Track',
artist: 'Artist', artist: 'Astra Artist',
album: 'Album', album: 'Astra Album',
isFavorite: false, isFavorite: false,
artworkDataUrl: null, artworkDataUrl: null,
}, },
updatedAt: 3000, updatedAt: 1000,
}, },
}) },
harness.stub.emit() spotifyState: {
unsubscribe() 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, [ try {
{ baseUrl: DEFAULT_ASTRA_BASE_URL, token: 'secret-token' }, await harness.manager.initialize()
]) await harness.manager.setProviderPriority(['spotify', 'astra', 'tidal'])
assert.deepEqual(harness.stub.controlCalls, ['next'])
assert.deepEqual(snapshots, ['astra', 'astra', 'astra']) 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 { } finally {
await harness.cleanup() await harness.cleanup()
} }
+80
View File
@@ -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<void>
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()
}
})