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