mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
changes to how astra handles integration
This commit is contained in:
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+2
-1
@@ -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,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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user