+1
-1
@@ -6,9 +6,9 @@ export default defineConfig([
|
||||
{
|
||||
extends: ['js/recommended'],
|
||||
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'],
|
||||
ignores: ['**/node_modules/**'],
|
||||
plugins: {
|
||||
js: jsPlugin,
|
||||
},
|
||||
ignores: ['**/node_modules/**'],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -7,7 +7,7 @@ import App from './App.vue'
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import 'uno.css'
|
||||
|
||||
const router = createRouter({ history: createWebHashHistory(), routes })
|
||||
const router = createRouter({ routes, history: createWebHashHistory() })
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
|
||||
+2
-2
@@ -11,12 +11,12 @@ export const weatherComponent = defineCallingComponent(
|
||||
Weather,
|
||||
object({
|
||||
city: string(),
|
||||
condition: string(),
|
||||
temperature: string(),
|
||||
condition: string(),
|
||||
}),
|
||||
{
|
||||
city: 'Tokyo',
|
||||
condition: 'Sunny',
|
||||
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>) {
|
||||
return {
|
||||
component: markRaw(component),
|
||||
exampleProps,
|
||||
name,
|
||||
schema: toJsonSchema(schema),
|
||||
component: markRaw(component),
|
||||
exampleProps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ export default defineConfig({
|
||||
...presetWebFontsFonts('fontsource'),
|
||||
},
|
||||
timeouts: {
|
||||
failure: 10000,
|
||||
warning: 5000,
|
||||
failure: 10000,
|
||||
},
|
||||
}),
|
||||
presetIcons({
|
||||
@@ -35,14 +35,14 @@ export default defineConfig({
|
||||
presetChromatic({
|
||||
baseHue: 240.25,
|
||||
colors: {
|
||||
complementary: 180,
|
||||
primary: 0,
|
||||
complementary: 180,
|
||||
},
|
||||
}) as Preset,
|
||||
],
|
||||
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
||||
transformers: [
|
||||
transformerDirectives(),
|
||||
transformerVariantGroup(),
|
||||
],
|
||||
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
||||
})
|
||||
|
||||
@@ -9,8 +9,8 @@ import { defineConfig } from 'vite'
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
VueRouter({
|
||||
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
|
||||
extensions: ['.vue', '.md'],
|
||||
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
|
||||
}),
|
||||
Vue(),
|
||||
// https://github.com/antfu/unocss
|
||||
|
||||
@@ -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 config: CapacitorConfig = {
|
||||
appId,
|
||||
appName: 'AIRI',
|
||||
webDir: 'dist',
|
||||
server: serverURL
|
||||
? {
|
||||
url: serverURL,
|
||||
cleartext: false,
|
||||
}
|
||||
: undefined,
|
||||
android: {
|
||||
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,
|
||||
keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS,
|
||||
keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD,
|
||||
keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD,
|
||||
releaseType: 'APK',
|
||||
signingType: 'apksigner',
|
||||
},
|
||||
},
|
||||
appId,
|
||||
appName: 'AIRI',
|
||||
server: serverURL
|
||||
? {
|
||||
cleartext: false,
|
||||
url: serverURL,
|
||||
}
|
||||
: undefined,
|
||||
webDir: 'dist',
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -9,7 +9,7 @@ export function useAudioInput() {
|
||||
const audioInputs = computed(() => devices.audioInputs.value)
|
||||
|
||||
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() {
|
||||
if (devices.permissionGranted.value) {
|
||||
@@ -71,13 +71,13 @@ export function useAudioInput() {
|
||||
}
|
||||
|
||||
return {
|
||||
audioInputs,
|
||||
media,
|
||||
request,
|
||||
|
||||
selectedAudioInput,
|
||||
selectedAudioInputId,
|
||||
selectedAudioInput,
|
||||
audioInputs,
|
||||
|
||||
start,
|
||||
stop,
|
||||
request,
|
||||
media,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) {
|
||||
})
|
||||
|
||||
return {
|
||||
animationIcon,
|
||||
iconAnimationStarted,
|
||||
showIconAnimation,
|
||||
animationIcon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
|
||||
|
||||
let router: Router
|
||||
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
|
||||
router = createRouter({ history: createWebHashHistory(), routes: routeRecords })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHashHistory() })
|
||||
else
|
||||
router = createRouter({ history: createWebHistory(), routes: routeRecords })
|
||||
router = createRouter({ routes: routeRecords, history: createWebHistory() })
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.path !== from.path)
|
||||
|
||||
@@ -15,8 +15,8 @@ function getLocale() {
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
locale: getLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
|
||||
interface MicrophonePermissionPlugin {
|
||||
checkPermission: () => Promise<MicrophonePermissionState>
|
||||
}
|
||||
|
||||
interface MicrophonePermissionState {
|
||||
granted: boolean
|
||||
}
|
||||
|
||||
interface MicrophonePermissionPlugin {
|
||||
checkPermission: () => Promise<MicrophonePermissionState>
|
||||
}
|
||||
|
||||
/** Reads Android's native microphone permission state without triggering a permission request. */
|
||||
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')
|
||||
|
||||
@@ -21,11 +21,11 @@ export async function probeServerChannelQrPayload(payload: ServerChannelQrPayloa
|
||||
const client = new Client({
|
||||
autoConnect: false,
|
||||
autoReconnect: false,
|
||||
connector: createTextProtocolConnector(connector),
|
||||
connectTimeoutMs: 2_000,
|
||||
name: WebSocketEventSource.StageWeb,
|
||||
token: payload.authToken,
|
||||
url,
|
||||
connector: createTextProtocolConnector(connector),
|
||||
})
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,13 +5,13 @@ interface WebAuthenticationOptions {
|
||||
url: string
|
||||
}
|
||||
|
||||
interface WebAuthenticationPlugin {
|
||||
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
|
||||
}
|
||||
|
||||
interface WebAuthenticationResult {
|
||||
callbackUrl?: string
|
||||
}
|
||||
|
||||
interface WebAuthenticationPlugin {
|
||||
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
|
||||
}
|
||||
|
||||
/** Opens an authorization URL with the native system browser session. */
|
||||
export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication')
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk'
|
||||
|
||||
type HostBridgeCommand
|
||||
= | { code?: number, id: string, kind: 'close', reason?: string }
|
||||
| { data: string, id: string, kind: 'send' }
|
||||
| { id: string, kind: 'connect', url: string }
|
||||
= | { kind: 'connect', id: string, url: string }
|
||||
| { kind: 'send', id: string, data: string }
|
||||
| { kind: 'close', id: string, code?: number, reason?: string }
|
||||
|
||||
type HostBridgeEvent
|
||||
= | { code?: number, id: string, kind: 'close', reason?: string }
|
||||
| { data: string, id: string, kind: 'message' }
|
||||
| { id: string, kind: 'error', message: string }
|
||||
| { id: string, kind: 'open' }
|
||||
= | { kind: 'open', id: string }
|
||||
| { kind: 'message', id: string, data: string }
|
||||
| { kind: 'error', id: string, message: string }
|
||||
| { kind: 'close', id: string, code?: number, reason?: string }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__airiHostBridge?: {
|
||||
onNativeMessage?: (payload: string) => void
|
||||
}
|
||||
AiriHostBridge?: {
|
||||
postMessage: (payload: string) => void
|
||||
}
|
||||
@@ -26,11 +23,38 @@ declare global {
|
||||
}
|
||||
}
|
||||
}
|
||||
__airiHostBridge?: {
|
||||
onNativeMessage?: (payload: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
readonly id = crypto.randomUUID()
|
||||
private opened = false
|
||||
@@ -45,37 +69,49 @@ class HostBridgeConnection {
|
||||
connections.set(this.id, this)
|
||||
|
||||
postBridgeMessage({
|
||||
id: this.id,
|
||||
kind: 'connect',
|
||||
id: this.id,
|
||||
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) {
|
||||
if (this.settled && !this.opened) {
|
||||
return
|
||||
}
|
||||
|
||||
postBridgeMessage({
|
||||
code,
|
||||
id: this.id,
|
||||
kind: 'close',
|
||||
id: this.id,
|
||||
code,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
handleNativeEvent(event: HostBridgeEvent) {
|
||||
switch (event.kind) {
|
||||
case 'close':
|
||||
connections.delete(this.id)
|
||||
if (!this.settled) {
|
||||
this.settled = true
|
||||
this.reject(createCloseBeforeOpenError(event))
|
||||
return
|
||||
}
|
||||
case 'open':
|
||||
this.opened = true
|
||||
this.settled = true
|
||||
this.resolve()
|
||||
break
|
||||
|
||||
this.opened = false
|
||||
this.events.close({ code: event.code, reason: event.reason })
|
||||
case 'message':
|
||||
this.events.message(event.data)
|
||||
break
|
||||
|
||||
case 'error':
|
||||
@@ -89,31 +125,25 @@ class HostBridgeConnection {
|
||||
this.events.error(new Error(event.message))
|
||||
break
|
||||
|
||||
case 'message':
|
||||
this.events.message(event.data)
|
||||
break
|
||||
case 'close':
|
||||
connections.delete(this.id)
|
||||
if (!this.settled) {
|
||||
this.settled = true
|
||||
this.reject(createCloseBeforeOpenError(event))
|
||||
return
|
||||
}
|
||||
|
||||
case 'open':
|
||||
this.opened = true
|
||||
this.settled = true
|
||||
this.resolve()
|
||||
this.opened = false
|
||||
this.events.close({ code: event.code, reason: event.reason })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
if (!this.opened) {
|
||||
return false
|
||||
}
|
||||
|
||||
postBridgeMessage({
|
||||
data,
|
||||
id: this.id,
|
||||
kind: 'send',
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
|
||||
export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined {
|
||||
@@ -138,40 +168,10 @@ export function getHostWebSocketConnector(url: string): ClientConnector<string>
|
||||
}
|
||||
|
||||
return {
|
||||
close: (code?: number, reason?: string) => activeConnection.close(code, reason),
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -7,30 +7,30 @@ import { AutoModel, Tensor } from '@huggingface/transformers'
|
||||
* Voice Activity Detection processor
|
||||
*/
|
||||
export class VAD implements BaseVAD {
|
||||
private config: BaseVADConfig
|
||||
private model: PreTrainedModel | undefined
|
||||
private state: Tensor
|
||||
private sampleRateTensor: Tensor
|
||||
private buffer: Float32Array
|
||||
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 model: PreTrainedModel | undefined
|
||||
private postSpeechSamples: number = 0
|
||||
private prevBuffers: Float32Array[] = []
|
||||
private sampleRateTensor: Tensor
|
||||
private state: Tensor
|
||||
private inferenceChain: Promise<any> = Promise.resolve()
|
||||
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
|
||||
private isReady: boolean = false
|
||||
|
||||
constructor(userConfig: Partial<BaseVADConfig> = {}) {
|
||||
// Default configuration
|
||||
const defaultConfig: BaseVADConfig = {
|
||||
exitThreshold: 0.1,
|
||||
maxBufferDuration: 30,
|
||||
minSilenceDurationMs: 400,
|
||||
minSpeechDurationMs: 250,
|
||||
newBufferSize: 512,
|
||||
sampleRate: 16000,
|
||||
speechPadMs: 80,
|
||||
speechThreshold: 0.3,
|
||||
exitThreshold: 0.1,
|
||||
minSilenceDurationMs: 400,
|
||||
speechPadMs: 80,
|
||||
minSpeechDurationMs: 250,
|
||||
maxBufferDuration: 30,
|
||||
newBufferSize: 512,
|
||||
}
|
||||
|
||||
this.config = { ...defaultConfig, ...userConfig }
|
||||
@@ -45,7 +45,7 @@ export class VAD implements BaseVAD {
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
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', {
|
||||
config: { model_type: 'custom' } as any,
|
||||
@@ -53,14 +53,24 @@ export class VAD implements BaseVAD {
|
||||
})
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -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 {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[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)
|
||||
}
|
||||
this.eventListeners[event]!.push(callback as any)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +144,7 @@ export class VAD implements BaseVAD {
|
||||
if (!this.isRecording) {
|
||||
// Speech just started
|
||||
this.emit('speech-start', undefined)
|
||||
this.emit('status', { message: 'Speech detected', type: 'info' })
|
||||
this.emit('status', { type: 'info', message: 'Speech detected' })
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
private async detectSpeech(buffer: Float32Array): Promise<boolean> {
|
||||
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?.({
|
||||
input,
|
||||
sr: this.sampleRateTensor,
|
||||
@@ -196,7 +189,7 @@ export class VAD implements BaseVAD {
|
||||
// Get the speech probability
|
||||
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
|
||||
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
|
||||
*/
|
||||
@@ -265,6 +247,24 @@ export class VAD implements BaseVAD {
|
||||
this.postSpeechSamples = 0
|
||||
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], [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,15 +11,15 @@ export default mergeConfigs([
|
||||
...presetWebFontsFonts('fontsource'),
|
||||
},
|
||||
timeouts: {
|
||||
failure: 10000,
|
||||
warning: 5000,
|
||||
failure: 10000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
rules: [
|
||||
['transition-colors-none', {
|
||||
'transition-duration': '0s',
|
||||
'transition-property': 'color, background-color, border-color, text-color',
|
||||
'transition-duration': '0s',
|
||||
}],
|
||||
|
||||
['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)' }],
|
||||
['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }],
|
||||
['p-safe', {
|
||||
'padding-top': 'env(safe-area-inset-top)',
|
||||
'padding-bottom': 'env(safe-area-inset-bottom)',
|
||||
'padding-left': 'env(safe-area-inset-left)',
|
||||
'padding-right': 'env(safe-area-inset-right)',
|
||||
'padding-top': 'env(safe-area-inset-top)',
|
||||
}],
|
||||
],
|
||||
shortcuts: [
|
||||
|
||||
Vendored
+1
-1
@@ -3,6 +3,6 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string
|
||||
readonly VITE_PLATFORM: 'android' | 'ios' | 'web'
|
||||
readonly VITE_PLATFORM: 'ios' | 'android' | 'web'
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
|
||||
VITE_SKIP_MKCERT?: string
|
||||
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// import { isEnvTruthy } from '@proj-airi/stage-shared'
|
||||
function isEnvTruthy(value: null | string | undefined): boolean {
|
||||
function isEnvTruthy(value: string | undefined | null): boolean {
|
||||
if (value == null)
|
||||
return false
|
||||
|
||||
@@ -36,9 +36,6 @@ const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'package
|
||||
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
// Internal Packages
|
||||
@@ -66,6 +63,46 @@ export default defineConfig({
|
||||
'@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: [
|
||||
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
|
||||
? []
|
||||
@@ -82,7 +119,6 @@ export default defineConfig({
|
||||
Yaml(),
|
||||
|
||||
VueMacros({
|
||||
betterDefine: false,
|
||||
plugins: {
|
||||
vue: Vue({
|
||||
include: [/\.vue$/, /\.md$/],
|
||||
@@ -90,24 +126,25 @@ export default defineConfig({
|
||||
}),
|
||||
vueJsx: false,
|
||||
},
|
||||
betterDefine: false,
|
||||
}),
|
||||
|
||||
VueRouter({
|
||||
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
|
||||
exclude: ['**/components/**'],
|
||||
extensions: ['.vue', '.md'],
|
||||
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
|
||||
importMode: 'async',
|
||||
routesFolder: [
|
||||
resolve(import.meta.dirname, 'src', 'pages'),
|
||||
{
|
||||
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||
exclude: base => [
|
||||
...base,
|
||||
'**/settings/connection/index.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
|
||||
@@ -124,35 +161,36 @@ export default defineConfig({
|
||||
|
||||
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
||||
VueI18n({
|
||||
runtimeOnly: true,
|
||||
compositionOnly: true,
|
||||
fullInstall: true,
|
||||
runtimeOnly: true,
|
||||
}),
|
||||
|
||||
// https://github.com/webfansplz/vite-plugin-vue-devtools
|
||||
VueDevTools(),
|
||||
|
||||
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 }),
|
||||
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 }),
|
||||
|
||||
...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '')
|
||||
? [{
|
||||
name: 'proj-airi:capacitor-sync',
|
||||
closeBundle: {
|
||||
sequential: true,
|
||||
handler() {
|
||||
if (this.meta.watchMode) {
|
||||
execSync('cap sync ios', { stdio: 'inherit' })
|
||||
}
|
||||
},
|
||||
sequential: true,
|
||||
},
|
||||
name: 'proj-airi:capacitor-sync',
|
||||
} as PluginOption]
|
||||
: [],
|
||||
|
||||
{
|
||||
name: 'proj-airi:defines',
|
||||
config(ctx) {
|
||||
const define: Record<string, any> = {
|
||||
'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'',
|
||||
@@ -166,44 +204,6 @@ export default defineConfig({
|
||||
|
||||
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 {
|
||||
appId: 'ai.moeru.airi',
|
||||
appImage: {
|
||||
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
||||
productName: 'AIRI',
|
||||
directories: {
|
||||
output: 'dist',
|
||||
buildResources: 'build',
|
||||
},
|
||||
asar: true,
|
||||
// // For self-publishing, testing, and distribution after modified the code without access to
|
||||
// // 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
|
||||
@@ -62,30 +63,6 @@ export default {
|
||||
// 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
|
||||
// await notarize({
|
||||
// appPath: `${appOutDir}/${appName}.app`,
|
||||
@@ -116,45 +93,55 @@ export default {
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
'!{tsconfig.json}',
|
||||
],
|
||||
linux: {
|
||||
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
||||
category: 'Utility',
|
||||
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.',
|
||||
executableName: 'airi',
|
||||
icon: 'build/icons/icon.png',
|
||||
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
|
||||
publish: {
|
||||
channel: 'latest-${arch}',
|
||||
owner: 'moeru-ai',
|
||||
provider: 'github',
|
||||
repo: 'airi',
|
||||
asar: true,
|
||||
asarUnpack: [
|
||||
'**/*.node',
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: '../../engines/stage-tamagotchi-godot/out/${os}',
|
||||
to: 'godot-stage',
|
||||
filter: ['**/*'],
|
||||
},
|
||||
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
|
||||
target: [
|
||||
'deb',
|
||||
'rpm',
|
||||
],
|
||||
],
|
||||
extraMetadata: {
|
||||
name: 'ai.moeru.airi',
|
||||
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: {
|
||||
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
|
||||
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
|
||||
publish: {
|
||||
provider: 'github',
|
||||
owner: 'moeru-ai',
|
||||
repo: 'airi',
|
||||
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
|
||||
// `${arch}` before it writes any publish metadata, and electron-updater later
|
||||
// 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 arm64 -> `latest-arm64-linux-arm64.yml`
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -73,8 +73,8 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@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': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -83,8 +83,8 @@ export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: {
|
||||
'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.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: {
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.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: {
|
||||
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-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')),
|
||||
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
||||
// 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
|
||||
// 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.
|
||||
'@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 argOptions = args.options as {
|
||||
release: boolean
|
||||
autoTag: boolean
|
||||
tag: string[]
|
||||
getBundleName: boolean
|
||||
getFilename: string[]
|
||||
getOutputFilename: string[]
|
||||
getProductName: boolean
|
||||
getVersion: boolean
|
||||
release: boolean
|
||||
tag: string[]
|
||||
getFilename: string[]
|
||||
getOutputFilename: string[]
|
||||
}
|
||||
|
||||
const target = args.args[0]
|
||||
@@ -94,7 +94,7 @@ async function main() {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ afterEach(() => {
|
||||
|
||||
function createMockSocket() {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
addEventListener: (event: string, listener: (...args: any[]) => void) => void
|
||||
close: 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.close = vi.fn(() => {
|
||||
|
||||
@@ -22,24 +22,16 @@ interface DebugTarget {
|
||||
webSocketDebuggerUrl?: string
|
||||
}
|
||||
|
||||
interface McpApplyResult {
|
||||
failed: Array<{ error: string, name: string }>
|
||||
skipped: Array<{ name: string, reason: string }>
|
||||
started: Array<{ name: string }>
|
||||
}
|
||||
|
||||
interface McpResult {
|
||||
content?: unknown[]
|
||||
isError?: boolean
|
||||
structuredContent?: Record<string, unknown>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
interface McpRuntimeStatus {
|
||||
servers: Array<{
|
||||
lastError?: string
|
||||
name: string
|
||||
state: 'error' | 'running' | 'stopped'
|
||||
}>
|
||||
interface McpApplyResult {
|
||||
started: Array<{ name: string }>
|
||||
failed: Array<{ name: string, error: string }>
|
||||
skipped: Array<{ name: string, reason: string }>
|
||||
}
|
||||
|
||||
interface McpToolDescriptor {
|
||||
@@ -48,6 +40,14 @@ interface McpToolDescriptor {
|
||||
toolName: string
|
||||
}
|
||||
|
||||
interface McpRuntimeStatus {
|
||||
servers: Array<{
|
||||
name: string
|
||||
state: 'running' | 'stopped' | 'error'
|
||||
lastError?: string
|
||||
}>
|
||||
}
|
||||
|
||||
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const repoDir = resolve(packageDir, '../..')
|
||||
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)}`
|
||||
|
||||
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 {
|
||||
private socket?: WebSocket
|
||||
private nextId = 1
|
||||
private pending = new Map<number, {
|
||||
reject: (error: Error) => void
|
||||
resolve: (value: Record<string, unknown>) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
|
||||
private socket?: WebSocket
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this.socket = socket
|
||||
this.socket.addEventListener('message', (event) => {
|
||||
@@ -125,16 +180,24 @@ export class CdpClient {
|
||||
return new CdpClient(socket)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.failPending('CDP socket closed')
|
||||
this.socket?.close()
|
||||
this.socket = undefined
|
||||
async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!this.socket) {
|
||||
throw new Error('CDP socket is closed')
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await this.send('Runtime.evaluate', {
|
||||
awaitPromise: true,
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
const result = response.result
|
||||
@@ -153,18 +216,10 @@ export class CdpClient {
|
||||
return remoteObject.value as T
|
||||
}
|
||||
|
||||
async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!this.socket) {
|
||||
throw new Error('CDP socket is closed')
|
||||
}
|
||||
|
||||
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
|
||||
close() {
|
||||
this.failPending('CDP socket closed')
|
||||
this.socket?.close()
|
||||
this.socket = undefined
|
||||
}
|
||||
|
||||
private failPending(reason: string) {
|
||||
@@ -179,17 +234,92 @@ export class CdpClient {
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition)
|
||||
throw new Error(message)
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
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> {
|
||||
const result = await client.evaluate<McpResult>(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ arguments: args, name })})`)
|
||||
if (result.isError) {
|
||||
throw new Error(`${name} returned isError=true`)
|
||||
async function prepareMcpConfig() {
|
||||
await mkdir(userDataDir, { recursive: true })
|
||||
await mkdir(mcpSessionRoot, { recursive: 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> {
|
||||
@@ -208,6 +338,14 @@ async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
|
||||
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> {
|
||||
const applyResult = await client.evaluate<McpApplyResult>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()')
|
||||
const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use')
|
||||
@@ -235,68 +373,91 @@ async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
|
||||
}, 30_000, 500)
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
function requireStructuredContent(result: McpResult, label: string): Record<string, unknown> {
|
||||
if (!isRecord(result.structuredContent))
|
||||
throw new Error(`${label} missing structuredContent`)
|
||||
|
||||
const missingOutputs: string[] = []
|
||||
for (const relativePath of requiredWorkspaceBuildOutputs) {
|
||||
try {
|
||||
await access(resolve(repoDir, relativePath))
|
||||
}
|
||||
catch {
|
||||
missingOutputs.push(relativePath)
|
||||
if (result.structuredContent.status && result.structuredContent.status !== 'ok')
|
||||
throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`)
|
||||
|
||||
return result.structuredContent
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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(' '))
|
||||
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 fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok)
|
||||
throw new Error(`${url} returned ${response.status}`)
|
||||
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'))
|
||||
}
|
||||
})
|
||||
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)}`))
|
||||
})
|
||||
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() {
|
||||
let stageProcess: ChildProcessWithoutNullStreams | undefined
|
||||
let overlayClient: CdpClient | undefined
|
||||
@@ -344,7 +505,7 @@ async function main() {
|
||||
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') {
|
||||
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)
|
||||
await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', {
|
||||
button: 'left',
|
||||
candidateId,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
})
|
||||
const postClickRunState = requireRunState(
|
||||
@@ -383,10 +544,10 @@ async function main() {
|
||||
})
|
||||
|
||||
console.info(JSON.stringify({
|
||||
heartbeat,
|
||||
ok: true,
|
||||
reportDir,
|
||||
stageLogPath,
|
||||
heartbeat,
|
||||
}, null, 2))
|
||||
}
|
||||
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) {
|
||||
main().catch((error) => {
|
||||
console.error(errorMessageFromValue(error))
|
||||
|
||||
@@ -8,22 +8,22 @@ import { cac } from 'cac'
|
||||
|
||||
import * as yaml from 'yaml'
|
||||
|
||||
type Platform = 'arm64' | 'both' | 'none' | 'x64'
|
||||
interface UpdateInfoFile {
|
||||
url: string
|
||||
sha2?: string
|
||||
sha512?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
[key: string]: unknown
|
||||
files?: UpdateInfoFile[]
|
||||
path?: string
|
||||
sha2?: string
|
||||
sha512?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface UpdateInfoFile {
|
||||
sha2?: string
|
||||
sha512?: string
|
||||
size?: number
|
||||
url: string
|
||||
}
|
||||
type Platform = 'x64' | 'arm64' | 'both' | 'none'
|
||||
|
||||
const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i
|
||||
|
||||
@@ -51,6 +51,18 @@ export const regexpHasX64 = /(^|[-_/])x64([-.]|$)/i
|
||||
export const regexpIsMacZip = /-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) {
|
||||
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 {
|
||||
const urls = getUrls(updateInfo)
|
||||
|
||||
@@ -133,16 +105,69 @@ function detectPlatform(updateInfo: UpdateInfo): Platform {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function getMacZipUrls(updateInfo: UpdateInfo): string[] {
|
||||
return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url))
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function isArm64MacZip(url: string): boolean {
|
||||
return regexpIsArm64MacZip.test(url)
|
||||
async function readUpdateInfo(filePath: string): Promise<UpdateInfo> {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
return yaml.parse(raw) as UpdateInfo
|
||||
}
|
||||
|
||||
function isX64MacZip(url: string): boolean {
|
||||
return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url)
|
||||
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', {
|
||||
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() {
|
||||
@@ -185,7 +210,7 @@ async function main() {
|
||||
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) {
|
||||
if (!existsSync(filePath)) {
|
||||
console.warn('merge-latest-mac: missing file', filePath)
|
||||
@@ -199,7 +224,7 @@ async function main() {
|
||||
assertContainsMacZip(updateInfo, platform, filePath)
|
||||
}
|
||||
|
||||
entries.push({ filePath, platform, updateInfo })
|
||||
entries.push({ filePath, updateInfo, platform })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -239,31 +264,6 @@ async function main() {
|
||||
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) => {
|
||||
console.error(error)
|
||||
exit(1)
|
||||
|
||||
@@ -32,12 +32,12 @@ describe('regenerateWindowsLatest', () => {
|
||||
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, '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',
|
||||
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')
|
||||
|
||||
process.chdir(packageDir)
|
||||
@@ -50,18 +50,18 @@ describe('regenerateWindowsLatest', () => {
|
||||
|
||||
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'))
|
||||
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: [
|
||||
{
|
||||
sha512: expectedHashes.sha512,
|
||||
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'))
|
||||
|
||||
@@ -81,24 +81,24 @@ describe('regenerateWindowsLatest', () => {
|
||||
await regenerateWindowsLatest({
|
||||
input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe',
|
||||
output: 'bundle/latest.yml',
|
||||
releaseDate: '2026-03-23T00:00:00.000Z',
|
||||
version: '9.9.9',
|
||||
releaseDate: '2026-03-23T00:00:00.000Z',
|
||||
})
|
||||
|
||||
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'))
|
||||
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: [
|
||||
{
|
||||
sha512: expectedHashes.sha512,
|
||||
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'
|
||||
|
||||
export interface RegenerateWindowsLatestOptions {
|
||||
input: string
|
||||
output: string
|
||||
releaseDate?: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface UpdateFileInfo {
|
||||
url: string
|
||||
sha512: string
|
||||
size?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
interface WindowsUpdateInfo {
|
||||
[key: string]: unknown
|
||||
version: string
|
||||
files: UpdateFileInfo[]
|
||||
path: string
|
||||
releaseDate?: string
|
||||
sha2?: 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) => {
|
||||
const sha512 = createHash('sha512')
|
||||
const sha256 = createHash('sha256')
|
||||
@@ -45,8 +38,8 @@ export async function hashFile(filePath: string): Promise<{ sha256: string, sha5
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => {
|
||||
resolveHash({
|
||||
sha256: sha256.digest('hex'),
|
||||
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> {
|
||||
const input = String(options.input || '').trim()
|
||||
const output = String(options.output || '').trim()
|
||||
@@ -81,24 +98,24 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
|
||||
const inputPath = await resolveFromWorkspace(input)
|
||||
const outputPath = await resolveFromWorkspace(output)
|
||||
const fileStats = await stat(inputPath)
|
||||
const { sha256, sha512 } = await hashFile(inputPath)
|
||||
const { sha512, sha256 } = await hashFile(inputPath)
|
||||
const existing = await readExistingUpdateInfo(outputPath)
|
||||
const url = basename(inputPath)
|
||||
|
||||
const nextUpdateInfo: WindowsUpdateInfo = {
|
||||
...existing,
|
||||
version,
|
||||
files: [
|
||||
{
|
||||
url,
|
||||
sha512,
|
||||
size: fileStats.size,
|
||||
url,
|
||||
},
|
||||
],
|
||||
path: url,
|
||||
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
|
||||
sha2: sha256,
|
||||
sha512,
|
||||
version,
|
||||
sha2: sha256,
|
||||
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
|
||||
}
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
@@ -107,23 +124,6 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
|
||||
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() {
|
||||
const cli = cac('regenerate-windows-latest')
|
||||
.option('--input <path>', 'Signed Windows installer path', { type: [String] })
|
||||
@@ -141,8 +141,8 @@ async function main() {
|
||||
await regenerateWindowsLatest({
|
||||
input,
|
||||
output,
|
||||
releaseDate,
|
||||
version,
|
||||
releaseDate,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ async function main() {
|
||||
const beforeProductName = productName
|
||||
|
||||
const argOptions = args.options as {
|
||||
autoTag: boolean
|
||||
release: boolean
|
||||
autoTag: boolean
|
||||
tag: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main() {
|
||||
}
|
||||
|
||||
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 macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions)
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('generateManifestFixtures', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
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
|
||||
})
|
||||
@@ -31,12 +31,12 @@ describe('generateManifestFixtures', () => {
|
||||
roots.push(root)
|
||||
|
||||
const result = await generateManifestFixtures({
|
||||
artifactContent: 'mock-installer-binary',
|
||||
channel: 'stable',
|
||||
releaseNotes: 'Mock update for AIRI local updater verification.',
|
||||
rootDir: root,
|
||||
channel: 'stable',
|
||||
target: 'x86_64-pc-windows-msvc',
|
||||
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'))
|
||||
@@ -45,14 +45,14 @@ describe('generateManifestFixtures', () => {
|
||||
|
||||
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
|
||||
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: [
|
||||
{
|
||||
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')
|
||||
@@ -66,12 +66,12 @@ describe('generateManifestFixtures', () => {
|
||||
roots.push(root)
|
||||
|
||||
const result = await generateManifestFixtures({
|
||||
artifactContent: `mock-installer-${channel}`,
|
||||
channel,
|
||||
releaseNotes: 'Mock update lane fixture',
|
||||
rootDir: root,
|
||||
channel,
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: '9.9.9-test.2',
|
||||
releaseNotes: 'Mock update lane fixture',
|
||||
artifactContent: `mock-installer-${channel}`,
|
||||
})
|
||||
|
||||
expect(result.channelDir).toBe(join(root, channel))
|
||||
|
||||
@@ -10,24 +10,59 @@ import * as yaml from 'yaml'
|
||||
|
||||
import { getFilenames } from '../utils'
|
||||
|
||||
export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary'
|
||||
|
||||
export interface GenerateManifestFixturesOptions {
|
||||
artifactContent?: string
|
||||
channel: UpdateTestChannel
|
||||
releaseNotes: string
|
||||
rootDir: string
|
||||
channel: UpdateTestChannel
|
||||
target: string
|
||||
version: string
|
||||
releaseNotes: string
|
||||
artifactContent?: string
|
||||
}
|
||||
|
||||
export interface GenerateManifestFixturesResult {
|
||||
artifactFilename: string
|
||||
artifactPath: string
|
||||
channelDir: string
|
||||
latestFilename: 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> {
|
||||
const channelDir = join(options.rootDir, options.channel)
|
||||
@@ -45,52 +80,31 @@ export async function generateManifestFixtures(options: GenerateManifestFixtures
|
||||
const size = Buffer.byteLength(artifactContent)
|
||||
|
||||
const manifest = {
|
||||
version: options.version,
|
||||
files: [
|
||||
{
|
||||
url: artifactFilename,
|
||||
sha512,
|
||||
size,
|
||||
url: artifactFilename,
|
||||
},
|
||||
],
|
||||
path: artifactFilename,
|
||||
sha512,
|
||||
releaseDate,
|
||||
releaseNotes: options.releaseNotes,
|
||||
sha512,
|
||||
version: options.version,
|
||||
}
|
||||
|
||||
await writeFile(manifestPath, yaml.stringify(manifest), 'utf8')
|
||||
|
||||
return {
|
||||
artifactFilename,
|
||||
artifactPath,
|
||||
channelDir,
|
||||
latestFilename,
|
||||
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() {
|
||||
const cli = cac('generate-update-test-manifest')
|
||||
.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 result = await generateManifestFixtures({
|
||||
channel: String(parsed.options.channel) as UpdateTestChannel,
|
||||
releaseNotes: String(parsed.options.releaseNotes),
|
||||
rootDir: String(parsed.options.root),
|
||||
channel: String(parsed.options.channel) as UpdateTestChannel,
|
||||
target: String(parsed.options.target),
|
||||
version: String(parsed.options.version),
|
||||
releaseNotes: String(parsed.options.releaseNotes),
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -114,20 +128,6 @@ async function main() {
|
||||
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) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
|
||||
@@ -8,14 +8,18 @@ import { extname, join, normalize } from 'node:path'
|
||||
import { cac } from 'cac'
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.deb': 'application/vnd.debian.binary-package',
|
||||
'.dmg': 'application/octet-stream',
|
||||
'.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',
|
||||
'.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 }) {
|
||||
@@ -44,10 +48,6 @@ export async function startUpdateTestServer(options: { port: number, rootDir: st
|
||||
return server
|
||||
}
|
||||
|
||||
function getContentType(pathname: string) {
|
||||
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cli = cac('update-test-server')
|
||||
.option('--port <port>', 'Port to listen on', { default: '8787' })
|
||||
|
||||
@@ -6,385 +6,7 @@ import { x } from 'tinyexec'
|
||||
|
||||
import packageJSON from '../package.json' with { type: 'json' }
|
||||
|
||||
interface FilenameOutputEntry {
|
||||
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[] }) {
|
||||
export async function getVersion(options: { release: boolean, autoTag: boolean, tag: string[] }) {
|
||||
if (!options.release || !options.tag) {
|
||||
// Otherwise, fetch from the latest git ref
|
||||
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(
|
||||
target: string,
|
||||
ext: string,
|
||||
@@ -463,17 +118,17 @@ export function mapArchFor(
|
||||
}
|
||||
}
|
||||
|
||||
function getLatestUpdateFilename(target: string): null | string {
|
||||
function getLatestUpdateFilename(target: string): string | null {
|
||||
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':
|
||||
return `latest-${mapArchFor(target, 'yml')}.yml`
|
||||
case 'x86_64-unknown-linux-gnu':
|
||||
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:
|
||||
return null
|
||||
}
|
||||
@@ -484,3 +139,348 @@ function getMacZipFilename(productName: string, version: string, target: string)
|
||||
const archPrefix = arch === 'x64' ? '' : `${arch}-`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,19 @@ import { env } from 'node:process'
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -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.
|
||||
*/
|
||||
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.
|
||||
* @param content - The formatted log content to append
|
||||
@@ -51,23 +55,68 @@ export interface FileLoggerHandle {
|
||||
* Closes the log file and releases resources.
|
||||
*/
|
||||
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 = {
|
||||
logFilePath: null,
|
||||
logFileFd: null,
|
||||
appendLog: async () => {},
|
||||
close: async () => {},
|
||||
logFileFd: null,
|
||||
logFilePath: null,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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.
|
||||
*
|
||||
@@ -137,7 +186,7 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
|
||||
console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`)
|
||||
}
|
||||
|
||||
return { appendLog, close, logFileFd, logFilePath }
|
||||
return { logFilePath, logFileFd, appendLog, close }
|
||||
}
|
||||
catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
@@ -145,52 +194,3 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -33,25 +55,3 @@ export function installSingleInstanceGuard(options: SingleInstanceGuardOptions)
|
||||
|
||||
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'
|
||||
|
||||
export const artistryConfigSchema = object({
|
||||
artistryProvider: optional(string(), 'none'),
|
||||
artistryGlobals: optional(object({
|
||||
comfyuiActiveWorkflow: optional(string(), ''),
|
||||
comfyuiSavedWorkflows: optional(array(any()), []),
|
||||
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(), ''),
|
||||
nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'),
|
||||
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() {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { array, object, optional, picklist, string } from 'valibot'
|
||||
import { createConfig } from '../libs/electron/persistence'
|
||||
|
||||
const shortcutAcceleratorSchema = object({
|
||||
key: string(),
|
||||
modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])),
|
||||
key: string(),
|
||||
})
|
||||
|
||||
export const globalAppConfigSchema = object({
|
||||
|
||||
@@ -134,6 +134,7 @@ app.whenReady().then(async () => {
|
||||
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
|
||||
const electronApp = injeca.provide('host:electron:app', () => app)
|
||||
const autoUpdater = injeca.provide('services:auto-updater', {
|
||||
dependsOn: { appConfig },
|
||||
build: ({ dependsOn }) => setupAutoUpdater({
|
||||
enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam',
|
||||
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
|
||||
@@ -145,17 +146,16 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
},
|
||||
}),
|
||||
dependsOn: { appConfig },
|
||||
})
|
||||
|
||||
const i18n = injeca.provide('libs:i18n', {
|
||||
build: ({ dependsOn }) => createI18n({ locale: dependsOn.appConfig.get()?.language, messages }),
|
||||
dependsOn: { appConfig },
|
||||
build: ({ dependsOn }) => createI18n({ messages, locale: dependsOn.appConfig.get()?.language }),
|
||||
})
|
||||
|
||||
const serverChannel = injeca.provide('modules:channel-server', {
|
||||
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
|
||||
dependsOn: { app: electronApp, lifecycle },
|
||||
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
|
||||
})
|
||||
|
||||
const airiHttpServer = injeca.provide('modules:airi-http-server', {
|
||||
@@ -167,8 +167,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', {
|
||||
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
|
||||
dependsOn: { lifecycle },
|
||||
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
|
||||
})
|
||||
|
||||
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
|
||||
@@ -176,13 +176,13 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const widgetsManager = injeca.provide('windows:widgets', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
||||
dependsOn: { i18n, serverChannel },
|
||||
})
|
||||
|
||||
const pluginHost = injeca.provide('modules:plugin-host', {
|
||||
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
|
||||
dependsOn: { serverChannel, widgetsManager },
|
||||
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
|
||||
})
|
||||
|
||||
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 onboardingWindowManager = injeca.provide('windows:onboarding', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
|
||||
dependsOn: { i18n, serverChannel },
|
||||
})
|
||||
|
||||
const noticeWindow = injeca.provide('windows:notice', {
|
||||
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
|
||||
dependsOn: { i18n, serverChannel },
|
||||
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
|
||||
})
|
||||
|
||||
const aboutWindow = injeca.provide('windows:about', {
|
||||
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
|
||||
dependsOn: { autoUpdater, i18n, serverChannel },
|
||||
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
|
||||
})
|
||||
|
||||
const chatWindow = injeca.provide('windows:chat', {
|
||||
dependsOn: { widgetsManager, serverChannel, mcpStdioManager, i18n },
|
||||
build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn),
|
||||
dependsOn: { i18n, mcpStdioManager, serverChannel, widgetsManager },
|
||||
})
|
||||
|
||||
const spotlightWindow = injeca.provide('windows:spotlight', {
|
||||
dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig },
|
||||
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
|
||||
dependsOn: { appConfig, chatWindow, globalShortcut, i18n, serverChannel },
|
||||
})
|
||||
|
||||
const editorWindow = injeca.provide('windows:editor', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupEditorWindowManager(dependsOn),
|
||||
dependsOn: { i18n, serverChannel },
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
|
||||
build: async ({ dependsOn }) =>
|
||||
setupSettingsWindowReusableFunc({
|
||||
...dependsOn,
|
||||
getMainWindow: () => userFacingMainWindow,
|
||||
}),
|
||||
dependsOn: { autoUpdater, beatSync, devtoolsWindow: devtoolsMarkdownStressWindow, globalShortcut, godotStageManager, i18n, mcpStdioManager, serverChannel, spotlightWindow, widgetsManager },
|
||||
})
|
||||
|
||||
const mainWindow = injeca.provide('windows:main', {
|
||||
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription },
|
||||
build: async ({ dependsOn }) => setupMainWindow({
|
||||
...dependsOn,
|
||||
onWindowCreated: (window) => {
|
||||
userFacingMainWindow = window
|
||||
},
|
||||
}),
|
||||
dependsOn: { appleSpeechTranscription, autoUpdater, beatSync, chatWindow, editorWindow, godotStageManager, i18n, mcpStdioManager, noticeWindow, onboardingWindowManager, serverChannel, settingsWindow, widgetsManager },
|
||||
})
|
||||
|
||||
const captionWindow = injeca.provide('windows:caption', {
|
||||
dependsOn: { mainWindow, serverChannel, i18n },
|
||||
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
|
||||
dependsOn: { i18n, mainWindow, serverChannel },
|
||||
})
|
||||
|
||||
const tray = injeca.provide('app:tray', {
|
||||
dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, serverChannel, beatSyncBgWindow: beatSync, aboutWindow, i18n },
|
||||
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
|
||||
if (isDesktopOverlayEnabled()) {
|
||||
const desktopOverlay = injeca.provide('windows:desktop-overlay', {
|
||||
dependsOn: { mcpStdioManager, serverChannel, i18n },
|
||||
build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn),
|
||||
dependsOn: { i18n, mcpStdioManager, serverChannel },
|
||||
})
|
||||
|
||||
// NOTICE: Separate invoke ensures the overlay is eagerly built.
|
||||
// Without this, injeca.start() would skip it because no other
|
||||
// provider depends on 'windows:desktop-overlay'.
|
||||
injeca.invoke({
|
||||
callback: noop,
|
||||
dependsOn: { desktopOverlay },
|
||||
callback: noop,
|
||||
})
|
||||
}
|
||||
|
||||
injeca.invoke({
|
||||
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig },
|
||||
callback: async (deps) => {
|
||||
const { context } = createContext(ipcMain)
|
||||
await setupArtistryBridge({
|
||||
artistryConfig: deps.artistryConfig,
|
||||
context,
|
||||
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))
|
||||
|
||||
@@ -2,10 +2,8 @@ const onAppReadyHooks = [] as (() => Promise<void> | void)[]
|
||||
const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[]
|
||||
const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[]
|
||||
|
||||
export async function emitAppBeforeQuit() {
|
||||
for (const fn of onAppBeforeQuitHooks) {
|
||||
await fn()
|
||||
}
|
||||
export function onAppReady(fn: () => Promise<void> | void) {
|
||||
onAppReadyHooks.push(fn)
|
||||
}
|
||||
|
||||
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) {
|
||||
onAppBeforeQuitHooks.push(fn)
|
||||
}
|
||||
|
||||
export function onAppReady(fn: () => Promise<void> | void) {
|
||||
onAppReadyHooks.push(fn)
|
||||
export async function emitAppBeforeQuit() {
|
||||
for (const fn of onAppBeforeQuitHooks) {
|
||||
await fn()
|
||||
}
|
||||
}
|
||||
|
||||
export function onAppWindowAllClosed(fn: () => Promise<void> | void) {
|
||||
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 = ''
|
||||
|
||||
export function setElectronMainDirname(dirname: string) {
|
||||
electronMainDirname = dirname
|
||||
}
|
||||
|
||||
export function getElectronMainDirname() {
|
||||
return electronMainDirname
|
||||
}
|
||||
|
||||
export function baseUrl(parentOfIndexHtml: string, filename?: string) {
|
||||
if (is.dev && env.ELECTRON_RENDERER_URL) {
|
||||
if (!filename) {
|
||||
@@ -25,11 +33,7 @@ export function baseUrl(parentOfIndexHtml: string, filename?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getElectronMainDirname() {
|
||||
return electronMainDirname
|
||||
}
|
||||
|
||||
export async function load(window: BrowserWindow, url: string | { file: string, options?: LoadFileOptions } | { options?: LoadURLOptions, url: string }) {
|
||||
export async function load(window: BrowserWindow, url: string | { url: string, options?: LoadURLOptions } | { file: string, options?: LoadFileOptions }) {
|
||||
try {
|
||||
if (typeof url === 'object' && 'url' in url) {
|
||||
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.
|
||||
*
|
||||
@@ -101,7 +101,7 @@ export function setElectronMainDirname(dirname: string) {
|
||||
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
|
||||
*/
|
||||
export function withHashRoute(
|
||||
baseUrl: string | { file: string } | { url: string },
|
||||
baseUrl: string | { url: string } | { file: string },
|
||||
hashRoute: string,
|
||||
options: Pick<LoadFileOptions, 'query'> = {},
|
||||
) {
|
||||
@@ -118,7 +118,7 @@ export function withHashRoute(
|
||||
|
||||
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) {
|
||||
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
|
||||
@@ -136,5 +136,5 @@ export function withHashRoute(
|
||||
|
||||
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 = {
|
||||
calls: 0,
|
||||
release: () => {},
|
||||
waitFor: Promise.resolve(),
|
||||
release: () => {},
|
||||
}
|
||||
const writeFileMock = vi.fn(async (path: string) => {
|
||||
existingTempFiles.add(path)
|
||||
|
||||
@@ -10,37 +10,57 @@ import { app } from 'electron'
|
||||
import { throttle } from 'es-toolkit'
|
||||
import { safeParse } from 'valibot'
|
||||
|
||||
type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error'
|
||||
|
||||
export interface ConfigDiagnostics<T> {
|
||||
error?: unknown
|
||||
healed?: boolean
|
||||
issues?: BaseIssue<unknown>[]
|
||||
path: string
|
||||
raw?: string
|
||||
status: ConfigStatus
|
||||
path: string
|
||||
issues?: BaseIssue<unknown>[]
|
||||
error?: unknown
|
||||
raw?: string
|
||||
healed?: boolean
|
||||
value?: T
|
||||
}
|
||||
|
||||
export interface CreateConfigOptions<T> {
|
||||
autoHeal?: boolean
|
||||
default?: T
|
||||
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
|
||||
autoHeal?: boolean
|
||||
onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void
|
||||
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
|
||||
}
|
||||
|
||||
type ConfigStatus = 'invalid' | 'missing' | 'ok' | 'read-error'
|
||||
|
||||
const persistenceMap = new Map<string, unknown>()
|
||||
const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>()
|
||||
|
||||
export interface Config<TSchema extends PersistedSchema> {
|
||||
get: () => InferOutput<TSchema> | undefined
|
||||
getDiagnostics: () => ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
||||
setup: () => ConfigDiagnostics<InferOutput<TSchema>>
|
||||
update: (newData: InferOutput<TSchema>) => void
|
||||
function createConfigPath(namespace: string, filename: string) {
|
||||
return join(app.getPath('userData'), `${namespace}-${filename}`)
|
||||
}
|
||||
|
||||
async function ensureConfigDirectory(path: string) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
}
|
||||
|
||||
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>(
|
||||
namespace: string,
|
||||
filename: string,
|
||||
@@ -90,8 +110,8 @@ export function createConfig<TSchema extends PersistedSchema>(
|
||||
const path = configPath()
|
||||
if (!existsSync(path)) {
|
||||
const diagnostics = recordDiagnostics({
|
||||
path,
|
||||
status: 'missing',
|
||||
path,
|
||||
value: options?.default,
|
||||
})
|
||||
persistenceMap.set(key, options?.default)
|
||||
@@ -103,8 +123,8 @@ export function createConfig<TSchema extends PersistedSchema>(
|
||||
const parsed = parseWithSchema(raw, schema)
|
||||
if (parsed.value !== undefined) {
|
||||
const diagnostics = recordDiagnostics({
|
||||
path,
|
||||
status: 'ok',
|
||||
path,
|
||||
value: parsed.value,
|
||||
})
|
||||
persistenceMap.set(key, parsed.value)
|
||||
@@ -113,10 +133,10 @@ export function createConfig<TSchema extends PersistedSchema>(
|
||||
|
||||
const fallback = options?.default
|
||||
const diagnostics = recordDiagnostics({
|
||||
issues: parsed.issues,
|
||||
path,
|
||||
raw,
|
||||
status: 'invalid',
|
||||
path,
|
||||
issues: parsed.issues,
|
||||
raw,
|
||||
value: fallback,
|
||||
})
|
||||
options?.onValidationFailure?.(diagnostics)
|
||||
@@ -134,9 +154,9 @@ export function createConfig<TSchema extends PersistedSchema>(
|
||||
catch (error) {
|
||||
const fallback = options?.default
|
||||
const diagnostics = recordDiagnostics({
|
||||
error,
|
||||
path,
|
||||
status: 'read-error',
|
||||
path,
|
||||
error,
|
||||
value: fallback,
|
||||
})
|
||||
options?.onReadError?.(diagnostics)
|
||||
@@ -155,29 +175,9 @@ export function createConfig<TSchema extends PersistedSchema>(
|
||||
const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
||||
|
||||
return {
|
||||
get,
|
||||
getDiagnostics,
|
||||
setup,
|
||||
get,
|
||||
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 { 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<
|
||||
// eslint-disable-next-line ts/no-empty-object-type
|
||||
Schema extends Record<string, any> = {},
|
||||
@@ -34,7 +28,7 @@ type ResolveResourceKeys<
|
||||
[K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K]
|
||||
}>
|
||||
: never,
|
||||
> = DefineMessagesPaths | SchemaPaths
|
||||
> = SchemaPaths | DefineMessagesPaths
|
||||
|
||||
interface TranslationFunction<
|
||||
// eslint-disable-next-line ts/no-empty-object-type
|
||||
@@ -138,17 +132,23 @@ interface TranslationFunction<
|
||||
): 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> {
|
||||
const log = useLogg('i18n').useGlobalConfig()
|
||||
|
||||
const locale = signal(options.locale)
|
||||
|
||||
const context = createCoreContext({
|
||||
fallbackFormat: true,
|
||||
fallbackLocale: options.fallbackLocale,
|
||||
fallbackWarn: false,
|
||||
missingWarn: false,
|
||||
warnHtmlMessage: false,
|
||||
fallbackFormat: true,
|
||||
...options,
|
||||
})
|
||||
|
||||
@@ -178,7 +178,7 @@ export function createI18n<Schema extends Record<string, any> = Record<string, a
|
||||
})
|
||||
|
||||
return {
|
||||
locale,
|
||||
t,
|
||||
locale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +34,6 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
|
||||
let closeLoopback: (() => void) | null = null
|
||||
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.
|
||||
*/
|
||||
@@ -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> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
code_verifier: codeVerifier,
|
||||
resource: SERVER_URL,
|
||||
})
|
||||
|
||||
const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
|
||||
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>
|
||||
return {
|
||||
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,
|
||||
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'
|
||||
|
||||
const channelServerConfigSchema = object({
|
||||
authToken: optional(string()),
|
||||
hostname: optional(string()),
|
||||
authToken: optional(string()),
|
||||
tlsConfig: optional(nullable(object({
|
||||
cert: optional(string()),
|
||||
key: optional(string()),
|
||||
@@ -41,44 +41,412 @@ const channelServerConfigSchema = object({
|
||||
})
|
||||
|
||||
const channelServerInvokeConfigSchema = z.object({
|
||||
authToken: z.string().optional(),
|
||||
hostname: z.string().optional(),
|
||||
authToken: z.string().optional(),
|
||||
tlsConfig: z.object({ }).nullable().optional(),
|
||||
}).strict()
|
||||
|
||||
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
|
||||
autoHeal: true,
|
||||
default: {
|
||||
authToken: '',
|
||||
hostname: '127.0.0.1',
|
||||
authToken: '',
|
||||
tlsConfig: null,
|
||||
},
|
||||
autoHeal: true,
|
||||
})
|
||||
let serverChannelServiceRegistered = false
|
||||
let serverChannelCertificateTrustConfigured = false
|
||||
|
||||
interface ServerChannelCertificateVerifyRequest {
|
||||
hostname: string
|
||||
verificationResult: string
|
||||
errorCode: number
|
||||
certificate: {
|
||||
subject: {
|
||||
commonName: string
|
||||
}
|
||||
issuer: {
|
||||
commonName: string
|
||||
country: string
|
||||
locality: string
|
||||
organizations: string[]
|
||||
}
|
||||
subject: {
|
||||
commonName: string
|
||||
}
|
||||
}
|
||||
errorCode: number
|
||||
hostname: string
|
||||
verificationResult: string
|
||||
}
|
||||
|
||||
function getServerChannelPort() {
|
||||
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 }) {
|
||||
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 }
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { HTTPError } from 'h3'
|
||||
|
||||
export interface HttpErrorInput {
|
||||
status: number
|
||||
code: string
|
||||
message: string
|
||||
reason?: string
|
||||
details?: unknown
|
||||
expose?: boolean
|
||||
}
|
||||
|
||||
export interface H3HttpErrorOptions {
|
||||
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.
|
||||
*
|
||||
@@ -28,11 +28,11 @@ export interface HttpErrorInput {
|
||||
* - Error instance with status and structured metadata
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly reason?: string
|
||||
readonly details?: unknown
|
||||
readonly expose: boolean
|
||||
readonly reason?: string
|
||||
readonly status: number
|
||||
|
||||
constructor(input: HttpErrorInput) {
|
||||
super(input.message)
|
||||
|
||||
@@ -25,9 +25,9 @@ export interface LoopbackCallbackResult {
|
||||
* - Random bound port, callback result promise, and manual cancellation method
|
||||
*/
|
||||
export async function startLoopbackServer(expectedState: string): Promise<{
|
||||
close: () => void
|
||||
port: number
|
||||
result: Promise<LoopbackCallbackResult>
|
||||
close: () => void
|
||||
}> {
|
||||
const host = '127.0.0.1'
|
||||
let settled = false
|
||||
@@ -44,8 +44,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
||||
const app = new H3()
|
||||
const loopbackServer = createH3Server({ app, host })
|
||||
const corsOptions = {
|
||||
methods: '*',
|
||||
origin: '*',
|
||||
methods: '*',
|
||||
preflight: {
|
||||
statusCode: 204,
|
||||
},
|
||||
@@ -93,8 +93,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
||||
const state = typeof query.state === 'string' ? query.state : ''
|
||||
if (!state || state !== expectedState) {
|
||||
return new Response('<html><body><h2>Invalid state</h2></body></html>', {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
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))
|
||||
})
|
||||
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,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code : ''
|
||||
if (!code) {
|
||||
return new Response('<html><body><h2>Missing parameters</h2></body></html>', {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
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>', {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -139,12 +139,12 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
result,
|
||||
close: () => {
|
||||
finish(() => {
|
||||
rejectResult(new Error('OIDC sign-in attempt cancelled'))
|
||||
})
|
||||
},
|
||||
port: address.port,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export interface BuiltInServer {
|
||||
*/
|
||||
export function setupBuiltInServer(params: {
|
||||
authServer?: ServerManager
|
||||
servers?: ServerManager[]
|
||||
staticAssetServer?: ServerManager
|
||||
servers?: ServerManager[]
|
||||
}): BuiltInServer {
|
||||
const servers = [
|
||||
...(params.authServer ? [params.authServer] : []),
|
||||
|
||||
@@ -3,9 +3,9 @@ import { getRandomPort } from 'get-port-please'
|
||||
import { serve } from 'h3'
|
||||
|
||||
export interface BuiltInServerAddress {
|
||||
baseUrl: string
|
||||
host: string
|
||||
port: number
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,9 +36,6 @@ export function createH3Server(options: {
|
||||
let address: BuiltInServerAddress | undefined
|
||||
|
||||
return {
|
||||
getAddress() {
|
||||
return address
|
||||
},
|
||||
async start(): Promise<BuiltInServerAddress> {
|
||||
return await lifecycleMutex.runExclusive(async () => {
|
||||
if (address) {
|
||||
@@ -49,9 +46,9 @@ export function createH3Server(options: {
|
||||
server = serve(options.app, { hostname: host, port, silent })
|
||||
|
||||
address = {
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
host,
|
||||
port,
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
}
|
||||
|
||||
return address
|
||||
@@ -69,5 +66,8 @@ export function createH3Server(options: {
|
||||
await activeServer.close().catch(() => {})
|
||||
})
|
||||
},
|
||||
getAddress() {
|
||||
return address
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -21,7 +21,7 @@ describe('createStaticAssetService', () => {
|
||||
}
|
||||
|
||||
for (const root of tempRoots) {
|
||||
await rm(root, { force: true, recursive: true })
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots.length = 0
|
||||
})
|
||||
@@ -53,10 +53,10 @@ describe('createStaticAssetService', () => {
|
||||
|
||||
const session = server.createSession({
|
||||
extensionId,
|
||||
version,
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version,
|
||||
})
|
||||
|
||||
const baseUrl = server.getBaseUrl()
|
||||
@@ -69,13 +69,13 @@ describe('createStaticAssetService', () => {
|
||||
})
|
||||
const responseBody = await response.text()
|
||||
expect({
|
||||
responseBody,
|
||||
status: response.status,
|
||||
validateInputs,
|
||||
responseBody,
|
||||
}).toEqual({
|
||||
responseBody: 'console.log("ok")\n',
|
||||
status: 200,
|
||||
validateInputs: ['assets/app.js'],
|
||||
responseBody: 'console.log("ok")\n',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,10 +89,10 @@ describe('createStaticAssetService', () => {
|
||||
|
||||
const session = server.createSession({
|
||||
extensionId,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
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 session = server.createSession({
|
||||
extensionId,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
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 session = server.createSession({
|
||||
extensionId,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
})
|
||||
const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`
|
||||
const headers = {
|
||||
@@ -171,10 +171,10 @@ describe('createStaticAssetService', () => {
|
||||
const { extensionId, server } = await createStartedAssetServer()
|
||||
const session = server.createSession({
|
||||
extensionId,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
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({
|
||||
extensionId,
|
||||
version,
|
||||
ownerSessionId: 'session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 60_000,
|
||||
version,
|
||||
})
|
||||
|
||||
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 {
|
||||
createSession: StaticAssetSessionStore['createSession']
|
||||
getBaseUrl: () => string | undefined
|
||||
revokeAll: StaticAssetSessionStore['revokeAll']
|
||||
revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId']
|
||||
revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId']
|
||||
createSession: StaticAssetSessionStore['createSession']
|
||||
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: {
|
||||
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
||||
getType?: (ext: string) => string | undefined
|
||||
host?: string
|
||||
sessionStore?: StaticAssetSessionStore
|
||||
getType?: (ext: string) => string | undefined
|
||||
}): StaticAssetService {
|
||||
const host = options.host ?? '127.0.0.1'
|
||||
const sessionStore = options.sessionStore ?? createStaticAssetSessionStore()
|
||||
@@ -71,54 +71,54 @@ export function createStaticAssetService(options: {
|
||||
}
|
||||
|
||||
const staticAssetRoute = createStaticAssetRoute({
|
||||
authorize: async ({ assetPath, assetSessionId, cookieValue, extensionId }) => {
|
||||
getType,
|
||||
authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => {
|
||||
const entry = getManifestEntryForRequest(extensionId)
|
||||
if (!entry) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED',
|
||||
message: 'Unauthorized',
|
||||
reason: 'extension manifest entry does not exist for requested extensionId',
|
||||
status: 401,
|
||||
}),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStore.validateRequest({
|
||||
assetPath,
|
||||
assetSessionId,
|
||||
cookieValue,
|
||||
extensionId,
|
||||
version: entry.version,
|
||||
assetSessionId,
|
||||
assetPath,
|
||||
cookieValue,
|
||||
})
|
||||
},
|
||||
getType,
|
||||
refreshSession: sessionStore.refreshSession,
|
||||
resolveAsset: async ({ assetPath, extensionId }) => {
|
||||
resolveAsset: async ({ extensionId, assetPath }) => {
|
||||
const entry = getManifestEntryForRequest(extensionId)
|
||||
if (!entry) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND',
|
||||
message: 'Not Found',
|
||||
reason: 'extension manifest entry does not exist for requested extensionId',
|
||||
status: 404,
|
||||
}),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAssetPath = normalizeStaticAssetPath(assetPath)
|
||||
if (!normalizedAssetPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 400,
|
||||
code: 'EXTENSION_ASSET_PATH_INVALID',
|
||||
message: 'Bad Request',
|
||||
reason: 'asset path could not be normalized',
|
||||
status: 400,
|
||||
}),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,24 +132,24 @@ export function createStaticAssetService(options: {
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'EXTENSION_ASSET_NOT_FOUND',
|
||||
message: 'Not Found',
|
||||
reason: 'resolved file does not exist',
|
||||
status: 404,
|
||||
}),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 400,
|
||||
code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED',
|
||||
message: 'Bad Request',
|
||||
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)
|
||||
if (!fileStats.isFile()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'EXTENSION_ASSET_NOT_FILE',
|
||||
message: 'Not Found',
|
||||
reason: 'resolved path exists but is not a file',
|
||||
status: 404,
|
||||
}),
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
mtime: fileStats.mtimeMs,
|
||||
ok: true,
|
||||
filePath,
|
||||
size: fileStats.size,
|
||||
mtime: fileStats.mtimeMs,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'EXTENSION_ASSET_NOT_FOUND',
|
||||
message: 'Not Found',
|
||||
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)))
|
||||
|
||||
return {
|
||||
createSession: sessionStore.createSession,
|
||||
getBaseUrl() {
|
||||
return serverLifecycle.getAddress()?.baseUrl
|
||||
},
|
||||
key: 'static-assets',
|
||||
revokeAll: sessionStore.revokeAll,
|
||||
revokeByExtensionId: sessionStore.revokeByExtensionId,
|
||||
revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId,
|
||||
revokeSession: sessionStore.revokeSession,
|
||||
async start() {
|
||||
await serverLifecycle.start()
|
||||
},
|
||||
async 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> = {
|
||||
'.wasm': 'application/wasm',
|
||||
'.avif': 'image/avif',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.wasm': 'application/wasm',
|
||||
}
|
||||
|
||||
function defaultStaticAssetMimeTypeResolver(ext: string) {
|
||||
|
||||
+11
-11
@@ -16,7 +16,7 @@ describe('static asset paths', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of tempRoots) {
|
||||
await rm(root, { force: true, recursive: true })
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots.length = 0
|
||||
})
|
||||
@@ -30,9 +30,9 @@ describe('static asset paths', () => {
|
||||
|
||||
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({
|
||||
assetPath: 'dist/ui/index.html',
|
||||
assetSessionId: 'asset-session-1',
|
||||
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/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', () => {
|
||||
expect(buildMountedStaticAssetPath({
|
||||
assetPath: 'dist/ui/index.html',
|
||||
assetSessionId: 'asset-session-1',
|
||||
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')
|
||||
expect(buildMountedStaticAssetPath({
|
||||
assetPath: 'dist/ui/file name.html',
|
||||
assetSessionId: 'asset-session-1',
|
||||
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')
|
||||
expect(buildMountedStaticAssetPath({
|
||||
assetPath: 'dist/ui/index.html',
|
||||
assetSessionId: 'asset-session-1',
|
||||
extensionId: 'bad/id',
|
||||
assetSessionId: 'asset-session-1',
|
||||
assetPath: 'dist/ui/index.html',
|
||||
})).toBeUndefined()
|
||||
expect(buildMountedStaticAssetPath({
|
||||
assetPath: 'dist/ui/index.html',
|
||||
assetSessionId: 'bad session',
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
assetSessionId: 'bad session',
|
||||
assetPath: 'dist/ui/index.html',
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,53 +8,30 @@ import { resolve, sep } from 'node:path'
|
||||
* @param assetSessionId Asset session identifier from the mounted route.
|
||||
*/
|
||||
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. */
|
||||
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 segmentPattern = /^[\w.+-]+$/
|
||||
|
||||
/**
|
||||
* Builds a session-scoped mounted plugin asset route path.
|
||||
*
|
||||
* Use when:
|
||||
* - 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: {
|
||||
assetPath: string
|
||||
assetSessionId: string
|
||||
extensionId: string
|
||||
}) {
|
||||
if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) {
|
||||
function decodePathSegment(segment: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath)
|
||||
if (!normalizedAssetPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
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}`
|
||||
function isSafeRouteSegment(segment: string): boolean {
|
||||
return !segment.includes('/')
|
||||
&& !segment.includes('\\')
|
||||
&& segmentPattern.test(segment)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,9 +151,9 @@ export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAsset
|
||||
}
|
||||
|
||||
return {
|
||||
assetPath,
|
||||
assetSessionId,
|
||||
extensionId,
|
||||
assetSessionId,
|
||||
assetPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,17 +196,40 @@ export async function resolveStaticAssetFilePath(rootDir: string, assetPath: str
|
||||
return realCandidate
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
}
|
||||
catch {
|
||||
/**
|
||||
* Builds a session-scoped mounted plugin asset route path.
|
||||
*
|
||||
* Use when:
|
||||
* - 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
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeRouteSegment(segment: string): boolean {
|
||||
return !segment.includes('/')
|
||||
&& !segment.includes('\\')
|
||||
&& segmentPattern.test(segment)
|
||||
const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath)
|
||||
if (!normalizedAssetPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
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
-17
@@ -17,7 +17,7 @@ describe('createStaticAssetRoute', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of tempRoots) {
|
||||
await rm(root, { force: true, recursive: true })
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots.length = 0
|
||||
|
||||
@@ -36,21 +36,21 @@ describe('createStaticAssetRoute', () => {
|
||||
const app = new H3()
|
||||
app.get('/_airi/extensions/**', createStaticAssetRoute({
|
||||
authorize: async () => ({
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 401,
|
||||
code: 'COOKIE_MISSING',
|
||||
message: 'Unauthorized',
|
||||
status: 401,
|
||||
}),
|
||||
ok: false,
|
||||
}),
|
||||
refreshSession: () => undefined,
|
||||
resolveAsset: async () => ({
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not Found',
|
||||
status: 404,
|
||||
}),
|
||||
ok: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -70,21 +70,21 @@ describe('createStaticAssetRoute', () => {
|
||||
const app = new H3()
|
||||
app.use('/_airi/extensions/**', createStaticAssetRoute({
|
||||
authorize: async () => ({
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 401,
|
||||
code: 'COOKIE_MISSING',
|
||||
message: 'Unauthorized',
|
||||
status: 401,
|
||||
}),
|
||||
ok: false,
|
||||
}),
|
||||
refreshSession: () => undefined,
|
||||
resolveAsset: async () => ({
|
||||
ok: false,
|
||||
error: new HttpError({
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not Found',
|
||||
status: 404,
|
||||
}),
|
||||
ok: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -122,23 +122,23 @@ describe('createStaticAssetRoute', () => {
|
||||
session: {
|
||||
assetSessionId: 's1',
|
||||
cookieName: createStaticAssetSessionCookieName('s1'),
|
||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||
cookieValue: 'test-token',
|
||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||
expiresAt: Date.now() + 1000,
|
||||
},
|
||||
}
|
||||
},
|
||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||
refreshSession: (assetSessionId) => {
|
||||
refreshedSessionId = assetSessionId
|
||||
return undefined
|
||||
},
|
||||
resolveAsset: async () => ({
|
||||
filePath: wasmFilePath,
|
||||
mtime: Date.now(),
|
||||
ok: true,
|
||||
filePath: wasmFilePath,
|
||||
size: 4,
|
||||
mtime: Date.now(),
|
||||
}),
|
||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||
}))
|
||||
|
||||
server = createServer(toNodeHandler(app))
|
||||
@@ -178,23 +178,23 @@ describe('createStaticAssetRoute', () => {
|
||||
session: {
|
||||
assetSessionId: 's1',
|
||||
cookieName: createStaticAssetSessionCookieName('s1'),
|
||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||
cookieValue: 'test-token',
|
||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||
expiresAt: Date.now() + 1000,
|
||||
},
|
||||
}
|
||||
},
|
||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||
refreshSession: (assetSessionId) => {
|
||||
refreshedSessionId = assetSessionId
|
||||
return undefined
|
||||
},
|
||||
resolveAsset: async () => ({
|
||||
filePath: wasmFilePath,
|
||||
mtime: Date.now(),
|
||||
ok: true,
|
||||
filePath: wasmFilePath,
|
||||
size: 4,
|
||||
mtime: Date.now(),
|
||||
}),
|
||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||
}))
|
||||
|
||||
server = createServer(toNodeHandler(app))
|
||||
|
||||
@@ -16,14 +16,14 @@ const staticAssetSecurityHeaders = {
|
||||
|
||||
export interface StaticAssetRouteOptions {
|
||||
authorize: (params: {
|
||||
assetPath: string
|
||||
assetSessionId: string
|
||||
cookieValue: string | undefined
|
||||
extensionId: string
|
||||
assetSessionId: string
|
||||
assetPath: string
|
||||
cookieValue: string | undefined
|
||||
}) => Promise<StaticAssetSessionValidationResult>
|
||||
getType?: (ext: string) => string | 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') {
|
||||
throw new HttpError({
|
||||
status: 405,
|
||||
code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED',
|
||||
message: 'Method Not Allowed',
|
||||
status: 405,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,19 +61,19 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
||||
|
||||
if (!extensionId || !assetSessionId || !assetPath) {
|
||||
throw new HttpError({
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_REQUEST_INVALID',
|
||||
message: 'Unauthorized',
|
||||
reason: 'required extensionId, assetSessionId, or assetPath is missing',
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId))
|
||||
const auth = await options.authorize({
|
||||
assetPath,
|
||||
assetSessionId,
|
||||
cookieValue,
|
||||
extensionId,
|
||||
assetSessionId,
|
||||
assetPath,
|
||||
cookieValue,
|
||||
})
|
||||
if (!auth.ok) {
|
||||
throw auth.error
|
||||
@@ -84,12 +84,13 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
||||
let resolved: Awaited<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | undefined
|
||||
const resolveOnce = async () => {
|
||||
if (!resolved) {
|
||||
resolved = await options.resolveAsset({ assetPath, extensionId })
|
||||
resolved = await options.resolveAsset({ extensionId, assetPath })
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return await serveStatic(event, {
|
||||
getType: options.getType,
|
||||
getContents: async () => {
|
||||
const item = await resolveOnce()
|
||||
if (!item.ok) {
|
||||
@@ -104,11 +105,10 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
mtime: item.mtime,
|
||||
size: item.size,
|
||||
mtime: item.mtime,
|
||||
}
|
||||
},
|
||||
getType: options.getType,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
+104
-104
@@ -27,21 +27,21 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now })
|
||||
const session = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
expect(session.assetSessionId).toBeTruthy()
|
||||
expect(session.cookieName).toContain(session.assetSessionId)
|
||||
expect(session.cookieValue).toBeTruthy()
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'assets/index.js',
|
||||
cookieValue: session.cookieValue,
|
||||
}).ok).toBe(true)
|
||||
})
|
||||
|
||||
@@ -54,39 +54,39 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now })
|
||||
const session = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'index.html',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'other-plugin',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'index.html',
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1)
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'index.html',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'index.html',
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -99,96 +99,96 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now })
|
||||
const session = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'assets/index.js',
|
||||
cookieValue: undefined,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_COOKIE_MISSING',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: 'wrong-cookie',
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'assets/index.js',
|
||||
cookieValue: 'wrong-cookie',
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.2.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'assets/index.js',
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_VERSION_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: '',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_PATH_EMPTY',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_PATH_EMPTY',
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'other/index.js',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'other/index.js',
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
},
|
||||
})
|
||||
|
||||
now.mockReturnValue(31_001)
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: 'assets/index.js',
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,24 +201,24 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now })
|
||||
const firstSession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
const secondSession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-2',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
const thirdSession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-go',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-3',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
now.mockReturnValue(2000)
|
||||
@@ -243,21 +243,21 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now })
|
||||
const createdSession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
const originalCookieValue = createdSession.cookieValue
|
||||
|
||||
tryMutateCookieValue(createdSession, 'mutated-create-cookie')
|
||||
|
||||
const firstValidation = store.validateRequest({
|
||||
assetPath: 'index.html',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
cookieValue: originalCookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
assetPath: 'index.html',
|
||||
cookieValue: originalCookieValue,
|
||||
})
|
||||
expect(firstValidation.ok).toBe(true)
|
||||
expect(Object.isFrozen(createdSession)).toBe(true)
|
||||
@@ -268,11 +268,11 @@ describe('createStaticAssetSessionStore', () => {
|
||||
tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie')
|
||||
|
||||
const secondValidation = store.validateRequest({
|
||||
assetPath: 'index.html',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
cookieValue: originalCookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
assetPath: 'index.html',
|
||||
cookieValue: originalCookieValue,
|
||||
})
|
||||
expect(secondValidation.ok).toBe(true)
|
||||
expect(Object.isFrozen(firstValidation.session)).toBe(true)
|
||||
@@ -290,11 +290,11 @@ describe('createStaticAssetSessionStore', () => {
|
||||
tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie')
|
||||
|
||||
const thirdValidation = store.validateRequest({
|
||||
assetPath: 'index.html',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
cookieValue: originalCookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: createdSession.assetSessionId,
|
||||
assetPath: 'index.html',
|
||||
cookieValue: originalCookieValue,
|
||||
})
|
||||
expect(thirdValidation.ok).toBe(true)
|
||||
expect(Object.isFrozen(refreshedSession)).toBe(true)
|
||||
@@ -321,9 +321,9 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
||||
const input = {
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '',
|
||||
version: '0.1.0',
|
||||
}
|
||||
|
||||
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 session = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
for (const assetPath of [
|
||||
@@ -354,26 +354,26 @@ describe('createStaticAssetSessionStore', () => {
|
||||
'assets%2Fsecret.js',
|
||||
]) {
|
||||
expect(store.validateRequest({
|
||||
assetPath,
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookieValue: session.cookieValue,
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath,
|
||||
cookieValue: session.cookieValue,
|
||||
})).toMatchObject({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
expect(() => store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: '../',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})).toThrow(RangeError)
|
||||
})
|
||||
|
||||
@@ -385,47 +385,47 @@ describe('createStaticAssetSessionStore', () => {
|
||||
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
||||
const directorySession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
const exactSession = store.createSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
ownerSessionId: 'plugin-session-2',
|
||||
pathPrefix: 'assets',
|
||||
ttlMs: 30_000,
|
||||
version: '0.1.0',
|
||||
})
|
||||
|
||||
expect(store.validateRequest({
|
||||
assetPath: 'assets/index.js',
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '0.1.0',
|
||||
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',
|
||||
assetSessionId: exactSession.assetSessionId,
|
||||
cookieValue: exactSession.cookieValue,
|
||||
cookieValue: directorySession.cookieValue,
|
||||
}).ok).toBe(true)
|
||||
|
||||
expect(store.validateRequest({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
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({
|
||||
error: {
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
status: 401,
|
||||
},
|
||||
ok: false,
|
||||
error: {
|
||||
status: 401,
|
||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+99
-99
@@ -14,15 +14,57 @@ import { normalizeStaticAssetPath } from './paths'
|
||||
|
||||
interface StaticAssetSessionRecord {
|
||||
assetSessionId: string
|
||||
cookieName: string
|
||||
cookiePath: string
|
||||
cookieValue: string
|
||||
expiresAt: number
|
||||
extensionId: string
|
||||
version: string
|
||||
ownerSessionId: string
|
||||
pathPrefix: string
|
||||
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}`
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -69,7 +147,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
||||
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)
|
||||
if (!record) {
|
||||
return {
|
||||
@@ -99,15 +177,15 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
||||
const assetSessionId = createOpaqueToken()
|
||||
const record: StaticAssetSessionRecord = {
|
||||
assetSessionId,
|
||||
cookieName: createStaticAssetSessionCookieName(assetSessionId),
|
||||
cookiePath: createCookiePath(input.extensionId, assetSessionId),
|
||||
cookieValue: createOpaqueToken(),
|
||||
expiresAt: now() + input.ttlMs,
|
||||
extensionId: input.extensionId,
|
||||
version: input.version,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
pathPrefix: normalizePathPrefix(input.pathPrefix),
|
||||
ttlMs: input.ttlMs,
|
||||
version: input.version,
|
||||
cookieName: createStaticAssetSessionCookieName(assetSessionId),
|
||||
cookieValue: createOpaqueToken(),
|
||||
cookiePath: createCookiePath(input.extensionId, assetSessionId),
|
||||
expiresAt: now() + input.ttlMs,
|
||||
}
|
||||
|
||||
records.set(assetSessionId, record)
|
||||
@@ -175,6 +253,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
||||
|
||||
return {
|
||||
createSession,
|
||||
validateRequest,
|
||||
refreshSession(assetSessionId) {
|
||||
const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED')
|
||||
if (!active.ok) {
|
||||
@@ -184,15 +263,6 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
||||
active.record.expiresAt = now() + active.record.ttlMs
|
||||
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) {
|
||||
const record = records.get(assetSessionId)
|
||||
if (!record) {
|
||||
@@ -202,84 +272,14 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
||||
records.delete(assetSessionId)
|
||||
return createSessionSnapshot(record)
|
||||
},
|
||||
validateRequest,
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
revokeByOwnerSessionId(ownerSessionId) {
|
||||
return revokeWhere(record => record.ownerSessionId === ownerSessionId)
|
||||
},
|
||||
revokeByExtensionId(extensionId) {
|
||||
return revokeWhere(record => record.extensionId === extensionId)
|
||||
},
|
||||
revokeAll() {
|
||||
return revokeWhere(() => true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
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.
|
||||
*/
|
||||
export interface StaticAssetSessionCreateInput {
|
||||
/** Plugin extension id that owns the served static assets. */
|
||||
extensionId: string
|
||||
/** Extension version expected by requests using this asset session. */
|
||||
version: string
|
||||
/** Parent plugin session id used for owner-scoped revocation. */
|
||||
ownerSessionId: string
|
||||
/**
|
||||
@@ -37,49 +19,67 @@ export interface StaticAssetSessionCreateInput {
|
||||
pathPrefix: string
|
||||
/** Session lifetime in milliseconds from creation or refresh time. */
|
||||
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.
|
||||
*/
|
||||
export interface StaticAssetSessionStore {
|
||||
/** Creates a new cookie-backed static asset session. */
|
||||
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. */
|
||||
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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data required to validate a cookie-backed static asset session.
|
||||
*/
|
||||
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 }
|
||||
export type StaticAssetResolveResult
|
||||
= | { ok: true, filePath: string, size: number, mtime: number }
|
||||
| { ok: false, error: HttpError }
|
||||
|
||||
@@ -11,7 +11,7 @@ import { injeca } from 'injeca'
|
||||
|
||||
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>> })
|
||||
params.i18n.locale(config.get()?.language || 'en')
|
||||
|
||||
|
||||
@@ -48,11 +48,11 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => {
|
||||
|
||||
return {
|
||||
StdioClientTransport: class {
|
||||
close = vi.fn(async () => undefined)
|
||||
|
||||
stderr = new PassThrough()
|
||||
|
||||
constructor(readonly server: unknown) {}
|
||||
|
||||
close = vi.fn(async () => undefined)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -76,10 +76,10 @@ describe('createMcpStdioManager', () => {
|
||||
})
|
||||
|
||||
const result = await manager.testServer({
|
||||
name: 'broken-server',
|
||||
config: {
|
||||
command: 'broken-mcp-server',
|
||||
},
|
||||
name: 'broken-server',
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
|
||||
@@ -36,23 +36,23 @@ import {
|
||||
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
|
||||
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 {
|
||||
client: Client
|
||||
config: ElectronMcpStdioServerConfig
|
||||
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 = {
|
||||
@@ -63,38 +63,53 @@ const mcpRequestTimeoutMsec = 10_000
|
||||
const mcpRequestMaxTotalTimeoutMsec = 15_000
|
||||
const mcpTestStderrMaxChars = 16_000
|
||||
|
||||
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
|
||||
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
|
||||
return params.manager.openConfigFile()
|
||||
})
|
||||
function stringifyError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
|
||||
return params.manager.applyAndRestart()
|
||||
})
|
||||
return String(error)
|
||||
}
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
|
||||
return params.manager.getRuntimeStatus()
|
||||
})
|
||||
function getConfigPath() {
|
||||
return join(app.getPath('userData'), 'mcp.json')
|
||||
}
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpListTools, async () => {
|
||||
return params.manager.listTools()
|
||||
})
|
||||
function parseQualifiedToolName(name: string) {
|
||||
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 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 {
|
||||
const normalizedTransportPrefix = toolName
|
||||
.replace(/^\.(?:stdio|stdo)::/, '')
|
||||
.replace(/^(?:stdio|stdo)::/, '')
|
||||
if (normalizedTransportPrefix !== toolName) {
|
||||
return normalizedTransportPrefix
|
||||
}
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
|
||||
return params.manager.writeConfigText(payload.text)
|
||||
})
|
||||
const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
|
||||
if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
|
||||
return params.manager.testServer(payload)
|
||||
})
|
||||
return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
|
||||
}
|
||||
|
||||
async function closeSession(session: McpServerSession) {
|
||||
try {
|
||||
await session.client.close()
|
||||
}
|
||||
catch {
|
||||
await session.transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpStdioManager(): McpStdioManager {
|
||||
@@ -138,11 +153,11 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
for (const [name, session] of entries) {
|
||||
await closeSession(session)
|
||||
setRuntimeStatus({
|
||||
args: session.config.args ?? [],
|
||||
command: session.config.command,
|
||||
name,
|
||||
pid: null,
|
||||
state: 'stopped',
|
||||
command: session.config.command,
|
||||
args: session.config.args ?? [],
|
||||
pid: null,
|
||||
})
|
||||
sessions.delete(name)
|
||||
}
|
||||
@@ -150,10 +165,10 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
|
||||
const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => {
|
||||
const transport = new StdioClientTransport({
|
||||
args: config.args ?? [],
|
||||
command: config.command,
|
||||
cwd: config.cwd,
|
||||
args: config.args ?? [],
|
||||
env: config.env,
|
||||
cwd: config.cwd,
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const client = new Client({
|
||||
@@ -169,13 +184,13 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
log.withFields({ serverName: name }).warn(text)
|
||||
}
|
||||
})
|
||||
sessions.set(name, { client, config, transport })
|
||||
sessions.set(name, { client, transport, config })
|
||||
setRuntimeStatus({
|
||||
args: config.args ?? [],
|
||||
command: config.command,
|
||||
name,
|
||||
pid: transport.pid,
|
||||
state: 'running',
|
||||
command: config.command,
|
||||
args: config.args ?? [],
|
||||
pid: transport.pid,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
@@ -192,21 +207,21 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
runtimeStatuses.clear()
|
||||
|
||||
const result: ElectronMcpStdioApplyResult = {
|
||||
failed: [],
|
||||
path,
|
||||
skipped: [],
|
||||
started: [],
|
||||
failed: [],
|
||||
skipped: [],
|
||||
}
|
||||
|
||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||
if (server.enabled === false) {
|
||||
result.skipped.push({ name, reason: 'disabled' })
|
||||
setRuntimeStatus({
|
||||
args: server.args ?? [],
|
||||
command: server.command,
|
||||
name,
|
||||
pid: null,
|
||||
state: 'stopped',
|
||||
command: server.command,
|
||||
args: server.args ?? [],
|
||||
pid: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -217,14 +232,14 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
}
|
||||
catch (error) {
|
||||
const message = stringifyError(error)
|
||||
result.failed.push({ error: message, name })
|
||||
result.failed.push({ name, error: message })
|
||||
setRuntimeStatus({
|
||||
args: server.args ?? [],
|
||||
command: server.command,
|
||||
lastError: message,
|
||||
name,
|
||||
pid: null,
|
||||
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]) => {
|
||||
try {
|
||||
const response = await session.client.listTools(undefined, {
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
timeout: mcpRequestTimeoutMsec,
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
})
|
||||
return response.tools.map<ElectronMcpToolDescriptor>(item => ({
|
||||
serverName,
|
||||
name: `${serverName}${toolNameSeparator}${item.name}`,
|
||||
toolName: item.name,
|
||||
description: item.description,
|
||||
inputSchema: item.inputSchema,
|
||||
name: `${serverName}${toolNameSeparator}${item.name}`,
|
||||
serverName,
|
||||
toolName: item.name,
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
@@ -269,11 +284,11 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
let result
|
||||
try {
|
||||
result = await session.client.callTool({
|
||||
arguments: payload.arguments ?? {},
|
||||
name: toolName,
|
||||
arguments: payload.arguments ?? {},
|
||||
}, undefined, {
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
timeout: mcpRequestTimeoutMsec,
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
@@ -283,17 +298,17 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
}
|
||||
|
||||
log.withFields({
|
||||
fallbackToolName,
|
||||
requestedToolName: toolName,
|
||||
serverName,
|
||||
requestedToolName: toolName,
|
||||
fallbackToolName,
|
||||
}).warn('retrying mcp tool call with normalized tool name')
|
||||
|
||||
result = await session.client.callTool({
|
||||
arguments: payload.arguments ?? {},
|
||||
name: fallbackToolName,
|
||||
arguments: payload.arguments ?? {},
|
||||
}, undefined, {
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
timeout: mcpRequestTimeoutMsec,
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,7 +353,7 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
|
||||
const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => {
|
||||
const startedAt = Date.now()
|
||||
let transport: null | StdioClientTransport = null
|
||||
let transport: StdioClientTransport | null = null
|
||||
let client: Client | null = null
|
||||
const stderrChunks: string[] = []
|
||||
|
||||
@@ -355,10 +370,10 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
|
||||
try {
|
||||
transport = new StdioClientTransport({
|
||||
args: payload.config.args ?? [],
|
||||
command: payload.config.command,
|
||||
cwd: payload.config.cwd,
|
||||
args: payload.config.args ?? [],
|
||||
env: payload.config.env,
|
||||
cwd: payload.config.cwd,
|
||||
stderr: 'pipe',
|
||||
})
|
||||
client = new Client({
|
||||
@@ -375,8 +390,8 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect')
|
||||
|
||||
const response = await client.listTools(undefined, {
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
timeout: mcpRequestTimeoutMsec,
|
||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||
})
|
||||
|
||||
if (stderrChunks.length > 0) {
|
||||
@@ -384,9 +399,9 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs: Date.now() - startedAt,
|
||||
ok: true,
|
||||
tools: response.tools.map(tool => tool.name),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -394,9 +409,9 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
// Keep only the tail so a noisy failed server cannot flood the settings UI.
|
||||
const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars)
|
||||
return {
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: stderr ? `${message}\n\n${stderr}` : message,
|
||||
ok: false,
|
||||
error: stderr ? `${message}\n\n${stderr}` : message,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
@@ -410,16 +425,16 @@ export function createMcpStdioManager(): McpStdioManager {
|
||||
}
|
||||
|
||||
return {
|
||||
applyAndRestart,
|
||||
callTool,
|
||||
ensureConfigFile,
|
||||
getRuntimeStatus,
|
||||
listTools,
|
||||
openConfigFile,
|
||||
readConfigText,
|
||||
applyAndRestart,
|
||||
listTools,
|
||||
callTool,
|
||||
stopAll,
|
||||
testServer,
|
||||
getRuntimeStatus,
|
||||
readConfigText,
|
||||
writeConfigText,
|
||||
testServer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,51 +458,36 @@ export async function setupMcpStdioManager() {
|
||||
return manager
|
||||
}
|
||||
|
||||
async function closeSession(session: McpServerSession) {
|
||||
try {
|
||||
await session.client.close()
|
||||
}
|
||||
catch {
|
||||
await session.transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigPath() {
|
||||
return join(app.getPath('userData'), 'mcp.json')
|
||||
}
|
||||
|
||||
function parseQualifiedToolName(name: string) {
|
||||
const separatorIndex = name.indexOf(toolNameSeparator)
|
||||
if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) {
|
||||
throw new Error(`invalid qualified tool name: ${name}`)
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: name.slice(0, separatorIndex),
|
||||
toolName: name.slice(separatorIndex + toolNameSeparator.length),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFallbackToolName(toolName: string): string | undefined {
|
||||
const normalizedTransportPrefix = toolName
|
||||
.replace(/^\.(?:stdio|stdo)::/, '')
|
||||
.replace(/^(?:stdio|stdo)::/, '')
|
||||
if (normalizedTransportPrefix !== toolName) {
|
||||
return normalizedTransportPrefix
|
||||
}
|
||||
|
||||
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)
|
||||
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
|
||||
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
|
||||
return params.manager.openConfigFile()
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
|
||||
return params.manager.applyAndRestart()
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
|
||||
return params.manager.getRuntimeStatus()
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpListTools, async () => {
|
||||
return params.manager.listTools()
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => {
|
||||
return params.manager.callTool(payload)
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpReadConfigText, async () => {
|
||||
return params.manager.readConfigText()
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
|
||||
return params.manager.writeConfigText(payload.text)
|
||||
})
|
||||
|
||||
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
|
||||
return params.manager.testServer(payload)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ const ANIMATION_DURATION = 350
|
||||
|
||||
export function createOnboardingService(params: {
|
||||
context: ReturnType<typeof createContext>['context']
|
||||
mainWindow: BrowserWindow
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
mainWindow: BrowserWindow
|
||||
}) {
|
||||
const mainWindowAnimator = new Animator(params.mainWindow)
|
||||
let cleanupOnClosed: (() => void) | undefined
|
||||
@@ -29,15 +29,15 @@ export function createOnboardingService(params: {
|
||||
|
||||
const adjacent = computeAdjacentPosition(
|
||||
onboardingBounds,
|
||||
{ height: savedBounds.height, width: savedBounds.width },
|
||||
{ width: savedBounds.width, height: savedBounds.height },
|
||||
display.workArea,
|
||||
)
|
||||
|
||||
mainWindowAnimator.windowBoundsAnimateTo({
|
||||
height: adjacent.height,
|
||||
width: adjacent.width,
|
||||
x: adjacent.x,
|
||||
y: adjacent.y,
|
||||
width: adjacent.width,
|
||||
height: adjacent.height,
|
||||
}, { duration: ANIMATION_DURATION })
|
||||
|
||||
let userMovedManually = false
|
||||
|
||||
+19
-19
@@ -24,12 +24,12 @@ import { manifestIdOf } from '../../host/registry'
|
||||
* - N/A
|
||||
*/
|
||||
export interface ExtensionAutoReloadFeatureOptions {
|
||||
getConfig: () => ExtensionConfig
|
||||
isLoaded: (extensionId: string) => boolean
|
||||
listEntries: () => ManifestEntry[]
|
||||
log: ReturnType<typeof useLogg>
|
||||
reload: (extensionId: string, changedPath: string) => Promise<void>
|
||||
getConfig: () => ExtensionConfig
|
||||
listEntries: () => ManifestEntry[]
|
||||
isLoaded: (extensionId: string) => boolean
|
||||
resolveWatchPaths: (extensionId: string) => string[]
|
||||
reload: (extensionId: string, changedPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,21 +102,6 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea
|
||||
}
|
||||
|
||||
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() {
|
||||
const enabledExtensionIds = new Set(options.getConfig().autoReload)
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+71
-71
@@ -14,51 +14,51 @@ vi.mock('../../../http-server/static-assets', () => ({
|
||||
createStaticAssetService: mockState.createStaticAssetService,
|
||||
}))
|
||||
|
||||
function createFakeCookieAdapter() {
|
||||
const setCookies: ExtensionAssetCookie[] = []
|
||||
const removedCookies: ExtensionAssetCookie[] = []
|
||||
|
||||
function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession {
|
||||
return {
|
||||
adapter: {
|
||||
removeCookie: vi.fn(async (cookie) => {
|
||||
removedCookies.push(cookie)
|
||||
}),
|
||||
setCookie: vi.fn(async (cookie) => {
|
||||
setCookies.push(cookie)
|
||||
}),
|
||||
} satisfies ExtensionAssetCookieAdapter,
|
||||
removedCookies,
|
||||
setCookies,
|
||||
assetSessionId,
|
||||
cookieName: `airi_extension_asset_session_${assetSessionId}`,
|
||||
cookieValue: `cookie-value-${assetSessionId}`,
|
||||
cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`,
|
||||
expiresAt: 123_456,
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeServer(options: {
|
||||
baseUrl?: string
|
||||
createSessionResult?: StaticAssetSession
|
||||
revokeAllResult?: StaticAssetSession[]
|
||||
revokeByExtensionIdResult?: StaticAssetSession[]
|
||||
revokeByOwnerSessionIdResult?: StaticAssetSession[]
|
||||
revokeByExtensionIdResult?: StaticAssetSession[]
|
||||
revokeAllResult?: StaticAssetSession[]
|
||||
} = {}) {
|
||||
return {
|
||||
createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')),
|
||||
getBaseUrl: vi.fn(() => options.baseUrl),
|
||||
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 () => {}),
|
||||
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
|
||||
}
|
||||
|
||||
function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession {
|
||||
function createFakeCookieAdapter() {
|
||||
const setCookies: ExtensionAssetCookie[] = []
|
||||
const removedCookies: ExtensionAssetCookie[] = []
|
||||
|
||||
return {
|
||||
assetSessionId,
|
||||
cookieName: `airi_extension_asset_session_${assetSessionId}`,
|
||||
cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`,
|
||||
cookieValue: `cookie-value-${assetSessionId}`,
|
||||
expiresAt: 123_456,
|
||||
adapter: {
|
||||
setCookie: vi.fn(async (cookie) => {
|
||||
setCookies.push(cookie)
|
||||
}),
|
||||
removeCookie: vi.fn(async (cookie) => {
|
||||
removedCookies.push(cookie)
|
||||
}),
|
||||
} satisfies ExtensionAssetCookieAdapter,
|
||||
removedCookies,
|
||||
setCookies,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,41 +76,41 @@ describe('createExtensionAssetService', () => {
|
||||
mockState.createStaticAssetService.mockReturnValue(server)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
cookieAdapter: adapter,
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
const result = await service.createAssetSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 60_000,
|
||||
})
|
||||
|
||||
expect(server.createSession).toHaveBeenCalledWith({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
})
|
||||
expect(adapter.setCookie).toHaveBeenCalledOnce()
|
||||
expect(setCookies).toEqual([
|
||||
{
|
||||
expiresAt: 123_456,
|
||||
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',
|
||||
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({
|
||||
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',
|
||||
cookie: setCookies[0],
|
||||
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)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
cookieAdapter: adapter,
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
await expect(service.createAssetSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
ttlMs: 60_000,
|
||||
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')
|
||||
|
||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2')
|
||||
@@ -149,17 +149,17 @@ describe('createExtensionAssetService', () => {
|
||||
const { adapter } = createFakeCookieAdapter()
|
||||
mockState.createStaticAssetService.mockReturnValue(server)
|
||||
const service = createExtensionAssetService({
|
||||
cookieAdapter: adapter,
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
await expect(service.createAssetSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
pathPrefix: '',
|
||||
routeAssetPath: '../secret.txt',
|
||||
ttlMs: 60_000,
|
||||
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')
|
||||
|
||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3')
|
||||
@@ -170,11 +170,11 @@ describe('createExtensionAssetService', () => {
|
||||
|
||||
await expect(service.createAssetSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
pathPrefix: 'assets/',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
ttlMs: 60_000,
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 60_000,
|
||||
})).rejects.toThrow('cookie jar unavailable')
|
||||
|
||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4')
|
||||
@@ -187,17 +187,17 @@ describe('createExtensionAssetService', () => {
|
||||
const allSession = createSession('all-asset-session')
|
||||
const server = createFakeServer({
|
||||
baseUrl: 'http://127.0.0.1:48123',
|
||||
revokeAllResult: [allSession],
|
||||
revokeByExtensionIdResult: [pluginSession],
|
||||
revokeByOwnerSessionIdResult: [ownerSession],
|
||||
revokeByExtensionIdResult: [pluginSession],
|
||||
revokeAllResult: [allSession],
|
||||
})
|
||||
server.revokeSession.mockReturnValue(directSession)
|
||||
const { adapter, removedCookies } = createFakeCookieAdapter()
|
||||
mockState.createStaticAssetService.mockReturnValue(server)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
cookieAdapter: adapter,
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
await service.revokeSession('direct-asset-session')
|
||||
@@ -212,32 +212,32 @@ describe('createExtensionAssetService', () => {
|
||||
expect(adapter.removeCookie).toHaveBeenCalledTimes(4)
|
||||
expect(removedCookies).toEqual([
|
||||
{
|
||||
expiresAt: 123_456,
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
cookieAdapter: adapter,
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
await service.stop()
|
||||
@@ -263,11 +263,11 @@ describe('createExtensionAssetService', () => {
|
||||
expect(server.stop).toHaveBeenCalledOnce()
|
||||
expect(removedCookies).toEqual([
|
||||
{
|
||||
expiresAt: 123_456,
|
||||
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',
|
||||
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,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
+133
-133
@@ -5,98 +5,6 @@ import type { StaticAssetSession } from '../../../http-server/static-assets/type
|
||||
import { createStaticAssetService } from '../../../http-server/static-assets'
|
||||
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.
|
||||
*
|
||||
@@ -115,16 +23,62 @@ export interface ExtensionAssetSession {
|
||||
export interface ExtensionAssetSessionInput {
|
||||
/** Extension manifest id that owns the static asset root. */
|
||||
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. */
|
||||
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
|
||||
*/
|
||||
export interface ExtensionAssetSnapshotService {
|
||||
createAssetSession: (input: Omit<ExtensionAssetSessionInput, 'ttlMs'>) => Promise<ExtensionAssetSession>
|
||||
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
|
||||
*/
|
||||
export function createExtensionAssetService(options: {
|
||||
cookieAdapter: ExtensionAssetCookieAdapter
|
||||
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
||||
cookieAdapter: ExtensionAssetCookieAdapter
|
||||
}): ExtensionAssetService {
|
||||
const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId })
|
||||
let lastBaseUrl: string | undefined
|
||||
@@ -185,13 +195,24 @@ export function createExtensionAssetService(options: {
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'extension-assets',
|
||||
async start() {
|
||||
await server.start()
|
||||
},
|
||||
async stop() {
|
||||
await revokeSessions(server.revokeAll())
|
||||
await server.stop()
|
||||
},
|
||||
getBaseUrl() {
|
||||
return readBaseUrl()
|
||||
},
|
||||
async createAssetSession(input) {
|
||||
const session = server.createSession({
|
||||
extensionId: input.extensionId,
|
||||
version: input.version,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
pathPrefix: input.pathPrefix,
|
||||
ttlMs: input.ttlMs,
|
||||
version: input.version,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -201,9 +222,9 @@ export function createExtensionAssetService(options: {
|
||||
}
|
||||
|
||||
const mountedPath = buildMountedStaticAssetPath({
|
||||
assetPath: input.routeAssetPath,
|
||||
assetSessionId: session.assetSessionId,
|
||||
extensionId: input.extensionId,
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: input.routeAssetPath,
|
||||
})
|
||||
|
||||
if (!mountedPath) {
|
||||
@@ -214,10 +235,10 @@ export function createExtensionAssetService(options: {
|
||||
await options.cookieAdapter.setCookie(cookie)
|
||||
|
||||
return {
|
||||
url: new URL(mountedPath, baseUrl).toString(),
|
||||
assetSessionId: session.assetSessionId,
|
||||
cookie,
|
||||
expiresAt: session.expiresAt,
|
||||
url: new URL(mountedPath, baseUrl).toString(),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -225,19 +246,6 @@ export function createExtensionAssetService(options: {
|
||||
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) {
|
||||
const session = server.revokeSession(assetSessionId)
|
||||
if (!session) {
|
||||
@@ -246,22 +254,14 @@ export function createExtensionAssetService(options: {
|
||||
|
||||
await revokeSessions([session])
|
||||
},
|
||||
async start() {
|
||||
await server.start()
|
||||
async revokeByOwnerSessionId(ownerSessionId) {
|
||||
await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
|
||||
},
|
||||
async stop() {
|
||||
async revokeByExtensionId(extensionId) {
|
||||
await revokeSessions(server.revokeByExtensionId(extensionId))
|
||||
},
|
||||
async 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'
|
||||
|
||||
const extensionConfigSchema = object({
|
||||
autoReload: array(string()),
|
||||
enabled: array(string()),
|
||||
autoReload: array(string()),
|
||||
known: record(string(), object({
|
||||
path: string(),
|
||||
})),
|
||||
})
|
||||
|
||||
function createDefaultExtensionConfig(): ExtensionConfig {
|
||||
return {
|
||||
enabled: [],
|
||||
autoReload: [],
|
||||
known: {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists extension host enablement and discovery metadata.
|
||||
*
|
||||
@@ -27,8 +35,8 @@ const extensionConfigSchema = object({
|
||||
* - Accessors around the persisted extension config document
|
||||
*/
|
||||
export interface ExtensionHostConfigStore {
|
||||
get: () => ExtensionConfig
|
||||
setup: () => void
|
||||
get: () => ExtensionConfig
|
||||
update: (config: ExtensionConfig) => void
|
||||
}
|
||||
|
||||
@@ -46,27 +54,19 @@ export interface ExtensionHostConfigStore {
|
||||
*/
|
||||
export function createExtensionHostConfigStore(): ExtensionHostConfigStore {
|
||||
const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, {
|
||||
autoHeal: true,
|
||||
default: createDefaultExtensionConfig(),
|
||||
autoHeal: true,
|
||||
})
|
||||
|
||||
return {
|
||||
get() {
|
||||
return extensionConfig.get() ?? createDefaultExtensionConfig()
|
||||
},
|
||||
setup() {
|
||||
extensionConfig.setup()
|
||||
},
|
||||
get() {
|
||||
return extensionConfig.get() ?? createDefaultExtensionConfig()
|
||||
},
|
||||
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
|
||||
*/
|
||||
export function buildPluginHostDebugSnapshot(options: {
|
||||
config: ExtensionConfig
|
||||
entries: ManifestEntry[]
|
||||
extensionAssetService?: ExtensionAssetSnapshotService
|
||||
extensionsRoot: string
|
||||
host: ExtensionHost
|
||||
extensionsRoot: string
|
||||
entries: ManifestEntry[]
|
||||
config: ExtensionConfig
|
||||
loaded: Set<string>
|
||||
manifestEntryByExtensionId: Map<string, ManifestEntry>
|
||||
extensionAssetService?: ExtensionAssetSnapshotService
|
||||
}): Promise<PluginHostDebugSnapshot> {
|
||||
const extensionAssetService = options.extensionAssetService
|
||||
const modules = Promise.all(options.host
|
||||
@@ -44,18 +44,18 @@ export function buildPluginHostDebugSnapshot(options: {
|
||||
extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(),
|
||||
...(extensionAssetService
|
||||
? {
|
||||
createAssetSession: ({ extensionId, routeAssetPath, sessionId, sessionPathPrefix, version }: {
|
||||
createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: {
|
||||
extensionId: string
|
||||
routeAssetPath: string
|
||||
sessionId: string
|
||||
sessionPathPrefix: string
|
||||
version: string
|
||||
sessionId: string
|
||||
routeAssetPath: string
|
||||
sessionPathPrefix: string
|
||||
}) => extensionAssetService.createAssetSession({
|
||||
extensionId,
|
||||
ownerSessionId: sessionId,
|
||||
pathPrefix: sessionPathPrefix,
|
||||
routeAssetPath,
|
||||
version,
|
||||
ownerSessionId: sessionId,
|
||||
routeAssetPath,
|
||||
pathPrefix: sessionPathPrefix,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
@@ -64,22 +64,22 @@ export function buildPluginHostDebugSnapshot(options: {
|
||||
))
|
||||
|
||||
return modules.then(resolvedModules => ({
|
||||
capabilities: options.host.listCapabilities(),
|
||||
kits: options.host.listKits(),
|
||||
modules: resolvedModules,
|
||||
refreshedAt: Date.now(),
|
||||
registry: buildPluginRegistrySnapshot({
|
||||
config: options.config,
|
||||
entries: options.entries,
|
||||
extensionsRoot: options.extensionsRoot,
|
||||
entries: options.entries,
|
||||
config: options.config,
|
||||
loaded: options.loaded,
|
||||
}),
|
||||
sessions: options.host.listSessions().map(session => ({
|
||||
extensionId: session.manifest.id,
|
||||
id: session.id,
|
||||
moduleId: session.extension.id,
|
||||
extensionId: session.manifest.id,
|
||||
phase: session.phase,
|
||||
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
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -47,50 +67,8 @@ const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
||||
* - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose
|
||||
*/
|
||||
export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
|
||||
tools: TamagotchiToolRegistry
|
||||
|
||||
/**
|
||||
* Lists the current extension registry snapshot.
|
||||
@@ -108,19 +86,35 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
list: () => Promise<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* Loads one extension by manifest id.
|
||||
* Persists whether one plugin is enabled.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer explicitly requests one plugin to start
|
||||
* - Host features need to restart a plugin after manifest or entrypoint changes
|
||||
* - Renderer controls toggle plugin enablement
|
||||
* - Host state must remember a known manifest path for a plugin name
|
||||
*
|
||||
* 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:
|
||||
* - 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.
|
||||
@@ -138,38 +132,19 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
loadEnabled: () => Promise<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* Persists whether one loaded plugin should use auto-reload.
|
||||
* Loads one extension by manifest id.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer controls toggle plugin file watching during development
|
||||
* - Host features need to resync optional watcher state after config changes
|
||||
* - Renderer explicitly requests one plugin to start
|
||||
* - Host features need to restart a plugin after manifest or entrypoint changes
|
||||
*
|
||||
* Expects:
|
||||
* - `payload.extensionId` matches one extension entry in config or discovery state
|
||||
* - `extensionId` resolves to a manifest entry in the current registry
|
||||
*
|
||||
* Returns:
|
||||
* - The updated extension registry snapshot after persistence
|
||||
* - The extension registry snapshot after the load completes
|
||||
*/
|
||||
setAutoReload: (payload: { enabled: boolean, 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
|
||||
load: (extensionId: string) => Promise<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* Stops one loaded extension by manifest id.
|
||||
@@ -185,6 +160,51 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
* - The extension registry snapshot after unload bookkeeping completes
|
||||
*/
|
||||
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
|
||||
const extensionAssetService = createExtensionAssetService({
|
||||
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
||||
getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(),
|
||||
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
||||
})
|
||||
await extensionAssetService.start()
|
||||
|
||||
@@ -262,21 +282,21 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
const listSnapshot = (): PluginRegistrySnapshot => {
|
||||
return buildPluginRegistrySnapshot({
|
||||
config: getConfig(),
|
||||
entries: extensionRegistry.listEntries(),
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
})
|
||||
}
|
||||
|
||||
const createModuleAssetSession = async (input: {
|
||||
extensionId: string
|
||||
ownerSessionId: string
|
||||
pathPrefix: string
|
||||
routeAssetPath: 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 cachedSession = moduleAssetSessionCache.get(cacheKey)
|
||||
if (cachedSession) {
|
||||
@@ -285,38 +305,38 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
const session = await extensionAssetService.createAssetSession({
|
||||
extensionId,
|
||||
ownerSessionId,
|
||||
pathPrefix,
|
||||
routeAssetPath,
|
||||
ttlMs: extensionAssetSessionTtlMs,
|
||||
version,
|
||||
ownerSessionId,
|
||||
routeAssetPath,
|
||||
pathPrefix,
|
||||
ttlMs: extensionAssetSessionTtlMs,
|
||||
})
|
||||
moduleAssetSessionCache.set(cacheKey, session)
|
||||
return session
|
||||
}
|
||||
|
||||
const extensionAssetSnapshotService: ExtensionAssetSnapshotService = {
|
||||
createAssetSession: ({ extensionId, ownerSessionId, pathPrefix, routeAssetPath, version }) => {
|
||||
getBaseUrl: extensionAssetService.getBaseUrl,
|
||||
createAssetSession: ({ extensionId, version, ownerSessionId, routeAssetPath, pathPrefix }) => {
|
||||
return createModuleAssetSession({
|
||||
extensionId,
|
||||
ownerSessionId,
|
||||
pathPrefix,
|
||||
routeAssetPath,
|
||||
version,
|
||||
ownerSessionId,
|
||||
routeAssetPath,
|
||||
pathPrefix,
|
||||
})
|
||||
},
|
||||
getBaseUrl: extensionAssetService.getBaseUrl,
|
||||
}
|
||||
|
||||
const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => {
|
||||
return await buildPluginHostDebugSnapshot({
|
||||
config: getConfig(),
|
||||
entries: extensionRegistry.listEntries(),
|
||||
extensionAssetService: extensionAssetSnapshotService,
|
||||
extensionsRoot,
|
||||
host,
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(),
|
||||
extensionAssetService: extensionAssetSnapshotService,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,16 +389,16 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
// Extension feature: Auto-reload for plugins
|
||||
const autoReloadFeature = createExtensionAutoReloadFeature({
|
||||
getConfig,
|
||||
isLoaded: extensionId => loaded.has(extensionId),
|
||||
listEntries: () => extensionRegistry.listEntries(),
|
||||
log,
|
||||
getConfig,
|
||||
listEntries: () => extensionRegistry.listEntries(),
|
||||
isLoaded: extensionId => loaded.has(extensionId),
|
||||
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
||||
reload: async (extensionId) => {
|
||||
await stopLoadedExtensionById(extensionId)
|
||||
await refreshManifests()
|
||||
await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||
},
|
||||
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
||||
})
|
||||
|
||||
const unloadExtensionById = async (extensionId: string) => {
|
||||
@@ -413,61 +433,17 @@ export async function setupExtensionHostServiceInternal(
|
||||
autoReloadFeature.sync()
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
autoReloadFeature.dispose()
|
||||
builtInKitRuntime.dispose()
|
||||
|
||||
moduleAssetSessionCache.clear()
|
||||
await extensionAssetService.revokeAll()
|
||||
await extensionAssetService.stop()
|
||||
},
|
||||
getAssetBaseUrl() {
|
||||
return extensionAssetService.getBaseUrl() ?? ''
|
||||
},
|
||||
host,
|
||||
async inspect() {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
return await inspectSnapshot()
|
||||
},
|
||||
// REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though
|
||||
// the host service also exposes it for IPC listing/invocation. Consider moving registry ownership
|
||||
// to this host service and passing it into kit registration as a dependency.
|
||||
tools: builtInKitRuntime.tools,
|
||||
manifests: extensionRegistry.listManifests(),
|
||||
async list() {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
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) {
|
||||
await refreshManifests()
|
||||
|
||||
@@ -485,8 +461,8 @@ export async function setupExtensionHostServiceInternal(
|
||||
const entry = extensionRegistry.findManifestEntry(payload.extensionId)
|
||||
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||
extensionConfig.update({
|
||||
autoReload: config.autoReload,
|
||||
enabled: [...enabled],
|
||||
autoReload: config.autoReload,
|
||||
known: {
|
||||
...config.known,
|
||||
[payload.extensionId]: { path: manifestPath },
|
||||
@@ -496,34 +472,58 @@ export async function setupExtensionHostServiceInternal(
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
// REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though
|
||||
// the host service also exposes it for IPC listing/invocation. Consider moving registry ownership
|
||||
// to this host service and passing it into kit registration as a dependency.
|
||||
tools: builtInKitRuntime.tools,
|
||||
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 loadEnabled() {
|
||||
await refreshManifests()
|
||||
await loadEnabledExtensions()
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
async load(extensionId) {
|
||||
await refreshManifests()
|
||||
await loadExtensionById(extensionId)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
async unload(extensionId) {
|
||||
await unloadExtensionById(extensionId)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
}
|
||||
}
|
||||
async inspect() {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
return await inspectSnapshot()
|
||||
},
|
||||
getAssetBaseUrl() {
|
||||
return extensionAssetService.getBaseUrl() ?? ''
|
||||
},
|
||||
async dispose() {
|
||||
autoReloadFeature.dispose()
|
||||
builtInKitRuntime.dispose()
|
||||
|
||||
function createElectronExtensionAssetCookieAdapter() {
|
||||
return {
|
||||
async removeCookie(cookie: ExtensionAssetCookie) {
|
||||
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,
|
||||
})
|
||||
moduleAssetSessionCache.clear()
|
||||
await extensionAssetService.revokeAll()
|
||||
await extensionAssetService.stop()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,166 +17,30 @@ import { safeParse } from 'valibot'
|
||||
|
||||
export const extensionManifestFileName = 'extension.airi.json'
|
||||
|
||||
/**
|
||||
* Tracks the manifest registry state used by the Electron extension host.
|
||||
*
|
||||
* 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[]>
|
||||
function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 {
|
||||
return safeParse(extensionManifestV1Schema, value).success
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
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,
|
||||
}
|
||||
export function manifestIdOf(manifest: ExtensionManifestV1) {
|
||||
return manifest.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the manifest registry store used by the extension host bootstrap.
|
||||
*
|
||||
* 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
|
||||
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
|
||||
if (!entry.isSymbolicLink()) {
|
||||
return { resolved: false }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
try {
|
||||
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
|
||||
const stats = await stat(resolvedPath)
|
||||
if (stats.isFile() || stats.isDirectory()) {
|
||||
return { resolved: true, path: resolvedPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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,
|
||||
return { resolved: false }
|
||||
}
|
||||
catch (error) {
|
||||
return { resolved: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +71,7 @@ export async function loadManifestsFrom(
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
const { error, resolved } = await realPathOf(entry, { cwd: dir })
|
||||
const { resolved, error } = await realPathOf(entry, { cwd: dir })
|
||||
if (error) {
|
||||
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping')
|
||||
continue
|
||||
@@ -299,8 +163,60 @@ export async function loadManifestsFrom(
|
||||
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)}`
|
||||
}
|
||||
|
||||
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()) {
|
||||
return { resolved: false }
|
||||
}
|
||||
/**
|
||||
* Tracks the manifest registry state used by the Electron extension host.
|
||||
*
|
||||
* 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))
|
||||
const stats = await stat(resolvedPath)
|
||||
if (stats.isFile() || stats.isDirectory()) {
|
||||
return { path: resolvedPath, resolved: true }
|
||||
}
|
||||
/**
|
||||
* Creates the manifest registry store used by the extension host bootstrap.
|
||||
*
|
||||
* 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 { resolved: false }
|
||||
}
|
||||
catch (error) {
|
||||
return { error, resolved: false }
|
||||
return {
|
||||
getRoot() {
|
||||
return options.extensionsRoot
|
||||
},
|
||||
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) => {
|
||||
const result = await hostService.setEnabled(payload)
|
||||
context.emit(electronPluginToolsChanged, {
|
||||
extensionId: payload.extensionId,
|
||||
reason: 'enabled-state-changed',
|
||||
extensionId: payload.extensionId,
|
||||
})
|
||||
return result
|
||||
})
|
||||
@@ -77,8 +77,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
||||
defineInvokeHandler(context, electronPluginLoad, async (payload) => {
|
||||
const result = await hostService.load(payload.extensionId)
|
||||
context.emit(electronPluginToolsChanged, {
|
||||
extensionId: payload.extensionId,
|
||||
reason: 'loaded',
|
||||
extensionId: payload.extensionId,
|
||||
})
|
||||
return result
|
||||
})
|
||||
@@ -86,8 +86,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
||||
defineInvokeHandler(context, electronPluginUnload, async (payload) => {
|
||||
const result = await hostService.unload(payload.extensionId)
|
||||
context.emit(electronPluginToolsChanged, {
|
||||
extensionId: payload.extensionId,
|
||||
reason: 'unloaded',
|
||||
extensionId: payload.extensionId,
|
||||
})
|
||||
return result
|
||||
})
|
||||
@@ -123,10 +123,10 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
||||
switch (payload.state) {
|
||||
case 'announced':
|
||||
return hostService.host.announceCapability(payload.key, payload.metadata)
|
||||
case 'degraded':
|
||||
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
|
||||
case 'ready':
|
||||
return hostService.host.markCapabilityReady(payload.key, payload.metadata)
|
||||
case 'degraded':
|
||||
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
|
||||
case 'withdrawn':
|
||||
return hostService.host.withdrawCapability(payload.key, payload.metadata)
|
||||
default: {
|
||||
|
||||
@@ -14,12 +14,12 @@ import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-
|
||||
* - The gamelet kit descriptor used for `kit.gamelet`
|
||||
*/
|
||||
export const gameletPluginKitDescriptor = {
|
||||
capabilities: [
|
||||
{ actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' },
|
||||
],
|
||||
kitId: 'kit.gamelet',
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.0',
|
||||
runtimes: ['electron', 'web'],
|
||||
capabilities: [
|
||||
{ key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] },
|
||||
],
|
||||
} satisfies KitDescriptor
|
||||
|
||||
/**
|
||||
|
||||
+16
-16
@@ -27,46 +27,39 @@ export function createGameletOrchestrationRuntime(
|
||||
widgetsManager: ExtensionHostGameletWidgetsManager,
|
||||
): GameletOrchestrationRuntime {
|
||||
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) {
|
||||
const componentProps = createComponentProps(bindingId, payload ?? {})
|
||||
|
||||
if (widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||
await widgetsManager.updateWidget({
|
||||
componentProps,
|
||||
id: bindingId,
|
||||
componentProps,
|
||||
size: 'l',
|
||||
})
|
||||
}
|
||||
else {
|
||||
await widgetsManager.pushWidget({
|
||||
id: bindingId,
|
||||
componentName: 'extension-ui',
|
||||
componentProps,
|
||||
id: bindingId,
|
||||
size: 'l',
|
||||
})
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||
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,
|
||||
payload,
|
||||
{
|
||||
@@ -74,6 +67,13 @@ export function createGameletOrchestrationRuntime(
|
||||
},
|
||||
) 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 ToolKitClient = ReturnType<typeof toolKit.createClient>
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef<GameletKitClient> {
|
||||
return {
|
||||
...gameletKit,
|
||||
createClient(runtime) {
|
||||
@@ -92,18 +56,18 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
|
||||
register: (input) => {
|
||||
ensureCleanup()
|
||||
options.tools.register({
|
||||
ownerSessionId: runtime.sessionId,
|
||||
ownerExtensionId: runtime.extensionId,
|
||||
ownerModuleId: runtime.moduleId,
|
||||
ownerSessionId: runtime.sessionId,
|
||||
...input,
|
||||
})
|
||||
},
|
||||
registerToolsetPrompt: (input) => {
|
||||
ensureCleanup()
|
||||
options.tools.registerToolsetPrompt({
|
||||
ownerSessionId: runtime.sessionId,
|
||||
ownerExtensionId: runtime.extensionId,
|
||||
ownerModuleId: runtime.moduleId,
|
||||
ownerSessionId: runtime.sessionId,
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -38,7 +51,7 @@ export interface WidgetAssetRoute {
|
||||
* Returns:
|
||||
* - 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)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
@@ -84,16 +97,16 @@ export function rewriteWidgetModuleAssetUrl(
|
||||
module: PluginHostModuleSummary,
|
||||
manifestEntryByExtensionId: Map<string, ManifestEntry>,
|
||||
options?: {
|
||||
extensionAssetBaseUrl?: string
|
||||
createAssetSession?: (input: {
|
||||
extensionId: string
|
||||
routeAssetPath: string
|
||||
sessionId: string
|
||||
sessionPathPrefix: string
|
||||
version: string
|
||||
sessionId: string
|
||||
routeAssetPath: string
|
||||
sessionPathPrefix: string
|
||||
}) => Promise<{ assetSessionId: string, url?: string }>
|
||||
extensionAssetBaseUrl?: string
|
||||
},
|
||||
): PluginHostModuleSummary | Promise<PluginHostModuleSummary> {
|
||||
): Promise<PluginHostModuleSummary> | PluginHostModuleSummary {
|
||||
const entry = manifestEntryByExtensionId.get(module.ownerExtensionId)
|
||||
if (!entry) {
|
||||
return module
|
||||
@@ -131,15 +144,15 @@ export function rewriteWidgetModuleAssetUrl(
|
||||
|
||||
return options.createAssetSession({
|
||||
extensionId: module.ownerExtensionId,
|
||||
routeAssetPath: widgetAssetRoute.routeAssetPath,
|
||||
sessionId: module.ownerSessionId,
|
||||
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
|
||||
version: entry.version,
|
||||
sessionId: module.ownerSessionId,
|
||||
routeAssetPath: widgetAssetRoute.routeAssetPath,
|
||||
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
|
||||
}).then((session) => {
|
||||
const mountedPath = buildMountedStaticAssetPath({
|
||||
assetPath: widgetAssetRoute.routeAssetPath,
|
||||
assetSessionId: session.assetSessionId,
|
||||
extensionId: module.ownerExtensionId,
|
||||
assetSessionId: session.assetSessionId,
|
||||
assetPath: widgetAssetRoute.routeAssetPath,
|
||||
})
|
||||
const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '')
|
||||
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`
|
||||
*/
|
||||
export const widgetPluginKitDescriptor = {
|
||||
capabilities: [
|
||||
{ actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' },
|
||||
],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.0',
|
||||
runtimes: ['electron', 'web'],
|
||||
capabilities: [
|
||||
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
|
||||
],
|
||||
} satisfies KitDescriptor
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,89 +7,9 @@ import type {
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Stable manifest id used as the runtime identity for one extension.
|
||||
*/
|
||||
export interface ExtensionConfig {
|
||||
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>
|
||||
}
|
||||
export type ExtensionId = string
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -137,20 +154,3 @@ export interface ManifestEntry {
|
||||
rootDir: 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'
|
||||
|
||||
interface ArtistrySyncSnapshot {
|
||||
globals?: Record<string, any>
|
||||
model?: string
|
||||
options?: Record<string, any>
|
||||
promptPrefix?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
promptPrefix?: string
|
||||
options?: Record<string, any>
|
||||
globals?: Record<string, any>
|
||||
}
|
||||
|
||||
interface TriggerConfig {
|
||||
globals?: Record<string, any>
|
||||
model?: string
|
||||
options?: Record<string, any>
|
||||
promptPrefix?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
promptPrefix?: string
|
||||
options?: Record<string, any>
|
||||
globals?: Record<string, any>
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
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) {
|
||||
@@ -105,15 +105,15 @@ artistryProviders.set('replicate', new ReplicateProvider())
|
||||
artistryProviders.set('nanobanana', new NanoBananaProvider())
|
||||
|
||||
// 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: {
|
||||
globals?: Record<string, any>
|
||||
model?: string
|
||||
options?: Record<string, any>
|
||||
prompt: string
|
||||
model?: 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
|
||||
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>
|
||||
@@ -130,12 +130,12 @@ export async function generateHeadless(params: {
|
||||
const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex')
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
gh: globalsHash, // Include globals hash (Issue #39)
|
||||
ih: imageHash,
|
||||
m: params.model,
|
||||
o: params.options,
|
||||
p: params.prompt,
|
||||
m: params.model,
|
||||
pr: params.provider,
|
||||
o: params.options,
|
||||
ih: imageHash,
|
||||
gh: globalsHash, // Include globals hash (Issue #39)
|
||||
})
|
||||
|
||||
if (pendingHeadlessRequests.has(fingerprint)) {
|
||||
@@ -167,16 +167,16 @@ export async function generateHeadless(params: {
|
||||
log.log(`[Headless] Source image length: ${activeGlobals.image.length}`)
|
||||
|
||||
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: {
|
||||
...params.options,
|
||||
image: activeGlobals?.image,
|
||||
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'}`)
|
||||
@@ -215,12 +215,12 @@ export async function generateHeadless(params: {
|
||||
|
||||
log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`)
|
||||
const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined
|
||||
return { base64, imageUrl: lastStatus.imageUrl }
|
||||
return { imageUrl: lastStatus.imageUrl, base64 }
|
||||
}
|
||||
else {
|
||||
// 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}`)
|
||||
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 timer = setTimeout(() => {
|
||||
reject(new Error('Image generation timed out after 5 minutes.'))
|
||||
@@ -231,7 +231,7 @@ export async function generateHeadless(params: {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined
|
||||
resolve({ base64, imageUrl: status.imageUrl })
|
||||
resolve({ imageUrl: status.imageUrl, base64 })
|
||||
}
|
||||
catch (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: {
|
||||
id: string
|
||||
componentName?: string
|
||||
componentProps?: unknown
|
||||
id: string
|
||||
widgetsManager: WidgetsWindowManager
|
||||
}) {
|
||||
if (params.componentName !== 'comfy' && params.componentName !== 'artistry')
|
||||
@@ -383,16 +281,16 @@ async function handleArtistryTrigger(params: {
|
||||
// 1. Explicitly provided in component props (_artistryConfig)
|
||||
// 2. Character-level defaults synced from renderer (cardDefaults)
|
||||
const config: TriggerConfig = {
|
||||
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
|
||||
// Older widget payloads can still send `Globals`, and dropping it now would break them.
|
||||
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
|
||||
provider: artistryConfigOverrides.provider as string | undefined,
|
||||
model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model,
|
||||
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
|
||||
options: {
|
||||
...cardDefaults.options,
|
||||
...robustParse(artistryConfigOverrides.options, 'artistryOptions'),
|
||||
},
|
||||
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
|
||||
provider: artistryConfigOverrides.provider as string | undefined,
|
||||
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
|
||||
// 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 providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER
|
||||
@@ -430,8 +328,8 @@ async function handleArtistryTrigger(params: {
|
||||
if (!provider) {
|
||||
log.error(`🔴 Provider '${providerId}' not found.`)
|
||||
params.widgetsManager.updateWidget({
|
||||
componentProps: { actionLabel: `Provider '${providerId}' not available`, status: 'error' },
|
||||
id: params.id,
|
||||
componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -446,6 +344,8 @@ async function handleArtistryTrigger(params: {
|
||||
try {
|
||||
// Build the abstract request
|
||||
const request: ArtistryRequest = {
|
||||
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
|
||||
model: config.model,
|
||||
extra: {
|
||||
...options,
|
||||
...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.
|
||||
remixId,
|
||||
},
|
||||
model: config.model,
|
||||
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
|
||||
}
|
||||
|
||||
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.
|
||||
const existing = params.widgetsManager.getWidgetSnapshot(params.id)
|
||||
params.widgetsManager.updateWidget({
|
||||
id: params.id,
|
||||
componentProps: {
|
||||
...(existing?.componentProps as any),
|
||||
...statusUpdate,
|
||||
},
|
||||
id: params.id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -481,7 +379,7 @@ async function handleArtistryTrigger(params: {
|
||||
updateIfActive(statusUpdate as Record<string, any>)
|
||||
if (statusUpdate.status === 'succeeded') {
|
||||
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') {
|
||||
log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`)
|
||||
@@ -502,7 +400,7 @@ async function handleArtistryTrigger(params: {
|
||||
// Check for timeout
|
||||
if (Date.now() - startTime > timeoutLength) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -529,7 +427,7 @@ async function handleArtistryTrigger(params: {
|
||||
const finalStatus = await provider.getStatus(job.jobId)
|
||||
if (finalStatus.status === 'succeeded') {
|
||||
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 {
|
||||
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) {
|
||||
lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44)
|
||||
params.widgetsManager.updateWidget({
|
||||
componentProps: { actionLabel: message, status: 'error' },
|
||||
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 { createWidgetsService } from './index'
|
||||
|
||||
function createWindow(id: number): BrowserWindow {
|
||||
return {
|
||||
webContents: {
|
||||
id,
|
||||
},
|
||||
} as BrowserWindow
|
||||
}
|
||||
|
||||
function createWidgetsManager() {
|
||||
return {
|
||||
clearWidgets: vi.fn(),
|
||||
fetchWidget: vi.fn(),
|
||||
getWidgetSnapshot: vi.fn(),
|
||||
getWindow: vi.fn(),
|
||||
getWidgetSnapshot: vi.fn(),
|
||||
hideWindow: vi.fn(),
|
||||
onWidgetEvent: vi.fn(),
|
||||
openWindow: vi.fn(),
|
||||
@@ -25,14 +33,6 @@ function createWidgetsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(id: number): BrowserWindow {
|
||||
return {
|
||||
webContents: {
|
||||
id,
|
||||
},
|
||||
} as BrowserWindow
|
||||
}
|
||||
|
||||
describe('createWidgetsService', () => {
|
||||
it('routes iframe request results from the widgets window to the manager', () => {
|
||||
const context = createContext()
|
||||
@@ -46,8 +46,8 @@ describe('createWidgetsService', () => {
|
||||
|
||||
context.emit(widgetsIframeRequestResultEvent, {
|
||||
id: 'kit-module:board',
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
}, {
|
||||
raw: {
|
||||
@@ -59,8 +59,8 @@ describe('createWidgetsService', () => {
|
||||
|
||||
expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({
|
||||
id: 'kit-module:board',
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})
|
||||
})
|
||||
@@ -77,8 +77,8 @@ describe('createWidgetsService', () => {
|
||||
|
||||
context.emit(widgetsIframeRequestResultEvent, {
|
||||
id: 'kit-module:board',
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
}, {
|
||||
raw: {
|
||||
|
||||
@@ -30,6 +30,13 @@ interface InvokeOptions {
|
||||
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.
|
||||
*
|
||||
@@ -64,22 +71,51 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
||||
defineInvokeHandlers(
|
||||
params.context,
|
||||
{
|
||||
widgetsPrepareWindow,
|
||||
widgetsOpenWindow,
|
||||
widgetsHideWindow,
|
||||
widgetsAdd,
|
||||
widgetsUpdate,
|
||||
widgetsRemove,
|
||||
widgetsClear,
|
||||
widgetsFetch,
|
||||
widgetsHideWindow,
|
||||
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) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
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) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
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.'),
|
||||
)
|
||||
},
|
||||
widgetsHideWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.hideWindow(payload ?? undefined)
|
||||
},
|
||||
widgetsIframePublish: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe 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.
|
||||
*/
|
||||
|
||||
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 {
|
||||
/** Internal job ID for tracking */
|
||||
jobId: string
|
||||
@@ -13,47 +28,34 @@ export interface ArtistryJob {
|
||||
providerJobId: string
|
||||
}
|
||||
|
||||
export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
|
||||
export interface ArtistryJobStatus {
|
||||
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */
|
||||
actionLabel?: string
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
/** Final output image URL */
|
||||
imageUrl?: string
|
||||
status: ArtistryJobStatusType
|
||||
/** Generation progress 0-100 (not all providers support this) */
|
||||
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'
|
||||
|
||||
/**
|
||||
* Per-card artistry settings stored in AiriExtension.modules.artistry
|
||||
*/
|
||||
export interface ArtistryModuleSettings {
|
||||
/** String prepended to every LLM-generated prompt for style consistency */
|
||||
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 ArtistryProviderConfig {
|
||||
/** Unique provider ID (e.g. "comfyui", "replicate") */
|
||||
id: string
|
||||
/** Human-readable display name */
|
||||
name: string
|
||||
/** Provider-specific configuration (API keys, paths, etc.) */
|
||||
settings: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ArtistryProvider {
|
||||
/**
|
||||
* Cancel a running job (optional — not all providers support this).
|
||||
*/
|
||||
cancel?: (jobId: string) => Promise<void>
|
||||
/**
|
||||
* Clean up resources when the provider is being switched out.
|
||||
*/
|
||||
dispose?: () => void
|
||||
/** Unique provider ID */
|
||||
readonly id: string
|
||||
/** Human-readable display name */
|
||||
readonly name: string
|
||||
|
||||
/**
|
||||
* Start an image generation job.
|
||||
@@ -67,43 +69,41 @@ export interface ArtistryProvider {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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") */
|
||||
id: string
|
||||
/** Human-readable display name */
|
||||
name: string
|
||||
/** Provider-specific configuration (API keys, paths, etc.) */
|
||||
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
|
||||
/**
|
||||
* Per-card artistry settings stored in AiriExtension.modules.artistry
|
||||
*/
|
||||
export interface ArtistryModuleSettings {
|
||||
/** Active provider ID (e.g. "comfyui", "replicate") */
|
||||
provider?: string
|
||||
/** Provider-specific model identifier */
|
||||
model?: string
|
||||
/** Negative prompt — things to avoid (provider support varies) */
|
||||
negativePrompt?: string
|
||||
/** The text prompt describing the desired image */
|
||||
prompt: string
|
||||
/** Image width in pixels */
|
||||
width?: number
|
||||
/** String prepended to every LLM-generated prompt for style consistency */
|
||||
defaultPromptPrefix?: 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>
|
||||
}
|
||||
|
||||
@@ -13,37 +13,43 @@ export class ComfyUIProvider implements ArtistryProvider {
|
||||
readonly id = 'comfyui'
|
||||
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 savedWorkflows: any[] = []
|
||||
private activeWorkflowId = ''
|
||||
|
||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
|
||||
// Resolve which workflow template to use --- per-request template override takes precedence over card model default
|
||||
const templateId = request.extra?.template || request.model || this.activeWorkflowId
|
||||
const template = this.savedWorkflows.find((w: any) => w.id === templateId)
|
||||
|
||||
if (!template) {
|
||||
this.updateStatus(jobId, {
|
||||
actionLabel: 'Error: No workflow configured',
|
||||
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
|
||||
status: 'failed',
|
||||
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
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> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||
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> {
|
||||
@@ -55,12 +61,195 @@ export class ComfyUIProvider implements ArtistryProvider {
|
||||
this.activeWorkflowId = config.comfyuiActiveWorkflow
|
||||
}
|
||||
|
||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||
this.callbacks.set(jobId, callback)
|
||||
// If we already have a result, fire it immediately
|
||||
const result = this.jobResults.get(jobId)
|
||||
if (result)
|
||||
callback(result)
|
||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
||||
|
||||
// Resolve which workflow template to use --- per-request template override takes precedence over card model default
|
||||
const templateId = request.extra?.template || request.model || this.activeWorkflowId
|
||||
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.
|
||||
*/
|
||||
private applyOverrides(
|
||||
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> },
|
||||
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
|
||||
request: ArtistryRequest,
|
||||
): Record<string, any> {
|
||||
// Deep clone the workflow so we don't mutate the stored template
|
||||
@@ -149,190 +338,36 @@ export class ComfyUIProvider implements ArtistryProvider {
|
||||
return prompt
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
|
||||
const controller = new AbortController()
|
||||
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
|
||||
}
|
||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
}
|
||||
|
||||
private async pollForResult(
|
||||
jobId: string,
|
||||
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> },
|
||||
request: ArtistryRequest,
|
||||
) {
|
||||
this.updateStatus(jobId, { actionLabel: 'Preparing workflow...', status: 'running' })
|
||||
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')
|
||||
|
||||
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}}')
|
||||
// 2. Prepare multipart form data
|
||||
const formData = new FormData()
|
||||
const fileName = `vhack_${Date.now()}.png`
|
||||
|
||||
let uploadedImageName = ''
|
||||
if (hasImagePlaceholder && request.extra?.image) {
|
||||
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`)
|
||||
this.updateStatus(jobId, { actionLabel: 'Uploading texture to ComfyUI...', status: 'running' })
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
// 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')
|
||||
|
||||
// 1. Apply overrides to the workflow template (standard injection)
|
||||
let resolvedPrompt = this.applyOverrides(template, request)
|
||||
const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}, 60000) // 1 minute timeout for uploads
|
||||
|
||||
// 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, { 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)
|
||||
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
|
||||
}
|
||||
|
||||
private replacePlaceholders(obj: any, replacements: Record<string, string>): any {
|
||||
@@ -356,39 +391,4 @@ export class ComfyUIProvider implements ArtistryProvider {
|
||||
}
|
||||
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 name = 'Nano Banana (Google AI Studio)'
|
||||
private apiKey = ''
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
private defaultModel = 'gemini-1.5-flash'
|
||||
|
||||
private defaultResolution = '1K'
|
||||
|
||||
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> {
|
||||
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) {
|
||||
this.updateStatus(jobId, { actionLabel: 'Inscribing with Nano Banana...', status: 'running' })
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' })
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}`
|
||||
const generationParts: any[] = [{ text: prompt }]
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: generationParts }],
|
||||
generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } },
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const json = await response.json()
|
||||
@@ -87,7 +90,7 @@ export class NanoBananaProvider implements ArtistryProvider {
|
||||
|
||||
if (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 {
|
||||
throw new Error('No image data returned from Nano Banana')
|
||||
@@ -95,7 +98,7 @@ export class NanoBananaProvider implements ArtistryProvider {
|
||||
}
|
||||
catch (e: any) {
|
||||
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 {
|
||||
// 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) {
|
||||
this.jobResults.set(jobId, status)
|
||||
const callback = this.callbacks.get(jobId)
|
||||
if (callback)
|
||||
callback(status)
|
||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,44 @@ export class ReplicateProvider implements ArtistryProvider {
|
||||
readonly name = 'Replicate.ai (Cloud)'
|
||||
|
||||
private apiKey = ''
|
||||
private aspectRatio = '16:9'
|
||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||
private defaultModel = 'black-forest-labs/flux-schnell'
|
||||
private aspectRatio = '16:9'
|
||||
private inferenceSteps = 4
|
||||
private replicate: Replicate | null = null
|
||||
|
||||
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> {
|
||||
if (!this.replicate) {
|
||||
@@ -30,11 +61,11 @@ export class ReplicateProvider implements ArtistryProvider {
|
||||
// 1. Start with defaults
|
||||
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
|
||||
let inputOptions: Record<string, any> = {
|
||||
aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
|
||||
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_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
|
||||
@@ -96,36 +127,8 @@ export class ReplicateProvider implements ArtistryProvider {
|
||||
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) {
|
||||
this.updateStatus(jobId, { actionLabel: 'Requesting cloud generation...', status: 'running' })
|
||||
this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' })
|
||||
|
||||
try {
|
||||
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:'))) {
|
||||
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 {
|
||||
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))
|
||||
log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`)
|
||||
this.updateStatus(jobId, {
|
||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||
error: errorMessage,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||
})
|
||||
}
|
||||
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 {
|
||||
if (prompt.length <= maxChars)
|
||||
return prompt
|
||||
log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`)
|
||||
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', () => {
|
||||
it('normalizes add payloads for the widgets manager', () => {
|
||||
expect(validateWidgetsAddPayload({
|
||||
alwaysOnTop: true,
|
||||
id: ' widget-1 ',
|
||||
componentName: ' weather ',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
id: ' widget-1 ',
|
||||
alwaysOnTop: true,
|
||||
ttlMs: 2500.9,
|
||||
windowSize: {
|
||||
width: 620.8,
|
||||
height: 480.2,
|
||||
minWidth: 320.9,
|
||||
width: 620.8,
|
||||
},
|
||||
})).toEqual({
|
||||
alwaysOnTop: true,
|
||||
id: 'widget-1',
|
||||
componentName: 'weather',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
id: 'widget-1',
|
||||
alwaysOnTop: true,
|
||||
ttlMs: 2500,
|
||||
windowSize: {
|
||||
width: 620,
|
||||
height: 480,
|
||||
minWidth: 320,
|
||||
width: 620,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -53,12 +53,12 @@ describe('widget invoke validation', () => {
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: 'weather',
|
||||
windowSize: { height: 320, width: 0 },
|
||||
windowSize: { width: 0, height: 320 },
|
||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
alwaysOnTop: 'yes' as any,
|
||||
componentName: 'weather',
|
||||
alwaysOnTop: 'yes' as any,
|
||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||
})
|
||||
})
|
||||
@@ -66,14 +66,14 @@ describe('widget invoke validation', () => {
|
||||
describe('validateWidgetsUpdatePayload', () => {
|
||||
it('normalizes widget updates and keeps optional fields optional', () => {
|
||||
expect(validateWidgetsUpdatePayload({
|
||||
alwaysOnTop: false,
|
||||
componentProps: { city: 'Taipei' },
|
||||
id: ' widget-1 ',
|
||||
componentProps: { city: 'Taipei' },
|
||||
alwaysOnTop: false,
|
||||
ttlMs: 1500.4,
|
||||
})).toEqual({
|
||||
alwaysOnTop: false,
|
||||
componentProps: { city: 'Taipei' },
|
||||
id: 'widget-1',
|
||||
componentProps: { city: 'Taipei' },
|
||||
alwaysOnTop: false,
|
||||
ttlMs: 1500,
|
||||
windowSize: undefined,
|
||||
})
|
||||
@@ -85,18 +85,18 @@ describe('widget invoke validation', () => {
|
||||
} as any)).toThrow('id is required to update a widget.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
componentProps: [] as any,
|
||||
id: 'widget-1',
|
||||
componentProps: [] as any,
|
||||
})).toThrow('componentProps must be a plain object.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
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.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
alwaysOnTop: 'yes' as any,
|
||||
id: 'widget-1',
|
||||
alwaysOnTop: 'yes' as any,
|
||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||
})
|
||||
})
|
||||
@@ -117,28 +117,28 @@ describe('widget invoke validation', () => {
|
||||
it('normalizes successful iframe request results', () => {
|
||||
expect(validateWidgetIframeRequestResult({
|
||||
id: ' kit-module:board ',
|
||||
ok: true,
|
||||
requestId: ' req-1 ',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})).toEqual({
|
||||
id: 'kit-module:board',
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
result: { fen: 'fen-after-request' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes failed iframe request results', () => {
|
||||
expect(validateWidgetIframeRequestResult({
|
||||
error: 'Board rejected request.',
|
||||
id: 'kit-module:board',
|
||||
ok: false,
|
||||
requestId: 'req-1',
|
||||
ok: false,
|
||||
error: 'Board rejected request.',
|
||||
})).toEqual({
|
||||
error: 'Board rejected request.',
|
||||
id: 'kit-module:board',
|
||||
ok: false,
|
||||
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({
|
||||
id: 'kit-module:board',
|
||||
ok: true,
|
||||
requestId: 'req-1',
|
||||
ok: true,
|
||||
})).toThrow('iframe request result payload must be a plain object.')
|
||||
expect(() => validateWidgetIframeRequestResult({
|
||||
id: 'kit-module:board',
|
||||
ok: false,
|
||||
requestId: 'req-1',
|
||||
ok: false,
|
||||
})).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'
|
||||
|
||||
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:
|
||||
* - `" widget-1 "`
|
||||
* - `""`
|
||||
* Use when:
|
||||
* - `defineInvokeHandler(...)` receives a widgets add request from a renderer
|
||||
*
|
||||
* After:
|
||||
* - `"widget-1"`
|
||||
* - `undefined`
|
||||
* 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 normalizeOptionalWidgetId(id?: string): string | undefined {
|
||||
return normalizeWidgetId(id)
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -99,8 +221,8 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
|
||||
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
requestId,
|
||||
ok: true,
|
||||
result: result.result,
|
||||
}
|
||||
}
|
||||
@@ -111,134 +233,12 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
|
||||
}
|
||||
|
||||
return {
|
||||
error: result.error,
|
||||
id,
|
||||
ok: false,
|
||||
requestId,
|
||||
ok: false,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
|
||||
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(),
|
||||
isPackaged: false,
|
||||
}))
|
||||
|
||||
const isDevState = vi.hoisted(() => ({
|
||||
@@ -21,16 +21,16 @@ const updaterState = vi.hoisted(() => ({
|
||||
|
||||
function createUpdaterMock() {
|
||||
return {
|
||||
allowPrerelease: false,
|
||||
on: vi.fn(),
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
channel: undefined as string | undefined,
|
||||
logger: undefined as any,
|
||||
forceDevUpdateConfig: false,
|
||||
setFeedURL: vi.fn(),
|
||||
checkForUpdates: vi.fn().mockResolvedValue(undefined),
|
||||
downloadUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
forceDevUpdateConfig: false,
|
||||
logger: undefined as any,
|
||||
on: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ vi.mock('std-env', () => ({
|
||||
vi.mock('@guiiai/logg', () => ({
|
||||
useLogg: () => ({
|
||||
useGlobalConfig: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
withError: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
@@ -82,35 +82,35 @@ describe('setupAutoUpdater', () => {
|
||||
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
|
||||
|
||||
const laneReleaseTagMap = {
|
||||
alpha: 'v0.9.11-alpha.4',
|
||||
beta: 'v0.9.10-beta.3',
|
||||
latest: 'v0.9.12-nightly.7',
|
||||
nightly: 'v0.9.12-nightly.7',
|
||||
stable: 'v0.9.9',
|
||||
beta: 'v0.9.10-beta.3',
|
||||
alpha: 'v0.9.11-alpha.4',
|
||||
nightly: 'v0.9.12-nightly.7',
|
||||
} 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 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 = [
|
||||
{ draft: false, prerelease: false, tag_name: 'v0.9.7' },
|
||||
{ draft: false, prerelease: false, tag_name: 'v0.9.9' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.9-beta.1' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.10-beta.3' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.10-alpha.5' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.11-alpha.4' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.11-nightly.1' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.12-nightly.7' },
|
||||
{ tag_name: 'v0.9.7', draft: false, prerelease: false },
|
||||
{ tag_name: 'v0.9.9', draft: false, prerelease: false },
|
||||
{ tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true },
|
||||
]
|
||||
|
||||
function mockGitHubReleasesFetch(releases = defaultReleases) {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
json: async () => releases,
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => releases,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
return fetchSpy
|
||||
@@ -131,8 +131,8 @@ describe('setupAutoUpdater', () => {
|
||||
|
||||
it('resolves release tag from GitHub API and configures generic provider for checks', async () => {
|
||||
const fetchSpy = mockGitHubReleasesFetch([
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' },
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.5' },
|
||||
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true },
|
||||
])
|
||||
const { setupAutoUpdater } = await import('./auto-updater')
|
||||
const service = setupAutoUpdater()
|
||||
@@ -198,9 +198,9 @@ describe('setupAutoUpdater', () => {
|
||||
it('supports explicit stable lane selection for future dynamic channel switching', async () => {
|
||||
process.env.AIRI_UPDATE_CHANNEL = 'stable'
|
||||
mockGitHubReleasesFetch([
|
||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' },
|
||||
{ draft: false, prerelease: false, tag_name: 'v0.8.9' },
|
||||
{ draft: false, prerelease: false, tag_name: 'v0.8.8' },
|
||||
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
|
||||
{ tag_name: 'v0.8.9', draft: false, prerelease: false },
|
||||
{ tag_name: 'v0.8.8', draft: false, prerelease: false },
|
||||
])
|
||||
|
||||
const { setupAutoUpdater } = await import('./auto-updater')
|
||||
@@ -274,12 +274,12 @@ describe('setupAutoUpdater', () => {
|
||||
const service = setupAutoUpdater()
|
||||
|
||||
expect(service.state.diagnostics).toEqual(expect.objectContaining({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
channel: expectedChannelByArch,
|
||||
executablePath: expect.any(String),
|
||||
isOverrideActive: false,
|
||||
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('pendingDir')
|
||||
|
||||
@@ -49,10 +49,10 @@ function getCacheRoot() {
|
||||
|
||||
function getLegacyCacheRoot() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return join(process.env.HOME || '', 'Library', 'Caches')
|
||||
case 'win32':
|
||||
return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local')
|
||||
case 'darwin':
|
||||
return join(process.env.HOME || '', 'Library', 'Caches')
|
||||
default:
|
||||
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 LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater')
|
||||
const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([
|
||||
LEGACY_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 {
|
||||
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
|
||||
autoDownload?: boolean
|
||||
channel?: string
|
||||
checkForUpdates: () => Promise<any>
|
||||
downloadUpdate: () => Promise<any>
|
||||
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 {
|
||||
state: AutoUpdaterState
|
||||
checkForUpdates: () => Promise<void>
|
||||
downloadUpdate: () => Promise<void>
|
||||
getPreferredUpdateLane: () => undefined | UpdateLane
|
||||
quitAndInstall: () => Promise<void>
|
||||
setPreferredUpdateLane: (lane: undefined | UpdateLane) => Promise<void>
|
||||
state: AutoUpdaterState
|
||||
getPreferredUpdateLane: () => UpdateLane | undefined
|
||||
setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise<void>
|
||||
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
|
||||
}
|
||||
|
||||
@@ -98,74 +267,42 @@ export interface AutoUpdaterOptions {
|
||||
*/
|
||||
enabled?: boolean
|
||||
/** Reads the release channel persisted by the application configuration. */
|
||||
getStoredUpdateLane?: () => undefined | UpdateLane
|
||||
getStoredUpdateLane?: () => UpdateLane | undefined
|
||||
/** Persists a release-channel change requested through updater IPC. */
|
||||
setStoredUpdateLane?: (lane: undefined | UpdateLane) => void
|
||||
}
|
||||
export type UpdateLane = ElectronUpdaterChannel
|
||||
|
||||
interface GitHubReleaseRecord {
|
||||
draft?: boolean
|
||||
prerelease?: boolean
|
||||
tag_name?: string
|
||||
setStoredUpdateLane?: (lane: UpdateLane | undefined) => void
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
const unsubscribe = service.subscribe((state) => {
|
||||
if (window.isDestroyed())
|
||||
return
|
||||
|
||||
tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state))
|
||||
})
|
||||
|
||||
const cleanups: Array<() => void> = [
|
||||
unsubscribe,
|
||||
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
|
||||
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
|
||||
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
|
||||
return service.state
|
||||
}),
|
||||
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()
|
||||
return {
|
||||
state,
|
||||
async checkForUpdates() {},
|
||||
async downloadUpdate() {},
|
||||
async quitAndInstall() {},
|
||||
getPreferredUpdateLane() {
|
||||
return storedPreferredLane
|
||||
},
|
||||
async setPreferredUpdateLane(lane) {
|
||||
storedPreferredLane = lane
|
||||
options.setStoredUpdateLane?.(lane)
|
||||
},
|
||||
subscribe(callback) {
|
||||
callback(state)
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -191,14 +328,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
autoUpdater.channel = releaseChannelName
|
||||
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
|
||||
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) => {
|
||||
log.log(message)
|
||||
void logToFile('INFO', message)
|
||||
@@ -207,6 +336,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
log.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)
|
||||
@@ -215,14 +352,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
|
||||
...next,
|
||||
diagnostics: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
channel: autoUpdater.channel || releaseChannelName,
|
||||
logFilePath: UPDATER_LOG_FILE,
|
||||
executablePath: process.execPath,
|
||||
installDirectory: dirname(process.execPath),
|
||||
isOverrideActive: !!activeFeedUrlOverride,
|
||||
logFilePath: UPDATER_LOG_FILE,
|
||||
platform: process.platform,
|
||||
requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath),
|
||||
isOverrideActive: !!activeFeedUrlOverride,
|
||||
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
|
||||
},
|
||||
})
|
||||
@@ -245,8 +382,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
|
||||
function broadcastUpdaterError(error: unknown, reason: string) {
|
||||
broadcast({
|
||||
error: { message: errorMessageFromValue(error) },
|
||||
status: 'error',
|
||||
error: { message: errorMessageFromValue(error) },
|
||||
})
|
||||
log.withError(error).error(reason)
|
||||
}
|
||||
@@ -315,7 +452,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
}
|
||||
|
||||
prepareFeedPromise = (async () => {
|
||||
const preferredLane = getPreferredUpdateLane({ storedLane: storedPreferredLane, version: appVersion })
|
||||
const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane })
|
||||
const tag = await resolveGitHubReleaseTagForLane(preferredLane)
|
||||
resolvedReleaseTag = tag
|
||||
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('checking-for-update', () => broadcast({ status: 'checking' }))
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ info, status: 'available' }))
|
||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ info, status: 'downloaded' }))
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info }))
|
||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info }))
|
||||
autoUpdater.on('update-not-available', () => broadcast({
|
||||
status: 'not-available',
|
||||
info: {
|
||||
version: app.getVersion(),
|
||||
files: [],
|
||||
releaseDate: committerDate,
|
||||
version: app.getVersion(),
|
||||
},
|
||||
status: 'not-available',
|
||||
}))
|
||||
autoUpdater.on('download-progress', progress => broadcast({
|
||||
...state,
|
||||
progress: {
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
percent: progress.percent,
|
||||
total: progress.total,
|
||||
transferred: progress.transferred,
|
||||
},
|
||||
status: 'downloading',
|
||||
progress: {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
},
|
||||
}))
|
||||
|
||||
void checkForUpdatesWithPreparedFeed()
|
||||
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
async checkForUpdates() {
|
||||
broadcast({ status: 'checking' })
|
||||
await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
||||
@@ -378,9 +518,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
semaphore.release()
|
||||
}
|
||||
},
|
||||
getPreferredUpdateLane() {
|
||||
return storedPreferredLane
|
||||
},
|
||||
async quitAndInstall() {
|
||||
await semaphore.acquire()
|
||||
|
||||
@@ -394,6 +531,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
semaphore.release()
|
||||
}
|
||||
},
|
||||
getPreferredUpdateLane() {
|
||||
return storedPreferredLane
|
||||
},
|
||||
async setPreferredUpdateLane(lane) {
|
||||
if (storedPreferredLane === lane)
|
||||
return
|
||||
@@ -405,9 +545,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
// A fresh check runs right after channel update from renderer.
|
||||
broadcast({ status: 'idle' })
|
||||
},
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
subscribe(callback) {
|
||||
hooks.add(callback)
|
||||
|
||||
@@ -423,185 +560,48 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
}
|
||||
}
|
||||
|
||||
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, { force: true, recursive: true })))
|
||||
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
|
||||
}
|
||||
export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) {
|
||||
const { context, window, service } = 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 {
|
||||
async checkForUpdates() {},
|
||||
async downloadUpdate() {},
|
||||
getPreferredUpdateLane() {
|
||||
return storedPreferredLane
|
||||
},
|
||||
async quitAndInstall() {},
|
||||
async setPreferredUpdateLane(lane) {
|
||||
storedPreferredLane = lane
|
||||
options.setStoredUpdateLane?.(lane)
|
||||
},
|
||||
state,
|
||||
subscribe(callback) {
|
||||
callback(state)
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
const unsubscribe = service.subscribe((state) => {
|
||||
if (window.isDestroyed())
|
||||
return
|
||||
|
||||
/**
|
||||
* 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
|
||||
tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state))
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
const cleanups: Array<() => void> = [
|
||||
unsubscribe,
|
||||
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
|
||||
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
|
||||
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
|
||||
return service.state
|
||||
}),
|
||||
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()
|
||||
}),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(value => normalize(value))
|
||||
}
|
||||
|
||||
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 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
|
||||
const cleanup = () => {
|
||||
for (const fn of cleanups)
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
window.on('closed', cleanup)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
@@ -3,24 +3,6 @@ import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||
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.
|
||||
*
|
||||
@@ -35,10 +17,28 @@ function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>)
|
||||
*/
|
||||
function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding {
|
||||
return {
|
||||
accelerator: { key, modifiers },
|
||||
id,
|
||||
receiveKeyUps: true,
|
||||
accelerator: { modifiers, key },
|
||||
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
|
||||
// tests assert real keycodes, not arbitrary numbers).
|
||||
const UiohookKey = {
|
||||
A: 30,
|
||||
K: 37,
|
||||
A: 30,
|
||||
Q: 16,
|
||||
} as const
|
||||
|
||||
@@ -118,17 +118,17 @@ async function setupMocks() {
|
||||
platform: overrides.platform ?? 'darwin',
|
||||
sessionType: overrides.sessionType,
|
||||
})
|
||||
return { broadcastTriggered, driver, logger }
|
||||
return { driver, broadcastTriggered, logger }
|
||||
}
|
||||
|
||||
return {
|
||||
createDriver,
|
||||
fire,
|
||||
isTrustedAccessibilityClientMock,
|
||||
onMock,
|
||||
removeListenerMock,
|
||||
startMock,
|
||||
stopMock,
|
||||
isTrustedAccessibilityClientMock,
|
||||
fire,
|
||||
createDriver,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('createUiohookDriver', () => {
|
||||
|
||||
it('broadcasts a "down" event when a matching keydown arrives', async () => {
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||
|
||||
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 start/stop frantically.
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||
|
||||
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 () => {
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||
|
||||
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
||||
@@ -222,7 +222,7 @@ describe('createUiohookDriver', () => {
|
||||
// The driver keys the "up" broadcast off the prior `pressed`
|
||||
// state rather than the modifier predicate.
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
||||
|
||||
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
||||
@@ -234,7 +234,7 @@ describe('createUiohookDriver', () => {
|
||||
|
||||
it('ignores keyup when no matching keydown was tracked', async () => {
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||
|
||||
m.fire('keyup', event({ keycode: 37, shiftKey: true }))
|
||||
@@ -246,21 +246,21 @@ describe('createUiohookDriver', () => {
|
||||
// Strict matching mirrors Electron's accelerator semantics: a
|
||||
// `Shift+K` binding must not fire on `Cmd+Shift+K`.
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
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()
|
||||
})
|
||||
|
||||
it('maps cmd-or-ctrl to metaKey on darwin', async () => {
|
||||
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'))
|
||||
|
||||
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).toHaveBeenCalledWith('ptt', 'down')
|
||||
@@ -268,10 +268,10 @@ describe('createUiohookDriver', () => {
|
||||
|
||||
it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => {
|
||||
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'))
|
||||
|
||||
m.fire('keydown', event({ ctrlKey: true, keycode: 37 }))
|
||||
m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
|
||||
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
||||
|
||||
// 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 () => {
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
|
||||
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
||||
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
||||
@@ -353,7 +353,7 @@ describe('createUiohookDriver', () => {
|
||||
|
||||
it('keeps per-binding pressed state independent across multiple bindings', async () => {
|
||||
const m = await setupMocks()
|
||||
const { broadcastTriggered, driver } = m.createDriver()
|
||||
const { driver, broadcastTriggered } = m.createDriver()
|
||||
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
||||
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
||||
|
||||
|
||||
@@ -17,26 +17,128 @@ import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||
type Logger = ReturnType<ReturnType<typeof useLogg>['useGlobalConfig']>
|
||||
|
||||
interface ModifierMask {
|
||||
alt: boolean
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
meta: boolean
|
||||
}
|
||||
|
||||
interface UiohookEntry {
|
||||
binding: ShortcutBinding
|
||||
expectedKeycode: number
|
||||
predicate: (event: UiohookKeyboardEvent) => boolean
|
||||
expectedKeycode: number
|
||||
pressed: boolean
|
||||
}
|
||||
|
||||
const W3C_TO_UIOHOOK: Readonly<Record<ShortcutKey, number>> = buildKeycodeMap()
|
||||
|
||||
export interface UiohookDriver {
|
||||
dispose: () => void
|
||||
tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult
|
||||
unregisterAll: () => void
|
||||
unregisterById: (id: string) => void
|
||||
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> = {
|
||||
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 {
|
||||
@@ -58,6 +160,13 @@ export interface UiohookDriverOptions {
|
||||
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
|
||||
* libuiohook (through `uiohook-napi`).
|
||||
@@ -173,8 +282,8 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
|
||||
|
||||
entries.set(binding.id, {
|
||||
binding,
|
||||
expectedKeycode: built.expectedKeycode,
|
||||
predicate: built.predicate,
|
||||
expectedKeycode: built.expectedKeycode,
|
||||
pressed: false,
|
||||
})
|
||||
ensureListeners()
|
||||
@@ -204,114 +313,5 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
|
||||
}
|
||||
}
|
||||
|
||||
return { dispose, tryRegister, unregisterAll, unregisterById }
|
||||
}
|
||||
|
||||
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
|
||||
return { tryRegister, unregisterById, unregisterAll, dispose }
|
||||
}
|
||||
|
||||
@@ -6,28 +6,23 @@ import type { EventaContext } from './global-shortcut'
|
||||
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||
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 {
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
invokeHandlers: Map<string, (payload: unknown) => unknown>
|
||||
}
|
||||
|
||||
interface MockWindow {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
/** Manually trigger the registered `closed` handler. */
|
||||
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 {
|
||||
@@ -44,22 +39,27 @@ function createMockContext(): MockContext {
|
||||
function createMockWindow(): MockWindow {
|
||||
let closedHandler: (() => void) | undefined
|
||||
return {
|
||||
close() {
|
||||
closedHandler?.()
|
||||
},
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'closed')
|
||||
closedHandler = handler
|
||||
}),
|
||||
close() {
|
||||
closedHandler?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding {
|
||||
return {
|
||||
accelerator: { key, modifiers: ['cmd-or-ctrl', 'shift'] },
|
||||
id,
|
||||
scope: 'global',
|
||||
}
|
||||
// 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 asBrowserWindow(window: MockWindow): BrowserWindow {
|
||||
return window as unknown as BrowserWindow
|
||||
}
|
||||
|
||||
function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow {
|
||||
@@ -93,7 +93,7 @@ async function setupMocks() {
|
||||
triggerCallbacks.clear()
|
||||
})
|
||||
|
||||
const onAppBeforeQuitMock = vi.fn<(fn: () => Promise<void> | void) => void>()
|
||||
const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise<void>) => void>()
|
||||
|
||||
vi.doMock('electron', () => ({
|
||||
globalShortcut: {
|
||||
@@ -108,10 +108,10 @@ async function setupMocks() {
|
||||
|
||||
vi.doMock('./global-shortcut-uiohook', () => ({
|
||||
createUiohookDriver: () => ({
|
||||
dispose: vi.fn(),
|
||||
tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })),
|
||||
unregisterAll: vi.fn(),
|
||||
unregisterById: vi.fn(),
|
||||
unregisterAll: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -144,12 +144,12 @@ async function setupMocks() {
|
||||
const { setupGlobalShortcutService } = await import('./global-shortcut')
|
||||
|
||||
return {
|
||||
onAppBeforeQuitMock,
|
||||
registerMock,
|
||||
setupGlobalShortcutService,
|
||||
triggerCallbacks,
|
||||
unregisterAllMock,
|
||||
registerMock,
|
||||
unregisterMock,
|
||||
unregisterAllMock,
|
||||
triggerCallbacks,
|
||||
onAppBeforeQuitMock,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('setupGlobalShortcutService', () => {
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
expect(() => reg({})).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()
|
||||
})
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
|
||||
|
||||
export type EventaContext = ReturnType<typeof createContext>['context']
|
||||
|
||||
export interface GlobalShortcutService {
|
||||
dispose: () => void
|
||||
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
|
||||
registerWindow: (params: RegisterWindowParams) => void
|
||||
export interface RegisterWindowParams {
|
||||
context: EventaContext
|
||||
window: BrowserWindow
|
||||
}
|
||||
|
||||
export interface RegisterMainShortcutParams {
|
||||
@@ -31,15 +30,16 @@ export interface RegisterMainShortcutParams {
|
||||
onTriggered: () => void
|
||||
}
|
||||
|
||||
export interface RegisterWindowParams {
|
||||
context: EventaContext
|
||||
window: BrowserWindow
|
||||
export interface GlobalShortcutService {
|
||||
registerWindow: (params: RegisterWindowParams) => void
|
||||
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
type ActiveBinding
|
||||
= | { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, onTriggered: () => void, owner: 'main' }
|
||||
| { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, owner: 'renderer' }
|
||||
| { binding: ShortcutBinding, driver: 'uiohook', owner: 'renderer' }
|
||||
= | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string }
|
||||
| { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void }
|
||||
| { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' }
|
||||
|
||||
export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
const log = useLogg('global-shortcut').useGlobalConfig()
|
||||
@@ -73,7 +73,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
|
||||
const result = uiohookDriver.tryRegister(binding)
|
||||
if (result.ok)
|
||||
active.set(binding.id, { binding, driver: 'uiohook', owner: 'renderer' })
|
||||
active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' })
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
||||
|
||||
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) {
|
||||
releaseEntry(binding.id, existing)
|
||||
if (globalShortcut.register(electronAccelerator, onTriggered)) {
|
||||
@@ -146,7 +146,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
active.delete(id)
|
||||
}
|
||||
|
||||
function tryRegister(binding: ShortcutBinding): Promise<ShortcutRegistrationResult> | ShortcutRegistrationResult {
|
||||
function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult | Promise<ShortcutRegistrationResult> {
|
||||
if (active.has(binding.id)) {
|
||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
||||
}
|
||||
@@ -209,5 +209,5 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
|
||||
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'
|
||||
|
||||
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 ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
|
||||
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>([
|
||||
'clipboard-sanitized-write',
|
||||
'display-capture',
|
||||
'clipboard-sanitized-write',
|
||||
])
|
||||
|
||||
/**
|
||||
* Registers the paired Electron session handlers required for complete permission policy.
|
||||
*
|
||||
* 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
|
||||
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
|
||||
*/
|
||||
export function setupMediaPermissionHandlers(
|
||||
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||
isDesktopCaptureAuthorized: () => boolean,
|
||||
): void {
|
||||
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
|
||||
})
|
||||
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
|
||||
return !!rawURL && rawURL !== 'null'
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
* Registers the paired Electron session handlers required for complete permission policy.
|
||||
*
|
||||
* 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}.
|
||||
* 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
|
||||
*/
|
||||
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
||||
if (permission !== 'media' || !details)
|
||||
return false
|
||||
export function setupMediaPermissionHandlers(
|
||||
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||
isDesktopCaptureAuthorized: () => boolean,
|
||||
): void {
|
||||
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
|
||||
})
|
||||
|
||||
return 'mediaTypes' in details && details.mediaTypes?.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ export class MockAutoUpdater extends EventEmitter {
|
||||
// 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
|
||||
const updateInfo = {
|
||||
version: '9.9.9-mock',
|
||||
files: [],
|
||||
path: 'mock-path',
|
||||
sha512: 'mock-sha',
|
||||
releaseDate: new Date().toISOString(),
|
||||
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)
|
||||
@@ -41,10 +41,10 @@ export class MockAutoUpdater extends EventEmitter {
|
||||
transferred = total
|
||||
|
||||
const progress = {
|
||||
bytesPerSecond: speed,
|
||||
percent: (transferred / total) * 100,
|
||||
total,
|
||||
transferred,
|
||||
percent: (transferred / total) * 100,
|
||||
bytesPerSecond: speed,
|
||||
}
|
||||
|
||||
this.emit('download-progress', progress)
|
||||
@@ -52,12 +52,12 @@ export class MockAutoUpdater extends EventEmitter {
|
||||
if (transferred >= total) {
|
||||
clearInterval(interval)
|
||||
this.emit('update-downloaded', {
|
||||
version: '9.9.9-mock',
|
||||
files: [],
|
||||
path: 'mock-path',
|
||||
sha512: 'mock-sha',
|
||||
releaseDate: new Date().toISOString(),
|
||||
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)
|
||||
|
||||
@@ -11,11 +11,11 @@ import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecy
|
||||
|
||||
export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
|
||||
const { start, stop } = createRendererLoop({
|
||||
window: params.window,
|
||||
run: () => {
|
||||
const dipPos = screen.getCursorScreenPoint()
|
||||
params.context.emit(cursorScreenPoint, dipPos)
|
||||
},
|
||||
window: params.window,
|
||||
})
|
||||
|
||||
onAppWindowAllClosed(() => stop())
|
||||
|
||||
@@ -34,10 +34,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
||||
}
|
||||
|
||||
const { start, stop } = createRendererLoop({
|
||||
window: params.window,
|
||||
run: () => {
|
||||
params.context.emit(bounds, params.window.getBounds())
|
||||
},
|
||||
window: params.window,
|
||||
})
|
||||
|
||||
onAppWindowAllClosed(() => stop())
|
||||
@@ -61,10 +61,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
||||
}
|
||||
|
||||
return {
|
||||
height: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -109,10 +109,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
||||
}
|
||||
|
||||
resizeWindowByDelta({
|
||||
window: params.window,
|
||||
deltaX: payload.deltaX,
|
||||
deltaY: payload.deltaY,
|
||||
direction: payload.direction,
|
||||
window: params.window,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,15 +31,82 @@ const RECOMMENDED_WIDTH = 450
|
||||
const RECOMMENDED_HEIGHT = 600
|
||||
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: {
|
||||
aboutWindow: () => Promise<BrowserWindow>
|
||||
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
|
||||
captionWindow: ReturnType<typeof setupCaptionWindowManager>
|
||||
i18n: I18n
|
||||
mainWindow: BrowserWindow
|
||||
serverChannel: ServerChannel
|
||||
settingsWindow: SettingsWindowManager
|
||||
captionWindow: ReturnType<typeof setupCaptionWindowManager>
|
||||
widgetsWindow: WidgetsWindowManager
|
||||
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
|
||||
aboutWindow: () => Promise<BrowserWindow>
|
||||
serverChannel: ServerChannel
|
||||
i18n: I18n
|
||||
}): void {
|
||||
once(() => {
|
||||
const mainWindowAnimator = new Animator(params.mainWindow)
|
||||
@@ -67,8 +134,8 @@ export function setupTray(params: {
|
||||
|
||||
const mainWindowBounds = params.mainWindow.getBounds()
|
||||
const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds)
|
||||
const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = currentDisplay.workArea
|
||||
const { height: windowHeight, width: windowWidth } = mainWindowBounds
|
||||
const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea
|
||||
const { width: windowWidth, height: windowHeight } = mainWindowBounds
|
||||
|
||||
const fullHeightTarget = areaHeight
|
||||
const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO)
|
||||
@@ -76,34 +143,34 @@ export function setupTray(params: {
|
||||
const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO)
|
||||
|
||||
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' },
|
||||
{
|
||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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' },
|
||||
{
|
||||
checked: isPositionMatch(params.mainWindow, areaX, areaY),
|
||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
|
||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
type: 'checkbox',
|
||||
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
|
||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ click: () => void params.settingsWindow.openWindow('/settings'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.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.settings'), click: () => void params.settingsWindow.openWindow('/settings') },
|
||||
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) },
|
||||
{ type: 'separator' },
|
||||
{ click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay') },
|
||||
{ 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_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) },
|
||||
{ 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()
|
||||
? 'tamagotchi.electron.tray.menu.labels.label.close_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'),
|
||||
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' },
|
||||
{ click: async () => await params.captionWindow.resetToSide(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position') },
|
||||
{ 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)) },
|
||||
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position'), click: async () => await params.captionWindow.resetToSide() },
|
||||
]),
|
||||
type: 'submenu',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG
|
||||
? [
|
||||
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools'), type: 'header' },
|
||||
{ click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync') },
|
||||
{ type: 'header', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools') },
|
||||
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync'), click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }) },
|
||||
{ type: 'separator' },
|
||||
] 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)
|
||||
@@ -186,7 +253,7 @@ export function setupTray(params: {
|
||||
rebuildContextMenu()
|
||||
|
||||
const stopLocaleEffect = effect(() => {
|
||||
const locale = params.i18n.locale as (() => LocaleDetector<any[]> | string | undefined)
|
||||
const locale = params.i18n.locale as (() => string | LocaleDetector<any[]> | undefined)
|
||||
locale()
|
||||
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 () => {
|
||||
const window = new BrowserWindow({
|
||||
title: 'About AIRI',
|
||||
width: 670,
|
||||
height: 880,
|
||||
icon,
|
||||
show: false,
|
||||
resizable: true,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
resizable: true,
|
||||
show: false,
|
||||
title: 'About AIRI',
|
||||
icon,
|
||||
webPreferences: {
|
||||
preload: join(getElectronMainDirname(), '../preload/index.mjs'),
|
||||
sandbox: false,
|
||||
},
|
||||
width: 670,
|
||||
})
|
||||
|
||||
window.on('ready-to-show', () => window.show())
|
||||
protectPrivilegedWindowNavigation(window)
|
||||
|
||||
await setupAboutWindowElectronInvokes({
|
||||
window,
|
||||
autoUpdater: params.autoUpdater,
|
||||
i18n: params.i18n,
|
||||
serverChannel: params.serverChannel,
|
||||
window,
|
||||
})
|
||||
|
||||
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
Reference in New Issue
Block a user