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