Revert "style: lint"

This reverts commit 98f40d7d0b.
This commit is contained in:
Neko Ayaka
2026-08-26 20:13:10 +08:00
parent cfcfc513ef
commit 146b3da65a
1625 changed files with 75440 additions and 75453 deletions
+1 -1
View File
@@ -6,9 +6,9 @@ export default defineConfig([
{ {
extends: ['js/recommended'], extends: ['js/recommended'],
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'], files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'],
ignores: ['**/node_modules/**'],
plugins: { plugins: {
js: jsPlugin, js: jsPlugin,
}, },
ignores: ['**/node_modules/**'],
}, },
]) ])
+1 -1
View File
@@ -7,7 +7,7 @@ import App from './App.vue'
import '@unocss/reset/tailwind.css' import '@unocss/reset/tailwind.css'
import 'uno.css' import 'uno.css'
const router = createRouter({ history: createWebHashHistory(), routes }) const router = createRouter({ routes, history: createWebHashHistory() })
createApp(App) createApp(App)
.use(router) .use(router)
@@ -11,12 +11,12 @@ export const weatherComponent = defineCallingComponent(
Weather, Weather,
object({ object({
city: string(), city: string(),
condition: string(),
temperature: string(), temperature: string(),
condition: string(),
}), }),
{ {
city: 'Tokyo', city: 'Tokyo',
condition: 'Sunny',
temperature: '25°', temperature: '25°',
condition: 'Sunny',
}, },
) )
@@ -6,9 +6,9 @@ import { toJsonSchema } from 'xsschema'
export function defineCallingComponent<T extends Schema>(name: string, component: Component, schema: T, exampleProps?: Record<string, any>) { export function defineCallingComponent<T extends Schema>(name: string, component: Component, schema: T, exampleProps?: Record<string, any>) {
return { return {
component: markRaw(component),
exampleProps,
name, name,
schema: toJsonSchema(schema), schema: toJsonSchema(schema),
component: markRaw(component),
exampleProps,
} }
} }
+3 -3
View File
@@ -25,8 +25,8 @@ export default defineConfig({
...presetWebFontsFonts('fontsource'), ...presetWebFontsFonts('fontsource'),
}, },
timeouts: { timeouts: {
failure: 10000,
warning: 5000, warning: 5000,
failure: 10000,
}, },
}), }),
presetIcons({ presetIcons({
@@ -35,14 +35,14 @@ export default defineConfig({
presetChromatic({ presetChromatic({
baseHue: 240.25, baseHue: 240.25,
colors: { colors: {
complementary: 180,
primary: 0, primary: 0,
complementary: 180,
}, },
}) as Preset, }) as Preset,
], ],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
transformers: [ transformers: [
transformerDirectives(), transformerDirectives(),
transformerVariantGroup(), transformerVariantGroup(),
], ],
safelist: 'prose prose-sm m-auto text-left'.split(' '),
}) })
+1 -1
View File
@@ -9,8 +9,8 @@ import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
VueRouter({ VueRouter({
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
extensions: ['.vue', '.md'], extensions: ['.vue', '.md'],
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
}), }),
Vue(), Vue(),
// https://github.com/antfu/unocss // https://github.com/antfu/unocss
+12 -12
View File
@@ -7,25 +7,25 @@ const serverURL = env.CAPACITOR_DEV_SERVER_URL
const appId = argv.includes('android') ? 'ai.moeru.airi_pocket' : 'ai.moeru.airi-pocket' const appId = argv.includes('android') ? 'ai.moeru.airi_pocket' : 'ai.moeru.airi-pocket'
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId,
appName: 'AIRI',
webDir: 'dist',
server: serverURL
? {
url: serverURL,
cleartext: false,
}
: undefined,
android: { android: {
buildOptions: { buildOptions: {
keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS,
keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD,
keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD,
keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH, keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH,
keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS,
keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD,
keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD,
releaseType: 'APK', releaseType: 'APK',
signingType: 'apksigner', signingType: 'apksigner',
}, },
}, },
appId,
appName: 'AIRI',
server: serverURL
? {
cleartext: false,
url: serverURL,
}
: undefined,
webDir: 'dist',
} }
export default config export default config
@@ -9,7 +9,7 @@ export function useAudioInput() {
const audioInputs = computed(() => devices.audioInputs.value) const audioInputs = computed(() => devices.audioInputs.value)
const constraints = ref<MediaStreamConstraints>({ audio: true }) const constraints = ref<MediaStreamConstraints>({ audio: true })
const media = useUserMedia({ autoSwitch: true, constraints, enabled: false }) const media = useUserMedia({ constraints, autoSwitch: true, enabled: false })
async function request() { async function request() {
if (devices.permissionGranted.value) { if (devices.permissionGranted.value) {
@@ -71,13 +71,13 @@ export function useAudioInput() {
} }
return { return {
audioInputs,
media,
request,
selectedAudioInput,
selectedAudioInputId, selectedAudioInputId,
selectedAudioInput,
audioInputs,
start, start,
stop, stop,
request,
media,
} }
} }
@@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) {
}) })
return { return {
animationIcon,
iconAnimationStarted, iconAnimationStarted,
showIconAnimation, showIconAnimation,
animationIcon,
} }
} }
+2 -2
View File
@@ -62,9 +62,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
let router: Router let router: Router
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE)) if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
router = createRouter({ history: createWebHashHistory(), routes: routeRecords }) router = createRouter({ routes: routeRecords, history: createWebHashHistory() })
else else
router = createRouter({ history: createWebHistory(), routes: routeRecords }) router = createRouter({ routes: routeRecords, history: createWebHistory() })
router.beforeEach((to, from) => { router.beforeEach((to, from) => {
if (to.path !== from.path) if (to.path !== from.path)
+1 -1
View File
@@ -15,8 +15,8 @@ function getLocale() {
} }
export const i18n = createI18n({ export const i18n = createI18n({
fallbackLocale: 'en',
legacy: false, legacy: false,
locale: getLocale(), locale: getLocale(),
fallbackLocale: 'en',
messages, messages,
}) })
@@ -1,12 +1,12 @@
import { registerPlugin } from '@capacitor/core' import { registerPlugin } from '@capacitor/core'
interface MicrophonePermissionPlugin {
checkPermission: () => Promise<MicrophonePermissionState>
}
interface MicrophonePermissionState { interface MicrophonePermissionState {
granted: boolean granted: boolean
} }
interface MicrophonePermissionPlugin {
checkPermission: () => Promise<MicrophonePermissionState>
}
/** Reads Android's native microphone permission state without triggering a permission request. */ /** Reads Android's native microphone permission state without triggering a permission request. */
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission') export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')
@@ -21,11 +21,11 @@ export async function probeServerChannelQrPayload(payload: ServerChannelQrPayloa
const client = new Client({ const client = new Client({
autoConnect: false, autoConnect: false,
autoReconnect: false, autoReconnect: false,
connector: createTextProtocolConnector(connector),
connectTimeoutMs: 2_000, connectTimeoutMs: 2_000,
name: WebSocketEventSource.StageWeb, name: WebSocketEventSource.StageWeb,
token: payload.authToken, token: payload.authToken,
url, url,
connector: createTextProtocolConnector(connector),
}) })
try { try {
@@ -5,13 +5,13 @@ interface WebAuthenticationOptions {
url: string url: string
} }
interface WebAuthenticationPlugin {
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
}
interface WebAuthenticationResult { interface WebAuthenticationResult {
callbackUrl?: string callbackUrl?: string
} }
interface WebAuthenticationPlugin {
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
}
/** Opens an authorization URL with the native system browser session. */ /** Opens an authorization URL with the native system browser session. */
export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication') export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication')
@@ -1,21 +1,18 @@
import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk' import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk'
type HostBridgeCommand type HostBridgeCommand
= | { code?: number, id: string, kind: 'close', reason?: string } = | { kind: 'connect', id: string, url: string }
| { data: string, id: string, kind: 'send' } | { kind: 'send', id: string, data: string }
| { id: string, kind: 'connect', url: string } | { kind: 'close', id: string, code?: number, reason?: string }
type HostBridgeEvent type HostBridgeEvent
= | { code?: number, id: string, kind: 'close', reason?: string } = | { kind: 'open', id: string }
| { data: string, id: string, kind: 'message' } | { kind: 'message', id: string, data: string }
| { id: string, kind: 'error', message: string } | { kind: 'error', id: string, message: string }
| { id: string, kind: 'open' } | { kind: 'close', id: string, code?: number, reason?: string }
declare global { declare global {
interface Window { interface Window {
__airiHostBridge?: {
onNativeMessage?: (payload: string) => void
}
AiriHostBridge?: { AiriHostBridge?: {
postMessage: (payload: string) => void postMessage: (payload: string) => void
} }
@@ -26,11 +23,38 @@ declare global {
} }
} }
} }
__airiHostBridge?: {
onNativeMessage?: (payload: string) => void
}
} }
} }
const connections = new Map<string, HostBridgeConnection>() const connections = new Map<string, HostBridgeConnection>()
function postBridgeMessage(command: HostBridgeCommand) {
if (window.AiriHostBridge) {
window.AiriHostBridge.postMessage(JSON.stringify(command))
return
}
if (window.webkit?.messageHandlers?.airiHostBridge) {
window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command))
return
}
throw new Error('AIRI host websocket bridge is unavailable')
}
function dispatchNativeEvent(payload: string) {
const event = JSON.parse(payload) as HostBridgeEvent
const connection = connections.get(event.id)
if (!connection) {
return
}
connection.handleNativeEvent(event)
}
class HostBridgeConnection { class HostBridgeConnection {
readonly id = crypto.randomUUID() readonly id = crypto.randomUUID()
private opened = false private opened = false
@@ -45,37 +69,49 @@ class HostBridgeConnection {
connections.set(this.id, this) connections.set(this.id, this)
postBridgeMessage({ postBridgeMessage({
id: this.id,
kind: 'connect', kind: 'connect',
id: this.id,
url: this.url, url: this.url,
}) })
} }
send(data: string) {
if (!this.opened) {
return false
}
postBridgeMessage({
kind: 'send',
id: this.id,
data,
})
return true
}
close(code?: number, reason?: string) { close(code?: number, reason?: string) {
if (this.settled && !this.opened) { if (this.settled && !this.opened) {
return return
} }
postBridgeMessage({ postBridgeMessage({
code,
id: this.id,
kind: 'close', kind: 'close',
id: this.id,
code,
reason, reason,
}) })
} }
handleNativeEvent(event: HostBridgeEvent) { handleNativeEvent(event: HostBridgeEvent) {
switch (event.kind) { switch (event.kind) {
case 'close': case 'open':
connections.delete(this.id) this.opened = true
if (!this.settled) { this.settled = true
this.settled = true this.resolve()
this.reject(createCloseBeforeOpenError(event)) break
return
}
this.opened = false case 'message':
this.events.close({ code: event.code, reason: event.reason }) this.events.message(event.data)
break break
case 'error': case 'error':
@@ -89,31 +125,25 @@ class HostBridgeConnection {
this.events.error(new Error(event.message)) this.events.error(new Error(event.message))
break break
case 'message': case 'close':
this.events.message(event.data) connections.delete(this.id)
break if (!this.settled) {
this.settled = true
this.reject(createCloseBeforeOpenError(event))
return
}
case 'open': this.opened = false
this.opened = true this.events.close({ code: event.code, reason: event.reason })
this.settled = true
this.resolve()
break break
} }
} }
}
send(data: string) { function createCloseBeforeOpenError(event: Extract<HostBridgeEvent, { kind: 'close' }>) {
if (!this.opened) { const reason = event.reason ? ` ${event.reason}` : ''
return false const code = typeof event.code === 'number' ? ` with code ${event.code}` : ''
} return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`)
postBridgeMessage({
data,
id: this.id,
kind: 'send',
})
return true
}
} }
export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined { export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined {
@@ -138,40 +168,10 @@ export function getHostWebSocketConnector(url: string): ClientConnector<string>
} }
return { return {
close: (code?: number, reason?: string) => activeConnection.close(code, reason),
send: message => activeConnection.send(message), send: message => activeConnection.send(message),
close: (code?: number, reason?: string) => activeConnection.close(code, reason),
} }
}) })
}, },
} }
} }
function createCloseBeforeOpenError(event: Extract<HostBridgeEvent, { kind: 'close' }>) {
const reason = event.reason ? ` ${event.reason}` : ''
const code = typeof event.code === 'number' ? ` with code ${event.code}` : ''
return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`)
}
function dispatchNativeEvent(payload: string) {
const event = JSON.parse(payload) as HostBridgeEvent
const connection = connections.get(event.id)
if (!connection) {
return
}
connection.handleNativeEvent(event)
}
function postBridgeMessage(command: HostBridgeCommand) {
if (window.AiriHostBridge) {
window.AiriHostBridge.postMessage(JSON.stringify(command))
return
}
if (window.webkit?.messageHandlers?.airiHostBridge) {
window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command))
return
}
throw new Error('AIRI host websocket bridge is unavailable')
}
+53 -53
View File
@@ -7,30 +7,30 @@ import { AutoModel, Tensor } from '@huggingface/transformers'
* Voice Activity Detection processor * Voice Activity Detection processor
*/ */
export class VAD implements BaseVAD { export class VAD implements BaseVAD {
private config: BaseVADConfig
private model: PreTrainedModel | undefined
private state: Tensor
private sampleRateTensor: Tensor
private buffer: Float32Array private buffer: Float32Array
private bufferPointer: number = 0 private bufferPointer: number = 0
private config: BaseVADConfig
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
private inferenceChain: Promise<any> = Promise.resolve()
private isReady: boolean = false
private isRecording: boolean = false private isRecording: boolean = false
private model: PreTrainedModel | undefined
private postSpeechSamples: number = 0 private postSpeechSamples: number = 0
private prevBuffers: Float32Array[] = [] private prevBuffers: Float32Array[] = []
private sampleRateTensor: Tensor private inferenceChain: Promise<any> = Promise.resolve()
private state: Tensor private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
private isReady: boolean = false
constructor(userConfig: Partial<BaseVADConfig> = {}) { constructor(userConfig: Partial<BaseVADConfig> = {}) {
// Default configuration // Default configuration
const defaultConfig: BaseVADConfig = { const defaultConfig: BaseVADConfig = {
exitThreshold: 0.1,
maxBufferDuration: 30,
minSilenceDurationMs: 400,
minSpeechDurationMs: 250,
newBufferSize: 512,
sampleRate: 16000, sampleRate: 16000,
speechPadMs: 80,
speechThreshold: 0.3, speechThreshold: 0.3,
exitThreshold: 0.1,
minSilenceDurationMs: 400,
speechPadMs: 80,
minSpeechDurationMs: 250,
maxBufferDuration: 30,
newBufferSize: 512,
} }
this.config = { ...defaultConfig, ...userConfig } this.config = { ...defaultConfig, ...userConfig }
@@ -45,7 +45,7 @@ export class VAD implements BaseVAD {
*/ */
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
try { try {
this.emit('status', { message: 'Loading VAD model...', type: 'info' }) this.emit('status', { type: 'info', message: 'Loading VAD model...' })
this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', { this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', {
config: { model_type: 'custom' } as any, config: { model_type: 'custom' } as any,
@@ -53,14 +53,24 @@ export class VAD implements BaseVAD {
}) })
this.isReady = true this.isReady = true
this.emit('status', { message: 'VAD model loaded successfully', type: 'info' }) this.emit('status', { type: 'info', message: 'VAD model loaded successfully' })
} }
catch (error) { catch (error) {
this.emit('status', { message: `Failed to load VAD model: ${error}`, type: 'error' }) this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` })
throw error throw error
} }
} }
/**
* Add event listener
*/
public on<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = []
}
this.eventListeners[event]!.push(callback as any)
}
/** /**
* Remove event listener * Remove event listener
*/ */
@@ -71,13 +81,14 @@ export class VAD implements BaseVAD {
} }
/** /**
* Add event listener * Emit event
*/ */
public on<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void { private emit<K extends keyof VADEvents>(event: K, data: VADEvents[K]): void {
if (!this.eventListeners[event]) { if (!this.eventListeners[event])
this.eventListeners[event] = [] return
for (const callback of this.eventListeners[event]!) {
callback(data)
} }
this.eventListeners[event]!.push(callback as any)
} }
/** /**
@@ -133,7 +144,7 @@ export class VAD implements BaseVAD {
if (!this.isRecording) { if (!this.isRecording) {
// Speech just started // Speech just started
this.emit('speech-start', undefined) this.emit('speech-start', undefined)
this.emit('status', { message: 'Speech detected', type: 'info' }) this.emit('status', { type: 'info', message: 'Speech detected' })
} }
// Update state // Update state
@@ -159,31 +170,13 @@ export class VAD implements BaseVAD {
} }
} }
/**
* Update configuration
*/
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
this.config = { ...this.config, ...newConfig }
// If buffer size changed, create a new buffer
if (newConfig.maxBufferDuration || newConfig.sampleRate) {
this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
this.bufferPointer = 0
}
// Update sample rate tensor if needed
if (newConfig.sampleRate) {
this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
}
}
/** /**
* Detect speech in an audio buffer * Detect speech in an audio buffer
*/ */
private async detectSpeech(buffer: Float32Array): Promise<boolean> { private async detectSpeech(buffer: Float32Array): Promise<boolean> {
const input = new Tensor('float32', buffer, [1, buffer.length]) const input = new Tensor('float32', buffer, [1, buffer.length])
const { output, stateN } = await (this.inferenceChain = this.inferenceChain.then(() => const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() =>
this.model?.({ this.model?.({
input, input,
sr: this.sampleRateTensor, sr: this.sampleRateTensor,
@@ -196,7 +189,7 @@ export class VAD implements BaseVAD {
// Get the speech probability // Get the speech probability
const speechProb = output.data[0] const speechProb = output.data[0]
this.emit('debug', { data: { probability: speechProb }, message: 'VAD score' }) this.emit('debug', { message: 'VAD score', data: { probability: speechProb } })
// Apply thresholds // Apply thresholds
return ( return (
@@ -205,17 +198,6 @@ export class VAD implements BaseVAD {
) )
} }
/**
* Emit event
*/
private emit<K extends keyof VADEvents>(event: K, data: VADEvents[K]): void {
if (!this.eventListeners[event])
return
for (const callback of this.eventListeners[event]!) {
callback(data)
}
}
/** /**
* Process a complete speech segment * Process a complete speech segment
*/ */
@@ -265,6 +247,24 @@ export class VAD implements BaseVAD {
this.postSpeechSamples = 0 this.postSpeechSamples = 0
this.prevBuffers = [] this.prevBuffers = []
} }
/**
* Update configuration
*/
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
this.config = { ...this.config, ...newConfig }
// If buffer size changed, create a new buffer
if (newConfig.maxBufferDuration || newConfig.sampleRate) {
this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
this.bufferPointer = 0
}
// Update sample rate tensor if needed
if (newConfig.sampleRate) {
this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
}
}
} }
/** /**
+3 -3
View File
@@ -11,15 +11,15 @@ export default mergeConfigs([
...presetWebFontsFonts('fontsource'), ...presetWebFontsFonts('fontsource'),
}, },
timeouts: { timeouts: {
failure: 10000,
warning: 5000, warning: 5000,
failure: 10000,
}, },
}), }),
], ],
rules: [ rules: [
['transition-colors-none', { ['transition-colors-none', {
'transition-duration': '0s',
'transition-property': 'color, background-color, border-color, text-color', 'transition-property': 'color, background-color, border-color, text-color',
'transition-duration': '0s',
}], }],
['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }], ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
@@ -27,10 +27,10 @@ export default mergeConfigs([
['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }], ['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }],
['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }], ['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }],
['p-safe', { ['p-safe', {
'padding-top': 'env(safe-area-inset-top)',
'padding-bottom': 'env(safe-area-inset-bottom)', 'padding-bottom': 'env(safe-area-inset-bottom)',
'padding-left': 'env(safe-area-inset-left)', 'padding-left': 'env(safe-area-inset-left)',
'padding-right': 'env(safe-area-inset-right)', 'padding-right': 'env(safe-area-inset-right)',
'padding-top': 'env(safe-area-inset-top)',
}], }],
], ],
shortcuts: [ shortcuts: [
+1 -1
View File
@@ -3,6 +3,6 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string
readonly VITE_PLATFORM: 'android' | 'ios' | 'web' readonly VITE_PLATFORM: 'ios' | 'android' | 'web'
// more env variables... // more env variables...
} }
+1 -1
View File
@@ -1,6 +1,6 @@
declare namespace NodeJS { declare namespace NodeJS {
export interface ProcessEnv { export interface ProcessEnv {
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
VITE_SKIP_MKCERT?: string VITE_SKIP_MKCERT?: string
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
} }
} }
+53 -53
View File
@@ -25,7 +25,7 @@ import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// import { isEnvTruthy } from '@proj-airi/stage-shared' // import { isEnvTruthy } from '@proj-airi/stage-shared'
function isEnvTruthy(value: null | string | undefined): boolean { function isEnvTruthy(value: string | undefined | null): boolean {
if (value == null) if (value == null)
return false return false
@@ -36,9 +36,6 @@ const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'package
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
export default defineConfig({ export default defineConfig({
build: {
sourcemap: true,
},
optimizeDeps: { optimizeDeps: {
exclude: [ exclude: [
// Internal Packages // Internal Packages
@@ -66,6 +63,46 @@ export default defineConfig({
'@framework/model/cubismmoc', '@framework/model/cubismmoc',
], ],
}, },
resolve: {
alias: {
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
},
},
server: {
host: '0.0.0.0',
port: 5273,
fs: {
// To mute errors like:
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
//
// See: https://vite.dev/config/server-options#server-fs-strict
strict: false,
},
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`,
],
},
},
build: {
sourcemap: true,
},
worker: {
format: 'es',
rollupOptions: {
output: {
inlineDynamicImports: false,
},
},
},
plugins: [ plugins: [
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '') ...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
? [] ? []
@@ -82,7 +119,6 @@ export default defineConfig({
Yaml(), Yaml(),
VueMacros({ VueMacros({
betterDefine: false,
plugins: { plugins: {
vue: Vue({ vue: Vue({
include: [/\.vue$/, /\.md$/], include: [/\.vue$/, /\.md$/],
@@ -90,24 +126,25 @@ export default defineConfig({
}), }),
vueJsx: false, vueJsx: false,
}, },
betterDefine: false,
}), }),
VueRouter({ VueRouter({
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
exclude: ['**/components/**'],
extensions: ['.vue', '.md'], extensions: ['.vue', '.md'],
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
importMode: 'async', importMode: 'async',
routesFolder: [ routesFolder: [
resolve(import.meta.dirname, 'src', 'pages'), resolve(import.meta.dirname, 'src', 'pages'),
{ {
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
exclude: base => [ exclude: base => [
...base, ...base,
'**/settings/connection/index.vue', '**/settings/connection/index.vue',
'**/settings/modules/beat-sync.vue', '**/settings/modules/beat-sync.vue',
], ],
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
}, },
], ],
exclude: ['**/components/**'],
}), }),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts // https://github.com/JohnCampionJr/vite-plugin-vue-layouts
@@ -124,35 +161,36 @@ export default defineConfig({
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({ VueI18n({
runtimeOnly: true,
compositionOnly: true, compositionOnly: true,
fullInstall: true, fullInstall: true,
runtimeOnly: true,
}), }),
// https://github.com/webfansplz/vite-plugin-vue-devtools // https://github.com/webfansplz/vite-plugin-vue-devtools
VueDevTools(), VueDevTools(),
DownloadLive2DSDK(), DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }), Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '') ...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '')
? [{ ? [{
name: 'proj-airi:capacitor-sync',
closeBundle: { closeBundle: {
sequential: true,
handler() { handler() {
if (this.meta.watchMode) { if (this.meta.watchMode) {
execSync('cap sync ios', { stdio: 'inherit' }) execSync('cap sync ios', { stdio: 'inherit' })
} }
}, },
sequential: true,
}, },
name: 'proj-airi:capacitor-sync',
} as PluginOption] } as PluginOption]
: [], : [],
{ {
name: 'proj-airi:defines',
config(ctx) { config(ctx) {
const define: Record<string, any> = { const define: Record<string, any> = {
'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'', 'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'',
@@ -166,44 +204,6 @@ export default defineConfig({
return { define } return { define }
}, },
name: 'proj-airi:defines',
}, },
], ],
resolve: {
alias: {
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
},
},
server: {
fs: {
// To mute errors like:
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
//
// See: https://vite.dev/config/server-options#server-fs-strict
strict: false,
},
host: '0.0.0.0',
port: 5273,
warmup: {
clientFiles: [
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`,
],
},
},
worker: {
format: 'es',
rollupOptions: {
output: {
inlineDynamicImports: false,
},
},
},
}) })
@@ -42,10 +42,11 @@ else {
export default { export default {
appId: 'ai.moeru.airi', appId: 'ai.moeru.airi',
appImage: { productName: 'AIRI',
artifactName: '${productName}-${version}-linux-${arch}.${ext}', directories: {
output: 'dist',
buildResources: 'build',
}, },
asar: true,
// // For self-publishing, testing, and distribution after modified the code without access to // // For self-publishing, testing, and distribution after modified the code without access to
// // an Apple Developer account, comment and uncomment the following lines. // // an Apple Developer account, comment and uncomment the following lines.
// // Later on when you obtained one, you can set up the necessary certificates and provisioning // // Later on when you obtained one, you can set up the necessary certificates and provisioning
@@ -62,30 +63,6 @@ export default {
// return // return
// } // }
asarUnpack: [
'**/*.node',
],
directories: {
buildResources: 'build',
output: 'dist',
},
dmg: {
artifactName: '${productName}-${version}-darwin-${arch}.${ext}',
},
extraMetadata: {
homepage: 'https://airi.moeru.ai/docs/',
license: 'MIT',
main: 'out/main/index.js',
name: 'ai.moeru.airi',
repository: 'https://github.com/moeru-ai/airi',
},
extraResources: [
{
filter: ['**/*'],
from: '../../engines/stage-tamagotchi-godot/out/${os}',
to: 'godot-stage',
},
],
// const appName = context.packager.appInfo.productFilename // const appName = context.packager.appInfo.productFilename
// await notarize({ // await notarize({
// appPath: `${appOutDir}/${appName}.app`, // appPath: `${appOutDir}/${appName}.app`,
@@ -116,45 +93,55 @@ export default {
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
'!{tsconfig.json}', '!{tsconfig.json}',
], ],
linux: { asar: true,
artifactName: '${productName}-${version}-linux-${arch}.${ext}', asarUnpack: [
category: 'Utility', '**/*.node',
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.', ],
executableName: 'airi', extraResources: [
icon: 'build/icons/icon.png', {
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux. from: '../../engines/stage-tamagotchi-godot/out/${os}',
publish: { to: 'godot-stage',
channel: 'latest-${arch}', filter: ['**/*'],
owner: 'moeru-ai',
provider: 'github',
repo: 'airi',
}, },
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.', ],
target: [ extraMetadata: {
'deb', name: 'ai.moeru.airi',
'rpm', main: 'out/main/index.js',
], homepage: 'https://airi.moeru.ai/docs/',
repository: 'https://github.com/moeru-ai/airi',
license: 'MIT',
},
win: {
executableName: 'airi',
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
channel: 'latest-${arch}',
},
},
nsis: {
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
deleteAppDataOnUninstall: true,
oneClick: false,
allowToChangeInstallationDirectory: true,
runAfterFinish: true,
}, },
mac: { mac: {
entitlementsInherit: 'build/entitlements.mac.plist', entitlementsInherit: 'build/entitlements.mac.plist',
executableName: 'airi',
extendInfo: {
NSCameraUsageDescription: 'AIRI requires camera access for vision understanding',
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device',
},
// For self-publishing, testing, and distribution after modified the code without access to
// an Apple Developer account, comment and uncomment the following 4 lines.
// Later on when you obtained one, you can set up the necessary certificates and provisioning
// profiles to enable these security features.
// hardenedRuntime: false,
hardenedRuntime: true,
icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns',
// notarize: false,
notarize: true,
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves // NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`). // to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
publish: { publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands // NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
// `${arch}` before it writes any publish metadata, and electron-updater later // `${arch}` before it writes any publish metadata, and electron-updater later
// reuses that expanded channel string when deciding which `*.yml` file to fetch. // reuses that expanded channel string when deciding which `*.yml` file to fetch.
@@ -218,35 +205,48 @@ export default {
// - Linux x64 -> `latest-x64-linux.yml` // - Linux x64 -> `latest-x64-linux.yml`
// - Linux arm64 -> `latest-arm64-linux-arm64.yml` // - Linux arm64 -> `latest-arm64-linux-arm64.yml`
channel: 'latest-${arch}', channel: 'latest-${arch}',
owner: 'moeru-ai',
provider: 'github',
repo: 'airi',
}, },
extendInfo: {
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device',
NSCameraUsageDescription: 'AIRI requires camera access for vision understanding',
},
// For self-publishing, testing, and distribution after modified the code without access to
// an Apple Developer account, comment and uncomment the following 4 lines.
// Later on when you obtained one, you can set up the necessary certificates and provisioning
// profiles to enable these security features.
// hardenedRuntime: false,
hardenedRuntime: true,
// notarize: false,
notarize: true,
executableName: 'airi',
icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns',
},
dmg: {
artifactName: '${productName}-${version}-darwin-${arch}.${ext}',
},
linux: {
target: [
'deb',
'rpm',
],
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
channel: 'latest-${arch}',
},
category: 'Utility',
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.',
executableName: 'airi',
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
icon: 'build/icons/icon.png',
},
appImage: {
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
}, },
npmRebuild: false, npmRebuild: false,
nsis: {
allowToChangeInstallationDirectory: true,
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
createDesktopShortcut: 'always',
deleteAppDataOnUninstall: true,
oneClick: false,
runAfterFinish: true,
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
},
productName: 'AIRI',
win: {
executableName: 'airi',
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
publish: {
channel: 'latest-${arch}',
owner: 'moeru-ai',
provider: 'github',
repo: 'airi',
},
},
} satisfies Configuration } satisfies Configuration
+91 -91
View File
@@ -73,8 +73,8 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')), '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')),
'@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')), '@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')),
'@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')),
}, },
}, },
}, },
@@ -83,8 +83,8 @@ export default defineConfig({
build: { build: {
lib: { lib: {
entry: { entry: {
'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')),
'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')), 'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')),
'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')),
}, },
}, },
}, },
@@ -100,8 +100,8 @@ export default defineConfig({
build: { build: {
rolldownOptions: { rolldownOptions: {
input: { input: {
'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')),
'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')), 'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')),
'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')),
}, },
}, },
}, },
@@ -135,102 +135,18 @@ export default defineConfig({
], ],
}, },
plugins: [
Info(),
{
config(ctx) {
const define: Record<string, any> = {
'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'',
}
if (ctx.mode === 'development') {
define['import.meta.env.URL_MODE'] = '\'server\''
}
if (ctx.mode === 'production') {
define['import.meta.env.URL_MODE'] = '\'file\''
}
return { define }
},
name: 'proj-airi:defines',
},
Inspect(),
Yaml(),
VueMacros({
betterDefine: false,
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
vueJsx: false,
},
}),
VueRouter({
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
exclude: ['**/components/**'],
routesFolder: [
{
exclude: base => [
...base,
'**/settings/account/index.vue',
'**/settings/connection/index.vue',
'**/settings/data/index.vue',
'**/settings/models/index.vue',
'**/settings/system/general.vue',
'**/settings/modules/mcp.vue',
'**/devtools/index.vue',
'**/settings/index.vue',
],
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
},
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
],
}),
VitePluginVueDevTools(),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts({
layoutsDirs: [
resolve(import.meta.dirname, 'src', 'renderer', 'layouts'),
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'),
],
pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')],
}),
UnoCss(),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
compositionOnly: true,
fullInstall: true,
runtimeOnly: true,
}),
DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
],
resolve: { resolve: {
alias: { alias: {
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')), '@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')), '@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
// NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown // NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown
// concatenates sub-paths without a file extension, so bare .ts files at the // concatenates sub-paths without a file extension, so bare .ts files at the
// stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases // stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases
// for each such file that the renderer imports from @proj-airi/stage-ui. // for each such file that the renderer imports from @proj-airi/stage-ui.
'@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')), '@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
}, },
}, },
@@ -258,5 +174,89 @@ export default defineConfig({
}, },
}, },
}, },
plugins: [
Info(),
{
name: 'proj-airi:defines',
config(ctx) {
const define: Record<string, any> = {
'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'',
}
if (ctx.mode === 'development') {
define['import.meta.env.URL_MODE'] = '\'server\''
}
if (ctx.mode === 'production') {
define['import.meta.env.URL_MODE'] = '\'file\''
}
return { define }
},
},
Inspect(),
Yaml(),
VueMacros({
plugins: {
vue: Vue({
include: [/\.vue$/, /\.md$/],
...templateCompilerOptions,
}),
vueJsx: false,
},
betterDefine: false,
}),
VueRouter({
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
routesFolder: [
{
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
exclude: base => [
...base,
'**/settings/account/index.vue',
'**/settings/connection/index.vue',
'**/settings/data/index.vue',
'**/settings/models/index.vue',
'**/settings/system/general.vue',
'**/settings/modules/mcp.vue',
'**/devtools/index.vue',
'**/settings/index.vue',
],
},
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
],
exclude: ['**/components/**'],
}),
VitePluginVueDevTools(),
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
Layouts({
layoutsDirs: [
resolve(import.meta.dirname, 'src', 'renderer', 'layouts'),
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'),
],
pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')],
}),
UnoCss(),
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
}),
DownloadLive2DSDK(),
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
],
}, },
}) })
@@ -50,14 +50,14 @@ async function main() {
const args = cli.parse() const args = cli.parse()
const argOptions = args.options as { const argOptions = args.options as {
release: boolean
autoTag: boolean autoTag: boolean
tag: string[]
getBundleName: boolean getBundleName: boolean
getFilename: string[]
getOutputFilename: string[]
getProductName: boolean getProductName: boolean
getVersion: boolean getVersion: boolean
release: boolean getFilename: string[]
tag: string[] getOutputFilename: string[]
} }
const target = args.args[0] const target = args.args[0]
@@ -94,7 +94,7 @@ async function main() {
return return
} }
if (argOptions.getVersion) { if (argOptions.getVersion) {
const version = await getVersion({ autoTag: argOptions.autoTag, release: argOptions.release, tag: argOptions.tag }) const version = await getVersion({ release: argOptions.release, autoTag: argOptions.autoTag, tag: argOptions.tag })
console.info(version) console.info(version)
} }
} }
@@ -10,9 +10,9 @@ afterEach(() => {
function createMockSocket() { function createMockSocket() {
const socket = new EventEmitter() as EventEmitter & { const socket = new EventEmitter() as EventEmitter & {
addEventListener: (event: string, listener: (...args: any[]) => void) => void
close: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn> send: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
addEventListener: (event: string, listener: (...args: any[]) => void) => void
} }
socket.send = vi.fn() socket.send = vi.fn()
socket.close = vi.fn(() => { socket.close = vi.fn(() => {
@@ -22,24 +22,16 @@ interface DebugTarget {
webSocketDebuggerUrl?: string webSocketDebuggerUrl?: string
} }
interface McpApplyResult {
failed: Array<{ error: string, name: string }>
skipped: Array<{ name: string, reason: string }>
started: Array<{ name: string }>
}
interface McpResult { interface McpResult {
content?: unknown[] content?: unknown[]
isError?: boolean
structuredContent?: Record<string, unknown> structuredContent?: Record<string, unknown>
isError?: boolean
} }
interface McpRuntimeStatus { interface McpApplyResult {
servers: Array<{ started: Array<{ name: string }>
lastError?: string failed: Array<{ name: string, error: string }>
name: string skipped: Array<{ name: string, reason: string }>
state: 'error' | 'running' | 'stopped'
}>
} }
interface McpToolDescriptor { interface McpToolDescriptor {
@@ -48,6 +40,14 @@ interface McpToolDescriptor {
toolName: string toolName: string
} }
interface McpRuntimeStatus {
servers: Array<{
name: string
state: 'running' | 'stopped' | 'error'
lastError?: string
}>
}
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const repoDir = resolve(packageDir, '../..') const repoDir = resolve(packageDir, '../..')
const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
@@ -79,15 +79,70 @@ const smokeHtml = `<!doctype html>
const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}` const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}`
function assert(condition: boolean, message: string): asserts condition {
if (!condition)
throw new Error(message)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function findAvailablePort(): Promise<number> {
return await new Promise((resolvePort, reject) => {
const server = createServer()
server.listen(0, '127.0.0.1', () => {
const address = server.address()
server.close(() => {
if (typeof address === 'object' && address?.port) {
resolvePort(address.port)
}
else {
reject(new Error('failed to allocate debug port'))
}
})
})
server.on('error', reject)
})
}
async function waitFor<T>(
label: string,
probe: () => Promise<T | undefined> | T | undefined,
timeoutMs: number,
intervalMs: number,
): Promise<T> {
const start = Date.now()
let lastError: unknown
while ((Date.now() - start) < timeoutMs) {
try {
const value = await probe()
if (value !== undefined)
return value
}
catch (error) {
lastError = error
}
await sleep(intervalMs)
}
const suffix = lastError instanceof Error ? `: ${lastError.message}` : ''
throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`)
}
export class CdpClient { export class CdpClient {
private socket?: WebSocket
private nextId = 1 private nextId = 1
private pending = new Map<number, { private pending = new Map<number, {
reject: (error: Error) => void
resolve: (value: Record<string, unknown>) => void resolve: (value: Record<string, unknown>) => void
reject: (error: Error) => void
}>() }>()
private socket?: WebSocket
constructor(socket: WebSocket) { constructor(socket: WebSocket) {
this.socket = socket this.socket = socket
this.socket.addEventListener('message', (event) => { this.socket.addEventListener('message', (event) => {
@@ -125,16 +180,24 @@ export class CdpClient {
return new CdpClient(socket) return new CdpClient(socket)
} }
close() { async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
this.failPending('CDP socket closed') if (!this.socket) {
this.socket?.close() throw new Error('CDP socket is closed')
this.socket = undefined }
const id = this.nextId++
const promise = new Promise<Record<string, unknown>>((resolveMessage, reject) => {
this.pending.set(id, { resolve: resolveMessage, reject })
})
this.socket.send(JSON.stringify({ id, method, params: params ?? {} }))
return await promise
} }
async evaluate<T>(expression: string): Promise<T> { async evaluate<T>(expression: string): Promise<T> {
const response = await this.send('Runtime.evaluate', { const response = await this.send('Runtime.evaluate', {
awaitPromise: true,
expression, expression,
awaitPromise: true,
returnByValue: true, returnByValue: true,
}) })
const result = response.result const result = response.result
@@ -153,18 +216,10 @@ export class CdpClient {
return remoteObject.value as T return remoteObject.value as T
} }
async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> { close() {
if (!this.socket) { this.failPending('CDP socket closed')
throw new Error('CDP socket is closed') this.socket?.close()
} this.socket = undefined
const id = this.nextId++
const promise = new Promise<Record<string, unknown>>((resolveMessage, reject) => {
this.pending.set(id, { reject, resolve: resolveMessage })
})
this.socket.send(JSON.stringify({ id, method, params: params ?? {} }))
return await promise
} }
private failPending(reason: string) { private failPending(reason: string) {
@@ -179,17 +234,92 @@ export class CdpClient {
} }
} }
function assert(condition: boolean, message: string): asserts condition { async function fetchJson<T>(url: string): Promise<T> {
if (!condition) const response = await fetch(url)
throw new Error(message) if (!response.ok)
throw new Error(`${url} returned ${response.status}`)
return await response.json() as T
} }
async function callOverlayMcpTool(client: CdpClient, name: string, args: Record<string, unknown> = {}): Promise<McpResult> { async function prepareMcpConfig() {
const result = await client.evaluate<McpResult>(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ arguments: args, name })})`) await mkdir(userDataDir, { recursive: true })
if (result.isError) { await mkdir(mcpSessionRoot, { recursive: true })
throw new Error(`${name} returned isError=true`)
const mcpEnv: Record<string, string> = {
PATH: env.PATH || '',
HOME: env.HOME || '',
SHELL: env.SHELL || '',
LANG: env.LANG || 'en_US.UTF-8',
TMPDIR: env.TMPDIR || '',
COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local',
COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never',
COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome',
COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`,
COMPUTER_USE_SESSION_ROOT: mcpSessionRoot,
} }
return result
for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) {
const value = env[optionalEnvName]?.trim()
if (value) {
mcpEnv[optionalEnvName] = value
}
}
const config = {
mcpServers: {
computer_use: {
command: 'pnpm',
args: ['-F', '@proj-airi/computer-use-mcp', 'start'],
cwd: repoDir,
enabled: true,
env: mcpEnv,
},
},
}
await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
}
async function ensureSmokePrerequisites() {
if (typeof WebSocket !== 'function') {
throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.')
}
const missingOutputs: string[] = []
for (const relativePath of requiredWorkspaceBuildOutputs) {
try {
await access(resolve(repoDir, relativePath))
}
catch {
missingOutputs.push(relativePath)
}
}
if (missingOutputs.length === 0)
return
throw new Error([
'APP_START_FAILED: required workspace build outputs are missing.',
`Missing: ${missingOutputs.join(', ')}`,
'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.',
'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build',
].join(' '))
}
async function waitForRemoteDebug(debugPort: number): Promise<string> {
const version = await waitFor('Electron remote debug endpoint', async () => {
const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`)
return data.webSocketDebuggerUrl
}, 120_000, 500)
return version
}
async function findOverlayTarget(debugPort: number): Promise<DebugTarget> {
return await waitFor('desktop overlay debug target', async () => {
const targets = await fetchJson<DebugTarget[]>(`http://127.0.0.1:${debugPort}/json/list`)
return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay'))
}, 120_000, 500)
} }
async function connectOverlayClient(debugPort: number): Promise<CdpClient> { async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
@@ -208,6 +338,14 @@ async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
return client return client
} }
async function callOverlayMcpTool(client: CdpClient, name: string, args: Record<string, unknown> = {}): Promise<McpResult> {
const result = await client.evaluate<McpResult>(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ name, arguments: args })})`)
if (result.isError) {
throw new Error(`${name} returned isError=true`)
}
return result
}
async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> { async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
const applyResult = await client.evaluate<McpApplyResult>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()') const applyResult = await client.evaluate<McpApplyResult>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()')
const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use') const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use')
@@ -235,68 +373,91 @@ async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
}, 30_000, 500) }, 30_000, 500)
} }
async function ensureSmokePrerequisites() { function requireStructuredContent(result: McpResult, label: string): Record<string, unknown> {
if (typeof WebSocket !== 'function') { if (!isRecord(result.structuredContent))
throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.') throw new Error(`${label} missing structuredContent`)
}
const missingOutputs: string[] = [] if (result.structuredContent.status && result.structuredContent.status !== 'ok')
for (const relativePath of requiredWorkspaceBuildOutputs) { throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`)
try {
await access(resolve(repoDir, relativePath)) return result.structuredContent
} }
catch {
missingOutputs.push(relativePath) function requireRunState(result: McpResult, label: string): Record<string, unknown> {
const structuredContent = requireStructuredContent(result, label)
if (!isRecord(structuredContent.runState))
throw new Error(`${label} missing runState`)
return structuredContent.runState
}
function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams {
const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], {
cwd: repoDir,
detached: true,
env: {
...env,
APP_REMOTE_DEBUG: 'true',
APP_REMOTE_DEBUG_PORT: String(debugPort),
APP_REMOTE_DEBUG_NO_OPEN: 'true',
APP_USER_DATA_PATH: userDataDir,
AIRI_DESKTOP_OVERLAY: '1',
AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1',
},
stdio: 'pipe',
})
const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' })
const capture = (chunk: Buffer) => {
const text = chunk.toString('utf-8')
stageLogStream.write(text)
for (const line of text.split(/\r?\n/u)) {
if (line.includes(desktopOverlayPollHeartbeatMarker)) {
heartbeatLines.push(line)
}
} }
} }
stageProcess.stdout.on('data', capture)
stageProcess.stderr.on('data', capture)
stageProcess.on('close', () => stageLogStream.end())
if (missingOutputs.length === 0) return stageProcess
}
async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) {
if (!stageProcess || stageProcess.exitCode !== null)
return return
throw new Error([ const signalStageProcessGroup = (signal: NodeJS.Signals) => {
'APP_START_FAILED: required workspace build outputs are missing.', try {
`Missing: ${missingOutputs.join(', ')}`, if (stageProcess.pid) {
'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.', killProcess(-stageProcess.pid, signal)
'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build', return
].join(' ')) }
}
catch {
// Fall back to the pnpm wrapper process if process-group signalling is
// unavailable. The smoke starts a detached group to make this reliable on
// macOS, but the fallback keeps the helper safe on other local setups.
}
stageProcess.kill(signal)
}
signalStageProcessGroup('SIGTERM')
await Promise.race([
new Promise(resolve => stageProcess.once('exit', resolve)),
sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')),
])
} }
async function fetchJson<T>(url: string): Promise<T> { function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise<never> {
const response = await fetch(url) return new Promise((_, reject) => {
if (!response.ok) stageProcess.once('exit', (code, signal) => {
throw new Error(`${url} returned ${response.status}`) reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`))
return await response.json() as T
}
async function findAvailablePort(): Promise<number> {
return await new Promise((resolvePort, reject) => {
const server = createServer()
server.listen(0, '127.0.0.1', () => {
const address = server.address()
server.close(() => {
if (typeof address === 'object' && address?.port) {
resolvePort(address.port)
}
else {
reject(new Error('failed to allocate debug port'))
}
})
}) })
server.on('error', reject)
}) })
} }
async function findOverlayTarget(debugPort: number): Promise<DebugTarget> {
return await waitFor('desktop overlay debug target', async () => {
const targets = await fetchJson<DebugTarget[]>(`http://127.0.0.1:${debugPort}/json/list`)
return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay'))
}, 120_000, 500)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
async function main() { async function main() {
let stageProcess: ChildProcessWithoutNullStreams | undefined let stageProcess: ChildProcessWithoutNullStreams | undefined
let overlayClient: CdpClient | undefined let overlayClient: CdpClient | undefined
@@ -344,7 +505,7 @@ async function main() {
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`) throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
}) })
const readiness = await overlayClient.evaluate<{ error?: string, state: 'booting' | 'degraded' | 'ready' }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()') const readiness = await overlayClient.evaluate<{ state: 'booting' | 'ready' | 'degraded', error?: string }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()')
if (readiness.state !== 'ready') { if (readiness.state !== 'ready') {
throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`) throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`)
} }
@@ -360,8 +521,8 @@ async function main() {
) )
const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState) const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState)
await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', { await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', {
button: 'left',
candidateId, candidateId,
button: 'left',
clickCount: 1, clickCount: 1,
}) })
const postClickRunState = requireRunState( const postClickRunState = requireRunState(
@@ -383,10 +544,10 @@ async function main() {
}) })
console.info(JSON.stringify({ console.info(JSON.stringify({
heartbeat,
ok: true, ok: true,
reportDir, reportDir,
stageLogPath, stageLogPath,
heartbeat,
}, null, 2)) }, null, 2))
} }
finally { finally {
@@ -396,168 +557,6 @@ async function main() {
} }
} }
async function prepareMcpConfig() {
await mkdir(userDataDir, { recursive: true })
await mkdir(mcpSessionRoot, { recursive: true })
const mcpEnv: Record<string, string> = {
COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never',
COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local',
COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome',
COMPUTER_USE_SESSION_ROOT: mcpSessionRoot,
COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`,
HOME: env.HOME || '',
LANG: env.LANG || 'en_US.UTF-8',
PATH: env.PATH || '',
SHELL: env.SHELL || '',
TMPDIR: env.TMPDIR || '',
}
for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) {
const value = env[optionalEnvName]?.trim()
if (value) {
mcpEnv[optionalEnvName] = value
}
}
const config = {
mcpServers: {
computer_use: {
args: ['-F', '@proj-airi/computer-use-mcp', 'start'],
command: 'pnpm',
cwd: repoDir,
enabled: true,
env: mcpEnv,
},
},
}
await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
}
function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise<never> {
return new Promise((_, reject) => {
stageProcess.once('exit', (code, signal) => {
reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`))
})
})
}
function requireRunState(result: McpResult, label: string): Record<string, unknown> {
const structuredContent = requireStructuredContent(result, label)
if (!isRecord(structuredContent.runState))
throw new Error(`${label} missing runState`)
return structuredContent.runState
}
function requireStructuredContent(result: McpResult, label: string): Record<string, unknown> {
if (!isRecord(result.structuredContent))
throw new Error(`${label} missing structuredContent`)
if (result.structuredContent.status && result.structuredContent.status !== 'ok')
throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`)
return result.structuredContent
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams {
const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], {
cwd: repoDir,
detached: true,
env: {
...env,
AIRI_DESKTOP_OVERLAY: '1',
AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1',
APP_REMOTE_DEBUG: 'true',
APP_REMOTE_DEBUG_NO_OPEN: 'true',
APP_REMOTE_DEBUG_PORT: String(debugPort),
APP_USER_DATA_PATH: userDataDir,
},
stdio: 'pipe',
})
const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' })
const capture = (chunk: Buffer) => {
const text = chunk.toString('utf-8')
stageLogStream.write(text)
for (const line of text.split(/\r?\n/u)) {
if (line.includes(desktopOverlayPollHeartbeatMarker)) {
heartbeatLines.push(line)
}
}
}
stageProcess.stdout.on('data', capture)
stageProcess.stderr.on('data', capture)
stageProcess.on('close', () => stageLogStream.end())
return stageProcess
}
async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) {
if (!stageProcess || stageProcess.exitCode !== null)
return
const signalStageProcessGroup = (signal: NodeJS.Signals) => {
try {
if (stageProcess.pid) {
killProcess(-stageProcess.pid, signal)
return
}
}
catch {
// Fall back to the pnpm wrapper process if process-group signalling is
// unavailable. The smoke starts a detached group to make this reliable on
// macOS, but the fallback keeps the helper safe on other local setups.
}
stageProcess.kill(signal)
}
signalStageProcessGroup('SIGTERM')
await Promise.race([
new Promise(resolve => stageProcess.once('exit', resolve)),
sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')),
])
}
async function waitFor<T>(
label: string,
probe: () => Promise<T | undefined> | T | undefined,
timeoutMs: number,
intervalMs: number,
): Promise<T> {
const start = Date.now()
let lastError: unknown
while ((Date.now() - start) < timeoutMs) {
try {
const value = await probe()
if (value !== undefined)
return value
}
catch (error) {
lastError = error
}
await sleep(intervalMs)
}
const suffix = lastError instanceof Error ? `: ${lastError.message}` : ''
throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`)
}
async function waitForRemoteDebug(debugPort: number): Promise<string> {
const version = await waitFor('Electron remote debug endpoint', async () => {
const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`)
return data.webSocketDebuggerUrl
}, 120_000, 500)
return version
}
if (import.meta.main) { if (import.meta.main) {
main().catch((error) => { main().catch((error) => {
console.error(errorMessageFromValue(error)) console.error(errorMessageFromValue(error))
@@ -8,22 +8,22 @@ import { cac } from 'cac'
import * as yaml from 'yaml' import * as yaml from 'yaml'
type Platform = 'arm64' | 'both' | 'none' | 'x64' interface UpdateInfoFile {
url: string
sha2?: string
sha512?: string
size?: number
}
interface UpdateInfo { interface UpdateInfo {
[key: string]: unknown
files?: UpdateInfoFile[] files?: UpdateInfoFile[]
path?: string path?: string
sha2?: string sha2?: string
sha512?: string sha512?: string
[key: string]: unknown
} }
interface UpdateInfoFile { type Platform = 'x64' | 'arm64' | 'both' | 'none'
sha2?: string
sha512?: string
size?: number
url: string
}
const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i
@@ -51,6 +51,18 @@ export const regexpHasX64 = /(^|[-_/])x64([-.]|$)/i
export const regexpIsMacZip = /-mac\.zip$/i export const regexpIsMacZip = /-mac\.zip$/i
export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i
function isArm64MacZip(url: string): boolean {
return regexpIsArm64MacZip.test(url)
}
function isX64MacZip(url: string): boolean {
return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url)
}
function getMacZipUrls(updateInfo: UpdateInfo): string[] {
return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url))
}
function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude<Platform, 'both' | 'none'>, filePath: string) { function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude<Platform, 'both' | 'none'>, filePath: string) {
const zipUrls = getMacZipUrls(updateInfo) const zipUrls = getMacZipUrls(updateInfo)
@@ -73,46 +85,6 @@ function assertMergedContainsBothMacZips(updateInfo: UpdateInfo) {
} }
} }
function collectLatestMacFiles(rootDir: string): string[] {
const results: string[] = []
// eslint-disable-next-line no-console
console.debug('merge-latest-mac: scan context', {
cwd: cwd(),
rootDir,
})
if (!existsSync(rootDir)) {
console.warn('merge-latest-mac: scan directory missing', rootDir)
return results
}
if (!statSync(rootDir).isDirectory()) {
return results
}
const entries = readdirSync(rootDir, { withFileTypes: true })
// eslint-disable-next-line no-console
console.debug('merge-latest-mac: scan directory entries', {
entries: entries.map(entry => ({
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
name: entry.name,
})),
rootDir,
})
for (const entry of entries) {
const fullPath = resolve(rootDir, entry.name)
if (entry.isDirectory()) {
results.push(...collectLatestMacFiles(fullPath))
continue
}
if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) {
results.push(fullPath)
}
}
return results
}
function detectPlatform(updateInfo: UpdateInfo): Platform { function detectPlatform(updateInfo: UpdateInfo): Platform {
const urls = getUrls(updateInfo) const urls = getUrls(updateInfo)
@@ -133,16 +105,69 @@ function detectPlatform(updateInfo: UpdateInfo): Platform {
return 'none' return 'none'
} }
function getMacZipUrls(updateInfo: UpdateInfo): string[] { function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo {
return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url)) const arm64Files = Array.isArray(arm64.files) ? arm64.files : []
const x64Files = Array.isArray(x64.files) ? x64.files : []
const byUrl = new Map<string, UpdateInfoFile>()
for (const file of [...arm64Files, ...x64Files]) {
if (file?.url) {
byUrl.set(file.url, file)
}
}
return {
...arm64,
files: [...byUrl.values()],
path: undefined,
sha2: undefined,
sha512: undefined,
}
} }
function isArm64MacZip(url: string): boolean { async function readUpdateInfo(filePath: string): Promise<UpdateInfo> {
return regexpIsArm64MacZip.test(url) const raw = await readFile(filePath, 'utf8')
return yaml.parse(raw) as UpdateInfo
} }
function isX64MacZip(url: string): boolean { function collectLatestMacFiles(rootDir: string): string[] {
return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url) const results: string[] = []
// eslint-disable-next-line no-console
console.debug('merge-latest-mac: scan context', {
cwd: cwd(),
rootDir,
})
if (!existsSync(rootDir)) {
console.warn('merge-latest-mac: scan directory missing', rootDir)
return results
}
if (!statSync(rootDir).isDirectory()) {
return results
}
const entries = readdirSync(rootDir, { withFileTypes: true })
// eslint-disable-next-line no-console
console.debug('merge-latest-mac: scan directory entries', {
rootDir,
entries: entries.map(entry => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
})),
})
for (const entry of entries) {
const fullPath = resolve(rootDir, entry.name)
if (entry.isDirectory()) {
results.push(...collectLatestMacFiles(fullPath))
continue
}
if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) {
results.push(fullPath)
}
}
return results
} }
async function main() { async function main() {
@@ -185,7 +210,7 @@ async function main() {
throw new Error('No latest-mac*.yml files found') throw new Error('No latest-mac*.yml files found')
} }
const entries: { filePath: string, platform: Platform, updateInfo: UpdateInfo }[] = [] const entries: { filePath: string, updateInfo: UpdateInfo, platform: Platform }[] = []
for (const filePath of files) { for (const filePath of files) {
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
console.warn('merge-latest-mac: missing file', filePath) console.warn('merge-latest-mac: missing file', filePath)
@@ -199,7 +224,7 @@ async function main() {
assertContainsMacZip(updateInfo, platform, filePath) assertContainsMacZip(updateInfo, platform, filePath)
} }
entries.push({ filePath, platform, updateInfo }) entries.push({ filePath, updateInfo, platform })
} }
if (entries.length === 0) { if (entries.length === 0) {
@@ -239,31 +264,6 @@ async function main() {
await writeFile(outputPath, yaml.stringify(merged), 'utf8') await writeFile(outputPath, yaml.stringify(merged), 'utf8')
} }
function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo {
const arm64Files = Array.isArray(arm64.files) ? arm64.files : []
const x64Files = Array.isArray(x64.files) ? x64.files : []
const byUrl = new Map<string, UpdateInfoFile>()
for (const file of [...arm64Files, ...x64Files]) {
if (file?.url) {
byUrl.set(file.url, file)
}
}
return {
...arm64,
files: [...byUrl.values()],
path: undefined,
sha2: undefined,
sha512: undefined,
}
}
async function readUpdateInfo(filePath: string): Promise<UpdateInfo> {
const raw = await readFile(filePath, 'utf8')
return yaml.parse(raw) as UpdateInfo
}
main().catch((error) => { main().catch((error) => {
console.error(error) console.error(error)
exit(1) exit(1)
@@ -32,12 +32,12 @@ describe('regenerateWindowsLatest', () => {
await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8') await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8')
await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8') await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8')
await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({ await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({
files: [{ sha512: 'stale-sha512', size: 10, url: 'stale.exe' }],
path: 'stale.exe',
releaseDate: '2026-01-02T03:04:05.000Z',
sha512: 'stale-sha512',
stagingPercentage: 25,
version: 'stale-version', version: 'stale-version',
path: 'stale.exe',
sha512: 'stale-sha512',
releaseDate: '2026-01-02T03:04:05.000Z',
stagingPercentage: 25,
files: [{ url: 'stale.exe', sha512: 'stale-sha512', size: 10 }],
}), 'utf8') }), 'utf8')
process.chdir(packageDir) process.chdir(packageDir)
@@ -50,18 +50,18 @@ describe('regenerateWindowsLatest', () => {
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe')) const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'))
expect(nextUpdateInfo).toMatchObject({ expect(nextUpdateInfo).toMatchObject({
version: '1.2.3',
path: 'AIRI-1.2.3-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
sha2: expectedHashes.sha256,
releaseDate: '2026-01-02T03:04:05.000Z',
stagingPercentage: 25,
files: [ files: [
{ {
sha512: expectedHashes.sha512,
url: 'AIRI-1.2.3-windows-x64-setup.exe', url: 'AIRI-1.2.3-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
}, },
], ],
path: 'AIRI-1.2.3-windows-x64-setup.exe',
releaseDate: '2026-01-02T03:04:05.000Z',
sha2: expectedHashes.sha256,
sha512: expectedHashes.sha512,
stagingPercentage: 25,
version: '1.2.3',
}) })
expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content')) expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content'))
@@ -81,24 +81,24 @@ describe('regenerateWindowsLatest', () => {
await regenerateWindowsLatest({ await regenerateWindowsLatest({
input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe', input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe',
output: 'bundle/latest.yml', output: 'bundle/latest.yml',
releaseDate: '2026-03-23T00:00:00.000Z',
version: '9.9.9', version: '9.9.9',
releaseDate: '2026-03-23T00:00:00.000Z',
}) })
const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8')) const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8'))
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe')) const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'))
expect(persisted).toMatchObject({ expect(persisted).toMatchObject({
version: '9.9.9',
path: 'AIRI-9.9.9-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
sha2: expectedHashes.sha256,
releaseDate: '2026-03-23T00:00:00.000Z',
files: [ files: [
{ {
sha512: expectedHashes.sha512,
url: 'AIRI-9.9.9-windows-x64-setup.exe', url: 'AIRI-9.9.9-windows-x64-setup.exe',
sha512: expectedHashes.sha512,
}, },
], ],
path: 'AIRI-9.9.9-windows-x64-setup.exe',
releaseDate: '2026-03-23T00:00:00.000Z',
sha2: expectedHashes.sha256,
sha512: expectedHashes.sha512,
version: '9.9.9',
}) })
}) })
}) })
@@ -9,30 +9,23 @@ import { cac } from 'cac'
import * as yaml from 'yaml' import * as yaml from 'yaml'
export interface RegenerateWindowsLatestOptions {
input: string
output: string
releaseDate?: string
version: string
}
interface UpdateFileInfo { interface UpdateFileInfo {
url: string
sha512: string sha512: string
size?: number size?: number
url: string
} }
interface WindowsUpdateInfo { interface WindowsUpdateInfo {
[key: string]: unknown version: string
files: UpdateFileInfo[] files: UpdateFileInfo[]
path: string path: string
releaseDate?: string
sha2?: string
sha512: string sha512: string
version: string sha2?: string
releaseDate?: string
[key: string]: unknown
} }
export async function hashFile(filePath: string): Promise<{ sha256: string, sha512: string }> { export async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> {
return await new Promise((resolveHash, reject) => { return await new Promise((resolveHash, reject) => {
const sha512 = createHash('sha512') const sha512 = createHash('sha512')
const sha256 = createHash('sha256') const sha256 = createHash('sha256')
@@ -45,8 +38,8 @@ export async function hashFile(filePath: string): Promise<{ sha256: string, sha5
stream.on('error', reject) stream.on('error', reject)
stream.on('end', () => { stream.on('end', () => {
resolveHash({ resolveHash({
sha256: sha256.digest('hex'),
sha512: sha512.digest('base64'), sha512: sha512.digest('base64'),
sha256: sha256.digest('hex'),
}) })
}) })
}) })
@@ -62,6 +55,30 @@ export async function readExistingUpdateInfo(filePath: string): Promise<Partial<
} }
} }
export async function resolveFromWorkspace(inputPath: string): Promise<string> {
const resolved = resolve(inputPath)
if (existsSync(resolved)) {
return resolved
}
const workspaceRoot = await findWorkspaceDir(cwd())
if (workspaceRoot) {
const workspaceResolved = resolve(workspaceRoot, inputPath)
if (existsSync(workspaceResolved)) {
return workspaceResolved
}
}
return resolved
}
export interface RegenerateWindowsLatestOptions {
input: string
output: string
version: string
releaseDate?: string
}
export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise<WindowsUpdateInfo> { export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise<WindowsUpdateInfo> {
const input = String(options.input || '').trim() const input = String(options.input || '').trim()
const output = String(options.output || '').trim() const output = String(options.output || '').trim()
@@ -81,24 +98,24 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
const inputPath = await resolveFromWorkspace(input) const inputPath = await resolveFromWorkspace(input)
const outputPath = await resolveFromWorkspace(output) const outputPath = await resolveFromWorkspace(output)
const fileStats = await stat(inputPath) const fileStats = await stat(inputPath)
const { sha256, sha512 } = await hashFile(inputPath) const { sha512, sha256 } = await hashFile(inputPath)
const existing = await readExistingUpdateInfo(outputPath) const existing = await readExistingUpdateInfo(outputPath)
const url = basename(inputPath) const url = basename(inputPath)
const nextUpdateInfo: WindowsUpdateInfo = { const nextUpdateInfo: WindowsUpdateInfo = {
...existing, ...existing,
version,
files: [ files: [
{ {
url,
sha512, sha512,
size: fileStats.size, size: fileStats.size,
url,
}, },
], ],
path: url, path: url,
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
sha2: sha256,
sha512, sha512,
version, sha2: sha256,
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
} }
await mkdir(dirname(outputPath), { recursive: true }) await mkdir(dirname(outputPath), { recursive: true })
@@ -107,23 +124,6 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
return nextUpdateInfo return nextUpdateInfo
} }
export async function resolveFromWorkspace(inputPath: string): Promise<string> {
const resolved = resolve(inputPath)
if (existsSync(resolved)) {
return resolved
}
const workspaceRoot = await findWorkspaceDir(cwd())
if (workspaceRoot) {
const workspaceResolved = resolve(workspaceRoot, inputPath)
if (existsSync(workspaceResolved)) {
return workspaceResolved
}
}
return resolved
}
async function main() { async function main() {
const cli = cac('regenerate-windows-latest') const cli = cac('regenerate-windows-latest')
.option('--input <path>', 'Signed Windows installer path', { type: [String] }) .option('--input <path>', 'Signed Windows installer path', { type: [String] })
@@ -141,8 +141,8 @@ async function main() {
await regenerateWindowsLatest({ await regenerateWindowsLatest({
input, input,
output, output,
releaseDate,
version, version,
releaseDate,
}) })
} }
@@ -39,8 +39,8 @@ async function main() {
const beforeProductName = productName const beforeProductName = productName
const argOptions = args.options as { const argOptions = args.options as {
autoTag: boolean
release: boolean release: boolean
autoTag: boolean
tag: string[] tag: string[]
} }
@@ -25,7 +25,7 @@ async function main() {
} }
const cleanVersion = version.replace(/^v/, '') const cleanVersion = version.replace(/^v/, '')
const releaseOptions = { autoTag: false, release: true, tag: [cleanVersion] } const releaseOptions = { release: true, autoTag: false, tag: [cleanVersion] }
const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions) const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions)
const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions) const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions)
@@ -13,7 +13,7 @@ describe('generateManifestFixtures', () => {
afterEach(async () => { afterEach(async () => {
await Promise.all(roots.map(async (root) => { await Promise.all(roots.map(async (root) => {
await import('node:fs/promises').then(({ rm }) => rm(root, { force: true, recursive: true })) await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))
})) }))
roots.length = 0 roots.length = 0
}) })
@@ -31,12 +31,12 @@ describe('generateManifestFixtures', () => {
roots.push(root) roots.push(root)
const result = await generateManifestFixtures({ const result = await generateManifestFixtures({
artifactContent: 'mock-installer-binary',
channel: 'stable',
releaseNotes: 'Mock update for AIRI local updater verification.',
rootDir: root, rootDir: root,
channel: 'stable',
target: 'x86_64-pc-windows-msvc', target: 'x86_64-pc-windows-msvc',
version: '9.9.9-test.1', version: '9.9.9-test.1',
releaseNotes: 'Mock update for AIRI local updater verification.',
artifactContent: 'mock-installer-binary',
}) })
expect(result.channelDir).toBe(join(root, 'stable')) expect(result.channelDir).toBe(join(root, 'stable'))
@@ -45,14 +45,14 @@ describe('generateManifestFixtures', () => {
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8')) const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
expect(manifest).toMatchObject({ expect(manifest).toMatchObject({
version: '9.9.9-test.1',
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
releaseNotes: 'Mock update for AIRI local updater verification.',
files: [ files: [
{ {
url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe', url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
}, },
], ],
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
releaseNotes: 'Mock update for AIRI local updater verification.',
version: '9.9.9-test.1',
}) })
expect(typeof manifest.sha512).toBe('string') expect(typeof manifest.sha512).toBe('string')
@@ -66,12 +66,12 @@ describe('generateManifestFixtures', () => {
roots.push(root) roots.push(root)
const result = await generateManifestFixtures({ const result = await generateManifestFixtures({
artifactContent: `mock-installer-${channel}`,
channel,
releaseNotes: 'Mock update lane fixture',
rootDir: root, rootDir: root,
channel,
target: 'aarch64-apple-darwin', target: 'aarch64-apple-darwin',
version: '9.9.9-test.2', version: '9.9.9-test.2',
releaseNotes: 'Mock update lane fixture',
artifactContent: `mock-installer-${channel}`,
}) })
expect(result.channelDir).toBe(join(root, channel)) expect(result.channelDir).toBe(join(root, channel))
@@ -10,24 +10,59 @@ import * as yaml from 'yaml'
import { getFilenames } from '../utils' import { getFilenames } from '../utils'
export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary'
export interface GenerateManifestFixturesOptions { export interface GenerateManifestFixturesOptions {
artifactContent?: string
channel: UpdateTestChannel
releaseNotes: string
rootDir: string rootDir: string
channel: UpdateTestChannel
target: string target: string
version: string version: string
releaseNotes: string
artifactContent?: string
} }
export interface GenerateManifestFixturesResult { export interface GenerateManifestFixturesResult {
artifactFilename: string
artifactPath: string
channelDir: string channelDir: string
latestFilename: string
manifestPath: string manifestPath: string
artifactPath: string
latestFilename: string
artifactFilename: string
} }
export type UpdateTestChannel = 'alpha' | 'beta' | 'canary' | 'nightly' | 'stable' export function resolveLatestFilenameForTarget(target: string) {
switch (target) {
case 'x86_64-pc-windows-msvc':
return 'latest-x64.yml'
case 'x86_64-unknown-linux-gnu':
return 'latest-x64-linux.yml'
case 'aarch64-unknown-linux-gnu':
return 'latest-arm64-linux-arm64.yml'
case 'x86_64-apple-darwin':
return 'latest-x64-mac.yml'
case 'aarch64-apple-darwin':
return 'latest-arm64-mac.yml'
default:
throw new Error(`Unsupported update-test target: ${target}`)
}
}
function encodeBase64Sha512(content: string) {
return createHash('sha512').update(content).digest('base64')
}
async function resolveArtifactFilename(target: string, version: string) {
const filenames = await getFilenames(target, {
release: true,
autoTag: false,
tag: [version],
})
const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap')
if (!artifact)
throw new Error(`Unable to determine artifact filename for target: ${target}`)
return artifact.releaseArtifactFilename
}
export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise<GenerateManifestFixturesResult> { export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise<GenerateManifestFixturesResult> {
const channelDir = join(options.rootDir, options.channel) const channelDir = join(options.rootDir, options.channel)
@@ -45,52 +80,31 @@ export async function generateManifestFixtures(options: GenerateManifestFixtures
const size = Buffer.byteLength(artifactContent) const size = Buffer.byteLength(artifactContent)
const manifest = { const manifest = {
version: options.version,
files: [ files: [
{ {
url: artifactFilename,
sha512, sha512,
size, size,
url: artifactFilename,
}, },
], ],
path: artifactFilename, path: artifactFilename,
sha512,
releaseDate, releaseDate,
releaseNotes: options.releaseNotes, releaseNotes: options.releaseNotes,
sha512,
version: options.version,
} }
await writeFile(manifestPath, yaml.stringify(manifest), 'utf8') await writeFile(manifestPath, yaml.stringify(manifest), 'utf8')
return { return {
artifactFilename,
artifactPath,
channelDir, channelDir,
latestFilename,
manifestPath, manifestPath,
artifactPath,
latestFilename,
artifactFilename,
} }
} }
export function resolveLatestFilenameForTarget(target: string) {
switch (target) {
case 'aarch64-apple-darwin':
return 'latest-arm64-mac.yml'
case 'aarch64-unknown-linux-gnu':
return 'latest-arm64-linux-arm64.yml'
case 'x86_64-apple-darwin':
return 'latest-x64-mac.yml'
case 'x86_64-pc-windows-msvc':
return 'latest-x64.yml'
case 'x86_64-unknown-linux-gnu':
return 'latest-x64-linux.yml'
default:
throw new Error(`Unsupported update-test target: ${target}`)
}
}
function encodeBase64Sha512(content: string) {
return createHash('sha512').update(content).digest('base64')
}
async function main() { async function main() {
const cli = cac('generate-update-test-manifest') const cli = cac('generate-update-test-manifest')
.option('--root <path>', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' }) .option('--root <path>', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' })
@@ -101,11 +115,11 @@ async function main() {
const parsed = cli.parse() const parsed = cli.parse()
const result = await generateManifestFixtures({ const result = await generateManifestFixtures({
channel: String(parsed.options.channel) as UpdateTestChannel,
releaseNotes: String(parsed.options.releaseNotes),
rootDir: String(parsed.options.root), rootDir: String(parsed.options.root),
channel: String(parsed.options.channel) as UpdateTestChannel,
target: String(parsed.options.target), target: String(parsed.options.target),
version: String(parsed.options.version), version: String(parsed.options.version),
releaseNotes: String(parsed.options.releaseNotes),
}) })
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -114,20 +128,6 @@ async function main() {
console.log(`Artifact: ${result.artifactFilename}`) console.log(`Artifact: ${result.artifactFilename}`)
} }
async function resolveArtifactFilename(target: string, version: string) {
const filenames = await getFilenames(target, {
autoTag: false,
release: true,
tag: [version],
})
const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap')
if (!artifact)
throw new Error(`Unable to determine artifact filename for target: ${target}`)
return artifact.releaseArtifactFilename
}
if (import.meta.main) { if (import.meta.main) {
main().catch((error) => { main().catch((error) => {
console.error(error) console.error(error)
@@ -8,14 +8,18 @@ import { extname, join, normalize } from 'node:path'
import { cac } from 'cac' import { cac } from 'cac'
const CONTENT_TYPES: Record<string, string> = { const CONTENT_TYPES: Record<string, string> = {
'.deb': 'application/vnd.debian.binary-package',
'.dmg': 'application/octet-stream',
'.exe': 'application/vnd.microsoft.portable-executable', '.exe': 'application/vnd.microsoft.portable-executable',
'.yml': 'text/yaml; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8',
'.zip': 'application/zip',
'.dmg': 'application/octet-stream',
'.deb': 'application/vnd.debian.binary-package',
'.rpm': 'application/x-rpm', '.rpm': 'application/x-rpm',
'.txt': 'text/plain; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8', }
'.yml': 'text/yaml; charset=utf-8',
'.zip': 'application/zip', function getContentType(pathname: string) {
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
} }
export async function startUpdateTestServer(options: { port: number, rootDir: string }) { export async function startUpdateTestServer(options: { port: number, rootDir: string }) {
@@ -44,10 +48,6 @@ export async function startUpdateTestServer(options: { port: number, rootDir: st
return server return server
} }
function getContentType(pathname: string) {
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
}
async function main() { async function main() {
const cli = cac('update-test-server') const cli = cac('update-test-server')
.option('--port <port>', 'Port to listen on', { default: '8787' }) .option('--port <port>', 'Port to listen on', { default: '8787' })
+385 -385
View File
@@ -6,385 +6,7 @@ import { x } from 'tinyexec'
import packageJSON from '../package.json' with { type: 'json' } import packageJSON from '../package.json' with { type: 'json' }
interface FilenameOutputEntry { export async function getVersion(options: { release: boolean, autoTag: boolean, tag: string[] }) {
extension: string
optional?: boolean
outputFilename: string
productName: string
releaseArtifactFilename: string
target: string
version: string
}
export function applyTemplateOfArtifactName(
template: string,
productName: string,
version: string,
arch: string,
ext: string,
): string {
return template
// eslint-disable-next-line no-template-curly-in-string
.replace('${productName}', productName)
// eslint-disable-next-line no-template-curly-in-string
.replace('${version}', version)
// eslint-disable-next-line no-template-curly-in-string
.replace('${arch}', arch)
// eslint-disable-next-line no-template-curly-in-string
.replace('${ext}', ext)
}
export async function getElectronBuilderConfig(): Promise<Configuration> {
const config = await import ('../electron-builder.config')
return config.default
}
export async function getFilenames(target: string, options: { autoTag: boolean, release: boolean, tag: string[] }): Promise<FilenameOutputEntry[]> {
const electronBuilder = await getElectronBuilderConfig()
const version = await getVersion(options)
if (!target) {
throw new Error('<Target> is required')
}
const beforeVersion = packageJSON.version
const productName = electronBuilder.productName!
switch (target) {
case 'aarch64-apple-darwin':
{
const artifacts: FilenameOutputEntry[] = [
{
extension: 'dmg',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'dmg'),
'dmg',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
version,
mapArchFor(target, 'dmg'),
'dmg',
),
target: 'aarch64-apple-darwin',
version,
},
]
artifacts.push(
{
extension: 'zip',
outputFilename: getMacZipFilename(productName, beforeVersion, target),
productName,
releaseArtifactFilename: getMacZipFilename(productName, version, target),
target: 'aarch64-apple-darwin',
version,
},
{
extension: getLatestUpdateFilename(target)!,
optional: true,
outputFilename: getLatestUpdateFilename(target)!,
productName,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
target: 'aarch64-apple-darwin',
version,
},
)
return artifacts
}
case 'aarch64-unknown-linux-gnu':
{
const artifacts: FilenameOutputEntry[] = []
if (electronBuilder.linux?.artifactName) {
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|| electronBuilder.linux.target === 'deb'
) {
artifacts.push(
{
extension: 'deb',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'deb'),
'deb',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'deb'),
'deb',
),
target: 'aarch64-unknown-linux-gnu',
version,
},
)
}
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|| electronBuilder.linux.target === 'rpm'
) {
artifacts.push(
{
extension: 'rpm',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'rpm'),
'rpm',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'rpm'),
'rpm',
),
target: 'aarch64-unknown-linux-gnu',
version,
},
)
}
// Flatpak artifact (built outside electron-builder, but we follow linux template)
artifacts.push(
{
extension: 'flatpak',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'flatpak'),
'flatpak',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'flatpak'),
'flatpak',
),
target: 'aarch64-unknown-linux-gnu',
version,
},
)
}
const latestUpdateFilename = getLatestUpdateFilename(target)
if (latestUpdateFilename) {
artifacts.push({
extension: latestUpdateFilename,
optional: true,
outputFilename: latestUpdateFilename,
productName,
releaseArtifactFilename: latestUpdateFilename,
target: 'aarch64-unknown-linux-gnu',
version,
})
}
return artifacts
}
case 'x86_64-apple-darwin':
{
const artifacts: FilenameOutputEntry[] = [
{
extension: 'dmg',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'dmg'),
'dmg',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
version,
mapArchFor(target, 'dmg'),
'dmg',
),
target: 'x86_64-apple-darwin',
version,
},
]
artifacts.push(
{
extension: 'zip',
outputFilename: getMacZipFilename(productName, beforeVersion, target),
productName,
releaseArtifactFilename: getMacZipFilename(productName, version, target),
target: 'x86_64-apple-darwin',
version,
},
{
extension: getLatestUpdateFilename(target)!,
optional: true,
outputFilename: getLatestUpdateFilename(target)!,
productName,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
target: 'x86_64-apple-darwin',
version,
},
)
return artifacts
}
case 'x86_64-pc-windows-msvc':
return [
{
extension: 'exe',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.nsis!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'exe'),
'exe',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.nsis!.artifactName!,
productName,
version,
mapArchFor(target, 'exe'),
'exe',
),
target: 'x86_64-pc-windows-msvc',
version,
},
{
extension: getLatestUpdateFilename(target)!,
optional: true,
outputFilename: getLatestUpdateFilename(target)!,
productName,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
target: 'x86_64-pc-windows-msvc',
version,
},
]
case 'x86_64-unknown-linux-gnu':
{
const artifacts: FilenameOutputEntry[] = []
if (electronBuilder.linux?.artifactName) {
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|| electronBuilder.linux.target === 'deb'
) {
artifacts.push(
{
extension: 'deb',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'deb'),
'deb',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'deb'),
'deb',
),
target: 'x86_64-unknown-linux-gnu',
version,
},
)
}
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|| electronBuilder.linux.target === 'rpm'
) {
artifacts.push(
{
extension: 'rpm',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'rpm'),
'rpm',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'rpm'),
'rpm',
),
target: 'x86_64-unknown-linux-gnu',
version,
},
)
}
// Flatpak artifact (built outside electron-builder, but we follow linux template)
artifacts.push(
{
extension: 'flatpak',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'flatpak'),
'flatpak',
),
productName,
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'flatpak'),
'flatpak',
),
target: 'x86_64-unknown-linux-gnu',
version,
},
)
}
const latestUpdateFilename = getLatestUpdateFilename(target)
if (latestUpdateFilename) {
artifacts.push({
extension: latestUpdateFilename,
optional: true,
outputFilename: latestUpdateFilename,
productName,
releaseArtifactFilename: latestUpdateFilename,
target: 'x86_64-unknown-linux-gnu',
version,
})
}
return artifacts
}
default:
console.error('Target is not supported')
process.exit(1)
}
}
export async function getVersion(options: { autoTag: boolean, release: boolean, tag: string[] }) {
if (!options.release || !options.tag) { if (!options.release || !options.tag) {
// Otherwise, fetch from the latest git ref // Otherwise, fetch from the latest git ref
const res = await x('git', ['log', '-1', '--pretty=format:"%H"']) const res = await x('git', ['log', '-1', '--pretty=format:"%H"'])
@@ -429,6 +51,39 @@ export async function getVersion(options: { autoTag: boolean, release: boolean,
} }
} }
export async function getElectronBuilderConfig(): Promise<Configuration> {
const config = await import ('../electron-builder.config')
return config.default
}
export function applyTemplateOfArtifactName(
template: string,
productName: string,
version: string,
arch: string,
ext: string,
): string {
return template
// eslint-disable-next-line no-template-curly-in-string
.replace('${productName}', productName)
// eslint-disable-next-line no-template-curly-in-string
.replace('${version}', version)
// eslint-disable-next-line no-template-curly-in-string
.replace('${arch}', arch)
// eslint-disable-next-line no-template-curly-in-string
.replace('${ext}', ext)
}
interface FilenameOutputEntry {
target: string
extension: string
outputFilename: string
releaseArtifactFilename: string
productName: string
version: string
optional?: boolean
}
export function mapArchFor( export function mapArchFor(
target: string, target: string,
ext: string, ext: string,
@@ -463,17 +118,17 @@ export function mapArchFor(
} }
} }
function getLatestUpdateFilename(target: string): null | string { function getLatestUpdateFilename(target: string): string | null {
switch (target) { switch (target) {
case 'aarch64-apple-darwin':
case 'x86_64-apple-darwin':
return `latest-${mapArchFor(target, 'yml')}-mac.yml`
case 'aarch64-unknown-linux-gnu':
return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml`
case 'x86_64-pc-windows-msvc': case 'x86_64-pc-windows-msvc':
return `latest-${mapArchFor(target, 'yml')}.yml` return `latest-${mapArchFor(target, 'yml')}.yml`
case 'x86_64-unknown-linux-gnu': case 'x86_64-unknown-linux-gnu':
return `latest-${mapArchFor(target, 'yml')}-linux.yml` return `latest-${mapArchFor(target, 'yml')}-linux.yml`
case 'aarch64-unknown-linux-gnu':
return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml`
case 'aarch64-apple-darwin':
case 'x86_64-apple-darwin':
return `latest-${mapArchFor(target, 'yml')}-mac.yml`
default: default:
return null return null
} }
@@ -484,3 +139,348 @@ function getMacZipFilename(productName: string, version: string, target: string)
const archPrefix = arch === 'x64' ? '' : `${arch}-` const archPrefix = arch === 'x64' ? '' : `${arch}-`
return `${productName}-${version}-${archPrefix}mac.zip` return `${productName}-${version}-${archPrefix}mac.zip`
} }
export async function getFilenames(target: string, options: { release: boolean, autoTag: boolean, tag: string[] }): Promise<FilenameOutputEntry[]> {
const electronBuilder = await getElectronBuilderConfig()
const version = await getVersion(options)
if (!target) {
throw new Error('<Target> is required')
}
const beforeVersion = packageJSON.version
const productName = electronBuilder.productName!
switch (target) {
case 'x86_64-pc-windows-msvc':
return [
{
target: 'x86_64-pc-windows-msvc',
extension: 'exe',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.nsis!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'exe'),
'exe',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.nsis!.artifactName!,
productName,
version,
mapArchFor(target, 'exe'),
'exe',
),
productName,
version,
},
{
target: 'x86_64-pc-windows-msvc',
extension: getLatestUpdateFilename(target)!,
outputFilename: getLatestUpdateFilename(target)!,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
productName,
version,
optional: true,
},
]
case 'x86_64-unknown-linux-gnu':
{
const artifacts: FilenameOutputEntry[] = []
if (electronBuilder.linux?.artifactName) {
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|| electronBuilder.linux.target === 'deb'
) {
artifacts.push(
{
target: 'x86_64-unknown-linux-gnu',
extension: 'deb',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'deb'),
'deb',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'deb'),
'deb',
),
productName,
version,
},
)
}
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|| electronBuilder.linux.target === 'rpm'
) {
artifacts.push(
{
target: 'x86_64-unknown-linux-gnu',
extension: 'rpm',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'rpm'),
'rpm',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'rpm'),
'rpm',
),
productName,
version,
},
)
}
// Flatpak artifact (built outside electron-builder, but we follow linux template)
artifacts.push(
{
target: 'x86_64-unknown-linux-gnu',
extension: 'flatpak',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'flatpak'),
'flatpak',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'flatpak'),
'flatpak',
),
productName,
version,
},
)
}
const latestUpdateFilename = getLatestUpdateFilename(target)
if (latestUpdateFilename) {
artifacts.push({
target: 'x86_64-unknown-linux-gnu',
extension: latestUpdateFilename,
outputFilename: latestUpdateFilename,
releaseArtifactFilename: latestUpdateFilename,
productName,
version,
optional: true,
})
}
return artifacts
}
case 'aarch64-unknown-linux-gnu':
{
const artifacts: FilenameOutputEntry[] = []
if (electronBuilder.linux?.artifactName) {
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|| electronBuilder.linux.target === 'deb'
) {
artifacts.push(
{
target: 'aarch64-unknown-linux-gnu',
extension: 'deb',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'deb'),
'deb',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'deb'),
'deb',
),
productName,
version,
},
)
}
if (
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|| electronBuilder.linux.target === 'rpm'
) {
artifacts.push(
{
target: 'aarch64-unknown-linux-gnu',
extension: 'rpm',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'rpm'),
'rpm',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'rpm'),
'rpm',
),
productName,
version,
},
)
}
// Flatpak artifact (built outside electron-builder, but we follow linux template)
artifacts.push(
{
target: 'aarch64-unknown-linux-gnu',
extension: 'flatpak',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'flatpak'),
'flatpak',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.linux.artifactName!,
productName,
version,
mapArchFor(target, 'flatpak'),
'flatpak',
),
productName,
version,
},
)
}
const latestUpdateFilename = getLatestUpdateFilename(target)
if (latestUpdateFilename) {
artifacts.push({
target: 'aarch64-unknown-linux-gnu',
extension: latestUpdateFilename,
outputFilename: latestUpdateFilename,
releaseArtifactFilename: latestUpdateFilename,
productName,
version,
optional: true,
})
}
return artifacts
}
case 'aarch64-apple-darwin':
{
const artifacts: FilenameOutputEntry[] = [
{
target: 'aarch64-apple-darwin',
extension: 'dmg',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'dmg'),
'dmg',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
version,
mapArchFor(target, 'dmg'),
'dmg',
),
productName,
version,
},
]
artifacts.push(
{
target: 'aarch64-apple-darwin',
extension: 'zip',
outputFilename: getMacZipFilename(productName, beforeVersion, target),
releaseArtifactFilename: getMacZipFilename(productName, version, target),
productName,
version,
},
{
target: 'aarch64-apple-darwin',
extension: getLatestUpdateFilename(target)!,
outputFilename: getLatestUpdateFilename(target)!,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
productName,
version,
optional: true,
},
)
return artifacts
}
case 'x86_64-apple-darwin':
{
const artifacts: FilenameOutputEntry[] = [
{
target: 'x86_64-apple-darwin',
extension: 'dmg',
outputFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
beforeVersion,
mapArchFor(target, 'dmg'),
'dmg',
),
releaseArtifactFilename: applyTemplateOfArtifactName(
electronBuilder.dmg!.artifactName!,
productName,
version,
mapArchFor(target, 'dmg'),
'dmg',
),
productName,
version,
},
]
artifacts.push(
{
target: 'x86_64-apple-darwin',
extension: 'zip',
outputFilename: getMacZipFilename(productName, beforeVersion, target),
releaseArtifactFilename: getMacZipFilename(productName, version, target),
productName,
version,
},
{
target: 'x86_64-apple-darwin',
extension: getLatestUpdateFilename(target)!,
outputFilename: getLatestUpdateFilename(target)!,
releaseArtifactFilename: getLatestUpdateFilename(target)!,
productName,
version,
optional: true,
},
)
return artifacts
}
default:
console.error('Target is not supported')
process.exit(1)
}
}
+13 -13
View File
@@ -4,6 +4,19 @@ import { env } from 'node:process'
import { app, shell } from 'electron' import { app, shell } from 'electron'
/** Enables Electron's CDP endpoint before the app ready event. */
export function setupDebugger() {
if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) {
const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222')
if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) {
throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`)
}
app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort))
app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`)
}
}
/** /**
* Opens the inspector for the first available Electron renderer target. * Opens the inspector for the first available Electron renderer target.
* *
@@ -47,16 +60,3 @@ export function openDebugger() {
}) })
} }
} }
/** Enables Electron's CDP endpoint before the app ready event. */
export function setupDebugger() {
if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) {
const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222')
if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) {
throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`)
}
app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort))
app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`)
}
}
@@ -42,6 +42,10 @@ const LOG_FILE_PREFIX = 'airi-tamagotchi'
* Handle for the file logger, providing access to the log file and append operations. * Handle for the file logger, providing access to the log file and append operations.
*/ */
export interface FileLoggerHandle { export interface FileLoggerHandle {
/** Path to the current session's log file, or null if initialization failed */
logFilePath: string | null
/** File descriptor for the current session's log file, or null if initialization failed */
logFileFd: number | null
/** /**
* Appends a log entry to the file. * Appends a log entry to the file.
* @param content - The formatted log content to append * @param content - The formatted log content to append
@@ -51,23 +55,68 @@ export interface FileLoggerHandle {
* Closes the log file and releases resources. * Closes the log file and releases resources.
*/ */
close: () => Promise<void> close: () => Promise<void>
/** File descriptor for the current session's log file, or null if initialization failed */
logFileFd: null | number
/** Path to the current session's log file, or null if initialization failed */
logFilePath: null | string
} }
export const nullFileLoggerHandle: FileLoggerHandle = { export const nullFileLoggerHandle: FileLoggerHandle = {
logFilePath: null,
logFileFd: null,
appendLog: async () => {}, appendLog: async () => {},
close: async () => {}, close: async () => {},
logFileFd: null,
logFilePath: null,
} }
// ============================================================================ // ============================================================================
// Internal Functions // Internal Functions
// ============================================================================ // ============================================================================
/**
* Extracts a human-readable error message from an unknown error object.
*/
function getErrorMessage(error: unknown): string {
return errorMessageFromValue(error)
}
/**
* Generates the log file path for the current session.
* Format: {userData}/logs/airi-tamagotchi-{timestamp}.log
*/
function createLogFilePath(logsDir: string, timestamp: number): string {
return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`)
}
/**
* Ensures the logs directory exists.
* Returns the logs directory path if successful, null otherwise.
*/
async function ensureLogsDirectory(): Promise<string | null> {
try {
const logsDir = join(app.getPath('userData'), 'logs')
await mkdir(logsDir, { recursive: true })
return logsDir
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to create logs directory: ${message}`)
return null
}
}
/**
* Checks if the current log file exists and returns its size.
*/
async function getLogFileSize(filePath: string): Promise<number | null> {
try {
const stats = await stat(filePath)
return stats.size
}
catch {
return null
}
}
// ============================================================================
// Public API
// ============================================================================
/** /**
* Sets up the file logger by creating a timestamped log file. * Sets up the file logger by creating a timestamped log file.
* *
@@ -137,7 +186,7 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`) console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`)
} }
return { appendLog, close, logFileFd, logFilePath } return { logFilePath, logFileFd, appendLog, close }
} }
catch (error) { catch (error) {
const message = getErrorMessage(error) const message = getErrorMessage(error)
@@ -145,52 +194,3 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
return nullFileLoggerHandle return nullFileLoggerHandle
} }
} }
/**
* Generates the log file path for the current session.
* Format: {userData}/logs/airi-tamagotchi-{timestamp}.log
*/
function createLogFilePath(logsDir: string, timestamp: number): string {
return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`)
}
/**
* Ensures the logs directory exists.
* Returns the logs directory path if successful, null otherwise.
*/
async function ensureLogsDirectory(): Promise<null | string> {
try {
const logsDir = join(app.getPath('userData'), 'logs')
await mkdir(logsDir, { recursive: true })
return logsDir
}
catch (error) {
const message = getErrorMessage(error)
console.error(`[FileLogger] Failed to create logs directory: ${message}`)
return null
}
}
/**
* Extracts a human-readable error message from an unknown error object.
*/
function getErrorMessage(error: unknown): string {
return errorMessageFromValue(error)
}
// ============================================================================
// Public API
// ============================================================================
/**
* Checks if the current log file exists and returns its size.
*/
async function getLogFileSize(filePath: string): Promise<null | number> {
try {
const stats = await stat(filePath)
return stats.size
}
catch {
return null
}
}
@@ -7,6 +7,28 @@ interface SingleInstanceGuardOptions {
getWindow: () => BrowserWindow | undefined getWindow: () => BrowserWindow | undefined
} }
/**
* Focuses the main AIRI window after a duplicate launch.
*
* Use when:
* - Electron forwards a second process launch to the primary instance
* - The app should show the already-running UI instead of starting another runtime
*
* Expects:
* - `getWindow` returns the main user-facing window when it has been created
*
* Returns:
* - N/A
*/
function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) {
const window = getWindow()
if (!window) {
return
}
toggleWindowShow(window)
}
/** /**
* Installs Electron's single-instance guard for the desktop runtime. * Installs Electron's single-instance guard for the desktop runtime.
* *
@@ -33,25 +55,3 @@ export function installSingleInstanceGuard(options: SingleInstanceGuardOptions)
return true return true
} }
/**
* Focuses the main AIRI window after a duplicate launch.
*
* Use when:
* - Electron forwards a second process launch to the primary instance
* - The app should show the already-running UI instead of starting another runtime
*
* Expects:
* - `getWindow` returns the main user-facing window when it has been created
*
* Returns:
* - N/A
*/
function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) {
const window = getWindow()
if (!window) {
return
}
toggleWindowShow(window)
}
@@ -3,19 +3,19 @@ import { any, array, number, object, optional, string } from 'valibot'
import { createConfig } from '../libs/electron/persistence' import { createConfig } from '../libs/electron/persistence'
export const artistryConfigSchema = object({ export const artistryConfigSchema = object({
artistryProvider: optional(string(), 'none'),
artistryGlobals: optional(object({ artistryGlobals: optional(object({
comfyuiActiveWorkflow: optional(string(), ''),
comfyuiSavedWorkflows: optional(array(any()), []),
comfyuiServerUrl: optional(string(), 'http://localhost:8188'), comfyuiServerUrl: optional(string(), 'http://localhost:8188'),
comfyuiSavedWorkflows: optional(array(any()), []),
comfyuiActiveWorkflow: optional(string(), ''),
replicateApiKey: optional(string(), ''),
replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'),
replicateAspectRatio: optional(string(), '16:9'),
replicateInferenceSteps: optional(number(), 4),
nanobananaApiKey: optional(string(), ''), nanobananaApiKey: optional(string(), ''),
nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'), nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'),
nanobananaResolution: optional(string(), '1K'), nanobananaResolution: optional(string(), '1K'),
replicateApiKey: optional(string(), ''),
replicateAspectRatio: optional(string(), '16:9'),
replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'),
replicateInferenceSteps: optional(number(), 4),
}), {}), }), {}),
artistryProvider: optional(string(), 'none'),
}) })
export function createArtistryConfig() { export function createArtistryConfig() {
@@ -3,8 +3,8 @@ import { array, object, optional, picklist, string } from 'valibot'
import { createConfig } from '../libs/electron/persistence' import { createConfig } from '../libs/electron/persistence'
const shortcutAcceleratorSchema = object({ const shortcutAcceleratorSchema = object({
key: string(),
modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])), modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])),
key: string(),
}) })
export const globalAppConfigSchema = object({ export const globalAppConfigSchema = object({
+21 -21
View File
@@ -134,6 +134,7 @@ app.whenReady().then(async () => {
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig()) const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
const electronApp = injeca.provide('host:electron:app', () => app) const electronApp = injeca.provide('host:electron:app', () => app)
const autoUpdater = injeca.provide('services:auto-updater', { const autoUpdater = injeca.provide('services:auto-updater', {
dependsOn: { appConfig },
build: ({ dependsOn }) => setupAutoUpdater({ build: ({ dependsOn }) => setupAutoUpdater({
enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam', enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam',
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel, getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
@@ -145,17 +146,16 @@ app.whenReady().then(async () => {
}) })
}, },
}), }),
dependsOn: { appConfig },
}) })
const i18n = injeca.provide('libs:i18n', { const i18n = injeca.provide('libs:i18n', {
build: ({ dependsOn }) => createI18n({ locale: dependsOn.appConfig.get()?.language, messages }),
dependsOn: { appConfig }, dependsOn: { appConfig },
build: ({ dependsOn }) => createI18n({ messages, locale: dependsOn.appConfig.get()?.language }),
}) })
const serverChannel = injeca.provide('modules:channel-server', { const serverChannel = injeca.provide('modules:channel-server', {
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
dependsOn: { app: electronApp, lifecycle }, dependsOn: { app: electronApp, lifecycle },
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
}) })
const airiHttpServer = injeca.provide('modules:airi-http-server', { const airiHttpServer = injeca.provide('modules:airi-http-server', {
@@ -167,8 +167,8 @@ app.whenReady().then(async () => {
}) })
const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', { const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', {
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
dependsOn: { lifecycle }, dependsOn: { lifecycle },
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
}) })
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', { const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
@@ -176,13 +176,13 @@ app.whenReady().then(async () => {
}) })
const widgetsManager = injeca.provide('windows:widgets', { const widgetsManager = injeca.provide('windows:widgets', {
dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn), build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
dependsOn: { i18n, serverChannel },
}) })
const pluginHost = injeca.provide('modules:plugin-host', { const pluginHost = injeca.provide('modules:plugin-host', {
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
dependsOn: { serverChannel, widgetsManager }, dependsOn: { serverChannel, widgetsManager },
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
}) })
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService()) const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
@@ -193,90 +193,90 @@ app.whenReady().then(async () => {
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow()) const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
const onboardingWindowManager = injeca.provide('windows:onboarding', { const onboardingWindowManager = injeca.provide('windows:onboarding', {
dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn), build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
dependsOn: { i18n, serverChannel },
}) })
const noticeWindow = injeca.provide('windows:notice', { const noticeWindow = injeca.provide('windows:notice', {
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
dependsOn: { i18n, serverChannel }, dependsOn: { i18n, serverChannel },
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
}) })
const aboutWindow = injeca.provide('windows:about', { const aboutWindow = injeca.provide('windows:about', {
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
dependsOn: { autoUpdater, i18n, serverChannel }, dependsOn: { autoUpdater, i18n, serverChannel },
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
}) })
const chatWindow = injeca.provide('windows:chat', { const chatWindow = injeca.provide('windows:chat', {
dependsOn: { widgetsManager, serverChannel, mcpStdioManager, i18n },
build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn), build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn),
dependsOn: { i18n, mcpStdioManager, serverChannel, widgetsManager },
}) })
const spotlightWindow = injeca.provide('windows:spotlight', { const spotlightWindow = injeca.provide('windows:spotlight', {
dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig },
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn), build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
dependsOn: { appConfig, chatWindow, globalShortcut, i18n, serverChannel },
}) })
const editorWindow = injeca.provide('windows:editor', { const editorWindow = injeca.provide('windows:editor', {
dependsOn: { serverChannel, i18n },
build: ({ dependsOn }) => setupEditorWindowManager(dependsOn), build: ({ dependsOn }) => setupEditorWindowManager(dependsOn),
dependsOn: { i18n, serverChannel },
}) })
const settingsWindow = injeca.provide('windows:settings', { const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
build: async ({ dependsOn }) => build: async ({ dependsOn }) =>
setupSettingsWindowReusableFunc({ setupSettingsWindowReusableFunc({
...dependsOn, ...dependsOn,
getMainWindow: () => userFacingMainWindow, getMainWindow: () => userFacingMainWindow,
}), }),
dependsOn: { autoUpdater, beatSync, devtoolsWindow: devtoolsMarkdownStressWindow, globalShortcut, godotStageManager, i18n, mcpStdioManager, serverChannel, spotlightWindow, widgetsManager },
}) })
const mainWindow = injeca.provide('windows:main', { const mainWindow = injeca.provide('windows:main', {
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription },
build: async ({ dependsOn }) => setupMainWindow({ build: async ({ dependsOn }) => setupMainWindow({
...dependsOn, ...dependsOn,
onWindowCreated: (window) => { onWindowCreated: (window) => {
userFacingMainWindow = window userFacingMainWindow = window
}, },
}), }),
dependsOn: { appleSpeechTranscription, autoUpdater, beatSync, chatWindow, editorWindow, godotStageManager, i18n, mcpStdioManager, noticeWindow, onboardingWindowManager, serverChannel, settingsWindow, widgetsManager },
}) })
const captionWindow = injeca.provide('windows:caption', { const captionWindow = injeca.provide('windows:caption', {
dependsOn: { mainWindow, serverChannel, i18n },
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn), build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
dependsOn: { i18n, mainWindow, serverChannel },
}) })
const tray = injeca.provide('app:tray', { const tray = injeca.provide('app:tray', {
dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, serverChannel, beatSyncBgWindow: beatSync, aboutWindow, i18n },
build: async ({ dependsOn }) => setupTray(dependsOn), build: async ({ dependsOn }) => setupTray(dependsOn),
dependsOn: { aboutWindow, beatSyncBgWindow: beatSync, captionWindow, i18n, mainWindow, serverChannel, settingsWindow, widgetsWindow: widgetsManager },
}) })
// Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1 // Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1
if (isDesktopOverlayEnabled()) { if (isDesktopOverlayEnabled()) {
const desktopOverlay = injeca.provide('windows:desktop-overlay', { const desktopOverlay = injeca.provide('windows:desktop-overlay', {
dependsOn: { mcpStdioManager, serverChannel, i18n },
build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn), build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn),
dependsOn: { i18n, mcpStdioManager, serverChannel },
}) })
// NOTICE: Separate invoke ensures the overlay is eagerly built. // NOTICE: Separate invoke ensures the overlay is eagerly built.
// Without this, injeca.start() would skip it because no other // Without this, injeca.start() would skip it because no other
// provider depends on 'windows:desktop-overlay'. // provider depends on 'windows:desktop-overlay'.
injeca.invoke({ injeca.invoke({
callback: noop,
dependsOn: { desktopOverlay }, dependsOn: { desktopOverlay },
callback: noop,
}) })
} }
injeca.invoke({ injeca.invoke({
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig },
callback: async (deps) => { callback: async (deps) => {
const { context } = createContext(ipcMain) const { context } = createContext(ipcMain)
await setupArtistryBridge({ await setupArtistryBridge({
artistryConfig: deps.artistryConfig,
context,
widgetsManager: deps.widgetsWindow, widgetsManager: deps.widgetsWindow,
context,
artistryConfig: deps.artistryConfig,
}) })
}, },
dependsOn: { airiHttpServer, artistryConfig, godotStageManager, mainWindow, mcpStdioManager, onboardingWindow: onboardingWindowManager, pluginHost, serverChannel, spotlightWindow, tray, widgetsWindow: widgetsManager },
}) })
injeca.start().catch(err => console.error(err)) injeca.start().catch(err => console.error(err))
@@ -2,10 +2,8 @@ const onAppReadyHooks = [] as (() => Promise<void> | void)[]
const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[] const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[]
const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[] const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[]
export async function emitAppBeforeQuit() { export function onAppReady(fn: () => Promise<void> | void) {
for (const fn of onAppBeforeQuitHooks) { onAppReadyHooks.push(fn)
await fn()
}
} }
export async function emitAppReady() { export async function emitAppReady() {
@@ -14,20 +12,22 @@ export async function emitAppReady() {
} }
} }
export async function emitAppWindowAllClosed() {
for (const fn of onAppWindowAllClosedHooks) {
await fn()
}
}
export function onAppBeforeQuit(fn: () => Promise<void> | void) { export function onAppBeforeQuit(fn: () => Promise<void> | void) {
onAppBeforeQuitHooks.push(fn) onAppBeforeQuitHooks.push(fn)
} }
export function onAppReady(fn: () => Promise<void> | void) { export async function emitAppBeforeQuit() {
onAppReadyHooks.push(fn) for (const fn of onAppBeforeQuitHooks) {
await fn()
}
} }
export function onAppWindowAllClosed(fn: () => Promise<void> | void) { export function onAppWindowAllClosed(fn: () => Promise<void> | void) {
onAppWindowAllClosedHooks.push(fn) onAppWindowAllClosedHooks.push(fn)
} }
export async function emitAppWindowAllClosed() {
for (const fn of onAppWindowAllClosedHooks) {
await fn()
}
}
@@ -7,6 +7,14 @@ import { is } from '@electron-toolkit/utils'
let electronMainDirname: string = '' let electronMainDirname: string = ''
export function setElectronMainDirname(dirname: string) {
electronMainDirname = dirname
}
export function getElectronMainDirname() {
return electronMainDirname
}
export function baseUrl(parentOfIndexHtml: string, filename?: string) { export function baseUrl(parentOfIndexHtml: string, filename?: string) {
if (is.dev && env.ELECTRON_RENDERER_URL) { if (is.dev && env.ELECTRON_RENDERER_URL) {
if (!filename) { if (!filename) {
@@ -25,11 +33,7 @@ export function baseUrl(parentOfIndexHtml: string, filename?: string) {
} }
} }
export function getElectronMainDirname() { export async function load(window: BrowserWindow, url: string | { url: string, options?: LoadURLOptions } | { file: string, options?: LoadFileOptions }) {
return electronMainDirname
}
export async function load(window: BrowserWindow, url: string | { file: string, options?: LoadFileOptions } | { options?: LoadURLOptions, url: string }) {
try { try {
if (typeof url === 'object' && 'url' in url) { if (typeof url === 'object' && 'url' in url) {
return await window.loadURL(url.url, url.options) return await window.loadURL(url.url, url.options)
@@ -87,10 +91,6 @@ export async function load(window: BrowserWindow, url: string | { file: string,
} }
} }
export function setElectronMainDirname(dirname: string) {
electronMainDirname = dirname
}
/** /**
* Adds a hash route and optional query to an Electron renderer location. * Adds a hash route and optional query to an Electron renderer location.
* *
@@ -101,7 +101,7 @@ export function setElectronMainDirname(dirname: string) {
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' } * // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
*/ */
export function withHashRoute( export function withHashRoute(
baseUrl: string | { file: string } | { url: string }, baseUrl: string | { url: string } | { file: string },
hashRoute: string, hashRoute: string,
options: Pick<LoadFileOptions, 'query'> = {}, options: Pick<LoadFileOptions, 'query'> = {},
) { ) {
@@ -118,7 +118,7 @@ export function withHashRoute(
baseURLinURL.hash = hashRoute baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string } return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
} }
if (typeof baseUrl === 'object' && 'file' in baseUrl) { if (typeof baseUrl === 'object' && 'file' in baseUrl) {
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions } return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
@@ -136,5 +136,5 @@ export function withHashRoute(
baseURLinURL.hash = hashRoute baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string } return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
} }
@@ -57,8 +57,8 @@ describe('createConfig', () => {
}) })
const writeCoordinator = { const writeCoordinator = {
calls: 0, calls: 0,
release: () => {},
waitFor: Promise.resolve(), waitFor: Promise.resolve(),
release: () => {},
} }
const writeFileMock = vi.fn(async (path: string) => { const writeFileMock = vi.fn(async (path: string) => {
existingTempFiles.add(path) existingTempFiles.add(path)
@@ -10,37 +10,57 @@ import { app } from 'electron'
import { throttle } from 'es-toolkit' import { throttle } from 'es-toolkit'
import { safeParse } from 'valibot' import { safeParse } from 'valibot'
type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error'
export interface ConfigDiagnostics<T> { export interface ConfigDiagnostics<T> {
error?: unknown
healed?: boolean
issues?: BaseIssue<unknown>[]
path: string
raw?: string
status: ConfigStatus status: ConfigStatus
path: string
issues?: BaseIssue<unknown>[]
error?: unknown
raw?: string
healed?: boolean
value?: T value?: T
} }
export interface CreateConfigOptions<T> { export interface CreateConfigOptions<T> {
autoHeal?: boolean
default?: T default?: T
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void autoHeal?: boolean
onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
} }
type ConfigStatus = 'invalid' | 'missing' | 'ok' | 'read-error'
const persistenceMap = new Map<string, unknown>() const persistenceMap = new Map<string, unknown>()
const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>() const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>()
export interface Config<TSchema extends PersistedSchema> { function createConfigPath(namespace: string, filename: string) {
get: () => InferOutput<TSchema> | undefined return join(app.getPath('userData'), `${namespace}-${filename}`)
getDiagnostics: () => ConfigDiagnostics<InferOutput<TSchema>> | undefined }
setup: () => ConfigDiagnostics<InferOutput<TSchema>>
update: (newData: InferOutput<TSchema>) => void async function ensureConfigDirectory(path: string) {
await mkdir(dirname(path), { recursive: true })
} }
type PersistedSchema = BaseSchema<unknown, unknown, BaseIssue<unknown>> type PersistedSchema = BaseSchema<unknown, unknown, BaseIssue<unknown>>
function parseWithSchema<TSchema extends PersistedSchema>(
raw: string,
schema: TSchema,
): { value?: InferOutput<TSchema>, issues?: InferIssue<TSchema>[] } {
const parsed = safeDestr<unknown>(raw)
const result = safeParse(schema, parsed)
if (result.success) {
return { value: result.output }
}
return { issues: result.issues }
}
export interface Config<TSchema extends PersistedSchema> {
setup: () => ConfigDiagnostics<InferOutput<TSchema>>
get: () => InferOutput<TSchema> | undefined
update: (newData: InferOutput<TSchema>) => void
getDiagnostics: () => ConfigDiagnostics<InferOutput<TSchema>> | undefined
}
export function createConfig<TSchema extends PersistedSchema>( export function createConfig<TSchema extends PersistedSchema>(
namespace: string, namespace: string,
filename: string, filename: string,
@@ -90,8 +110,8 @@ export function createConfig<TSchema extends PersistedSchema>(
const path = configPath() const path = configPath()
if (!existsSync(path)) { if (!existsSync(path)) {
const diagnostics = recordDiagnostics({ const diagnostics = recordDiagnostics({
path,
status: 'missing', status: 'missing',
path,
value: options?.default, value: options?.default,
}) })
persistenceMap.set(key, options?.default) persistenceMap.set(key, options?.default)
@@ -103,8 +123,8 @@ export function createConfig<TSchema extends PersistedSchema>(
const parsed = parseWithSchema(raw, schema) const parsed = parseWithSchema(raw, schema)
if (parsed.value !== undefined) { if (parsed.value !== undefined) {
const diagnostics = recordDiagnostics({ const diagnostics = recordDiagnostics({
path,
status: 'ok', status: 'ok',
path,
value: parsed.value, value: parsed.value,
}) })
persistenceMap.set(key, parsed.value) persistenceMap.set(key, parsed.value)
@@ -113,10 +133,10 @@ export function createConfig<TSchema extends PersistedSchema>(
const fallback = options?.default const fallback = options?.default
const diagnostics = recordDiagnostics({ const diagnostics = recordDiagnostics({
issues: parsed.issues,
path,
raw,
status: 'invalid', status: 'invalid',
path,
issues: parsed.issues,
raw,
value: fallback, value: fallback,
}) })
options?.onValidationFailure?.(diagnostics) options?.onValidationFailure?.(diagnostics)
@@ -134,9 +154,9 @@ export function createConfig<TSchema extends PersistedSchema>(
catch (error) { catch (error) {
const fallback = options?.default const fallback = options?.default
const diagnostics = recordDiagnostics({ const diagnostics = recordDiagnostics({
error,
path,
status: 'read-error', status: 'read-error',
path,
error,
value: fallback, value: fallback,
}) })
options?.onReadError?.(diagnostics) options?.onReadError?.(diagnostics)
@@ -155,29 +175,9 @@ export function createConfig<TSchema extends PersistedSchema>(
const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined
return { return {
get,
getDiagnostics,
setup, setup,
get,
update, update,
getDiagnostics,
} }
} }
function createConfigPath(namespace: string, filename: string) {
return join(app.getPath('userData'), `${namespace}-${filename}`)
}
async function ensureConfigDirectory(path: string) {
await mkdir(dirname(path), { recursive: true })
}
function parseWithSchema<TSchema extends PersistedSchema>(
raw: string,
schema: TSchema,
): { issues?: InferIssue<TSchema>[], value?: InferOutput<TSchema> } {
const parsed = safeDestr<unknown>(raw)
const result = safeParse(schema, parsed)
if (result.success) {
return { value: result.output }
}
return { issues: result.issues }
}
@@ -13,12 +13,6 @@ import { createCoreContext, translate } from '@intlify/core'
import { effect, signal } from 'alien-signals' import { effect, signal } from 'alien-signals'
import { isString } from 'es-toolkit' import { isString } from 'es-toolkit'
export interface I18n<Schema extends Record<string, any> = Record<string, any>> {
locale:
(() => (LocaleDetector<any[]> | string | undefined)) | ((value: LocaleDetector<any[]> | string | undefined) => void)
t: TranslationFunction<Schema>
}
type ResolveResourceKeys< type ResolveResourceKeys<
// eslint-disable-next-line ts/no-empty-object-type // eslint-disable-next-line ts/no-empty-object-type
Schema extends Record<string, any> = {}, Schema extends Record<string, any> = {},
@@ -34,7 +28,7 @@ type ResolveResourceKeys<
[K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K] [K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K]
}> }>
: never, : never,
> = DefineMessagesPaths | SchemaPaths > = SchemaPaths | DefineMessagesPaths
interface TranslationFunction< interface TranslationFunction<
// eslint-disable-next-line ts/no-empty-object-type // eslint-disable-next-line ts/no-empty-object-type
@@ -138,17 +132,23 @@ interface TranslationFunction<
): string ): string
} }
export interface I18n<Schema extends Record<string, any> = Record<string, any>> {
t: TranslationFunction<Schema>
locale:
(() => (string | LocaleDetector<any[]> | undefined)) | ((value: string | LocaleDetector<any[]> | undefined) => void)
}
export function createI18n<Schema extends Record<string, any> = Record<string, any>>(options: CoreOptions): I18n<Schema> { export function createI18n<Schema extends Record<string, any> = Record<string, any>>(options: CoreOptions): I18n<Schema> {
const log = useLogg('i18n').useGlobalConfig() const log = useLogg('i18n').useGlobalConfig()
const locale = signal(options.locale) const locale = signal(options.locale)
const context = createCoreContext({ const context = createCoreContext({
fallbackFormat: true,
fallbackLocale: options.fallbackLocale, fallbackLocale: options.fallbackLocale,
fallbackWarn: false, fallbackWarn: false,
missingWarn: false, missingWarn: false,
warnHtmlMessage: false, warnHtmlMessage: false,
fallbackFormat: true,
...options, ...options,
}) })
@@ -178,7 +178,7 @@ export function createI18n<Schema extends Record<string, any> = Record<string, a
}) })
return { return {
locale,
t, t,
locale,
} }
} }
@@ -34,15 +34,6 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
let closeLoopback: (() => void) | null = null let closeLoopback: (() => void) | null = null
let signingInFlight = false let signingInFlight = false
interface TokenExchangeResult {
accessToken: string
expiresIn: number
idToken?: string
refreshToken?: string
}
// --- Internal helpers ---
/** /**
* Create the auth service IPC handlers for a given window context. * Create the auth service IPC handlers for a given window context.
*/ */
@@ -135,20 +126,29 @@ export function createAuthService(params: {
}) })
} }
// --- Internal helpers ---
interface TokenExchangeResult {
accessToken: string
refreshToken?: string
idToken?: string
expiresIn: number
}
async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenExchangeResult> { async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenExchangeResult> {
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: OIDC_CLIENT_ID,
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code,
redirect_uri: redirectUri, redirect_uri: redirectUri,
client_id: OIDC_CLIENT_ID,
code_verifier: codeVerifier,
resource: SERVER_URL, resource: SERVER_URL,
}) })
const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), { const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
}) })
if (!response.ok) { if (!response.ok) {
@@ -159,8 +159,8 @@ async function exchangeCode(code: string, codeVerifier: string, redirectUri: str
const data = await response.json() as Record<string, unknown> const data = await response.json() as Record<string, unknown>
return { return {
accessToken: data.access_token as string, accessToken: data.access_token as string,
expiresIn: data.expires_in as number,
idToken: data.id_token as string | undefined,
refreshToken: data.refresh_token as string | undefined, refreshToken: data.refresh_token as string | undefined,
idToken: data.id_token as string | undefined,
expiresIn: data.expires_in as number,
} }
} }
@@ -31,8 +31,8 @@ import { createConfig } from '../../../libs/electron/persistence'
import { ensureServerChannelConfigDefaults } from './config' import { ensureServerChannelConfigDefaults } from './config'
const channelServerConfigSchema = object({ const channelServerConfigSchema = object({
authToken: optional(string()),
hostname: optional(string()), hostname: optional(string()),
authToken: optional(string()),
tlsConfig: optional(nullable(object({ tlsConfig: optional(nullable(object({
cert: optional(string()), cert: optional(string()),
key: optional(string()), key: optional(string()),
@@ -41,44 +41,412 @@ const channelServerConfigSchema = object({
}) })
const channelServerInvokeConfigSchema = z.object({ const channelServerInvokeConfigSchema = z.object({
authToken: z.string().optional(),
hostname: z.string().optional(), hostname: z.string().optional(),
authToken: z.string().optional(),
tlsConfig: z.object({ }).nullable().optional(), tlsConfig: z.object({ }).nullable().optional(),
}).strict() }).strict()
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, { const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
autoHeal: true,
default: { default: {
authToken: '',
hostname: '127.0.0.1', hostname: '127.0.0.1',
authToken: '',
tlsConfig: null, tlsConfig: null,
}, },
autoHeal: true,
}) })
let serverChannelServiceRegistered = false let serverChannelServiceRegistered = false
let serverChannelCertificateTrustConfigured = false let serverChannelCertificateTrustConfigured = false
interface ServerChannelCertificateVerifyRequest { interface ServerChannelCertificateVerifyRequest {
hostname: string
verificationResult: string
errorCode: number
certificate: { certificate: {
subject: {
commonName: string
}
issuer: { issuer: {
commonName: string commonName: string
country: string country: string
locality: string locality: string
organizations: string[] organizations: string[]
} }
subject: {
commonName: string
}
} }
errorCode: number
hostname: string
verificationResult: string
} }
function getServerChannelPort() { function getServerChannelPort() {
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121 return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
} }
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']) const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
function isLoopbackHost(host: string) {
return LOOPBACK_HOSTS.has(host)
}
function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) {
if (config.hostname === '0.0.0.0') {
return Array.from(new Set(serverChannel.getConnectionHost()))
.filter(host => !isLoopbackHost(host))
.sort()
}
if (isLoopbackHost(config.hostname)) {
return []
}
return [config.hostname]
}
function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) {
const urlHost = isIP(host) === 6 ? `[${host}]` : host
// TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts`
// and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals.
return `${protocol}://${urlHost}:${getServerChannelPort()}/ws`
}
function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) {
const protocol = config.tlsConfig ? 'wss' : 'ws'
const urls = getServerChannelQrHosts(config, serverChannel)
.map(host => createServerChannelUrl(protocol, host))
if (!urls.length) {
throw new Error('No reachable private LAN address is available for the current server channel host.')
}
return createServerChannelQrPayload({
type: 'airi:server-channel',
version: 1,
urls,
authToken: config.authToken,
})
}
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null }
return {
hostname: config.hostname || '127.0.0.1',
authToken: config.authToken || '',
tlsConfig: config.tlsConfig || null,
}
}
function getServerRuntimeBaseOptions() {
return {
port: getServerChannelPort(),
hostname: '127.0.0.1',
}
}
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
return {
...getServerRuntimeBaseOptions(),
auth: {
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
},
hostname: 'hostname' in config && typeof config.hostname === 'string'
? config.hostname || '127.0.0.1'
: '127.0.0.1',
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
}
}
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
if (!fallback) {
fallback = await getChannelServerConfig()
}
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
if (!parsed.success) {
return fallback
}
const normalizedConfig = {
hostname: parsed.data.hostname ?? fallback.hostname,
authToken: parsed.data.authToken ?? fallback.authToken,
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
}
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
}
function getCertificateDomains(): string[] {
const localIPs = getLocalIPs()
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
return Array.from(new Set([
'localhost',
'127.0.0.1',
'::1',
...(hostname ? [hostname] : []),
...localIPs,
]))
}
function getCertificatePaths() {
const userDataPath = app.getPath('userData')
return {
certPath: join(userDataPath, 'websocket-cert.pem'),
keyPath: join(userDataPath, 'websocket-key.pem'),
caCertPath: join(userDataPath, 'websocket-ca-cert.pem'),
caKeyPath: join(userDataPath, 'websocket-ca-key.pem'),
}
}
function withCertificateChain(cert: string, caCert?: string) {
return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert
}
function certHasAllDomains(certPem: string, domains: string[]): boolean {
try {
const cert = new X509Certificate(certPem)
const san = cert.subjectAltName || ''
const entries = san.split(',').map(part => part.trim())
const values = entries
.map((entry) => {
if (entry.startsWith('DNS:'))
return entry.slice(4).trim()
if (entry.startsWith('IP Address:'))
return entry.slice(11).trim()
return ''
})
.filter(Boolean)
const sanSet = new Set(values)
return domains.every(domain => sanSet.has(domain))
}
catch {
return false
}
}
function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean {
if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult)
&& request.errorCode !== -202) {
return false
}
if (!getCertificateDomains().includes(request.hostname)) {
return false
}
const issuer = request.certificate.issuer
return request.certificate.subject.commonName === 'localhost'
&& issuer.commonName === 'AIRI'
&& issuer.country === 'US'
&& issuer.locality === 'Local'
&& issuer.organizations.includes('AIRI')
}
function configureServerChannelCertificateTrust() {
if (serverChannelCertificateTrustConfigured) {
return
}
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (isTrustedServerChannelCertificate(request)) {
callback(0)
return
}
callback(-3)
})
serverChannelCertificateTrustConfigured = true
}
async function installCACertificate(caCert: string) {
const { caCertPath } = getCertificatePaths()
const log = useLogg('main/server-runtime').useGlobalConfig()
writeFileSync(caCertPath, caCert)
try {
if (platform === 'darwin') {
await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'win32') {
await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'linux') {
const caDir = '/usr/local/share/ca-certificates'
const caFileName = 'airi-websocket-ca.crt'
try {
writeFileSync(join(caDir, caFileName), caCert)
await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } })
}
catch {
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
try {
if (!existsSync(userCaDir)) {
await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } })
}
writeFileSync(join(userCaDir, caFileName), caCert)
}
catch {
// Ignore errors
}
}
}
}
catch (error) {
log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`)
}
}
async function generateCertificate() {
const { caCertPath, caKeyPath } = getCertificatePaths()
let ca: { key: string, cert: string }
if (existsSync(caCertPath) && existsSync(caKeyPath)) {
ca = {
cert: readFileSync(caCertPath, 'utf-8'),
key: readFileSync(caKeyPath, 'utf-8'),
}
}
else {
ca = await createCA({
organization: 'AIRI',
countryCode: 'US',
state: 'Development',
locality: 'Local',
validity: 365,
})
writeFileSync(caCertPath, ca.cert)
writeFileSync(caKeyPath, ca.key)
}
await installCACertificate(ca.cert)
const domains = getCertificateDomains()
const cert = await createCert({
ca: { key: ca.key, cert: ca.cert },
domains,
validity: 365,
})
return {
cert: cert.cert,
key: cert.key,
}
}
async function getOrCreateCertificate() {
const { certPath, keyPath, caCertPath } = getCertificatePaths()
const expectedDomains = getCertificateDomains()
if (existsSync(certPath) && existsSync(keyPath)) {
const cert = readFileSync(certPath, 'utf-8')
const key = readFileSync(keyPath, 'utf-8')
if (certHasAllDomains(cert, expectedDomains)) {
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
return { cert: withCertificateChain(cert, caCert), key }
}
}
const { cert, key } = await generateCertificate()
writeFileSync(certPath, cert)
writeFileSync(keyPath, key)
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
return { cert: withCertificateChain(cert, caCert), key }
}
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
channelServerConfigStore.setup()
configureServerChannelCertificateTrust()
const storedConfig = await getChannelServerConfig()
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
if (storedConfigChanged) {
channelServerConfigStore.update(normalizedStoredConfig)
}
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
const mutex = new Mutex()
params.lifecycle.appHooks.onStart(async () => {
const release = await mutex.acquire()
const log = useLogg('main/server-runtime').useGlobalConfig()
try {
await serverChannel.start()
log.log('WebSocket server started')
}
catch (error) {
log.withError(error).error('Error starting WebSocket server')
}
finally {
release()
}
})
params.lifecycle.appHooks.onStop(async () => {
const release = await mutex.acquire()
const log = useLogg('main/server-runtime').useGlobalConfig()
if (!serverChannel) {
return
}
try {
await serverChannel.stop()
log.log('WebSocket server closed')
}
catch (error) {
log.withError(error).error('Error closing WebSocket server')
}
finally {
release()
}
})
return {
getConnectionHost() {
return serverChannel.getConnectionHost()
},
async start() {
const release = await mutex.acquire()
try {
await serverChannel.start()
}
finally {
release()
}
},
async restart() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
await serverChannel.start()
}
finally {
release()
}
},
async stop() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
}
finally {
release()
}
},
async updateConfig(config) {
const release = await mutex.acquire()
try {
await serverChannel.updateConfig(config)
}
finally {
release()
}
},
}
}
export async function createServerChannelService(params: { serverChannel: Server }) { export async function createServerChannelService(params: { serverChannel: Server }) {
if (serverChannelServiceRegistered) { if (serverChannelServiceRegistered) {
@@ -138,372 +506,4 @@ export async function createServerChannelService(params: { serverChannel: Server
}) })
} }
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
channelServerConfigStore.setup()
configureServerChannelCertificateTrust()
const storedConfig = await getChannelServerConfig()
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
if (storedConfigChanged) {
channelServerConfigStore.update(normalizedStoredConfig)
}
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
const mutex = new Mutex()
params.lifecycle.appHooks.onStart(async () => {
const release = await mutex.acquire()
const log = useLogg('main/server-runtime').useGlobalConfig()
try {
await serverChannel.start()
log.log('WebSocket server started')
}
catch (error) {
log.withError(error).error('Error starting WebSocket server')
}
finally {
release()
}
})
params.lifecycle.appHooks.onStop(async () => {
const release = await mutex.acquire()
const log = useLogg('main/server-runtime').useGlobalConfig()
if (!serverChannel) {
return
}
try {
await serverChannel.stop()
log.log('WebSocket server closed')
}
catch (error) {
log.withError(error).error('Error closing WebSocket server')
}
finally {
release()
}
})
return {
getConnectionHost() {
return serverChannel.getConnectionHost()
},
async restart() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
await serverChannel.start()
}
finally {
release()
}
},
async start() {
const release = await mutex.acquire()
try {
await serverChannel.start()
}
finally {
release()
}
},
async stop() {
const release = await mutex.acquire()
try {
await serverChannel.stop()
}
finally {
release()
}
},
async updateConfig(config) {
const release = await mutex.acquire()
try {
await serverChannel.updateConfig(config)
}
finally {
release()
}
},
}
}
function certHasAllDomains(certPem: string, domains: string[]): boolean {
try {
const cert = new X509Certificate(certPem)
const san = cert.subjectAltName || ''
const entries = san.split(',').map(part => part.trim())
const values = entries
.map((entry) => {
if (entry.startsWith('DNS:'))
return entry.slice(4).trim()
if (entry.startsWith('IP Address:'))
return entry.slice(11).trim()
return ''
})
.filter(Boolean)
const sanSet = new Set(values)
return domains.every(domain => sanSet.has(domain))
}
catch {
return false
}
}
function configureServerChannelCertificateTrust() {
if (serverChannelCertificateTrustConfigured) {
return
}
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (isTrustedServerChannelCertificate(request)) {
callback(0)
return
}
callback(-3)
})
serverChannelCertificateTrustConfigured = true
}
function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) {
const urlHost = isIP(host) === 6 ? `[${host}]` : host
// TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts`
// and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals.
return `${protocol}://${urlHost}:${getServerChannelPort()}/ws`
}
async function generateCertificate() {
const { caCertPath, caKeyPath } = getCertificatePaths()
let ca: { cert: string, key: string }
if (existsSync(caCertPath) && existsSync(caKeyPath)) {
ca = {
cert: readFileSync(caCertPath, 'utf-8'),
key: readFileSync(caKeyPath, 'utf-8'),
}
}
else {
ca = await createCA({
countryCode: 'US',
locality: 'Local',
organization: 'AIRI',
state: 'Development',
validity: 365,
})
writeFileSync(caCertPath, ca.cert)
writeFileSync(caKeyPath, ca.key)
}
await installCACertificate(ca.cert)
const domains = getCertificateDomains()
const cert = await createCert({
ca: { cert: ca.cert, key: ca.key },
domains,
validity: 365,
})
return {
cert: cert.cert,
key: cert.key,
}
}
function getCertificateDomains(): string[] {
const localIPs = getLocalIPs()
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
return Array.from(new Set([
'127.0.0.1',
'::1',
'localhost',
...(hostname ? [hostname] : []),
...localIPs,
]))
}
function getCertificatePaths() {
const userDataPath = app.getPath('userData')
return {
caCertPath: join(userDataPath, 'websocket-ca-cert.pem'),
caKeyPath: join(userDataPath, 'websocket-ca-key.pem'),
certPath: join(userDataPath, 'websocket-cert.pem'),
keyPath: join(userDataPath, 'websocket-key.pem'),
}
}
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
const config = channelServerConfigStore.get() || { authToken: '', hostname: '127.0.0.1', tlsConfig: null }
return {
authToken: config.authToken || '',
hostname: config.hostname || '127.0.0.1',
tlsConfig: config.tlsConfig || null,
}
}
async function getOrCreateCertificate() {
const { caCertPath, certPath, keyPath } = getCertificatePaths()
const expectedDomains = getCertificateDomains()
if (existsSync(certPath) && existsSync(keyPath)) {
const cert = readFileSync(certPath, 'utf-8')
const key = readFileSync(keyPath, 'utf-8')
if (certHasAllDomains(cert, expectedDomains)) {
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
return { cert: withCertificateChain(cert, caCert), key }
}
}
const { cert, key } = await generateCertificate()
writeFileSync(certPath, cert)
writeFileSync(keyPath, key)
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
return { cert: withCertificateChain(cert, caCert), key }
}
function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) {
if (config.hostname === '0.0.0.0') {
return Array.from(new Set(serverChannel.getConnectionHost()))
.filter(host => !isLoopbackHost(host))
.sort()
}
if (isLoopbackHost(config.hostname)) {
return []
}
return [config.hostname]
}
function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) {
const protocol = config.tlsConfig ? 'wss' : 'ws'
const urls = getServerChannelQrHosts(config, serverChannel)
.map(host => createServerChannelUrl(protocol, host))
if (!urls.length) {
throw new Error('No reachable private LAN address is available for the current server channel host.')
}
return createServerChannelQrPayload({
authToken: config.authToken,
type: 'airi:server-channel',
urls,
version: 1,
})
}
function getServerRuntimeBaseOptions() {
return {
hostname: '127.0.0.1',
port: getServerChannelPort(),
}
}
async function installCACertificate(caCert: string) {
const { caCertPath } = getCertificatePaths()
const log = useLogg('main/server-runtime').useGlobalConfig()
writeFileSync(caCertPath, caCert)
try {
if (platform === 'darwin') {
await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'win32') {
await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'linux') {
const caDir = '/usr/local/share/ca-certificates'
const caFileName = 'airi-websocket-ca.crt'
try {
writeFileSync(join(caDir, caFileName), caCert)
await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } })
}
catch {
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
try {
if (!existsSync(userCaDir)) {
await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } })
}
writeFileSync(join(userCaDir, caFileName), caCert)
}
catch {
// Ignore errors
}
}
}
}
catch (error) {
log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`)
}
}
function isLoopbackHost(host: string) {
return LOOPBACK_HOSTS.has(host)
}
function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean {
if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult)
&& request.errorCode !== -202) {
return false
}
if (!getCertificateDomains().includes(request.hostname)) {
return false
}
const issuer = request.certificate.issuer
return request.certificate.subject.commonName === 'localhost'
&& issuer.commonName === 'AIRI'
&& issuer.country === 'US'
&& issuer.locality === 'Local'
&& issuer.organizations.includes('AIRI')
}
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
if (!fallback) {
fallback = await getChannelServerConfig()
}
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
if (!parsed.success) {
return fallback
}
const normalizedConfig = {
authToken: parsed.data.authToken ?? fallback.authToken,
hostname: parsed.data.hostname ?? fallback.hostname,
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
}
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
}
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
return {
...getServerRuntimeBaseOptions(),
auth: {
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
},
hostname: 'hostname' in config && typeof config.hostname === 'string'
? config.hostname || '127.0.0.1'
: '127.0.0.1',
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
}
}
function withCertificateChain(cert: string, caCert?: string) {
return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert
}
export type { Server as ServerChannel } export type { Server as ServerChannel }
@@ -1,18 +1,18 @@
import { HTTPError } from 'h3' import { HTTPError } from 'h3'
export interface HttpErrorInput {
status: number
code: string
message: string
reason?: string
details?: unknown
expose?: boolean
}
export interface H3HttpErrorOptions { export interface H3HttpErrorOptions {
headers?: HeadersInit headers?: HeadersInit
} }
export interface HttpErrorInput {
code: string
details?: unknown
expose?: boolean
message: string
reason?: string
status: number
}
/** /**
* Unified HTTP error shape for AIRI local HTTP server modules. * Unified HTTP error shape for AIRI local HTTP server modules.
* *
@@ -28,11 +28,11 @@ export interface HttpErrorInput {
* - Error instance with status and structured metadata * - Error instance with status and structured metadata
*/ */
export class HttpError extends Error { export class HttpError extends Error {
readonly status: number
readonly code: string readonly code: string
readonly reason?: string
readonly details?: unknown readonly details?: unknown
readonly expose: boolean readonly expose: boolean
readonly reason?: string
readonly status: number
constructor(input: HttpErrorInput) { constructor(input: HttpErrorInput) {
super(input.message) super(input.message)
@@ -25,9 +25,9 @@ export interface LoopbackCallbackResult {
* - Random bound port, callback result promise, and manual cancellation method * - Random bound port, callback result promise, and manual cancellation method
*/ */
export async function startLoopbackServer(expectedState: string): Promise<{ export async function startLoopbackServer(expectedState: string): Promise<{
close: () => void
port: number port: number
result: Promise<LoopbackCallbackResult> result: Promise<LoopbackCallbackResult>
close: () => void
}> { }> {
const host = '127.0.0.1' const host = '127.0.0.1'
let settled = false let settled = false
@@ -44,8 +44,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
const app = new H3() const app = new H3()
const loopbackServer = createH3Server({ app, host }) const loopbackServer = createH3Server({ app, host })
const corsOptions = { const corsOptions = {
methods: '*',
origin: '*', origin: '*',
methods: '*',
preflight: { preflight: {
statusCode: 204, statusCode: 204,
}, },
@@ -93,8 +93,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
const state = typeof query.state === 'string' ? query.state : '' const state = typeof query.state === 'string' ? query.state : ''
if (!state || state !== expectedState) { if (!state || state !== expectedState) {
return new Response('<html><body><h2>Invalid state</h2></body></html>', { return new Response('<html><body><h2>Invalid state</h2></body></html>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
status: 400, status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}) })
} }
@@ -107,16 +107,16 @@ export async function startLoopbackServer(expectedState: string): Promise<{
rejectResult(new Error(description)) rejectResult(new Error(description))
}) })
return new Response('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>', { return new Response('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
status: 200, status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}) })
} }
const code = typeof query.code === 'string' ? query.code : '' const code = typeof query.code === 'string' ? query.code : ''
if (!code) { if (!code) {
return new Response('<html><body><h2>Missing parameters</h2></body></html>', { return new Response('<html><body><h2>Missing parameters</h2></body></html>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
status: 400, status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}) })
} }
@@ -125,8 +125,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
}) })
return new Response('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', { return new Response('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
status: 200, status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}) })
})) }))
@@ -139,12 +139,12 @@ export async function startLoopbackServer(expectedState: string): Promise<{
}, 5 * 60 * 1000) }, 5 * 60 * 1000)
return { return {
port: address.port,
result,
close: () => { close: () => {
finish(() => { finish(() => {
rejectResult(new Error('OIDC sign-in attempt cancelled')) rejectResult(new Error('OIDC sign-in attempt cancelled'))
}) })
}, },
port: address.port,
result,
} }
} }
@@ -21,8 +21,8 @@ export interface BuiltInServer {
*/ */
export function setupBuiltInServer(params: { export function setupBuiltInServer(params: {
authServer?: ServerManager authServer?: ServerManager
servers?: ServerManager[]
staticAssetServer?: ServerManager staticAssetServer?: ServerManager
servers?: ServerManager[]
}): BuiltInServer { }): BuiltInServer {
const servers = [ const servers = [
...(params.authServer ? [params.authServer] : []), ...(params.authServer ? [params.authServer] : []),
@@ -3,9 +3,9 @@ import { getRandomPort } from 'get-port-please'
import { serve } from 'h3' import { serve } from 'h3'
export interface BuiltInServerAddress { export interface BuiltInServerAddress {
baseUrl: string
host: string host: string
port: number port: number
baseUrl: string
} }
/** /**
@@ -36,9 +36,6 @@ export function createH3Server(options: {
let address: BuiltInServerAddress | undefined let address: BuiltInServerAddress | undefined
return { return {
getAddress() {
return address
},
async start(): Promise<BuiltInServerAddress> { async start(): Promise<BuiltInServerAddress> {
return await lifecycleMutex.runExclusive(async () => { return await lifecycleMutex.runExclusive(async () => {
if (address) { if (address) {
@@ -49,9 +46,9 @@ export function createH3Server(options: {
server = serve(options.app, { hostname: host, port, silent }) server = serve(options.app, { hostname: host, port, silent })
address = { address = {
baseUrl: `http://${host}:${port}`,
host, host,
port, port,
baseUrl: `http://${host}:${port}`,
} }
return address return address
@@ -69,5 +66,8 @@ export function createH3Server(options: {
await activeServer.close().catch(() => {}) await activeServer.close().catch(() => {})
}) })
}, },
getAddress() {
return address
},
} }
} }
@@ -21,7 +21,7 @@ describe('createStaticAssetService', () => {
} }
for (const root of tempRoots) { for (const root of tempRoots) {
await rm(root, { force: true, recursive: true }) await rm(root, { recursive: true, force: true })
} }
tempRoots.length = 0 tempRoots.length = 0
}) })
@@ -53,10 +53,10 @@ describe('createStaticAssetService', () => {
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version,
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version,
}) })
const baseUrl = server.getBaseUrl() const baseUrl = server.getBaseUrl()
@@ -69,13 +69,13 @@ describe('createStaticAssetService', () => {
}) })
const responseBody = await response.text() const responseBody = await response.text()
expect({ expect({
responseBody,
status: response.status, status: response.status,
validateInputs, validateInputs,
responseBody,
}).toEqual({ }).toEqual({
responseBody: 'console.log("ok")\n',
status: 200, status: 200,
validateInputs: ['assets/app.js'], validateInputs: ['assets/app.js'],
responseBody: 'console.log("ok")\n',
}) })
}) })
@@ -89,10 +89,10 @@ describe('createStaticAssetService', () => {
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version: '1.0.0',
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version: '1.0.0',
}) })
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
@@ -127,10 +127,10 @@ describe('createStaticAssetService', () => {
const { extensionId, server } = await createStartedAssetServer() const { extensionId, server } = await createStartedAssetServer()
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version: '1.0.0',
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version: '1.0.0',
}) })
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`) const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`)
@@ -145,10 +145,10 @@ describe('createStaticAssetService', () => {
const { extensionId, server } = await createStartedAssetServer() const { extensionId, server } = await createStartedAssetServer()
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version: '1.0.0',
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version: '1.0.0',
}) })
const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js` const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`
const headers = { const headers = {
@@ -171,10 +171,10 @@ describe('createStaticAssetService', () => {
const { extensionId, server } = await createStartedAssetServer() const { extensionId, server } = await createStartedAssetServer()
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version: '1.0.0',
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version: '1.0.0',
}) })
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, { const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, {
@@ -213,10 +213,10 @@ describe('createStaticAssetService', () => {
const session = server.createSession({ const session = server.createSession({
extensionId, extensionId,
version,
ownerSessionId: 'session-1', ownerSessionId: 'session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 60_000, ttlMs: 60_000,
version,
}) })
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
@@ -22,12 +22,12 @@ export interface StaticAssetManifestEntry {
} }
export interface StaticAssetService extends ServerManager { export interface StaticAssetService extends ServerManager {
createSession: StaticAssetSessionStore['createSession']
getBaseUrl: () => string | undefined getBaseUrl: () => string | undefined
revokeAll: StaticAssetSessionStore['revokeAll'] createSession: StaticAssetSessionStore['createSession']
revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId']
revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId']
revokeSession: StaticAssetSessionStore['revokeSession'] revokeSession: StaticAssetSessionStore['revokeSession']
revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId']
revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId']
revokeAll: StaticAssetSessionStore['revokeAll']
} }
/** /**
@@ -46,9 +46,9 @@ export interface StaticAssetService extends ServerManager {
*/ */
export function createStaticAssetService(options: { export function createStaticAssetService(options: {
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry> getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
getType?: (ext: string) => string | undefined
host?: string host?: string
sessionStore?: StaticAssetSessionStore sessionStore?: StaticAssetSessionStore
getType?: (ext: string) => string | undefined
}): StaticAssetService { }): StaticAssetService {
const host = options.host ?? '127.0.0.1' const host = options.host ?? '127.0.0.1'
const sessionStore = options.sessionStore ?? createStaticAssetSessionStore() const sessionStore = options.sessionStore ?? createStaticAssetSessionStore()
@@ -71,54 +71,54 @@ export function createStaticAssetService(options: {
} }
const staticAssetRoute = createStaticAssetRoute({ const staticAssetRoute = createStaticAssetRoute({
authorize: async ({ assetPath, assetSessionId, cookieValue, extensionId }) => { getType,
authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => {
const entry = getManifestEntryForRequest(extensionId) const entry = getManifestEntryForRequest(extensionId)
if (!entry) { if (!entry) {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 401,
code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED', code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED',
message: 'Unauthorized', message: 'Unauthorized',
reason: 'extension manifest entry does not exist for requested extensionId', reason: 'extension manifest entry does not exist for requested extensionId',
status: 401,
}), }),
ok: false,
} }
} }
return sessionStore.validateRequest({ return sessionStore.validateRequest({
assetPath,
assetSessionId,
cookieValue,
extensionId, extensionId,
version: entry.version, version: entry.version,
assetSessionId,
assetPath,
cookieValue,
}) })
}, },
getType,
refreshSession: sessionStore.refreshSession, refreshSession: sessionStore.refreshSession,
resolveAsset: async ({ assetPath, extensionId }) => { resolveAsset: async ({ extensionId, assetPath }) => {
const entry = getManifestEntryForRequest(extensionId) const entry = getManifestEntryForRequest(extensionId)
if (!entry) { if (!entry) {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND', code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND',
message: 'Not Found', message: 'Not Found',
reason: 'extension manifest entry does not exist for requested extensionId', reason: 'extension manifest entry does not exist for requested extensionId',
status: 404,
}), }),
ok: false,
} }
} }
const normalizedAssetPath = normalizeStaticAssetPath(assetPath) const normalizedAssetPath = normalizeStaticAssetPath(assetPath)
if (!normalizedAssetPath) { if (!normalizedAssetPath) {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 400,
code: 'EXTENSION_ASSET_PATH_INVALID', code: 'EXTENSION_ASSET_PATH_INVALID',
message: 'Bad Request', message: 'Bad Request',
reason: 'asset path could not be normalized', reason: 'asset path could not be normalized',
status: 400,
}), }),
ok: false,
} }
} }
@@ -132,24 +132,24 @@ export function createStaticAssetService(options: {
} }
catch { catch {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'EXTENSION_ASSET_NOT_FOUND', code: 'EXTENSION_ASSET_NOT_FOUND',
message: 'Not Found', message: 'Not Found',
reason: 'resolved file does not exist', reason: 'resolved file does not exist',
status: 404,
}), }),
ok: false,
} }
} }
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 400,
code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED', code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED',
message: 'Bad Request', message: 'Bad Request',
reason: 'resolved asset path is outside extension root', reason: 'resolved asset path is outside extension root',
status: 400,
}), }),
ok: false,
} }
} }
@@ -157,32 +157,32 @@ export function createStaticAssetService(options: {
const fileStats = await stat(filePath) const fileStats = await stat(filePath)
if (!fileStats.isFile()) { if (!fileStats.isFile()) {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'EXTENSION_ASSET_NOT_FILE', code: 'EXTENSION_ASSET_NOT_FILE',
message: 'Not Found', message: 'Not Found',
reason: 'resolved path exists but is not a file', reason: 'resolved path exists but is not a file',
status: 404,
}), }),
ok: false,
} }
} }
return { return {
filePath,
mtime: fileStats.mtimeMs,
ok: true, ok: true,
filePath,
size: fileStats.size, size: fileStats.size,
mtime: fileStats.mtimeMs,
} }
} }
catch { catch {
return { return {
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'EXTENSION_ASSET_NOT_FOUND', code: 'EXTENSION_ASSET_NOT_FOUND',
message: 'Not Found', message: 'Not Found',
reason: 'resolved file does not exist', reason: 'resolved file does not exist',
status: 404,
}), }),
ok: false,
} }
} }
}, },
@@ -191,29 +191,29 @@ export function createStaticAssetService(options: {
app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event))) app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event)))
return { return {
createSession: sessionStore.createSession,
getBaseUrl() {
return serverLifecycle.getAddress()?.baseUrl
},
key: 'static-assets', key: 'static-assets',
revokeAll: sessionStore.revokeAll,
revokeByExtensionId: sessionStore.revokeByExtensionId,
revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId,
revokeSession: sessionStore.revokeSession,
async start() { async start() {
await serverLifecycle.start() await serverLifecycle.start()
}, },
async stop() { async stop() {
await serverLifecycle.stop() await serverLifecycle.stop()
}, },
getBaseUrl() {
return serverLifecycle.getAddress()?.baseUrl
},
createSession: sessionStore.createSession,
revokeSession: sessionStore.revokeSession,
revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId,
revokeByExtensionId: sessionStore.revokeByExtensionId,
revokeAll: sessionStore.revokeAll,
} }
} }
const staticAssetMimeTypeOverrides: Record<string, string> = { const staticAssetMimeTypeOverrides: Record<string, string> = {
'.wasm': 'application/wasm',
'.avif': 'image/avif', '.avif': 'image/avif',
'.heic': 'image/heic', '.heic': 'image/heic',
'.heif': 'image/heif', '.heif': 'image/heif',
'.wasm': 'application/wasm',
} }
function defaultStaticAssetMimeTypeResolver(ext: string) { function defaultStaticAssetMimeTypeResolver(ext: string) {
@@ -16,7 +16,7 @@ describe('static asset paths', () => {
afterEach(async () => { afterEach(async () => {
for (const root of tempRoots) { for (const root of tempRoots) {
await rm(root, { force: true, recursive: true }) await rm(root, { recursive: true, force: true })
} }
tempRoots.length = 0 tempRoots.length = 0
}) })
@@ -30,9 +30,9 @@ describe('static asset paths', () => {
it('parses session-scoped mounted plugin request path and rejects malformed routes', () => { it('parses session-scoped mounted plugin request path and rejects malformed routes', () => {
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({ expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({
assetPath: 'dist/ui/index.html',
assetSessionId: 'asset-session-1',
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
assetSessionId: 'asset-session-1',
assetPath: 'dist/ui/index.html',
}) })
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined() expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined()
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined() expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined()
@@ -46,24 +46,24 @@ describe('static asset paths', () => {
it('builds session-scoped mounted asset path with encoded segments', () => { it('builds session-scoped mounted asset path with encoded segments', () => {
expect(buildMountedStaticAssetPath({ expect(buildMountedStaticAssetPath({
assetPath: 'dist/ui/index.html',
assetSessionId: 'asset-session-1',
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
assetSessionId: 'asset-session-1',
assetPath: 'dist/ui/index.html',
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html') })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')
expect(buildMountedStaticAssetPath({ expect(buildMountedStaticAssetPath({
assetPath: 'dist/ui/file name.html',
assetSessionId: 'asset-session-1',
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
assetSessionId: 'asset-session-1',
assetPath: 'dist/ui/file name.html',
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html') })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html')
expect(buildMountedStaticAssetPath({ expect(buildMountedStaticAssetPath({
assetPath: 'dist/ui/index.html',
assetSessionId: 'asset-session-1',
extensionId: 'bad/id', extensionId: 'bad/id',
assetSessionId: 'asset-session-1',
assetPath: 'dist/ui/index.html',
})).toBeUndefined() })).toBeUndefined()
expect(buildMountedStaticAssetPath({ expect(buildMountedStaticAssetPath({
assetPath: 'dist/ui/index.html',
assetSessionId: 'bad session',
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
assetSessionId: 'bad session',
assetPath: 'dist/ui/index.html',
})).toBeUndefined() })).toBeUndefined()
}) })
@@ -8,53 +8,30 @@ import { resolve, sep } from 'node:path'
* @param assetSessionId Asset session identifier from the mounted route. * @param assetSessionId Asset session identifier from the mounted route.
*/ */
export interface ParsedStaticAssetRequest { export interface ParsedStaticAssetRequest {
/** Normalized plugin asset path relative to the mounted UI asset root. */
assetPath: string
/** Asset session identifier validated as one safe route segment. */
assetSessionId: string
/** Plugin extension identifier validated as one safe route segment. */ /** Plugin extension identifier validated as one safe route segment. */
extensionId: string extensionId: string
/** Asset session identifier validated as one safe route segment. */
assetSessionId: string
/** Normalized plugin asset path relative to the mounted UI asset root. */
assetPath: string
} }
const pathPrefix = '/_airi/extensions/' const pathPrefix = '/_airi/extensions/'
const segmentPattern = /^[\w.+-]+$/ const segmentPattern = /^[\w.+-]+$/
/** function decodePathSegment(segment: string): string | undefined {
* Builds a session-scoped mounted plugin asset route path. try {
* return decodeURIComponent(segment)
* Use when: }
* - Converting a validated plugin asset path into a mounted HTTP route catch {
* - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath`
*
* Expects:
* - `extensionId` and `assetSessionId` are safe single route segments
* - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath}
*
* Returns:
* - Encoded mounted route path, or `undefined` when any input is unsafe
*/
export function buildMountedStaticAssetPath(input: {
assetPath: string
assetSessionId: string
extensionId: string
}) {
if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) {
return undefined return undefined
} }
}
const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath) function isSafeRouteSegment(segment: string): boolean {
if (!normalizedAssetPath) { return !segment.includes('/')
return undefined && !segment.includes('\\')
} && segmentPattern.test(segment)
const encodedExtensionId = encodeURIComponent(input.extensionId)
const encodedAssetSessionId = encodeURIComponent(input.assetSessionId)
const encodedAssetPath = normalizedAssetPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}`
} }
/** /**
@@ -174,9 +151,9 @@ export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAsset
} }
return { return {
assetPath,
assetSessionId,
extensionId, extensionId,
assetSessionId,
assetPath,
} }
} }
@@ -219,17 +196,40 @@ export async function resolveStaticAssetFilePath(rootDir: string, assetPath: str
return realCandidate return realCandidate
} }
function decodePathSegment(segment: string): string | undefined { /**
try { * Builds a session-scoped mounted plugin asset route path.
return decodeURIComponent(segment) *
} * Use when:
catch { * - Converting a validated plugin asset path into a mounted HTTP route
* - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath`
*
* Expects:
* - `extensionId` and `assetSessionId` are safe single route segments
* - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath}
*
* Returns:
* - Encoded mounted route path, or `undefined` when any input is unsafe
*/
export function buildMountedStaticAssetPath(input: {
extensionId: string
assetSessionId: string
assetPath: string
}) {
if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) {
return undefined return undefined
} }
}
function isSafeRouteSegment(segment: string): boolean { const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath)
return !segment.includes('/') if (!normalizedAssetPath) {
&& !segment.includes('\\') return undefined
&& segmentPattern.test(segment) }
const encodedExtensionId = encodeURIComponent(input.extensionId)
const encodedAssetSessionId = encodeURIComponent(input.assetSessionId)
const encodedAssetPath = normalizedAssetPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}`
} }
@@ -17,7 +17,7 @@ describe('createStaticAssetRoute', () => {
afterEach(async () => { afterEach(async () => {
for (const root of tempRoots) { for (const root of tempRoots) {
await rm(root, { force: true, recursive: true }) await rm(root, { recursive: true, force: true })
} }
tempRoots.length = 0 tempRoots.length = 0
@@ -36,21 +36,21 @@ describe('createStaticAssetRoute', () => {
const app = new H3() const app = new H3()
app.get('/_airi/extensions/**', createStaticAssetRoute({ app.get('/_airi/extensions/**', createStaticAssetRoute({
authorize: async () => ({ authorize: async () => ({
ok: false,
error: new HttpError({ error: new HttpError({
status: 401,
code: 'COOKIE_MISSING', code: 'COOKIE_MISSING',
message: 'Unauthorized', message: 'Unauthorized',
status: 401,
}), }),
ok: false,
}), }),
refreshSession: () => undefined, refreshSession: () => undefined,
resolveAsset: async () => ({ resolveAsset: async () => ({
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Not Found', message: 'Not Found',
status: 404,
}), }),
ok: false,
}), }),
})) }))
@@ -70,21 +70,21 @@ describe('createStaticAssetRoute', () => {
const app = new H3() const app = new H3()
app.use('/_airi/extensions/**', createStaticAssetRoute({ app.use('/_airi/extensions/**', createStaticAssetRoute({
authorize: async () => ({ authorize: async () => ({
ok: false,
error: new HttpError({ error: new HttpError({
status: 401,
code: 'COOKIE_MISSING', code: 'COOKIE_MISSING',
message: 'Unauthorized', message: 'Unauthorized',
status: 401,
}), }),
ok: false,
}), }),
refreshSession: () => undefined, refreshSession: () => undefined,
resolveAsset: async () => ({ resolveAsset: async () => ({
ok: false,
error: new HttpError({ error: new HttpError({
status: 404,
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Not Found', message: 'Not Found',
status: 404,
}), }),
ok: false,
}), }),
})) }))
@@ -122,23 +122,23 @@ describe('createStaticAssetRoute', () => {
session: { session: {
assetSessionId: 's1', assetSessionId: 's1',
cookieName: createStaticAssetSessionCookieName('s1'), cookieName: createStaticAssetSessionCookieName('s1'),
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
cookieValue: 'test-token', cookieValue: 'test-token',
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
expiresAt: Date.now() + 1000, expiresAt: Date.now() + 1000,
}, },
} }
}, },
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
refreshSession: (assetSessionId) => { refreshSession: (assetSessionId) => {
refreshedSessionId = assetSessionId refreshedSessionId = assetSessionId
return undefined return undefined
}, },
resolveAsset: async () => ({ resolveAsset: async () => ({
filePath: wasmFilePath,
mtime: Date.now(),
ok: true, ok: true,
filePath: wasmFilePath,
size: 4, size: 4,
mtime: Date.now(),
}), }),
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
})) }))
server = createServer(toNodeHandler(app)) server = createServer(toNodeHandler(app))
@@ -178,23 +178,23 @@ describe('createStaticAssetRoute', () => {
session: { session: {
assetSessionId: 's1', assetSessionId: 's1',
cookieName: createStaticAssetSessionCookieName('s1'), cookieName: createStaticAssetSessionCookieName('s1'),
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
cookieValue: 'test-token', cookieValue: 'test-token',
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
expiresAt: Date.now() + 1000, expiresAt: Date.now() + 1000,
}, },
} }
}, },
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
refreshSession: (assetSessionId) => { refreshSession: (assetSessionId) => {
refreshedSessionId = assetSessionId refreshedSessionId = assetSessionId
return undefined return undefined
}, },
resolveAsset: async () => ({ resolveAsset: async () => ({
filePath: wasmFilePath,
mtime: Date.now(),
ok: true, ok: true,
filePath: wasmFilePath,
size: 4, size: 4,
mtime: Date.now(),
}), }),
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
})) }))
server = createServer(toNodeHandler(app)) server = createServer(toNodeHandler(app))
@@ -16,14 +16,14 @@ const staticAssetSecurityHeaders = {
export interface StaticAssetRouteOptions { export interface StaticAssetRouteOptions {
authorize: (params: { authorize: (params: {
assetPath: string
assetSessionId: string
cookieValue: string | undefined
extensionId: string extensionId: string
assetSessionId: string
assetPath: string
cookieValue: string | undefined
}) => Promise<StaticAssetSessionValidationResult> }) => Promise<StaticAssetSessionValidationResult>
getType?: (ext: string) => string | undefined
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
resolveAsset: (params: { assetPath: string, extensionId: string }) => Promise<StaticAssetResolveResult> resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise<StaticAssetResolveResult>
getType?: (ext: string) => string | undefined
} }
/** /**
@@ -48,9 +48,9 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
if (event.req.method !== 'GET' && event.req.method !== 'HEAD') { if (event.req.method !== 'GET' && event.req.method !== 'HEAD') {
throw new HttpError({ throw new HttpError({
status: 405,
code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED', code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED',
message: 'Method Not Allowed', message: 'Method Not Allowed',
status: 405,
}) })
} }
@@ -61,19 +61,19 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
if (!extensionId || !assetSessionId || !assetPath) { if (!extensionId || !assetSessionId || !assetPath) {
throw new HttpError({ throw new HttpError({
status: 401,
code: 'EXTENSION_ASSET_REQUEST_INVALID', code: 'EXTENSION_ASSET_REQUEST_INVALID',
message: 'Unauthorized', message: 'Unauthorized',
reason: 'required extensionId, assetSessionId, or assetPath is missing', reason: 'required extensionId, assetSessionId, or assetPath is missing',
status: 401,
}) })
} }
const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId)) const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId))
const auth = await options.authorize({ const auth = await options.authorize({
assetPath,
assetSessionId,
cookieValue,
extensionId, extensionId,
assetSessionId,
assetPath,
cookieValue,
}) })
if (!auth.ok) { if (!auth.ok) {
throw auth.error throw auth.error
@@ -84,12 +84,13 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
let resolved: Awaited<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | undefined let resolved: Awaited<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | undefined
const resolveOnce = async () => { const resolveOnce = async () => {
if (!resolved) { if (!resolved) {
resolved = await options.resolveAsset({ assetPath, extensionId }) resolved = await options.resolveAsset({ extensionId, assetPath })
} }
return resolved return resolved
} }
return await serveStatic(event, { return await serveStatic(event, {
getType: options.getType,
getContents: async () => { getContents: async () => {
const item = await resolveOnce() const item = await resolveOnce()
if (!item.ok) { if (!item.ok) {
@@ -104,11 +105,10 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
} }
return { return {
mtime: item.mtime,
size: item.size, size: item.size,
mtime: item.mtime,
} }
}, },
getType: options.getType,
}) })
} }
catch (error) { catch (error) {
@@ -27,21 +27,21 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now }) const store = createStaticAssetSessionStore({ now })
const session = store.createSession({ const session = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
expect(session.assetSessionId).toBeTruthy() expect(session.assetSessionId).toBeTruthy()
expect(session.cookieName).toContain(session.assetSessionId) expect(session.cookieName).toContain(session.assetSessionId)
expect(session.cookieValue).toBeTruthy() expect(session.cookieValue).toBeTruthy()
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: session.cookieValue,
}).ok).toBe(true) }).ok).toBe(true)
}) })
@@ -54,39 +54,39 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now }) const store = createStaticAssetSessionStore({ now })
const session = store.createSession({ const session = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'index.html',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'other-plugin', extensionId: 'other-plugin',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'index.html',
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
},
}) })
expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1) expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1)
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'index.html',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'index.html',
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
},
}) })
}) })
@@ -99,96 +99,96 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now }) const store = createStaticAssetSessionStore({ now })
const session = store.createSession({ const session = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: 'assets/', pathPrefix: 'assets/',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
assetSessionId: session.assetSessionId, assetSessionId: session.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: undefined, cookieValue: undefined,
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
})).toMatchObject({ })).toMatchObject({
ok: false,
error: { error: {
status: 401,
code: 'EXTENSION_ASSET_COOKIE_MISSING', code: 'EXTENSION_ASSET_COOKIE_MISSING',
status: 401,
}, },
ok: false,
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js',
assetSessionId: session.assetSessionId,
cookieValue: 'wrong-cookie',
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: 'wrong-cookie',
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
},
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.2.0', version: '0.2.0',
assetSessionId: session.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
ok: false,
error: { error: {
status: 401,
code: 'EXTENSION_ASSET_VERSION_MISMATCH', code: 'EXTENSION_ASSET_VERSION_MISMATCH',
status: 401,
}, },
ok: false,
}) })
expect(store.validateRequest({ expect(store.validateRequest({
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: '', assetPath: '',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue, cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_PATH_EMPTY',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_PATH_EMPTY',
},
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'other/index.js',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'other/index.js',
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
},
}) })
now.mockReturnValue(31_001) now.mockReturnValue(31_001)
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js',
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
},
}) })
}) })
@@ -201,24 +201,24 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now }) const store = createStaticAssetSessionStore({ now })
const firstSession = store.createSession({ const firstSession = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
const secondSession = store.createSession({ const secondSession = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-2', ownerSessionId: 'plugin-session-2',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
const thirdSession = store.createSession({ const thirdSession = store.createSession({
extensionId: 'airi-plugin-game-go', extensionId: 'airi-plugin-game-go',
version: '0.1.0',
ownerSessionId: 'plugin-session-3', ownerSessionId: 'plugin-session-3',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
now.mockReturnValue(2000) now.mockReturnValue(2000)
@@ -243,21 +243,21 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now }) const store = createStaticAssetSessionStore({ now })
const createdSession = store.createSession({ const createdSession = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '', pathPrefix: '',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
const originalCookieValue = createdSession.cookieValue const originalCookieValue = createdSession.cookieValue
tryMutateCookieValue(createdSession, 'mutated-create-cookie') tryMutateCookieValue(createdSession, 'mutated-create-cookie')
const firstValidation = store.validateRequest({ const firstValidation = store.validateRequest({
assetPath: 'index.html',
assetSessionId: createdSession.assetSessionId,
cookieValue: originalCookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: createdSession.assetSessionId,
assetPath: 'index.html',
cookieValue: originalCookieValue,
}) })
expect(firstValidation.ok).toBe(true) expect(firstValidation.ok).toBe(true)
expect(Object.isFrozen(createdSession)).toBe(true) expect(Object.isFrozen(createdSession)).toBe(true)
@@ -268,11 +268,11 @@ describe('createStaticAssetSessionStore', () => {
tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie') tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie')
const secondValidation = store.validateRequest({ const secondValidation = store.validateRequest({
assetPath: 'index.html',
assetSessionId: createdSession.assetSessionId,
cookieValue: originalCookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: createdSession.assetSessionId,
assetPath: 'index.html',
cookieValue: originalCookieValue,
}) })
expect(secondValidation.ok).toBe(true) expect(secondValidation.ok).toBe(true)
expect(Object.isFrozen(firstValidation.session)).toBe(true) expect(Object.isFrozen(firstValidation.session)).toBe(true)
@@ -290,11 +290,11 @@ describe('createStaticAssetSessionStore', () => {
tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie') tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie')
const thirdValidation = store.validateRequest({ const thirdValidation = store.validateRequest({
assetPath: 'index.html',
assetSessionId: createdSession.assetSessionId,
cookieValue: originalCookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: createdSession.assetSessionId,
assetPath: 'index.html',
cookieValue: originalCookieValue,
}) })
expect(thirdValidation.ok).toBe(true) expect(thirdValidation.ok).toBe(true)
expect(Object.isFrozen(refreshedSession)).toBe(true) expect(Object.isFrozen(refreshedSession)).toBe(true)
@@ -321,9 +321,9 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
const input = { const input = {
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '', pathPrefix: '',
version: '0.1.0',
} }
for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) { for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
@@ -342,10 +342,10 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
const session = store.createSession({ const session = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: 'assets/', pathPrefix: 'assets/',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
for (const assetPath of [ for (const assetPath of [
@@ -354,26 +354,26 @@ describe('createStaticAssetSessionStore', () => {
'assets%2Fsecret.js', 'assets%2Fsecret.js',
]) { ]) {
expect(store.validateRequest({ expect(store.validateRequest({
assetPath,
assetSessionId: session.assetSessionId,
cookieValue: session.cookieValue,
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: session.assetSessionId,
assetPath,
cookieValue: session.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
},
}) })
} }
expect(() => store.createSession({ expect(() => store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: '../', pathPrefix: '../',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
})).toThrow(RangeError) })).toThrow(RangeError)
}) })
@@ -385,47 +385,47 @@ describe('createStaticAssetSessionStore', () => {
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
const directorySession = store.createSession({ const directorySession = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-1', ownerSessionId: 'plugin-session-1',
pathPrefix: 'assets/', pathPrefix: 'assets/',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
const exactSession = store.createSession({ const exactSession = store.createSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
ownerSessionId: 'plugin-session-2', ownerSessionId: 'plugin-session-2',
pathPrefix: 'assets', pathPrefix: 'assets',
ttlMs: 30_000, ttlMs: 30_000,
version: '0.1.0',
}) })
expect(store.validateRequest({ expect(store.validateRequest({
assetPath: 'assets/index.js', extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
assetSessionId: directorySession.assetSessionId, assetSessionId: directorySession.assetSessionId,
cookieValue: directorySession.cookieValue,
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
}).ok).toBe(true)
expect(store.validateRequest({
assetPath: 'assets',
assetSessionId: exactSession.assetSessionId,
cookieValue: exactSession.cookieValue,
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
}).ok).toBe(true)
expect(store.validateRequest({
assetPath: 'assets/index.js', assetPath: 'assets/index.js',
assetSessionId: exactSession.assetSessionId, cookieValue: directorySession.cookieValue,
cookieValue: exactSession.cookieValue, }).ok).toBe(true)
expect(store.validateRequest({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '0.1.0', version: '0.1.0',
assetSessionId: exactSession.assetSessionId,
assetPath: 'assets',
cookieValue: exactSession.cookieValue,
}).ok).toBe(true)
expect(store.validateRequest({
extensionId: 'airi-plugin-game-chess',
version: '0.1.0',
assetSessionId: exactSession.assetSessionId,
assetPath: 'assets/index.js',
cookieValue: exactSession.cookieValue,
})).toMatchObject({ })).toMatchObject({
error: {
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
status: 401,
},
ok: false, ok: false,
error: {
status: 401,
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
},
}) })
}) })
}) })
@@ -14,15 +14,57 @@ import { normalizeStaticAssetPath } from './paths'
interface StaticAssetSessionRecord { interface StaticAssetSessionRecord {
assetSessionId: string assetSessionId: string
cookieName: string
cookiePath: string
cookieValue: string
expiresAt: number
extensionId: string extensionId: string
version: string
ownerSessionId: string ownerSessionId: string
pathPrefix: string pathPrefix: string
ttlMs: number ttlMs: number
version: string cookieName: string
cookieValue: string
cookiePath: string
expiresAt: number
}
/**
* Normalizes asset path prefixes used to constrain a session.
*
* Before:
* - " assets\\ "
*
* After:
* - "assets/"
*/
function normalizePathPrefix(pathPrefix: string) {
const normalizedInput = pathPrefix.trim().replaceAll('\\', '/')
if (!normalizedInput) {
return ''
}
const isDirectoryPrefix = normalizedInput.endsWith('/')
const normalized = normalizeStaticAssetPath(normalizedInput)
if (!normalized) {
throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path')
}
return isDirectoryPrefix ? `${normalized}/` : normalized
}
/**
* Normalizes requested asset paths before comparing them to session prefixes.
*
* Before:
* - " assets\\index.js "
*
* After:
* - "assets/index.js"
*/
function normalizeAssetPath(assetPath: string) {
return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/'))
}
function createOpaqueToken() {
// Node's base64url alphabet is route/cookie friendly while staying opaque.
return randomBytes(18).toString('base64url')
} }
/** /**
@@ -42,6 +84,42 @@ export function createStaticAssetSessionCookieName(assetSessionId: string) {
return `airi_extension_asset_session_${assetSessionId}` return `airi_extension_asset_session_${assetSessionId}`
} }
function createCookiePath(extensionId: string, assetSessionId: string) {
return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui`
}
function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession {
return Object.freeze({
assetSessionId: record.assetSessionId,
cookieName: record.cookieName,
cookieValue: record.cookieValue,
cookiePath: record.cookiePath,
expiresAt: record.expiresAt,
})
}
function cookieValuesMatch(expected: string, actual: string) {
const expectedBuffer = Buffer.from(expected, 'utf8')
const actualBuffer = Buffer.from(actual, 'utf8')
if (expectedBuffer.length !== actualBuffer.length) {
return false
}
return timingSafeEqual(expectedBuffer, actualBuffer)
}
function unauthorized(code: string, reason: string) {
return {
ok: false as const,
error: new HttpError({
status: 401,
code,
message: 'Unauthorized',
reason,
}),
}
}
/** /**
* Creates an in-memory cookie-backed session store for extension static assets. * Creates an in-memory cookie-backed session store for extension static assets.
* *
@@ -69,7 +147,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
return true return true
} }
const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { error: HttpError, ok: false } } | { ok: true, record: StaticAssetSessionRecord } => { const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { ok: false, error: HttpError } } | { ok: true, record: StaticAssetSessionRecord } => {
const record = records.get(assetSessionId) const record = records.get(assetSessionId)
if (!record) { if (!record) {
return { return {
@@ -99,15 +177,15 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
const assetSessionId = createOpaqueToken() const assetSessionId = createOpaqueToken()
const record: StaticAssetSessionRecord = { const record: StaticAssetSessionRecord = {
assetSessionId, assetSessionId,
cookieName: createStaticAssetSessionCookieName(assetSessionId),
cookiePath: createCookiePath(input.extensionId, assetSessionId),
cookieValue: createOpaqueToken(),
expiresAt: now() + input.ttlMs,
extensionId: input.extensionId, extensionId: input.extensionId,
version: input.version,
ownerSessionId: input.ownerSessionId, ownerSessionId: input.ownerSessionId,
pathPrefix: normalizePathPrefix(input.pathPrefix), pathPrefix: normalizePathPrefix(input.pathPrefix),
ttlMs: input.ttlMs, ttlMs: input.ttlMs,
version: input.version, cookieName: createStaticAssetSessionCookieName(assetSessionId),
cookieValue: createOpaqueToken(),
cookiePath: createCookiePath(input.extensionId, assetSessionId),
expiresAt: now() + input.ttlMs,
} }
records.set(assetSessionId, record) records.set(assetSessionId, record)
@@ -175,6 +253,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
return { return {
createSession, createSession,
validateRequest,
refreshSession(assetSessionId) { refreshSession(assetSessionId) {
const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED')
if (!active.ok) { if (!active.ok) {
@@ -184,15 +263,6 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
active.record.expiresAt = now() + active.record.ttlMs active.record.expiresAt = now() + active.record.ttlMs
return createSessionSnapshot(active.record) return createSessionSnapshot(active.record)
}, },
revokeAll() {
return revokeWhere(() => true)
},
revokeByExtensionId(extensionId) {
return revokeWhere(record => record.extensionId === extensionId)
},
revokeByOwnerSessionId(ownerSessionId) {
return revokeWhere(record => record.ownerSessionId === ownerSessionId)
},
revokeSession(assetSessionId) { revokeSession(assetSessionId) {
const record = records.get(assetSessionId) const record = records.get(assetSessionId)
if (!record) { if (!record) {
@@ -202,84 +272,14 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
records.delete(assetSessionId) records.delete(assetSessionId)
return createSessionSnapshot(record) return createSessionSnapshot(record)
}, },
validateRequest, revokeByOwnerSessionId(ownerSessionId) {
} return revokeWhere(record => record.ownerSessionId === ownerSessionId)
} },
revokeByExtensionId(extensionId) {
function cookieValuesMatch(expected: string, actual: string) { return revokeWhere(record => record.extensionId === extensionId)
const expectedBuffer = Buffer.from(expected, 'utf8') },
const actualBuffer = Buffer.from(actual, 'utf8') revokeAll() {
if (expectedBuffer.length !== actualBuffer.length) { return revokeWhere(() => true)
return false },
}
return timingSafeEqual(expectedBuffer, actualBuffer)
}
function createCookiePath(extensionId: string, assetSessionId: string) {
return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui`
}
function createOpaqueToken() {
// Node's base64url alphabet is route/cookie friendly while staying opaque.
return randomBytes(18).toString('base64url')
}
function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession {
return Object.freeze({
assetSessionId: record.assetSessionId,
cookieName: record.cookieName,
cookiePath: record.cookiePath,
cookieValue: record.cookieValue,
expiresAt: record.expiresAt,
})
}
/**
* Normalizes requested asset paths before comparing them to session prefixes.
*
* Before:
* - " assets\\index.js "
*
* After:
* - "assets/index.js"
*/
function normalizeAssetPath(assetPath: string) {
return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/'))
}
/**
* Normalizes asset path prefixes used to constrain a session.
*
* Before:
* - " assets\\ "
*
* After:
* - "assets/"
*/
function normalizePathPrefix(pathPrefix: string) {
const normalizedInput = pathPrefix.trim().replaceAll('\\', '/')
if (!normalizedInput) {
return ''
}
const isDirectoryPrefix = normalizedInput.endsWith('/')
const normalized = normalizeStaticAssetPath(normalizedInput)
if (!normalized) {
throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path')
}
return isDirectoryPrefix ? `${normalized}/` : normalized
}
function unauthorized(code: string, reason: string) {
return {
error: new HttpError({
code,
message: 'Unauthorized',
reason,
status: 401,
}),
ok: false as const,
} }
} }
@@ -1,31 +1,13 @@
import type { HttpError } from '../errors' import type { HttpError } from '../errors'
export type StaticAssetResolveResult
= | { error: HttpError, ok: false }
| { filePath: string, mtime: number, ok: true, size: number }
/**
* Cookie data returned after creating a static asset session.
*/
export interface StaticAssetSession {
/** Opaque server-side session id embedded in extension asset routes. */
readonly assetSessionId: string
/** Cookie name callers set on the asset route path. */
readonly cookieName: string
/** Cookie path scope for browser requests. */
readonly cookiePath: string
/** Opaque cookie value required to validate asset requests. */
readonly cookieValue: string
/** Unix timestamp in milliseconds when the session expires. */
readonly expiresAt: number
}
/** /**
* Input required to create a cookie-backed static asset session. * Input required to create a cookie-backed static asset session.
*/ */
export interface StaticAssetSessionCreateInput { export interface StaticAssetSessionCreateInput {
/** Plugin extension id that owns the served static assets. */ /** Plugin extension id that owns the served static assets. */
extensionId: string extensionId: string
/** Extension version expected by requests using this asset session. */
version: string
/** Parent plugin session id used for owner-scoped revocation. */ /** Parent plugin session id used for owner-scoped revocation. */
ownerSessionId: string ownerSessionId: string
/** /**
@@ -37,49 +19,67 @@ export interface StaticAssetSessionCreateInput {
pathPrefix: string pathPrefix: string
/** Session lifetime in milliseconds from creation or refresh time. */ /** Session lifetime in milliseconds from creation or refresh time. */
ttlMs: number ttlMs: number
/** Extension version expected by requests using this asset session. */
version: string
} }
/**
* Cookie data returned after creating a static asset session.
*/
export interface StaticAssetSession {
/** Opaque server-side session id embedded in extension asset routes. */
readonly assetSessionId: string
/** Cookie name callers set on the asset route path. */
readonly cookieName: string
/** Opaque cookie value required to validate asset requests. */
readonly cookieValue: string
/** Cookie path scope for browser requests. */
readonly cookiePath: string
/** Unix timestamp in milliseconds when the session expires. */
readonly expiresAt: number
}
/**
* Request data required to validate a cookie-backed static asset session.
*/
export interface StaticAssetSessionValidateInput {
/** Plugin extension id from the requested route. */
extensionId: string
/** Extension version from the requested route. */
version: string
/** Opaque session id from the requested route. */
assetSessionId: string
/** Static asset path being requested. */
assetPath: string
/** Cookie value provided by the request, if any. */
cookieValue: string | undefined
}
/**
* Result of validating a cookie-backed static asset request.
*/
export type StaticAssetSessionValidationResult
= | { ok: true, session: StaticAssetSession }
| { ok: false, error: HttpError }
/** /**
* In-memory store for cookie-backed extension static asset sessions. * In-memory store for cookie-backed extension static asset sessions.
*/ */
export interface StaticAssetSessionStore { export interface StaticAssetSessionStore {
/** Creates a new cookie-backed static asset session. */ /** Creates a new cookie-backed static asset session. */
createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession
/** Extends an existing session using its original TTL. */
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
/** Revokes every static asset session. */
revokeAll: () => StaticAssetSession[]
/** Revokes all static asset sessions for one extension. */
revokeByExtensionId: (extensionId: string) => StaticAssetSession[]
/** Revokes all static asset sessions owned by a plugin session. */
revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[]
/** Revokes one static asset session by id. */
revokeSession: (assetSessionId: string) => StaticAssetSession | undefined
/** Validates route and cookie data for a static asset request. */ /** Validates route and cookie data for a static asset request. */
validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult
/** Extends an existing session using its original TTL. */
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
/** Revokes one static asset session by id. */
revokeSession: (assetSessionId: string) => StaticAssetSession | undefined
/** Revokes all static asset sessions owned by a plugin session. */
revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[]
/** Revokes all static asset sessions for one extension. */
revokeByExtensionId: (extensionId: string) => StaticAssetSession[]
/** Revokes every static asset session. */
revokeAll: () => StaticAssetSession[]
} }
/** export type StaticAssetResolveResult
* Request data required to validate a cookie-backed static asset session. = | { ok: true, filePath: string, size: number, mtime: number }
*/ | { ok: false, error: HttpError }
export interface StaticAssetSessionValidateInput {
/** Static asset path being requested. */
assetPath: string
/** Opaque session id from the requested route. */
assetSessionId: string
/** Cookie value provided by the request, if any. */
cookieValue: string | undefined
/** Plugin extension id from the requested route. */
extensionId: string
/** Extension version from the requested route. */
version: string
}
/**
* Result of validating a cookie-backed static asset request.
*/
export type StaticAssetSessionValidationResult
= | { error: HttpError, ok: false }
| { ok: true, session: StaticAssetSession }
@@ -11,7 +11,7 @@ import { injeca } from 'injeca'
import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa' import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa'
export async function createI18nService(params: { context: ReturnType<typeof createContext>['context'], i18n: I18n, window: BrowserWindow }) { export async function createI18nService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow, i18n: I18n }) {
const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy<Config<typeof globalAppConfigSchema>> }) const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy<Config<typeof globalAppConfigSchema>> })
params.i18n.locale(config.get()?.language || 'en') params.i18n.locale(config.get()?.language || 'en')
@@ -48,11 +48,11 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => {
return { return {
StdioClientTransport: class { StdioClientTransport: class {
close = vi.fn(async () => undefined)
stderr = new PassThrough() stderr = new PassThrough()
constructor(readonly server: unknown) {} constructor(readonly server: unknown) {}
close = vi.fn(async () => undefined)
}, },
} }
}) })
@@ -76,10 +76,10 @@ describe('createMcpStdioManager', () => {
}) })
const result = await manager.testServer({ const result = await manager.testServer({
name: 'broken-server',
config: { config: {
command: 'broken-mcp-server', command: 'broken-mcp-server',
}, },
name: 'broken-server',
}) })
expect(result.ok).toBe(false) expect(result.ok).toBe(false)
@@ -36,23 +36,23 @@ import {
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config' import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
export interface McpStdioManager {
applyAndRestart: () => Promise<ElectronMcpStdioApplyResult>
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
ensureConfigFile: () => Promise<{ path: string }>
getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus
listTools: () => Promise<ElectronMcpToolDescriptor[]>
openConfigFile: () => Promise<{ path: string }>
readConfigText: () => Promise<ElectronMcpStdioConfigText>
stopAll: () => Promise<void>
testServer: (payload: ElectronMcpStdioTestPayload) => Promise<ElectronMcpStdioTestResult>
writeConfigText: (text: string) => Promise<ElectronMcpStdioConfigText>
}
interface McpServerSession { interface McpServerSession {
client: Client client: Client
config: ElectronMcpStdioServerConfig
transport: StdioClientTransport transport: StdioClientTransport
config: ElectronMcpStdioServerConfig
}
export interface McpStdioManager {
ensureConfigFile: () => Promise<{ path: string }>
openConfigFile: () => Promise<{ path: string }>
applyAndRestart: () => Promise<ElectronMcpStdioApplyResult>
listTools: () => Promise<ElectronMcpToolDescriptor[]>
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
stopAll: () => Promise<void>
getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus
readConfigText: () => Promise<ElectronMcpStdioConfigText>
writeConfigText: (text: string) => Promise<ElectronMcpStdioConfigText>
testServer: (payload: ElectronMcpStdioTestPayload) => Promise<ElectronMcpStdioTestResult>
} }
const defaultMcpConfig: ElectronMcpStdioConfigFile = { const defaultMcpConfig: ElectronMcpStdioConfigFile = {
@@ -63,38 +63,53 @@ const mcpRequestTimeoutMsec = 10_000
const mcpRequestMaxTotalTimeoutMsec = 15_000 const mcpRequestMaxTotalTimeoutMsec = 15_000
const mcpTestStderrMaxChars = 16_000 const mcpTestStderrMaxChars = 16_000
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) { function stringifyError(error: unknown) {
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { if (error instanceof Error) {
return params.manager.openConfigFile() return error.message
}) }
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { return String(error)
return params.manager.applyAndRestart() }
})
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { function getConfigPath() {
return params.manager.getRuntimeStatus() return join(app.getPath('userData'), 'mcp.json')
}) }
defineInvokeHandler(params.context, electronMcpListTools, async () => { function parseQualifiedToolName(name: string) {
return params.manager.listTools() const separatorIndex = name.indexOf(toolNameSeparator)
}) if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) {
throw new Error(`invalid qualified tool name: ${name}`)
}
defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => { return {
return params.manager.callTool(payload) serverName: name.slice(0, separatorIndex),
}) toolName: name.slice(separatorIndex + toolNameSeparator.length),
}
}
defineInvokeHandler(params.context, electronMcpReadConfigText, async () => { function resolveFallbackToolName(toolName: string): string | undefined {
return params.manager.readConfigText() const normalizedTransportPrefix = toolName
}) .replace(/^\.(?:stdio|stdo)::/, '')
.replace(/^(?:stdio|stdo)::/, '')
if (normalizedTransportPrefix !== toolName) {
return normalizedTransportPrefix
}
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => { const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
return params.manager.writeConfigText(payload.text) if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
}) return undefined
}
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => { return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
return params.manager.testServer(payload) }
})
async function closeSession(session: McpServerSession) {
try {
await session.client.close()
}
catch {
await session.transport.close()
}
} }
export function createMcpStdioManager(): McpStdioManager { export function createMcpStdioManager(): McpStdioManager {
@@ -138,11 +153,11 @@ export function createMcpStdioManager(): McpStdioManager {
for (const [name, session] of entries) { for (const [name, session] of entries) {
await closeSession(session) await closeSession(session)
setRuntimeStatus({ setRuntimeStatus({
args: session.config.args ?? [],
command: session.config.command,
name, name,
pid: null,
state: 'stopped', state: 'stopped',
command: session.config.command,
args: session.config.args ?? [],
pid: null,
}) })
sessions.delete(name) sessions.delete(name)
} }
@@ -150,10 +165,10 @@ export function createMcpStdioManager(): McpStdioManager {
const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => { const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => {
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
args: config.args ?? [],
command: config.command, command: config.command,
cwd: config.cwd, args: config.args ?? [],
env: config.env, env: config.env,
cwd: config.cwd,
stderr: 'pipe', stderr: 'pipe',
}) })
const client = new Client({ const client = new Client({
@@ -169,13 +184,13 @@ export function createMcpStdioManager(): McpStdioManager {
log.withFields({ serverName: name }).warn(text) log.withFields({ serverName: name }).warn(text)
} }
}) })
sessions.set(name, { client, config, transport }) sessions.set(name, { client, transport, config })
setRuntimeStatus({ setRuntimeStatus({
args: config.args ?? [],
command: config.command,
name, name,
pid: transport.pid,
state: 'running', state: 'running',
command: config.command,
args: config.args ?? [],
pid: transport.pid,
}) })
} }
catch (error) { catch (error) {
@@ -192,21 +207,21 @@ export function createMcpStdioManager(): McpStdioManager {
runtimeStatuses.clear() runtimeStatuses.clear()
const result: ElectronMcpStdioApplyResult = { const result: ElectronMcpStdioApplyResult = {
failed: [],
path, path,
skipped: [],
started: [], started: [],
failed: [],
skipped: [],
} }
for (const [name, server] of Object.entries(config.mcpServers)) { for (const [name, server] of Object.entries(config.mcpServers)) {
if (server.enabled === false) { if (server.enabled === false) {
result.skipped.push({ name, reason: 'disabled' }) result.skipped.push({ name, reason: 'disabled' })
setRuntimeStatus({ setRuntimeStatus({
args: server.args ?? [],
command: server.command,
name, name,
pid: null,
state: 'stopped', state: 'stopped',
command: server.command,
args: server.args ?? [],
pid: null,
}) })
continue continue
} }
@@ -217,14 +232,14 @@ export function createMcpStdioManager(): McpStdioManager {
} }
catch (error) { catch (error) {
const message = stringifyError(error) const message = stringifyError(error)
result.failed.push({ error: message, name }) result.failed.push({ name, error: message })
setRuntimeStatus({ setRuntimeStatus({
args: server.args ?? [],
command: server.command,
lastError: message,
name, name,
pid: null,
state: 'error', state: 'error',
command: server.command,
args: server.args ?? [],
pid: null,
lastError: message,
}) })
} }
} }
@@ -239,15 +254,15 @@ export function createMcpStdioManager(): McpStdioManager {
const listResult = await Promise.all(entries.map(async ([serverName, session]) => { const listResult = await Promise.all(entries.map(async ([serverName, session]) => {
try { try {
const response = await session.client.listTools(undefined, { const response = await session.client.listTools(undefined, {
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
timeout: mcpRequestTimeoutMsec, timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
}) })
return response.tools.map<ElectronMcpToolDescriptor>(item => ({ return response.tools.map<ElectronMcpToolDescriptor>(item => ({
serverName,
name: `${serverName}${toolNameSeparator}${item.name}`,
toolName: item.name,
description: item.description, description: item.description,
inputSchema: item.inputSchema, inputSchema: item.inputSchema,
name: `${serverName}${toolNameSeparator}${item.name}`,
serverName,
toolName: item.name,
})) }))
} }
catch (error) { catch (error) {
@@ -269,11 +284,11 @@ export function createMcpStdioManager(): McpStdioManager {
let result let result
try { try {
result = await session.client.callTool({ result = await session.client.callTool({
arguments: payload.arguments ?? {},
name: toolName, name: toolName,
arguments: payload.arguments ?? {},
}, undefined, { }, undefined, {
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
timeout: mcpRequestTimeoutMsec, timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
}) })
} }
catch (error) { catch (error) {
@@ -283,17 +298,17 @@ export function createMcpStdioManager(): McpStdioManager {
} }
log.withFields({ log.withFields({
fallbackToolName,
requestedToolName: toolName,
serverName, serverName,
requestedToolName: toolName,
fallbackToolName,
}).warn('retrying mcp tool call with normalized tool name') }).warn('retrying mcp tool call with normalized tool name')
result = await session.client.callTool({ result = await session.client.callTool({
arguments: payload.arguments ?? {},
name: fallbackToolName, name: fallbackToolName,
arguments: payload.arguments ?? {},
}, undefined, { }, undefined, {
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
timeout: mcpRequestTimeoutMsec, timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
}) })
} }
@@ -338,7 +353,7 @@ export function createMcpStdioManager(): McpStdioManager {
const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => { const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => {
const startedAt = Date.now() const startedAt = Date.now()
let transport: null | StdioClientTransport = null let transport: StdioClientTransport | null = null
let client: Client | null = null let client: Client | null = null
const stderrChunks: string[] = [] const stderrChunks: string[] = []
@@ -355,10 +370,10 @@ export function createMcpStdioManager(): McpStdioManager {
try { try {
transport = new StdioClientTransport({ transport = new StdioClientTransport({
args: payload.config.args ?? [],
command: payload.config.command, command: payload.config.command,
cwd: payload.config.cwd, args: payload.config.args ?? [],
env: payload.config.env, env: payload.config.env,
cwd: payload.config.cwd,
stderr: 'pipe', stderr: 'pipe',
}) })
client = new Client({ client = new Client({
@@ -375,8 +390,8 @@ export function createMcpStdioManager(): McpStdioManager {
await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect') await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect')
const response = await client.listTools(undefined, { const response = await client.listTools(undefined, {
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
timeout: mcpRequestTimeoutMsec, timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
}) })
if (stderrChunks.length > 0) { if (stderrChunks.length > 0) {
@@ -384,9 +399,9 @@ export function createMcpStdioManager(): McpStdioManager {
} }
return { return {
durationMs: Date.now() - startedAt,
ok: true, ok: true,
tools: response.tools.map(tool => tool.name), tools: response.tools.map(tool => tool.name),
durationMs: Date.now() - startedAt,
} }
} }
catch (error) { catch (error) {
@@ -394,9 +409,9 @@ export function createMcpStdioManager(): McpStdioManager {
// Keep only the tail so a noisy failed server cannot flood the settings UI. // Keep only the tail so a noisy failed server cannot flood the settings UI.
const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars) const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars)
return { return {
durationMs: Date.now() - startedAt,
error: stderr ? `${message}\n\n${stderr}` : message,
ok: false, ok: false,
error: stderr ? `${message}\n\n${stderr}` : message,
durationMs: Date.now() - startedAt,
} }
} }
finally { finally {
@@ -410,16 +425,16 @@ export function createMcpStdioManager(): McpStdioManager {
} }
return { return {
applyAndRestart,
callTool,
ensureConfigFile, ensureConfigFile,
getRuntimeStatus,
listTools,
openConfigFile, openConfigFile,
readConfigText, applyAndRestart,
listTools,
callTool,
stopAll, stopAll,
testServer, getRuntimeStatus,
readConfigText,
writeConfigText, writeConfigText,
testServer,
} }
} }
@@ -443,51 +458,36 @@ export async function setupMcpStdioManager() {
return manager return manager
} }
async function closeSession(session: McpServerSession) { export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
try { defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
await session.client.close() return params.manager.openConfigFile()
} })
catch {
await session.transport.close() defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
} return params.manager.applyAndRestart()
} })
function getConfigPath() { defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
return join(app.getPath('userData'), 'mcp.json') return params.manager.getRuntimeStatus()
} })
function parseQualifiedToolName(name: string) { defineInvokeHandler(params.context, electronMcpListTools, async () => {
const separatorIndex = name.indexOf(toolNameSeparator) return params.manager.listTools()
if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) { })
throw new Error(`invalid qualified tool name: ${name}`)
} defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => {
return params.manager.callTool(payload)
return { })
serverName: name.slice(0, separatorIndex),
toolName: name.slice(separatorIndex + toolNameSeparator.length), defineInvokeHandler(params.context, electronMcpReadConfigText, async () => {
} return params.manager.readConfigText()
} })
function resolveFallbackToolName(toolName: string): string | undefined { defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
const normalizedTransportPrefix = toolName return params.manager.writeConfigText(payload.text)
.replace(/^\.(?:stdio|stdo)::/, '') })
.replace(/^(?:stdio|stdo)::/, '')
if (normalizedTransportPrefix !== toolName) { defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
return normalizedTransportPrefix return params.manager.testServer(payload)
} })
const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
return undefined
}
return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
}
function stringifyError(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
} }
@@ -14,8 +14,8 @@ const ANIMATION_DURATION = 350
export function createOnboardingService(params: { export function createOnboardingService(params: {
context: ReturnType<typeof createContext>['context'] context: ReturnType<typeof createContext>['context']
mainWindow: BrowserWindow
onboardingWindowManager: OnboardingWindowManager onboardingWindowManager: OnboardingWindowManager
mainWindow: BrowserWindow
}) { }) {
const mainWindowAnimator = new Animator(params.mainWindow) const mainWindowAnimator = new Animator(params.mainWindow)
let cleanupOnClosed: (() => void) | undefined let cleanupOnClosed: (() => void) | undefined
@@ -29,15 +29,15 @@ export function createOnboardingService(params: {
const adjacent = computeAdjacentPosition( const adjacent = computeAdjacentPosition(
onboardingBounds, onboardingBounds,
{ height: savedBounds.height, width: savedBounds.width }, { width: savedBounds.width, height: savedBounds.height },
display.workArea, display.workArea,
) )
mainWindowAnimator.windowBoundsAnimateTo({ mainWindowAnimator.windowBoundsAnimateTo({
height: adjacent.height,
width: adjacent.width,
x: adjacent.x, x: adjacent.x,
y: adjacent.y, y: adjacent.y,
width: adjacent.width,
height: adjacent.height,
}, { duration: ANIMATION_DURATION }) }, { duration: ANIMATION_DURATION })
let userMovedManually = false let userMovedManually = false
@@ -24,12 +24,12 @@ import { manifestIdOf } from '../../host/registry'
* - N/A * - N/A
*/ */
export interface ExtensionAutoReloadFeatureOptions { export interface ExtensionAutoReloadFeatureOptions {
getConfig: () => ExtensionConfig
isLoaded: (extensionId: string) => boolean
listEntries: () => ManifestEntry[]
log: ReturnType<typeof useLogg> log: ReturnType<typeof useLogg>
reload: (extensionId: string, changedPath: string) => Promise<void> getConfig: () => ExtensionConfig
listEntries: () => ManifestEntry[]
isLoaded: (extensionId: string) => boolean
resolveWatchPaths: (extensionId: string) => string[] resolveWatchPaths: (extensionId: string) => string[]
reload: (extensionId: string, changedPath: string) => Promise<void>
} }
/** /**
@@ -102,21 +102,6 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea
} }
return { return {
clearExtension(extensionId: string) {
clearTimer(extensionId)
closeWatchers(extensionId)
},
dispose() {
const managedNames = new Set([
...autoReloadTimers.keys(),
...autoReloadWatchers.keys(),
])
for (const extensionId of managedNames) {
clearTimer(extensionId)
closeWatchers(extensionId)
}
},
sync() { sync() {
const enabledExtensionIds = new Set(options.getConfig().autoReload) const enabledExtensionIds = new Set(options.getConfig().autoReload)
const desiredExtensionIds = new Set(options.listEntries() const desiredExtensionIds = new Set(options.listEntries()
@@ -159,5 +144,20 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea
} }
} }
}, },
clearExtension(extensionId: string) {
clearTimer(extensionId)
closeWatchers(extensionId)
},
dispose() {
const managedNames = new Set([
...autoReloadTimers.keys(),
...autoReloadWatchers.keys(),
])
for (const extensionId of managedNames) {
clearTimer(extensionId)
closeWatchers(extensionId)
}
},
} }
} }
@@ -14,51 +14,51 @@ vi.mock('../../../http-server/static-assets', () => ({
createStaticAssetService: mockState.createStaticAssetService, createStaticAssetService: mockState.createStaticAssetService,
})) }))
function createFakeCookieAdapter() { function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession {
const setCookies: ExtensionAssetCookie[] = []
const removedCookies: ExtensionAssetCookie[] = []
return { return {
adapter: { assetSessionId,
removeCookie: vi.fn(async (cookie) => { cookieName: `airi_extension_asset_session_${assetSessionId}`,
removedCookies.push(cookie) cookieValue: `cookie-value-${assetSessionId}`,
}), cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`,
setCookie: vi.fn(async (cookie) => { expiresAt: 123_456,
setCookies.push(cookie)
}),
} satisfies ExtensionAssetCookieAdapter,
removedCookies,
setCookies,
} }
} }
function createFakeServer(options: { function createFakeServer(options: {
baseUrl?: string baseUrl?: string
createSessionResult?: StaticAssetSession createSessionResult?: StaticAssetSession
revokeAllResult?: StaticAssetSession[]
revokeByExtensionIdResult?: StaticAssetSession[]
revokeByOwnerSessionIdResult?: StaticAssetSession[] revokeByOwnerSessionIdResult?: StaticAssetSession[]
revokeByExtensionIdResult?: StaticAssetSession[]
revokeAllResult?: StaticAssetSession[]
} = {}) { } = {}) {
return { return {
createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')),
getBaseUrl: vi.fn(() => options.baseUrl),
key: 'static-assets', key: 'static-assets',
revokeAll: vi.fn(() => options.revokeAllResult ?? []),
revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []),
revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []),
revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)),
start: vi.fn(async () => {}), start: vi.fn(async () => {}),
stop: vi.fn(async () => {}), stop: vi.fn(async () => {}),
getBaseUrl: vi.fn(() => options.baseUrl),
createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')),
revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)),
revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []),
revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []),
revokeAll: vi.fn(() => options.revokeAllResult ?? []),
} satisfies StaticAssetService } satisfies StaticAssetService
} }
function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession { function createFakeCookieAdapter() {
const setCookies: ExtensionAssetCookie[] = []
const removedCookies: ExtensionAssetCookie[] = []
return { return {
assetSessionId, adapter: {
cookieName: `airi_extension_asset_session_${assetSessionId}`, setCookie: vi.fn(async (cookie) => {
cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`, setCookies.push(cookie)
cookieValue: `cookie-value-${assetSessionId}`, }),
expiresAt: 123_456, removeCookie: vi.fn(async (cookie) => {
removedCookies.push(cookie)
}),
} satisfies ExtensionAssetCookieAdapter,
removedCookies,
setCookies,
} }
} }
@@ -76,41 +76,41 @@ describe('createExtensionAssetService', () => {
mockState.createStaticAssetService.mockReturnValue(server) mockState.createStaticAssetService.mockReturnValue(server)
const service = createExtensionAssetService({ const service = createExtensionAssetService({
cookieAdapter: adapter,
getManifestEntryByExtensionId: () => new Map(), getManifestEntryByExtensionId: () => new Map(),
cookieAdapter: adapter,
}) })
const result = await service.createAssetSession({ const result = await service.createAssetSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
ownerSessionId: 'owner-session-1',
pathPrefix: 'assets/',
routeAssetPath: 'assets/app.js',
ttlMs: 60_000,
version: '1.0.0', version: '1.0.0',
ownerSessionId: 'owner-session-1',
routeAssetPath: 'assets/app.js',
pathPrefix: 'assets/',
ttlMs: 60_000,
}) })
expect(server.createSession).toHaveBeenCalledWith({ expect(server.createSession).toHaveBeenCalledWith({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
version: '1.0.0',
ownerSessionId: 'owner-session-1', ownerSessionId: 'owner-session-1',
pathPrefix: 'assets/', pathPrefix: 'assets/',
ttlMs: 60_000, ttlMs: 60_000,
version: '1.0.0',
}) })
expect(adapter.setCookie).toHaveBeenCalledOnce() expect(adapter.setCookie).toHaveBeenCalledOnce()
expect(setCookies).toEqual([ expect(setCookies).toEqual([
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_asset-session-1', name: 'airi_extension_asset_session_asset-session-1',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
value: 'cookie-value-asset-session-1', value: 'cookie-value-asset-session-1',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
expiresAt: 123_456,
}, },
]) ])
expect(result).toEqual({ expect(result).toEqual({
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js',
assetSessionId: 'asset-session-1', assetSessionId: 'asset-session-1',
cookie: setCookies[0], cookie: setCookies[0],
expiresAt: 123_456, expiresAt: 123_456,
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js',
}) })
}) })
@@ -123,17 +123,17 @@ describe('createExtensionAssetService', () => {
mockState.createStaticAssetService.mockReturnValue(server) mockState.createStaticAssetService.mockReturnValue(server)
const service = createExtensionAssetService({ const service = createExtensionAssetService({
cookieAdapter: adapter,
getManifestEntryByExtensionId: () => new Map(), getManifestEntryByExtensionId: () => new Map(),
cookieAdapter: adapter,
}) })
await expect(service.createAssetSession({ await expect(service.createAssetSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
ownerSessionId: 'owner-session-1',
pathPrefix: 'assets/',
routeAssetPath: 'assets/app.js',
ttlMs: 60_000,
version: '1.0.0', version: '1.0.0',
ownerSessionId: 'owner-session-1',
routeAssetPath: 'assets/app.js',
pathPrefix: 'assets/',
ttlMs: 60_000,
})).rejects.toThrow('Extension asset server base URL is unavailable') })).rejects.toThrow('Extension asset server base URL is unavailable')
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2')
@@ -149,17 +149,17 @@ describe('createExtensionAssetService', () => {
const { adapter } = createFakeCookieAdapter() const { adapter } = createFakeCookieAdapter()
mockState.createStaticAssetService.mockReturnValue(server) mockState.createStaticAssetService.mockReturnValue(server)
const service = createExtensionAssetService({ const service = createExtensionAssetService({
cookieAdapter: adapter,
getManifestEntryByExtensionId: () => new Map(), getManifestEntryByExtensionId: () => new Map(),
cookieAdapter: adapter,
}) })
await expect(service.createAssetSession({ await expect(service.createAssetSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
ownerSessionId: 'owner-session-1',
pathPrefix: '',
routeAssetPath: '../secret.txt',
ttlMs: 60_000,
version: '1.0.0', version: '1.0.0',
ownerSessionId: 'owner-session-1',
routeAssetPath: '../secret.txt',
pathPrefix: '',
ttlMs: 60_000,
})).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path') })).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path')
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3')
@@ -170,11 +170,11 @@ describe('createExtensionAssetService', () => {
await expect(service.createAssetSession({ await expect(service.createAssetSession({
extensionId: 'airi-plugin-game-chess', extensionId: 'airi-plugin-game-chess',
ownerSessionId: 'owner-session-1',
pathPrefix: 'assets/',
routeAssetPath: 'assets/app.js',
ttlMs: 60_000,
version: '1.0.0', version: '1.0.0',
ownerSessionId: 'owner-session-1',
routeAssetPath: 'assets/app.js',
pathPrefix: 'assets/',
ttlMs: 60_000,
})).rejects.toThrow('cookie jar unavailable') })).rejects.toThrow('cookie jar unavailable')
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4') expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4')
@@ -187,17 +187,17 @@ describe('createExtensionAssetService', () => {
const allSession = createSession('all-asset-session') const allSession = createSession('all-asset-session')
const server = createFakeServer({ const server = createFakeServer({
baseUrl: 'http://127.0.0.1:48123', baseUrl: 'http://127.0.0.1:48123',
revokeAllResult: [allSession],
revokeByExtensionIdResult: [pluginSession],
revokeByOwnerSessionIdResult: [ownerSession], revokeByOwnerSessionIdResult: [ownerSession],
revokeByExtensionIdResult: [pluginSession],
revokeAllResult: [allSession],
}) })
server.revokeSession.mockReturnValue(directSession) server.revokeSession.mockReturnValue(directSession)
const { adapter, removedCookies } = createFakeCookieAdapter() const { adapter, removedCookies } = createFakeCookieAdapter()
mockState.createStaticAssetService.mockReturnValue(server) mockState.createStaticAssetService.mockReturnValue(server)
const service = createExtensionAssetService({ const service = createExtensionAssetService({
cookieAdapter: adapter,
getManifestEntryByExtensionId: () => new Map(), getManifestEntryByExtensionId: () => new Map(),
cookieAdapter: adapter,
}) })
await service.revokeSession('direct-asset-session') await service.revokeSession('direct-asset-session')
@@ -212,32 +212,32 @@ describe('createExtensionAssetService', () => {
expect(adapter.removeCookie).toHaveBeenCalledTimes(4) expect(adapter.removeCookie).toHaveBeenCalledTimes(4)
expect(removedCookies).toEqual([ expect(removedCookies).toEqual([
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_direct-asset-session', name: 'airi_extension_asset_session_direct-asset-session',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
value: 'cookie-value-direct-asset-session', value: 'cookie-value-direct-asset-session',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
expiresAt: 123_456,
}, },
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_owner-asset-session', name: 'airi_extension_asset_session_owner-asset-session',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
value: 'cookie-value-owner-asset-session', value: 'cookie-value-owner-asset-session',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
expiresAt: 123_456,
}, },
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_plugin-asset-session', name: 'airi_extension_asset_session_plugin-asset-session',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
value: 'cookie-value-plugin-asset-session', value: 'cookie-value-plugin-asset-session',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
expiresAt: 123_456,
}, },
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_all-asset-session', name: 'airi_extension_asset_session_all-asset-session',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
value: 'cookie-value-all-asset-session', value: 'cookie-value-all-asset-session',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
expiresAt: 123_456,
}, },
]) ])
}) })
@@ -252,8 +252,8 @@ describe('createExtensionAssetService', () => {
mockState.createStaticAssetService.mockReturnValue(server) mockState.createStaticAssetService.mockReturnValue(server)
const service = createExtensionAssetService({ const service = createExtensionAssetService({
cookieAdapter: adapter,
getManifestEntryByExtensionId: () => new Map(), getManifestEntryByExtensionId: () => new Map(),
cookieAdapter: adapter,
}) })
await service.stop() await service.stop()
@@ -263,11 +263,11 @@ describe('createExtensionAssetService', () => {
expect(server.stop).toHaveBeenCalledOnce() expect(server.stop).toHaveBeenCalledOnce()
expect(removedCookies).toEqual([ expect(removedCookies).toEqual([
{ {
expiresAt: 123_456,
name: 'airi_extension_asset_session_stop-asset-session', name: 'airi_extension_asset_session_stop-asset-session',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
value: 'cookie-value-stop-asset-session', value: 'cookie-value-stop-asset-session',
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
expiresAt: 123_456,
}, },
]) ])
}) })
@@ -5,98 +5,6 @@ import type { StaticAssetSession } from '../../../http-server/static-assets/type
import { createStaticAssetService } from '../../../http-server/static-assets' import { createStaticAssetService } from '../../../http-server/static-assets'
import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths' import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths'
/**
* Describes the cookie material Electron must apply before loading an extension asset URL.
*
* Use when:
* - Main process bridges server-side asset sessions into Electron's cookie jar
* - Revocation needs enough cookie identity to remove previously issued asset cookies
*
* Expects:
* - `url` belongs to the local asset server origin
* - `path` matches the server-issued cookie path for the asset session route
*
* Returns:
* - N/A
*/
export interface ExtensionAssetCookie {
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
expiresAt: number
/** Cookie name generated for the asset session. */
name: string
/** Route path scope generated by the asset session store. */
path: string
/** Absolute URL on the local asset server used by Electron cookie APIs. */
url: string
/** Opaque cookie value required by the static asset route. */
value: string
}
/**
* Applies and removes extension asset cookies from the Electron host.
*
* Use when:
* - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL
* - Asset session revocation must remove browser-visible cookie state
*
* Expects:
* - `setCookie` resolves only after Electron can send the cookie for matching asset URLs
* - `removeCookie` is idempotent for already-removed cookies
*
* Returns:
* - N/A
*/
export interface ExtensionAssetCookieAdapter {
removeCookie: (cookie: ExtensionAssetCookie) => Promise<void>
setCookie: (cookie: ExtensionAssetCookie) => Promise<void>
}
/**
* Defines the extension-owned asset hosting service used by the extension host.
*
* Use when:
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
* - Host teardown must revoke extension asset access independently from widget/gamelet logic
*
* Expects:
* - Implementations own the underlying transport, cookie, and session lifecycle
*
* Returns:
* - A startable/stoppable asset-hosting service with generic extension-facing methods
*/
export interface ExtensionAssetService extends ServerManager {
createAssetSession: (input: ExtensionAssetSessionInput) => Promise<ExtensionAssetSession>
getBaseUrl: () => string | undefined
revokeAll: () => Promise<void>
revokeByExtensionId: (extensionId: string) => Promise<void>
revokeByOwnerSessionId: (ownerSessionId: string) => Promise<void>
revokeSession: (assetSessionId: string) => Promise<void>
}
/**
* Describes an extension asset session prepared for renderer iframe navigation.
*
* Use when:
* - A plugin iframe needs a mounted static asset URL and pre-applied cookie state
* - Callers need the opaque session id for later targeted revocation
*
* Expects:
* - `cookie` was set through the host adapter before the value is returned
*
* Returns:
* - Renderer-facing URL plus server and cookie metadata
*/
export interface ExtensionAssetSession {
/** Opaque server-side asset session id embedded in mounted asset routes. */
assetSessionId: string
/** Cookie data that was applied through the host adapter. */
cookie: ExtensionAssetCookie
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
expiresAt: number
/** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */
url: string
}
/** /**
* Describes one extension asset session creation request. * Describes one extension asset session creation request.
* *
@@ -115,16 +23,62 @@ export interface ExtensionAssetSession {
export interface ExtensionAssetSessionInput { export interface ExtensionAssetSessionInput {
/** Extension manifest id that owns the static asset root. */ /** Extension manifest id that owns the static asset root. */
extensionId: string extensionId: string
/** Parent extension session id used for owner-scoped revocation. */
ownerSessionId: string
/** Allowed asset path prefix enforced by the server-side session store. */
pathPrefix: string
/** Asset path to mount in the returned renderer-facing URL. */
routeAssetPath: string
/** Session lifetime in milliseconds from creation or refresh time. */
ttlMs: number
/** Extension/plugin version expected by the server-side session validator. */ /** Extension/plugin version expected by the server-side session validator. */
version: string version: string
/** Parent extension session id used for owner-scoped revocation. */
ownerSessionId: string
/** Asset path to mount in the returned renderer-facing URL. */
routeAssetPath: string
/** Allowed asset path prefix enforced by the server-side session store. */
pathPrefix: string
/** Session lifetime in milliseconds from creation or refresh time. */
ttlMs: number
}
/**
* Describes the cookie material Electron must apply before loading an extension asset URL.
*
* Use when:
* - Main process bridges server-side asset sessions into Electron's cookie jar
* - Revocation needs enough cookie identity to remove previously issued asset cookies
*
* Expects:
* - `url` belongs to the local asset server origin
* - `path` matches the server-issued cookie path for the asset session route
*
* Returns:
* - N/A
*/
export interface ExtensionAssetCookie {
/** Cookie name generated for the asset session. */
name: string
/** Opaque cookie value required by the static asset route. */
value: string
/** Absolute URL on the local asset server used by Electron cookie APIs. */
url: string
/** Route path scope generated by the asset session store. */
path: string
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
expiresAt: number
}
/**
* Applies and removes extension asset cookies from the Electron host.
*
* Use when:
* - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL
* - Asset session revocation must remove browser-visible cookie state
*
* Expects:
* - `setCookie` resolves only after Electron can send the cookie for matching asset URLs
* - `removeCookie` is idempotent for already-removed cookies
*
* Returns:
* - N/A
*/
export interface ExtensionAssetCookieAdapter {
setCookie: (cookie: ExtensionAssetCookie) => Promise<void>
removeCookie: (cookie: ExtensionAssetCookie) => Promise<void>
} }
/** /**
@@ -142,8 +96,64 @@ export interface ExtensionAssetSessionInput {
* - A mounted asset URL and cookie-backed session metadata * - A mounted asset URL and cookie-backed session metadata
*/ */
export interface ExtensionAssetSnapshotService { export interface ExtensionAssetSnapshotService {
createAssetSession: (input: Omit<ExtensionAssetSessionInput, 'ttlMs'>) => Promise<ExtensionAssetSession>
getBaseUrl: () => string | undefined getBaseUrl: () => string | undefined
createAssetSession: (input: Omit<ExtensionAssetSessionInput, 'ttlMs'>) => Promise<ExtensionAssetSession>
}
/**
* Describes an extension asset session prepared for renderer iframe navigation.
*
* Use when:
* - A plugin iframe needs a mounted static asset URL and pre-applied cookie state
* - Callers need the opaque session id for later targeted revocation
*
* Expects:
* - `cookie` was set through the host adapter before the value is returned
*
* Returns:
* - Renderer-facing URL plus server and cookie metadata
*/
export interface ExtensionAssetSession {
/** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */
url: string
/** Opaque server-side asset session id embedded in mounted asset routes. */
assetSessionId: string
/** Cookie data that was applied through the host adapter. */
cookie: ExtensionAssetCookie
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
expiresAt: number
}
/**
* Defines the extension-owned asset hosting service used by the extension host.
*
* Use when:
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
* - Host teardown must revoke extension asset access independently from widget/gamelet logic
*
* Expects:
* - Implementations own the underlying transport, cookie, and session lifecycle
*
* Returns:
* - A startable/stoppable asset-hosting service with generic extension-facing methods
*/
export interface ExtensionAssetService extends ServerManager {
getBaseUrl: () => string | undefined
createAssetSession: (input: ExtensionAssetSessionInput) => Promise<ExtensionAssetSession>
revokeSession: (assetSessionId: string) => Promise<void>
revokeByOwnerSessionId: (ownerSessionId: string) => Promise<void>
revokeByExtensionId: (extensionId: string) => Promise<void>
revokeAll: () => Promise<void>
}
function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie {
return {
name: session.cookieName,
value: session.cookieValue,
url: new URL(session.cookiePath, baseUrl).toString(),
path: session.cookiePath,
expiresAt: session.expiresAt,
}
} }
/** /**
@@ -161,8 +171,8 @@ export interface ExtensionAssetSnapshotService {
* - An extension-facing asset host service with generic extension asset methods * - An extension-facing asset host service with generic extension asset methods
*/ */
export function createExtensionAssetService(options: { export function createExtensionAssetService(options: {
cookieAdapter: ExtensionAssetCookieAdapter
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry> getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
cookieAdapter: ExtensionAssetCookieAdapter
}): ExtensionAssetService { }): ExtensionAssetService {
const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId }) const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId })
let lastBaseUrl: string | undefined let lastBaseUrl: string | undefined
@@ -185,13 +195,24 @@ export function createExtensionAssetService(options: {
} }
return { return {
key: 'extension-assets',
async start() {
await server.start()
},
async stop() {
await revokeSessions(server.revokeAll())
await server.stop()
},
getBaseUrl() {
return readBaseUrl()
},
async createAssetSession(input) { async createAssetSession(input) {
const session = server.createSession({ const session = server.createSession({
extensionId: input.extensionId, extensionId: input.extensionId,
version: input.version,
ownerSessionId: input.ownerSessionId, ownerSessionId: input.ownerSessionId,
pathPrefix: input.pathPrefix, pathPrefix: input.pathPrefix,
ttlMs: input.ttlMs, ttlMs: input.ttlMs,
version: input.version,
}) })
try { try {
@@ -201,9 +222,9 @@ export function createExtensionAssetService(options: {
} }
const mountedPath = buildMountedStaticAssetPath({ const mountedPath = buildMountedStaticAssetPath({
assetPath: input.routeAssetPath,
assetSessionId: session.assetSessionId,
extensionId: input.extensionId, extensionId: input.extensionId,
assetSessionId: session.assetSessionId,
assetPath: input.routeAssetPath,
}) })
if (!mountedPath) { if (!mountedPath) {
@@ -214,10 +235,10 @@ export function createExtensionAssetService(options: {
await options.cookieAdapter.setCookie(cookie) await options.cookieAdapter.setCookie(cookie)
return { return {
url: new URL(mountedPath, baseUrl).toString(),
assetSessionId: session.assetSessionId, assetSessionId: session.assetSessionId,
cookie, cookie,
expiresAt: session.expiresAt, expiresAt: session.expiresAt,
url: new URL(mountedPath, baseUrl).toString(),
} }
} }
catch (error) { catch (error) {
@@ -225,19 +246,6 @@ export function createExtensionAssetService(options: {
throw error throw error
} }
}, },
getBaseUrl() {
return readBaseUrl()
},
key: 'extension-assets',
async revokeAll() {
await revokeSessions(server.revokeAll())
},
async revokeByExtensionId(extensionId) {
await revokeSessions(server.revokeByExtensionId(extensionId))
},
async revokeByOwnerSessionId(ownerSessionId) {
await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
},
async revokeSession(assetSessionId) { async revokeSession(assetSessionId) {
const session = server.revokeSession(assetSessionId) const session = server.revokeSession(assetSessionId)
if (!session) { if (!session) {
@@ -246,22 +254,14 @@ export function createExtensionAssetService(options: {
await revokeSessions([session]) await revokeSessions([session])
}, },
async start() { async revokeByOwnerSessionId(ownerSessionId) {
await server.start() await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
}, },
async stop() { async revokeByExtensionId(extensionId) {
await revokeSessions(server.revokeByExtensionId(extensionId))
},
async revokeAll() {
await revokeSessions(server.revokeAll()) await revokeSessions(server.revokeAll())
await server.stop()
}, },
} }
} }
function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie {
return {
expiresAt: session.expiresAt,
name: session.cookieName,
path: session.cookiePath,
url: new URL(session.cookiePath, baseUrl).toString(),
value: session.cookieValue,
}
}
@@ -5,13 +5,21 @@ import { array, object, record, string } from 'valibot'
import { createConfig } from '../../../../libs/electron/persistence' import { createConfig } from '../../../../libs/electron/persistence'
const extensionConfigSchema = object({ const extensionConfigSchema = object({
autoReload: array(string()),
enabled: array(string()), enabled: array(string()),
autoReload: array(string()),
known: record(string(), object({ known: record(string(), object({
path: string(), path: string(),
})), })),
}) })
function createDefaultExtensionConfig(): ExtensionConfig {
return {
enabled: [],
autoReload: [],
known: {},
}
}
/** /**
* Persists extension host enablement and discovery metadata. * Persists extension host enablement and discovery metadata.
* *
@@ -27,8 +35,8 @@ const extensionConfigSchema = object({
* - Accessors around the persisted extension config document * - Accessors around the persisted extension config document
*/ */
export interface ExtensionHostConfigStore { export interface ExtensionHostConfigStore {
get: () => ExtensionConfig
setup: () => void setup: () => void
get: () => ExtensionConfig
update: (config: ExtensionConfig) => void update: (config: ExtensionConfig) => void
} }
@@ -46,27 +54,19 @@ export interface ExtensionHostConfigStore {
*/ */
export function createExtensionHostConfigStore(): ExtensionHostConfigStore { export function createExtensionHostConfigStore(): ExtensionHostConfigStore {
const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, { const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, {
autoHeal: true,
default: createDefaultExtensionConfig(), default: createDefaultExtensionConfig(),
autoHeal: true,
}) })
return { return {
get() {
return extensionConfig.get() ?? createDefaultExtensionConfig()
},
setup() { setup() {
extensionConfig.setup() extensionConfig.setup()
}, },
get() {
return extensionConfig.get() ?? createDefaultExtensionConfig()
},
update(config) { update(config) {
extensionConfig.update(config) extensionConfig.update(config)
}, },
} }
} }
function createDefaultExtensionConfig(): ExtensionConfig {
return {
autoReload: [],
enabled: [],
known: {},
}
}
@@ -25,13 +25,13 @@ import { buildPluginRegistrySnapshot } from './registry'
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities * - A full debug snapshot with registry, sessions, kits, modules, and capabilities
*/ */
export function buildPluginHostDebugSnapshot(options: { export function buildPluginHostDebugSnapshot(options: {
config: ExtensionConfig
entries: ManifestEntry[]
extensionAssetService?: ExtensionAssetSnapshotService
extensionsRoot: string
host: ExtensionHost host: ExtensionHost
extensionsRoot: string
entries: ManifestEntry[]
config: ExtensionConfig
loaded: Set<string> loaded: Set<string>
manifestEntryByExtensionId: Map<string, ManifestEntry> manifestEntryByExtensionId: Map<string, ManifestEntry>
extensionAssetService?: ExtensionAssetSnapshotService
}): Promise<PluginHostDebugSnapshot> { }): Promise<PluginHostDebugSnapshot> {
const extensionAssetService = options.extensionAssetService const extensionAssetService = options.extensionAssetService
const modules = Promise.all(options.host const modules = Promise.all(options.host
@@ -44,18 +44,18 @@ export function buildPluginHostDebugSnapshot(options: {
extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(), extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(),
...(extensionAssetService ...(extensionAssetService
? { ? {
createAssetSession: ({ extensionId, routeAssetPath, sessionId, sessionPathPrefix, version }: { createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: {
extensionId: string extensionId: string
routeAssetPath: string
sessionId: string
sessionPathPrefix: string
version: string version: string
sessionId: string
routeAssetPath: string
sessionPathPrefix: string
}) => extensionAssetService.createAssetSession({ }) => extensionAssetService.createAssetSession({
extensionId, extensionId,
ownerSessionId: sessionId,
pathPrefix: sessionPathPrefix,
routeAssetPath,
version, version,
ownerSessionId: sessionId,
routeAssetPath,
pathPrefix: sessionPathPrefix,
}), }),
} }
: {}), : {}),
@@ -64,22 +64,22 @@ export function buildPluginHostDebugSnapshot(options: {
)) ))
return modules.then(resolvedModules => ({ return modules.then(resolvedModules => ({
capabilities: options.host.listCapabilities(),
kits: options.host.listKits(),
modules: resolvedModules,
refreshedAt: Date.now(),
registry: buildPluginRegistrySnapshot({ registry: buildPluginRegistrySnapshot({
config: options.config,
entries: options.entries,
extensionsRoot: options.extensionsRoot, extensionsRoot: options.extensionsRoot,
entries: options.entries,
config: options.config,
loaded: options.loaded, loaded: options.loaded,
}), }),
sessions: options.host.listSessions().map(session => ({ sessions: options.host.listSessions().map(session => ({
extensionId: session.manifest.id,
id: session.id, id: session.id,
moduleId: session.extension.id, extensionId: session.manifest.id,
phase: session.phase, phase: session.phase,
runtime: session.runtime ?? 'electron', runtime: session.runtime ?? 'electron',
moduleId: session.extension.id,
})), })),
kits: options.host.listKits(),
modules: resolvedModules,
capabilities: options.host.listCapabilities(),
refreshedAt: Date.now(),
})) }))
} }
@@ -32,6 +32,26 @@ import {
const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000 const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
function createElectronExtensionAssetCookieAdapter() {
return {
async setCookie(cookie: ExtensionAssetCookie) {
await electronSession.defaultSession.cookies.set({
url: cookie.url,
name: cookie.name,
value: cookie.value,
path: cookie.path,
httpOnly: true,
sameSite: 'no_restriction',
secure: true,
expirationDate: Math.floor(cookie.expiresAt / 1000),
})
},
async removeCookie(cookie: ExtensionAssetCookie) {
await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name)
},
}
}
/** /**
* Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade. * Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade.
* *
@@ -47,50 +67,8 @@ const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
* - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose * - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose
*/ */
export interface ExtensionHostServiceInternal extends ExtensionHostService { export interface ExtensionHostServiceInternal extends ExtensionHostService {
/** /** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
* Disposes optional host features and asset hosting resources. tools: TamagotchiToolRegistry
*
* Use when:
* - Electron shutdown needs to stop extension-owned background work
* - Tests need to release watchers and local asset servers deterministically
*
* Expects:
* - Disposal may be called after partial startup or after prior plugin failures
*
* Returns:
* - A promise that resolves after feature and asset cleanup finish
*/
dispose: () => Promise<void>
/**
* Returns the mounted base URL for plugin-served assets.
*
* Use when:
* - Renderer code needs to construct extension asset URLs
* - Snapshot consumers need the current loopback asset mount base
*
* Expects:
* - The extension asset service may be started before this is called
*
* Returns:
* - The current extension asset base URL, or an empty string when unavailable
*/
getAssetBaseUrl: () => string
/**
* Builds the full extension host debug snapshot.
*
* Use when:
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
* - Host debugging needs a fresh runtime snapshot after registry refresh
*
* Expects:
* - The host and extension asset service are both initialized
*
* Returns:
* - The full debug snapshot exposed through plugin inspection IPC
*/
inspect: () => Promise<PluginHostDebugSnapshot>
/** /**
* Lists the current extension registry snapshot. * Lists the current extension registry snapshot.
@@ -108,19 +86,35 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
list: () => Promise<PluginRegistrySnapshot> list: () => Promise<PluginRegistrySnapshot>
/** /**
* Loads one extension by manifest id. * Persists whether one plugin is enabled.
* *
* Use when: * Use when:
* - Renderer explicitly requests one plugin to start * - Renderer controls toggle plugin enablement
* - Host features need to restart a plugin after manifest or entrypoint changes * - Host state must remember a known manifest path for a plugin name
* *
* Expects: * Expects:
* - `extensionId` resolves to a manifest entry in the current registry * - `payload.extensionId` matches a discovered or previously known extension
* - `payload.path` is only needed when the manifest is not currently discoverable
* *
* Returns: * Returns:
* - The extension registry snapshot after the load completes * - The updated extension registry snapshot after persistence
*/ */
load: (extensionId: string) => Promise<PluginRegistrySnapshot> setEnabled: (payload: { extensionId: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
/**
* Persists whether one loaded plugin should use auto-reload.
*
* Use when:
* - Renderer controls toggle plugin file watching during development
* - Host features need to resync optional watcher state after config changes
*
* Expects:
* - `payload.extensionId` matches one extension entry in config or discovery state
*
* Returns:
* - The updated extension registry snapshot after persistence
*/
setAutoReload: (payload: { extensionId: string, enabled: boolean }) => Promise<PluginRegistrySnapshot>
/** /**
* Loads every plugin currently marked as enabled. * Loads every plugin currently marked as enabled.
@@ -138,38 +132,19 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
loadEnabled: () => Promise<PluginRegistrySnapshot> loadEnabled: () => Promise<PluginRegistrySnapshot>
/** /**
* Persists whether one loaded plugin should use auto-reload. * Loads one extension by manifest id.
* *
* Use when: * Use when:
* - Renderer controls toggle plugin file watching during development * - Renderer explicitly requests one plugin to start
* - Host features need to resync optional watcher state after config changes * - Host features need to restart a plugin after manifest or entrypoint changes
* *
* Expects: * Expects:
* - `payload.extensionId` matches one extension entry in config or discovery state * - `extensionId` resolves to a manifest entry in the current registry
* *
* Returns: * Returns:
* - The updated extension registry snapshot after persistence * - The extension registry snapshot after the load completes
*/ */
setAutoReload: (payload: { enabled: boolean, extensionId: string }) => Promise<PluginRegistrySnapshot> load: (extensionId: string) => Promise<PluginRegistrySnapshot>
/**
* Persists whether one plugin is enabled.
*
* Use when:
* - Renderer controls toggle plugin enablement
* - Host state must remember a known manifest path for a plugin name
*
* Expects:
* - `payload.extensionId` matches a discovered or previously known extension
* - `payload.path` is only needed when the manifest is not currently discoverable
*
* Returns:
* - The updated extension registry snapshot after persistence
*/
setEnabled: (payload: { enabled: boolean, extensionId: string, path?: string }) => Promise<PluginRegistrySnapshot>
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
tools: TamagotchiToolRegistry
/** /**
* Stops one loaded extension by manifest id. * Stops one loaded extension by manifest id.
@@ -185,6 +160,51 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
* - The extension registry snapshot after unload bookkeeping completes * - The extension registry snapshot after unload bookkeeping completes
*/ */
unload: (extensionId: string) => Promise<PluginRegistrySnapshot> unload: (extensionId: string) => Promise<PluginRegistrySnapshot>
/**
* Builds the full extension host debug snapshot.
*
* Use when:
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
* - Host debugging needs a fresh runtime snapshot after registry refresh
*
* Expects:
* - The host and extension asset service are both initialized
*
* Returns:
* - The full debug snapshot exposed through plugin inspection IPC
*/
inspect: () => Promise<PluginHostDebugSnapshot>
/**
* Returns the mounted base URL for plugin-served assets.
*
* Use when:
* - Renderer code needs to construct extension asset URLs
* - Snapshot consumers need the current loopback asset mount base
*
* Expects:
* - The extension asset service may be started before this is called
*
* Returns:
* - The current extension asset base URL, or an empty string when unavailable
*/
getAssetBaseUrl: () => string
/**
* Disposes optional host features and asset hosting resources.
*
* Use when:
* - Electron shutdown needs to stop extension-owned background work
* - Tests need to release watchers and local asset servers deterministically
*
* Expects:
* - Disposal may be called after partial startup or after prior plugin failures
*
* Returns:
* - A promise that resolves after feature and asset cleanup finish
*/
dispose: () => Promise<void>
} }
/** /**
@@ -228,8 +248,8 @@ export async function setupExtensionHostServiceInternal(
// Extension feature: Static Assets serving // Extension feature: Static Assets serving
const extensionAssetService = createExtensionAssetService({ const extensionAssetService = createExtensionAssetService({
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(), getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(),
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
}) })
await extensionAssetService.start() await extensionAssetService.start()
@@ -262,21 +282,21 @@ export async function setupExtensionHostServiceInternal(
const listSnapshot = (): PluginRegistrySnapshot => { const listSnapshot = (): PluginRegistrySnapshot => {
return buildPluginRegistrySnapshot({ return buildPluginRegistrySnapshot({
config: getConfig(),
entries: extensionRegistry.listEntries(),
extensionsRoot, extensionsRoot,
entries: extensionRegistry.listEntries(),
config: getConfig(),
loaded, loaded,
}) })
} }
const createModuleAssetSession = async (input: { const createModuleAssetSession = async (input: {
extensionId: string extensionId: string
ownerSessionId: string
pathPrefix: string
routeAssetPath: string
version: string version: string
ownerSessionId: string
routeAssetPath: string
pathPrefix: string
}) => { }) => {
const { extensionId, ownerSessionId, pathPrefix, routeAssetPath, version } = input const { extensionId, version, ownerSessionId, routeAssetPath, pathPrefix } = input
const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}` const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}`
const cachedSession = moduleAssetSessionCache.get(cacheKey) const cachedSession = moduleAssetSessionCache.get(cacheKey)
if (cachedSession) { if (cachedSession) {
@@ -285,38 +305,38 @@ export async function setupExtensionHostServiceInternal(
const session = await extensionAssetService.createAssetSession({ const session = await extensionAssetService.createAssetSession({
extensionId, extensionId,
ownerSessionId,
pathPrefix,
routeAssetPath,
ttlMs: extensionAssetSessionTtlMs,
version, version,
ownerSessionId,
routeAssetPath,
pathPrefix,
ttlMs: extensionAssetSessionTtlMs,
}) })
moduleAssetSessionCache.set(cacheKey, session) moduleAssetSessionCache.set(cacheKey, session)
return session return session
} }
const extensionAssetSnapshotService: ExtensionAssetSnapshotService = { const extensionAssetSnapshotService: ExtensionAssetSnapshotService = {
createAssetSession: ({ extensionId, ownerSessionId, pathPrefix, routeAssetPath, version }) => { getBaseUrl: extensionAssetService.getBaseUrl,
createAssetSession: ({ extensionId, version, ownerSessionId, routeAssetPath, pathPrefix }) => {
return createModuleAssetSession({ return createModuleAssetSession({
extensionId, extensionId,
ownerSessionId,
pathPrefix,
routeAssetPath,
version, version,
ownerSessionId,
routeAssetPath,
pathPrefix,
}) })
}, },
getBaseUrl: extensionAssetService.getBaseUrl,
} }
const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => { const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => {
return await buildPluginHostDebugSnapshot({ return await buildPluginHostDebugSnapshot({
config: getConfig(),
entries: extensionRegistry.listEntries(),
extensionAssetService: extensionAssetSnapshotService,
extensionsRoot,
host, host,
extensionsRoot,
entries: extensionRegistry.listEntries(),
config: getConfig(),
loaded, loaded,
manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(), manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(),
extensionAssetService: extensionAssetSnapshotService,
}) })
} }
@@ -369,16 +389,16 @@ export async function setupExtensionHostServiceInternal(
// Extension feature: Auto-reload for plugins // Extension feature: Auto-reload for plugins
const autoReloadFeature = createExtensionAutoReloadFeature({ const autoReloadFeature = createExtensionAutoReloadFeature({
getConfig,
isLoaded: extensionId => loaded.has(extensionId),
listEntries: () => extensionRegistry.listEntries(),
log, log,
getConfig,
listEntries: () => extensionRegistry.listEntries(),
isLoaded: extensionId => loaded.has(extensionId),
resolveWatchPaths: resolveAutoReloadWatchPaths,
reload: async (extensionId) => { reload: async (extensionId) => {
await stopLoadedExtensionById(extensionId) await stopLoadedExtensionById(extensionId)
await refreshManifests() await refreshManifests()
await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` }) await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` })
}, },
resolveWatchPaths: resolveAutoReloadWatchPaths,
}) })
const unloadExtensionById = async (extensionId: string) => { const unloadExtensionById = async (extensionId: string) => {
@@ -413,61 +433,17 @@ export async function setupExtensionHostServiceInternal(
autoReloadFeature.sync() autoReloadFeature.sync()
return { return {
async dispose() {
autoReloadFeature.dispose()
builtInKitRuntime.dispose()
moduleAssetSessionCache.clear()
await extensionAssetService.revokeAll()
await extensionAssetService.stop()
},
getAssetBaseUrl() {
return extensionAssetService.getBaseUrl() ?? ''
},
host, host,
async inspect() { // REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though
await refreshManifests() // the host service also exposes it for IPC listing/invocation. Consider moving registry ownership
autoReloadFeature.sync() // to this host service and passing it into kit registration as a dependency.
return await inspectSnapshot() tools: builtInKitRuntime.tools,
}, manifests: extensionRegistry.listManifests(),
async list() { async list() {
await refreshManifests() await refreshManifests()
autoReloadFeature.sync() autoReloadFeature.sync()
return listSnapshot() return listSnapshot()
}, },
async load(extensionId) {
await refreshManifests()
await loadExtensionById(extensionId)
autoReloadFeature.sync()
return listSnapshot()
},
async loadEnabled() {
await refreshManifests()
await loadEnabledExtensions()
autoReloadFeature.sync()
return listSnapshot()
},
manifests: extensionRegistry.listManifests(),
async setAutoReload(payload) {
await refreshManifests()
const config = getConfig()
const autoReload = new Set(config.autoReload)
if (payload.enabled) {
autoReload.add(payload.extensionId)
}
else {
autoReload.delete(payload.extensionId)
}
extensionConfig.update({
...config,
autoReload: [...autoReload],
})
autoReloadFeature.sync()
return listSnapshot()
},
async setEnabled(payload) { async setEnabled(payload) {
await refreshManifests() await refreshManifests()
@@ -485,8 +461,8 @@ export async function setupExtensionHostServiceInternal(
const entry = extensionRegistry.findManifestEntry(payload.extensionId) const entry = extensionRegistry.findManifestEntry(payload.extensionId)
const manifestPath = entry?.path ?? payload.path ?? '' const manifestPath = entry?.path ?? payload.path ?? ''
extensionConfig.update({ extensionConfig.update({
autoReload: config.autoReload,
enabled: [...enabled], enabled: [...enabled],
autoReload: config.autoReload,
known: { known: {
...config.known, ...config.known,
[payload.extensionId]: { path: manifestPath }, [payload.extensionId]: { path: manifestPath },
@@ -496,34 +472,58 @@ export async function setupExtensionHostServiceInternal(
autoReloadFeature.sync() autoReloadFeature.sync()
return listSnapshot() return listSnapshot()
}, },
// REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though async setAutoReload(payload) {
// the host service also exposes it for IPC listing/invocation. Consider moving registry ownership await refreshManifests()
// to this host service and passing it into kit registration as a dependency.
tools: builtInKitRuntime.tools, const config = getConfig()
const autoReload = new Set(config.autoReload)
if (payload.enabled) {
autoReload.add(payload.extensionId)
}
else {
autoReload.delete(payload.extensionId)
}
extensionConfig.update({
...config,
autoReload: [...autoReload],
})
autoReloadFeature.sync()
return listSnapshot()
},
async loadEnabled() {
await refreshManifests()
await loadEnabledExtensions()
autoReloadFeature.sync()
return listSnapshot()
},
async load(extensionId) {
await refreshManifests()
await loadExtensionById(extensionId)
autoReloadFeature.sync()
return listSnapshot()
},
async unload(extensionId) { async unload(extensionId) {
await unloadExtensionById(extensionId) await unloadExtensionById(extensionId)
autoReloadFeature.sync() autoReloadFeature.sync()
return listSnapshot() return listSnapshot()
}, },
} async inspect() {
} await refreshManifests()
autoReloadFeature.sync()
return await inspectSnapshot()
},
getAssetBaseUrl() {
return extensionAssetService.getBaseUrl() ?? ''
},
async dispose() {
autoReloadFeature.dispose()
builtInKitRuntime.dispose()
function createElectronExtensionAssetCookieAdapter() { moduleAssetSessionCache.clear()
return { await extensionAssetService.revokeAll()
async removeCookie(cookie: ExtensionAssetCookie) { await extensionAssetService.stop()
await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name)
},
async setCookie(cookie: ExtensionAssetCookie) {
await electronSession.defaultSession.cookies.set({
expirationDate: Math.floor(cookie.expiresAt / 1000),
httpOnly: true,
name: cookie.name,
path: cookie.path,
sameSite: 'no_restriction',
secure: true,
url: cookie.url,
value: cookie.value,
})
}, },
} }
} }
@@ -17,166 +17,30 @@ import { safeParse } from 'valibot'
export const extensionManifestFileName = 'extension.airi.json' export const extensionManifestFileName = 'extension.airi.json'
/** function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 {
* Tracks the manifest registry state used by the Electron extension host. return safeParse(extensionManifestV1Schema, value).success
*
* Use when:
* - Refreshing extension manifests from disk
* - Looking up manifests by extension id during load or inspect operations
*
* Expects:
* - `refresh()` is called before consumers read entries or manifests
* - `extensionsRoot` points at the extension manifest root under user data
*
* Returns:
* - Read access to the current manifest entries, manifest list, and lookup map
*/
export interface ExtensionHostRegistry {
findManifestEntry: (extensionId: string) => ManifestEntry | undefined
getManifestEntryByExtensionId: () => Map<string, ManifestEntry>
getRoot: () => string
listEntries: () => ManifestEntry[]
listManifests: () => ExtensionManifestV1[]
refresh: () => Promise<ManifestEntry[]>
} }
/** export function manifestIdOf(manifest: ExtensionManifestV1) {
* Builds the renderer-facing extension registry snapshot. return manifest.id
*
* Use when:
* - IPC clients request the plugin list
* - Internal host operations need a fresh registry view after config or load changes
*
* Expects:
* - `entries`, `config`, and `loaded` come from the latest in-memory host state
*
* Returns:
* - A stable registry snapshot for renderer consumption
*/
export function buildPluginRegistrySnapshot(options: {
config: ExtensionConfig
entries: ManifestEntry[]
extensionsRoot: string
loaded: Set<string>
}): PluginRegistrySnapshot {
return {
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
root: options.extensionsRoot,
}
} }
/** async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
* Creates the manifest registry store used by the extension host bootstrap. if (!entry.isSymbolicLink()) {
* return { resolved: false }
* Use when:
* - Host bootstrap needs in-memory manifest lookup and refresh operations
*
* Expects:
* - `log` is the plugin-host logger used for manifest loading diagnostics
*
* Returns:
* - A registry wrapper around the current manifest entry array and lookup map
*/
export function createExtensionHostRegistry(options: {
extensionsRoot: string
log: ReturnType<typeof useLogg>
}): ExtensionHostRegistry {
let entries: ManifestEntry[] = []
let manifests: ExtensionManifestV1[] = []
let manifestEntryByExtensionId = new Map<string, ManifestEntry>()
return {
findManifestEntry(extensionId) {
return manifestEntryByExtensionId.get(extensionId)
},
getManifestEntryByExtensionId() {
return manifestEntryByExtensionId
},
getRoot() {
return options.extensionsRoot
},
listEntries() {
return entries
},
listManifests() {
return manifests
},
async refresh() {
entries = await loadManifestsFrom(options.extensionsRoot, options.log)
manifestEntryByExtensionId = new Map()
for (const entry of entries) {
const id = manifestIdOf(entry.manifest)
if (!manifestEntryByExtensionId.has(id)) {
manifestEntryByExtensionId.set(id, entry)
}
}
manifests = entries.map(entry => entry.manifest)
return entries
},
}
}
/**
* Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint.
*
* Use when:
* - Loading a plugin normally
* - Reloading a plugin after file changes to avoid stale module cache
*
* Expects:
* - `cacheBustKey` is omitted for standard loads
* - `cacheBustKey` is deterministic enough for one reload cycle when provided
*
* Returns:
* - Original manifest or cloned manifest with cache-busted runtime entrypoint
*/
export function createManifestForLoad(
entry: ManifestEntry,
options: { cacheBustKey?: string },
): ExtensionManifestV1 {
const loadManifest = entry.manifest
if (!options.cacheBustKey) {
return loadManifest
} }
const manifest = structuredClone(loadManifest) try {
if (manifest.entrypoints.electron) { const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) const stats = await stat(resolvedPath)
} if (stats.isFile() || stats.isDirectory()) {
else if (manifest.entrypoints.default) { return { resolved: true, path: resolvedPath }
manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey) }
}
return manifest
}
/** return { resolved: false }
* Builds a renderer-facing extension summary from manifest, config, and runtime state. }
* catch (error) {
* Use when: return { resolved: false, error }
* - Registry snapshots need one UI-friendly entry per discovered plugin
*
* Expects:
* - `entry` corresponds to a currently discovered manifest
* - `config` is the latest persisted extension config
* - `loaded` tracks currently running plugin names
*
* Returns:
* - Stable manifest summary for UI consumption
*/
export function createPluginSummary(
entry: ManifestEntry,
config: ExtensionConfig,
loaded: Set<string>,
): PluginManifestSummary {
const extensionId = manifestIdOf(entry.manifest)
return {
autoReload: config.autoReload.includes(extensionId),
enabled: config.enabled.includes(extensionId),
entrypoints: entry.manifest.entrypoints,
extensionId,
isNew: !config.known[extensionId],
loaded: loaded.has(extensionId),
path: entry.path,
} }
} }
@@ -207,7 +71,7 @@ export async function loadManifestsFrom(
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
if (entry.isSymbolicLink()) { if (entry.isSymbolicLink()) {
const { error, resolved } = await realPathOf(entry, { cwd: dir }) const { resolved, error } = await realPathOf(entry, { cwd: dir })
if (error) { if (error) {
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping') log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping')
continue continue
@@ -299,8 +163,60 @@ export async function loadManifestsFrom(
return manifests return manifests
} }
export function manifestIdOf(manifest: ExtensionManifestV1) { /**
return manifest.id * Builds a renderer-facing extension summary from manifest, config, and runtime state.
*
* Use when:
* - Registry snapshots need one UI-friendly entry per discovered plugin
*
* Expects:
* - `entry` corresponds to a currently discovered manifest
* - `config` is the latest persisted extension config
* - `loaded` tracks currently running plugin names
*
* Returns:
* - Stable manifest summary for UI consumption
*/
export function createPluginSummary(
entry: ManifestEntry,
config: ExtensionConfig,
loaded: Set<string>,
): PluginManifestSummary {
const extensionId = manifestIdOf(entry.manifest)
return {
extensionId,
entrypoints: entry.manifest.entrypoints,
path: entry.path,
enabled: config.enabled.includes(extensionId),
autoReload: config.autoReload.includes(extensionId),
loaded: loaded.has(extensionId),
isNew: !config.known[extensionId],
}
}
/**
* Builds the renderer-facing extension registry snapshot.
*
* Use when:
* - IPC clients request the plugin list
* - Internal host operations need a fresh registry view after config or load changes
*
* Expects:
* - `entries`, `config`, and `loaded` come from the latest in-memory host state
*
* Returns:
* - A stable registry snapshot for renderer consumption
*/
export function buildPluginRegistrySnapshot(options: {
extensionsRoot: string
entries: ManifestEntry[]
config: ExtensionConfig
loaded: Set<string>
}): PluginRegistrySnapshot {
return {
root: options.extensionsRoot,
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
}
} }
/** /**
@@ -331,25 +247,109 @@ function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string {
return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}` return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}`
} }
function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 { /**
return safeParse(extensionManifestV1Schema, value).success * Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint.
*
* Use when:
* - Loading a plugin normally
* - Reloading a plugin after file changes to avoid stale module cache
*
* Expects:
* - `cacheBustKey` is omitted for standard loads
* - `cacheBustKey` is deterministic enough for one reload cycle when provided
*
* Returns:
* - Original manifest or cloned manifest with cache-busted runtime entrypoint
*/
export function createManifestForLoad(
entry: ManifestEntry,
options: { cacheBustKey?: string },
): ExtensionManifestV1 {
const loadManifest = entry.manifest
if (!options.cacheBustKey) {
return loadManifest
}
const manifest = structuredClone(loadManifest)
if (manifest.entrypoints.electron) {
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey)
}
else if (manifest.entrypoints.default) {
manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey)
}
return manifest
} }
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ error?: unknown, path: string, resolved: true } | { error?: unknown, path?: string, resolved: false }> { /**
if (!entry.isSymbolicLink()) { * Tracks the manifest registry state used by the Electron extension host.
return { resolved: false } *
} * Use when:
* - Refreshing extension manifests from disk
* - Looking up manifests by extension id during load or inspect operations
*
* Expects:
* - `refresh()` is called before consumers read entries or manifests
* - `extensionsRoot` points at the extension manifest root under user data
*
* Returns:
* - Read access to the current manifest entries, manifest list, and lookup map
*/
export interface ExtensionHostRegistry {
getRoot: () => string
refresh: () => Promise<ManifestEntry[]>
listEntries: () => ManifestEntry[]
listManifests: () => ExtensionManifestV1[]
findManifestEntry: (extensionId: string) => ManifestEntry | undefined
getManifestEntryByExtensionId: () => Map<string, ManifestEntry>
}
try { /**
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) * Creates the manifest registry store used by the extension host bootstrap.
const stats = await stat(resolvedPath) *
if (stats.isFile() || stats.isDirectory()) { * Use when:
return { path: resolvedPath, resolved: true } * - Host bootstrap needs in-memory manifest lookup and refresh operations
} *
* Expects:
* - `log` is the plugin-host logger used for manifest loading diagnostics
*
* Returns:
* - A registry wrapper around the current manifest entry array and lookup map
*/
export function createExtensionHostRegistry(options: {
extensionsRoot: string
log: ReturnType<typeof useLogg>
}): ExtensionHostRegistry {
let entries: ManifestEntry[] = []
let manifests: ExtensionManifestV1[] = []
let manifestEntryByExtensionId = new Map<string, ManifestEntry>()
return { resolved: false } return {
} getRoot() {
catch (error) { return options.extensionsRoot
return { error, resolved: false } },
async refresh() {
entries = await loadManifestsFrom(options.extensionsRoot, options.log)
manifestEntryByExtensionId = new Map()
for (const entry of entries) {
const id = manifestIdOf(entry.manifest)
if (!manifestEntryByExtensionId.has(id)) {
manifestEntryByExtensionId.set(id, entry)
}
}
manifests = entries.map(entry => entry.manifest)
return entries
},
listEntries() {
return entries
},
listManifests() {
return manifests
},
findManifestEntry(extensionId) {
return manifestEntryByExtensionId.get(extensionId)
},
getManifestEntryByExtensionId() {
return manifestEntryByExtensionId
},
} }
} }
File diff suppressed because it is too large Load Diff
@@ -56,8 +56,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => { defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => {
const result = await hostService.setEnabled(payload) const result = await hostService.setEnabled(payload)
context.emit(electronPluginToolsChanged, { context.emit(electronPluginToolsChanged, {
extensionId: payload.extensionId,
reason: 'enabled-state-changed', reason: 'enabled-state-changed',
extensionId: payload.extensionId,
}) })
return result return result
}) })
@@ -77,8 +77,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
defineInvokeHandler(context, electronPluginLoad, async (payload) => { defineInvokeHandler(context, electronPluginLoad, async (payload) => {
const result = await hostService.load(payload.extensionId) const result = await hostService.load(payload.extensionId)
context.emit(electronPluginToolsChanged, { context.emit(electronPluginToolsChanged, {
extensionId: payload.extensionId,
reason: 'loaded', reason: 'loaded',
extensionId: payload.extensionId,
}) })
return result return result
}) })
@@ -86,8 +86,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
defineInvokeHandler(context, electronPluginUnload, async (payload) => { defineInvokeHandler(context, electronPluginUnload, async (payload) => {
const result = await hostService.unload(payload.extensionId) const result = await hostService.unload(payload.extensionId)
context.emit(electronPluginToolsChanged, { context.emit(electronPluginToolsChanged, {
extensionId: payload.extensionId,
reason: 'unloaded', reason: 'unloaded',
extensionId: payload.extensionId,
}) })
return result return result
}) })
@@ -123,10 +123,10 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
switch (payload.state) { switch (payload.state) {
case 'announced': case 'announced':
return hostService.host.announceCapability(payload.key, payload.metadata) return hostService.host.announceCapability(payload.key, payload.metadata)
case 'degraded':
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
case 'ready': case 'ready':
return hostService.host.markCapabilityReady(payload.key, payload.metadata) return hostService.host.markCapabilityReady(payload.key, payload.metadata)
case 'degraded':
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
case 'withdrawn': case 'withdrawn':
return hostService.host.withdrawCapability(payload.key, payload.metadata) return hostService.host.withdrawCapability(payload.key, payload.metadata)
default: { default: {
@@ -14,12 +14,12 @@ import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-
* - The gamelet kit descriptor used for `kit.gamelet` * - The gamelet kit descriptor used for `kit.gamelet`
*/ */
export const gameletPluginKitDescriptor = { export const gameletPluginKitDescriptor = {
capabilities: [
{ actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' },
],
kitId: 'kit.gamelet', kitId: 'kit.gamelet',
runtimes: ['electron', 'web'],
version: '1.0.0', version: '1.0.0',
runtimes: ['electron', 'web'],
capabilities: [
{ key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] },
],
} satisfies KitDescriptor } satisfies KitDescriptor
/** /**
@@ -27,46 +27,39 @@ export function createGameletOrchestrationRuntime(
widgetsManager: ExtensionHostGameletWidgetsManager, widgetsManager: ExtensionHostGameletWidgetsManager,
): GameletOrchestrationRuntime { ): GameletOrchestrationRuntime {
return { return {
async close(bindingId) {
await widgetsManager.removeWidget(bindingId)
},
async configure(bindingId, payload) {
await widgetsManager.updateWidget({
componentProps: createComponentProps(bindingId, payload),
id: bindingId,
})
},
dispose() {},
async isOpen(bindingId) {
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
},
async open(bindingId, payload) { async open(bindingId, payload) {
const componentProps = createComponentProps(bindingId, payload ?? {}) const componentProps = createComponentProps(bindingId, payload ?? {})
if (widgetsManager.getWidgetSnapshot(bindingId)) { if (widgetsManager.getWidgetSnapshot(bindingId)) {
await widgetsManager.updateWidget({ await widgetsManager.updateWidget({
componentProps,
id: bindingId, id: bindingId,
componentProps,
size: 'l', size: 'l',
}) })
} }
else { else {
await widgetsManager.pushWidget({ await widgetsManager.pushWidget({
id: bindingId,
componentName: 'extension-ui', componentName: 'extension-ui',
componentProps, componentProps,
id: bindingId,
size: 'l', size: 'l',
}) })
} }
await widgetsManager.openWindow({ id: bindingId }) await widgetsManager.openWindow({ id: bindingId })
}, },
async configure(bindingId, payload) {
await widgetsManager.updateWidget({
id: bindingId,
componentProps: createComponentProps(bindingId, payload),
})
},
async request<TResponse = HostDataRecord>(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise<TResponse> { async request<TResponse = HostDataRecord>(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise<TResponse> {
if (!widgetsManager.getWidgetSnapshot(bindingId)) { if (!widgetsManager.getWidgetSnapshot(bindingId)) {
throw new Error(`Gamelet \`${bindingId}\` is not open.`) throw new Error(`Gamelet \`${bindingId}\` is not open.`)
} }
return await widgetsManager.requestWidgetIframe<Record<string, unknown> & TResponse>( return await widgetsManager.requestWidgetIframe<TResponse & Record<string, unknown>>(
bindingId, bindingId,
payload, payload,
{ {
@@ -74,6 +67,13 @@ export function createGameletOrchestrationRuntime(
}, },
) as TResponse ) as TResponse
}, },
async close(bindingId) {
await widgetsManager.removeWidget(bindingId)
},
async isOpen(bindingId) {
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
},
dispose() {},
} }
} }
@@ -15,43 +15,7 @@ import { registerWidgetPluginKit } from './widget'
type GameletKitClient = ReturnType<typeof gameletKit.createClient> type GameletKitClient = ReturnType<typeof gameletKit.createClient>
type ToolKitClient = ReturnType<typeof toolKit.createClient> type ToolKitClient = ReturnType<typeof toolKit.createClient>
/** function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef<GameletKitClient> {
* Creates the built-in kit runtime installed by the Electron extension host.
*
* Use when:
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
* - Built-in kit registration should remain outside the host layer
*
* Expects:
* - `widgetsManager` is initialized before host construction
*
* Returns:
* - Helpers to register built-in kits on the host
*/
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
dispose: () => void
registerHostKits: (host: ExtensionHost) => void
tools: TamagotchiToolRegistry
} {
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
const tools = new TamagotchiToolRegistry()
return {
dispose() {
gamelets.dispose()
tools.clear()
},
registerHostKits(host) {
registerWidgetPluginKit(host)
registerGameletPluginKit(host)
host.registerKitApi(createHostGameletKit({ gamelets, host }))
host.registerKitApi(createHostToolKit({ tools }))
},
tools,
}
}
function createHostGameletKit(options: { gamelets: GameletOrchestrationRuntime, host: ExtensionHost }): KitRef<GameletKitClient> {
return { return {
...gameletKit, ...gameletKit,
createClient(runtime) { createClient(runtime) {
@@ -92,18 +56,18 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
register: (input) => { register: (input) => {
ensureCleanup() ensureCleanup()
options.tools.register({ options.tools.register({
ownerSessionId: runtime.sessionId,
ownerExtensionId: runtime.extensionId, ownerExtensionId: runtime.extensionId,
ownerModuleId: runtime.moduleId, ownerModuleId: runtime.moduleId,
ownerSessionId: runtime.sessionId,
...input, ...input,
}) })
}, },
registerToolsetPrompt: (input) => { registerToolsetPrompt: (input) => {
ensureCleanup() ensureCleanup()
options.tools.registerToolsetPrompt({ options.tools.registerToolsetPrompt({
ownerSessionId: runtime.sessionId,
ownerExtensionId: runtime.extensionId, ownerExtensionId: runtime.extensionId,
ownerModuleId: runtime.moduleId, ownerModuleId: runtime.moduleId,
ownerSessionId: runtime.sessionId,
toolset: input, toolset: input,
}) })
}, },
@@ -114,3 +78,39 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
}, },
} }
} }
/**
* Creates the built-in kit runtime installed by the Electron extension host.
*
* Use when:
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
* - Built-in kit registration should remain outside the host layer
*
* Expects:
* - `widgetsManager` is initialized before host construction
*
* Returns:
* - Helpers to register built-in kits on the host
*/
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
registerHostKits: (host: ExtensionHost) => void
tools: TamagotchiToolRegistry
dispose: () => void
} {
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
const tools = new TamagotchiToolRegistry()
return {
registerHostKits(host) {
registerWidgetPluginKit(host)
registerGameletPluginKit(host)
host.registerKitApi(createHostGameletKit({ host, gamelets }))
host.registerKitApi(createHostToolKit({ tools }))
},
tools,
dispose() {
gamelets.dispose()
tools.clear()
},
}
}
@@ -24,6 +24,19 @@ export interface WidgetAssetRoute {
sessionPathPrefix: string sessionPathPrefix: string
} }
function normalizeWidgetAssetPath(assetPath: string): string | undefined {
const trimmed = assetPath.trim().replaceAll('\\', '/')
if (!trimmed) {
return undefined
}
const withoutRelativePrefix = trimmed.startsWith('./')
? trimmed.slice(2)
: trimmed
return normalizeStaticAssetPath(withoutRelativePrefix)
}
/** /**
* Normalizes a widget iframe asset path into `/ui` route semantics. * Normalizes a widget iframe asset path into `/ui` route semantics.
* *
@@ -38,7 +51,7 @@ export interface WidgetAssetRoute {
* Returns: * Returns:
* - The route-relative asset path and the allowed session prefix for that route * - The route-relative asset path and the allowed session prefix for that route
*/ */
export function resolveWidgetAssetRoute(assetPath: string): undefined | WidgetAssetRoute { export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined {
const normalized = normalizeWidgetAssetPath(assetPath) const normalized = normalizeWidgetAssetPath(assetPath)
if (!normalized) { if (!normalized) {
return undefined return undefined
@@ -84,16 +97,16 @@ export function rewriteWidgetModuleAssetUrl(
module: PluginHostModuleSummary, module: PluginHostModuleSummary,
manifestEntryByExtensionId: Map<string, ManifestEntry>, manifestEntryByExtensionId: Map<string, ManifestEntry>,
options?: { options?: {
extensionAssetBaseUrl?: string
createAssetSession?: (input: { createAssetSession?: (input: {
extensionId: string extensionId: string
routeAssetPath: string
sessionId: string
sessionPathPrefix: string
version: string version: string
sessionId: string
routeAssetPath: string
sessionPathPrefix: string
}) => Promise<{ assetSessionId: string, url?: string }> }) => Promise<{ assetSessionId: string, url?: string }>
extensionAssetBaseUrl?: string
}, },
): PluginHostModuleSummary | Promise<PluginHostModuleSummary> { ): Promise<PluginHostModuleSummary> | PluginHostModuleSummary {
const entry = manifestEntryByExtensionId.get(module.ownerExtensionId) const entry = manifestEntryByExtensionId.get(module.ownerExtensionId)
if (!entry) { if (!entry) {
return module return module
@@ -131,15 +144,15 @@ export function rewriteWidgetModuleAssetUrl(
return options.createAssetSession({ return options.createAssetSession({
extensionId: module.ownerExtensionId, extensionId: module.ownerExtensionId,
routeAssetPath: widgetAssetRoute.routeAssetPath,
sessionId: module.ownerSessionId,
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
version: entry.version, version: entry.version,
sessionId: module.ownerSessionId,
routeAssetPath: widgetAssetRoute.routeAssetPath,
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
}).then((session) => { }).then((session) => {
const mountedPath = buildMountedStaticAssetPath({ const mountedPath = buildMountedStaticAssetPath({
assetPath: widgetAssetRoute.routeAssetPath,
assetSessionId: session.assetSessionId,
extensionId: module.ownerExtensionId, extensionId: module.ownerExtensionId,
assetSessionId: session.assetSessionId,
assetPath: widgetAssetRoute.routeAssetPath,
}) })
const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '') const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '')
if (!iframeUrl) { if (!iframeUrl) {
@@ -161,16 +174,3 @@ export function rewriteWidgetModuleAssetUrl(
} }
}) })
} }
function normalizeWidgetAssetPath(assetPath: string): string | undefined {
const trimmed = assetPath.trim().replaceAll('\\', '/')
if (!trimmed) {
return undefined
}
const withoutRelativePrefix = trimmed.startsWith('./')
? trimmed.slice(2)
: trimmed
return normalizeStaticAssetPath(withoutRelativePrefix)
}
@@ -16,12 +16,12 @@ export { resolveWidgetAssetRoute, rewriteWidgetModuleAssetUrl } from './asset-ur
* - The widget kit descriptor used for `kit.widget` * - The widget kit descriptor used for `kit.widget`
*/ */
export const widgetPluginKitDescriptor = { export const widgetPluginKitDescriptor = {
capabilities: [
{ actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' },
],
kitId: 'kit.widget', kitId: 'kit.widget',
runtimes: ['electron', 'web'],
version: '1.0.0', version: '1.0.0',
runtimes: ['electron', 'web'],
capabilities: [
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
],
} satisfies KitDescriptor } satisfies KitDescriptor
/** /**
@@ -7,89 +7,9 @@ import type {
} from '../../../../shared/eventa' } from '../../../../shared/eventa'
/** /**
* Persisted extension configuration snapshot. * Stable manifest id used as the runtime identity for one extension.
*
* Use when:
* - Reading/writing enabled and auto-reload extension state
* - Keeping known extension manifest path metadata
*
* Expects:
* - Arrays contain extension manifest ids
* - `known` maps extension manifest ids to canonical manifest paths
*
* Returns:
* - N/A
*/ */
export interface ExtensionConfig { export type ExtensionId = string
autoReload: ExtensionId[]
enabled: ExtensionId[]
known: Record<ExtensionId, { path: string }>
}
/**
* Binding announcement payload used by extension-side runtime registration.
*
* Use when:
* - Announcing a new module for a registered kit
* - Reusing existing module ownership with the same module identifier
*
* Expects:
* - `moduleId` is unique per owner session/plugin pair
* - `kitId` and `kitModuleType` map to a registered kit descriptor
* - `config` is a JSON-compatible record
*
* Returns:
* - N/A
*/
export interface ExtensionHostBindingAnnounceInput {
config: Record<string, unknown>
kitId: string
kitModuleType: string
moduleId: string
}
/**
* Optional filters for listing announced bindings.
*
* Use when:
* - Querying only modules from one session
* - Querying modules belonging to one kit
*
* Expects:
* - Any provided key is treated as a strict equality filter
*
* Returns:
* - N/A
*/
export interface ExtensionHostBindingListOptions {
kitId?: string
ownerSessionId?: string
}
/**
* Describes the widget manager surface required by extension-driven gamelet APIs.
*
* Use when:
* - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets
*
* Expects:
* - Widget ids remain stable and may be reused for the same module id
*
* Returns:
* - The minimal widget-manager contract consumed by the extension host service
*/
export interface ExtensionHostGameletWidgetsManager {
getWidgetSnapshot: (id: string) => undefined | WidgetSnapshot
openWindow: (params?: { id?: string }) => Promise<void>
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
removeWidget: (id: string) => Promise<void>
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
options?: { timeoutMs?: number },
) => Promise<TResponse>
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
}
/** /**
* Runtime-facing extension host service bundle returned by setup. * Runtime-facing extension host service bundle returned by setup.
@@ -111,9 +31,106 @@ export interface ExtensionHostService {
} }
/** /**
* Stable manifest id used as the runtime identity for one extension. * Describes the widget manager surface required by extension-driven gamelet APIs.
*
* Use when:
* - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets
*
* Expects:
* - Widget ids remain stable and may be reused for the same module id
*
* Returns:
* - The minimal widget-manager contract consumed by the extension host service
*/ */
export type ExtensionId = string export interface ExtensionHostGameletWidgetsManager {
openWindow: (params?: { id?: string }) => Promise<void>
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
removeWidget: (id: string) => Promise<void>
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
options?: { timeoutMs?: number },
) => Promise<TResponse>
}
/**
* Configures the runtime dependencies required by `setupExtensionHost(...)`.
*
* Use when:
* - Wiring the extension host during Electron startup
* - Providing test doubles for extension-driven gamelet orchestration
*
* Expects:
* - `widgetsManager` is already initialized and ready to manage overlay widgets
*
* Returns:
* - N/A
*/
export interface SetupExtensionHostOptions {
widgetsManager: ExtensionHostGameletWidgetsManager
}
/**
* Binding announcement payload used by extension-side runtime registration.
*
* Use when:
* - Announcing a new module for a registered kit
* - Reusing existing module ownership with the same module identifier
*
* Expects:
* - `moduleId` is unique per owner session/plugin pair
* - `kitId` and `kitModuleType` map to a registered kit descriptor
* - `config` is a JSON-compatible record
*
* Returns:
* - N/A
*/
export interface ExtensionHostBindingAnnounceInput {
moduleId: string
kitId: string
kitModuleType: string
config: Record<string, unknown>
}
/**
* Optional filters for listing announced bindings.
*
* Use when:
* - Querying only modules from one session
* - Querying modules belonging to one kit
*
* Expects:
* - Any provided key is treated as a strict equality filter
*
* Returns:
* - N/A
*/
export interface ExtensionHostBindingListOptions {
ownerSessionId?: string
kitId?: string
}
/**
* Persisted extension configuration snapshot.
*
* Use when:
* - Reading/writing enabled and auto-reload extension state
* - Keeping known extension manifest path metadata
*
* Expects:
* - Arrays contain extension manifest ids
* - `known` maps extension manifest ids to canonical manifest paths
*
* Returns:
* - N/A
*/
export interface ExtensionConfig {
enabled: ExtensionId[]
autoReload: ExtensionId[]
known: Record<ExtensionId, { path: string }>
}
/** /**
* Internal manifest record with resolved location and package version. * Internal manifest record with resolved location and package version.
@@ -137,20 +154,3 @@ export interface ManifestEntry {
rootDir: string rootDir: string
version: string version: string
} }
/**
* Configures the runtime dependencies required by `setupExtensionHost(...)`.
*
* Use when:
* - Wiring the extension host during Electron startup
* - Providing test doubles for extension-driven gamelet orchestration
*
* Expects:
* - `widgetsManager` is already initialized and ready to manage overlay widgets
*
* Returns:
* - N/A
*/
export interface SetupExtensionHostOptions {
widgetsManager: ExtensionHostGameletWidgetsManager
}
@@ -24,19 +24,19 @@ const DEFAULT_REMIX_ID = '48250602'
const DEFAULT_ARTISTRY_PROVIDER = 'none' const DEFAULT_ARTISTRY_PROVIDER = 'none'
interface ArtistrySyncSnapshot { interface ArtistrySyncSnapshot {
globals?: Record<string, any>
model?: string
options?: Record<string, any>
promptPrefix?: string
provider?: string provider?: string
model?: string
promptPrefix?: string
options?: Record<string, any>
globals?: Record<string, any>
} }
interface TriggerConfig { interface TriggerConfig {
globals?: Record<string, any>
model?: string
options?: Record<string, any>
promptPrefix?: string
provider?: string provider?: string
model?: string
promptPrefix?: string
options?: Record<string, any>
globals?: Record<string, any>
} }
function robustParse(input: unknown, context?: string): Record<string, unknown> { function robustParse(input: unknown, context?: string): Record<string, unknown> {
@@ -66,11 +66,11 @@ const activeRunMap = new Map<string, string>()
* Synced from the renderer App.vue whenever the character or settings change. * Synced from the renderer App.vue whenever the character or settings change.
*/ */
const cardDefaults: ArtistrySyncSnapshot = { const cardDefaults: ArtistrySyncSnapshot = {
globals: undefined as Record<string, unknown> | undefined,
model: undefined as string | undefined,
options: undefined as Record<string, unknown> | undefined,
promptPrefix: undefined as string | undefined,
provider: undefined as string | undefined, provider: undefined as string | undefined,
model: undefined as string | undefined,
promptPrefix: undefined as string | undefined,
options: undefined as Record<string, unknown> | undefined,
globals: undefined as Record<string, unknown> | undefined,
} }
function createRunId(widgetId: string) { function createRunId(widgetId: string) {
@@ -105,15 +105,15 @@ artistryProviders.set('replicate', new ReplicateProvider())
artistryProviders.set('nanobanana', new NanoBananaProvider()) artistryProviders.set('nanobanana', new NanoBananaProvider())
// Deduplication map for headless requests // Deduplication map for headless requests
const pendingHeadlessRequests = new Map<string, Promise<{ base64?: string, error?: string, imageUrl?: string }>>() const pendingHeadlessRequests = new Map<string, Promise<{ imageUrl?: string, base64?: string, error?: string }>>()
export async function generateHeadless(params: { export async function generateHeadless(params: {
globals?: Record<string, any>
model?: string
options?: Record<string, any>
prompt: string prompt: string
model?: string
provider?: string provider?: string
}): Promise<{ base64?: string, error?: string, imageUrl?: string }> { options?: Record<string, any>
globals?: Record<string, any>
}): Promise<{ imageUrl?: string, base64?: string, error?: string }> {
// Resolve config and effective globals early to secure the deduplication fingerprint // Resolve config and effective globals early to secure the deduplication fingerprint
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> }) const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record<string, any> const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record<string, any>
@@ -130,12 +130,12 @@ export async function generateHeadless(params: {
const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex') const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex')
const fingerprint = JSON.stringify({ const fingerprint = JSON.stringify({
gh: globalsHash, // Include globals hash (Issue #39)
ih: imageHash,
m: params.model,
o: params.options,
p: params.prompt, p: params.prompt,
m: params.model,
pr: params.provider, pr: params.provider,
o: params.options,
ih: imageHash,
gh: globalsHash, // Include globals hash (Issue #39)
}) })
if (pendingHeadlessRequests.has(fingerprint)) { if (pendingHeadlessRequests.has(fingerprint)) {
@@ -167,16 +167,16 @@ export async function generateHeadless(params: {
log.log(`[Headless] Source image length: ${activeGlobals.image.length}`) log.log(`[Headless] Source image length: ${activeGlobals.image.length}`)
const request: ArtistryRequest = { const request: ArtistryRequest = {
prompt: params.prompt,
negativePrompt: params.options?.negativePrompt,
width: typeof params.options?.width === 'number' ? params.options.width : undefined,
height: typeof params.options?.height === 'number' ? params.options.height : undefined,
model: params.model,
extra: { extra: {
...params.options, ...params.options,
image: activeGlobals?.image, image: activeGlobals?.image,
internalJobId: createRunId('headless'), internalJobId: createRunId('headless'),
}, },
height: typeof params.options?.height === 'number' ? params.options.height : undefined,
model: params.model,
negativePrompt: params.options?.negativePrompt,
prompt: params.prompt,
width: typeof params.options?.width === 'number' ? params.options.width : undefined,
} }
log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`) log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`)
@@ -215,12 +215,12 @@ export async function generateHeadless(params: {
log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`) log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`)
const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined
return { base64, imageUrl: lastStatus.imageUrl } return { imageUrl: lastStatus.imageUrl, base64 }
} }
else { else {
// For providers with callbacks (like ComfyUI), we wait for the result via the callback // For providers with callbacks (like ComfyUI), we wait for the result via the callback
log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`) log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`)
return new Promise<{ base64?: string, imageUrl?: string }>((resolve, reject) => { return new Promise<{ imageUrl?: string, base64?: string }>((resolve, reject) => {
const timeout = 1000 * 60 * 5 // 5 minutes timeout const timeout = 1000 * 60 * 5 // 5 minutes timeout
const timer = setTimeout(() => { const timer = setTimeout(() => {
reject(new Error('Image generation timed out after 5 minutes.')) reject(new Error('Image generation timed out after 5 minutes.'))
@@ -231,7 +231,7 @@ export async function generateHeadless(params: {
clearTimeout(timer) clearTimeout(timer)
try { try {
const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined
resolve({ base64, imageUrl: status.imageUrl }) resolve({ imageUrl: status.imageUrl, base64 })
} }
catch (e) { catch (e) {
reject(e) reject(e)
@@ -260,112 +260,10 @@ export async function generateHeadless(params: {
} }
} }
export async function setupArtistryBridge(params: {
artistryConfig: Config<typeof artistryConfigSchema>
context?: ReturnType<typeof createMainEventaContext>['context']
widgetsManager: WidgetsWindowManager
}) {
log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...')
if (params.context) {
defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => {
log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`)
return await generateHeadless(payload)
})
defineInvokeHandler(params.context, artistrySyncConfig, (payload) => {
log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`)
params.artistryConfig.update({
artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || {
comfyuiActiveWorkflow: '',
comfyuiSavedWorkflows: [],
comfyuiServerUrl: 'http://localhost:8188',
nanobananaApiKey: '',
nanobananaModel: 'gemini-3.1-flash-image-preview',
nanobananaResolution: '1K',
replicateApiKey: '',
replicateAspectRatio: '16:9',
replicateDefaultModel: 'black-forest-labs/flux-schnell',
replicateInferenceSteps: 4,
},
artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER,
})
// Update character-level defaults (volatile only)
cardDefaults.provider = payload.provider
cardDefaults.model = payload.model
cardDefaults.promptPrefix = payload.promptPrefix
cardDefaults.options = payload.options
cardDefaults.globals = payload.globals
})
defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => {
log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`)
try {
const url = payload.url.replace(/\/+$/, '')
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), 10000)
const resp = await fetch(`${url}/system_stats`, { signal: controller.signal })
clearTimeout(id)
if (!resp.ok)
throw new Error(`HTTP ${resp.status}`)
const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> }
const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU'
const vram = data.devices?.[0]?.vram_total
const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : ''
return {
info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`,
ok: true,
}
}
catch (e: unknown) {
const message = errorMessageFrom(e) ?? 'Unknown connection error'
log.error(`🔌 ComfyUI connection test failed: ${message}`)
return {
info: `Failed: ${message}`,
ok: false,
}
}
})
}
const originalUpdateWidget = params.widgetsManager.updateWidget
params.widgetsManager.updateWidget = async (payload) => {
const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id)
await originalUpdateWidget.call(params.widgetsManager, payload)
await handleArtistryTrigger({
componentName: snapshot?.componentName,
componentProps: payload.componentProps,
id: payload.id,
widgetsManager: params.widgetsManager,
})
}
const originalPushWidget = params.widgetsManager.pushWidget
params.widgetsManager.pushWidget = async (payload) => {
if (payload.componentName === 'comfy' || payload.componentName === 'artistry') {
log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`)
payload.ttlMs = 0
}
const resultId = await originalPushWidget.call(params.widgetsManager, payload)
await handleArtistryTrigger({
componentName: payload.componentName,
componentProps: payload.componentProps,
id: resultId,
widgetsManager: params.widgetsManager,
})
return resultId
}
}
async function handleArtistryTrigger(params: { async function handleArtistryTrigger(params: {
id: string
componentName?: string componentName?: string
componentProps?: unknown componentProps?: unknown
id: string
widgetsManager: WidgetsWindowManager widgetsManager: WidgetsWindowManager
}) { }) {
if (params.componentName !== 'comfy' && params.componentName !== 'artistry') if (params.componentName !== 'comfy' && params.componentName !== 'artistry')
@@ -383,16 +281,16 @@ async function handleArtistryTrigger(params: {
// 1. Explicitly provided in component props (_artistryConfig) // 1. Explicitly provided in component props (_artistryConfig)
// 2. Character-level defaults synced from renderer (cardDefaults) // 2. Character-level defaults synced from renderer (cardDefaults)
const config: TriggerConfig = { const config: TriggerConfig = {
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`. provider: artistryConfigOverrides.provider as string | undefined,
// Older widget payloads can still send `Globals`, and dropping it now would break them.
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model, model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model,
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
options: { options: {
...cardDefaults.options, ...cardDefaults.options,
...robustParse(artistryConfigOverrides.options, 'artistryOptions'), ...robustParse(artistryConfigOverrides.options, 'artistryOptions'),
}, },
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix, // NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
provider: artistryConfigOverrides.provider as string | undefined, // Older widget payloads can still send `Globals`, and dropping it now would break them.
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
} }
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> }) const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER
@@ -430,8 +328,8 @@ async function handleArtistryTrigger(params: {
if (!provider) { if (!provider) {
log.error(`🔴 Provider '${providerId}' not found.`) log.error(`🔴 Provider '${providerId}' not found.`)
params.widgetsManager.updateWidget({ params.widgetsManager.updateWidget({
componentProps: { actionLabel: `Provider '${providerId}' not available`, status: 'error' },
id: params.id, id: params.id,
componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` },
}) })
return return
} }
@@ -446,6 +344,8 @@ async function handleArtistryTrigger(params: {
try { try {
// Build the abstract request // Build the abstract request
const request: ArtistryRequest = { const request: ArtistryRequest = {
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
model: config.model,
extra: { extra: {
...options, ...options,
...props, // Include root componentProps overrides (template, node overrides) ...props, // Include root componentProps overrides (template, node overrides)
@@ -453,8 +353,6 @@ async function handleArtistryTrigger(params: {
internalJobId: runId, // Track each generation independently, even on the same widget. internalJobId: runId, // Track each generation independently, even on the same widget.
remixId, remixId,
}, },
model: config.model,
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
} }
const updateIfActive = (statusUpdate: Record<string, any>) => { const updateIfActive = (statusUpdate: Record<string, any>) => {
@@ -467,11 +365,11 @@ async function handleArtistryTrigger(params: {
// that would otherwise be lost when the final 'done' status is sent. // that would otherwise be lost when the final 'done' status is sent.
const existing = params.widgetsManager.getWidgetSnapshot(params.id) const existing = params.widgetsManager.getWidgetSnapshot(params.id)
params.widgetsManager.updateWidget({ params.widgetsManager.updateWidget({
id: params.id,
componentProps: { componentProps: {
...(existing?.componentProps as any), ...(existing?.componentProps as any),
...statusUpdate, ...statusUpdate,
}, },
id: params.id,
}) })
} }
@@ -481,7 +379,7 @@ async function handleArtistryTrigger(params: {
updateIfActive(statusUpdate as Record<string, any>) updateIfActive(statusUpdate as Record<string, any>)
if (statusUpdate.status === 'succeeded') { if (statusUpdate.status === 'succeeded') {
log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`) log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`)
updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' }) updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
} }
else if (statusUpdate.status === 'failed') { else if (statusUpdate.status === 'failed') {
log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`) log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`)
@@ -502,7 +400,7 @@ async function handleArtistryTrigger(params: {
// Check for timeout // Check for timeout
if (Date.now() - startTime > timeoutLength) { if (Date.now() - startTime > timeoutLength) {
log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`) log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`)
updateIfActive({ actionLabel: 'Generation timed out', status: 'error' }) updateIfActive({ status: 'error', actionLabel: 'Generation timed out' })
break break
} }
@@ -529,7 +427,7 @@ async function handleArtistryTrigger(params: {
const finalStatus = await provider.getStatus(job.jobId) const finalStatus = await provider.getStatus(job.jobId)
if (finalStatus.status === 'succeeded') { if (finalStatus.status === 'succeeded') {
log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`) log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`)
updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' }) updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
} }
else { else {
log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`) log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`)
@@ -543,10 +441,112 @@ async function handleArtistryTrigger(params: {
if (activeRunMap.get(params.id) === runId) { if (activeRunMap.get(params.id) === runId) {
lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44) lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44)
params.widgetsManager.updateWidget({ params.widgetsManager.updateWidget({
componentProps: { actionLabel: message, status: 'error' },
id: params.id, id: params.id,
componentProps: { status: 'error', actionLabel: message },
}) })
} }
} }
} }
} }
export async function setupArtistryBridge(params: {
widgetsManager: WidgetsWindowManager
context?: ReturnType<typeof createMainEventaContext>['context']
artistryConfig: Config<typeof artistryConfigSchema>
}) {
log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...')
if (params.context) {
defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => {
log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`)
return await generateHeadless(payload)
})
defineInvokeHandler(params.context, artistrySyncConfig, (payload) => {
log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`)
params.artistryConfig.update({
artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER,
artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || {
comfyuiServerUrl: 'http://localhost:8188',
comfyuiSavedWorkflows: [],
comfyuiActiveWorkflow: '',
replicateApiKey: '',
replicateDefaultModel: 'black-forest-labs/flux-schnell',
replicateAspectRatio: '16:9',
replicateInferenceSteps: 4,
nanobananaApiKey: '',
nanobananaModel: 'gemini-3.1-flash-image-preview',
nanobananaResolution: '1K',
},
})
// Update character-level defaults (volatile only)
cardDefaults.provider = payload.provider
cardDefaults.model = payload.model
cardDefaults.promptPrefix = payload.promptPrefix
cardDefaults.options = payload.options
cardDefaults.globals = payload.globals
})
defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => {
log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`)
try {
const url = payload.url.replace(/\/+$/, '')
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), 10000)
const resp = await fetch(`${url}/system_stats`, { signal: controller.signal })
clearTimeout(id)
if (!resp.ok)
throw new Error(`HTTP ${resp.status}`)
const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> }
const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU'
const vram = data.devices?.[0]?.vram_total
const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : ''
return {
ok: true,
info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`,
}
}
catch (e: unknown) {
const message = errorMessageFrom(e) ?? 'Unknown connection error'
log.error(`🔌 ComfyUI connection test failed: ${message}`)
return {
ok: false,
info: `Failed: ${message}`,
}
}
})
}
const originalUpdateWidget = params.widgetsManager.updateWidget
params.widgetsManager.updateWidget = async (payload) => {
const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id)
await originalUpdateWidget.call(params.widgetsManager, payload)
await handleArtistryTrigger({
id: payload.id,
componentName: snapshot?.componentName,
componentProps: payload.componentProps,
widgetsManager: params.widgetsManager,
})
}
const originalPushWidget = params.widgetsManager.pushWidget
params.widgetsManager.pushWidget = async (payload) => {
if (payload.componentName === 'comfy' || payload.componentName === 'artistry') {
log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`)
payload.ttlMs = 0
}
const resultId = await originalPushWidget.call(params.widgetsManager, payload)
await handleArtistryTrigger({
id: resultId,
componentName: payload.componentName,
componentProps: payload.componentProps,
widgetsManager: params.widgetsManager,
})
return resultId
}
}
@@ -6,12 +6,20 @@ import { describe, expect, it, vi } from 'vitest'
import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa' import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa'
import { createWidgetsService } from './index' import { createWidgetsService } from './index'
function createWindow(id: number): BrowserWindow {
return {
webContents: {
id,
},
} as BrowserWindow
}
function createWidgetsManager() { function createWidgetsManager() {
return { return {
clearWidgets: vi.fn(), clearWidgets: vi.fn(),
fetchWidget: vi.fn(), fetchWidget: vi.fn(),
getWidgetSnapshot: vi.fn(),
getWindow: vi.fn(), getWindow: vi.fn(),
getWidgetSnapshot: vi.fn(),
hideWindow: vi.fn(), hideWindow: vi.fn(),
onWidgetEvent: vi.fn(), onWidgetEvent: vi.fn(),
openWindow: vi.fn(), openWindow: vi.fn(),
@@ -25,14 +33,6 @@ function createWidgetsManager() {
} }
} }
function createWindow(id: number): BrowserWindow {
return {
webContents: {
id,
},
} as BrowserWindow
}
describe('createWidgetsService', () => { describe('createWidgetsService', () => {
it('routes iframe request results from the widgets window to the manager', () => { it('routes iframe request results from the widgets window to the manager', () => {
const context = createContext() const context = createContext()
@@ -46,8 +46,8 @@ describe('createWidgetsService', () => {
context.emit(widgetsIframeRequestResultEvent, { context.emit(widgetsIframeRequestResultEvent, {
id: 'kit-module:board', id: 'kit-module:board',
ok: true,
requestId: 'req-1', requestId: 'req-1',
ok: true,
result: { fen: 'fen-after-request' }, result: { fen: 'fen-after-request' },
}, { }, {
raw: { raw: {
@@ -59,8 +59,8 @@ describe('createWidgetsService', () => {
expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({ expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({
id: 'kit-module:board', id: 'kit-module:board',
ok: true,
requestId: 'req-1', requestId: 'req-1',
ok: true,
result: { fen: 'fen-after-request' }, result: { fen: 'fen-after-request' },
}) })
}) })
@@ -77,8 +77,8 @@ describe('createWidgetsService', () => {
context.emit(widgetsIframeRequestResultEvent, { context.emit(widgetsIframeRequestResultEvent, {
id: 'kit-module:board', id: 'kit-module:board',
ok: true,
requestId: 'req-1', requestId: 'req-1',
ok: true,
result: { fen: 'fen-after-request' }, result: { fen: 'fen-after-request' },
}, { }, {
raw: { raw: {
@@ -30,6 +30,13 @@ interface InvokeOptions {
raw?: { ipcMainEvent?: IpcMainEvent } raw?: { ipcMainEvent?: IpcMainEvent }
} }
function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) {
const sender = options?.raw?.ipcMainEvent?.sender
if (!sender)
return false
return sender.id === window.webContents.id
}
/** /**
* Registers widget-related Electron invoke handlers for one window context. * Registers widget-related Electron invoke handlers for one window context.
* *
@@ -64,22 +71,51 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
defineInvokeHandlers( defineInvokeHandlers(
params.context, params.context,
{ {
widgetsPrepareWindow,
widgetsOpenWindow,
widgetsHideWindow,
widgetsAdd, widgetsAdd,
widgetsUpdate,
widgetsRemove,
widgetsClear, widgetsClear,
widgetsFetch, widgetsFetch,
widgetsHideWindow,
widgetsIframePublish, widgetsIframePublish,
widgetsOpenWindow,
widgetsPrepareWindow,
widgetsRemove,
widgetsUpdate,
}, },
{ {
widgetsPrepareWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
const id = normalizeOptionalWidgetId(payload?.id)
return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined)
},
widgetsOpenWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
const id = normalizeOptionalWidgetId(payload?.id)
return params.widgetsManager.openWindow(id ? { id } : undefined)
},
widgetsHideWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager!.hideWindow(payload ?? undefined)
},
widgetsAdd: async (payload, options) => { widgetsAdd: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window)) if (!isFromWindow(options as InvokeOptions, params.window))
return undefined return undefined
return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload)) return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload))
}, },
widgetsUpdate: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload))
},
widgetsRemove: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager.removeWidget(
normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'),
)
},
widgetsClear: async (_payload, options) => { widgetsClear: async (_payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window)) if (!isFromWindow(options as InvokeOptions, params.window))
return undefined return undefined
@@ -92,48 +128,12 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'), normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'),
) )
}, },
widgetsHideWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager!.hideWindow(payload ?? undefined)
},
widgetsIframePublish: async (payload, options) => { widgetsIframePublish: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window)) if (!isFromWindow(options as InvokeOptions, params.window))
return undefined return undefined
const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.') const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.')
params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event)) params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event))
}, },
widgetsOpenWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
const id = normalizeOptionalWidgetId(payload?.id)
return params.widgetsManager.openWindow(id ? { id } : undefined)
},
widgetsPrepareWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
const id = normalizeOptionalWidgetId(payload?.id)
return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined)
},
widgetsRemove: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager.removeWidget(
normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'),
)
},
widgetsUpdate: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
return undefined
return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload))
},
}, },
) )
} }
function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) {
const sender = options?.raw?.ipcMainEvent?.sender
if (!sender)
return false
return sender.id === window.webContents.id
}
@@ -6,6 +6,21 @@
* the current AIRI card's artistry settings. * the current AIRI card's artistry settings.
*/ */
export interface ArtistryRequest {
/** The text prompt describing the desired image */
prompt: string
/** Negative prompt — things to avoid (provider support varies) */
negativePrompt?: string
/** Image width in pixels */
width?: number
/** Image height in pixels */
height?: number
/** Provider-specific model identifier */
model?: string
/** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */
extra?: Record<string, any>
}
export interface ArtistryJob { export interface ArtistryJob {
/** Internal job ID for tracking */ /** Internal job ID for tracking */
jobId: string jobId: string
@@ -13,47 +28,34 @@ export interface ArtistryJob {
providerJobId: string providerJobId: string
} }
export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
export interface ArtistryJobStatus { export interface ArtistryJobStatus {
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */ status: ArtistryJobStatusType
actionLabel?: string
/** Error message if failed */
error?: string
/** Final output image URL */
imageUrl?: string
/** Generation progress 0-100 (not all providers support this) */ /** Generation progress 0-100 (not all providers support this) */
progress?: number progress?: number
status: ArtistryJobStatusType /** Final output image URL */
imageUrl?: string
/** Error message if failed */
error?: string
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */
actionLabel?: string
} }
export type ArtistryJobStatusType = 'cancelled' | 'failed' | 'queued' | 'running' | 'succeeded' export interface ArtistryProviderConfig {
/** Unique provider ID (e.g. "comfyui", "replicate") */
/** id: string
* Per-card artistry settings stored in AiriExtension.modules.artistry /** Human-readable display name */
*/ name: string
export interface ArtistryModuleSettings { /** Provider-specific configuration (API keys, paths, etc.) */
/** String prepended to every LLM-generated prompt for style consistency */ settings: Record<string, any>
defaultPromptPrefix?: string
/** Provider-specific model identifier */
model?: string
/** Active provider ID (e.g. "comfyui", "replicate") */
provider?: string
/**
* Free-form provider-specific options as a JSON object.
* For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... }
* For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" }
*/
providerOptions?: Record<string, any>
} }
export interface ArtistryProvider { export interface ArtistryProvider {
/** /** Unique provider ID */
* Cancel a running job (optional not all providers support this). readonly id: string
*/ /** Human-readable display name */
cancel?: (jobId: string) => Promise<void> readonly name: string
/**
* Clean up resources when the provider is being switched out.
*/
dispose?: () => void
/** /**
* Start an image generation job. * Start an image generation job.
@@ -67,43 +69,41 @@ export interface ArtistryProvider {
*/ */
getStatus: (jobId: string) => Promise<ArtistryJobStatus> getStatus: (jobId: string) => Promise<ArtistryJobStatus>
/** Unique provider ID */ /**
readonly id: string * Cancel a running job (optional not all providers support this).
*/
cancel?: (jobId: string) => Promise<void>
/** /**
* Called when the provider is first initialized with its config. * Called when the provider is first initialized with its config.
*/ */
initialize?: (config: Record<string, any>) => Promise<void> initialize?: (config: Record<string, any>) => Promise<void>
/** Human-readable display name */
readonly name: string
/** /**
* Optional push callback for providers that stream or callback status updates. * Optional push callback for providers that stream or callback status updates.
*/ */
setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void
/**
* Clean up resources when the provider is being switched out.
*/
dispose?: () => void
} }
export interface ArtistryProviderConfig { /**
/** Unique provider ID (e.g. "comfyui", "replicate") */ * Per-card artistry settings stored in AiriExtension.modules.artistry
id: string */
/** Human-readable display name */ export interface ArtistryModuleSettings {
name: string /** Active provider ID (e.g. "comfyui", "replicate") */
/** Provider-specific configuration (API keys, paths, etc.) */ provider?: string
settings: Record<string, any>
}
export interface ArtistryRequest {
/** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */
extra?: Record<string, any>
/** Image height in pixels */
height?: number
/** Provider-specific model identifier */ /** Provider-specific model identifier */
model?: string model?: string
/** Negative prompt — things to avoid (provider support varies) */ /** String prepended to every LLM-generated prompt for style consistency */
negativePrompt?: string defaultPromptPrefix?: string
/** The text prompt describing the desired image */ /**
prompt: string * Free-form provider-specific options as a JSON object.
/** Image width in pixels */ * For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... }
width?: number * For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" }
*/
providerOptions?: Record<string, any>
} }
@@ -13,37 +13,43 @@ export class ComfyUIProvider implements ArtistryProvider {
readonly id = 'comfyui' readonly id = 'comfyui'
readonly name = 'ComfyUI (Local)' readonly name = 'ComfyUI (Local)'
private activeWorkflowId = ''
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
private jobResults = new Map<string, ArtistryJobStatus>()
private savedWorkflows: any[] = []
private serverUrl = 'http://localhost:8188' private serverUrl = 'http://localhost:8188'
private savedWorkflows: any[] = []
private activeWorkflowId = ''
async generate(request: ArtistryRequest): Promise<ArtistryJob> { private jobResults = new Map<string, ArtistryJobStatus>()
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2) private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
// Resolve which workflow template to use --- per-request template override takes precedence over card model default private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
const templateId = request.extra?.template || request.model || this.activeWorkflowId const controller = new AbortController()
const template = this.savedWorkflows.find((w: any) => w.id === templateId) const id = setTimeout(() => controller.abort(), timeoutMs)
try {
if (!template) { const response = await fetch(url, {
this.updateStatus(jobId, { ...options,
actionLabel: 'Error: No workflow configured', signal: controller.signal,
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
status: 'failed',
}) })
return { jobId, providerJobId: jobId } clearTimeout(id)
return response
}
catch (error) {
clearTimeout(id)
throw error
} }
// Start async generation
this.pollForResult(jobId, template, request)
return { jobId, providerJobId: jobId }
} }
async getStatus(jobId: string): Promise<ArtistryJobStatus> { setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
return this.jobResults.get(jobId) || { status: 'queued' } this.callbacks.set(jobId, callback)
// If we already have a result, fire it immediately
const result = this.jobResults.get(jobId)
if (result)
callback(result)
}
private updateStatus(jobId: string, status: ArtistryJobStatus) {
this.jobResults.set(jobId, status)
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
} }
async initialize(config: any): Promise<void> { async initialize(config: any): Promise<void> {
@@ -55,12 +61,195 @@ export class ComfyUIProvider implements ArtistryProvider {
this.activeWorkflowId = config.comfyuiActiveWorkflow this.activeWorkflowId = config.comfyuiActiveWorkflow
} }
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) { async generate(request: ArtistryRequest): Promise<ArtistryJob> {
this.callbacks.set(jobId, callback) const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
// If we already have a result, fire it immediately
const result = this.jobResults.get(jobId) // Resolve which workflow template to use --- per-request template override takes precedence over card model default
if (result) const templateId = request.extra?.template || request.model || this.activeWorkflowId
callback(result) const template = this.savedWorkflows.find((w: any) => w.id === templateId)
if (!template) {
this.updateStatus(jobId, {
status: 'failed',
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
actionLabel: 'Error: No workflow configured',
})
return { jobId, providerJobId: jobId }
}
// Start async generation
this.pollForResult(jobId, template, request)
return { jobId, providerJobId: jobId }
}
private async pollForResult(
jobId: string,
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
request: ArtistryRequest,
) {
this.updateStatus(jobId, { status: 'running', actionLabel: 'Preparing workflow...' })
try {
// 0. Handle potential image and prompt upload bidirectional flow
const extraStr = JSON.stringify(request.extra || {})
const workflowStr = JSON.stringify(template.workflow || {})
const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}')
const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}')
let uploadedImageName = ''
if (hasImagePlaceholder && request.extra?.image) {
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`)
this.updateStatus(jobId, { status: 'running', actionLabel: 'Uploading texture to ComfyUI...' })
try {
uploadedImageName = await this.uploadImage(request.extra.image)
log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`)
}
catch (e: any) {
log.error(`[ComfyUI] Texture upload failed: ${e.message}`)
}
}
// 1. Apply overrides to the workflow template (standard injection)
let resolvedPrompt = this.applyOverrides(template, request)
// 2. Perform final placeholder resolution across the ENTIRE resolved prompt
if (hasImagePlaceholder || hasPromptPlaceholder) {
log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`)
const replacements: Record<string, string> = {
'{{PROMPT}}': request.prompt || '',
}
if (uploadedImageName) {
replacements['{{IMAGE}}'] = uploadedImageName
}
resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements)
}
log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2))
// 2. POST /prompt to queue the workflow
this.updateStatus(jobId, { status: 'running', actionLabel: 'Queuing in ComfyUI...' })
let queueResp: Response
try {
queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: resolvedPrompt }),
}, 15000)
}
catch (e: any) {
throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`)
}
if (!queueResp.ok) {
const errorBody = await queueResp.text()
throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`)
}
const queueData = await queueResp.json()
const promptId = queueData.prompt_id
if (!promptId) {
throw new Error('ComfyUI returned no prompt_id')
}
log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`)
this.updateStatus(jobId, { status: 'running', actionLabel: 'Generating...' })
// 3. Poll /history/{prompt_id} until completion
let historyDone = false
let attempt = 0
const startTime = Date.now()
while (!historyDone) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
attempt++
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
throw new Error('Generation timed out after 5 minutes')
}
if (attempt % 3 === 0) {
log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`)
}
let histResp: Response
try {
histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
}
catch (e: any) {
throw new Error(`ComfyUI disconnected during polling: ${e.message}`)
}
if (histResp.ok) {
const histData = await histResp.json()
if (histData[promptId]) {
let outputs = histData[promptId].outputs
const stats = histData[promptId].status
// 3.1. Race condition protection: If outputs are missing, wait a beat and retry once
if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) {
log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`)
await new Promise(r => setTimeout(r, 1000))
const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
if (retryResp.ok) {
const retryData = await retryResp.json()
if (retryData[promptId] && retryData[promptId].outputs) {
log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`)
outputs = retryData[promptId].outputs
}
}
}
// Log raw history if no images found or if there are status messages
if (stats?.messages && stats.messages.length > 0) {
log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages)
}
// Find first image in any node's output
for (const nodeId in outputs) {
const nodeOutput = outputs[nodeId]
if (nodeOutput.images && nodeOutput.images.length > 0) {
const img = nodeOutput.images[0]
const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`
log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`)
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
historyDone = true
break
}
}
// Job finished but no images
if (!historyDone) {
log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2))
this.updateStatus(jobId, {
status: 'failed',
error: 'Job completed but no images were generated',
actionLabel: 'Error: No images generated',
})
historyDone = true
}
}
}
}
}
catch (error: any) {
const errorMessage = error.message || String(error)
log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`)
this.updateStatus(jobId, {
status: 'failed',
error: errorMessage,
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
})
}
finally {
// Clean up callback and job result after completion to prevent memory leaks
setTimeout(() => {
this.callbacks.delete(jobId)
this.jobResults.delete(jobId)
}, 10000)
}
} }
/** /**
@@ -69,7 +258,7 @@ export class ComfyUIProvider implements ArtistryProvider {
* Mirrors the logic from CUIPP's getComfyTemplate.js. * Mirrors the logic from CUIPP's getComfyTemplate.js.
*/ */
private applyOverrides( private applyOverrides(
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> }, template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
request: ArtistryRequest, request: ArtistryRequest,
): Record<string, any> { ): Record<string, any> {
// Deep clone the workflow so we don't mutate the stored template // Deep clone the workflow so we don't mutate the stored template
@@ -149,190 +338,36 @@ export class ComfyUIProvider implements ArtistryProvider {
return prompt return prompt
} }
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) { async getStatus(jobId: string): Promise<ArtistryJobStatus> {
const controller = new AbortController() return this.jobResults.get(jobId) || { status: 'queued' }
const id = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
clearTimeout(id)
return response
}
catch (error) {
clearTimeout(id)
throw error
}
} }
private async pollForResult( private async uploadImage(base64Data: string): Promise<string> {
jobId: string, // 1. Clean data URL prefix if present
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> }, const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
request: ArtistryRequest, const buffer = Buffer.from(base64, 'base64')
) {
this.updateStatus(jobId, { actionLabel: 'Preparing workflow...', status: 'running' })
try { // 2. Prepare multipart form data
// 0. Handle potential image and prompt upload bidirectional flow const formData = new FormData()
const extraStr = JSON.stringify(request.extra || {}) const fileName = `vhack_${Date.now()}.png`
const workflowStr = JSON.stringify(template.workflow || {})
const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}')
const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}')
let uploadedImageName = '' // Electron/Node 18+ fetch handles Blobs in FormData
if (hasImagePlaceholder && request.extra?.image) { const blob = new Blob([buffer], { type: 'image/png' })
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`) formData.append('image', blob, fileName)
this.updateStatus(jobId, { actionLabel: 'Uploading texture to ComfyUI...', status: 'running' }) formData.append('overwrite', 'true')
try {
uploadedImageName = await this.uploadImage(request.extra.image)
log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`)
}
catch (e: any) {
log.error(`[ComfyUI] Texture upload failed: ${e.message}`)
}
}
// 1. Apply overrides to the workflow template (standard injection) const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
let resolvedPrompt = this.applyOverrides(template, request) method: 'POST',
body: formData,
}, 60000) // 1 minute timeout for uploads
// 2. Perform final placeholder resolution across the ENTIRE resolved prompt if (!response.ok) {
if (hasImagePlaceholder || hasPromptPlaceholder) { const error = await response.text()
log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`) throw new Error(`ComfyUI upload failed: ${error}`)
const replacements: Record<string, string> = {
'{{PROMPT}}': request.prompt || '',
}
if (uploadedImageName) {
replacements['{{IMAGE}}'] = uploadedImageName
}
resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements)
}
log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2))
// 2. POST /prompt to queue the workflow
this.updateStatus(jobId, { actionLabel: 'Queuing in ComfyUI...', status: 'running' })
let queueResp: Response
try {
queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, {
body: JSON.stringify({ prompt: resolvedPrompt }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}, 15000)
}
catch (e: any) {
throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`)
}
if (!queueResp.ok) {
const errorBody = await queueResp.text()
throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`)
}
const queueData = await queueResp.json()
const promptId = queueData.prompt_id
if (!promptId) {
throw new Error('ComfyUI returned no prompt_id')
}
log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`)
this.updateStatus(jobId, { actionLabel: 'Generating...', status: 'running' })
// 3. Poll /history/{prompt_id} until completion
let historyDone = false
let attempt = 0
const startTime = Date.now()
while (!historyDone) {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
attempt++
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
throw new Error('Generation timed out after 5 minutes')
}
if (attempt % 3 === 0) {
log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`)
}
let histResp: Response
try {
histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
}
catch (e: any) {
throw new Error(`ComfyUI disconnected during polling: ${e.message}`)
}
if (histResp.ok) {
const histData = await histResp.json()
if (histData[promptId]) {
let outputs = histData[promptId].outputs
const stats = histData[promptId].status
// 3.1. Race condition protection: If outputs are missing, wait a beat and retry once
if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) {
log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`)
await new Promise(r => setTimeout(r, 1000))
const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
if (retryResp.ok) {
const retryData = await retryResp.json()
if (retryData[promptId] && retryData[promptId].outputs) {
log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`)
outputs = retryData[promptId].outputs
}
}
}
// Log raw history if no images found or if there are status messages
if (stats?.messages && stats.messages.length > 0) {
log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages)
}
// Find first image in any node's output
for (const nodeId in outputs) {
const nodeOutput = outputs[nodeId]
if (nodeOutput.images && nodeOutput.images.length > 0) {
const img = nodeOutput.images[0]
const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`
log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`)
this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' })
historyDone = true
break
}
}
// Job finished but no images
if (!historyDone) {
log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2))
this.updateStatus(jobId, {
actionLabel: 'Error: No images generated',
error: 'Job completed but no images were generated',
status: 'failed',
})
historyDone = true
}
}
}
}
}
catch (error: any) {
const errorMessage = error.message || String(error)
log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`)
this.updateStatus(jobId, {
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
error: errorMessage,
status: 'failed',
})
}
finally {
// Clean up callback and job result after completion to prevent memory leaks
setTimeout(() => {
this.callbacks.delete(jobId)
this.jobResults.delete(jobId)
}, 10000)
} }
const data = await response.json()
return data.name // Returns the filename in ComfyUI's input folder
} }
private replacePlaceholders(obj: any, replacements: Record<string, string>): any { private replacePlaceholders(obj: any, replacements: Record<string, string>): any {
@@ -356,39 +391,4 @@ export class ComfyUIProvider implements ArtistryProvider {
} }
return obj return obj
} }
private updateStatus(jobId: string, status: ArtistryJobStatus) {
this.jobResults.set(jobId, status)
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
}
private async uploadImage(base64Data: string): Promise<string> {
// 1. Clean data URL prefix if present
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
const buffer = Buffer.from(base64, 'base64')
// 2. Prepare multipart form data
const formData = new FormData()
const fileName = `vhack_${Date.now()}.png`
// Electron/Node 18+ fetch handles Blobs in FormData
const blob = new Blob([buffer], { type: 'image/png' })
formData.append('image', blob, fileName)
formData.append('overwrite', 'true')
const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
body: formData,
method: 'POST',
}, 60000) // 1 minute timeout for uploads
if (!response.ok) {
const error = await response.text()
throw new Error(`ComfyUI upload failed: ${error}`)
}
const data = await response.json()
return data.name // Returns the filename in ComfyUI's input folder
}
} }
@@ -8,11 +8,34 @@ export class NanoBananaProvider implements ArtistryProvider {
readonly id = 'nanobanana' readonly id = 'nanobanana'
readonly name = 'Nano Banana (Google AI Studio)' readonly name = 'Nano Banana (Google AI Studio)'
private apiKey = '' private apiKey = ''
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
private defaultModel = 'gemini-1.5-flash' private defaultModel = 'gemini-1.5-flash'
private defaultResolution = '1K' private defaultResolution = '1K'
private jobResults = new Map<string, ArtistryJobStatus>() private jobResults = new Map<string, ArtistryJobStatus>()
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
this.callbacks.set(jobId, callback)
const result = this.jobResults.get(jobId)
if (result)
callback(result)
}
private updateStatus(jobId: string, status: ArtistryJobStatus) {
this.jobResults.set(jobId, status)
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
}
async initialize(config: any) {
this.apiKey = config.nanobananaApiKey || config.apiKey || ''
if (config.nanobananaModel)
this.defaultModel = config.nanobananaModel
if (config.nanobananaResolution)
this.defaultResolution = config.nanobananaResolution
log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`)
}
async generate(request: ArtistryRequest): Promise<ArtistryJob> { async generate(request: ArtistryRequest): Promise<ArtistryJob> {
if (!this.apiKey) { if (!this.apiKey) {
@@ -36,43 +59,23 @@ export class NanoBananaProvider implements ArtistryProvider {
} }
} }
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
return this.jobResults.get(jobId) || { status: 'queued' }
}
async initialize(config: any) {
this.apiKey = config.nanobananaApiKey || config.apiKey || ''
if (config.nanobananaModel)
this.defaultModel = config.nanobananaModel
if (config.nanobananaResolution)
this.defaultResolution = config.nanobananaResolution
log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`)
}
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
this.callbacks.set(jobId, callback)
const result = this.jobResults.get(jobId)
if (result)
callback(result)
}
private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) { private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) {
this.updateStatus(jobId, { actionLabel: 'Inscribing with Nano Banana...', status: 'running' }) this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' })
try { try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}` const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}`
const generationParts: any[] = [{ text: prompt }] const generationParts: any[] = [{ text: prompt }]
if (base64Image) { if (base64Image) {
generationParts.push({ inline_data: { data: base64Image, mime_type: 'image/jpeg' } }) generationParts.push({ inline_data: { mime_type: 'image/jpeg', data: base64Image } })
} }
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
contents: [{ parts: generationParts }], contents: [{ parts: generationParts }],
generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } }, generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } },
}), }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
}) })
const json = await response.json() const json = await response.json()
@@ -87,7 +90,7 @@ export class NanoBananaProvider implements ArtistryProvider {
if (inlineData?.data) { if (inlineData?.data) {
const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}` const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`
this.updateStatus(jobId, { imageUrl: dataUrl, progress: 100, status: 'succeeded' }) this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl: dataUrl })
} }
else { else {
throw new Error('No image data returned from Nano Banana') throw new Error('No image data returned from Nano Banana')
@@ -95,7 +98,7 @@ export class NanoBananaProvider implements ArtistryProvider {
} }
catch (e: any) { catch (e: any) {
log.error(`[Nano Banana] Generation failed: ${e.message}`) log.error(`[Nano Banana] Generation failed: ${e.message}`)
this.updateStatus(jobId, { error: e.message, status: 'failed' }) this.updateStatus(jobId, { status: 'failed', error: e.message })
} }
finally { finally {
// Clean up callback and job result after completion to prevent memory leaks // Clean up callback and job result after completion to prevent memory leaks
@@ -106,10 +109,7 @@ export class NanoBananaProvider implements ArtistryProvider {
} }
} }
private updateStatus(jobId: string, status: ArtistryJobStatus) { async getStatus(jobId: string): Promise<ArtistryJobStatus> {
this.jobResults.set(jobId, status) return this.jobResults.get(jobId) || { status: 'queued' }
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
} }
} }
@@ -11,13 +11,44 @@ export class ReplicateProvider implements ArtistryProvider {
readonly name = 'Replicate.ai (Cloud)' readonly name = 'Replicate.ai (Cloud)'
private apiKey = '' private apiKey = ''
private aspectRatio = '16:9'
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
private defaultModel = 'black-forest-labs/flux-schnell' private defaultModel = 'black-forest-labs/flux-schnell'
private aspectRatio = '16:9'
private inferenceSteps = 4 private inferenceSteps = 4
private replicate: Replicate | null = null
private jobResults = new Map<string, ArtistryJobStatus>() private jobResults = new Map<string, ArtistryJobStatus>()
private replicate: null | Replicate = null private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
this.callbacks.set(jobId, callback)
const result = this.jobResults.get(jobId)
if (result)
callback(result)
}
private updateStatus(jobId: string, status: ArtistryJobStatus) {
this.jobResults.set(jobId, status)
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
}
async initialize(config: any): Promise<void> {
if (config?.replicateApiKey) {
this.apiKey = config.replicateApiKey
this.replicate = new Replicate({ auth: this.apiKey })
}
else {
this.apiKey = ''
this.replicate = null
}
if (config?.replicateDefaultModel)
this.defaultModel = config.replicateDefaultModel
if (config?.replicateAspectRatio)
this.aspectRatio = config.replicateAspectRatio
if (config?.replicateInferenceSteps)
this.inferenceSteps = config.replicateInferenceSteps
}
async generate(request: ArtistryRequest): Promise<ArtistryJob> { async generate(request: ArtistryRequest): Promise<ArtistryJob> {
if (!this.replicate) { if (!this.replicate) {
@@ -30,11 +61,11 @@ export class ReplicateProvider implements ArtistryProvider {
// 1. Start with defaults // 1. Start with defaults
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}') const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
let inputOptions: Record<string, any> = { let inputOptions: Record<string, any> = {
aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
go_fast: request.extra?.go_fast ?? true, go_fast: request.extra?.go_fast ?? true,
num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps, aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
output_format: request.extra?.output_format ?? 'png', output_format: request.extra?.output_format ?? 'png',
output_quality: request.extra?.output_quality ?? 80, output_quality: request.extra?.output_quality ?? 80,
num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps,
} }
// Default prompt injection if NO placeholder is used in overrides // Default prompt injection if NO placeholder is used in overrides
@@ -96,36 +127,8 @@ export class ReplicateProvider implements ArtistryProvider {
return { jobId, providerJobId: jobId } return { jobId, providerJobId: jobId }
} }
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
return this.jobResults.get(jobId) || { status: 'queued' }
}
async initialize(config: any): Promise<void> {
if (config?.replicateApiKey) {
this.apiKey = config.replicateApiKey
this.replicate = new Replicate({ auth: this.apiKey })
}
else {
this.apiKey = ''
this.replicate = null
}
if (config?.replicateDefaultModel)
this.defaultModel = config.replicateDefaultModel
if (config?.replicateAspectRatio)
this.aspectRatio = config.replicateAspectRatio
if (config?.replicateInferenceSteps)
this.inferenceSteps = config.replicateInferenceSteps
}
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
this.callbacks.set(jobId, callback)
const result = this.jobResults.get(jobId)
if (result)
callback(result)
}
private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) { private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) {
this.updateStatus(jobId, { actionLabel: 'Requesting cloud generation...', status: 'running' }) this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' })
try { try {
const output = await this.replicate!.run(model, { input }) const output = await this.replicate!.run(model, { input })
@@ -157,7 +160,7 @@ export class ReplicateProvider implements ArtistryProvider {
if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) { if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) {
log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`) log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`)
this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' }) this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
} }
else { else {
log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`) log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`)
@@ -172,9 +175,9 @@ export class ReplicateProvider implements ArtistryProvider {
const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error)) const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error))
log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`) log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`)
this.updateStatus(jobId, { this.updateStatus(jobId, {
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
error: errorMessage,
status: 'failed', status: 'failed',
error: errorMessage,
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
}) })
} }
finally { finally {
@@ -186,17 +189,14 @@ export class ReplicateProvider implements ArtistryProvider {
} }
} }
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
return this.jobResults.get(jobId) || { status: 'queued' }
}
private truncatePrompt(prompt: string, maxChars: number = 380): string { private truncatePrompt(prompt: string, maxChars: number = 380): string {
if (prompt.length <= maxChars) if (prompt.length <= maxChars)
return prompt return prompt
log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`) log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`)
return `${prompt.slice(0, maxChars)}...` return `${prompt.slice(0, maxChars)}...`
} }
private updateStatus(jobId: string, status: ArtistryJobStatus) {
this.jobResults.set(jobId, status)
const callback = this.callbacks.get(jobId)
if (callback)
callback(status)
}
} }
@@ -12,26 +12,26 @@ describe('widget invoke validation', () => {
describe('validateWidgetsAddPayload', () => { describe('validateWidgetsAddPayload', () => {
it('normalizes add payloads for the widgets manager', () => { it('normalizes add payloads for the widgets manager', () => {
expect(validateWidgetsAddPayload({ expect(validateWidgetsAddPayload({
alwaysOnTop: true, id: ' widget-1 ',
componentName: ' weather ', componentName: ' weather ',
componentProps: { city: 'Tokyo' }, componentProps: { city: 'Tokyo' },
id: ' widget-1 ', alwaysOnTop: true,
ttlMs: 2500.9, ttlMs: 2500.9,
windowSize: { windowSize: {
width: 620.8,
height: 480.2, height: 480.2,
minWidth: 320.9, minWidth: 320.9,
width: 620.8,
}, },
})).toEqual({ })).toEqual({
alwaysOnTop: true, id: 'widget-1',
componentName: 'weather', componentName: 'weather',
componentProps: { city: 'Tokyo' }, componentProps: { city: 'Tokyo' },
id: 'widget-1', alwaysOnTop: true,
ttlMs: 2500, ttlMs: 2500,
windowSize: { windowSize: {
width: 620,
height: 480, height: 480,
minWidth: 320, minWidth: 320,
width: 620,
}, },
}) })
}) })
@@ -53,12 +53,12 @@ describe('widget invoke validation', () => {
expect(() => validateWidgetsAddPayload({ expect(() => validateWidgetsAddPayload({
componentName: 'weather', componentName: 'weather',
windowSize: { height: 320, width: 0 }, windowSize: { width: 0, height: 320 },
} as any)).toThrow('windowSize must contain a positive finite width and height.') } as any)).toThrow('windowSize must contain a positive finite width and height.')
expect(() => validateWidgetsAddPayload({ expect(() => validateWidgetsAddPayload({
alwaysOnTop: 'yes' as any,
componentName: 'weather', componentName: 'weather',
alwaysOnTop: 'yes' as any,
})).toThrow('alwaysOnTop must be a boolean when provided.') })).toThrow('alwaysOnTop must be a boolean when provided.')
}) })
}) })
@@ -66,14 +66,14 @@ describe('widget invoke validation', () => {
describe('validateWidgetsUpdatePayload', () => { describe('validateWidgetsUpdatePayload', () => {
it('normalizes widget updates and keeps optional fields optional', () => { it('normalizes widget updates and keeps optional fields optional', () => {
expect(validateWidgetsUpdatePayload({ expect(validateWidgetsUpdatePayload({
alwaysOnTop: false,
componentProps: { city: 'Taipei' },
id: ' widget-1 ', id: ' widget-1 ',
componentProps: { city: 'Taipei' },
alwaysOnTop: false,
ttlMs: 1500.4, ttlMs: 1500.4,
})).toEqual({ })).toEqual({
alwaysOnTop: false,
componentProps: { city: 'Taipei' },
id: 'widget-1', id: 'widget-1',
componentProps: { city: 'Taipei' },
alwaysOnTop: false,
ttlMs: 1500, ttlMs: 1500,
windowSize: undefined, windowSize: undefined,
}) })
@@ -85,18 +85,18 @@ describe('widget invoke validation', () => {
} as any)).toThrow('id is required to update a widget.') } as any)).toThrow('id is required to update a widget.')
expect(() => validateWidgetsUpdatePayload({ expect(() => validateWidgetsUpdatePayload({
componentProps: [] as any,
id: 'widget-1', id: 'widget-1',
componentProps: [] as any,
})).toThrow('componentProps must be a plain object.') })).toThrow('componentProps must be a plain object.')
expect(() => validateWidgetsUpdatePayload({ expect(() => validateWidgetsUpdatePayload({
id: 'widget-1', id: 'widget-1',
windowSize: { height: 400, width: Number.NaN }, windowSize: { width: Number.NaN, height: 400 },
} as any)).toThrow('windowSize must contain a positive finite width and height.') } as any)).toThrow('windowSize must contain a positive finite width and height.')
expect(() => validateWidgetsUpdatePayload({ expect(() => validateWidgetsUpdatePayload({
alwaysOnTop: 'yes' as any,
id: 'widget-1', id: 'widget-1',
alwaysOnTop: 'yes' as any,
})).toThrow('alwaysOnTop must be a boolean when provided.') })).toThrow('alwaysOnTop must be a boolean when provided.')
}) })
}) })
@@ -117,28 +117,28 @@ describe('widget invoke validation', () => {
it('normalizes successful iframe request results', () => { it('normalizes successful iframe request results', () => {
expect(validateWidgetIframeRequestResult({ expect(validateWidgetIframeRequestResult({
id: ' kit-module:board ', id: ' kit-module:board ',
ok: true,
requestId: ' req-1 ', requestId: ' req-1 ',
ok: true,
result: { fen: 'fen-after-request' }, result: { fen: 'fen-after-request' },
})).toEqual({ })).toEqual({
id: 'kit-module:board', id: 'kit-module:board',
ok: true,
requestId: 'req-1', requestId: 'req-1',
ok: true,
result: { fen: 'fen-after-request' }, result: { fen: 'fen-after-request' },
}) })
}) })
it('normalizes failed iframe request results', () => { it('normalizes failed iframe request results', () => {
expect(validateWidgetIframeRequestResult({ expect(validateWidgetIframeRequestResult({
error: 'Board rejected request.',
id: 'kit-module:board', id: 'kit-module:board',
ok: false,
requestId: 'req-1', requestId: 'req-1',
ok: false,
error: 'Board rejected request.',
})).toEqual({ })).toEqual({
error: 'Board rejected request.',
id: 'kit-module:board', id: 'kit-module:board',
ok: false,
requestId: 'req-1', requestId: 'req-1',
ok: false,
error: 'Board rejected request.',
}) })
}) })
@@ -146,13 +146,13 @@ describe('widget invoke validation', () => {
expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.') expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.')
expect(() => validateWidgetIframeRequestResult({ expect(() => validateWidgetIframeRequestResult({
id: 'kit-module:board', id: 'kit-module:board',
ok: true,
requestId: 'req-1', requestId: 'req-1',
ok: true,
})).toThrow('iframe request result payload must be a plain object.') })).toThrow('iframe request result payload must be a plain object.')
expect(() => validateWidgetIframeRequestResult({ expect(() => validateWidgetIframeRequestResult({
id: 'kit-module:board', id: 'kit-module:board',
ok: false,
requestId: 'req-1', requestId: 'req-1',
ok: false,
})).toThrow('iframe request result error is required.') })).toThrow('iframe request result error is required.')
}) })
}) })
@@ -8,19 +8,126 @@ import { isPlainObject } from 'es-toolkit'
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size' import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
function normalizeWidgetId(value?: string): string | undefined {
if (!value)
return undefined
const normalized = value.trim()
return normalized || undefined
}
function normalizeTtlMs(ttlMs?: number): number {
if (ttlMs === undefined)
return 0
if (!Number.isFinite(ttlMs) || ttlMs < 0)
throw new Error('ttlMs must be a non-negative finite number.')
return Math.floor(ttlMs)
}
function normalizeComponentProps(componentProps?: Record<string, unknown>): Record<string, unknown> {
if (componentProps === undefined)
return {}
if (!isPlainObject(componentProps))
throw new Error('componentProps must be a plain object.')
return componentProps
}
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
if (value === undefined)
return undefined
if (typeof value !== 'boolean')
throw new Error(`${fieldName} must be a boolean when provided.`)
return value
}
/** /**
* Normalizes optional widget ids for open/prepare operations. * Validates and normalizes widget spawn payloads at the Electron invoke boundary.
* *
* Before: * Use when:
* - `" widget-1 "` * - `defineInvokeHandler(...)` receives a widgets add request from a renderer
* - `""`
* *
* After: * Expects:
* - `"widget-1"` * - `componentName` is a non-empty string
* - `undefined` * - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
* - `ttlMs`, when provided, is a non-negative finite number
*
* Returns:
* - A normalized payload safe to pass into the widgets manager
*/ */
export function normalizeOptionalWidgetId(id?: string): string | undefined { export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload {
return normalizeWidgetId(id) if (!payload)
throw new Error('widgets.add requires a payload.')
const componentName = payload.componentName?.trim()
if (!componentName)
throw new Error('componentName is required to spawn a widget.')
const normalizedWindowSize = payload.windowSize === undefined
? undefined
: normalizeWidgetWindowSize(payload.windowSize)
if (payload.windowSize !== undefined && !normalizedWindowSize)
throw new Error('windowSize must contain a positive finite width and height.')
return {
...payload,
id: normalizeWidgetId(payload.id),
componentName,
componentProps: normalizeComponentProps(payload.componentProps),
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
ttlMs: normalizeTtlMs(payload.ttlMs),
windowSize: normalizedWindowSize,
}
}
/**
* Validates and normalizes widget update payloads at the Electron invoke boundary.
*
* Use when:
* - `defineInvokeHandler(...)` receives a widgets update request from a renderer
*
* Expects:
* - `id` is a non-empty string after trimming
* - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
*
* Returns:
* - A normalized payload safe to pass into the widgets manager
*/
export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload {
if (!payload)
throw new Error('widgets.update requires a payload.')
const id = normalizeWidgetId(payload.id)
if (!id)
throw new Error('id is required to update a widget.')
const normalizedWindowSize = payload.windowSize === undefined
? undefined
: normalizeWidgetWindowSize(payload.windowSize)
if (payload.windowSize !== undefined && !normalizedWindowSize)
throw new Error('windowSize must contain a positive finite width and height.')
return {
...payload,
id,
componentProps: payload.componentProps === undefined
? undefined
: normalizeComponentProps(payload.componentProps),
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
ttlMs: payload.ttlMs === undefined
? undefined
: normalizeTtlMs(payload.ttlMs),
windowSize: normalizedWindowSize,
}
} }
/** /**
@@ -43,6 +150,21 @@ export function normalizeRequiredWidgetId(id?: string, reason = 'id is required.
return normalized return normalized
} }
/**
* Normalizes optional widget ids for open/prepare operations.
*
* Before:
* - `" widget-1 "`
* - `""`
*
* After:
* - `"widget-1"`
* - `undefined`
*/
export function normalizeOptionalWidgetId(id?: string): string | undefined {
return normalizeWidgetId(id)
}
/** /**
* Validates iframe-published widget events at the Electron invoke boundary. * Validates iframe-published widget events at the Electron invoke boundary.
* *
@@ -99,8 +221,8 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
return { return {
id, id,
ok: true,
requestId, requestId,
ok: true,
result: result.result, result: result.result,
} }
} }
@@ -111,134 +233,12 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
} }
return { return {
error: result.error,
id, id,
ok: false,
requestId, requestId,
ok: false,
error: result.error,
} }
} }
throw new Error('iframe request result ok must be a boolean.') throw new Error('iframe request result ok must be a boolean.')
} }
/**
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
*
* Use when:
* - `defineInvokeHandler(...)` receives a widgets add request from a renderer
*
* Expects:
* - `componentName` is a non-empty string
* - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
* - `ttlMs`, when provided, is a non-negative finite number
*
* Returns:
* - A normalized payload safe to pass into the widgets manager
*/
export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload {
if (!payload)
throw new Error('widgets.add requires a payload.')
const componentName = payload.componentName?.trim()
if (!componentName)
throw new Error('componentName is required to spawn a widget.')
const normalizedWindowSize = payload.windowSize === undefined
? undefined
: normalizeWidgetWindowSize(payload.windowSize)
if (payload.windowSize !== undefined && !normalizedWindowSize)
throw new Error('windowSize must contain a positive finite width and height.')
return {
...payload,
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
componentName,
componentProps: normalizeComponentProps(payload.componentProps),
id: normalizeWidgetId(payload.id),
ttlMs: normalizeTtlMs(payload.ttlMs),
windowSize: normalizedWindowSize,
}
}
/**
* Validates and normalizes widget update payloads at the Electron invoke boundary.
*
* Use when:
* - `defineInvokeHandler(...)` receives a widgets update request from a renderer
*
* Expects:
* - `id` is a non-empty string after trimming
* - `componentProps`, when provided, is a plain object
* - `alwaysOnTop`, when provided, is a boolean
*
* Returns:
* - A normalized payload safe to pass into the widgets manager
*/
export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload {
if (!payload)
throw new Error('widgets.update requires a payload.')
const id = normalizeWidgetId(payload.id)
if (!id)
throw new Error('id is required to update a widget.')
const normalizedWindowSize = payload.windowSize === undefined
? undefined
: normalizeWidgetWindowSize(payload.windowSize)
if (payload.windowSize !== undefined && !normalizedWindowSize)
throw new Error('windowSize must contain a positive finite width and height.')
return {
...payload,
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
componentProps: payload.componentProps === undefined
? undefined
: normalizeComponentProps(payload.componentProps),
id,
ttlMs: payload.ttlMs === undefined
? undefined
: normalizeTtlMs(payload.ttlMs),
windowSize: normalizedWindowSize,
}
}
function normalizeComponentProps(componentProps?: Record<string, unknown>): Record<string, unknown> {
if (componentProps === undefined)
return {}
if (!isPlainObject(componentProps))
throw new Error('componentProps must be a plain object.')
return componentProps
}
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
if (value === undefined)
return undefined
if (typeof value !== 'boolean')
throw new Error(`${fieldName} must be a boolean when provided.`)
return value
}
function normalizeTtlMs(ttlMs?: number): number {
if (ttlMs === undefined)
return 0
if (!Number.isFinite(ttlMs) || ttlMs < 0)
throw new Error('ttlMs must be a non-negative finite number.')
return Math.floor(ttlMs)
}
function normalizeWidgetId(value?: string): string | undefined {
if (!value)
return undefined
const normalized = value.trim()
return normalized || undefined
}
@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
const appMock = vi.hoisted(() => ({ const appMock = vi.hoisted(() => ({
getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
getVersion: vi.fn(() => '0.9.0-beta.4'), getVersion: vi.fn(() => '0.9.0-beta.4'),
isPackaged: false, getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
quit: vi.fn(), quit: vi.fn(),
isPackaged: false,
})) }))
const isDevState = vi.hoisted(() => ({ const isDevState = vi.hoisted(() => ({
@@ -21,16 +21,16 @@ const updaterState = vi.hoisted(() => ({
function createUpdaterMock() { function createUpdaterMock() {
return { return {
allowPrerelease: false, on: vi.fn(),
autoDownload: true, autoDownload: true,
allowPrerelease: false,
channel: undefined as string | undefined, channel: undefined as string | undefined,
logger: undefined as any,
forceDevUpdateConfig: false,
setFeedURL: vi.fn(),
checkForUpdates: vi.fn().mockResolvedValue(undefined), checkForUpdates: vi.fn().mockResolvedValue(undefined),
downloadUpdate: vi.fn().mockResolvedValue(undefined), downloadUpdate: vi.fn().mockResolvedValue(undefined),
forceDevUpdateConfig: false,
logger: undefined as any,
on: vi.fn(),
quitAndInstall: vi.fn(), quitAndInstall: vi.fn(),
setFeedURL: vi.fn(),
} }
} }
@@ -55,10 +55,10 @@ vi.mock('std-env', () => ({
vi.mock('@guiiai/logg', () => ({ vi.mock('@guiiai/logg', () => ({
useLogg: () => ({ useLogg: () => ({
useGlobalConfig: () => ({ useGlobalConfig: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withError: () => ({ withError: () => ({
error: vi.fn(), error: vi.fn(),
}), }),
@@ -82,35 +82,35 @@ describe('setupAutoUpdater', () => {
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64' const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
const laneReleaseTagMap = { const laneReleaseTagMap = {
alpha: 'v0.9.11-alpha.4',
beta: 'v0.9.10-beta.3',
latest: 'v0.9.12-nightly.7', latest: 'v0.9.12-nightly.7',
nightly: 'v0.9.12-nightly.7',
stable: 'v0.9.9', stable: 'v0.9.9',
beta: 'v0.9.10-beta.3',
alpha: 'v0.9.11-alpha.4',
nightly: 'v0.9.12-nightly.7',
} as const } as const
const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const
const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const
const defaultReleases = [ const defaultReleases = [
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
] ]
const matrixReleases = [ const matrixReleases = [
{ draft: false, prerelease: false, tag_name: 'v0.9.7' }, { tag_name: 'v0.9.7', draft: false, prerelease: false },
{ draft: false, prerelease: false, tag_name: 'v0.9.9' }, { tag_name: 'v0.9.9', draft: false, prerelease: false },
{ draft: false, prerelease: true, tag_name: 'v0.9.9-beta.1' }, { tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.10-beta.3' }, { tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.10-alpha.5' }, { tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.11-alpha.4' }, { tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.11-nightly.1' }, { tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.12-nightly.7' }, { tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true },
] ]
function mockGitHubReleasesFetch(releases = defaultReleases) { function mockGitHubReleasesFetch(releases = defaultReleases) {
const fetchSpy = vi.fn().mockResolvedValue({ const fetchSpy = vi.fn().mockResolvedValue({
json: async () => releases,
ok: true, ok: true,
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
json: async () => releases,
}) })
vi.stubGlobal('fetch', fetchSpy) vi.stubGlobal('fetch', fetchSpy)
return fetchSpy return fetchSpy
@@ -131,8 +131,8 @@ describe('setupAutoUpdater', () => {
it('resolves release tag from GitHub API and configures generic provider for checks', async () => { it('resolves release tag from GitHub API and configures generic provider for checks', async () => {
const fetchSpy = mockGitHubReleasesFetch([ const fetchSpy = mockGitHubReleasesFetch([
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.5' }, { tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true },
]) ])
const { setupAutoUpdater } = await import('./auto-updater') const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater() const service = setupAutoUpdater()
@@ -198,9 +198,9 @@ describe('setupAutoUpdater', () => {
it('supports explicit stable lane selection for future dynamic channel switching', async () => { it('supports explicit stable lane selection for future dynamic channel switching', async () => {
process.env.AIRI_UPDATE_CHANNEL = 'stable' process.env.AIRI_UPDATE_CHANNEL = 'stable'
mockGitHubReleasesFetch([ mockGitHubReleasesFetch([
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' }, { tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
{ draft: false, prerelease: false, tag_name: 'v0.8.9' }, { tag_name: 'v0.8.9', draft: false, prerelease: false },
{ draft: false, prerelease: false, tag_name: 'v0.8.8' }, { tag_name: 'v0.8.8', draft: false, prerelease: false },
]) ])
const { setupAutoUpdater } = await import('./auto-updater') const { setupAutoUpdater } = await import('./auto-updater')
@@ -274,12 +274,12 @@ describe('setupAutoUpdater', () => {
const service = setupAutoUpdater() const service = setupAutoUpdater()
expect(service.state.diagnostics).toEqual(expect.objectContaining({ expect(service.state.diagnostics).toEqual(expect.objectContaining({
platform: process.platform,
arch: process.arch, arch: process.arch,
channel: expectedChannelByArch, channel: expectedChannelByArch,
executablePath: expect.any(String), executablePath: expect.any(String),
isOverrideActive: false,
logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/), logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/),
platform: process.platform, isOverrideActive: false,
})) }))
expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir') expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir')
expect(service.state.diagnostics).not.toHaveProperty('pendingDir') expect(service.state.diagnostics).not.toHaveProperty('pendingDir')
@@ -49,10 +49,10 @@ function getCacheRoot() {
function getLegacyCacheRoot() { function getLegacyCacheRoot() {
switch (process.platform) { switch (process.platform) {
case 'darwin':
return join(process.env.HOME || '', 'Library', 'Caches')
case 'win32': case 'win32':
return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local') return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local')
case 'darwin':
return join(process.env.HOME || '', 'Library', 'Caches')
default: default:
return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache') return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache')
} }
@@ -63,30 +63,199 @@ const UPDATER_LOG_FILE = join(UPDATER_DEBUG_CACHE_DIR, 'updater-log.txt')
const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater') const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater')
const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater') const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater')
const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([ const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([
LEGACY_OFFICIAL_UPDATER_CACHE_DIR,
OFFICIAL_UPDATER_CACHE_DIR, OFFICIAL_UPDATER_CACHE_DIR,
LEGACY_OFFICIAL_UPDATER_CACHE_DIR,
])) ]))
async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) {
await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {})
}
async function cleanupStaleUpdateFiles() {
// Remove both current and legacy updater cache roots so stale installers do not linger.
await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { recursive: true, force: true })))
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
}
export type UpdateLane = ElectronUpdaterChannel
interface GitHubReleaseRecord {
tag_name?: string
draft?: boolean
prerelease?: boolean
}
function getUpdateServerOverride() {
// NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness.
// Production update routing must not depend on this variable.
if (!is.dev)
return undefined
const value = process.env.UPDATE_SERVER_URL?.trim()
return value || undefined
}
function normalizeLane(value: string | undefined): UpdateLane | undefined {
if (!value)
return undefined
switch (value.toLowerCase()) {
case 'stable':
case 'latest':
case 'alpha':
case 'beta':
case 'nightly':
case 'canary':
return value.toLowerCase() as UpdateLane
default:
return undefined
}
}
function laneFromVersion(version: string): UpdateLane {
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
return normalizeLane(prerelease) ?? 'stable'
}
function getPreferredUpdateLane(params: { version: string, storedLane?: UpdateLane }): UpdateLane {
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version)
}
function getSemverFromTag(tag: string) {
return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag)
}
function isTagInLane(tag: string, lane: UpdateLane) {
const version = getSemverFromTag(tag)
if (!version)
return false
if (lane === 'latest')
return true
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
if (lane === 'stable')
return !prerelease
return prerelease === lane
}
function isPathInside(parentPath: string, targetPath: string) {
const normalizedParent = normalize(parentPath)
const normalizedTarget = normalize(targetPath)
const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\`
return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator)
}
function getWindowsProtectedInstallRoots() {
return [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.ProgramW6432,
process.env.SystemRoot,
process.env.windir,
]
.filter((value): value is string => Boolean(value))
.map(value => normalize(value))
}
function requiresAdminForInstallPath(executablePath: string) {
if (!isWindows)
return false
const installDirectory = dirname(executablePath)
return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory))
}
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
const candidates = releases
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
.map((release) => {
const tag = release.tag_name as string
const version = getSemverFromTag(tag)
return version ? { tag, version } : null
})
.filter(Boolean) as Array<{ tag: string, version: string }>
candidates.sort((a, b) => semver.rcompare(a.version, b.version))
return candidates[0]?.tag
}
/**
* Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies.
*
* The current feed contains entries like:
* `<entry><link rel="alternate" type="text/html" href="https://github.com/moeru-ai/airi/releases/tag/v0.9.0-beta.6"/></entry>`
* and
* `<entry><id>tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36</id></entry>`
*
* We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links.
*/
function extractReleaseTagsFromAtom(atom: string) {
const tags: string[] = []
const marker = '/moeru-ai/airi/releases/tag/'
let offset = 0
while (offset < atom.length) {
const markerIndex = atom.indexOf(marker, offset)
if (markerIndex === -1)
break
const start = markerIndex + marker.length
let end = start
while (end < atom.length) {
const char = atom[end]
if (char === '"' || char === '<' || char === '?' || char === '&')
break
end += 1
}
// Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`.
const rawTag = atom.slice(start, end).trim()
// Atom encodes URLs, so decode in case future tags contain escaped characters.
const decodedTag = decodeURIComponent(rawTag)
// Feed entries can repeat across updates; keep a unique ordered tag list.
if (decodedTag && !tags.includes(decodedTag))
tags.push(decodedTag)
offset = end + 1
}
return tags
}
export interface AppUpdaterLike { export interface AppUpdaterLike {
on: (event: string, listener: (...args: any[]) => void) => any
checkForUpdates: () => Promise<any>
downloadUpdate: () => Promise<any>
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise<void> | void
setFeedURL?: (options: { provider: 'generic', url: string }) => void
logger?: any
allowPrerelease?: boolean allowPrerelease?: boolean
autoDownload?: boolean autoDownload?: boolean
channel?: string channel?: string
checkForUpdates: () => Promise<any>
downloadUpdate: () => Promise<any>
forceDevUpdateConfig?: boolean forceDevUpdateConfig?: boolean
logger?: any
on: (event: string, listener: (...args: any[]) => void) => any
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise<void> | void
setFeedURL?: (options: { provider: 'generic', url: string }) => void
} }
// NOTICE: this part of code is copied from https://www.electron.build/auto-update
// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66
export function fromImported(): AppUpdaterLike {
if (is.dev && !getUpdateServerOverride())
return new MockAutoUpdater()
const { autoUpdater } = electronUpdater
return autoUpdater as unknown as AppUpdaterLike
}
type MainContext = ReturnType<typeof createContext>['context']
export interface AutoUpdater { export interface AutoUpdater {
state: AutoUpdaterState
checkForUpdates: () => Promise<void> checkForUpdates: () => Promise<void>
downloadUpdate: () => Promise<void> downloadUpdate: () => Promise<void>
getPreferredUpdateLane: () => undefined | UpdateLane
quitAndInstall: () => Promise<void> quitAndInstall: () => Promise<void>
setPreferredUpdateLane: (lane: undefined | UpdateLane) => Promise<void> getPreferredUpdateLane: () => UpdateLane | undefined
state: AutoUpdaterState setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise<void>
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
} }
@@ -98,74 +267,42 @@ export interface AutoUpdaterOptions {
*/ */
enabled?: boolean enabled?: boolean
/** Reads the release channel persisted by the application configuration. */ /** Reads the release channel persisted by the application configuration. */
getStoredUpdateLane?: () => undefined | UpdateLane getStoredUpdateLane?: () => UpdateLane | undefined
/** Persists a release-channel change requested through updater IPC. */ /** Persists a release-channel change requested through updater IPC. */
setStoredUpdateLane?: (lane: undefined | UpdateLane) => void setStoredUpdateLane?: (lane: UpdateLane | undefined) => void
}
export type UpdateLane = ElectronUpdaterChannel
interface GitHubReleaseRecord {
draft?: boolean
prerelease?: boolean
tag_name?: string
} }
type MainContext = ReturnType<typeof createContext>['context'] function isPrereleaseVersion(version: string) {
return (semver.prerelease(version)?.length ?? 0) > 0
}
export function createAutoUpdaterService(params: { context: MainContext, service: AutoUpdater, window: BrowserWindow }) { /**
const { context, service, window } = params * Preserves the updater IPC contract when the storefront owns application updates.
*
* No method reaches Electron Updater or a release feed, while preference reads and
* subscriptions remain available to existing renderer consumers.
*/
function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater {
const state: AutoUpdaterState = { status: 'disabled' }
let storedPreferredLane = options.getStoredUpdateLane?.()
const log = useLogg('auto-updater-service').useGlobalConfig() return {
state,
const unsubscribe = service.subscribe((state) => { async checkForUpdates() {},
if (window.isDestroyed()) async downloadUpdate() {},
return async quitAndInstall() {},
getPreferredUpdateLane() {
tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state)) return storedPreferredLane
}) },
async setPreferredUpdateLane(lane) {
const cleanups: Array<() => void> = [ storedPreferredLane = lane
unsubscribe, options.setStoredUpdateLane?.(lane)
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state), },
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => { subscribe(callback) {
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed')) callback(state)
return service.state return () => {}
}), },
defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => {
await service.downloadUpdate()
return service.state
}),
defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({
channel: service.getPreferredUpdateLane(),
})),
defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => {
await service.setPreferredUpdateLane(payload?.channel)
return {
channel: service.getPreferredUpdateLane(),
}
}),
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
await service.quitAndInstall()
}),
]
const cleanup = () => {
for (const fn of cleanups)
fn()
} }
window.on('closed', cleanup)
return cleanup
}
// NOTICE: this part of code is copied from https://www.electron.build/auto-update
// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66
export function fromImported(): AppUpdaterLike {
if (is.dev && !getUpdateServerOverride())
return new MockAutoUpdater()
const { autoUpdater } = electronUpdater
return autoUpdater as unknown as AppUpdaterLike
} }
export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater { export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
@@ -191,14 +328,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
autoUpdater.channel = releaseChannelName autoUpdater.channel = releaseChannelName
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
autoUpdater.logger = { autoUpdater.logger = {
debug: (message: string) => {
log.debug(message)
void logToFile('DEBUG', message)
},
error: (message: string) => {
log.error(message)
void logToFile('ERROR', message)
},
info: (message: string) => { info: (message: string) => {
log.log(message) log.log(message)
void logToFile('INFO', message) void logToFile('INFO', message)
@@ -207,6 +336,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
log.warn(message) log.warn(message)
void logToFile('WARN', message) void logToFile('WARN', message)
}, },
error: (message: string) => {
log.error(message)
void logToFile('ERROR', message)
},
debug: (message: string) => {
log.debug(message)
void logToFile('DEBUG', message)
},
} }
if (activeFeedUrlOverride) if (activeFeedUrlOverride)
@@ -215,14 +352,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({ const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
...next, ...next,
diagnostics: { diagnostics: {
platform: process.platform,
arch: process.arch, arch: process.arch,
channel: autoUpdater.channel || releaseChannelName, channel: autoUpdater.channel || releaseChannelName,
logFilePath: UPDATER_LOG_FILE,
executablePath: process.execPath, executablePath: process.execPath,
installDirectory: dirname(process.execPath), installDirectory: dirname(process.execPath),
isOverrideActive: !!activeFeedUrlOverride,
logFilePath: UPDATER_LOG_FILE,
platform: process.platform,
requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath), requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath),
isOverrideActive: !!activeFeedUrlOverride,
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}), ...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
}, },
}) })
@@ -245,8 +382,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
function broadcastUpdaterError(error: unknown, reason: string) { function broadcastUpdaterError(error: unknown, reason: string) {
broadcast({ broadcast({
error: { message: errorMessageFromValue(error) },
status: 'error', status: 'error',
error: { message: errorMessageFromValue(error) },
}) })
log.withError(error).error(reason) log.withError(error).error(reason)
} }
@@ -315,7 +452,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
} }
prepareFeedPromise = (async () => { prepareFeedPromise = (async () => {
const preferredLane = getPreferredUpdateLane({ storedLane: storedPreferredLane, version: appVersion }) const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane })
const tag = await resolveGitHubReleaseTagForLane(preferredLane) const tag = await resolveGitHubReleaseTagForLane(preferredLane)
resolvedReleaseTag = tag resolvedReleaseTag = tag
applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`) applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`)
@@ -336,31 +473,34 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error')) autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error'))
autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' })) autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' }))
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ info, status: 'available' })) autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info }))
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ info, status: 'downloaded' })) autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info }))
autoUpdater.on('update-not-available', () => broadcast({ autoUpdater.on('update-not-available', () => broadcast({
status: 'not-available',
info: { info: {
version: app.getVersion(),
files: [], files: [],
releaseDate: committerDate, releaseDate: committerDate,
version: app.getVersion(),
}, },
status: 'not-available',
})) }))
autoUpdater.on('download-progress', progress => broadcast({ autoUpdater.on('download-progress', progress => broadcast({
...state, ...state,
progress: {
bytesPerSecond: progress.bytesPerSecond,
percent: progress.percent,
total: progress.total,
transferred: progress.transferred,
},
status: 'downloading', status: 'downloading',
progress: {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
},
})) }))
void checkForUpdatesWithPreparedFeed() void checkForUpdatesWithPreparedFeed()
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) .catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
return { return {
get state() {
return state
},
async checkForUpdates() { async checkForUpdates() {
broadcast({ status: 'checking' }) broadcast({ status: 'checking' })
await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed')) await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
@@ -378,9 +518,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
semaphore.release() semaphore.release()
} }
}, },
getPreferredUpdateLane() {
return storedPreferredLane
},
async quitAndInstall() { async quitAndInstall() {
await semaphore.acquire() await semaphore.acquire()
@@ -394,6 +531,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
semaphore.release() semaphore.release()
} }
}, },
getPreferredUpdateLane() {
return storedPreferredLane
},
async setPreferredUpdateLane(lane) { async setPreferredUpdateLane(lane) {
if (storedPreferredLane === lane) if (storedPreferredLane === lane)
return return
@@ -405,9 +545,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
// A fresh check runs right after channel update from renderer. // A fresh check runs right after channel update from renderer.
broadcast({ status: 'idle' }) broadcast({ status: 'idle' })
}, },
get state() {
return state
},
subscribe(callback) { subscribe(callback) {
hooks.add(callback) hooks.add(callback)
@@ -423,185 +560,48 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
} }
} }
async function cleanupStaleUpdateFiles() { export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) {
// Remove both current and legacy updater cache roots so stale installers do not linger. const { context, window, service } = params
await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { force: true, recursive: true })))
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
}
/** const log = useLogg('auto-updater-service').useGlobalConfig()
* Preserves the updater IPC contract when the storefront owns application updates.
*
* No method reaches Electron Updater or a release feed, while preference reads and
* subscriptions remain available to existing renderer consumers.
*/
function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater {
const state: AutoUpdaterState = { status: 'disabled' }
let storedPreferredLane = options.getStoredUpdateLane?.()
return { const unsubscribe = service.subscribe((state) => {
async checkForUpdates() {}, if (window.isDestroyed())
async downloadUpdate() {}, return
getPreferredUpdateLane() {
return storedPreferredLane
},
async quitAndInstall() {},
async setPreferredUpdateLane(lane) {
storedPreferredLane = lane
options.setStoredUpdateLane?.(lane)
},
state,
subscribe(callback) {
callback(state)
return () => {}
},
}
}
/** tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state))
* Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies. })
*
* The current feed contains entries like:
* `<entry><link rel="alternate" type="text/html" href="https://github.com/moeru-ai/airi/releases/tag/v0.9.0-beta.6"/></entry>`
* and
* `<entry><id>tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36</id></entry>`
*
* We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links.
*/
function extractReleaseTagsFromAtom(atom: string) {
const tags: string[] = []
const marker = '/moeru-ai/airi/releases/tag/'
let offset = 0
while (offset < atom.length) { const cleanups: Array<() => void> = [
const markerIndex = atom.indexOf(marker, offset) unsubscribe,
if (markerIndex === -1) defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
break defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
const start = markerIndex + marker.length return service.state
let end = start }),
while (end < atom.length) { defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => {
const char = atom[end] await service.downloadUpdate()
if (char === '"' || char === '<' || char === '?' || char === '&') return service.state
break }),
end += 1 defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({
} channel: service.getPreferredUpdateLane(),
})),
// Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`. defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => {
const rawTag = atom.slice(start, end).trim() await service.setPreferredUpdateLane(payload?.channel)
// Atom encodes URLs, so decode in case future tags contain escaped characters. return {
const decodedTag = decodeURIComponent(rawTag) channel: service.getPreferredUpdateLane(),
// Feed entries can repeat across updates; keep a unique ordered tag list. }
if (decodedTag && !tags.includes(decodedTag)) }),
tags.push(decodedTag) defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
await service.quitAndInstall()
offset = end + 1 }),
}
return tags
}
function getPreferredUpdateLane(params: { storedLane?: UpdateLane, version: string }): UpdateLane {
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version)
}
function getSemverFromTag(tag: string) {
return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag)
}
function getUpdateServerOverride() {
// NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness.
// Production update routing must not depend on this variable.
if (!is.dev)
return undefined
const value = process.env.UPDATE_SERVER_URL?.trim()
return value || undefined
}
function getWindowsProtectedInstallRoots() {
return [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.ProgramW6432,
process.env.SystemRoot,
process.env.windir,
] ]
.filter((value): value is string => Boolean(value))
.map(value => normalize(value))
}
function isPathInside(parentPath: string, targetPath: string) { const cleanup = () => {
const normalizedParent = normalize(parentPath) for (const fn of cleanups)
const normalizedTarget = normalize(targetPath) fn()
const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\`
return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator)
}
function isPrereleaseVersion(version: string) {
return (semver.prerelease(version)?.length ?? 0) > 0
}
function isTagInLane(tag: string, lane: UpdateLane) {
const version = getSemverFromTag(tag)
if (!version)
return false
if (lane === 'latest')
return true
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
if (lane === 'stable')
return !prerelease
return prerelease === lane
}
function laneFromVersion(version: string): UpdateLane {
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
return normalizeLane(prerelease) ?? 'stable'
}
async function logToFile(level: 'DEBUG' | 'ERROR' | 'INFO' | 'WARN', message: string) {
await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {})
}
function normalizeLane(value: string | undefined): undefined | UpdateLane {
if (!value)
return undefined
switch (value.toLowerCase()) {
case 'alpha':
case 'beta':
case 'canary':
case 'latest':
case 'nightly':
case 'stable':
return value.toLowerCase() as UpdateLane
default:
return undefined
} }
}
function requiresAdminForInstallPath(executablePath: string) { window.on('closed', cleanup)
if (!isWindows) return cleanup
return false
const installDirectory = dirname(executablePath)
return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory))
}
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
const candidates = releases
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
.map((release) => {
const tag = release.tag_name as string
const version = getSemverFromTag(tag)
return version ? { tag, version } : null
})
.filter(Boolean) as Array<{ tag: string, version: string }>
candidates.sort((a, b) => semver.rcompare(a.version, b.version))
return candidates[0]?.tag
} }
@@ -3,24 +3,6 @@ import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
interface KeyboardEvent {
altKey: boolean
ctrlKey: boolean
keycode: number
metaKey: boolean
shiftKey: boolean
}
function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>): KeyboardEvent {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...partial,
}
}
/** /**
* Builds a binding for the uiohook driver. * Builds a binding for the uiohook driver.
* *
@@ -35,10 +17,28 @@ function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>)
*/ */
function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding { function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding {
return { return {
accelerator: { key, modifiers },
id, id,
receiveKeyUps: true, accelerator: { modifiers, key },
scope: 'global', scope: 'global',
receiveKeyUps: true,
}
}
interface KeyboardEvent {
keycode: number
altKey: boolean
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
}
function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>): KeyboardEvent {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...partial,
} }
} }
@@ -78,8 +78,8 @@ async function setupMocks() {
// mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so // mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so
// tests assert real keycodes, not arbitrary numbers). // tests assert real keycodes, not arbitrary numbers).
const UiohookKey = { const UiohookKey = {
A: 30,
K: 37, K: 37,
A: 30,
Q: 16, Q: 16,
} as const } as const
@@ -118,17 +118,17 @@ async function setupMocks() {
platform: overrides.platform ?? 'darwin', platform: overrides.platform ?? 'darwin',
sessionType: overrides.sessionType, sessionType: overrides.sessionType,
}) })
return { broadcastTriggered, driver, logger } return { driver, broadcastTriggered, logger }
} }
return { return {
createDriver,
fire,
isTrustedAccessibilityClientMock,
onMock, onMock,
removeListenerMock, removeListenerMock,
startMock, startMock,
stopMock, stopMock,
isTrustedAccessibilityClientMock,
fire,
createDriver,
} }
} }
@@ -171,7 +171,7 @@ describe('createUiohookDriver', () => {
it('broadcasts a "down" event when a matching keydown arrives', async () => { it('broadcasts a "down" event when a matching keydown arrives', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, shiftKey: true })) m.fire('keydown', event({ keycode: 37, shiftKey: true }))
@@ -189,7 +189,7 @@ describe('createUiohookDriver', () => {
// would emit hundreds of `down` broadcasts per second and the mic // would emit hundreds of `down` broadcasts per second and the mic
// would start/stop frantically. // would start/stop frantically.
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, shiftKey: true })) m.fire('keydown', event({ keycode: 37, shiftKey: true }))
@@ -202,7 +202,7 @@ describe('createUiohookDriver', () => {
it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => { it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, shiftKey: true })) m.fire('keydown', event({ keycode: 37, shiftKey: true }))
@@ -222,7 +222,7 @@ describe('createUiohookDriver', () => {
// The driver keys the "up" broadcast off the prior `pressed` // The driver keys the "up" broadcast off the prior `pressed`
// state rather than the modifier predicate. // state rather than the modifier predicate.
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, metaKey: true })) m.fire('keydown', event({ keycode: 37, metaKey: true }))
@@ -234,7 +234,7 @@ describe('createUiohookDriver', () => {
it('ignores keyup when no matching keydown was tracked', async () => { it('ignores keyup when no matching keydown was tracked', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
m.fire('keyup', event({ keycode: 37, shiftKey: true })) m.fire('keyup', event({ keycode: 37, shiftKey: true }))
@@ -246,21 +246,21 @@ describe('createUiohookDriver', () => {
// Strict matching mirrors Electron's accelerator semantics: a // Strict matching mirrors Electron's accelerator semantics: a
// `Shift+K` binding must not fire on `Cmd+Shift+K`. // `Shift+K` binding must not fire on `Cmd+Shift+K`.
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, metaKey: true, shiftKey: true })) m.fire('keydown', event({ keycode: 37, shiftKey: true, metaKey: true }))
expect(broadcastTriggered).not.toHaveBeenCalled() expect(broadcastTriggered).not.toHaveBeenCalled()
}) })
it('maps cmd-or-ctrl to metaKey on darwin', async () => { it('maps cmd-or-ctrl to metaKey on darwin', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver({ platform: 'darwin' }) const { driver, broadcastTriggered } = m.createDriver({ platform: 'darwin' })
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
m.fire('keydown', event({ keycode: 37, metaKey: true })) m.fire('keydown', event({ keycode: 37, metaKey: true }))
m.fire('keydown', event({ ctrlKey: true, keycode: 37 })) m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
expect(broadcastTriggered).toHaveBeenCalledTimes(1) expect(broadcastTriggered).toHaveBeenCalledTimes(1)
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down') expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
@@ -268,10 +268,10 @@ describe('createUiohookDriver', () => {
it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => { it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver({ platform: 'win32' }) const { driver, broadcastTriggered } = m.createDriver({ platform: 'win32' })
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK')) driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
m.fire('keydown', event({ ctrlKey: true, keycode: 37 })) m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
m.fire('keydown', event({ keycode: 37, metaKey: true })) m.fire('keydown', event({ keycode: 37, metaKey: true }))
// First (ctrl) matches; second (meta) does not — the pressed // First (ctrl) matches; second (meta) does not — the pressed
@@ -327,7 +327,7 @@ describe('createUiohookDriver', () => {
it('unregisterAll clears every binding and stops the hook in one shot', async () => { it('unregisterAll clears every binding and stops the hook in one shot', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
@@ -353,7 +353,7 @@ describe('createUiohookDriver', () => {
it('keeps per-binding pressed state independent across multiple bindings', async () => { it('keeps per-binding pressed state independent across multiple bindings', async () => {
const m = await setupMocks() const m = await setupMocks()
const { broadcastTriggered, driver } = m.createDriver() const { driver, broadcastTriggered } = m.createDriver()
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA')) driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ')) driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
@@ -17,26 +17,128 @@ import { uIOhook, UiohookKey } from 'uiohook-napi'
type Logger = ReturnType<ReturnType<typeof useLogg>['useGlobalConfig']> type Logger = ReturnType<ReturnType<typeof useLogg>['useGlobalConfig']>
interface ModifierMask { interface ModifierMask {
alt: boolean
ctrl: boolean ctrl: boolean
meta: boolean
shift: boolean shift: boolean
alt: boolean
meta: boolean
} }
interface UiohookEntry { interface UiohookEntry {
binding: ShortcutBinding binding: ShortcutBinding
expectedKeycode: number
predicate: (event: UiohookKeyboardEvent) => boolean predicate: (event: UiohookKeyboardEvent) => boolean
expectedKeycode: number
pressed: boolean pressed: boolean
} }
const W3C_TO_UIOHOOK: Readonly<Record<ShortcutKey, number>> = buildKeycodeMap() const W3C_TO_UIOHOOK: Readonly<Record<ShortcutKey, number>> = buildKeycodeMap()
export interface UiohookDriver { function buildKeycodeMap(): Record<ShortcutKey, number> {
dispose: () => void const map: Record<string, number> = {}
tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult
unregisterAll: () => void for (let i = 0; i < 26; i++) {
unregisterById: (id: string) => void const letter = String.fromCharCode(65 + i)
map[`Key${letter}`] = (UiohookKey as unknown as Record<string, number>)[letter]
}
for (let i = 0; i <= 9; i++) {
map[`Digit${i}`] = (UiohookKey as unknown as Record<string, number>)[String(i)]
}
for (let i = 1; i <= 24; i++) {
map[`F${i}`] = (UiohookKey as unknown as Record<string, number>)[`F${i}`]
}
const named: Record<string, keyof typeof UiohookKey> = {
Space: 'Space',
Tab: 'Tab',
Enter: 'Enter',
Escape: 'Escape',
Backspace: 'Backspace',
Delete: 'Delete',
Insert: 'Insert',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
Backquote: 'Backquote',
Minus: 'Minus',
Equal: 'Equal',
BracketLeft: 'BracketLeft',
BracketRight: 'BracketRight',
Backslash: 'Backslash',
Semicolon: 'Semicolon',
Quote: 'Quote',
Comma: 'Comma',
Period: 'Period',
Slash: 'Slash',
}
for (const [w3c, uioName] of Object.entries(named))
map[w3c] = (UiohookKey as unknown as Record<string, number>)[uioName as string]
return map
}
function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask {
const mask: ModifierMask = { ctrl: false, shift: false, alt: false, meta: false }
for (const m of modifiers) {
switch (m) {
case 'cmd-or-ctrl':
if (platform === 'darwin')
mask.meta = true
else
mask.ctrl = true
break
case 'cmd':
case 'super':
// libuiohook surfaces macOS Cmd, Windows key, and X11 Super
// through the same `metaKey` flag.
mask.meta = true
break
case 'ctrl':
mask.ctrl = true
break
case 'alt':
mask.alt = true
break
case 'shift':
mask.shift = true
break
}
}
return mask
}
function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): { predicate: UiohookEntry['predicate'], expectedKeycode: number } | undefined {
const expectedKeycode = W3C_TO_UIOHOOK[acc.key]
if (expectedKeycode === undefined)
return undefined
const required = resolveModifierMask(acc.modifiers, platform)
const predicate: UiohookEntry['predicate'] = e =>
e.keycode === expectedKeycode
&& e.ctrlKey === required.ctrl
&& e.shiftKey === required.shift
&& e.altKey === required.alt
&& e.metaKey === required.meta
return { predicate, expectedKeycode }
}
function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean {
return platform === 'linux' && sessionType === 'wayland'
}
function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean {
if (platform !== 'darwin')
return true
try {
return systemPreferences.isTrustedAccessibilityClient(prompt)
}
catch {
return true
}
} }
export interface UiohookDriverOptions { export interface UiohookDriverOptions {
@@ -58,6 +160,13 @@ export interface UiohookDriverOptions {
sessionType?: string sessionType?: string
} }
export interface UiohookDriver {
tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult
unregisterById: (id: string) => void
unregisterAll: () => void
dispose: () => void
}
/** /**
* Driver that captures global key-down and key-up events via * Driver that captures global key-down and key-up events via
* libuiohook (through `uiohook-napi`). * libuiohook (through `uiohook-napi`).
@@ -173,8 +282,8 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
entries.set(binding.id, { entries.set(binding.id, {
binding, binding,
expectedKeycode: built.expectedKeycode,
predicate: built.predicate, predicate: built.predicate,
expectedKeycode: built.expectedKeycode,
pressed: false, pressed: false,
}) })
ensureListeners() ensureListeners()
@@ -204,114 +313,5 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
} }
} }
return { dispose, tryRegister, unregisterAll, unregisterById } return { tryRegister, unregisterById, unregisterAll, dispose }
}
function buildKeycodeMap(): Record<ShortcutKey, number> {
const map: Record<string, number> = {}
for (let i = 0; i < 26; i++) {
const letter = String.fromCharCode(65 + i)
map[`Key${letter}`] = (UiohookKey as unknown as Record<string, number>)[letter]
}
for (let i = 0; i <= 9; i++) {
map[`Digit${i}`] = (UiohookKey as unknown as Record<string, number>)[String(i)]
}
for (let i = 1; i <= 24; i++) {
map[`F${i}`] = (UiohookKey as unknown as Record<string, number>)[`F${i}`]
}
const named: Record<string, keyof typeof UiohookKey> = {
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
ArrowUp: 'ArrowUp',
Backquote: 'Backquote',
Backslash: 'Backslash',
Backspace: 'Backspace',
BracketLeft: 'BracketLeft',
BracketRight: 'BracketRight',
Comma: 'Comma',
Delete: 'Delete',
End: 'End',
Enter: 'Enter',
Equal: 'Equal',
Escape: 'Escape',
Home: 'Home',
Insert: 'Insert',
Minus: 'Minus',
PageDown: 'PageDown',
PageUp: 'PageUp',
Period: 'Period',
Quote: 'Quote',
Semicolon: 'Semicolon',
Slash: 'Slash',
Space: 'Space',
Tab: 'Tab',
}
for (const [w3c, uioName] of Object.entries(named))
map[w3c] = (UiohookKey as unknown as Record<string, number>)[uioName as string]
return map
}
function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): undefined | { expectedKeycode: number, predicate: UiohookEntry['predicate'] } {
const expectedKeycode = W3C_TO_UIOHOOK[acc.key]
if (expectedKeycode === undefined)
return undefined
const required = resolveModifierMask(acc.modifiers, platform)
const predicate: UiohookEntry['predicate'] = e =>
e.keycode === expectedKeycode
&& e.ctrlKey === required.ctrl
&& e.shiftKey === required.shift
&& e.altKey === required.alt
&& e.metaKey === required.meta
return { expectedKeycode, predicate }
}
function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean {
if (platform !== 'darwin')
return true
try {
return systemPreferences.isTrustedAccessibilityClient(prompt)
}
catch {
return true
}
}
function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean {
return platform === 'linux' && sessionType === 'wayland'
}
function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask {
const mask: ModifierMask = { alt: false, ctrl: false, meta: false, shift: false }
for (const m of modifiers) {
switch (m) {
case 'alt':
mask.alt = true
break
case 'cmd':
case 'super':
// libuiohook surfaces macOS Cmd, Windows key, and X11 Super
// through the same `metaKey` flag.
mask.meta = true
break
case 'cmd-or-ctrl':
if (platform === 'darwin')
mask.meta = true
else
mask.ctrl = true
break
case 'ctrl':
mask.ctrl = true
break
case 'shift':
mask.shift = true
break
}
}
return mask
} }
@@ -6,28 +6,23 @@ import type { EventaContext } from './global-shortcut'
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut' import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding {
return {
id,
accelerator: { modifiers: ['cmd-or-ctrl', 'shift'], key },
scope: 'global',
}
}
interface MockContext { interface MockContext {
emit: ReturnType<typeof vi.fn> emit: ReturnType<typeof vi.fn>
invokeHandlers: Map<string, (payload: unknown) => unknown> invokeHandlers: Map<string, (payload: unknown) => unknown>
} }
interface MockWindow { interface MockWindow {
on: ReturnType<typeof vi.fn>
/** Manually trigger the registered `closed` handler. */ /** Manually trigger the registered `closed` handler. */
close: () => void close: () => void
on: ReturnType<typeof vi.fn>
}
function asBrowserWindow(window: MockWindow): BrowserWindow {
return window as unknown as BrowserWindow
}
// NOTICE:
// MockContext / MockWindow are intentionally minimal — only what the
// driver touches. Casting through `unknown` lets us pass them to
// `service.registerWindow` whose typed signature wants the full
// `EventaContext` and `BrowserWindow` types.
function asEventaContext(ctx: MockContext): EventaContext {
return ctx as unknown as EventaContext
} }
function createMockContext(): MockContext { function createMockContext(): MockContext {
@@ -44,22 +39,27 @@ function createMockContext(): MockContext {
function createMockWindow(): MockWindow { function createMockWindow(): MockWindow {
let closedHandler: (() => void) | undefined let closedHandler: (() => void) | undefined
return { return {
close() {
closedHandler?.()
},
on: vi.fn((event: string, handler: () => void) => { on: vi.fn((event: string, handler: () => void) => {
if (event === 'closed') if (event === 'closed')
closedHandler = handler closedHandler = handler
}), }),
close() {
closedHandler?.()
},
} }
} }
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding { // NOTICE:
return { // MockContext / MockWindow are intentionally minimal — only what the
accelerator: { key, modifiers: ['cmd-or-ctrl', 'shift'] }, // driver touches. Casting through `unknown` lets us pass them to
id, // `service.registerWindow` whose typed signature wants the full
scope: 'global', // `EventaContext` and `BrowserWindow` types.
} function asEventaContext(ctx: MockContext): EventaContext {
return ctx as unknown as EventaContext
}
function asBrowserWindow(window: MockWindow): BrowserWindow {
return window as unknown as BrowserWindow
} }
function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow { function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow {
@@ -93,7 +93,7 @@ async function setupMocks() {
triggerCallbacks.clear() triggerCallbacks.clear()
}) })
const onAppBeforeQuitMock = vi.fn<(fn: () => Promise<void> | void) => void>() const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise<void>) => void>()
vi.doMock('electron', () => ({ vi.doMock('electron', () => ({
globalShortcut: { globalShortcut: {
@@ -108,10 +108,10 @@ async function setupMocks() {
vi.doMock('./global-shortcut-uiohook', () => ({ vi.doMock('./global-shortcut-uiohook', () => ({
createUiohookDriver: () => ({ createUiohookDriver: () => ({
dispose: vi.fn(),
tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })), tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })),
unregisterAll: vi.fn(),
unregisterById: vi.fn(), unregisterById: vi.fn(),
unregisterAll: vi.fn(),
dispose: vi.fn(),
}), }),
})) }))
@@ -144,12 +144,12 @@ async function setupMocks() {
const { setupGlobalShortcutService } = await import('./global-shortcut') const { setupGlobalShortcutService } = await import('./global-shortcut')
return { return {
onAppBeforeQuitMock,
registerMock,
setupGlobalShortcutService, setupGlobalShortcutService,
triggerCallbacks, registerMock,
unregisterAllMock,
unregisterMock, unregisterMock,
unregisterAllMock,
triggerCallbacks,
onAppBeforeQuitMock,
} }
} }
@@ -417,7 +417,7 @@ describe('setupGlobalShortcutService', () => {
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')! const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
expect(() => reg({})).toThrow(TypeError) expect(() => reg({})).toThrow(TypeError)
expect(() => reg({ id: 'no-accel' })).toThrow(TypeError) expect(() => reg({ id: 'no-accel' })).toThrow(TypeError)
expect(() => reg({ accelerator: { key: 'KeyK', modifiers: [] } })).toThrow(TypeError) expect(() => reg({ accelerator: { modifiers: [], key: 'KeyK' } })).toThrow(TypeError)
expect(m.registerMock).not.toHaveBeenCalled() expect(m.registerMock).not.toHaveBeenCalled()
}) })
@@ -20,10 +20,9 @@ import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
export type EventaContext = ReturnType<typeof createContext>['context'] export type EventaContext = ReturnType<typeof createContext>['context']
export interface GlobalShortcutService { export interface RegisterWindowParams {
dispose: () => void context: EventaContext
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult window: BrowserWindow
registerWindow: (params: RegisterWindowParams) => void
} }
export interface RegisterMainShortcutParams { export interface RegisterMainShortcutParams {
@@ -31,15 +30,16 @@ export interface RegisterMainShortcutParams {
onTriggered: () => void onTriggered: () => void
} }
export interface RegisterWindowParams { export interface GlobalShortcutService {
context: EventaContext registerWindow: (params: RegisterWindowParams) => void
window: BrowserWindow registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
dispose: () => void
} }
type ActiveBinding type ActiveBinding
= | { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, onTriggered: () => void, owner: 'main' } = | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string }
| { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, owner: 'renderer' } | { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void }
| { binding: ShortcutBinding, driver: 'uiohook', owner: 'renderer' } | { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' }
export function setupGlobalShortcutService(): GlobalShortcutService { export function setupGlobalShortcutService(): GlobalShortcutService {
const log = useLogg('global-shortcut').useGlobalConfig() const log = useLogg('global-shortcut').useGlobalConfig()
@@ -73,7 +73,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict } return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
} }
active.set(binding.id, { binding, driver: 'electron', electronAccelerator, owner: 'renderer' }) active.set(binding.id, { binding, owner: 'renderer', driver: 'electron', electronAccelerator })
return { id: binding.id, ok: true } return { id: binding.id, ok: true }
} }
@@ -96,7 +96,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
const result = uiohookDriver.tryRegister(binding) const result = uiohookDriver.tryRegister(binding)
if (result.ok) if (result.ok)
active.set(binding.id, { binding, driver: 'uiohook', owner: 'renderer' }) active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' })
return result return result
} }
@@ -107,7 +107,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
const electronAccelerator = formatElectronAccelerator(binding.accelerator) const electronAccelerator = formatElectronAccelerator(binding.accelerator)
const nextEntry: ActiveBinding = { binding, driver: 'electron', electronAccelerator, onTriggered, owner: 'main' } const nextEntry: ActiveBinding = { binding, owner: 'main', driver: 'electron', electronAccelerator, onTriggered }
if (existing?.electronAccelerator === electronAccelerator) { if (existing?.electronAccelerator === electronAccelerator) {
releaseEntry(binding.id, existing) releaseEntry(binding.id, existing)
if (globalShortcut.register(electronAccelerator, onTriggered)) { if (globalShortcut.register(electronAccelerator, onTriggered)) {
@@ -146,7 +146,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
active.delete(id) active.delete(id)
} }
function tryRegister(binding: ShortcutBinding): Promise<ShortcutRegistrationResult> | ShortcutRegistrationResult { function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult | Promise<ShortcutRegistrationResult> {
if (active.has(binding.id)) { if (active.has(binding.id)) {
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId } return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
} }
@@ -209,5 +209,5 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
onAppBeforeQuit(() => dispose()) onAppBeforeQuit(() => dispose())
return { dispose, registerMainShortcut, registerWindow } return { registerWindow, registerMainShortcut, dispose }
} }
@@ -2,42 +2,73 @@ import type { Session, WebContents } from 'electron'
import { isLocalAppURL } from '../../libs/electron/url' import { isLocalAppURL } from '../../libs/electron/url'
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1] type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3] type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
type LocalAppWebContents = Pick<WebContents, 'getURL'> type LocalAppWebContents = Pick<WebContents, 'getURL'>
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([ const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
'clipboard-sanitized-write',
'display-capture', 'display-capture',
'clipboard-sanitized-write',
]) ])
/** /**
* Registers the paired Electron session handlers required for complete permission policy. * Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
*
* Use when:
* - Initializing Electron's default session after app readiness
*
* Expects:
* - The session is the one used by AIRI renderer windows
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
*
* Returns:
* - Nothing; both handlers are installed on the supplied session
*/ */
export function setupMediaPermissionHandlers( function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>, return !!rawURL && rawURL !== 'null'
isDesktopCaptureAuthorized: () => boolean, }
): void {
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
})
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { /**
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized) * Checks whether Electron described an audio-only media permission operation.
}) */
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
if (permission !== 'media' || !details)
return false
if ('mediaTypes' in details && details.mediaTypes?.length) {
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
}
return 'mediaType' in details && details.mediaType === 'audio'
}
/**
* Checks whether Electron described a desktop capture operation of any kind.
*
* Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to
* `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type
* at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like
* this, so the permission details alone cannot tell them apart.
* See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}.
*/
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
if (permission !== 'media' || !details)
return false
return 'mediaTypes' in details && details.mediaTypes?.length === 0
}
/**
* Checks whether every requester identity supplied by Electron is local to AIRI.
*/
function shouldGrantLocalAppPermission(
webContents: LocalAppWebContents | null,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
const requesterURLs = [
requestingOrigin,
details?.requestingUrl,
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
].filter(isUsableRequesterURL)
if (requesterURLs.length)
return requesterURLs.every(isLocalAppURL)
return isLocalAppURL(webContents?.getURL())
} }
/** /**
@@ -109,59 +140,28 @@ export function shouldGrantElectronPermission(
} }
/** /**
* Checks whether Electron described an audio-only media permission operation. * Registers the paired Electron session handlers required for complete permission policy.
*/
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
if (permission !== 'media' || !details)
return false
if ('mediaTypes' in details && details.mediaTypes?.length) {
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
}
return 'mediaType' in details && details.mediaType === 'audio'
}
/**
* Checks whether Electron described a desktop capture operation of any kind.
* *
* Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to * Use when:
* `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type * - Initializing Electron's default session after app readiness
* at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like *
* this, so the permission details alone cannot tell them apart. * Expects:
* See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}. * - The session is the one used by AIRI renderer windows
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
*
* Returns:
* - Nothing; both handlers are installed on the supplied session
*/ */
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean { export function setupMediaPermissionHandlers(
if (permission !== 'media' || !details) targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
return false isDesktopCaptureAuthorized: () => boolean,
): void {
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
})
return 'mediaTypes' in details && details.mediaTypes?.length === 0 targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
} return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
})
/**
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
*/
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
return !!rawURL && rawURL !== 'null'
}
/**
* Checks whether every requester identity supplied by Electron is local to AIRI.
*/
function shouldGrantLocalAppPermission(
webContents: LocalAppWebContents | null,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
const requesterURLs = [
requestingOrigin,
details?.requestingUrl,
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
].filter(isUsableRequesterURL)
if (requesterURLs.length)
return requesterURLs.every(isLocalAppURL)
return isLocalAppURL(webContents?.getURL())
} }
@@ -14,12 +14,12 @@ export class MockAutoUpdater extends EventEmitter {
// Simulate update available // Simulate update available
// We can toggle this based on some logic if needed, but for now let's assume update is always available in mock // We can toggle this based on some logic if needed, but for now let's assume update is always available in mock
const updateInfo = { const updateInfo = {
version: '9.9.9-mock',
files: [], files: [],
path: 'mock-path', path: 'mock-path',
sha512: 'mock-sha',
releaseDate: new Date().toISOString(), releaseDate: new Date().toISOString(),
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
sha512: 'mock-sha',
version: '9.9.9-mock',
} }
this.emit('update-available', updateInfo) this.emit('update-available', updateInfo)
@@ -41,10 +41,10 @@ export class MockAutoUpdater extends EventEmitter {
transferred = total transferred = total
const progress = { const progress = {
bytesPerSecond: speed,
percent: (transferred / total) * 100,
total, total,
transferred, transferred,
percent: (transferred / total) * 100,
bytesPerSecond: speed,
} }
this.emit('download-progress', progress) this.emit('download-progress', progress)
@@ -52,12 +52,12 @@ export class MockAutoUpdater extends EventEmitter {
if (transferred >= total) { if (transferred >= total) {
clearInterval(interval) clearInterval(interval)
this.emit('update-downloaded', { this.emit('update-downloaded', {
version: '9.9.9-mock',
files: [], files: [],
path: 'mock-path', path: 'mock-path',
sha512: 'mock-sha',
releaseDate: new Date().toISOString(), releaseDate: new Date().toISOString(),
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
sha512: 'mock-sha',
version: '9.9.9-mock',
}) })
} }
}, 100) }, 100)
@@ -11,11 +11,11 @@ import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecy
export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) { export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
const { start, stop } = createRendererLoop({ const { start, stop } = createRendererLoop({
window: params.window,
run: () => { run: () => {
const dipPos = screen.getCursorScreenPoint() const dipPos = screen.getCursorScreenPoint()
params.context.emit(cursorScreenPoint, dipPos) params.context.emit(cursorScreenPoint, dipPos)
}, },
window: params.window,
}) })
onAppWindowAllClosed(() => stop()) onAppWindowAllClosed(() => stop())
@@ -34,10 +34,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
} }
const { start, stop } = createRendererLoop({ const { start, stop } = createRendererLoop({
window: params.window,
run: () => { run: () => {
params.context.emit(bounds, params.window.getBounds()) params.context.emit(bounds, params.window.getBounds())
}, },
window: params.window,
}) })
onAppWindowAllClosed(() => stop()) onAppWindowAllClosed(() => stop())
@@ -61,10 +61,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
} }
return { return {
height: 0,
width: 0,
x: 0, x: 0,
y: 0, y: 0,
width: 0,
height: 0,
} }
}) })
@@ -109,10 +109,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
} }
resizeWindowByDelta({ resizeWindowByDelta({
window: params.window,
deltaX: payload.deltaX, deltaX: payload.deltaX,
deltaY: payload.deltaY, deltaY: payload.deltaY,
direction: payload.direction, direction: payload.direction,
window: params.window,
}) })
}) })
+107 -107
View File
@@ -31,15 +31,82 @@ const RECOMMENDED_WIDTH = 450
const RECOMMENDED_HEIGHT = 600 const RECOMMENDED_HEIGHT = 600
const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT
function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void {
if (isRendererUnavailable(window)) {
return
}
window.setResizable(true)
const bounds = x !== undefined && y !== undefined
? {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
}
: computeResizedBoundsAnchoredToDominantDisplay({
currentBounds: window.getBounds(),
targetSize: { width, height },
displays: screen.getAllDisplays(),
})
window.setBounds(bounds)
window.show()
}
function resolveAlignedWindowBounds(
window: BrowserWindow,
workArea: Rectangle,
position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
): Rectangle {
const { width: windowWidth, height: windowHeight } = window.getBounds()
const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = workArea
let x = areaX
let y = areaY
switch (position) {
case 'center':
x = areaX + Math.floor((areaWidth - windowWidth) / 2)
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
break
case 'top-left':
break
case 'top-right':
x = areaX + areaWidth - windowWidth
break
case 'bottom-left':
y = areaY + areaHeight - windowHeight
break
case 'bottom-right':
x = areaX + areaWidth - windowWidth
y = areaY + areaHeight - windowHeight
break
}
return { x, y, width: windowWidth, height: windowHeight }
}
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
const { width, height } = window.getBounds()
return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2
}
function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean {
const { x, y } = window.getBounds()
return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5
}
export function setupTray(params: { export function setupTray(params: {
aboutWindow: () => Promise<BrowserWindow>
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
captionWindow: ReturnType<typeof setupCaptionWindowManager>
i18n: I18n
mainWindow: BrowserWindow mainWindow: BrowserWindow
serverChannel: ServerChannel
settingsWindow: SettingsWindowManager settingsWindow: SettingsWindowManager
captionWindow: ReturnType<typeof setupCaptionWindowManager>
widgetsWindow: WidgetsWindowManager widgetsWindow: WidgetsWindowManager
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
aboutWindow: () => Promise<BrowserWindow>
serverChannel: ServerChannel
i18n: I18n
}): void { }): void {
once(() => { once(() => {
const mainWindowAnimator = new Animator(params.mainWindow) const mainWindowAnimator = new Animator(params.mainWindow)
@@ -67,8 +134,8 @@ export function setupTray(params: {
const mainWindowBounds = params.mainWindow.getBounds() const mainWindowBounds = params.mainWindow.getBounds()
const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds) const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds)
const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = currentDisplay.workArea const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea
const { height: windowHeight, width: windowWidth } = mainWindowBounds const { width: windowWidth, height: windowHeight } = mainWindowBounds
const fullHeightTarget = areaHeight const fullHeightTarget = areaHeight
const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO) const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO)
@@ -76,34 +143,34 @@ export function setupTray(params: {
const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO) const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO)
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ click: () => toggleWindowShow(params.mainWindow), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show'), click: () => toggleWindowShow(params.mainWindow) },
{ type: 'separator' }, { type: 'separator' },
{ {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'),
submenu: [ submenu: [
{ {
checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
}, },
{ {
checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget),
click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget),
click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget),
}, },
{ {
checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget),
click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget),
click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget),
}, },
{ {
checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight),
click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'),
type: 'checkbox', type: 'checkbox',
checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight),
click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY),
}, },
], ],
}, },
@@ -111,69 +178,69 @@ export function setupTray(params: {
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'),
submenu: [ submenu: [
{ {
checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)),
click: () => animateMainWindowTo(currentDisplay.workArea, 'center'),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)),
click: () => animateMainWindowTo(currentDisplay.workArea, 'center'),
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
checked: isPositionMatch(params.mainWindow, areaX, areaY),
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX, areaY),
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
}, },
{ {
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY),
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY),
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'),
}, },
{ {
checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight),
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight),
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'),
}, },
{ {
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'),
type: 'checkbox', type: 'checkbox',
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
}, },
], ],
}, },
{ type: 'separator' }, { type: 'separator' },
{ click: () => void params.settingsWindow.openWindow('/settings'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => void params.settingsWindow.openWindow('/settings') },
{ click: () => params.aboutWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) },
{ type: 'separator' }, { type: 'separator' },
{ click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) },
{ click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets'), click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) },
{ {
click: () => {
void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu())
},
label: params.i18n.t(params.captionWindow.isVisible() label: params.i18n.t(params.captionWindow.isVisible()
? 'tamagotchi.electron.tray.menu.labels.label.close_caption' ? 'tamagotchi.electron.tray.menu.labels.label.close_caption'
: 'tamagotchi.electron.tray.menu.labels.label.open_caption'), : 'tamagotchi.electron.tray.menu.labels.label.open_caption'),
click: () => {
void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu())
},
}, },
{ {
type: 'submenu',
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'),
submenu: Menu.buildFromTemplate([ submenu: Menu.buildFromTemplate([
{ checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), type: 'checkbox' }, { type: 'checkbox', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) },
{ click: async () => await params.captionWindow.resetToSide(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position'), click: async () => await params.captionWindow.resetToSide() },
]), ]),
type: 'submenu',
}, },
{ type: 'separator' }, { type: 'separator' },
...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG ...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG
? [ ? [
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools'), type: 'header' }, { type: 'header', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools') },
{ click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync'), click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }) },
{ type: 'separator' }, { type: 'separator' },
] as const ] as const
: [], : [],
{ click: () => app.quit(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit') }, { label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit'), click: () => app.quit() },
]) ])
appTray.setContextMenu(contextMenu) appTray.setContextMenu(contextMenu)
@@ -186,7 +253,7 @@ export function setupTray(params: {
rebuildContextMenu() rebuildContextMenu()
const stopLocaleEffect = effect(() => { const stopLocaleEffect = effect(() => {
const locale = params.i18n.locale as (() => LocaleDetector<any[]> | string | undefined) const locale = params.i18n.locale as (() => string | LocaleDetector<any[]> | undefined)
locale() locale()
rebuildContextMenu() rebuildContextMenu()
}) })
@@ -215,70 +282,3 @@ export function setupTray(params: {
} }
})() })()
} }
function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void {
if (isRendererUnavailable(window)) {
return
}
window.setResizable(true)
const bounds = x !== undefined && y !== undefined
? {
height: Math.round(height),
width: Math.round(width),
x: Math.round(x),
y: Math.round(y),
}
: computeResizedBoundsAnchoredToDominantDisplay({
currentBounds: window.getBounds(),
displays: screen.getAllDisplays(),
targetSize: { height, width },
})
window.setBounds(bounds)
window.show()
}
function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean {
const { x, y } = window.getBounds()
return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5
}
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
const { height, width } = window.getBounds()
return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2
}
function resolveAlignedWindowBounds(
window: BrowserWindow,
workArea: Rectangle,
position: 'bottom-left' | 'bottom-right' | 'center' | 'top-left' | 'top-right',
): Rectangle {
const { height: windowHeight, width: windowWidth } = window.getBounds()
const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = workArea
let x = areaX
let y = areaY
switch (position) {
case 'bottom-left':
y = areaY + areaHeight - windowHeight
break
case 'bottom-right':
x = areaX + areaWidth - windowWidth
y = areaY + areaHeight - windowHeight
break
case 'center':
x = areaX + Math.floor((areaWidth - windowWidth) / 2)
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
break
case 'top-left':
break
case 'top-right':
x = areaX + areaWidth - windowWidth
break
}
return { height: windowHeight, width: windowWidth, x, y }
}
@@ -20,28 +20,28 @@ export function setupAboutWindowReusable(params: {
}) { }) {
return createReusableWindow(async () => { return createReusableWindow(async () => {
const window = new BrowserWindow({ const window = new BrowserWindow({
title: 'About AIRI',
width: 670,
height: 880, height: 880,
icon, show: false,
resizable: true,
maximizable: false, maximizable: false,
minimizable: false, minimizable: false,
resizable: true, icon,
show: false,
title: 'About AIRI',
webPreferences: { webPreferences: {
preload: join(getElectronMainDirname(), '../preload/index.mjs'), preload: join(getElectronMainDirname(), '../preload/index.mjs'),
sandbox: false, sandbox: false,
}, },
width: 670,
}) })
window.on('ready-to-show', () => window.show()) window.on('ready-to-show', () => window.show())
protectPrivilegedWindowNavigation(window) protectPrivilegedWindowNavigation(window)
await setupAboutWindowElectronInvokes({ await setupAboutWindowElectronInvokes({
window,
autoUpdater: params.autoUpdater, autoUpdater: params.autoUpdater,
i18n: params.i18n, i18n: params.i18n,
serverChannel: params.serverChannel, serverChannel: params.serverChannel,
window,
}) })
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', { await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', {

Some files were not shown because too many files have changed in this diff Show More