+1
-1
@@ -6,9 +6,9 @@ export default defineConfig([
|
|||||||
{
|
{
|
||||||
extends: ['js/recommended'],
|
extends: ['js/recommended'],
|
||||||
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'],
|
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'],
|
||||||
ignores: ['**/node_modules/**'],
|
|
||||||
plugins: {
|
plugins: {
|
||||||
js: jsPlugin,
|
js: jsPlugin,
|
||||||
},
|
},
|
||||||
|
ignores: ['**/node_modules/**'],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import App from './App.vue'
|
|||||||
import '@unocss/reset/tailwind.css'
|
import '@unocss/reset/tailwind.css'
|
||||||
import 'uno.css'
|
import 'uno.css'
|
||||||
|
|
||||||
const router = createRouter({ history: createWebHashHistory(), routes })
|
const router = createRouter({ routes, history: createWebHashHistory() })
|
||||||
|
|
||||||
createApp(App)
|
createApp(App)
|
||||||
.use(router)
|
.use(router)
|
||||||
|
|||||||
+2
-2
@@ -11,12 +11,12 @@ export const weatherComponent = defineCallingComponent(
|
|||||||
Weather,
|
Weather,
|
||||||
object({
|
object({
|
||||||
city: string(),
|
city: string(),
|
||||||
condition: string(),
|
|
||||||
temperature: string(),
|
temperature: string(),
|
||||||
|
condition: string(),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
city: 'Tokyo',
|
city: 'Tokyo',
|
||||||
condition: 'Sunny',
|
|
||||||
temperature: '25°',
|
temperature: '25°',
|
||||||
|
condition: 'Sunny',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { toJsonSchema } from 'xsschema'
|
|||||||
|
|
||||||
export function defineCallingComponent<T extends Schema>(name: string, component: Component, schema: T, exampleProps?: Record<string, any>) {
|
export function defineCallingComponent<T extends Schema>(name: string, component: Component, schema: T, exampleProps?: Record<string, any>) {
|
||||||
return {
|
return {
|
||||||
component: markRaw(component),
|
|
||||||
exampleProps,
|
|
||||||
name,
|
name,
|
||||||
schema: toJsonSchema(schema),
|
schema: toJsonSchema(schema),
|
||||||
|
component: markRaw(component),
|
||||||
|
exampleProps,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export default defineConfig({
|
|||||||
...presetWebFontsFonts('fontsource'),
|
...presetWebFontsFonts('fontsource'),
|
||||||
},
|
},
|
||||||
timeouts: {
|
timeouts: {
|
||||||
failure: 10000,
|
|
||||||
warning: 5000,
|
warning: 5000,
|
||||||
|
failure: 10000,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
presetIcons({
|
presetIcons({
|
||||||
@@ -35,14 +35,14 @@ export default defineConfig({
|
|||||||
presetChromatic({
|
presetChromatic({
|
||||||
baseHue: 240.25,
|
baseHue: 240.25,
|
||||||
colors: {
|
colors: {
|
||||||
complementary: 180,
|
|
||||||
primary: 0,
|
primary: 0,
|
||||||
|
complementary: 180,
|
||||||
},
|
},
|
||||||
}) as Preset,
|
}) as Preset,
|
||||||
],
|
],
|
||||||
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
|
||||||
transformers: [
|
transformers: [
|
||||||
transformerDirectives(),
|
transformerDirectives(),
|
||||||
transformerVariantGroup(),
|
transformerVariantGroup(),
|
||||||
],
|
],
|
||||||
|
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { defineConfig } from 'vite'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
VueRouter({
|
VueRouter({
|
||||||
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
|
|
||||||
extensions: ['.vue', '.md'],
|
extensions: ['.vue', '.md'],
|
||||||
|
dts: resolve(import.meta.dirname, 'src', 'typed-router.d.ts'),
|
||||||
}),
|
}),
|
||||||
Vue(),
|
Vue(),
|
||||||
// https://github.com/antfu/unocss
|
// https://github.com/antfu/unocss
|
||||||
|
|||||||
@@ -7,25 +7,25 @@ const serverURL = env.CAPACITOR_DEV_SERVER_URL
|
|||||||
const appId = argv.includes('android') ? 'ai.moeru.airi_pocket' : 'ai.moeru.airi-pocket'
|
const appId = argv.includes('android') ? 'ai.moeru.airi_pocket' : 'ai.moeru.airi-pocket'
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
|
appId,
|
||||||
|
appName: 'AIRI',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: serverURL
|
||||||
|
? {
|
||||||
|
url: serverURL,
|
||||||
|
cleartext: false,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
android: {
|
android: {
|
||||||
buildOptions: {
|
buildOptions: {
|
||||||
keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS,
|
|
||||||
keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD,
|
|
||||||
keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD,
|
|
||||||
keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH,
|
keystorePath: env.CAPACITOR_ANDROID_KEYSTORE_PATH,
|
||||||
|
keystoreAlias: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS,
|
||||||
|
keystorePassword: env.CAPACITOR_ANDROID_KEYSTORE_PASSWORD,
|
||||||
|
keystoreAliasPassword: env.CAPACITOR_ANDROID_KEYSTORE_ALIAS_PASSWORD,
|
||||||
releaseType: 'APK',
|
releaseType: 'APK',
|
||||||
signingType: 'apksigner',
|
signingType: 'apksigner',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
appId,
|
|
||||||
appName: 'AIRI',
|
|
||||||
server: serverURL
|
|
||||||
? {
|
|
||||||
cleartext: false,
|
|
||||||
url: serverURL,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
webDir: 'dist',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function useAudioInput() {
|
|||||||
const audioInputs = computed(() => devices.audioInputs.value)
|
const audioInputs = computed(() => devices.audioInputs.value)
|
||||||
|
|
||||||
const constraints = ref<MediaStreamConstraints>({ audio: true })
|
const constraints = ref<MediaStreamConstraints>({ audio: true })
|
||||||
const media = useUserMedia({ autoSwitch: true, constraints, enabled: false })
|
const media = useUserMedia({ constraints, autoSwitch: true, enabled: false })
|
||||||
|
|
||||||
async function request() {
|
async function request() {
|
||||||
if (devices.permissionGranted.value) {
|
if (devices.permissionGranted.value) {
|
||||||
@@ -71,13 +71,13 @@ export function useAudioInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioInputs,
|
|
||||||
media,
|
|
||||||
request,
|
|
||||||
|
|
||||||
selectedAudioInput,
|
|
||||||
selectedAudioInputId,
|
selectedAudioInputId,
|
||||||
|
selectedAudioInput,
|
||||||
|
audioInputs,
|
||||||
|
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
|
request,
|
||||||
|
media,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export function useIconAnimation(icon: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
animationIcon,
|
|
||||||
iconAnimationStarted,
|
iconAnimationStarted,
|
||||||
showIconAnimation,
|
showIconAnimation,
|
||||||
|
animationIcon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
|
|||||||
|
|
||||||
let router: Router
|
let router: Router
|
||||||
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
|
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
|
||||||
router = createRouter({ history: createWebHashHistory(), routes: routeRecords })
|
router = createRouter({ routes: routeRecords, history: createWebHashHistory() })
|
||||||
else
|
else
|
||||||
router = createRouter({ history: createWebHistory(), routes: routeRecords })
|
router = createRouter({ routes: routeRecords, history: createWebHistory() })
|
||||||
|
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
if (to.path !== from.path)
|
if (to.path !== from.path)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ function getLocale() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
fallbackLocale: 'en',
|
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: getLocale(),
|
locale: getLocale(),
|
||||||
|
fallbackLocale: 'en',
|
||||||
messages,
|
messages,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { registerPlugin } from '@capacitor/core'
|
import { registerPlugin } from '@capacitor/core'
|
||||||
|
|
||||||
interface MicrophonePermissionPlugin {
|
|
||||||
checkPermission: () => Promise<MicrophonePermissionState>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MicrophonePermissionState {
|
interface MicrophonePermissionState {
|
||||||
granted: boolean
|
granted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MicrophonePermissionPlugin {
|
||||||
|
checkPermission: () => Promise<MicrophonePermissionState>
|
||||||
|
}
|
||||||
|
|
||||||
/** Reads Android's native microphone permission state without triggering a permission request. */
|
/** Reads Android's native microphone permission state without triggering a permission request. */
|
||||||
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')
|
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ export async function probeServerChannelQrPayload(payload: ServerChannelQrPayloa
|
|||||||
const client = new Client({
|
const client = new Client({
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
connector: createTextProtocolConnector(connector),
|
|
||||||
connectTimeoutMs: 2_000,
|
connectTimeoutMs: 2_000,
|
||||||
name: WebSocketEventSource.StageWeb,
|
name: WebSocketEventSource.StageWeb,
|
||||||
token: payload.authToken,
|
token: payload.authToken,
|
||||||
url,
|
url,
|
||||||
|
connector: createTextProtocolConnector(connector),
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ interface WebAuthenticationOptions {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebAuthenticationPlugin {
|
|
||||||
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebAuthenticationResult {
|
interface WebAuthenticationResult {
|
||||||
callbackUrl?: string
|
callbackUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebAuthenticationPlugin {
|
||||||
|
authenticate: (options: WebAuthenticationOptions) => Promise<WebAuthenticationResult>
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens an authorization URL with the native system browser session. */
|
/** Opens an authorization URL with the native system browser session. */
|
||||||
export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication')
|
export const WebAuthentication = registerPlugin<WebAuthenticationPlugin>('WebAuthentication')
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk'
|
import type { ClientConnector, ClientEvents } from '@proj-airi/server-sdk'
|
||||||
|
|
||||||
type HostBridgeCommand
|
type HostBridgeCommand
|
||||||
= | { code?: number, id: string, kind: 'close', reason?: string }
|
= | { kind: 'connect', id: string, url: string }
|
||||||
| { data: string, id: string, kind: 'send' }
|
| { kind: 'send', id: string, data: string }
|
||||||
| { id: string, kind: 'connect', url: string }
|
| { kind: 'close', id: string, code?: number, reason?: string }
|
||||||
|
|
||||||
type HostBridgeEvent
|
type HostBridgeEvent
|
||||||
= | { code?: number, id: string, kind: 'close', reason?: string }
|
= | { kind: 'open', id: string }
|
||||||
| { data: string, id: string, kind: 'message' }
|
| { kind: 'message', id: string, data: string }
|
||||||
| { id: string, kind: 'error', message: string }
|
| { kind: 'error', id: string, message: string }
|
||||||
| { id: string, kind: 'open' }
|
| { kind: 'close', id: string, code?: number, reason?: string }
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__airiHostBridge?: {
|
|
||||||
onNativeMessage?: (payload: string) => void
|
|
||||||
}
|
|
||||||
AiriHostBridge?: {
|
AiriHostBridge?: {
|
||||||
postMessage: (payload: string) => void
|
postMessage: (payload: string) => void
|
||||||
}
|
}
|
||||||
@@ -26,11 +23,38 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
__airiHostBridge?: {
|
||||||
|
onNativeMessage?: (payload: string) => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = new Map<string, HostBridgeConnection>()
|
const connections = new Map<string, HostBridgeConnection>()
|
||||||
|
|
||||||
|
function postBridgeMessage(command: HostBridgeCommand) {
|
||||||
|
if (window.AiriHostBridge) {
|
||||||
|
window.AiriHostBridge.postMessage(JSON.stringify(command))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.webkit?.messageHandlers?.airiHostBridge) {
|
||||||
|
window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('AIRI host websocket bridge is unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchNativeEvent(payload: string) {
|
||||||
|
const event = JSON.parse(payload) as HostBridgeEvent
|
||||||
|
const connection = connections.get(event.id)
|
||||||
|
if (!connection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.handleNativeEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
class HostBridgeConnection {
|
class HostBridgeConnection {
|
||||||
readonly id = crypto.randomUUID()
|
readonly id = crypto.randomUUID()
|
||||||
private opened = false
|
private opened = false
|
||||||
@@ -45,37 +69,49 @@ class HostBridgeConnection {
|
|||||||
connections.set(this.id, this)
|
connections.set(this.id, this)
|
||||||
|
|
||||||
postBridgeMessage({
|
postBridgeMessage({
|
||||||
id: this.id,
|
|
||||||
kind: 'connect',
|
kind: 'connect',
|
||||||
|
id: this.id,
|
||||||
url: this.url,
|
url: this.url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
if (!this.opened) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
postBridgeMessage({
|
||||||
|
kind: 'send',
|
||||||
|
id: this.id,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
close(code?: number, reason?: string) {
|
close(code?: number, reason?: string) {
|
||||||
if (this.settled && !this.opened) {
|
if (this.settled && !this.opened) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
postBridgeMessage({
|
postBridgeMessage({
|
||||||
code,
|
|
||||||
id: this.id,
|
|
||||||
kind: 'close',
|
kind: 'close',
|
||||||
|
id: this.id,
|
||||||
|
code,
|
||||||
reason,
|
reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNativeEvent(event: HostBridgeEvent) {
|
handleNativeEvent(event: HostBridgeEvent) {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'close':
|
case 'open':
|
||||||
connections.delete(this.id)
|
this.opened = true
|
||||||
if (!this.settled) {
|
this.settled = true
|
||||||
this.settled = true
|
this.resolve()
|
||||||
this.reject(createCloseBeforeOpenError(event))
|
break
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.opened = false
|
case 'message':
|
||||||
this.events.close({ code: event.code, reason: event.reason })
|
this.events.message(event.data)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -89,31 +125,25 @@ class HostBridgeConnection {
|
|||||||
this.events.error(new Error(event.message))
|
this.events.error(new Error(event.message))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'message':
|
case 'close':
|
||||||
this.events.message(event.data)
|
connections.delete(this.id)
|
||||||
break
|
if (!this.settled) {
|
||||||
|
this.settled = true
|
||||||
|
this.reject(createCloseBeforeOpenError(event))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case 'open':
|
this.opened = false
|
||||||
this.opened = true
|
this.events.close({ code: event.code, reason: event.reason })
|
||||||
this.settled = true
|
|
||||||
this.resolve()
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
send(data: string) {
|
function createCloseBeforeOpenError(event: Extract<HostBridgeEvent, { kind: 'close' }>) {
|
||||||
if (!this.opened) {
|
const reason = event.reason ? ` ${event.reason}` : ''
|
||||||
return false
|
const code = typeof event.code === 'number' ? ` with code ${event.code}` : ''
|
||||||
}
|
return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`)
|
||||||
|
|
||||||
postBridgeMessage({
|
|
||||||
data,
|
|
||||||
id: this.id,
|
|
||||||
kind: 'send',
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined {
|
export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined {
|
||||||
@@ -138,40 +168,10 @@ export function getHostWebSocketConnector(url: string): ClientConnector<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close: (code?: number, reason?: string) => activeConnection.close(code, reason),
|
|
||||||
send: message => activeConnection.send(message),
|
send: message => activeConnection.send(message),
|
||||||
|
close: (code?: number, reason?: string) => activeConnection.close(code, reason),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCloseBeforeOpenError(event: Extract<HostBridgeEvent, { kind: 'close' }>) {
|
|
||||||
const reason = event.reason ? ` ${event.reason}` : ''
|
|
||||||
const code = typeof event.code === 'number' ? ` with code ${event.code}` : ''
|
|
||||||
return new Error(`AIRI host websocket bridge closed before opening${code}.${reason}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchNativeEvent(payload: string) {
|
|
||||||
const event = JSON.parse(payload) as HostBridgeEvent
|
|
||||||
const connection = connections.get(event.id)
|
|
||||||
if (!connection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.handleNativeEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
function postBridgeMessage(command: HostBridgeCommand) {
|
|
||||||
if (window.AiriHostBridge) {
|
|
||||||
window.AiriHostBridge.postMessage(JSON.stringify(command))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.webkit?.messageHandlers?.airiHostBridge) {
|
|
||||||
window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('AIRI host websocket bridge is unavailable')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,30 +7,30 @@ import { AutoModel, Tensor } from '@huggingface/transformers'
|
|||||||
* Voice Activity Detection processor
|
* Voice Activity Detection processor
|
||||||
*/
|
*/
|
||||||
export class VAD implements BaseVAD {
|
export class VAD implements BaseVAD {
|
||||||
|
private config: BaseVADConfig
|
||||||
|
private model: PreTrainedModel | undefined
|
||||||
|
private state: Tensor
|
||||||
|
private sampleRateTensor: Tensor
|
||||||
private buffer: Float32Array
|
private buffer: Float32Array
|
||||||
private bufferPointer: number = 0
|
private bufferPointer: number = 0
|
||||||
private config: BaseVADConfig
|
|
||||||
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
|
|
||||||
private inferenceChain: Promise<any> = Promise.resolve()
|
|
||||||
private isReady: boolean = false
|
|
||||||
private isRecording: boolean = false
|
private isRecording: boolean = false
|
||||||
private model: PreTrainedModel | undefined
|
|
||||||
private postSpeechSamples: number = 0
|
private postSpeechSamples: number = 0
|
||||||
private prevBuffers: Float32Array[] = []
|
private prevBuffers: Float32Array[] = []
|
||||||
private sampleRateTensor: Tensor
|
private inferenceChain: Promise<any> = Promise.resolve()
|
||||||
private state: Tensor
|
private eventListeners: Partial<Record<keyof VADEvents, VADEventCallback<any>[]>> = {}
|
||||||
|
private isReady: boolean = false
|
||||||
|
|
||||||
constructor(userConfig: Partial<BaseVADConfig> = {}) {
|
constructor(userConfig: Partial<BaseVADConfig> = {}) {
|
||||||
// Default configuration
|
// Default configuration
|
||||||
const defaultConfig: BaseVADConfig = {
|
const defaultConfig: BaseVADConfig = {
|
||||||
exitThreshold: 0.1,
|
|
||||||
maxBufferDuration: 30,
|
|
||||||
minSilenceDurationMs: 400,
|
|
||||||
minSpeechDurationMs: 250,
|
|
||||||
newBufferSize: 512,
|
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
speechPadMs: 80,
|
|
||||||
speechThreshold: 0.3,
|
speechThreshold: 0.3,
|
||||||
|
exitThreshold: 0.1,
|
||||||
|
minSilenceDurationMs: 400,
|
||||||
|
speechPadMs: 80,
|
||||||
|
minSpeechDurationMs: 250,
|
||||||
|
maxBufferDuration: 30,
|
||||||
|
newBufferSize: 512,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.config = { ...defaultConfig, ...userConfig }
|
this.config = { ...defaultConfig, ...userConfig }
|
||||||
@@ -45,7 +45,7 @@ export class VAD implements BaseVAD {
|
|||||||
*/
|
*/
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.emit('status', { message: 'Loading VAD model...', type: 'info' })
|
this.emit('status', { type: 'info', message: 'Loading VAD model...' })
|
||||||
|
|
||||||
this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', {
|
this.model = await AutoModel.from_pretrained('onnx-community/silero-vad', {
|
||||||
config: { model_type: 'custom' } as any,
|
config: { model_type: 'custom' } as any,
|
||||||
@@ -53,14 +53,24 @@ export class VAD implements BaseVAD {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.isReady = true
|
this.isReady = true
|
||||||
this.emit('status', { message: 'VAD model loaded successfully', type: 'info' })
|
this.emit('status', { type: 'info', message: 'VAD model loaded successfully' })
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
this.emit('status', { message: `Failed to load VAD model: ${error}`, type: 'error' })
|
this.emit('status', { type: 'error', message: `Failed to load VAD model: ${error}` })
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listener
|
||||||
|
*/
|
||||||
|
public on<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void {
|
||||||
|
if (!this.eventListeners[event]) {
|
||||||
|
this.eventListeners[event] = []
|
||||||
|
}
|
||||||
|
this.eventListeners[event]!.push(callback as any)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove event listener
|
* Remove event listener
|
||||||
*/
|
*/
|
||||||
@@ -71,13 +81,14 @@ export class VAD implements BaseVAD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add event listener
|
* Emit event
|
||||||
*/
|
*/
|
||||||
public on<K extends keyof VADEvents>(event: K, callback: VADEventCallback<K>): void {
|
private emit<K extends keyof VADEvents>(event: K, data: VADEvents[K]): void {
|
||||||
if (!this.eventListeners[event]) {
|
if (!this.eventListeners[event])
|
||||||
this.eventListeners[event] = []
|
return
|
||||||
|
for (const callback of this.eventListeners[event]!) {
|
||||||
|
callback(data)
|
||||||
}
|
}
|
||||||
this.eventListeners[event]!.push(callback as any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +144,7 @@ export class VAD implements BaseVAD {
|
|||||||
if (!this.isRecording) {
|
if (!this.isRecording) {
|
||||||
// Speech just started
|
// Speech just started
|
||||||
this.emit('speech-start', undefined)
|
this.emit('speech-start', undefined)
|
||||||
this.emit('status', { message: 'Speech detected', type: 'info' })
|
this.emit('status', { type: 'info', message: 'Speech detected' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -159,31 +170,13 @@ export class VAD implements BaseVAD {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update configuration
|
|
||||||
*/
|
|
||||||
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
|
|
||||||
this.config = { ...this.config, ...newConfig }
|
|
||||||
|
|
||||||
// If buffer size changed, create a new buffer
|
|
||||||
if (newConfig.maxBufferDuration || newConfig.sampleRate) {
|
|
||||||
this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
|
|
||||||
this.bufferPointer = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sample rate tensor if needed
|
|
||||||
if (newConfig.sampleRate) {
|
|
||||||
this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect speech in an audio buffer
|
* Detect speech in an audio buffer
|
||||||
*/
|
*/
|
||||||
private async detectSpeech(buffer: Float32Array): Promise<boolean> {
|
private async detectSpeech(buffer: Float32Array): Promise<boolean> {
|
||||||
const input = new Tensor('float32', buffer, [1, buffer.length])
|
const input = new Tensor('float32', buffer, [1, buffer.length])
|
||||||
|
|
||||||
const { output, stateN } = await (this.inferenceChain = this.inferenceChain.then(() =>
|
const { stateN, output } = await (this.inferenceChain = this.inferenceChain.then(() =>
|
||||||
this.model?.({
|
this.model?.({
|
||||||
input,
|
input,
|
||||||
sr: this.sampleRateTensor,
|
sr: this.sampleRateTensor,
|
||||||
@@ -196,7 +189,7 @@ export class VAD implements BaseVAD {
|
|||||||
// Get the speech probability
|
// Get the speech probability
|
||||||
const speechProb = output.data[0]
|
const speechProb = output.data[0]
|
||||||
|
|
||||||
this.emit('debug', { data: { probability: speechProb }, message: 'VAD score' })
|
this.emit('debug', { message: 'VAD score', data: { probability: speechProb } })
|
||||||
|
|
||||||
// Apply thresholds
|
// Apply thresholds
|
||||||
return (
|
return (
|
||||||
@@ -205,17 +198,6 @@ export class VAD implements BaseVAD {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit event
|
|
||||||
*/
|
|
||||||
private emit<K extends keyof VADEvents>(event: K, data: VADEvents[K]): void {
|
|
||||||
if (!this.eventListeners[event])
|
|
||||||
return
|
|
||||||
for (const callback of this.eventListeners[event]!) {
|
|
||||||
callback(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a complete speech segment
|
* Process a complete speech segment
|
||||||
*/
|
*/
|
||||||
@@ -265,6 +247,24 @@ export class VAD implements BaseVAD {
|
|||||||
this.postSpeechSamples = 0
|
this.postSpeechSamples = 0
|
||||||
this.prevBuffers = []
|
this.prevBuffers = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update configuration
|
||||||
|
*/
|
||||||
|
public updateConfig(newConfig: Partial<BaseVADConfig>): void {
|
||||||
|
this.config = { ...this.config, ...newConfig }
|
||||||
|
|
||||||
|
// If buffer size changed, create a new buffer
|
||||||
|
if (newConfig.maxBufferDuration || newConfig.sampleRate) {
|
||||||
|
this.buffer = new Float32Array(this.config.maxBufferDuration * this.config.sampleRate)
|
||||||
|
this.bufferPointer = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sample rate tensor if needed
|
||||||
|
if (newConfig.sampleRate) {
|
||||||
|
this.sampleRateTensor = new Tensor('int64', [this.config.sampleRate], [])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ export default mergeConfigs([
|
|||||||
...presetWebFontsFonts('fontsource'),
|
...presetWebFontsFonts('fontsource'),
|
||||||
},
|
},
|
||||||
timeouts: {
|
timeouts: {
|
||||||
failure: 10000,
|
|
||||||
warning: 5000,
|
warning: 5000,
|
||||||
|
failure: 10000,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
rules: [
|
rules: [
|
||||||
['transition-colors-none', {
|
['transition-colors-none', {
|
||||||
'transition-duration': '0s',
|
|
||||||
'transition-property': 'color, background-color, border-color, text-color',
|
'transition-property': 'color, background-color, border-color, text-color',
|
||||||
|
'transition-duration': '0s',
|
||||||
}],
|
}],
|
||||||
|
|
||||||
['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
|
['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }],
|
||||||
@@ -27,10 +27,10 @@ export default mergeConfigs([
|
|||||||
['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }],
|
['pl-safe', { 'padding-left': 'env(safe-area-inset-left)' }],
|
||||||
['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }],
|
['pr-safe', { 'padding-right': 'env(safe-area-inset-right)' }],
|
||||||
['p-safe', {
|
['p-safe', {
|
||||||
|
'padding-top': 'env(safe-area-inset-top)',
|
||||||
'padding-bottom': 'env(safe-area-inset-bottom)',
|
'padding-bottom': 'env(safe-area-inset-bottom)',
|
||||||
'padding-left': 'env(safe-area-inset-left)',
|
'padding-left': 'env(safe-area-inset-left)',
|
||||||
'padding-right': 'env(safe-area-inset-right)',
|
'padding-right': 'env(safe-area-inset-right)',
|
||||||
'padding-top': 'env(safe-area-inset-top)',
|
|
||||||
}],
|
}],
|
||||||
],
|
],
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
|
|||||||
Vendored
+1
-1
@@ -3,6 +3,6 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string
|
readonly VITE_APP_TARGET_HUGGINGFACE_SPACE: string
|
||||||
readonly VITE_PLATFORM: 'android' | 'ios' | 'web'
|
readonly VITE_PLATFORM: 'ios' | 'android' | 'web'
|
||||||
// more env variables...
|
// more env variables...
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
|
|
||||||
VITE_SKIP_MKCERT?: string
|
VITE_SKIP_MKCERT?: string
|
||||||
|
VITE_CAP_SYNC_IOS_AFTER_BUILD?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
// import { isEnvTruthy } from '@proj-airi/stage-shared'
|
// import { isEnvTruthy } from '@proj-airi/stage-shared'
|
||||||
function isEnvTruthy(value: null | string | undefined): boolean {
|
function isEnvTruthy(value: string | undefined | null): boolean {
|
||||||
if (value == null)
|
if (value == null)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -36,9 +36,6 @@ const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'package
|
|||||||
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: [
|
exclude: [
|
||||||
// Internal Packages
|
// Internal Packages
|
||||||
@@ -66,6 +63,46 @@ export default defineConfig({
|
|||||||
'@framework/model/cubismmoc',
|
'@framework/model/cubismmoc',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
|
||||||
|
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
||||||
|
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
|
||||||
|
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
|
||||||
|
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
|
||||||
|
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5273,
|
||||||
|
fs: {
|
||||||
|
// To mute errors like:
|
||||||
|
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
|
||||||
|
//
|
||||||
|
// See: https://vite.dev/config/server-options#server-fs-strict
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
warmup: {
|
||||||
|
clientFiles: [
|
||||||
|
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
|
||||||
|
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
|
||||||
|
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
|
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
|
||||||
? []
|
? []
|
||||||
@@ -82,7 +119,6 @@ export default defineConfig({
|
|||||||
Yaml(),
|
Yaml(),
|
||||||
|
|
||||||
VueMacros({
|
VueMacros({
|
||||||
betterDefine: false,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
vue: Vue({
|
vue: Vue({
|
||||||
include: [/\.vue$/, /\.md$/],
|
include: [/\.vue$/, /\.md$/],
|
||||||
@@ -90,24 +126,25 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
vueJsx: false,
|
vueJsx: false,
|
||||||
},
|
},
|
||||||
|
betterDefine: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
VueRouter({
|
VueRouter({
|
||||||
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
|
|
||||||
exclude: ['**/components/**'],
|
|
||||||
extensions: ['.vue', '.md'],
|
extensions: ['.vue', '.md'],
|
||||||
|
dts: resolve(import.meta.dirname, 'src/typed-router.d.ts'),
|
||||||
importMode: 'async',
|
importMode: 'async',
|
||||||
routesFolder: [
|
routesFolder: [
|
||||||
resolve(import.meta.dirname, 'src', 'pages'),
|
resolve(import.meta.dirname, 'src', 'pages'),
|
||||||
{
|
{
|
||||||
|
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||||
exclude: base => [
|
exclude: base => [
|
||||||
...base,
|
...base,
|
||||||
'**/settings/connection/index.vue',
|
'**/settings/connection/index.vue',
|
||||||
'**/settings/modules/beat-sync.vue',
|
'**/settings/modules/beat-sync.vue',
|
||||||
],
|
],
|
||||||
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
exclude: ['**/components/**'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
||||||
@@ -124,35 +161,36 @@ export default defineConfig({
|
|||||||
|
|
||||||
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
||||||
VueI18n({
|
VueI18n({
|
||||||
|
runtimeOnly: true,
|
||||||
compositionOnly: true,
|
compositionOnly: true,
|
||||||
fullInstall: true,
|
fullInstall: true,
|
||||||
runtimeOnly: true,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// https://github.com/webfansplz/vite-plugin-vue-devtools
|
// https://github.com/webfansplz/vite-plugin-vue-devtools
|
||||||
VueDevTools(),
|
VueDevTools(),
|
||||||
|
|
||||||
DownloadLive2DSDK(),
|
DownloadLive2DSDK(),
|
||||||
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
|
||||||
...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '')
|
...isEnvTruthy(process.env.VITE_CAP_SYNC_IOS_AFTER_BUILD ?? '')
|
||||||
? [{
|
? [{
|
||||||
|
name: 'proj-airi:capacitor-sync',
|
||||||
closeBundle: {
|
closeBundle: {
|
||||||
|
sequential: true,
|
||||||
handler() {
|
handler() {
|
||||||
if (this.meta.watchMode) {
|
if (this.meta.watchMode) {
|
||||||
execSync('cap sync ios', { stdio: 'inherit' })
|
execSync('cap sync ios', { stdio: 'inherit' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sequential: true,
|
|
||||||
},
|
},
|
||||||
name: 'proj-airi:capacitor-sync',
|
|
||||||
} as PluginOption]
|
} as PluginOption]
|
||||||
: [],
|
: [],
|
||||||
|
|
||||||
{
|
{
|
||||||
|
name: 'proj-airi:defines',
|
||||||
config(ctx) {
|
config(ctx) {
|
||||||
const define: Record<string, any> = {
|
const define: Record<string, any> = {
|
||||||
'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'',
|
'import.meta.env.RUNTIME_ENVIRONMENT': '\'capacitor\'',
|
||||||
@@ -166,44 +204,6 @@ export default defineConfig({
|
|||||||
|
|
||||||
return { define }
|
return { define }
|
||||||
},
|
},
|
||||||
name: 'proj-airi:defines',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
|
||||||
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
|
|
||||||
'@proj-airi/stage-layouts': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src')),
|
|
||||||
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
|
|
||||||
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
|
|
||||||
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
fs: {
|
|
||||||
// To mute errors like:
|
|
||||||
// The request id ".../node_modules/@fontsource/sniglet/files/sniglet-latin-400-normal.woff" is outside of Vite serving allow list.
|
|
||||||
//
|
|
||||||
// See: https://vite.dev/config/server-options#server-fs-strict
|
|
||||||
strict: false,
|
|
||||||
},
|
|
||||||
host: '0.0.0.0',
|
|
||||||
port: 5273,
|
|
||||||
warmup: {
|
|
||||||
clientFiles: [
|
|
||||||
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`,
|
|
||||||
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src'))}/*.vue`,
|
|
||||||
`${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src'))}/*.vue`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
worker: {
|
|
||||||
format: 'es',
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
inlineDynamicImports: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ else {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
appId: 'ai.moeru.airi',
|
appId: 'ai.moeru.airi',
|
||||||
appImage: {
|
productName: 'AIRI',
|
||||||
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
directories: {
|
||||||
|
output: 'dist',
|
||||||
|
buildResources: 'build',
|
||||||
},
|
},
|
||||||
asar: true,
|
|
||||||
// // For self-publishing, testing, and distribution after modified the code without access to
|
// // For self-publishing, testing, and distribution after modified the code without access to
|
||||||
// // an Apple Developer account, comment and uncomment the following lines.
|
// // an Apple Developer account, comment and uncomment the following lines.
|
||||||
// // Later on when you obtained one, you can set up the necessary certificates and provisioning
|
// // Later on when you obtained one, you can set up the necessary certificates and provisioning
|
||||||
@@ -62,30 +63,6 @@ export default {
|
|||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
|
|
||||||
asarUnpack: [
|
|
||||||
'**/*.node',
|
|
||||||
],
|
|
||||||
directories: {
|
|
||||||
buildResources: 'build',
|
|
||||||
output: 'dist',
|
|
||||||
},
|
|
||||||
dmg: {
|
|
||||||
artifactName: '${productName}-${version}-darwin-${arch}.${ext}',
|
|
||||||
},
|
|
||||||
extraMetadata: {
|
|
||||||
homepage: 'https://airi.moeru.ai/docs/',
|
|
||||||
license: 'MIT',
|
|
||||||
main: 'out/main/index.js',
|
|
||||||
name: 'ai.moeru.airi',
|
|
||||||
repository: 'https://github.com/moeru-ai/airi',
|
|
||||||
},
|
|
||||||
extraResources: [
|
|
||||||
{
|
|
||||||
filter: ['**/*'],
|
|
||||||
from: '../../engines/stage-tamagotchi-godot/out/${os}',
|
|
||||||
to: 'godot-stage',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// const appName = context.packager.appInfo.productFilename
|
// const appName = context.packager.appInfo.productFilename
|
||||||
// await notarize({
|
// await notarize({
|
||||||
// appPath: `${appOutDir}/${appName}.app`,
|
// appPath: `${appOutDir}/${appName}.app`,
|
||||||
@@ -116,45 +93,55 @@ export default {
|
|||||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||||
'!{tsconfig.json}',
|
'!{tsconfig.json}',
|
||||||
],
|
],
|
||||||
linux: {
|
asar: true,
|
||||||
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
asarUnpack: [
|
||||||
category: 'Utility',
|
'**/*.node',
|
||||||
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.',
|
],
|
||||||
executableName: 'airi',
|
extraResources: [
|
||||||
icon: 'build/icons/icon.png',
|
{
|
||||||
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
|
from: '../../engines/stage-tamagotchi-godot/out/${os}',
|
||||||
publish: {
|
to: 'godot-stage',
|
||||||
channel: 'latest-${arch}',
|
filter: ['**/*'],
|
||||||
owner: 'moeru-ai',
|
|
||||||
provider: 'github',
|
|
||||||
repo: 'airi',
|
|
||||||
},
|
},
|
||||||
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
|
],
|
||||||
target: [
|
extraMetadata: {
|
||||||
'deb',
|
name: 'ai.moeru.airi',
|
||||||
'rpm',
|
main: 'out/main/index.js',
|
||||||
],
|
homepage: 'https://airi.moeru.ai/docs/',
|
||||||
|
repository: 'https://github.com/moeru-ai/airi',
|
||||||
|
license: 'MIT',
|
||||||
|
},
|
||||||
|
win: {
|
||||||
|
executableName: 'airi',
|
||||||
|
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
|
||||||
|
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
|
||||||
|
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
|
||||||
|
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
|
||||||
|
publish: {
|
||||||
|
provider: 'github',
|
||||||
|
owner: 'moeru-ai',
|
||||||
|
repo: 'airi',
|
||||||
|
channel: 'latest-${arch}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
|
||||||
|
shortcutName: '${productName}',
|
||||||
|
uninstallDisplayName: '${productName}',
|
||||||
|
createDesktopShortcut: 'always',
|
||||||
|
deleteAppDataOnUninstall: true,
|
||||||
|
oneClick: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
runAfterFinish: true,
|
||||||
},
|
},
|
||||||
mac: {
|
mac: {
|
||||||
entitlementsInherit: 'build/entitlements.mac.plist',
|
entitlementsInherit: 'build/entitlements.mac.plist',
|
||||||
executableName: 'airi',
|
|
||||||
extendInfo: {
|
|
||||||
NSCameraUsageDescription: 'AIRI requires camera access for vision understanding',
|
|
||||||
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
|
|
||||||
NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device',
|
|
||||||
},
|
|
||||||
// For self-publishing, testing, and distribution after modified the code without access to
|
|
||||||
// an Apple Developer account, comment and uncomment the following 4 lines.
|
|
||||||
// Later on when you obtained one, you can set up the necessary certificates and provisioning
|
|
||||||
// profiles to enable these security features.
|
|
||||||
// hardenedRuntime: false,
|
|
||||||
hardenedRuntime: true,
|
|
||||||
icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns',
|
|
||||||
// notarize: false,
|
|
||||||
notarize: true,
|
|
||||||
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
|
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
|
||||||
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
|
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
|
||||||
publish: {
|
publish: {
|
||||||
|
provider: 'github',
|
||||||
|
owner: 'moeru-ai',
|
||||||
|
repo: 'airi',
|
||||||
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
|
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
|
||||||
// `${arch}` before it writes any publish metadata, and electron-updater later
|
// `${arch}` before it writes any publish metadata, and electron-updater later
|
||||||
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
|
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
|
||||||
@@ -218,35 +205,48 @@ export default {
|
|||||||
// - Linux x64 -> `latest-x64-linux.yml`
|
// - Linux x64 -> `latest-x64-linux.yml`
|
||||||
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
|
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
|
||||||
channel: 'latest-${arch}',
|
channel: 'latest-${arch}',
|
||||||
owner: 'moeru-ai',
|
|
||||||
provider: 'github',
|
|
||||||
repo: 'airi',
|
|
||||||
},
|
},
|
||||||
|
extendInfo: {
|
||||||
|
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
|
||||||
|
NSSpeechRecognitionUsageDescription: 'AIRI uses Apple Speech to transcribe voice interactions on this device',
|
||||||
|
NSCameraUsageDescription: 'AIRI requires camera access for vision understanding',
|
||||||
|
},
|
||||||
|
// For self-publishing, testing, and distribution after modified the code without access to
|
||||||
|
// an Apple Developer account, comment and uncomment the following 4 lines.
|
||||||
|
// Later on when you obtained one, you can set up the necessary certificates and provisioning
|
||||||
|
// profiles to enable these security features.
|
||||||
|
// hardenedRuntime: false,
|
||||||
|
hardenedRuntime: true,
|
||||||
|
// notarize: false,
|
||||||
|
notarize: true,
|
||||||
|
executableName: 'airi',
|
||||||
|
icon: useIconFormattedMacAppIcon ? 'icon.icon' : 'icon.icns',
|
||||||
|
},
|
||||||
|
dmg: {
|
||||||
|
artifactName: '${productName}-${version}-darwin-${arch}.${ext}',
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
target: [
|
||||||
|
'deb',
|
||||||
|
'rpm',
|
||||||
|
],
|
||||||
|
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
|
||||||
|
publish: {
|
||||||
|
provider: 'github',
|
||||||
|
owner: 'moeru-ai',
|
||||||
|
repo: 'airi',
|
||||||
|
channel: 'latest-${arch}',
|
||||||
|
},
|
||||||
|
category: 'Utility',
|
||||||
|
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
|
||||||
|
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.',
|
||||||
|
executableName: 'airi',
|
||||||
|
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
||||||
|
icon: 'build/icons/icon.png',
|
||||||
|
},
|
||||||
|
appImage: {
|
||||||
|
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
|
||||||
},
|
},
|
||||||
npmRebuild: false,
|
npmRebuild: false,
|
||||||
nsis: {
|
|
||||||
allowToChangeInstallationDirectory: true,
|
|
||||||
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
|
|
||||||
createDesktopShortcut: 'always',
|
|
||||||
deleteAppDataOnUninstall: true,
|
|
||||||
oneClick: false,
|
|
||||||
runAfterFinish: true,
|
|
||||||
shortcutName: '${productName}',
|
|
||||||
uninstallDisplayName: '${productName}',
|
|
||||||
},
|
|
||||||
productName: 'AIRI',
|
|
||||||
win: {
|
|
||||||
executableName: 'airi',
|
|
||||||
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
|
|
||||||
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
|
|
||||||
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
|
|
||||||
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
|
|
||||||
publish: {
|
|
||||||
channel: 'latest-${arch}',
|
|
||||||
owner: 'moeru-ai',
|
|
||||||
provider: 'github',
|
|
||||||
repo: 'airi',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
} satisfies Configuration
|
} satisfies Configuration
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
||||||
'@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')),
|
|
||||||
'@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')),
|
'@proj-airi/server-runtime/server': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'server', 'index.ts')),
|
||||||
|
'@proj-airi/server-runtime': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-runtime', 'src', 'index.ts')),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -83,8 +83,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: {
|
entry: {
|
||||||
'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')),
|
|
||||||
'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')),
|
'index': resolve(join(import.meta.dirname, 'src', 'preload', 'index.ts')),
|
||||||
|
'beat-sync': resolve(join(import.meta.dirname, 'src', 'preload', 'beat-sync.ts')),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,8 +100,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
input: {
|
input: {
|
||||||
'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')),
|
|
||||||
'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')),
|
'main': resolve(join(import.meta.dirname, 'src', 'renderer', 'index.html')),
|
||||||
|
'beat-sync': resolve(join(import.meta.dirname, 'src', 'renderer', 'beat-sync.html')),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -135,102 +135,18 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
|
||||||
Info(),
|
|
||||||
|
|
||||||
{
|
|
||||||
config(ctx) {
|
|
||||||
const define: Record<string, any> = {
|
|
||||||
'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'',
|
|
||||||
}
|
|
||||||
if (ctx.mode === 'development') {
|
|
||||||
define['import.meta.env.URL_MODE'] = '\'server\''
|
|
||||||
}
|
|
||||||
if (ctx.mode === 'production') {
|
|
||||||
define['import.meta.env.URL_MODE'] = '\'file\''
|
|
||||||
}
|
|
||||||
|
|
||||||
return { define }
|
|
||||||
},
|
|
||||||
name: 'proj-airi:defines',
|
|
||||||
},
|
|
||||||
|
|
||||||
Inspect(),
|
|
||||||
|
|
||||||
Yaml(),
|
|
||||||
|
|
||||||
VueMacros({
|
|
||||||
betterDefine: false,
|
|
||||||
plugins: {
|
|
||||||
vue: Vue({
|
|
||||||
include: [/\.vue$/, /\.md$/],
|
|
||||||
...templateCompilerOptions,
|
|
||||||
}),
|
|
||||||
vueJsx: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
VueRouter({
|
|
||||||
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
|
|
||||||
exclude: ['**/components/**'],
|
|
||||||
routesFolder: [
|
|
||||||
{
|
|
||||||
exclude: base => [
|
|
||||||
...base,
|
|
||||||
'**/settings/account/index.vue',
|
|
||||||
'**/settings/connection/index.vue',
|
|
||||||
'**/settings/data/index.vue',
|
|
||||||
'**/settings/models/index.vue',
|
|
||||||
'**/settings/system/general.vue',
|
|
||||||
'**/settings/modules/mcp.vue',
|
|
||||||
'**/devtools/index.vue',
|
|
||||||
'**/settings/index.vue',
|
|
||||||
],
|
|
||||||
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
|
||||||
},
|
|
||||||
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
VitePluginVueDevTools(),
|
|
||||||
|
|
||||||
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
|
||||||
Layouts({
|
|
||||||
layoutsDirs: [
|
|
||||||
resolve(import.meta.dirname, 'src', 'renderer', 'layouts'),
|
|
||||||
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'),
|
|
||||||
],
|
|
||||||
pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')],
|
|
||||||
}),
|
|
||||||
|
|
||||||
UnoCss(),
|
|
||||||
|
|
||||||
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
|
||||||
VueI18n({
|
|
||||||
compositionOnly: true,
|
|
||||||
fullInstall: true,
|
|
||||||
runtimeOnly: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
DownloadLive2DSDK(),
|
|
||||||
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
|
||||||
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
|
||||||
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
|
||||||
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { cacheDir: sharedCacheDir, parentDir: stageUIAssetsRoot }),
|
|
||||||
],
|
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
|
||||||
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
|
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
|
||||||
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
|
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
|
||||||
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
|
|
||||||
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
|
|
||||||
// NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown
|
// NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown
|
||||||
// concatenates sub-paths without a file extension, so bare .ts files at the
|
// concatenates sub-paths without a file extension, so bare .ts files at the
|
||||||
// stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases
|
// stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases
|
||||||
// for each such file that the renderer imports from @proj-airi/stage-ui.
|
// for each such file that the renderer imports from @proj-airi/stage-ui.
|
||||||
'@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')),
|
'@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')),
|
||||||
|
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
|
||||||
|
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
|
||||||
|
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -258,5 +174,89 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
Info(),
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'proj-airi:defines',
|
||||||
|
config(ctx) {
|
||||||
|
const define: Record<string, any> = {
|
||||||
|
'import.meta.env.RUNTIME_ENVIRONMENT': '\'electron\'',
|
||||||
|
}
|
||||||
|
if (ctx.mode === 'development') {
|
||||||
|
define['import.meta.env.URL_MODE'] = '\'server\''
|
||||||
|
}
|
||||||
|
if (ctx.mode === 'production') {
|
||||||
|
define['import.meta.env.URL_MODE'] = '\'file\''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { define }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Inspect(),
|
||||||
|
|
||||||
|
Yaml(),
|
||||||
|
|
||||||
|
VueMacros({
|
||||||
|
plugins: {
|
||||||
|
vue: Vue({
|
||||||
|
include: [/\.vue$/, /\.md$/],
|
||||||
|
...templateCompilerOptions,
|
||||||
|
}),
|
||||||
|
vueJsx: false,
|
||||||
|
},
|
||||||
|
betterDefine: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
VueRouter({
|
||||||
|
dts: resolve(import.meta.dirname, 'src/renderer/typed-router.d.ts'),
|
||||||
|
routesFolder: [
|
||||||
|
{
|
||||||
|
src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'),
|
||||||
|
exclude: base => [
|
||||||
|
...base,
|
||||||
|
'**/settings/account/index.vue',
|
||||||
|
'**/settings/connection/index.vue',
|
||||||
|
'**/settings/data/index.vue',
|
||||||
|
'**/settings/models/index.vue',
|
||||||
|
'**/settings/system/general.vue',
|
||||||
|
'**/settings/modules/mcp.vue',
|
||||||
|
'**/devtools/index.vue',
|
||||||
|
'**/settings/index.vue',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve(import.meta.dirname, 'src', 'renderer', 'pages'),
|
||||||
|
],
|
||||||
|
exclude: ['**/components/**'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
VitePluginVueDevTools(),
|
||||||
|
|
||||||
|
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts
|
||||||
|
Layouts({
|
||||||
|
layoutsDirs: [
|
||||||
|
resolve(import.meta.dirname, 'src', 'renderer', 'layouts'),
|
||||||
|
resolve(import.meta.dirname, '..', '..', 'packages', 'stage-layouts', 'src', 'layouts'),
|
||||||
|
],
|
||||||
|
pagesDirs: [resolve(import.meta.dirname, 'src', 'renderer', 'pages')],
|
||||||
|
}),
|
||||||
|
|
||||||
|
UnoCss(),
|
||||||
|
|
||||||
|
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
||||||
|
VueI18n({
|
||||||
|
runtimeOnly: true,
|
||||||
|
compositionOnly: true,
|
||||||
|
fullInstall: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
DownloadLive2DSDK(),
|
||||||
|
Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
Download('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', 'hiyori_pro_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', 'AvatarSample_A.vrm', 'vrm/models/AvatarSample-A', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
Download('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', 'AvatarSample_B.vrm', 'vrm/models/AvatarSample-B', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ async function main() {
|
|||||||
const args = cli.parse()
|
const args = cli.parse()
|
||||||
|
|
||||||
const argOptions = args.options as {
|
const argOptions = args.options as {
|
||||||
|
release: boolean
|
||||||
autoTag: boolean
|
autoTag: boolean
|
||||||
|
tag: string[]
|
||||||
getBundleName: boolean
|
getBundleName: boolean
|
||||||
getFilename: string[]
|
|
||||||
getOutputFilename: string[]
|
|
||||||
getProductName: boolean
|
getProductName: boolean
|
||||||
getVersion: boolean
|
getVersion: boolean
|
||||||
release: boolean
|
getFilename: string[]
|
||||||
tag: string[]
|
getOutputFilename: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = args.args[0]
|
const target = args.args[0]
|
||||||
@@ -94,7 +94,7 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (argOptions.getVersion) {
|
if (argOptions.getVersion) {
|
||||||
const version = await getVersion({ autoTag: argOptions.autoTag, release: argOptions.release, tag: argOptions.tag })
|
const version = await getVersion({ release: argOptions.release, autoTag: argOptions.autoTag, tag: argOptions.tag })
|
||||||
console.info(version)
|
console.info(version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ afterEach(() => {
|
|||||||
|
|
||||||
function createMockSocket() {
|
function createMockSocket() {
|
||||||
const socket = new EventEmitter() as EventEmitter & {
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
addEventListener: (event: string, listener: (...args: any[]) => void) => void
|
|
||||||
close: ReturnType<typeof vi.fn>
|
|
||||||
send: ReturnType<typeof vi.fn>
|
send: ReturnType<typeof vi.fn>
|
||||||
|
close: ReturnType<typeof vi.fn>
|
||||||
|
addEventListener: (event: string, listener: (...args: any[]) => void) => void
|
||||||
}
|
}
|
||||||
socket.send = vi.fn()
|
socket.send = vi.fn()
|
||||||
socket.close = vi.fn(() => {
|
socket.close = vi.fn(() => {
|
||||||
|
|||||||
@@ -22,24 +22,16 @@ interface DebugTarget {
|
|||||||
webSocketDebuggerUrl?: string
|
webSocketDebuggerUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpApplyResult {
|
|
||||||
failed: Array<{ error: string, name: string }>
|
|
||||||
skipped: Array<{ name: string, reason: string }>
|
|
||||||
started: Array<{ name: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface McpResult {
|
interface McpResult {
|
||||||
content?: unknown[]
|
content?: unknown[]
|
||||||
isError?: boolean
|
|
||||||
structuredContent?: Record<string, unknown>
|
structuredContent?: Record<string, unknown>
|
||||||
|
isError?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpRuntimeStatus {
|
interface McpApplyResult {
|
||||||
servers: Array<{
|
started: Array<{ name: string }>
|
||||||
lastError?: string
|
failed: Array<{ name: string, error: string }>
|
||||||
name: string
|
skipped: Array<{ name: string, reason: string }>
|
||||||
state: 'error' | 'running' | 'stopped'
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpToolDescriptor {
|
interface McpToolDescriptor {
|
||||||
@@ -48,6 +40,14 @@ interface McpToolDescriptor {
|
|||||||
toolName: string
|
toolName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface McpRuntimeStatus {
|
||||||
|
servers: Array<{
|
||||||
|
name: string
|
||||||
|
state: 'running' | 'stopped' | 'error'
|
||||||
|
lastError?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const repoDir = resolve(packageDir, '../..')
|
const repoDir = resolve(packageDir, '../..')
|
||||||
const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
||||||
@@ -79,15 +79,70 @@ const smokeHtml = `<!doctype html>
|
|||||||
|
|
||||||
const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}`
|
const smokeUrl = `data:text/html;charset=utf-8,${encodeURIComponent(smokeHtml)}`
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): asserts condition {
|
||||||
|
if (!condition)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolvePort, reject) => {
|
||||||
|
const server = createServer()
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address()
|
||||||
|
server.close(() => {
|
||||||
|
if (typeof address === 'object' && address?.port) {
|
||||||
|
resolvePort(address.port)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reject(new Error('failed to allocate debug port'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
server.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(
|
||||||
|
label: string,
|
||||||
|
probe: () => Promise<T | undefined> | T | undefined,
|
||||||
|
timeoutMs: number,
|
||||||
|
intervalMs: number,
|
||||||
|
): Promise<T> {
|
||||||
|
const start = Date.now()
|
||||||
|
let lastError: unknown
|
||||||
|
|
||||||
|
while ((Date.now() - start) < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const value = await probe()
|
||||||
|
if (value !== undefined)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
await sleep(intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||||
|
throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
export class CdpClient {
|
export class CdpClient {
|
||||||
|
private socket?: WebSocket
|
||||||
private nextId = 1
|
private nextId = 1
|
||||||
private pending = new Map<number, {
|
private pending = new Map<number, {
|
||||||
reject: (error: Error) => void
|
|
||||||
resolve: (value: Record<string, unknown>) => void
|
resolve: (value: Record<string, unknown>) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
private socket?: WebSocket
|
|
||||||
|
|
||||||
constructor(socket: WebSocket) {
|
constructor(socket: WebSocket) {
|
||||||
this.socket = socket
|
this.socket = socket
|
||||||
this.socket.addEventListener('message', (event) => {
|
this.socket.addEventListener('message', (event) => {
|
||||||
@@ -125,16 +180,24 @@ export class CdpClient {
|
|||||||
return new CdpClient(socket)
|
return new CdpClient(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
this.failPending('CDP socket closed')
|
if (!this.socket) {
|
||||||
this.socket?.close()
|
throw new Error('CDP socket is closed')
|
||||||
this.socket = undefined
|
}
|
||||||
|
|
||||||
|
const id = this.nextId++
|
||||||
|
const promise = new Promise<Record<string, unknown>>((resolveMessage, reject) => {
|
||||||
|
this.pending.set(id, { resolve: resolveMessage, reject })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params: params ?? {} }))
|
||||||
|
return await promise
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluate<T>(expression: string): Promise<T> {
|
async evaluate<T>(expression: string): Promise<T> {
|
||||||
const response = await this.send('Runtime.evaluate', {
|
const response = await this.send('Runtime.evaluate', {
|
||||||
awaitPromise: true,
|
|
||||||
expression,
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
})
|
})
|
||||||
const result = response.result
|
const result = response.result
|
||||||
@@ -153,18 +216,10 @@ export class CdpClient {
|
|||||||
return remoteObject.value as T
|
return remoteObject.value as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
close() {
|
||||||
if (!this.socket) {
|
this.failPending('CDP socket closed')
|
||||||
throw new Error('CDP socket is closed')
|
this.socket?.close()
|
||||||
}
|
this.socket = undefined
|
||||||
|
|
||||||
const id = this.nextId++
|
|
||||||
const promise = new Promise<Record<string, unknown>>((resolveMessage, reject) => {
|
|
||||||
this.pending.set(id, { reject, resolve: resolveMessage })
|
|
||||||
})
|
|
||||||
|
|
||||||
this.socket.send(JSON.stringify({ id, method, params: params ?? {} }))
|
|
||||||
return await promise
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private failPending(reason: string) {
|
private failPending(reason: string) {
|
||||||
@@ -179,17 +234,92 @@ export class CdpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): asserts condition {
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
if (!condition)
|
const response = await fetch(url)
|
||||||
throw new Error(message)
|
if (!response.ok)
|
||||||
|
throw new Error(`${url} returned ${response.status}`)
|
||||||
|
return await response.json() as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callOverlayMcpTool(client: CdpClient, name: string, args: Record<string, unknown> = {}): Promise<McpResult> {
|
async function prepareMcpConfig() {
|
||||||
const result = await client.evaluate<McpResult>(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ arguments: args, name })})`)
|
await mkdir(userDataDir, { recursive: true })
|
||||||
if (result.isError) {
|
await mkdir(mcpSessionRoot, { recursive: true })
|
||||||
throw new Error(`${name} returned isError=true`)
|
|
||||||
|
const mcpEnv: Record<string, string> = {
|
||||||
|
PATH: env.PATH || '',
|
||||||
|
HOME: env.HOME || '',
|
||||||
|
SHELL: env.SHELL || '',
|
||||||
|
LANG: env.LANG || 'en_US.UTF-8',
|
||||||
|
TMPDIR: env.TMPDIR || '',
|
||||||
|
COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local',
|
||||||
|
COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never',
|
||||||
|
COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome',
|
||||||
|
COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`,
|
||||||
|
COMPUTER_USE_SESSION_ROOT: mcpSessionRoot,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) {
|
||||||
|
const value = env[optionalEnvName]?.trim()
|
||||||
|
if (value) {
|
||||||
|
mcpEnv[optionalEnvName] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
mcpServers: {
|
||||||
|
computer_use: {
|
||||||
|
command: 'pnpm',
|
||||||
|
args: ['-F', '@proj-airi/computer-use-mcp', 'start'],
|
||||||
|
cwd: repoDir,
|
||||||
|
enabled: true,
|
||||||
|
env: mcpEnv,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSmokePrerequisites() {
|
||||||
|
if (typeof WebSocket !== 'function') {
|
||||||
|
throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingOutputs: string[] = []
|
||||||
|
for (const relativePath of requiredWorkspaceBuildOutputs) {
|
||||||
|
try {
|
||||||
|
await access(resolve(repoDir, relativePath))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
missingOutputs.push(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingOutputs.length === 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
throw new Error([
|
||||||
|
'APP_START_FAILED: required workspace build outputs are missing.',
|
||||||
|
`Missing: ${missingOutputs.join(', ')}`,
|
||||||
|
'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.',
|
||||||
|
'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build',
|
||||||
|
].join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRemoteDebug(debugPort: number): Promise<string> {
|
||||||
|
const version = await waitFor('Electron remote debug endpoint', async () => {
|
||||||
|
const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`)
|
||||||
|
return data.webSocketDebuggerUrl
|
||||||
|
}, 120_000, 500)
|
||||||
|
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOverlayTarget(debugPort: number): Promise<DebugTarget> {
|
||||||
|
return await waitFor('desktop overlay debug target', async () => {
|
||||||
|
const targets = await fetchJson<DebugTarget[]>(`http://127.0.0.1:${debugPort}/json/list`)
|
||||||
|
return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay'))
|
||||||
|
}, 120_000, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
|
async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
|
||||||
@@ -208,6 +338,14 @@ async function connectOverlayClient(debugPort: number): Promise<CdpClient> {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function callOverlayMcpTool(client: CdpClient, name: string, args: Record<string, unknown> = {}): Promise<McpResult> {
|
||||||
|
const result = await client.evaluate<McpResult>(`window.__AIRI_DESKTOP_OVERLAY_SMOKE__.callMcpTool(${JSON.stringify({ name, arguments: args })})`)
|
||||||
|
if (result.isError) {
|
||||||
|
throw new Error(`${name} returned isError=true`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
|
async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
|
||||||
const applyResult = await client.evaluate<McpApplyResult>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()')
|
const applyResult = await client.evaluate<McpApplyResult>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.applyAndRestartMcp()')
|
||||||
const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use')
|
const failedComputerUse = applyResult.failed.find(item => item.name === 'computer_use')
|
||||||
@@ -235,68 +373,91 @@ async function ensureOverlayMcpServerReady(client: CdpClient): Promise<void> {
|
|||||||
}, 30_000, 500)
|
}, 30_000, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSmokePrerequisites() {
|
function requireStructuredContent(result: McpResult, label: string): Record<string, unknown> {
|
||||||
if (typeof WebSocket !== 'function') {
|
if (!isRecord(result.structuredContent))
|
||||||
throw new TypeError('APP_START_FAILED: WebSocket is unavailable in this Node runtime. Run through the package script or set NODE_OPTIONS=--experimental-websocket.')
|
throw new Error(`${label} missing structuredContent`)
|
||||||
}
|
|
||||||
|
|
||||||
const missingOutputs: string[] = []
|
if (result.structuredContent.status && result.structuredContent.status !== 'ok')
|
||||||
for (const relativePath of requiredWorkspaceBuildOutputs) {
|
throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`)
|
||||||
try {
|
|
||||||
await access(resolve(repoDir, relativePath))
|
return result.structuredContent
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
missingOutputs.push(relativePath)
|
function requireRunState(result: McpResult, label: string): Record<string, unknown> {
|
||||||
|
const structuredContent = requireStructuredContent(result, label)
|
||||||
|
if (!isRecord(structuredContent.runState))
|
||||||
|
throw new Error(`${label} missing runState`)
|
||||||
|
return structuredContent.runState
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams {
|
||||||
|
const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
detached: true,
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
APP_REMOTE_DEBUG: 'true',
|
||||||
|
APP_REMOTE_DEBUG_PORT: String(debugPort),
|
||||||
|
APP_REMOTE_DEBUG_NO_OPEN: 'true',
|
||||||
|
APP_USER_DATA_PATH: userDataDir,
|
||||||
|
AIRI_DESKTOP_OVERLAY: '1',
|
||||||
|
AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1',
|
||||||
|
},
|
||||||
|
stdio: 'pipe',
|
||||||
|
})
|
||||||
|
|
||||||
|
const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' })
|
||||||
|
const capture = (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString('utf-8')
|
||||||
|
stageLogStream.write(text)
|
||||||
|
for (const line of text.split(/\r?\n/u)) {
|
||||||
|
if (line.includes(desktopOverlayPollHeartbeatMarker)) {
|
||||||
|
heartbeatLines.push(line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stageProcess.stdout.on('data', capture)
|
||||||
|
stageProcess.stderr.on('data', capture)
|
||||||
|
stageProcess.on('close', () => stageLogStream.end())
|
||||||
|
|
||||||
if (missingOutputs.length === 0)
|
return stageProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) {
|
||||||
|
if (!stageProcess || stageProcess.exitCode !== null)
|
||||||
return
|
return
|
||||||
|
|
||||||
throw new Error([
|
const signalStageProcessGroup = (signal: NodeJS.Signals) => {
|
||||||
'APP_START_FAILED: required workspace build outputs are missing.',
|
try {
|
||||||
`Missing: ${missingOutputs.join(', ')}`,
|
if (stageProcess.pid) {
|
||||||
'Build stage-tamagotchi dependencies manually before this smoke. The smoke command does not auto-build them to avoid saturating the local machine.',
|
killProcess(-stageProcess.pid, signal)
|
||||||
'Suggested command: pnpm -F \'@proj-airi/stage-tamagotchi^...\' --if-present build',
|
return
|
||||||
].join(' '))
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Fall back to the pnpm wrapper process if process-group signalling is
|
||||||
|
// unavailable. The smoke starts a detached group to make this reliable on
|
||||||
|
// macOS, but the fallback keeps the helper safe on other local setups.
|
||||||
|
}
|
||||||
|
|
||||||
|
stageProcess.kill(signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
signalStageProcessGroup('SIGTERM')
|
||||||
|
await Promise.race([
|
||||||
|
new Promise(resolve => stageProcess.once('exit', resolve)),
|
||||||
|
sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise<never> {
|
||||||
const response = await fetch(url)
|
return new Promise((_, reject) => {
|
||||||
if (!response.ok)
|
stageProcess.once('exit', (code, signal) => {
|
||||||
throw new Error(`${url} returned ${response.status}`)
|
reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`))
|
||||||
return await response.json() as T
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findAvailablePort(): Promise<number> {
|
|
||||||
return await new Promise((resolvePort, reject) => {
|
|
||||||
const server = createServer()
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
const address = server.address()
|
|
||||||
server.close(() => {
|
|
||||||
if (typeof address === 'object' && address?.port) {
|
|
||||||
resolvePort(address.port)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
reject(new Error('failed to allocate debug port'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
server.on('error', reject)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findOverlayTarget(debugPort: number): Promise<DebugTarget> {
|
|
||||||
return await waitFor('desktop overlay debug target', async () => {
|
|
||||||
const targets = await fetchJson<DebugTarget[]>(`http://127.0.0.1:${debugPort}/json/list`)
|
|
||||||
return targets.find(target => target.type === 'page' && target.url.includes('#/desktop-overlay'))
|
|
||||||
}, 120_000, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
let stageProcess: ChildProcessWithoutNullStreams | undefined
|
let stageProcess: ChildProcessWithoutNullStreams | undefined
|
||||||
let overlayClient: CdpClient | undefined
|
let overlayClient: CdpClient | undefined
|
||||||
@@ -344,7 +505,7 @@ async function main() {
|
|||||||
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
|
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const readiness = await overlayClient.evaluate<{ error?: string, state: 'booting' | 'degraded' | 'ready' }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()')
|
const readiness = await overlayClient.evaluate<{ state: 'booting' | 'ready' | 'degraded', error?: string }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()')
|
||||||
if (readiness.state !== 'ready') {
|
if (readiness.state !== 'ready') {
|
||||||
throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`)
|
throw new Error(`OVERLAY_READINESS_DEGRADED: state=${readiness.state}${readiness.error ? ` error=${readiness.error}` : ''}`)
|
||||||
}
|
}
|
||||||
@@ -360,8 +521,8 @@ async function main() {
|
|||||||
)
|
)
|
||||||
const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState)
|
const candidateId = selectDesktopOverlaySmokeCandidateId(preClickRunState)
|
||||||
await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', {
|
await callOverlayMcpTool(overlayClient, 'computer_use::desktop_click_target', {
|
||||||
button: 'left',
|
|
||||||
candidateId,
|
candidateId,
|
||||||
|
button: 'left',
|
||||||
clickCount: 1,
|
clickCount: 1,
|
||||||
})
|
})
|
||||||
const postClickRunState = requireRunState(
|
const postClickRunState = requireRunState(
|
||||||
@@ -383,10 +544,10 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.info(JSON.stringify({
|
console.info(JSON.stringify({
|
||||||
heartbeat,
|
|
||||||
ok: true,
|
ok: true,
|
||||||
reportDir,
|
reportDir,
|
||||||
stageLogPath,
|
stageLogPath,
|
||||||
|
heartbeat,
|
||||||
}, null, 2))
|
}, null, 2))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -396,168 +557,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareMcpConfig() {
|
|
||||||
await mkdir(userDataDir, { recursive: true })
|
|
||||||
await mkdir(mcpSessionRoot, { recursive: true })
|
|
||||||
|
|
||||||
const mcpEnv: Record<string, string> = {
|
|
||||||
COMPUTER_USE_APPROVAL_MODE: env.COMPUTER_USE_SMOKE_APPROVAL_MODE || env.COMPUTER_USE_APPROVAL_MODE || 'never',
|
|
||||||
COMPUTER_USE_EXECUTOR: env.COMPUTER_USE_SMOKE_EXECUTOR || env.COMPUTER_USE_EXECUTOR || 'macos-local',
|
|
||||||
COMPUTER_USE_OPENABLE_APPS: env.COMPUTER_USE_OPENABLE_APPS || 'Terminal,Cursor,Google Chrome',
|
|
||||||
COMPUTER_USE_SESSION_ROOT: mcpSessionRoot,
|
|
||||||
COMPUTER_USE_SESSION_TAG: `desktop-overlay-live-window-smoke-${runId}`,
|
|
||||||
HOME: env.HOME || '',
|
|
||||||
LANG: env.LANG || 'en_US.UTF-8',
|
|
||||||
PATH: env.PATH || '',
|
|
||||||
SHELL: env.SHELL || '',
|
|
||||||
TMPDIR: env.TMPDIR || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const optionalEnvName of ['PNPM_HOME', 'COREPACK_HOME']) {
|
|
||||||
const value = env[optionalEnvName]?.trim()
|
|
||||||
if (value) {
|
|
||||||
mcpEnv[optionalEnvName] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
mcpServers: {
|
|
||||||
computer_use: {
|
|
||||||
args: ['-F', '@proj-airi/computer-use-mcp', 'start'],
|
|
||||||
command: 'pnpm',
|
|
||||||
cwd: repoDir,
|
|
||||||
enabled: true,
|
|
||||||
env: mcpEnv,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(mcpConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectWhenStageExits(stageProcess: ChildProcessWithoutNullStreams): Promise<never> {
|
|
||||||
return new Promise((_, reject) => {
|
|
||||||
stageProcess.once('exit', (code, signal) => {
|
|
||||||
reject(new Error(`stage-tamagotchi exited with code=${String(code)} signal=${String(signal)}`))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireRunState(result: McpResult, label: string): Record<string, unknown> {
|
|
||||||
const structuredContent = requireStructuredContent(result, label)
|
|
||||||
if (!isRecord(structuredContent.runState))
|
|
||||||
throw new Error(`${label} missing runState`)
|
|
||||||
return structuredContent.runState
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireStructuredContent(result: McpResult, label: string): Record<string, unknown> {
|
|
||||||
if (!isRecord(result.structuredContent))
|
|
||||||
throw new Error(`${label} missing structuredContent`)
|
|
||||||
|
|
||||||
if (result.structuredContent.status && result.structuredContent.status !== 'ok')
|
|
||||||
throw new Error(`${label} expected status=ok, got ${String(result.structuredContent.status)}`)
|
|
||||||
|
|
||||||
return result.structuredContent
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
function startStage(debugPort: number, heartbeatLines: string[]): ChildProcessWithoutNullStreams {
|
|
||||||
const stageProcess = spawn('pnpm', ['-F', '@proj-airi/stage-tamagotchi', 'dev'], {
|
|
||||||
cwd: repoDir,
|
|
||||||
detached: true,
|
|
||||||
env: {
|
|
||||||
...env,
|
|
||||||
AIRI_DESKTOP_OVERLAY: '1',
|
|
||||||
AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT: '1',
|
|
||||||
APP_REMOTE_DEBUG: 'true',
|
|
||||||
APP_REMOTE_DEBUG_NO_OPEN: 'true',
|
|
||||||
APP_REMOTE_DEBUG_PORT: String(debugPort),
|
|
||||||
APP_USER_DATA_PATH: userDataDir,
|
|
||||||
},
|
|
||||||
stdio: 'pipe',
|
|
||||||
})
|
|
||||||
|
|
||||||
const stageLogStream = createWriteStream(stageLogPath, { flags: 'a' })
|
|
||||||
const capture = (chunk: Buffer) => {
|
|
||||||
const text = chunk.toString('utf-8')
|
|
||||||
stageLogStream.write(text)
|
|
||||||
for (const line of text.split(/\r?\n/u)) {
|
|
||||||
if (line.includes(desktopOverlayPollHeartbeatMarker)) {
|
|
||||||
heartbeatLines.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stageProcess.stdout.on('data', capture)
|
|
||||||
stageProcess.stderr.on('data', capture)
|
|
||||||
stageProcess.on('close', () => stageLogStream.end())
|
|
||||||
|
|
||||||
return stageProcess
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopStage(stageProcess: ChildProcessWithoutNullStreams | undefined) {
|
|
||||||
if (!stageProcess || stageProcess.exitCode !== null)
|
|
||||||
return
|
|
||||||
|
|
||||||
const signalStageProcessGroup = (signal: NodeJS.Signals) => {
|
|
||||||
try {
|
|
||||||
if (stageProcess.pid) {
|
|
||||||
killProcess(-stageProcess.pid, signal)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Fall back to the pnpm wrapper process if process-group signalling is
|
|
||||||
// unavailable. The smoke starts a detached group to make this reliable on
|
|
||||||
// macOS, but the fallback keeps the helper safe on other local setups.
|
|
||||||
}
|
|
||||||
|
|
||||||
stageProcess.kill(signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
signalStageProcessGroup('SIGTERM')
|
|
||||||
await Promise.race([
|
|
||||||
new Promise(resolve => stageProcess.once('exit', resolve)),
|
|
||||||
sleep(5_000).then(() => signalStageProcessGroup('SIGKILL')),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitFor<T>(
|
|
||||||
label: string,
|
|
||||||
probe: () => Promise<T | undefined> | T | undefined,
|
|
||||||
timeoutMs: number,
|
|
||||||
intervalMs: number,
|
|
||||||
): Promise<T> {
|
|
||||||
const start = Date.now()
|
|
||||||
let lastError: unknown
|
|
||||||
|
|
||||||
while ((Date.now() - start) < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const value = await probe()
|
|
||||||
if (value !== undefined)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
lastError = error
|
|
||||||
}
|
|
||||||
await sleep(intervalMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const suffix = lastError instanceof Error ? `: ${lastError.message}` : ''
|
|
||||||
throw new Error(`${label} timed out after ${timeoutMs}ms${suffix}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForRemoteDebug(debugPort: number): Promise<string> {
|
|
||||||
const version = await waitFor('Electron remote debug endpoint', async () => {
|
|
||||||
const data = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${debugPort}/json/version`)
|
|
||||||
return data.webSocketDebuggerUrl
|
|
||||||
}, 120_000, 500)
|
|
||||||
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(errorMessageFromValue(error))
|
console.error(errorMessageFromValue(error))
|
||||||
|
|||||||
@@ -8,22 +8,22 @@ import { cac } from 'cac'
|
|||||||
|
|
||||||
import * as yaml from 'yaml'
|
import * as yaml from 'yaml'
|
||||||
|
|
||||||
type Platform = 'arm64' | 'both' | 'none' | 'x64'
|
interface UpdateInfoFile {
|
||||||
|
url: string
|
||||||
|
sha2?: string
|
||||||
|
sha512?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
[key: string]: unknown
|
|
||||||
files?: UpdateInfoFile[]
|
files?: UpdateInfoFile[]
|
||||||
path?: string
|
path?: string
|
||||||
sha2?: string
|
sha2?: string
|
||||||
sha512?: string
|
sha512?: string
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateInfoFile {
|
type Platform = 'x64' | 'arm64' | 'both' | 'none'
|
||||||
sha2?: string
|
|
||||||
sha512?: string
|
|
||||||
size?: number
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i
|
const regexpIsLatestMacMetadata = /^latest(?:-[^-]+)?-mac\.yml$/i
|
||||||
|
|
||||||
@@ -51,6 +51,18 @@ export const regexpHasX64 = /(^|[-_/])x64([-.]|$)/i
|
|||||||
export const regexpIsMacZip = /-mac\.zip$/i
|
export const regexpIsMacZip = /-mac\.zip$/i
|
||||||
export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i
|
export const regexpIsArm64MacZip = /-arm64-mac\.zip$/i
|
||||||
|
|
||||||
|
function isArm64MacZip(url: string): boolean {
|
||||||
|
return regexpIsArm64MacZip.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isX64MacZip(url: string): boolean {
|
||||||
|
return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMacZipUrls(updateInfo: UpdateInfo): string[] {
|
||||||
|
return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url))
|
||||||
|
}
|
||||||
|
|
||||||
function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude<Platform, 'both' | 'none'>, filePath: string) {
|
function assertContainsMacZip(updateInfo: UpdateInfo, platform: Exclude<Platform, 'both' | 'none'>, filePath: string) {
|
||||||
const zipUrls = getMacZipUrls(updateInfo)
|
const zipUrls = getMacZipUrls(updateInfo)
|
||||||
|
|
||||||
@@ -73,46 +85,6 @@ function assertMergedContainsBothMacZips(updateInfo: UpdateInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectLatestMacFiles(rootDir: string): string[] {
|
|
||||||
const results: string[] = []
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.debug('merge-latest-mac: scan context', {
|
|
||||||
cwd: cwd(),
|
|
||||||
rootDir,
|
|
||||||
})
|
|
||||||
if (!existsSync(rootDir)) {
|
|
||||||
console.warn('merge-latest-mac: scan directory missing', rootDir)
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
if (!statSync(rootDir).isDirectory()) {
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = readdirSync(rootDir, { withFileTypes: true })
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.debug('merge-latest-mac: scan directory entries', {
|
|
||||||
entries: entries.map(entry => ({
|
|
||||||
isDirectory: entry.isDirectory(),
|
|
||||||
isFile: entry.isFile(),
|
|
||||||
name: entry.name,
|
|
||||||
})),
|
|
||||||
rootDir,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = resolve(rootDir, entry.name)
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
results.push(...collectLatestMacFiles(fullPath))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) {
|
|
||||||
results.push(fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectPlatform(updateInfo: UpdateInfo): Platform {
|
function detectPlatform(updateInfo: UpdateInfo): Platform {
|
||||||
const urls = getUrls(updateInfo)
|
const urls = getUrls(updateInfo)
|
||||||
|
|
||||||
@@ -133,16 +105,69 @@ function detectPlatform(updateInfo: UpdateInfo): Platform {
|
|||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMacZipUrls(updateInfo: UpdateInfo): string[] {
|
function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo {
|
||||||
return getUrls(updateInfo).filter(url => regexpIsMacZip.test(url))
|
const arm64Files = Array.isArray(arm64.files) ? arm64.files : []
|
||||||
|
const x64Files = Array.isArray(x64.files) ? x64.files : []
|
||||||
|
|
||||||
|
const byUrl = new Map<string, UpdateInfoFile>()
|
||||||
|
for (const file of [...arm64Files, ...x64Files]) {
|
||||||
|
if (file?.url) {
|
||||||
|
byUrl.set(file.url, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...arm64,
|
||||||
|
files: [...byUrl.values()],
|
||||||
|
path: undefined,
|
||||||
|
sha2: undefined,
|
||||||
|
sha512: undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isArm64MacZip(url: string): boolean {
|
async function readUpdateInfo(filePath: string): Promise<UpdateInfo> {
|
||||||
return regexpIsArm64MacZip.test(url)
|
const raw = await readFile(filePath, 'utf8')
|
||||||
|
return yaml.parse(raw) as UpdateInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
function isX64MacZip(url: string): boolean {
|
function collectLatestMacFiles(rootDir: string): string[] {
|
||||||
return regexpIsMacZip.test(url) && !regexpContainsArm64.test(url)
|
const results: string[] = []
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('merge-latest-mac: scan context', {
|
||||||
|
cwd: cwd(),
|
||||||
|
rootDir,
|
||||||
|
})
|
||||||
|
if (!existsSync(rootDir)) {
|
||||||
|
console.warn('merge-latest-mac: scan directory missing', rootDir)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
if (!statSync(rootDir).isDirectory()) {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = readdirSync(rootDir, { withFileTypes: true })
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('merge-latest-mac: scan directory entries', {
|
||||||
|
rootDir,
|
||||||
|
entries: entries.map(entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
isFile: entry.isFile(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = resolve(rootDir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...collectLatestMacFiles(fullPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.isFile() && regexpIsLatestMacMetadata.test(entry.name)) {
|
||||||
|
results.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -185,7 +210,7 @@ async function main() {
|
|||||||
throw new Error('No latest-mac*.yml files found')
|
throw new Error('No latest-mac*.yml files found')
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: { filePath: string, platform: Platform, updateInfo: UpdateInfo }[] = []
|
const entries: { filePath: string, updateInfo: UpdateInfo, platform: Platform }[] = []
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
console.warn('merge-latest-mac: missing file', filePath)
|
console.warn('merge-latest-mac: missing file', filePath)
|
||||||
@@ -199,7 +224,7 @@ async function main() {
|
|||||||
assertContainsMacZip(updateInfo, platform, filePath)
|
assertContainsMacZip(updateInfo, platform, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({ filePath, platform, updateInfo })
|
entries.push({ filePath, updateInfo, platform })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -239,31 +264,6 @@ async function main() {
|
|||||||
await writeFile(outputPath, yaml.stringify(merged), 'utf8')
|
await writeFile(outputPath, yaml.stringify(merged), 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeFiles(arm64: UpdateInfo, x64: UpdateInfo): UpdateInfo {
|
|
||||||
const arm64Files = Array.isArray(arm64.files) ? arm64.files : []
|
|
||||||
const x64Files = Array.isArray(x64.files) ? x64.files : []
|
|
||||||
|
|
||||||
const byUrl = new Map<string, UpdateInfoFile>()
|
|
||||||
for (const file of [...arm64Files, ...x64Files]) {
|
|
||||||
if (file?.url) {
|
|
||||||
byUrl.set(file.url, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...arm64,
|
|
||||||
files: [...byUrl.values()],
|
|
||||||
path: undefined,
|
|
||||||
sha2: undefined,
|
|
||||||
sha512: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readUpdateInfo(filePath: string): Promise<UpdateInfo> {
|
|
||||||
const raw = await readFile(filePath, 'utf8')
|
|
||||||
return yaml.parse(raw) as UpdateInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ describe('regenerateWindowsLatest', () => {
|
|||||||
await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8')
|
await writeFile(join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n', 'utf8')
|
||||||
await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8')
|
await writeFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'), 'signed-binary-content', 'utf8')
|
||||||
await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({
|
await writeFile(join(bundleDir, 'latest.yml'), yaml.stringify({
|
||||||
files: [{ sha512: 'stale-sha512', size: 10, url: 'stale.exe' }],
|
|
||||||
path: 'stale.exe',
|
|
||||||
releaseDate: '2026-01-02T03:04:05.000Z',
|
|
||||||
sha512: 'stale-sha512',
|
|
||||||
stagingPercentage: 25,
|
|
||||||
version: 'stale-version',
|
version: 'stale-version',
|
||||||
|
path: 'stale.exe',
|
||||||
|
sha512: 'stale-sha512',
|
||||||
|
releaseDate: '2026-01-02T03:04:05.000Z',
|
||||||
|
stagingPercentage: 25,
|
||||||
|
files: [{ url: 'stale.exe', sha512: 'stale-sha512', size: 10 }],
|
||||||
}), 'utf8')
|
}), 'utf8')
|
||||||
|
|
||||||
process.chdir(packageDir)
|
process.chdir(packageDir)
|
||||||
@@ -50,18 +50,18 @@ describe('regenerateWindowsLatest', () => {
|
|||||||
|
|
||||||
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'))
|
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-1.2.3-windows-x64-setup.exe'))
|
||||||
expect(nextUpdateInfo).toMatchObject({
|
expect(nextUpdateInfo).toMatchObject({
|
||||||
|
version: '1.2.3',
|
||||||
|
path: 'AIRI-1.2.3-windows-x64-setup.exe',
|
||||||
|
sha512: expectedHashes.sha512,
|
||||||
|
sha2: expectedHashes.sha256,
|
||||||
|
releaseDate: '2026-01-02T03:04:05.000Z',
|
||||||
|
stagingPercentage: 25,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
sha512: expectedHashes.sha512,
|
|
||||||
url: 'AIRI-1.2.3-windows-x64-setup.exe',
|
url: 'AIRI-1.2.3-windows-x64-setup.exe',
|
||||||
|
sha512: expectedHashes.sha512,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
path: 'AIRI-1.2.3-windows-x64-setup.exe',
|
|
||||||
releaseDate: '2026-01-02T03:04:05.000Z',
|
|
||||||
sha2: expectedHashes.sha256,
|
|
||||||
sha512: expectedHashes.sha512,
|
|
||||||
stagingPercentage: 25,
|
|
||||||
version: '1.2.3',
|
|
||||||
})
|
})
|
||||||
expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content'))
|
expect(nextUpdateInfo.files[0]?.size).toBe(Buffer.byteLength('signed-binary-content'))
|
||||||
|
|
||||||
@@ -81,24 +81,24 @@ describe('regenerateWindowsLatest', () => {
|
|||||||
await regenerateWindowsLatest({
|
await regenerateWindowsLatest({
|
||||||
input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe',
|
input: 'bundle/AIRI-9.9.9-windows-x64-setup.exe',
|
||||||
output: 'bundle/latest.yml',
|
output: 'bundle/latest.yml',
|
||||||
releaseDate: '2026-03-23T00:00:00.000Z',
|
|
||||||
version: '9.9.9',
|
version: '9.9.9',
|
||||||
|
releaseDate: '2026-03-23T00:00:00.000Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8'))
|
const persisted = yaml.parse(await readFile(resolve(bundleDir, 'latest.yml'), 'utf8'))
|
||||||
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'))
|
const expectedHashes = await hashFile(join(bundleDir, 'AIRI-9.9.9-windows-x64-setup.exe'))
|
||||||
expect(persisted).toMatchObject({
|
expect(persisted).toMatchObject({
|
||||||
|
version: '9.9.9',
|
||||||
|
path: 'AIRI-9.9.9-windows-x64-setup.exe',
|
||||||
|
sha512: expectedHashes.sha512,
|
||||||
|
sha2: expectedHashes.sha256,
|
||||||
|
releaseDate: '2026-03-23T00:00:00.000Z',
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
sha512: expectedHashes.sha512,
|
|
||||||
url: 'AIRI-9.9.9-windows-x64-setup.exe',
|
url: 'AIRI-9.9.9-windows-x64-setup.exe',
|
||||||
|
sha512: expectedHashes.sha512,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
path: 'AIRI-9.9.9-windows-x64-setup.exe',
|
|
||||||
releaseDate: '2026-03-23T00:00:00.000Z',
|
|
||||||
sha2: expectedHashes.sha256,
|
|
||||||
sha512: expectedHashes.sha512,
|
|
||||||
version: '9.9.9',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,30 +9,23 @@ import { cac } from 'cac'
|
|||||||
|
|
||||||
import * as yaml from 'yaml'
|
import * as yaml from 'yaml'
|
||||||
|
|
||||||
export interface RegenerateWindowsLatestOptions {
|
|
||||||
input: string
|
|
||||||
output: string
|
|
||||||
releaseDate?: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateFileInfo {
|
interface UpdateFileInfo {
|
||||||
|
url: string
|
||||||
sha512: string
|
sha512: string
|
||||||
size?: number
|
size?: number
|
||||||
url: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowsUpdateInfo {
|
interface WindowsUpdateInfo {
|
||||||
[key: string]: unknown
|
version: string
|
||||||
files: UpdateFileInfo[]
|
files: UpdateFileInfo[]
|
||||||
path: string
|
path: string
|
||||||
releaseDate?: string
|
|
||||||
sha2?: string
|
|
||||||
sha512: string
|
sha512: string
|
||||||
version: string
|
sha2?: string
|
||||||
|
releaseDate?: string
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashFile(filePath: string): Promise<{ sha256: string, sha512: string }> {
|
export async function hashFile(filePath: string): Promise<{ sha512: string, sha256: string }> {
|
||||||
return await new Promise((resolveHash, reject) => {
|
return await new Promise((resolveHash, reject) => {
|
||||||
const sha512 = createHash('sha512')
|
const sha512 = createHash('sha512')
|
||||||
const sha256 = createHash('sha256')
|
const sha256 = createHash('sha256')
|
||||||
@@ -45,8 +38,8 @@ export async function hashFile(filePath: string): Promise<{ sha256: string, sha5
|
|||||||
stream.on('error', reject)
|
stream.on('error', reject)
|
||||||
stream.on('end', () => {
|
stream.on('end', () => {
|
||||||
resolveHash({
|
resolveHash({
|
||||||
sha256: sha256.digest('hex'),
|
|
||||||
sha512: sha512.digest('base64'),
|
sha512: sha512.digest('base64'),
|
||||||
|
sha256: sha256.digest('hex'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -62,6 +55,30 @@ export async function readExistingUpdateInfo(filePath: string): Promise<Partial<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveFromWorkspace(inputPath: string): Promise<string> {
|
||||||
|
const resolved = resolve(inputPath)
|
||||||
|
if (existsSync(resolved)) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = await findWorkspaceDir(cwd())
|
||||||
|
if (workspaceRoot) {
|
||||||
|
const workspaceResolved = resolve(workspaceRoot, inputPath)
|
||||||
|
if (existsSync(workspaceResolved)) {
|
||||||
|
return workspaceResolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateWindowsLatestOptions {
|
||||||
|
input: string
|
||||||
|
output: string
|
||||||
|
version: string
|
||||||
|
releaseDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise<WindowsUpdateInfo> {
|
export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOptions): Promise<WindowsUpdateInfo> {
|
||||||
const input = String(options.input || '').trim()
|
const input = String(options.input || '').trim()
|
||||||
const output = String(options.output || '').trim()
|
const output = String(options.output || '').trim()
|
||||||
@@ -81,24 +98,24 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
|
|||||||
const inputPath = await resolveFromWorkspace(input)
|
const inputPath = await resolveFromWorkspace(input)
|
||||||
const outputPath = await resolveFromWorkspace(output)
|
const outputPath = await resolveFromWorkspace(output)
|
||||||
const fileStats = await stat(inputPath)
|
const fileStats = await stat(inputPath)
|
||||||
const { sha256, sha512 } = await hashFile(inputPath)
|
const { sha512, sha256 } = await hashFile(inputPath)
|
||||||
const existing = await readExistingUpdateInfo(outputPath)
|
const existing = await readExistingUpdateInfo(outputPath)
|
||||||
const url = basename(inputPath)
|
const url = basename(inputPath)
|
||||||
|
|
||||||
const nextUpdateInfo: WindowsUpdateInfo = {
|
const nextUpdateInfo: WindowsUpdateInfo = {
|
||||||
...existing,
|
...existing,
|
||||||
|
version,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
|
url,
|
||||||
sha512,
|
sha512,
|
||||||
size: fileStats.size,
|
size: fileStats.size,
|
||||||
url,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
path: url,
|
path: url,
|
||||||
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
|
|
||||||
sha2: sha256,
|
|
||||||
sha512,
|
sha512,
|
||||||
version,
|
sha2: sha256,
|
||||||
|
releaseDate: releaseDate || existing.releaseDate || new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true })
|
await mkdir(dirname(outputPath), { recursive: true })
|
||||||
@@ -107,23 +124,6 @@ export async function regenerateWindowsLatest(options: RegenerateWindowsLatestOp
|
|||||||
return nextUpdateInfo
|
return nextUpdateInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveFromWorkspace(inputPath: string): Promise<string> {
|
|
||||||
const resolved = resolve(inputPath)
|
|
||||||
if (existsSync(resolved)) {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceRoot = await findWorkspaceDir(cwd())
|
|
||||||
if (workspaceRoot) {
|
|
||||||
const workspaceResolved = resolve(workspaceRoot, inputPath)
|
|
||||||
if (existsSync(workspaceResolved)) {
|
|
||||||
return workspaceResolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const cli = cac('regenerate-windows-latest')
|
const cli = cac('regenerate-windows-latest')
|
||||||
.option('--input <path>', 'Signed Windows installer path', { type: [String] })
|
.option('--input <path>', 'Signed Windows installer path', { type: [String] })
|
||||||
@@ -141,8 +141,8 @@ async function main() {
|
|||||||
await regenerateWindowsLatest({
|
await regenerateWindowsLatest({
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
releaseDate,
|
|
||||||
version,
|
version,
|
||||||
|
releaseDate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ async function main() {
|
|||||||
const beforeProductName = productName
|
const beforeProductName = productName
|
||||||
|
|
||||||
const argOptions = args.options as {
|
const argOptions = args.options as {
|
||||||
autoTag: boolean
|
|
||||||
release: boolean
|
release: boolean
|
||||||
|
autoTag: boolean
|
||||||
tag: string[]
|
tag: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanVersion = version.replace(/^v/, '')
|
const cleanVersion = version.replace(/^v/, '')
|
||||||
const releaseOptions = { autoTag: false, release: true, tag: [cleanVersion] }
|
const releaseOptions = { release: true, autoTag: false, tag: [cleanVersion] }
|
||||||
|
|
||||||
const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions)
|
const windowsFilenames = await getFilenames('x86_64-pc-windows-msvc', releaseOptions)
|
||||||
const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions)
|
const macosFilenames = await getFilenames('aarch64-apple-darwin', releaseOptions)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ describe('generateManifestFixtures', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(roots.map(async (root) => {
|
await Promise.all(roots.map(async (root) => {
|
||||||
await import('node:fs/promises').then(({ rm }) => rm(root, { force: true, recursive: true }))
|
await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))
|
||||||
}))
|
}))
|
||||||
roots.length = 0
|
roots.length = 0
|
||||||
})
|
})
|
||||||
@@ -31,12 +31,12 @@ describe('generateManifestFixtures', () => {
|
|||||||
roots.push(root)
|
roots.push(root)
|
||||||
|
|
||||||
const result = await generateManifestFixtures({
|
const result = await generateManifestFixtures({
|
||||||
artifactContent: 'mock-installer-binary',
|
|
||||||
channel: 'stable',
|
|
||||||
releaseNotes: 'Mock update for AIRI local updater verification.',
|
|
||||||
rootDir: root,
|
rootDir: root,
|
||||||
|
channel: 'stable',
|
||||||
target: 'x86_64-pc-windows-msvc',
|
target: 'x86_64-pc-windows-msvc',
|
||||||
version: '9.9.9-test.1',
|
version: '9.9.9-test.1',
|
||||||
|
releaseNotes: 'Mock update for AIRI local updater verification.',
|
||||||
|
artifactContent: 'mock-installer-binary',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.channelDir).toBe(join(root, 'stable'))
|
expect(result.channelDir).toBe(join(root, 'stable'))
|
||||||
@@ -45,14 +45,14 @@ describe('generateManifestFixtures', () => {
|
|||||||
|
|
||||||
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
|
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
|
||||||
expect(manifest).toMatchObject({
|
expect(manifest).toMatchObject({
|
||||||
|
version: '9.9.9-test.1',
|
||||||
|
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
|
||||||
|
releaseNotes: 'Mock update for AIRI local updater verification.',
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
|
url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
|
|
||||||
releaseNotes: 'Mock update for AIRI local updater verification.',
|
|
||||||
version: '9.9.9-test.1',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(typeof manifest.sha512).toBe('string')
|
expect(typeof manifest.sha512).toBe('string')
|
||||||
@@ -66,12 +66,12 @@ describe('generateManifestFixtures', () => {
|
|||||||
roots.push(root)
|
roots.push(root)
|
||||||
|
|
||||||
const result = await generateManifestFixtures({
|
const result = await generateManifestFixtures({
|
||||||
artifactContent: `mock-installer-${channel}`,
|
|
||||||
channel,
|
|
||||||
releaseNotes: 'Mock update lane fixture',
|
|
||||||
rootDir: root,
|
rootDir: root,
|
||||||
|
channel,
|
||||||
target: 'aarch64-apple-darwin',
|
target: 'aarch64-apple-darwin',
|
||||||
version: '9.9.9-test.2',
|
version: '9.9.9-test.2',
|
||||||
|
releaseNotes: 'Mock update lane fixture',
|
||||||
|
artifactContent: `mock-installer-${channel}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.channelDir).toBe(join(root, channel))
|
expect(result.channelDir).toBe(join(root, channel))
|
||||||
|
|||||||
@@ -10,24 +10,59 @@ import * as yaml from 'yaml'
|
|||||||
|
|
||||||
import { getFilenames } from '../utils'
|
import { getFilenames } from '../utils'
|
||||||
|
|
||||||
|
export type UpdateTestChannel = 'stable' | 'beta' | 'alpha' | 'nightly' | 'canary'
|
||||||
|
|
||||||
export interface GenerateManifestFixturesOptions {
|
export interface GenerateManifestFixturesOptions {
|
||||||
artifactContent?: string
|
|
||||||
channel: UpdateTestChannel
|
|
||||||
releaseNotes: string
|
|
||||||
rootDir: string
|
rootDir: string
|
||||||
|
channel: UpdateTestChannel
|
||||||
target: string
|
target: string
|
||||||
version: string
|
version: string
|
||||||
|
releaseNotes: string
|
||||||
|
artifactContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateManifestFixturesResult {
|
export interface GenerateManifestFixturesResult {
|
||||||
artifactFilename: string
|
|
||||||
artifactPath: string
|
|
||||||
channelDir: string
|
channelDir: string
|
||||||
latestFilename: string
|
|
||||||
manifestPath: string
|
manifestPath: string
|
||||||
|
artifactPath: string
|
||||||
|
latestFilename: string
|
||||||
|
artifactFilename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateTestChannel = 'alpha' | 'beta' | 'canary' | 'nightly' | 'stable'
|
export function resolveLatestFilenameForTarget(target: string) {
|
||||||
|
switch (target) {
|
||||||
|
case 'x86_64-pc-windows-msvc':
|
||||||
|
return 'latest-x64.yml'
|
||||||
|
case 'x86_64-unknown-linux-gnu':
|
||||||
|
return 'latest-x64-linux.yml'
|
||||||
|
case 'aarch64-unknown-linux-gnu':
|
||||||
|
return 'latest-arm64-linux-arm64.yml'
|
||||||
|
case 'x86_64-apple-darwin':
|
||||||
|
return 'latest-x64-mac.yml'
|
||||||
|
case 'aarch64-apple-darwin':
|
||||||
|
return 'latest-arm64-mac.yml'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported update-test target: ${target}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64Sha512(content: string) {
|
||||||
|
return createHash('sha512').update(content).digest('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveArtifactFilename(target: string, version: string) {
|
||||||
|
const filenames = await getFilenames(target, {
|
||||||
|
release: true,
|
||||||
|
autoTag: false,
|
||||||
|
tag: [version],
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap')
|
||||||
|
if (!artifact)
|
||||||
|
throw new Error(`Unable to determine artifact filename for target: ${target}`)
|
||||||
|
|
||||||
|
return artifact.releaseArtifactFilename
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise<GenerateManifestFixturesResult> {
|
export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise<GenerateManifestFixturesResult> {
|
||||||
const channelDir = join(options.rootDir, options.channel)
|
const channelDir = join(options.rootDir, options.channel)
|
||||||
@@ -45,52 +80,31 @@ export async function generateManifestFixtures(options: GenerateManifestFixtures
|
|||||||
const size = Buffer.byteLength(artifactContent)
|
const size = Buffer.byteLength(artifactContent)
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
version: options.version,
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
|
url: artifactFilename,
|
||||||
sha512,
|
sha512,
|
||||||
size,
|
size,
|
||||||
url: artifactFilename,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
path: artifactFilename,
|
path: artifactFilename,
|
||||||
|
sha512,
|
||||||
releaseDate,
|
releaseDate,
|
||||||
releaseNotes: options.releaseNotes,
|
releaseNotes: options.releaseNotes,
|
||||||
sha512,
|
|
||||||
version: options.version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeFile(manifestPath, yaml.stringify(manifest), 'utf8')
|
await writeFile(manifestPath, yaml.stringify(manifest), 'utf8')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
artifactFilename,
|
|
||||||
artifactPath,
|
|
||||||
channelDir,
|
channelDir,
|
||||||
latestFilename,
|
|
||||||
manifestPath,
|
manifestPath,
|
||||||
|
artifactPath,
|
||||||
|
latestFilename,
|
||||||
|
artifactFilename,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLatestFilenameForTarget(target: string) {
|
|
||||||
switch (target) {
|
|
||||||
case 'aarch64-apple-darwin':
|
|
||||||
return 'latest-arm64-mac.yml'
|
|
||||||
case 'aarch64-unknown-linux-gnu':
|
|
||||||
return 'latest-arm64-linux-arm64.yml'
|
|
||||||
case 'x86_64-apple-darwin':
|
|
||||||
return 'latest-x64-mac.yml'
|
|
||||||
case 'x86_64-pc-windows-msvc':
|
|
||||||
return 'latest-x64.yml'
|
|
||||||
case 'x86_64-unknown-linux-gnu':
|
|
||||||
return 'latest-x64-linux.yml'
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported update-test target: ${target}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeBase64Sha512(content: string) {
|
|
||||||
return createHash('sha512').update(content).digest('base64')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const cli = cac('generate-update-test-manifest')
|
const cli = cac('generate-update-test-manifest')
|
||||||
.option('--root <path>', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' })
|
.option('--root <path>', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' })
|
||||||
@@ -101,11 +115,11 @@ async function main() {
|
|||||||
|
|
||||||
const parsed = cli.parse()
|
const parsed = cli.parse()
|
||||||
const result = await generateManifestFixtures({
|
const result = await generateManifestFixtures({
|
||||||
channel: String(parsed.options.channel) as UpdateTestChannel,
|
|
||||||
releaseNotes: String(parsed.options.releaseNotes),
|
|
||||||
rootDir: String(parsed.options.root),
|
rootDir: String(parsed.options.root),
|
||||||
|
channel: String(parsed.options.channel) as UpdateTestChannel,
|
||||||
target: String(parsed.options.target),
|
target: String(parsed.options.target),
|
||||||
version: String(parsed.options.version),
|
version: String(parsed.options.version),
|
||||||
|
releaseNotes: String(parsed.options.releaseNotes),
|
||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -114,20 +128,6 @@ async function main() {
|
|||||||
console.log(`Artifact: ${result.artifactFilename}`)
|
console.log(`Artifact: ${result.artifactFilename}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveArtifactFilename(target: string, version: string) {
|
|
||||||
const filenames = await getFilenames(target, {
|
|
||||||
autoTag: false,
|
|
||||||
release: true,
|
|
||||||
tag: [version],
|
|
||||||
})
|
|
||||||
|
|
||||||
const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap')
|
|
||||||
if (!artifact)
|
|
||||||
throw new Error(`Unable to determine artifact filename for target: ${target}`)
|
|
||||||
|
|
||||||
return artifact.releaseArtifactFilename
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import { extname, join, normalize } from 'node:path'
|
|||||||
import { cac } from 'cac'
|
import { cac } from 'cac'
|
||||||
|
|
||||||
const CONTENT_TYPES: Record<string, string> = {
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
'.deb': 'application/vnd.debian.binary-package',
|
|
||||||
'.dmg': 'application/octet-stream',
|
|
||||||
'.exe': 'application/vnd.microsoft.portable-executable',
|
'.exe': 'application/vnd.microsoft.portable-executable',
|
||||||
|
'.yml': 'text/yaml; charset=utf-8',
|
||||||
|
'.yaml': 'text/yaml; charset=utf-8',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.dmg': 'application/octet-stream',
|
||||||
|
'.deb': 'application/vnd.debian.binary-package',
|
||||||
'.rpm': 'application/x-rpm',
|
'.rpm': 'application/x-rpm',
|
||||||
'.txt': 'text/plain; charset=utf-8',
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
'.yaml': 'text/yaml; charset=utf-8',
|
}
|
||||||
'.yml': 'text/yaml; charset=utf-8',
|
|
||||||
'.zip': 'application/zip',
|
function getContentType(pathname: string) {
|
||||||
|
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startUpdateTestServer(options: { port: number, rootDir: string }) {
|
export async function startUpdateTestServer(options: { port: number, rootDir: string }) {
|
||||||
@@ -44,10 +48,6 @@ export async function startUpdateTestServer(options: { port: number, rootDir: st
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContentType(pathname: string) {
|
|
||||||
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const cli = cac('update-test-server')
|
const cli = cac('update-test-server')
|
||||||
.option('--port <port>', 'Port to listen on', { default: '8787' })
|
.option('--port <port>', 'Port to listen on', { default: '8787' })
|
||||||
|
|||||||
@@ -6,385 +6,7 @@ import { x } from 'tinyexec'
|
|||||||
|
|
||||||
import packageJSON from '../package.json' with { type: 'json' }
|
import packageJSON from '../package.json' with { type: 'json' }
|
||||||
|
|
||||||
interface FilenameOutputEntry {
|
export async function getVersion(options: { release: boolean, autoTag: boolean, tag: string[] }) {
|
||||||
extension: string
|
|
||||||
optional?: boolean
|
|
||||||
outputFilename: string
|
|
||||||
productName: string
|
|
||||||
releaseArtifactFilename: string
|
|
||||||
target: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTemplateOfArtifactName(
|
|
||||||
template: string,
|
|
||||||
productName: string,
|
|
||||||
version: string,
|
|
||||||
arch: string,
|
|
||||||
ext: string,
|
|
||||||
): string {
|
|
||||||
return template
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
|
||||||
.replace('${productName}', productName)
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
|
||||||
.replace('${version}', version)
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
|
||||||
.replace('${arch}', arch)
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
|
||||||
.replace('${ext}', ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getElectronBuilderConfig(): Promise<Configuration> {
|
|
||||||
const config = await import ('../electron-builder.config')
|
|
||||||
return config.default
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFilenames(target: string, options: { autoTag: boolean, release: boolean, tag: string[] }): Promise<FilenameOutputEntry[]> {
|
|
||||||
const electronBuilder = await getElectronBuilderConfig()
|
|
||||||
const version = await getVersion(options)
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
throw new Error('<Target> is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeVersion = packageJSON.version
|
|
||||||
const productName = electronBuilder.productName!
|
|
||||||
|
|
||||||
switch (target) {
|
|
||||||
case 'aarch64-apple-darwin':
|
|
||||||
{
|
|
||||||
const artifacts: FilenameOutputEntry[] = [
|
|
||||||
{
|
|
||||||
extension: 'dmg',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.dmg!.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'dmg'),
|
|
||||||
'dmg',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.dmg!.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'dmg'),
|
|
||||||
'dmg',
|
|
||||||
),
|
|
||||||
target: 'aarch64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'zip',
|
|
||||||
outputFilename: getMacZipFilename(productName, beforeVersion, target),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: getMacZipFilename(productName, version, target),
|
|
||||||
target: 'aarch64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extension: getLatestUpdateFilename(target)!,
|
|
||||||
optional: true,
|
|
||||||
outputFilename: getLatestUpdateFilename(target)!,
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
|
||||||
target: 'aarch64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return artifacts
|
|
||||||
}
|
|
||||||
case 'aarch64-unknown-linux-gnu':
|
|
||||||
{
|
|
||||||
const artifacts: FilenameOutputEntry[] = []
|
|
||||||
if (electronBuilder.linux?.artifactName) {
|
|
||||||
if (
|
|
||||||
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|
|
||||||
|| electronBuilder.linux.target === 'deb'
|
|
||||||
) {
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'deb',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'deb'),
|
|
||||||
'deb',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'deb'),
|
|
||||||
'deb',
|
|
||||||
),
|
|
||||||
target: 'aarch64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|
|
||||||
|| electronBuilder.linux.target === 'rpm'
|
|
||||||
) {
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'rpm',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'rpm'),
|
|
||||||
'rpm',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'rpm'),
|
|
||||||
'rpm',
|
|
||||||
),
|
|
||||||
target: 'aarch64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatpak artifact (built outside electron-builder, but we follow linux template)
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'flatpak',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'flatpak'),
|
|
||||||
'flatpak',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'flatpak'),
|
|
||||||
'flatpak',
|
|
||||||
),
|
|
||||||
target: 'aarch64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestUpdateFilename = getLatestUpdateFilename(target)
|
|
||||||
if (latestUpdateFilename) {
|
|
||||||
artifacts.push({
|
|
||||||
extension: latestUpdateFilename,
|
|
||||||
optional: true,
|
|
||||||
outputFilename: latestUpdateFilename,
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: latestUpdateFilename,
|
|
||||||
target: 'aarch64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return artifacts
|
|
||||||
}
|
|
||||||
case 'x86_64-apple-darwin':
|
|
||||||
{
|
|
||||||
const artifacts: FilenameOutputEntry[] = [
|
|
||||||
{
|
|
||||||
extension: 'dmg',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.dmg!.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'dmg'),
|
|
||||||
'dmg',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.dmg!.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'dmg'),
|
|
||||||
'dmg',
|
|
||||||
),
|
|
||||||
target: 'x86_64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'zip',
|
|
||||||
outputFilename: getMacZipFilename(productName, beforeVersion, target),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: getMacZipFilename(productName, version, target),
|
|
||||||
target: 'x86_64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extension: getLatestUpdateFilename(target)!,
|
|
||||||
optional: true,
|
|
||||||
outputFilename: getLatestUpdateFilename(target)!,
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
|
||||||
target: 'x86_64-apple-darwin',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return artifacts
|
|
||||||
}
|
|
||||||
case 'x86_64-pc-windows-msvc':
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
extension: 'exe',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.nsis!.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'exe'),
|
|
||||||
'exe',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.nsis!.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'exe'),
|
|
||||||
'exe',
|
|
||||||
),
|
|
||||||
target: 'x86_64-pc-windows-msvc',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
extension: getLatestUpdateFilename(target)!,
|
|
||||||
optional: true,
|
|
||||||
outputFilename: getLatestUpdateFilename(target)!,
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
|
||||||
target: 'x86_64-pc-windows-msvc',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
case 'x86_64-unknown-linux-gnu':
|
|
||||||
{
|
|
||||||
const artifacts: FilenameOutputEntry[] = []
|
|
||||||
if (electronBuilder.linux?.artifactName) {
|
|
||||||
if (
|
|
||||||
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|
|
||||||
|| electronBuilder.linux.target === 'deb'
|
|
||||||
) {
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'deb',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'deb'),
|
|
||||||
'deb',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'deb'),
|
|
||||||
'deb',
|
|
||||||
),
|
|
||||||
target: 'x86_64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|
|
||||||
|| electronBuilder.linux.target === 'rpm'
|
|
||||||
) {
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'rpm',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'rpm'),
|
|
||||||
'rpm',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'rpm'),
|
|
||||||
'rpm',
|
|
||||||
),
|
|
||||||
target: 'x86_64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flatpak artifact (built outside electron-builder, but we follow linux template)
|
|
||||||
artifacts.push(
|
|
||||||
{
|
|
||||||
extension: 'flatpak',
|
|
||||||
outputFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
beforeVersion,
|
|
||||||
mapArchFor(target, 'flatpak'),
|
|
||||||
'flatpak',
|
|
||||||
),
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: applyTemplateOfArtifactName(
|
|
||||||
electronBuilder.linux.artifactName!,
|
|
||||||
productName,
|
|
||||||
version,
|
|
||||||
mapArchFor(target, 'flatpak'),
|
|
||||||
'flatpak',
|
|
||||||
),
|
|
||||||
target: 'x86_64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestUpdateFilename = getLatestUpdateFilename(target)
|
|
||||||
if (latestUpdateFilename) {
|
|
||||||
artifacts.push({
|
|
||||||
extension: latestUpdateFilename,
|
|
||||||
optional: true,
|
|
||||||
outputFilename: latestUpdateFilename,
|
|
||||||
productName,
|
|
||||||
releaseArtifactFilename: latestUpdateFilename,
|
|
||||||
target: 'x86_64-unknown-linux-gnu',
|
|
||||||
version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return artifacts
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
console.error('Target is not supported')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVersion(options: { autoTag: boolean, release: boolean, tag: string[] }) {
|
|
||||||
if (!options.release || !options.tag) {
|
if (!options.release || !options.tag) {
|
||||||
// Otherwise, fetch from the latest git ref
|
// Otherwise, fetch from the latest git ref
|
||||||
const res = await x('git', ['log', '-1', '--pretty=format:"%H"'])
|
const res = await x('git', ['log', '-1', '--pretty=format:"%H"'])
|
||||||
@@ -429,6 +51,39 @@ export async function getVersion(options: { autoTag: boolean, release: boolean,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getElectronBuilderConfig(): Promise<Configuration> {
|
||||||
|
const config = await import ('../electron-builder.config')
|
||||||
|
return config.default
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTemplateOfArtifactName(
|
||||||
|
template: string,
|
||||||
|
productName: string,
|
||||||
|
version: string,
|
||||||
|
arch: string,
|
||||||
|
ext: string,
|
||||||
|
): string {
|
||||||
|
return template
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
.replace('${productName}', productName)
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
.replace('${version}', version)
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
.replace('${arch}', arch)
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
.replace('${ext}', ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilenameOutputEntry {
|
||||||
|
target: string
|
||||||
|
extension: string
|
||||||
|
outputFilename: string
|
||||||
|
releaseArtifactFilename: string
|
||||||
|
productName: string
|
||||||
|
version: string
|
||||||
|
optional?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function mapArchFor(
|
export function mapArchFor(
|
||||||
target: string,
|
target: string,
|
||||||
ext: string,
|
ext: string,
|
||||||
@@ -463,17 +118,17 @@ export function mapArchFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestUpdateFilename(target: string): null | string {
|
function getLatestUpdateFilename(target: string): string | null {
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case 'aarch64-apple-darwin':
|
|
||||||
case 'x86_64-apple-darwin':
|
|
||||||
return `latest-${mapArchFor(target, 'yml')}-mac.yml`
|
|
||||||
case 'aarch64-unknown-linux-gnu':
|
|
||||||
return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml`
|
|
||||||
case 'x86_64-pc-windows-msvc':
|
case 'x86_64-pc-windows-msvc':
|
||||||
return `latest-${mapArchFor(target, 'yml')}.yml`
|
return `latest-${mapArchFor(target, 'yml')}.yml`
|
||||||
case 'x86_64-unknown-linux-gnu':
|
case 'x86_64-unknown-linux-gnu':
|
||||||
return `latest-${mapArchFor(target, 'yml')}-linux.yml`
|
return `latest-${mapArchFor(target, 'yml')}-linux.yml`
|
||||||
|
case 'aarch64-unknown-linux-gnu':
|
||||||
|
return `latest-${mapArchFor(target, 'yml')}-linux-${mapArchFor(target, 'yml')}.yml`
|
||||||
|
case 'aarch64-apple-darwin':
|
||||||
|
case 'x86_64-apple-darwin':
|
||||||
|
return `latest-${mapArchFor(target, 'yml')}-mac.yml`
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -484,3 +139,348 @@ function getMacZipFilename(productName: string, version: string, target: string)
|
|||||||
const archPrefix = arch === 'x64' ? '' : `${arch}-`
|
const archPrefix = arch === 'x64' ? '' : `${arch}-`
|
||||||
return `${productName}-${version}-${archPrefix}mac.zip`
|
return `${productName}-${version}-${archPrefix}mac.zip`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFilenames(target: string, options: { release: boolean, autoTag: boolean, tag: string[] }): Promise<FilenameOutputEntry[]> {
|
||||||
|
const electronBuilder = await getElectronBuilderConfig()
|
||||||
|
const version = await getVersion(options)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('<Target> is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeVersion = packageJSON.version
|
||||||
|
const productName = electronBuilder.productName!
|
||||||
|
|
||||||
|
switch (target) {
|
||||||
|
case 'x86_64-pc-windows-msvc':
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
target: 'x86_64-pc-windows-msvc',
|
||||||
|
extension: 'exe',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.nsis!.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'exe'),
|
||||||
|
'exe',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.nsis!.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'exe'),
|
||||||
|
'exe',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'x86_64-pc-windows-msvc',
|
||||||
|
extension: getLatestUpdateFilename(target)!,
|
||||||
|
outputFilename: getLatestUpdateFilename(target)!,
|
||||||
|
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
case 'x86_64-unknown-linux-gnu':
|
||||||
|
{
|
||||||
|
const artifacts: FilenameOutputEntry[] = []
|
||||||
|
if (electronBuilder.linux?.artifactName) {
|
||||||
|
if (
|
||||||
|
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|
||||||
|
|| electronBuilder.linux.target === 'deb'
|
||||||
|
) {
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'x86_64-unknown-linux-gnu',
|
||||||
|
extension: 'deb',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'deb'),
|
||||||
|
'deb',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'deb'),
|
||||||
|
'deb',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|
||||||
|
|| electronBuilder.linux.target === 'rpm'
|
||||||
|
) {
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'x86_64-unknown-linux-gnu',
|
||||||
|
extension: 'rpm',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'rpm'),
|
||||||
|
'rpm',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'rpm'),
|
||||||
|
'rpm',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatpak artifact (built outside electron-builder, but we follow linux template)
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'x86_64-unknown-linux-gnu',
|
||||||
|
extension: 'flatpak',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'flatpak'),
|
||||||
|
'flatpak',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'flatpak'),
|
||||||
|
'flatpak',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUpdateFilename = getLatestUpdateFilename(target)
|
||||||
|
if (latestUpdateFilename) {
|
||||||
|
artifacts.push({
|
||||||
|
target: 'x86_64-unknown-linux-gnu',
|
||||||
|
extension: latestUpdateFilename,
|
||||||
|
outputFilename: latestUpdateFilename,
|
||||||
|
releaseArtifactFilename: latestUpdateFilename,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
optional: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
case 'aarch64-unknown-linux-gnu':
|
||||||
|
{
|
||||||
|
const artifacts: FilenameOutputEntry[] = []
|
||||||
|
if (electronBuilder.linux?.artifactName) {
|
||||||
|
if (
|
||||||
|
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('deb'))
|
||||||
|
|| electronBuilder.linux.target === 'deb'
|
||||||
|
) {
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'aarch64-unknown-linux-gnu',
|
||||||
|
extension: 'deb',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'deb'),
|
||||||
|
'deb',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'deb'),
|
||||||
|
'deb',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Array.isArray(electronBuilder.linux.target) && electronBuilder.linux.target.includes('rpm'))
|
||||||
|
|| electronBuilder.linux.target === 'rpm'
|
||||||
|
) {
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'aarch64-unknown-linux-gnu',
|
||||||
|
extension: 'rpm',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'rpm'),
|
||||||
|
'rpm',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'rpm'),
|
||||||
|
'rpm',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatpak artifact (built outside electron-builder, but we follow linux template)
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'aarch64-unknown-linux-gnu',
|
||||||
|
extension: 'flatpak',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'flatpak'),
|
||||||
|
'flatpak',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.linux.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'flatpak'),
|
||||||
|
'flatpak',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUpdateFilename = getLatestUpdateFilename(target)
|
||||||
|
if (latestUpdateFilename) {
|
||||||
|
artifacts.push({
|
||||||
|
target: 'aarch64-unknown-linux-gnu',
|
||||||
|
extension: latestUpdateFilename,
|
||||||
|
outputFilename: latestUpdateFilename,
|
||||||
|
releaseArtifactFilename: latestUpdateFilename,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
optional: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
case 'aarch64-apple-darwin':
|
||||||
|
{
|
||||||
|
const artifacts: FilenameOutputEntry[] = [
|
||||||
|
{
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
extension: 'dmg',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.dmg!.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'dmg'),
|
||||||
|
'dmg',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.dmg!.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'dmg'),
|
||||||
|
'dmg',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
extension: 'zip',
|
||||||
|
outputFilename: getMacZipFilename(productName, beforeVersion, target),
|
||||||
|
releaseArtifactFilename: getMacZipFilename(productName, version, target),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'aarch64-apple-darwin',
|
||||||
|
extension: getLatestUpdateFilename(target)!,
|
||||||
|
outputFilename: getLatestUpdateFilename(target)!,
|
||||||
|
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
case 'x86_64-apple-darwin':
|
||||||
|
{
|
||||||
|
const artifacts: FilenameOutputEntry[] = [
|
||||||
|
{
|
||||||
|
target: 'x86_64-apple-darwin',
|
||||||
|
extension: 'dmg',
|
||||||
|
outputFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.dmg!.artifactName!,
|
||||||
|
productName,
|
||||||
|
beforeVersion,
|
||||||
|
mapArchFor(target, 'dmg'),
|
||||||
|
'dmg',
|
||||||
|
),
|
||||||
|
releaseArtifactFilename: applyTemplateOfArtifactName(
|
||||||
|
electronBuilder.dmg!.artifactName!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
mapArchFor(target, 'dmg'),
|
||||||
|
'dmg',
|
||||||
|
),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
artifacts.push(
|
||||||
|
{
|
||||||
|
target: 'x86_64-apple-darwin',
|
||||||
|
extension: 'zip',
|
||||||
|
outputFilename: getMacZipFilename(productName, beforeVersion, target),
|
||||||
|
releaseArtifactFilename: getMacZipFilename(productName, version, target),
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'x86_64-apple-darwin',
|
||||||
|
extension: getLatestUpdateFilename(target)!,
|
||||||
|
outputFilename: getLatestUpdateFilename(target)!,
|
||||||
|
releaseArtifactFilename: getLatestUpdateFilename(target)!,
|
||||||
|
productName,
|
||||||
|
version,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error('Target is not supported')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import { env } from 'node:process'
|
|||||||
|
|
||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
|
|
||||||
|
/** Enables Electron's CDP endpoint before the app ready event. */
|
||||||
|
export function setupDebugger() {
|
||||||
|
if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) {
|
||||||
|
const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222')
|
||||||
|
if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) {
|
||||||
|
throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort))
|
||||||
|
app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the inspector for the first available Electron renderer target.
|
* Opens the inspector for the first available Electron renderer target.
|
||||||
*
|
*
|
||||||
@@ -47,16 +60,3 @@ export function openDebugger() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enables Electron's CDP endpoint before the app ready event. */
|
|
||||||
export function setupDebugger() {
|
|
||||||
if (/^true$/i.test(env.APP_REMOTE_DEBUG || '')) {
|
|
||||||
const remoteDebugPort = Number(env.APP_REMOTE_DEBUG_PORT || '9222')
|
|
||||||
if (Number.isNaN(remoteDebugPort) || !Number.isInteger(remoteDebugPort) || remoteDebugPort < 0 || remoteDebugPort > 65535) {
|
|
||||||
throw new Error(`Invalid remote debug port: ${env.APP_REMOTE_DEBUG_PORT}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.commandLine.appendSwitch('remote-debugging-port', String(remoteDebugPort))
|
|
||||||
app.commandLine.appendSwitch('remote-allow-origins', `http://localhost:${remoteDebugPort}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const LOG_FILE_PREFIX = 'airi-tamagotchi'
|
|||||||
* Handle for the file logger, providing access to the log file and append operations.
|
* Handle for the file logger, providing access to the log file and append operations.
|
||||||
*/
|
*/
|
||||||
export interface FileLoggerHandle {
|
export interface FileLoggerHandle {
|
||||||
|
/** Path to the current session's log file, or null if initialization failed */
|
||||||
|
logFilePath: string | null
|
||||||
|
/** File descriptor for the current session's log file, or null if initialization failed */
|
||||||
|
logFileFd: number | null
|
||||||
/**
|
/**
|
||||||
* Appends a log entry to the file.
|
* Appends a log entry to the file.
|
||||||
* @param content - The formatted log content to append
|
* @param content - The formatted log content to append
|
||||||
@@ -51,23 +55,68 @@ export interface FileLoggerHandle {
|
|||||||
* Closes the log file and releases resources.
|
* Closes the log file and releases resources.
|
||||||
*/
|
*/
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
/** File descriptor for the current session's log file, or null if initialization failed */
|
|
||||||
logFileFd: null | number
|
|
||||||
/** Path to the current session's log file, or null if initialization failed */
|
|
||||||
logFilePath: null | string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nullFileLoggerHandle: FileLoggerHandle = {
|
export const nullFileLoggerHandle: FileLoggerHandle = {
|
||||||
|
logFilePath: null,
|
||||||
|
logFileFd: null,
|
||||||
appendLog: async () => {},
|
appendLog: async () => {},
|
||||||
close: async () => {},
|
close: async () => {},
|
||||||
logFileFd: null,
|
|
||||||
logFilePath: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Internal Functions
|
// Internal Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human-readable error message from an unknown error object.
|
||||||
|
*/
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return errorMessageFromValue(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the log file path for the current session.
|
||||||
|
* Format: {userData}/logs/airi-tamagotchi-{timestamp}.log
|
||||||
|
*/
|
||||||
|
function createLogFilePath(logsDir: string, timestamp: number): string {
|
||||||
|
return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the logs directory exists.
|
||||||
|
* Returns the logs directory path if successful, null otherwise.
|
||||||
|
*/
|
||||||
|
async function ensureLogsDirectory(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const logsDir = join(app.getPath('userData'), 'logs')
|
||||||
|
await mkdir(logsDir, { recursive: true })
|
||||||
|
return logsDir
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = getErrorMessage(error)
|
||||||
|
console.error(`[FileLogger] Failed to create logs directory: ${message}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current log file exists and returns its size.
|
||||||
|
*/
|
||||||
|
async function getLogFileSize(filePath: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
return stats.size
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up the file logger by creating a timestamped log file.
|
* Sets up the file logger by creating a timestamped log file.
|
||||||
*
|
*
|
||||||
@@ -137,7 +186,7 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
|
|||||||
console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`)
|
console.info(`[FileLogger] Session log file: ${logFilePath}${sizeInfo}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { appendLog, close, logFileFd, logFilePath }
|
return { logFilePath, logFileFd, appendLog, close }
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
const message = getErrorMessage(error)
|
const message = getErrorMessage(error)
|
||||||
@@ -145,52 +194,3 @@ export async function setupFileLogger(): Promise<FileLoggerHandle> {
|
|||||||
return nullFileLoggerHandle
|
return nullFileLoggerHandle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the log file path for the current session.
|
|
||||||
* Format: {userData}/logs/airi-tamagotchi-{timestamp}.log
|
|
||||||
*/
|
|
||||||
function createLogFilePath(logsDir: string, timestamp: number): string {
|
|
||||||
return join(logsDir, `${LOG_FILE_PREFIX}-${timestamp}.log`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the logs directory exists.
|
|
||||||
* Returns the logs directory path if successful, null otherwise.
|
|
||||||
*/
|
|
||||||
async function ensureLogsDirectory(): Promise<null | string> {
|
|
||||||
try {
|
|
||||||
const logsDir = join(app.getPath('userData'), 'logs')
|
|
||||||
await mkdir(logsDir, { recursive: true })
|
|
||||||
return logsDir
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
const message = getErrorMessage(error)
|
|
||||||
console.error(`[FileLogger] Failed to create logs directory: ${message}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a human-readable error message from an unknown error object.
|
|
||||||
*/
|
|
||||||
function getErrorMessage(error: unknown): string {
|
|
||||||
return errorMessageFromValue(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Public API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current log file exists and returns its size.
|
|
||||||
*/
|
|
||||||
async function getLogFileSize(filePath: string): Promise<null | number> {
|
|
||||||
try {
|
|
||||||
const stats = await stat(filePath)
|
|
||||||
return stats.size
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ interface SingleInstanceGuardOptions {
|
|||||||
getWindow: () => BrowserWindow | undefined
|
getWindow: () => BrowserWindow | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the main AIRI window after a duplicate launch.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Electron forwards a second process launch to the primary instance
|
||||||
|
* - The app should show the already-running UI instead of starting another runtime
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `getWindow` returns the main user-facing window when it has been created
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) {
|
||||||
|
const window = getWindow()
|
||||||
|
if (!window) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWindowShow(window)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs Electron's single-instance guard for the desktop runtime.
|
* Installs Electron's single-instance guard for the desktop runtime.
|
||||||
*
|
*
|
||||||
@@ -33,25 +55,3 @@ export function installSingleInstanceGuard(options: SingleInstanceGuardOptions)
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Focuses the main AIRI window after a duplicate launch.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Electron forwards a second process launch to the primary instance
|
|
||||||
* - The app should show the already-running UI instead of starting another runtime
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `getWindow` returns the main user-facing window when it has been created
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
function focusMainWindow(getWindow: SingleInstanceGuardOptions['getWindow']) {
|
|
||||||
const window = getWindow()
|
|
||||||
if (!window) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleWindowShow(window)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { any, array, number, object, optional, string } from 'valibot'
|
|||||||
import { createConfig } from '../libs/electron/persistence'
|
import { createConfig } from '../libs/electron/persistence'
|
||||||
|
|
||||||
export const artistryConfigSchema = object({
|
export const artistryConfigSchema = object({
|
||||||
|
artistryProvider: optional(string(), 'none'),
|
||||||
artistryGlobals: optional(object({
|
artistryGlobals: optional(object({
|
||||||
comfyuiActiveWorkflow: optional(string(), ''),
|
|
||||||
comfyuiSavedWorkflows: optional(array(any()), []),
|
|
||||||
comfyuiServerUrl: optional(string(), 'http://localhost:8188'),
|
comfyuiServerUrl: optional(string(), 'http://localhost:8188'),
|
||||||
|
comfyuiSavedWorkflows: optional(array(any()), []),
|
||||||
|
comfyuiActiveWorkflow: optional(string(), ''),
|
||||||
|
replicateApiKey: optional(string(), ''),
|
||||||
|
replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'),
|
||||||
|
replicateAspectRatio: optional(string(), '16:9'),
|
||||||
|
replicateInferenceSteps: optional(number(), 4),
|
||||||
nanobananaApiKey: optional(string(), ''),
|
nanobananaApiKey: optional(string(), ''),
|
||||||
nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'),
|
nanobananaModel: optional(string(), 'gemini-3.1-flash-image-preview'),
|
||||||
nanobananaResolution: optional(string(), '1K'),
|
nanobananaResolution: optional(string(), '1K'),
|
||||||
replicateApiKey: optional(string(), ''),
|
|
||||||
replicateAspectRatio: optional(string(), '16:9'),
|
|
||||||
replicateDefaultModel: optional(string(), 'black-forest-labs/flux-schnell'),
|
|
||||||
replicateInferenceSteps: optional(number(), 4),
|
|
||||||
}), {}),
|
}), {}),
|
||||||
artistryProvider: optional(string(), 'none'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function createArtistryConfig() {
|
export function createArtistryConfig() {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { array, object, optional, picklist, string } from 'valibot'
|
|||||||
import { createConfig } from '../libs/electron/persistence'
|
import { createConfig } from '../libs/electron/persistence'
|
||||||
|
|
||||||
const shortcutAcceleratorSchema = object({
|
const shortcutAcceleratorSchema = object({
|
||||||
key: string(),
|
|
||||||
modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])),
|
modifiers: array(picklist(['cmd-or-ctrl', 'cmd', 'ctrl', 'alt', 'shift', 'super'])),
|
||||||
|
key: string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const globalAppConfigSchema = object({
|
export const globalAppConfigSchema = object({
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ app.whenReady().then(async () => {
|
|||||||
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
|
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
|
||||||
const electronApp = injeca.provide('host:electron:app', () => app)
|
const electronApp = injeca.provide('host:electron:app', () => app)
|
||||||
const autoUpdater = injeca.provide('services:auto-updater', {
|
const autoUpdater = injeca.provide('services:auto-updater', {
|
||||||
|
dependsOn: { appConfig },
|
||||||
build: ({ dependsOn }) => setupAutoUpdater({
|
build: ({ dependsOn }) => setupAutoUpdater({
|
||||||
enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam',
|
enabled: import.meta.env.VITE_DISTRIBUTION !== 'steam',
|
||||||
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
|
getStoredUpdateLane: () => dependsOn.appConfig.get()?.updateChannel,
|
||||||
@@ -145,17 +146,16 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
dependsOn: { appConfig },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const i18n = injeca.provide('libs:i18n', {
|
const i18n = injeca.provide('libs:i18n', {
|
||||||
build: ({ dependsOn }) => createI18n({ locale: dependsOn.appConfig.get()?.language, messages }),
|
|
||||||
dependsOn: { appConfig },
|
dependsOn: { appConfig },
|
||||||
|
build: ({ dependsOn }) => createI18n({ messages, locale: dependsOn.appConfig.get()?.language }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverChannel = injeca.provide('modules:channel-server', {
|
const serverChannel = injeca.provide('modules:channel-server', {
|
||||||
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
|
|
||||||
dependsOn: { app: electronApp, lifecycle },
|
dependsOn: { app: electronApp, lifecycle },
|
||||||
|
build: async ({ dependsOn }) => setupServerChannel(dependsOn),
|
||||||
})
|
})
|
||||||
|
|
||||||
const airiHttpServer = injeca.provide('modules:airi-http-server', {
|
const airiHttpServer = injeca.provide('modules:airi-http-server', {
|
||||||
@@ -167,8 +167,8 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', {
|
const appleSpeechTranscription = injeca.provide('modules:apple-speech-transcription', {
|
||||||
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
|
|
||||||
dependsOn: { lifecycle },
|
dependsOn: { lifecycle },
|
||||||
|
build: ({ dependsOn }) => setupAppleSpeechTranscriptionService(dependsOn),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
|
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
|
||||||
@@ -176,13 +176,13 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const widgetsManager = injeca.provide('windows:widgets', {
|
const widgetsManager = injeca.provide('windows:widgets', {
|
||||||
|
dependsOn: { serverChannel, i18n },
|
||||||
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
||||||
dependsOn: { i18n, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const pluginHost = injeca.provide('modules:plugin-host', {
|
const pluginHost = injeca.provide('modules:plugin-host', {
|
||||||
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
|
|
||||||
dependsOn: { serverChannel, widgetsManager },
|
dependsOn: { serverChannel, widgetsManager },
|
||||||
|
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
|
||||||
})
|
})
|
||||||
|
|
||||||
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
|
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
|
||||||
@@ -193,90 +193,90 @@ app.whenReady().then(async () => {
|
|||||||
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
|
const devtoolsMarkdownStressWindow = injeca.provide('windows:devtools:markdown-stress', () => setupDevtoolsWindow())
|
||||||
|
|
||||||
const onboardingWindowManager = injeca.provide('windows:onboarding', {
|
const onboardingWindowManager = injeca.provide('windows:onboarding', {
|
||||||
|
dependsOn: { serverChannel, i18n },
|
||||||
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
|
build: ({ dependsOn }) => setupOnboardingWindowManager(dependsOn),
|
||||||
dependsOn: { i18n, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const noticeWindow = injeca.provide('windows:notice', {
|
const noticeWindow = injeca.provide('windows:notice', {
|
||||||
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
|
|
||||||
dependsOn: { i18n, serverChannel },
|
dependsOn: { i18n, serverChannel },
|
||||||
|
build: ({ dependsOn }) => setupNoticeWindowManager(dependsOn),
|
||||||
})
|
})
|
||||||
|
|
||||||
const aboutWindow = injeca.provide('windows:about', {
|
const aboutWindow = injeca.provide('windows:about', {
|
||||||
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
|
|
||||||
dependsOn: { autoUpdater, i18n, serverChannel },
|
dependsOn: { autoUpdater, i18n, serverChannel },
|
||||||
|
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatWindow = injeca.provide('windows:chat', {
|
const chatWindow = injeca.provide('windows:chat', {
|
||||||
|
dependsOn: { widgetsManager, serverChannel, mcpStdioManager, i18n },
|
||||||
build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn),
|
build: ({ dependsOn }) => setupChatWindowReusableFunc(dependsOn),
|
||||||
dependsOn: { i18n, mcpStdioManager, serverChannel, widgetsManager },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const spotlightWindow = injeca.provide('windows:spotlight', {
|
const spotlightWindow = injeca.provide('windows:spotlight', {
|
||||||
|
dependsOn: { serverChannel, i18n, chatWindow, globalShortcut, appConfig },
|
||||||
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
|
build: ({ dependsOn }) => setupSpotlightWindowManager(dependsOn),
|
||||||
dependsOn: { appConfig, chatWindow, globalShortcut, i18n, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const editorWindow = injeca.provide('windows:editor', {
|
const editorWindow = injeca.provide('windows:editor', {
|
||||||
|
dependsOn: { serverChannel, i18n },
|
||||||
build: ({ dependsOn }) => setupEditorWindowManager(dependsOn),
|
build: ({ dependsOn }) => setupEditorWindowManager(dependsOn),
|
||||||
dependsOn: { i18n, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const settingsWindow = injeca.provide('windows:settings', {
|
const settingsWindow = injeca.provide('windows:settings', {
|
||||||
|
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
|
||||||
build: async ({ dependsOn }) =>
|
build: async ({ dependsOn }) =>
|
||||||
setupSettingsWindowReusableFunc({
|
setupSettingsWindowReusableFunc({
|
||||||
...dependsOn,
|
...dependsOn,
|
||||||
getMainWindow: () => userFacingMainWindow,
|
getMainWindow: () => userFacingMainWindow,
|
||||||
}),
|
}),
|
||||||
dependsOn: { autoUpdater, beatSync, devtoolsWindow: devtoolsMarkdownStressWindow, globalShortcut, godotStageManager, i18n, mcpStdioManager, serverChannel, spotlightWindow, widgetsManager },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const mainWindow = injeca.provide('windows:main', {
|
const mainWindow = injeca.provide('windows:main', {
|
||||||
|
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription },
|
||||||
build: async ({ dependsOn }) => setupMainWindow({
|
build: async ({ dependsOn }) => setupMainWindow({
|
||||||
...dependsOn,
|
...dependsOn,
|
||||||
onWindowCreated: (window) => {
|
onWindowCreated: (window) => {
|
||||||
userFacingMainWindow = window
|
userFacingMainWindow = window
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
dependsOn: { appleSpeechTranscription, autoUpdater, beatSync, chatWindow, editorWindow, godotStageManager, i18n, mcpStdioManager, noticeWindow, onboardingWindowManager, serverChannel, settingsWindow, widgetsManager },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const captionWindow = injeca.provide('windows:caption', {
|
const captionWindow = injeca.provide('windows:caption', {
|
||||||
|
dependsOn: { mainWindow, serverChannel, i18n },
|
||||||
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
|
build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn),
|
||||||
dependsOn: { i18n, mainWindow, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const tray = injeca.provide('app:tray', {
|
const tray = injeca.provide('app:tray', {
|
||||||
|
dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager, serverChannel, beatSyncBgWindow: beatSync, aboutWindow, i18n },
|
||||||
build: async ({ dependsOn }) => setupTray(dependsOn),
|
build: async ({ dependsOn }) => setupTray(dependsOn),
|
||||||
dependsOn: { aboutWindow, beatSyncBgWindow: beatSync, captionWindow, i18n, mainWindow, serverChannel, settingsWindow, widgetsWindow: widgetsManager },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1
|
// Desktop grounding overlay — gated by AIRI_DESKTOP_OVERLAY=1
|
||||||
if (isDesktopOverlayEnabled()) {
|
if (isDesktopOverlayEnabled()) {
|
||||||
const desktopOverlay = injeca.provide('windows:desktop-overlay', {
|
const desktopOverlay = injeca.provide('windows:desktop-overlay', {
|
||||||
|
dependsOn: { mcpStdioManager, serverChannel, i18n },
|
||||||
build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn),
|
build: async ({ dependsOn }) => setupDesktopOverlayWindow(dependsOn),
|
||||||
dependsOn: { i18n, mcpStdioManager, serverChannel },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// NOTICE: Separate invoke ensures the overlay is eagerly built.
|
// NOTICE: Separate invoke ensures the overlay is eagerly built.
|
||||||
// Without this, injeca.start() would skip it because no other
|
// Without this, injeca.start() would skip it because no other
|
||||||
// provider depends on 'windows:desktop-overlay'.
|
// provider depends on 'windows:desktop-overlay'.
|
||||||
injeca.invoke({
|
injeca.invoke({
|
||||||
callback: noop,
|
|
||||||
dependsOn: { desktopOverlay },
|
dependsOn: { desktopOverlay },
|
||||||
|
callback: noop,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
injeca.invoke({
|
injeca.invoke({
|
||||||
|
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, spotlightWindow, artistryConfig },
|
||||||
callback: async (deps) => {
|
callback: async (deps) => {
|
||||||
const { context } = createContext(ipcMain)
|
const { context } = createContext(ipcMain)
|
||||||
await setupArtistryBridge({
|
await setupArtistryBridge({
|
||||||
artistryConfig: deps.artistryConfig,
|
|
||||||
context,
|
|
||||||
widgetsManager: deps.widgetsWindow,
|
widgetsManager: deps.widgetsWindow,
|
||||||
|
context,
|
||||||
|
artistryConfig: deps.artistryConfig,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
dependsOn: { airiHttpServer, artistryConfig, godotStageManager, mainWindow, mcpStdioManager, onboardingWindow: onboardingWindowManager, pluginHost, serverChannel, spotlightWindow, tray, widgetsWindow: widgetsManager },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
injeca.start().catch(err => console.error(err))
|
injeca.start().catch(err => console.error(err))
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ const onAppReadyHooks = [] as (() => Promise<void> | void)[]
|
|||||||
const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[]
|
const onAppBeforeQuitHooks = [] as (() => Promise<void> | void)[]
|
||||||
const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[]
|
const onAppWindowAllClosedHooks = [] as (() => Promise<void> | void)[]
|
||||||
|
|
||||||
export async function emitAppBeforeQuit() {
|
export function onAppReady(fn: () => Promise<void> | void) {
|
||||||
for (const fn of onAppBeforeQuitHooks) {
|
onAppReadyHooks.push(fn)
|
||||||
await fn()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function emitAppReady() {
|
export async function emitAppReady() {
|
||||||
@@ -14,20 +12,22 @@ export async function emitAppReady() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function emitAppWindowAllClosed() {
|
|
||||||
for (const fn of onAppWindowAllClosedHooks) {
|
|
||||||
await fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onAppBeforeQuit(fn: () => Promise<void> | void) {
|
export function onAppBeforeQuit(fn: () => Promise<void> | void) {
|
||||||
onAppBeforeQuitHooks.push(fn)
|
onAppBeforeQuitHooks.push(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAppReady(fn: () => Promise<void> | void) {
|
export async function emitAppBeforeQuit() {
|
||||||
onAppReadyHooks.push(fn)
|
for (const fn of onAppBeforeQuitHooks) {
|
||||||
|
await fn()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAppWindowAllClosed(fn: () => Promise<void> | void) {
|
export function onAppWindowAllClosed(fn: () => Promise<void> | void) {
|
||||||
onAppWindowAllClosedHooks.push(fn)
|
onAppWindowAllClosedHooks.push(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function emitAppWindowAllClosed() {
|
||||||
|
for (const fn of onAppWindowAllClosedHooks) {
|
||||||
|
await fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import { is } from '@electron-toolkit/utils'
|
|||||||
|
|
||||||
let electronMainDirname: string = ''
|
let electronMainDirname: string = ''
|
||||||
|
|
||||||
|
export function setElectronMainDirname(dirname: string) {
|
||||||
|
electronMainDirname = dirname
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronMainDirname() {
|
||||||
|
return electronMainDirname
|
||||||
|
}
|
||||||
|
|
||||||
export function baseUrl(parentOfIndexHtml: string, filename?: string) {
|
export function baseUrl(parentOfIndexHtml: string, filename?: string) {
|
||||||
if (is.dev && env.ELECTRON_RENDERER_URL) {
|
if (is.dev && env.ELECTRON_RENDERER_URL) {
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
@@ -25,11 +33,7 @@ export function baseUrl(parentOfIndexHtml: string, filename?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getElectronMainDirname() {
|
export async function load(window: BrowserWindow, url: string | { url: string, options?: LoadURLOptions } | { file: string, options?: LoadFileOptions }) {
|
||||||
return electronMainDirname
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load(window: BrowserWindow, url: string | { file: string, options?: LoadFileOptions } | { options?: LoadURLOptions, url: string }) {
|
|
||||||
try {
|
try {
|
||||||
if (typeof url === 'object' && 'url' in url) {
|
if (typeof url === 'object' && 'url' in url) {
|
||||||
return await window.loadURL(url.url, url.options)
|
return await window.loadURL(url.url, url.options)
|
||||||
@@ -87,10 +91,6 @@ export async function load(window: BrowserWindow, url: string | { file: string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setElectronMainDirname(dirname: string) {
|
|
||||||
electronMainDirname = dirname
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a hash route and optional query to an Electron renderer location.
|
* Adds a hash route and optional query to an Electron renderer location.
|
||||||
*
|
*
|
||||||
@@ -101,7 +101,7 @@ export function setElectronMainDirname(dirname: string) {
|
|||||||
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
|
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
|
||||||
*/
|
*/
|
||||||
export function withHashRoute(
|
export function withHashRoute(
|
||||||
baseUrl: string | { file: string } | { url: string },
|
baseUrl: string | { url: string } | { file: string },
|
||||||
hashRoute: string,
|
hashRoute: string,
|
||||||
options: Pick<LoadFileOptions, 'query'> = {},
|
options: Pick<LoadFileOptions, 'query'> = {},
|
||||||
) {
|
) {
|
||||||
@@ -118,7 +118,7 @@ export function withHashRoute(
|
|||||||
|
|
||||||
baseURLinURL.hash = hashRoute
|
baseURLinURL.hash = hashRoute
|
||||||
|
|
||||||
return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string }
|
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
|
||||||
}
|
}
|
||||||
if (typeof baseUrl === 'object' && 'file' in baseUrl) {
|
if (typeof baseUrl === 'object' && 'file' in baseUrl) {
|
||||||
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
|
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
|
||||||
@@ -136,5 +136,5 @@ export function withHashRoute(
|
|||||||
|
|
||||||
baseURLinURL.hash = hashRoute
|
baseURLinURL.hash = hashRoute
|
||||||
|
|
||||||
return { url: baseURLinURL.toString() } satisfies { options?: LoadURLOptions, url: string }
|
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ describe('createConfig', () => {
|
|||||||
})
|
})
|
||||||
const writeCoordinator = {
|
const writeCoordinator = {
|
||||||
calls: 0,
|
calls: 0,
|
||||||
release: () => {},
|
|
||||||
waitFor: Promise.resolve(),
|
waitFor: Promise.resolve(),
|
||||||
|
release: () => {},
|
||||||
}
|
}
|
||||||
const writeFileMock = vi.fn(async (path: string) => {
|
const writeFileMock = vi.fn(async (path: string) => {
|
||||||
existingTempFiles.add(path)
|
existingTempFiles.add(path)
|
||||||
|
|||||||
@@ -10,37 +10,57 @@ import { app } from 'electron'
|
|||||||
import { throttle } from 'es-toolkit'
|
import { throttle } from 'es-toolkit'
|
||||||
import { safeParse } from 'valibot'
|
import { safeParse } from 'valibot'
|
||||||
|
|
||||||
|
type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error'
|
||||||
|
|
||||||
export interface ConfigDiagnostics<T> {
|
export interface ConfigDiagnostics<T> {
|
||||||
error?: unknown
|
|
||||||
healed?: boolean
|
|
||||||
issues?: BaseIssue<unknown>[]
|
|
||||||
path: string
|
|
||||||
raw?: string
|
|
||||||
status: ConfigStatus
|
status: ConfigStatus
|
||||||
|
path: string
|
||||||
|
issues?: BaseIssue<unknown>[]
|
||||||
|
error?: unknown
|
||||||
|
raw?: string
|
||||||
|
healed?: boolean
|
||||||
value?: T
|
value?: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConfigOptions<T> {
|
export interface CreateConfigOptions<T> {
|
||||||
autoHeal?: boolean
|
|
||||||
default?: T
|
default?: T
|
||||||
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
|
autoHeal?: boolean
|
||||||
onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void
|
onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void
|
||||||
|
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigStatus = 'invalid' | 'missing' | 'ok' | 'read-error'
|
|
||||||
|
|
||||||
const persistenceMap = new Map<string, unknown>()
|
const persistenceMap = new Map<string, unknown>()
|
||||||
const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>()
|
const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>()
|
||||||
|
|
||||||
export interface Config<TSchema extends PersistedSchema> {
|
function createConfigPath(namespace: string, filename: string) {
|
||||||
get: () => InferOutput<TSchema> | undefined
|
return join(app.getPath('userData'), `${namespace}-${filename}`)
|
||||||
getDiagnostics: () => ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
}
|
||||||
setup: () => ConfigDiagnostics<InferOutput<TSchema>>
|
|
||||||
update: (newData: InferOutput<TSchema>) => void
|
async function ensureConfigDirectory(path: string) {
|
||||||
|
await mkdir(dirname(path), { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
type PersistedSchema = BaseSchema<unknown, unknown, BaseIssue<unknown>>
|
type PersistedSchema = BaseSchema<unknown, unknown, BaseIssue<unknown>>
|
||||||
|
|
||||||
|
function parseWithSchema<TSchema extends PersistedSchema>(
|
||||||
|
raw: string,
|
||||||
|
schema: TSchema,
|
||||||
|
): { value?: InferOutput<TSchema>, issues?: InferIssue<TSchema>[] } {
|
||||||
|
const parsed = safeDestr<unknown>(raw)
|
||||||
|
const result = safeParse(schema, parsed)
|
||||||
|
if (result.success) {
|
||||||
|
return { value: result.output }
|
||||||
|
}
|
||||||
|
return { issues: result.issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config<TSchema extends PersistedSchema> {
|
||||||
|
setup: () => ConfigDiagnostics<InferOutput<TSchema>>
|
||||||
|
get: () => InferOutput<TSchema> | undefined
|
||||||
|
update: (newData: InferOutput<TSchema>) => void
|
||||||
|
getDiagnostics: () => ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function createConfig<TSchema extends PersistedSchema>(
|
export function createConfig<TSchema extends PersistedSchema>(
|
||||||
namespace: string,
|
namespace: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -90,8 +110,8 @@ export function createConfig<TSchema extends PersistedSchema>(
|
|||||||
const path = configPath()
|
const path = configPath()
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
const diagnostics = recordDiagnostics({
|
const diagnostics = recordDiagnostics({
|
||||||
path,
|
|
||||||
status: 'missing',
|
status: 'missing',
|
||||||
|
path,
|
||||||
value: options?.default,
|
value: options?.default,
|
||||||
})
|
})
|
||||||
persistenceMap.set(key, options?.default)
|
persistenceMap.set(key, options?.default)
|
||||||
@@ -103,8 +123,8 @@ export function createConfig<TSchema extends PersistedSchema>(
|
|||||||
const parsed = parseWithSchema(raw, schema)
|
const parsed = parseWithSchema(raw, schema)
|
||||||
if (parsed.value !== undefined) {
|
if (parsed.value !== undefined) {
|
||||||
const diagnostics = recordDiagnostics({
|
const diagnostics = recordDiagnostics({
|
||||||
path,
|
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
path,
|
||||||
value: parsed.value,
|
value: parsed.value,
|
||||||
})
|
})
|
||||||
persistenceMap.set(key, parsed.value)
|
persistenceMap.set(key, parsed.value)
|
||||||
@@ -113,10 +133,10 @@ export function createConfig<TSchema extends PersistedSchema>(
|
|||||||
|
|
||||||
const fallback = options?.default
|
const fallback = options?.default
|
||||||
const diagnostics = recordDiagnostics({
|
const diagnostics = recordDiagnostics({
|
||||||
issues: parsed.issues,
|
|
||||||
path,
|
|
||||||
raw,
|
|
||||||
status: 'invalid',
|
status: 'invalid',
|
||||||
|
path,
|
||||||
|
issues: parsed.issues,
|
||||||
|
raw,
|
||||||
value: fallback,
|
value: fallback,
|
||||||
})
|
})
|
||||||
options?.onValidationFailure?.(diagnostics)
|
options?.onValidationFailure?.(diagnostics)
|
||||||
@@ -134,9 +154,9 @@ export function createConfig<TSchema extends PersistedSchema>(
|
|||||||
catch (error) {
|
catch (error) {
|
||||||
const fallback = options?.default
|
const fallback = options?.default
|
||||||
const diagnostics = recordDiagnostics({
|
const diagnostics = recordDiagnostics({
|
||||||
error,
|
|
||||||
path,
|
|
||||||
status: 'read-error',
|
status: 'read-error',
|
||||||
|
path,
|
||||||
|
error,
|
||||||
value: fallback,
|
value: fallback,
|
||||||
})
|
})
|
||||||
options?.onReadError?.(diagnostics)
|
options?.onReadError?.(diagnostics)
|
||||||
@@ -155,29 +175,9 @@ export function createConfig<TSchema extends PersistedSchema>(
|
|||||||
const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get,
|
|
||||||
getDiagnostics,
|
|
||||||
setup,
|
setup,
|
||||||
|
get,
|
||||||
update,
|
update,
|
||||||
|
getDiagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConfigPath(namespace: string, filename: string) {
|
|
||||||
return join(app.getPath('userData'), `${namespace}-${filename}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureConfigDirectory(path: string) {
|
|
||||||
await mkdir(dirname(path), { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWithSchema<TSchema extends PersistedSchema>(
|
|
||||||
raw: string,
|
|
||||||
schema: TSchema,
|
|
||||||
): { issues?: InferIssue<TSchema>[], value?: InferOutput<TSchema> } {
|
|
||||||
const parsed = safeDestr<unknown>(raw)
|
|
||||||
const result = safeParse(schema, parsed)
|
|
||||||
if (result.success) {
|
|
||||||
return { value: result.output }
|
|
||||||
}
|
|
||||||
return { issues: result.issues }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ import { createCoreContext, translate } from '@intlify/core'
|
|||||||
import { effect, signal } from 'alien-signals'
|
import { effect, signal } from 'alien-signals'
|
||||||
import { isString } from 'es-toolkit'
|
import { isString } from 'es-toolkit'
|
||||||
|
|
||||||
export interface I18n<Schema extends Record<string, any> = Record<string, any>> {
|
|
||||||
locale:
|
|
||||||
(() => (LocaleDetector<any[]> | string | undefined)) | ((value: LocaleDetector<any[]> | string | undefined) => void)
|
|
||||||
t: TranslationFunction<Schema>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResolveResourceKeys<
|
type ResolveResourceKeys<
|
||||||
// eslint-disable-next-line ts/no-empty-object-type
|
// eslint-disable-next-line ts/no-empty-object-type
|
||||||
Schema extends Record<string, any> = {},
|
Schema extends Record<string, any> = {},
|
||||||
@@ -34,7 +28,7 @@ type ResolveResourceKeys<
|
|||||||
[K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K]
|
[K in keyof DefinedLocaleMessage]: DefinedLocaleMessage[K]
|
||||||
}>
|
}>
|
||||||
: never,
|
: never,
|
||||||
> = DefineMessagesPaths | SchemaPaths
|
> = SchemaPaths | DefineMessagesPaths
|
||||||
|
|
||||||
interface TranslationFunction<
|
interface TranslationFunction<
|
||||||
// eslint-disable-next-line ts/no-empty-object-type
|
// eslint-disable-next-line ts/no-empty-object-type
|
||||||
@@ -138,17 +132,23 @@ interface TranslationFunction<
|
|||||||
): string
|
): string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface I18n<Schema extends Record<string, any> = Record<string, any>> {
|
||||||
|
t: TranslationFunction<Schema>
|
||||||
|
locale:
|
||||||
|
(() => (string | LocaleDetector<any[]> | undefined)) | ((value: string | LocaleDetector<any[]> | undefined) => void)
|
||||||
|
}
|
||||||
|
|
||||||
export function createI18n<Schema extends Record<string, any> = Record<string, any>>(options: CoreOptions): I18n<Schema> {
|
export function createI18n<Schema extends Record<string, any> = Record<string, any>>(options: CoreOptions): I18n<Schema> {
|
||||||
const log = useLogg('i18n').useGlobalConfig()
|
const log = useLogg('i18n').useGlobalConfig()
|
||||||
|
|
||||||
const locale = signal(options.locale)
|
const locale = signal(options.locale)
|
||||||
|
|
||||||
const context = createCoreContext({
|
const context = createCoreContext({
|
||||||
fallbackFormat: true,
|
|
||||||
fallbackLocale: options.fallbackLocale,
|
fallbackLocale: options.fallbackLocale,
|
||||||
fallbackWarn: false,
|
fallbackWarn: false,
|
||||||
missingWarn: false,
|
missingWarn: false,
|
||||||
warnHtmlMessage: false,
|
warnHtmlMessage: false,
|
||||||
|
fallbackFormat: true,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export function createI18n<Schema extends Record<string, any> = Record<string, a
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
|
||||||
t,
|
t,
|
||||||
|
locale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,6 @@ const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
|
|||||||
let closeLoopback: (() => void) | null = null
|
let closeLoopback: (() => void) | null = null
|
||||||
let signingInFlight = false
|
let signingInFlight = false
|
||||||
|
|
||||||
interface TokenExchangeResult {
|
|
||||||
accessToken: string
|
|
||||||
expiresIn: number
|
|
||||||
idToken?: string
|
|
||||||
refreshToken?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Internal helpers ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the auth service IPC handlers for a given window context.
|
* Create the auth service IPC handlers for a given window context.
|
||||||
*/
|
*/
|
||||||
@@ -135,20 +126,29 @@ export function createAuthService(params: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Internal helpers ---
|
||||||
|
|
||||||
|
interface TokenExchangeResult {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken?: string
|
||||||
|
idToken?: string
|
||||||
|
expiresIn: number
|
||||||
|
}
|
||||||
|
|
||||||
async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenExchangeResult> {
|
async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise<TokenExchangeResult> {
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
client_id: OIDC_CLIENT_ID,
|
|
||||||
code,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
|
client_id: OIDC_CLIENT_ID,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
resource: SERVER_URL,
|
resource: SERVER_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
|
const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
|
||||||
body,
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -159,8 +159,8 @@ async function exchangeCode(code: string, codeVerifier: string, redirectUri: str
|
|||||||
const data = await response.json() as Record<string, unknown>
|
const data = await response.json() as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token as string,
|
accessToken: data.access_token as string,
|
||||||
expiresIn: data.expires_in as number,
|
|
||||||
idToken: data.id_token as string | undefined,
|
|
||||||
refreshToken: data.refresh_token as string | undefined,
|
refreshToken: data.refresh_token as string | undefined,
|
||||||
|
idToken: data.id_token as string | undefined,
|
||||||
|
expiresIn: data.expires_in as number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import { createConfig } from '../../../libs/electron/persistence'
|
|||||||
import { ensureServerChannelConfigDefaults } from './config'
|
import { ensureServerChannelConfigDefaults } from './config'
|
||||||
|
|
||||||
const channelServerConfigSchema = object({
|
const channelServerConfigSchema = object({
|
||||||
authToken: optional(string()),
|
|
||||||
hostname: optional(string()),
|
hostname: optional(string()),
|
||||||
|
authToken: optional(string()),
|
||||||
tlsConfig: optional(nullable(object({
|
tlsConfig: optional(nullable(object({
|
||||||
cert: optional(string()),
|
cert: optional(string()),
|
||||||
key: optional(string()),
|
key: optional(string()),
|
||||||
@@ -41,44 +41,412 @@ const channelServerConfigSchema = object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const channelServerInvokeConfigSchema = z.object({
|
const channelServerInvokeConfigSchema = z.object({
|
||||||
authToken: z.string().optional(),
|
|
||||||
hostname: z.string().optional(),
|
hostname: z.string().optional(),
|
||||||
|
authToken: z.string().optional(),
|
||||||
tlsConfig: z.object({ }).nullable().optional(),
|
tlsConfig: z.object({ }).nullable().optional(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
|
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
|
||||||
autoHeal: true,
|
|
||||||
default: {
|
default: {
|
||||||
authToken: '',
|
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
|
authToken: '',
|
||||||
tlsConfig: null,
|
tlsConfig: null,
|
||||||
},
|
},
|
||||||
|
autoHeal: true,
|
||||||
})
|
})
|
||||||
let serverChannelServiceRegistered = false
|
let serverChannelServiceRegistered = false
|
||||||
let serverChannelCertificateTrustConfigured = false
|
let serverChannelCertificateTrustConfigured = false
|
||||||
|
|
||||||
interface ServerChannelCertificateVerifyRequest {
|
interface ServerChannelCertificateVerifyRequest {
|
||||||
|
hostname: string
|
||||||
|
verificationResult: string
|
||||||
|
errorCode: number
|
||||||
certificate: {
|
certificate: {
|
||||||
|
subject: {
|
||||||
|
commonName: string
|
||||||
|
}
|
||||||
issuer: {
|
issuer: {
|
||||||
commonName: string
|
commonName: string
|
||||||
country: string
|
country: string
|
||||||
locality: string
|
locality: string
|
||||||
organizations: string[]
|
organizations: string[]
|
||||||
}
|
}
|
||||||
subject: {
|
|
||||||
commonName: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
errorCode: number
|
|
||||||
hostname: string
|
|
||||||
verificationResult: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerChannelPort() {
|
function getServerChannelPort() {
|
||||||
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
|
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string) {
|
||||||
|
return LOOPBACK_HOSTS.has(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) {
|
||||||
|
if (config.hostname === '0.0.0.0') {
|
||||||
|
return Array.from(new Set(serverChannel.getConnectionHost()))
|
||||||
|
.filter(host => !isLoopbackHost(host))
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoopbackHost(config.hostname)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [config.hostname]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) {
|
||||||
|
const urlHost = isIP(host) === 6 ? `[${host}]` : host
|
||||||
|
// TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts`
|
||||||
|
// and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals.
|
||||||
|
return `${protocol}://${urlHost}:${getServerChannelPort()}/ws`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) {
|
||||||
|
const protocol = config.tlsConfig ? 'wss' : 'ws'
|
||||||
|
const urls = getServerChannelQrHosts(config, serverChannel)
|
||||||
|
.map(host => createServerChannelUrl(protocol, host))
|
||||||
|
|
||||||
|
if (!urls.length) {
|
||||||
|
throw new Error('No reachable private LAN address is available for the current server channel host.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return createServerChannelQrPayload({
|
||||||
|
type: 'airi:server-channel',
|
||||||
|
version: 1,
|
||||||
|
urls,
|
||||||
|
authToken: config.authToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
|
||||||
|
const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null }
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: config.hostname || '127.0.0.1',
|
||||||
|
authToken: config.authToken || '',
|
||||||
|
tlsConfig: config.tlsConfig || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerRuntimeBaseOptions() {
|
||||||
|
return {
|
||||||
|
port: getServerChannelPort(),
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
|
||||||
|
return {
|
||||||
|
...getServerRuntimeBaseOptions(),
|
||||||
|
auth: {
|
||||||
|
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
|
||||||
|
},
|
||||||
|
hostname: 'hostname' in config && typeof config.hostname === 'string'
|
||||||
|
? config.hostname || '127.0.0.1'
|
||||||
|
: '127.0.0.1',
|
||||||
|
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
|
||||||
|
if (!fallback) {
|
||||||
|
fallback = await getChannelServerConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedConfig = {
|
||||||
|
hostname: parsed.data.hostname ?? fallback.hostname,
|
||||||
|
authToken: parsed.data.authToken ?? fallback.authToken,
|
||||||
|
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCertificateDomains(): string[] {
|
||||||
|
const localIPs = getLocalIPs()
|
||||||
|
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
|
||||||
|
return Array.from(new Set([
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1',
|
||||||
|
...(hostname ? [hostname] : []),
|
||||||
|
...localIPs,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCertificatePaths() {
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
|
||||||
|
return {
|
||||||
|
certPath: join(userDataPath, 'websocket-cert.pem'),
|
||||||
|
keyPath: join(userDataPath, 'websocket-key.pem'),
|
||||||
|
caCertPath: join(userDataPath, 'websocket-ca-cert.pem'),
|
||||||
|
caKeyPath: join(userDataPath, 'websocket-ca-key.pem'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCertificateChain(cert: string, caCert?: string) {
|
||||||
|
return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert
|
||||||
|
}
|
||||||
|
|
||||||
|
function certHasAllDomains(certPem: string, domains: string[]): boolean {
|
||||||
|
try {
|
||||||
|
const cert = new X509Certificate(certPem)
|
||||||
|
const san = cert.subjectAltName || ''
|
||||||
|
const entries = san.split(',').map(part => part.trim())
|
||||||
|
const values = entries
|
||||||
|
.map((entry) => {
|
||||||
|
if (entry.startsWith('DNS:'))
|
||||||
|
return entry.slice(4).trim()
|
||||||
|
if (entry.startsWith('IP Address:'))
|
||||||
|
return entry.slice(11).trim()
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const sanSet = new Set(values)
|
||||||
|
return domains.every(domain => sanSet.has(domain))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean {
|
||||||
|
if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult)
|
||||||
|
&& request.errorCode !== -202) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getCertificateDomains().includes(request.hostname)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = request.certificate.issuer
|
||||||
|
return request.certificate.subject.commonName === 'localhost'
|
||||||
|
&& issuer.commonName === 'AIRI'
|
||||||
|
&& issuer.country === 'US'
|
||||||
|
&& issuer.locality === 'Local'
|
||||||
|
&& issuer.organizations.includes('AIRI')
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureServerChannelCertificateTrust() {
|
||||||
|
if (serverChannelCertificateTrustConfigured) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
||||||
|
if (isTrustedServerChannelCertificate(request)) {
|
||||||
|
callback(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(-3)
|
||||||
|
})
|
||||||
|
|
||||||
|
serverChannelCertificateTrustConfigured = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installCACertificate(caCert: string) {
|
||||||
|
const { caCertPath } = getCertificatePaths()
|
||||||
|
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||||
|
writeFileSync(caCertPath, caCert)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } })
|
||||||
|
}
|
||||||
|
else if (platform === 'win32') {
|
||||||
|
await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } })
|
||||||
|
}
|
||||||
|
else if (platform === 'linux') {
|
||||||
|
const caDir = '/usr/local/share/ca-certificates'
|
||||||
|
const caFileName = 'airi-websocket-ca.crt'
|
||||||
|
try {
|
||||||
|
writeFileSync(join(caDir, caFileName), caCert)
|
||||||
|
await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
|
||||||
|
try {
|
||||||
|
if (!existsSync(userCaDir)) {
|
||||||
|
await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } })
|
||||||
|
}
|
||||||
|
writeFileSync(join(userCaDir, caFileName), caCert)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCertificate() {
|
||||||
|
const { caCertPath, caKeyPath } = getCertificatePaths()
|
||||||
|
|
||||||
|
let ca: { key: string, cert: string }
|
||||||
|
|
||||||
|
if (existsSync(caCertPath) && existsSync(caKeyPath)) {
|
||||||
|
ca = {
|
||||||
|
cert: readFileSync(caCertPath, 'utf-8'),
|
||||||
|
key: readFileSync(caKeyPath, 'utf-8'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ca = await createCA({
|
||||||
|
organization: 'AIRI',
|
||||||
|
countryCode: 'US',
|
||||||
|
state: 'Development',
|
||||||
|
locality: 'Local',
|
||||||
|
validity: 365,
|
||||||
|
})
|
||||||
|
writeFileSync(caCertPath, ca.cert)
|
||||||
|
writeFileSync(caKeyPath, ca.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
await installCACertificate(ca.cert)
|
||||||
|
|
||||||
|
const domains = getCertificateDomains()
|
||||||
|
|
||||||
|
const cert = await createCert({
|
||||||
|
ca: { key: ca.key, cert: ca.cert },
|
||||||
|
domains,
|
||||||
|
validity: 365,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
cert: cert.cert,
|
||||||
|
key: cert.key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateCertificate() {
|
||||||
|
const { certPath, keyPath, caCertPath } = getCertificatePaths()
|
||||||
|
const expectedDomains = getCertificateDomains()
|
||||||
|
|
||||||
|
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||||
|
const cert = readFileSync(certPath, 'utf-8')
|
||||||
|
const key = readFileSync(keyPath, 'utf-8')
|
||||||
|
if (certHasAllDomains(cert, expectedDomains)) {
|
||||||
|
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
||||||
|
return { cert: withCertificateChain(cert, caCert), key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cert, key } = await generateCertificate()
|
||||||
|
writeFileSync(certPath, cert)
|
||||||
|
writeFileSync(keyPath, key)
|
||||||
|
|
||||||
|
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
||||||
|
return { cert: withCertificateChain(cert, caCert), key }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
|
||||||
|
channelServerConfigStore.setup()
|
||||||
|
configureServerChannelCertificateTrust()
|
||||||
|
|
||||||
|
const storedConfig = await getChannelServerConfig()
|
||||||
|
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
|
||||||
|
if (storedConfigChanged) {
|
||||||
|
channelServerConfigStore.update(normalizedStoredConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
|
||||||
|
|
||||||
|
const mutex = new Mutex()
|
||||||
|
|
||||||
|
params.lifecycle.appHooks.onStart(async () => {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
|
||||||
|
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serverChannel.start()
|
||||||
|
log.log('WebSocket server started')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.withError(error).error('Error starting WebSocket server')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
params.lifecycle.appHooks.onStop(async () => {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
|
||||||
|
const log = useLogg('main/server-runtime').useGlobalConfig()
|
||||||
|
if (!serverChannel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serverChannel.stop()
|
||||||
|
log.log('WebSocket server closed')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.withError(error).error('Error closing WebSocket server')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
getConnectionHost() {
|
||||||
|
return serverChannel.getConnectionHost()
|
||||||
|
},
|
||||||
|
async start() {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
try {
|
||||||
|
await serverChannel.start()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async restart() {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
try {
|
||||||
|
await serverChannel.stop()
|
||||||
|
await serverChannel.start()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
try {
|
||||||
|
await serverChannel.stop()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async updateConfig(config) {
|
||||||
|
const release = await mutex.acquire()
|
||||||
|
try {
|
||||||
|
await serverChannel.updateConfig(config)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createServerChannelService(params: { serverChannel: Server }) {
|
export async function createServerChannelService(params: { serverChannel: Server }) {
|
||||||
if (serverChannelServiceRegistered) {
|
if (serverChannelServiceRegistered) {
|
||||||
@@ -138,372 +506,4 @@ export async function createServerChannelService(params: { serverChannel: Server
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
|
|
||||||
channelServerConfigStore.setup()
|
|
||||||
configureServerChannelCertificateTrust()
|
|
||||||
|
|
||||||
const storedConfig = await getChannelServerConfig()
|
|
||||||
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
|
|
||||||
if (storedConfigChanged) {
|
|
||||||
channelServerConfigStore.update(normalizedStoredConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
|
|
||||||
|
|
||||||
const mutex = new Mutex()
|
|
||||||
|
|
||||||
params.lifecycle.appHooks.onStart(async () => {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
|
|
||||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await serverChannel.start()
|
|
||||||
log.log('WebSocket server started')
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
log.withError(error).error('Error starting WebSocket server')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
params.lifecycle.appHooks.onStop(async () => {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
|
|
||||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
|
||||||
if (!serverChannel) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await serverChannel.stop()
|
|
||||||
log.log('WebSocket server closed')
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
log.withError(error).error('Error closing WebSocket server')
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
getConnectionHost() {
|
|
||||||
return serverChannel.getConnectionHost()
|
|
||||||
},
|
|
||||||
async restart() {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
try {
|
|
||||||
await serverChannel.stop()
|
|
||||||
await serverChannel.start()
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async start() {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
try {
|
|
||||||
await serverChannel.start()
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async stop() {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
try {
|
|
||||||
await serverChannel.stop()
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async updateConfig(config) {
|
|
||||||
const release = await mutex.acquire()
|
|
||||||
try {
|
|
||||||
await serverChannel.updateConfig(config)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function certHasAllDomains(certPem: string, domains: string[]): boolean {
|
|
||||||
try {
|
|
||||||
const cert = new X509Certificate(certPem)
|
|
||||||
const san = cert.subjectAltName || ''
|
|
||||||
const entries = san.split(',').map(part => part.trim())
|
|
||||||
const values = entries
|
|
||||||
.map((entry) => {
|
|
||||||
if (entry.startsWith('DNS:'))
|
|
||||||
return entry.slice(4).trim()
|
|
||||||
if (entry.startsWith('IP Address:'))
|
|
||||||
return entry.slice(11).trim()
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const sanSet = new Set(values)
|
|
||||||
return domains.every(domain => sanSet.has(domain))
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureServerChannelCertificateTrust() {
|
|
||||||
if (serverChannelCertificateTrustConfigured) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
|
||||||
if (isTrustedServerChannelCertificate(request)) {
|
|
||||||
callback(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callback(-3)
|
|
||||||
})
|
|
||||||
|
|
||||||
serverChannelCertificateTrustConfigured = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function createServerChannelUrl(protocol: 'ws' | 'wss', host: string) {
|
|
||||||
const urlHost = isIP(host) === 6 ? `[${host}]` : host
|
|
||||||
// TODO: Deduplicate the server channel websocket path with `packages/server-runtime/src/index.ts`
|
|
||||||
// and `packages/server-sdk/src/client.ts` so this does not rely on three separate `/ws` literals.
|
|
||||||
return `${protocol}://${urlHost}:${getServerChannelPort()}/ws`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateCertificate() {
|
|
||||||
const { caCertPath, caKeyPath } = getCertificatePaths()
|
|
||||||
|
|
||||||
let ca: { cert: string, key: string }
|
|
||||||
|
|
||||||
if (existsSync(caCertPath) && existsSync(caKeyPath)) {
|
|
||||||
ca = {
|
|
||||||
cert: readFileSync(caCertPath, 'utf-8'),
|
|
||||||
key: readFileSync(caKeyPath, 'utf-8'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
ca = await createCA({
|
|
||||||
countryCode: 'US',
|
|
||||||
locality: 'Local',
|
|
||||||
organization: 'AIRI',
|
|
||||||
state: 'Development',
|
|
||||||
validity: 365,
|
|
||||||
})
|
|
||||||
writeFileSync(caCertPath, ca.cert)
|
|
||||||
writeFileSync(caKeyPath, ca.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
await installCACertificate(ca.cert)
|
|
||||||
|
|
||||||
const domains = getCertificateDomains()
|
|
||||||
|
|
||||||
const cert = await createCert({
|
|
||||||
ca: { cert: ca.cert, key: ca.key },
|
|
||||||
domains,
|
|
||||||
validity: 365,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
cert: cert.cert,
|
|
||||||
key: cert.key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCertificateDomains(): string[] {
|
|
||||||
const localIPs = getLocalIPs()
|
|
||||||
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
|
|
||||||
return Array.from(new Set([
|
|
||||||
'127.0.0.1',
|
|
||||||
'::1',
|
|
||||||
'localhost',
|
|
||||||
...(hostname ? [hostname] : []),
|
|
||||||
...localIPs,
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCertificatePaths() {
|
|
||||||
const userDataPath = app.getPath('userData')
|
|
||||||
|
|
||||||
return {
|
|
||||||
caCertPath: join(userDataPath, 'websocket-ca-cert.pem'),
|
|
||||||
caKeyPath: join(userDataPath, 'websocket-ca-key.pem'),
|
|
||||||
certPath: join(userDataPath, 'websocket-cert.pem'),
|
|
||||||
keyPath: join(userDataPath, 'websocket-key.pem'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
|
|
||||||
const config = channelServerConfigStore.get() || { authToken: '', hostname: '127.0.0.1', tlsConfig: null }
|
|
||||||
|
|
||||||
return {
|
|
||||||
authToken: config.authToken || '',
|
|
||||||
hostname: config.hostname || '127.0.0.1',
|
|
||||||
tlsConfig: config.tlsConfig || null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOrCreateCertificate() {
|
|
||||||
const { caCertPath, certPath, keyPath } = getCertificatePaths()
|
|
||||||
const expectedDomains = getCertificateDomains()
|
|
||||||
|
|
||||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
|
||||||
const cert = readFileSync(certPath, 'utf-8')
|
|
||||||
const key = readFileSync(keyPath, 'utf-8')
|
|
||||||
if (certHasAllDomains(cert, expectedDomains)) {
|
|
||||||
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
|
||||||
return { cert: withCertificateChain(cert, caCert), key }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cert, key } = await generateCertificate()
|
|
||||||
writeFileSync(certPath, cert)
|
|
||||||
writeFileSync(keyPath, key)
|
|
||||||
|
|
||||||
const caCert = existsSync(caCertPath) ? readFileSync(caCertPath, 'utf-8') : undefined
|
|
||||||
return { cert: withCertificateChain(cert, caCert), key }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerChannelQrHosts(config: ElectronServerChannelConfig, serverChannel: Server) {
|
|
||||||
if (config.hostname === '0.0.0.0') {
|
|
||||||
return Array.from(new Set(serverChannel.getConnectionHost()))
|
|
||||||
.filter(host => !isLoopbackHost(host))
|
|
||||||
.sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoopbackHost(config.hostname)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return [config.hostname]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerChannelQrPayload(config: ElectronServerChannelConfig, serverChannel: Server) {
|
|
||||||
const protocol = config.tlsConfig ? 'wss' : 'ws'
|
|
||||||
const urls = getServerChannelQrHosts(config, serverChannel)
|
|
||||||
.map(host => createServerChannelUrl(protocol, host))
|
|
||||||
|
|
||||||
if (!urls.length) {
|
|
||||||
throw new Error('No reachable private LAN address is available for the current server channel host.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return createServerChannelQrPayload({
|
|
||||||
authToken: config.authToken,
|
|
||||||
type: 'airi:server-channel',
|
|
||||||
urls,
|
|
||||||
version: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerRuntimeBaseOptions() {
|
|
||||||
return {
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port: getServerChannelPort(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installCACertificate(caCert: string) {
|
|
||||||
const { caCertPath } = getCertificatePaths()
|
|
||||||
const log = useLogg('main/server-runtime').useGlobalConfig()
|
|
||||||
writeFileSync(caCertPath, caCert)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } })
|
|
||||||
}
|
|
||||||
else if (platform === 'win32') {
|
|
||||||
await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } })
|
|
||||||
}
|
|
||||||
else if (platform === 'linux') {
|
|
||||||
const caDir = '/usr/local/share/ca-certificates'
|
|
||||||
const caFileName = 'airi-websocket-ca.crt'
|
|
||||||
try {
|
|
||||||
writeFileSync(join(caDir, caFileName), caCert)
|
|
||||||
await x('update-ca-certificates', [], { nodeOptions: { stdio: 'ignore' } })
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
|
|
||||||
try {
|
|
||||||
if (!existsSync(userCaDir)) {
|
|
||||||
await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } })
|
|
||||||
}
|
|
||||||
writeFileSync(join(userCaDir, caFileName), caCert)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackHost(host: string) {
|
|
||||||
return LOOPBACK_HOSTS.has(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean {
|
|
||||||
if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult)
|
|
||||||
&& request.errorCode !== -202) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!getCertificateDomains().includes(request.hostname)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const issuer = request.certificate.issuer
|
|
||||||
return request.certificate.subject.commonName === 'localhost'
|
|
||||||
&& issuer.commonName === 'AIRI'
|
|
||||||
&& issuer.country === 'US'
|
|
||||||
&& issuer.locality === 'Local'
|
|
||||||
&& issuer.organizations.includes('AIRI')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
|
|
||||||
if (!fallback) {
|
|
||||||
fallback = await getChannelServerConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = channelServerInvokeConfigSchema.safeParse(payload)
|
|
||||||
if (!parsed.success) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedConfig = {
|
|
||||||
authToken: parsed.data.authToken ?? fallback.authToken,
|
|
||||||
hostname: parsed.data.hostname ?? fallback.hostname,
|
|
||||||
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
|
|
||||||
return {
|
|
||||||
...getServerRuntimeBaseOptions(),
|
|
||||||
auth: {
|
|
||||||
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
|
|
||||||
},
|
|
||||||
hostname: 'hostname' in config && typeof config.hostname === 'string'
|
|
||||||
? config.hostname || '127.0.0.1'
|
|
||||||
: '127.0.0.1',
|
|
||||||
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withCertificateChain(cert: string, caCert?: string) {
|
|
||||||
return caCert ? `${cert.trim()}\n${caCert.trim()}\n` : cert
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Server as ServerChannel }
|
export type { Server as ServerChannel }
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { HTTPError } from 'h3'
|
import { HTTPError } from 'h3'
|
||||||
|
|
||||||
|
export interface HttpErrorInput {
|
||||||
|
status: number
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
reason?: string
|
||||||
|
details?: unknown
|
||||||
|
expose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface H3HttpErrorOptions {
|
export interface H3HttpErrorOptions {
|
||||||
headers?: HeadersInit
|
headers?: HeadersInit
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpErrorInput {
|
|
||||||
code: string
|
|
||||||
details?: unknown
|
|
||||||
expose?: boolean
|
|
||||||
message: string
|
|
||||||
reason?: string
|
|
||||||
status: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified HTTP error shape for AIRI local HTTP server modules.
|
* Unified HTTP error shape for AIRI local HTTP server modules.
|
||||||
*
|
*
|
||||||
@@ -28,11 +28,11 @@ export interface HttpErrorInput {
|
|||||||
* - Error instance with status and structured metadata
|
* - Error instance with status and structured metadata
|
||||||
*/
|
*/
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
|
readonly status: number
|
||||||
readonly code: string
|
readonly code: string
|
||||||
|
readonly reason?: string
|
||||||
readonly details?: unknown
|
readonly details?: unknown
|
||||||
readonly expose: boolean
|
readonly expose: boolean
|
||||||
readonly reason?: string
|
|
||||||
readonly status: number
|
|
||||||
|
|
||||||
constructor(input: HttpErrorInput) {
|
constructor(input: HttpErrorInput) {
|
||||||
super(input.message)
|
super(input.message)
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export interface LoopbackCallbackResult {
|
|||||||
* - Random bound port, callback result promise, and manual cancellation method
|
* - Random bound port, callback result promise, and manual cancellation method
|
||||||
*/
|
*/
|
||||||
export async function startLoopbackServer(expectedState: string): Promise<{
|
export async function startLoopbackServer(expectedState: string): Promise<{
|
||||||
close: () => void
|
|
||||||
port: number
|
port: number
|
||||||
result: Promise<LoopbackCallbackResult>
|
result: Promise<LoopbackCallbackResult>
|
||||||
|
close: () => void
|
||||||
}> {
|
}> {
|
||||||
const host = '127.0.0.1'
|
const host = '127.0.0.1'
|
||||||
let settled = false
|
let settled = false
|
||||||
@@ -44,8 +44,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
|||||||
const app = new H3()
|
const app = new H3()
|
||||||
const loopbackServer = createH3Server({ app, host })
|
const loopbackServer = createH3Server({ app, host })
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
methods: '*',
|
|
||||||
origin: '*',
|
origin: '*',
|
||||||
|
methods: '*',
|
||||||
preflight: {
|
preflight: {
|
||||||
statusCode: 204,
|
statusCode: 204,
|
||||||
},
|
},
|
||||||
@@ -93,8 +93,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
|||||||
const state = typeof query.state === 'string' ? query.state : ''
|
const state = typeof query.state === 'string' ? query.state : ''
|
||||||
if (!state || state !== expectedState) {
|
if (!state || state !== expectedState) {
|
||||||
return new Response('<html><body><h2>Invalid state</h2></body></html>', {
|
return new Response('<html><body><h2>Invalid state</h2></body></html>', {
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
status: 400,
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,16 +107,16 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
|||||||
rejectResult(new Error(description))
|
rejectResult(new Error(description))
|
||||||
})
|
})
|
||||||
return new Response('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>', {
|
return new Response('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>', {
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
status: 200,
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = typeof query.code === 'string' ? query.code : ''
|
const code = typeof query.code === 'string' ? query.code : ''
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return new Response('<html><body><h2>Missing parameters</h2></body></html>', {
|
return new Response('<html><body><h2>Missing parameters</h2></body></html>', {
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
status: 400,
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +125,8 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
|||||||
})
|
})
|
||||||
|
|
||||||
return new Response('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', {
|
return new Response('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', {
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
status: 200,
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -139,12 +139,12 @@ export async function startLoopbackServer(expectedState: string): Promise<{
|
|||||||
}, 5 * 60 * 1000)
|
}, 5 * 60 * 1000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
port: address.port,
|
||||||
|
result,
|
||||||
close: () => {
|
close: () => {
|
||||||
finish(() => {
|
finish(() => {
|
||||||
rejectResult(new Error('OIDC sign-in attempt cancelled'))
|
rejectResult(new Error('OIDC sign-in attempt cancelled'))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
port: address.port,
|
|
||||||
result,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export interface BuiltInServer {
|
|||||||
*/
|
*/
|
||||||
export function setupBuiltInServer(params: {
|
export function setupBuiltInServer(params: {
|
||||||
authServer?: ServerManager
|
authServer?: ServerManager
|
||||||
servers?: ServerManager[]
|
|
||||||
staticAssetServer?: ServerManager
|
staticAssetServer?: ServerManager
|
||||||
|
servers?: ServerManager[]
|
||||||
}): BuiltInServer {
|
}): BuiltInServer {
|
||||||
const servers = [
|
const servers = [
|
||||||
...(params.authServer ? [params.authServer] : []),
|
...(params.authServer ? [params.authServer] : []),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { getRandomPort } from 'get-port-please'
|
|||||||
import { serve } from 'h3'
|
import { serve } from 'h3'
|
||||||
|
|
||||||
export interface BuiltInServerAddress {
|
export interface BuiltInServerAddress {
|
||||||
baseUrl: string
|
|
||||||
host: string
|
host: string
|
||||||
port: number
|
port: number
|
||||||
|
baseUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,9 +36,6 @@ export function createH3Server(options: {
|
|||||||
let address: BuiltInServerAddress | undefined
|
let address: BuiltInServerAddress | undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAddress() {
|
|
||||||
return address
|
|
||||||
},
|
|
||||||
async start(): Promise<BuiltInServerAddress> {
|
async start(): Promise<BuiltInServerAddress> {
|
||||||
return await lifecycleMutex.runExclusive(async () => {
|
return await lifecycleMutex.runExclusive(async () => {
|
||||||
if (address) {
|
if (address) {
|
||||||
@@ -49,9 +46,9 @@ export function createH3Server(options: {
|
|||||||
server = serve(options.app, { hostname: host, port, silent })
|
server = serve(options.app, { hostname: host, port, silent })
|
||||||
|
|
||||||
address = {
|
address = {
|
||||||
baseUrl: `http://${host}:${port}`,
|
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
|
baseUrl: `http://${host}:${port}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
return address
|
return address
|
||||||
@@ -69,5 +66,8 @@ export function createH3Server(options: {
|
|||||||
await activeServer.close().catch(() => {})
|
await activeServer.close().catch(() => {})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
getAddress() {
|
||||||
|
return address
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -21,7 +21,7 @@ describe('createStaticAssetService', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const root of tempRoots) {
|
for (const root of tempRoots) {
|
||||||
await rm(root, { force: true, recursive: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
tempRoots.length = 0
|
tempRoots.length = 0
|
||||||
})
|
})
|
||||||
@@ -53,10 +53,10 @@ describe('createStaticAssetService', () => {
|
|||||||
|
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version,
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseUrl = server.getBaseUrl()
|
const baseUrl = server.getBaseUrl()
|
||||||
@@ -69,13 +69,13 @@ describe('createStaticAssetService', () => {
|
|||||||
})
|
})
|
||||||
const responseBody = await response.text()
|
const responseBody = await response.text()
|
||||||
expect({
|
expect({
|
||||||
responseBody,
|
|
||||||
status: response.status,
|
status: response.status,
|
||||||
validateInputs,
|
validateInputs,
|
||||||
|
responseBody,
|
||||||
}).toEqual({
|
}).toEqual({
|
||||||
responseBody: 'console.log("ok")\n',
|
|
||||||
status: 200,
|
status: 200,
|
||||||
validateInputs: ['assets/app.js'],
|
validateInputs: ['assets/app.js'],
|
||||||
|
responseBody: 'console.log("ok")\n',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,10 +89,10 @@ describe('createStaticAssetService', () => {
|
|||||||
|
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version: '1.0.0',
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version: '1.0.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
|
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
|
||||||
@@ -127,10 +127,10 @@ describe('createStaticAssetService', () => {
|
|||||||
const { extensionId, server } = await createStartedAssetServer()
|
const { extensionId, server } = await createStartedAssetServer()
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version: '1.0.0',
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version: '1.0.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`)
|
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`)
|
||||||
@@ -145,10 +145,10 @@ describe('createStaticAssetService', () => {
|
|||||||
const { extensionId, server } = await createStartedAssetServer()
|
const { extensionId, server } = await createStartedAssetServer()
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version: '1.0.0',
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version: '1.0.0',
|
|
||||||
})
|
})
|
||||||
const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`
|
const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -171,10 +171,10 @@ describe('createStaticAssetService', () => {
|
|||||||
const { extensionId, server } = await createStartedAssetServer()
|
const { extensionId, server } = await createStartedAssetServer()
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version: '1.0.0',
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version: '1.0.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, {
|
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, {
|
||||||
@@ -213,10 +213,10 @@ describe('createStaticAssetService', () => {
|
|||||||
|
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
|
version,
|
||||||
ownerSessionId: 'session-1',
|
ownerSessionId: 'session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
|
const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, {
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export interface StaticAssetManifestEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StaticAssetService extends ServerManager {
|
export interface StaticAssetService extends ServerManager {
|
||||||
createSession: StaticAssetSessionStore['createSession']
|
|
||||||
getBaseUrl: () => string | undefined
|
getBaseUrl: () => string | undefined
|
||||||
revokeAll: StaticAssetSessionStore['revokeAll']
|
createSession: StaticAssetSessionStore['createSession']
|
||||||
revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId']
|
|
||||||
revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId']
|
|
||||||
revokeSession: StaticAssetSessionStore['revokeSession']
|
revokeSession: StaticAssetSessionStore['revokeSession']
|
||||||
|
revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId']
|
||||||
|
revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId']
|
||||||
|
revokeAll: StaticAssetSessionStore['revokeAll']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,9 +46,9 @@ export interface StaticAssetService extends ServerManager {
|
|||||||
*/
|
*/
|
||||||
export function createStaticAssetService(options: {
|
export function createStaticAssetService(options: {
|
||||||
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
||||||
getType?: (ext: string) => string | undefined
|
|
||||||
host?: string
|
host?: string
|
||||||
sessionStore?: StaticAssetSessionStore
|
sessionStore?: StaticAssetSessionStore
|
||||||
|
getType?: (ext: string) => string | undefined
|
||||||
}): StaticAssetService {
|
}): StaticAssetService {
|
||||||
const host = options.host ?? '127.0.0.1'
|
const host = options.host ?? '127.0.0.1'
|
||||||
const sessionStore = options.sessionStore ?? createStaticAssetSessionStore()
|
const sessionStore = options.sessionStore ?? createStaticAssetSessionStore()
|
||||||
@@ -71,54 +71,54 @@ export function createStaticAssetService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const staticAssetRoute = createStaticAssetRoute({
|
const staticAssetRoute = createStaticAssetRoute({
|
||||||
authorize: async ({ assetPath, assetSessionId, cookieValue, extensionId }) => {
|
getType,
|
||||||
|
authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => {
|
||||||
const entry = getManifestEntryForRequest(extensionId)
|
const entry = getManifestEntryForRequest(extensionId)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 401,
|
||||||
code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED',
|
code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED',
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
reason: 'extension manifest entry does not exist for requested extensionId',
|
reason: 'extension manifest entry does not exist for requested extensionId',
|
||||||
status: 401,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionStore.validateRequest({
|
return sessionStore.validateRequest({
|
||||||
assetPath,
|
|
||||||
assetSessionId,
|
|
||||||
cookieValue,
|
|
||||||
extensionId,
|
extensionId,
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
|
assetSessionId,
|
||||||
|
assetPath,
|
||||||
|
cookieValue,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getType,
|
|
||||||
refreshSession: sessionStore.refreshSession,
|
refreshSession: sessionStore.refreshSession,
|
||||||
resolveAsset: async ({ assetPath, extensionId }) => {
|
resolveAsset: async ({ extensionId, assetPath }) => {
|
||||||
const entry = getManifestEntryForRequest(extensionId)
|
const entry = getManifestEntryForRequest(extensionId)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND',
|
code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
reason: 'extension manifest entry does not exist for requested extensionId',
|
reason: 'extension manifest entry does not exist for requested extensionId',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedAssetPath = normalizeStaticAssetPath(assetPath)
|
const normalizedAssetPath = normalizeStaticAssetPath(assetPath)
|
||||||
if (!normalizedAssetPath) {
|
if (!normalizedAssetPath) {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 400,
|
||||||
code: 'EXTENSION_ASSET_PATH_INVALID',
|
code: 'EXTENSION_ASSET_PATH_INVALID',
|
||||||
message: 'Bad Request',
|
message: 'Bad Request',
|
||||||
reason: 'asset path could not be normalized',
|
reason: 'asset path could not be normalized',
|
||||||
status: 400,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,24 +132,24 @@ export function createStaticAssetService(options: {
|
|||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'EXTENSION_ASSET_NOT_FOUND',
|
code: 'EXTENSION_ASSET_NOT_FOUND',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
reason: 'resolved file does not exist',
|
reason: 'resolved file does not exist',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 400,
|
||||||
code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED',
|
code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED',
|
||||||
message: 'Bad Request',
|
message: 'Bad Request',
|
||||||
reason: 'resolved asset path is outside extension root',
|
reason: 'resolved asset path is outside extension root',
|
||||||
status: 400,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,32 +157,32 @@ export function createStaticAssetService(options: {
|
|||||||
const fileStats = await stat(filePath)
|
const fileStats = await stat(filePath)
|
||||||
if (!fileStats.isFile()) {
|
if (!fileStats.isFile()) {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'EXTENSION_ASSET_NOT_FILE',
|
code: 'EXTENSION_ASSET_NOT_FILE',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
reason: 'resolved path exists but is not a file',
|
reason: 'resolved path exists but is not a file',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath,
|
|
||||||
mtime: fileStats.mtimeMs,
|
|
||||||
ok: true,
|
ok: true,
|
||||||
|
filePath,
|
||||||
size: fileStats.size,
|
size: fileStats.size,
|
||||||
|
mtime: fileStats.mtimeMs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return {
|
return {
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'EXTENSION_ASSET_NOT_FOUND',
|
code: 'EXTENSION_ASSET_NOT_FOUND',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
reason: 'resolved file does not exist',
|
reason: 'resolved file does not exist',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -191,29 +191,29 @@ export function createStaticAssetService(options: {
|
|||||||
app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event)))
|
app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createSession: sessionStore.createSession,
|
|
||||||
getBaseUrl() {
|
|
||||||
return serverLifecycle.getAddress()?.baseUrl
|
|
||||||
},
|
|
||||||
key: 'static-assets',
|
key: 'static-assets',
|
||||||
revokeAll: sessionStore.revokeAll,
|
|
||||||
revokeByExtensionId: sessionStore.revokeByExtensionId,
|
|
||||||
revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId,
|
|
||||||
revokeSession: sessionStore.revokeSession,
|
|
||||||
async start() {
|
async start() {
|
||||||
await serverLifecycle.start()
|
await serverLifecycle.start()
|
||||||
},
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
await serverLifecycle.stop()
|
await serverLifecycle.stop()
|
||||||
},
|
},
|
||||||
|
getBaseUrl() {
|
||||||
|
return serverLifecycle.getAddress()?.baseUrl
|
||||||
|
},
|
||||||
|
createSession: sessionStore.createSession,
|
||||||
|
revokeSession: sessionStore.revokeSession,
|
||||||
|
revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId,
|
||||||
|
revokeByExtensionId: sessionStore.revokeByExtensionId,
|
||||||
|
revokeAll: sessionStore.revokeAll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const staticAssetMimeTypeOverrides: Record<string, string> = {
|
const staticAssetMimeTypeOverrides: Record<string, string> = {
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
'.avif': 'image/avif',
|
'.avif': 'image/avif',
|
||||||
'.heic': 'image/heic',
|
'.heic': 'image/heic',
|
||||||
'.heif': 'image/heif',
|
'.heif': 'image/heif',
|
||||||
'.wasm': 'application/wasm',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultStaticAssetMimeTypeResolver(ext: string) {
|
function defaultStaticAssetMimeTypeResolver(ext: string) {
|
||||||
|
|||||||
+11
-11
@@ -16,7 +16,7 @@ describe('static asset paths', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const root of tempRoots) {
|
for (const root of tempRoots) {
|
||||||
await rm(root, { force: true, recursive: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
tempRoots.length = 0
|
tempRoots.length = 0
|
||||||
})
|
})
|
||||||
@@ -30,9 +30,9 @@ describe('static asset paths', () => {
|
|||||||
|
|
||||||
it('parses session-scoped mounted plugin request path and rejects malformed routes', () => {
|
it('parses session-scoped mounted plugin request path and rejects malformed routes', () => {
|
||||||
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({
|
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({
|
||||||
assetPath: 'dist/ui/index.html',
|
|
||||||
assetSessionId: 'asset-session-1',
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
assetSessionId: 'asset-session-1',
|
||||||
|
assetPath: 'dist/ui/index.html',
|
||||||
})
|
})
|
||||||
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined()
|
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined()
|
||||||
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined()
|
expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined()
|
||||||
@@ -46,24 +46,24 @@ describe('static asset paths', () => {
|
|||||||
|
|
||||||
it('builds session-scoped mounted asset path with encoded segments', () => {
|
it('builds session-scoped mounted asset path with encoded segments', () => {
|
||||||
expect(buildMountedStaticAssetPath({
|
expect(buildMountedStaticAssetPath({
|
||||||
assetPath: 'dist/ui/index.html',
|
|
||||||
assetSessionId: 'asset-session-1',
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
assetSessionId: 'asset-session-1',
|
||||||
|
assetPath: 'dist/ui/index.html',
|
||||||
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')
|
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')
|
||||||
expect(buildMountedStaticAssetPath({
|
expect(buildMountedStaticAssetPath({
|
||||||
assetPath: 'dist/ui/file name.html',
|
|
||||||
assetSessionId: 'asset-session-1',
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
assetSessionId: 'asset-session-1',
|
||||||
|
assetPath: 'dist/ui/file name.html',
|
||||||
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html')
|
})).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html')
|
||||||
expect(buildMountedStaticAssetPath({
|
expect(buildMountedStaticAssetPath({
|
||||||
assetPath: 'dist/ui/index.html',
|
|
||||||
assetSessionId: 'asset-session-1',
|
|
||||||
extensionId: 'bad/id',
|
extensionId: 'bad/id',
|
||||||
|
assetSessionId: 'asset-session-1',
|
||||||
|
assetPath: 'dist/ui/index.html',
|
||||||
})).toBeUndefined()
|
})).toBeUndefined()
|
||||||
expect(buildMountedStaticAssetPath({
|
expect(buildMountedStaticAssetPath({
|
||||||
assetPath: 'dist/ui/index.html',
|
|
||||||
assetSessionId: 'bad session',
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
assetSessionId: 'bad session',
|
||||||
|
assetPath: 'dist/ui/index.html',
|
||||||
})).toBeUndefined()
|
})).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,53 +8,30 @@ import { resolve, sep } from 'node:path'
|
|||||||
* @param assetSessionId Asset session identifier from the mounted route.
|
* @param assetSessionId Asset session identifier from the mounted route.
|
||||||
*/
|
*/
|
||||||
export interface ParsedStaticAssetRequest {
|
export interface ParsedStaticAssetRequest {
|
||||||
/** Normalized plugin asset path relative to the mounted UI asset root. */
|
|
||||||
assetPath: string
|
|
||||||
/** Asset session identifier validated as one safe route segment. */
|
|
||||||
assetSessionId: string
|
|
||||||
/** Plugin extension identifier validated as one safe route segment. */
|
/** Plugin extension identifier validated as one safe route segment. */
|
||||||
extensionId: string
|
extensionId: string
|
||||||
|
/** Asset session identifier validated as one safe route segment. */
|
||||||
|
assetSessionId: string
|
||||||
|
/** Normalized plugin asset path relative to the mounted UI asset root. */
|
||||||
|
assetPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathPrefix = '/_airi/extensions/'
|
const pathPrefix = '/_airi/extensions/'
|
||||||
const segmentPattern = /^[\w.+-]+$/
|
const segmentPattern = /^[\w.+-]+$/
|
||||||
|
|
||||||
/**
|
function decodePathSegment(segment: string): string | undefined {
|
||||||
* Builds a session-scoped mounted plugin asset route path.
|
try {
|
||||||
*
|
return decodeURIComponent(segment)
|
||||||
* Use when:
|
}
|
||||||
* - Converting a validated plugin asset path into a mounted HTTP route
|
catch {
|
||||||
* - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath`
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `extensionId` and `assetSessionId` are safe single route segments
|
|
||||||
* - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath}
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Encoded mounted route path, or `undefined` when any input is unsafe
|
|
||||||
*/
|
|
||||||
export function buildMountedStaticAssetPath(input: {
|
|
||||||
assetPath: string
|
|
||||||
assetSessionId: string
|
|
||||||
extensionId: string
|
|
||||||
}) {
|
|
||||||
if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) {
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath)
|
function isSafeRouteSegment(segment: string): boolean {
|
||||||
if (!normalizedAssetPath) {
|
return !segment.includes('/')
|
||||||
return undefined
|
&& !segment.includes('\\')
|
||||||
}
|
&& segmentPattern.test(segment)
|
||||||
|
|
||||||
const encodedExtensionId = encodeURIComponent(input.extensionId)
|
|
||||||
const encodedAssetSessionId = encodeURIComponent(input.assetSessionId)
|
|
||||||
const encodedAssetPath = normalizedAssetPath
|
|
||||||
.split('/')
|
|
||||||
.map(segment => encodeURIComponent(segment))
|
|
||||||
.join('/')
|
|
||||||
|
|
||||||
return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,9 +151,9 @@ export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAsset
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetPath,
|
|
||||||
assetSessionId,
|
|
||||||
extensionId,
|
extensionId,
|
||||||
|
assetSessionId,
|
||||||
|
assetPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,17 +196,40 @@ export async function resolveStaticAssetFilePath(rootDir: string, assetPath: str
|
|||||||
return realCandidate
|
return realCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodePathSegment(segment: string): string | undefined {
|
/**
|
||||||
try {
|
* Builds a session-scoped mounted plugin asset route path.
|
||||||
return decodeURIComponent(segment)
|
*
|
||||||
}
|
* Use when:
|
||||||
catch {
|
* - Converting a validated plugin asset path into a mounted HTTP route
|
||||||
|
* - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath`
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `extensionId` and `assetSessionId` are safe single route segments
|
||||||
|
* - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath}
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Encoded mounted route path, or `undefined` when any input is unsafe
|
||||||
|
*/
|
||||||
|
export function buildMountedStaticAssetPath(input: {
|
||||||
|
extensionId: string
|
||||||
|
assetSessionId: string
|
||||||
|
assetPath: string
|
||||||
|
}) {
|
||||||
|
if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function isSafeRouteSegment(segment: string): boolean {
|
const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath)
|
||||||
return !segment.includes('/')
|
if (!normalizedAssetPath) {
|
||||||
&& !segment.includes('\\')
|
return undefined
|
||||||
&& segmentPattern.test(segment)
|
}
|
||||||
|
|
||||||
|
const encodedExtensionId = encodeURIComponent(input.extensionId)
|
||||||
|
const encodedAssetSessionId = encodeURIComponent(input.assetSessionId)
|
||||||
|
const encodedAssetPath = normalizedAssetPath
|
||||||
|
.split('/')
|
||||||
|
.map(segment => encodeURIComponent(segment))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}`
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -17,7 +17,7 @@ describe('createStaticAssetRoute', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const root of tempRoots) {
|
for (const root of tempRoots) {
|
||||||
await rm(root, { force: true, recursive: true })
|
await rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
tempRoots.length = 0
|
tempRoots.length = 0
|
||||||
|
|
||||||
@@ -36,21 +36,21 @@ describe('createStaticAssetRoute', () => {
|
|||||||
const app = new H3()
|
const app = new H3()
|
||||||
app.get('/_airi/extensions/**', createStaticAssetRoute({
|
app.get('/_airi/extensions/**', createStaticAssetRoute({
|
||||||
authorize: async () => ({
|
authorize: async () => ({
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 401,
|
||||||
code: 'COOKIE_MISSING',
|
code: 'COOKIE_MISSING',
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
status: 401,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}),
|
}),
|
||||||
refreshSession: () => undefined,
|
refreshSession: () => undefined,
|
||||||
resolveAsset: async () => ({
|
resolveAsset: async () => ({
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -70,21 +70,21 @@ describe('createStaticAssetRoute', () => {
|
|||||||
const app = new H3()
|
const app = new H3()
|
||||||
app.use('/_airi/extensions/**', createStaticAssetRoute({
|
app.use('/_airi/extensions/**', createStaticAssetRoute({
|
||||||
authorize: async () => ({
|
authorize: async () => ({
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 401,
|
||||||
code: 'COOKIE_MISSING',
|
code: 'COOKIE_MISSING',
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
status: 401,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}),
|
}),
|
||||||
refreshSession: () => undefined,
|
refreshSession: () => undefined,
|
||||||
resolveAsset: async () => ({
|
resolveAsset: async () => ({
|
||||||
|
ok: false,
|
||||||
error: new HttpError({
|
error: new HttpError({
|
||||||
|
status: 404,
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Not Found',
|
message: 'Not Found',
|
||||||
status: 404,
|
|
||||||
}),
|
}),
|
||||||
ok: false,
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -122,23 +122,23 @@ describe('createStaticAssetRoute', () => {
|
|||||||
session: {
|
session: {
|
||||||
assetSessionId: 's1',
|
assetSessionId: 's1',
|
||||||
cookieName: createStaticAssetSessionCookieName('s1'),
|
cookieName: createStaticAssetSessionCookieName('s1'),
|
||||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
|
||||||
cookieValue: 'test-token',
|
cookieValue: 'test-token',
|
||||||
|
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||||
expiresAt: Date.now() + 1000,
|
expiresAt: Date.now() + 1000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
|
||||||
refreshSession: (assetSessionId) => {
|
refreshSession: (assetSessionId) => {
|
||||||
refreshedSessionId = assetSessionId
|
refreshedSessionId = assetSessionId
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
resolveAsset: async () => ({
|
resolveAsset: async () => ({
|
||||||
filePath: wasmFilePath,
|
|
||||||
mtime: Date.now(),
|
|
||||||
ok: true,
|
ok: true,
|
||||||
|
filePath: wasmFilePath,
|
||||||
size: 4,
|
size: 4,
|
||||||
|
mtime: Date.now(),
|
||||||
}),
|
}),
|
||||||
|
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
server = createServer(toNodeHandler(app))
|
server = createServer(toNodeHandler(app))
|
||||||
@@ -178,23 +178,23 @@ describe('createStaticAssetRoute', () => {
|
|||||||
session: {
|
session: {
|
||||||
assetSessionId: 's1',
|
assetSessionId: 's1',
|
||||||
cookieName: createStaticAssetSessionCookieName('s1'),
|
cookieName: createStaticAssetSessionCookieName('s1'),
|
||||||
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
|
||||||
cookieValue: 'test-token',
|
cookieValue: 'test-token',
|
||||||
|
cookiePath: '/_airi/extensions/a/sessions/s1/ui',
|
||||||
expiresAt: Date.now() + 1000,
|
expiresAt: Date.now() + 1000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
|
||||||
refreshSession: (assetSessionId) => {
|
refreshSession: (assetSessionId) => {
|
||||||
refreshedSessionId = assetSessionId
|
refreshedSessionId = assetSessionId
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
resolveAsset: async () => ({
|
resolveAsset: async () => ({
|
||||||
filePath: wasmFilePath,
|
|
||||||
mtime: Date.now(),
|
|
||||||
ok: true,
|
ok: true,
|
||||||
|
filePath: wasmFilePath,
|
||||||
size: 4,
|
size: 4,
|
||||||
|
mtime: Date.now(),
|
||||||
}),
|
}),
|
||||||
|
getType: ext => ext === '.wasm' ? 'application/wasm' : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
server = createServer(toNodeHandler(app))
|
server = createServer(toNodeHandler(app))
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ const staticAssetSecurityHeaders = {
|
|||||||
|
|
||||||
export interface StaticAssetRouteOptions {
|
export interface StaticAssetRouteOptions {
|
||||||
authorize: (params: {
|
authorize: (params: {
|
||||||
assetPath: string
|
|
||||||
assetSessionId: string
|
|
||||||
cookieValue: string | undefined
|
|
||||||
extensionId: string
|
extensionId: string
|
||||||
|
assetSessionId: string
|
||||||
|
assetPath: string
|
||||||
|
cookieValue: string | undefined
|
||||||
}) => Promise<StaticAssetSessionValidationResult>
|
}) => Promise<StaticAssetSessionValidationResult>
|
||||||
getType?: (ext: string) => string | undefined
|
|
||||||
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
|
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
|
||||||
resolveAsset: (params: { assetPath: string, extensionId: string }) => Promise<StaticAssetResolveResult>
|
resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise<StaticAssetResolveResult>
|
||||||
|
getType?: (ext: string) => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,9 +48,9 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
|||||||
|
|
||||||
if (event.req.method !== 'GET' && event.req.method !== 'HEAD') {
|
if (event.req.method !== 'GET' && event.req.method !== 'HEAD') {
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
|
status: 405,
|
||||||
code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED',
|
code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED',
|
||||||
message: 'Method Not Allowed',
|
message: 'Method Not Allowed',
|
||||||
status: 405,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,19 +61,19 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
|||||||
|
|
||||||
if (!extensionId || !assetSessionId || !assetPath) {
|
if (!extensionId || !assetSessionId || !assetPath) {
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
|
status: 401,
|
||||||
code: 'EXTENSION_ASSET_REQUEST_INVALID',
|
code: 'EXTENSION_ASSET_REQUEST_INVALID',
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
reason: 'required extensionId, assetSessionId, or assetPath is missing',
|
reason: 'required extensionId, assetSessionId, or assetPath is missing',
|
||||||
status: 401,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId))
|
const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId))
|
||||||
const auth = await options.authorize({
|
const auth = await options.authorize({
|
||||||
assetPath,
|
|
||||||
assetSessionId,
|
|
||||||
cookieValue,
|
|
||||||
extensionId,
|
extensionId,
|
||||||
|
assetSessionId,
|
||||||
|
assetPath,
|
||||||
|
cookieValue,
|
||||||
})
|
})
|
||||||
if (!auth.ok) {
|
if (!auth.ok) {
|
||||||
throw auth.error
|
throw auth.error
|
||||||
@@ -84,12 +84,13 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
|||||||
let resolved: Awaited<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | undefined
|
let resolved: Awaited<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | undefined
|
||||||
const resolveOnce = async () => {
|
const resolveOnce = async () => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = await options.resolveAsset({ assetPath, extensionId })
|
resolved = await options.resolveAsset({ extensionId, assetPath })
|
||||||
}
|
}
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
return await serveStatic(event, {
|
return await serveStatic(event, {
|
||||||
|
getType: options.getType,
|
||||||
getContents: async () => {
|
getContents: async () => {
|
||||||
const item = await resolveOnce()
|
const item = await resolveOnce()
|
||||||
if (!item.ok) {
|
if (!item.ok) {
|
||||||
@@ -104,11 +105,10 @@ export function createStaticAssetRoute(options: StaticAssetRouteOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mtime: item.mtime,
|
|
||||||
size: item.size,
|
size: item.size,
|
||||||
|
mtime: item.mtime,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getType: options.getType,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|||||||
+104
-104
@@ -27,21 +27,21 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now })
|
const store = createStaticAssetSessionStore({ now })
|
||||||
const session = store.createSession({
|
const session = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(session.assetSessionId).toBeTruthy()
|
expect(session.assetSessionId).toBeTruthy()
|
||||||
expect(session.cookieName).toContain(session.assetSessionId)
|
expect(session.cookieName).toContain(session.assetSessionId)
|
||||||
expect(session.cookieValue).toBeTruthy()
|
expect(session.cookieValue).toBeTruthy()
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
}).ok).toBe(true)
|
}).ok).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,39 +54,39 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now })
|
const store = createStaticAssetSessionStore({ now })
|
||||||
const session = store.createSession({
|
const session = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'index.html',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'other-plugin',
|
extensionId: 'other-plugin',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'index.html',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_EXTENSION_MISMATCH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1)
|
expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1)
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'index.html',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'index.html',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_SESSION_NOT_FOUND',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,96 +99,96 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now })
|
const store = createStaticAssetSessionStore({ now })
|
||||||
const session = store.createSession({
|
const session = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: 'assets/',
|
pathPrefix: 'assets/',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
assetSessionId: session.assetSessionId,
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
cookieValue: undefined,
|
cookieValue: undefined,
|
||||||
extensionId: 'airi-plugin-game-chess',
|
|
||||||
version: '0.1.0',
|
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
|
status: 401,
|
||||||
code: 'EXTENSION_ASSET_COOKIE_MISSING',
|
code: 'EXTENSION_ASSET_COOKIE_MISSING',
|
||||||
status: 401,
|
|
||||||
},
|
},
|
||||||
ok: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: 'wrong-cookie',
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
|
cookieValue: 'wrong-cookie',
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_COOKIE_MISMATCH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.2.0',
|
version: '0.2.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
|
status: 401,
|
||||||
code: 'EXTENSION_ASSET_VERSION_MISMATCH',
|
code: 'EXTENSION_ASSET_VERSION_MISMATCH',
|
||||||
status: 401,
|
|
||||||
},
|
},
|
||||||
ok: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
assetPath: '',
|
assetPath: '',
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
cookieValue: session.cookieValue,
|
||||||
extensionId: 'airi-plugin-game-chess',
|
|
||||||
version: '0.1.0',
|
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_PATH_EMPTY',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_PATH_EMPTY',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'other/index.js',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'other/index.js',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
now.mockReturnValue(31_001)
|
now.mockReturnValue(31_001)
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_SESSION_EXPIRED',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,24 +201,24 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now })
|
const store = createStaticAssetSessionStore({ now })
|
||||||
const firstSession = store.createSession({
|
const firstSession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
const secondSession = store.createSession({
|
const secondSession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-2',
|
ownerSessionId: 'plugin-session-2',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
const thirdSession = store.createSession({
|
const thirdSession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-go',
|
extensionId: 'airi-plugin-game-go',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-3',
|
ownerSessionId: 'plugin-session-3',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
now.mockReturnValue(2000)
|
now.mockReturnValue(2000)
|
||||||
@@ -243,21 +243,21 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now })
|
const store = createStaticAssetSessionStore({ now })
|
||||||
const createdSession = store.createSession({
|
const createdSession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
const originalCookieValue = createdSession.cookieValue
|
const originalCookieValue = createdSession.cookieValue
|
||||||
|
|
||||||
tryMutateCookieValue(createdSession, 'mutated-create-cookie')
|
tryMutateCookieValue(createdSession, 'mutated-create-cookie')
|
||||||
|
|
||||||
const firstValidation = store.validateRequest({
|
const firstValidation = store.validateRequest({
|
||||||
assetPath: 'index.html',
|
|
||||||
assetSessionId: createdSession.assetSessionId,
|
|
||||||
cookieValue: originalCookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: createdSession.assetSessionId,
|
||||||
|
assetPath: 'index.html',
|
||||||
|
cookieValue: originalCookieValue,
|
||||||
})
|
})
|
||||||
expect(firstValidation.ok).toBe(true)
|
expect(firstValidation.ok).toBe(true)
|
||||||
expect(Object.isFrozen(createdSession)).toBe(true)
|
expect(Object.isFrozen(createdSession)).toBe(true)
|
||||||
@@ -268,11 +268,11 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie')
|
tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie')
|
||||||
|
|
||||||
const secondValidation = store.validateRequest({
|
const secondValidation = store.validateRequest({
|
||||||
assetPath: 'index.html',
|
|
||||||
assetSessionId: createdSession.assetSessionId,
|
|
||||||
cookieValue: originalCookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: createdSession.assetSessionId,
|
||||||
|
assetPath: 'index.html',
|
||||||
|
cookieValue: originalCookieValue,
|
||||||
})
|
})
|
||||||
expect(secondValidation.ok).toBe(true)
|
expect(secondValidation.ok).toBe(true)
|
||||||
expect(Object.isFrozen(firstValidation.session)).toBe(true)
|
expect(Object.isFrozen(firstValidation.session)).toBe(true)
|
||||||
@@ -290,11 +290,11 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie')
|
tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie')
|
||||||
|
|
||||||
const thirdValidation = store.validateRequest({
|
const thirdValidation = store.validateRequest({
|
||||||
assetPath: 'index.html',
|
|
||||||
assetSessionId: createdSession.assetSessionId,
|
|
||||||
cookieValue: originalCookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: createdSession.assetSessionId,
|
||||||
|
assetPath: 'index.html',
|
||||||
|
cookieValue: originalCookieValue,
|
||||||
})
|
})
|
||||||
expect(thirdValidation.ok).toBe(true)
|
expect(thirdValidation.ok).toBe(true)
|
||||||
expect(Object.isFrozen(refreshedSession)).toBe(true)
|
expect(Object.isFrozen(refreshedSession)).toBe(true)
|
||||||
@@ -321,9 +321,9 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
||||||
const input = {
|
const input = {
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '',
|
pathPrefix: '',
|
||||||
version: '0.1.0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
|
for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) {
|
||||||
@@ -342,10 +342,10 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
||||||
const session = store.createSession({
|
const session = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: 'assets/',
|
pathPrefix: 'assets/',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const assetPath of [
|
for (const assetPath of [
|
||||||
@@ -354,26 +354,26 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
'assets%2Fsecret.js',
|
'assets%2Fsecret.js',
|
||||||
]) {
|
]) {
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath,
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
cookieValue: session.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath,
|
||||||
|
cookieValue: session.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => store.createSession({
|
expect(() => store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: '../',
|
pathPrefix: '../',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})).toThrow(RangeError)
|
})).toThrow(RangeError)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -385,47 +385,47 @@ describe('createStaticAssetSessionStore', () => {
|
|||||||
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) })
|
||||||
const directorySession = store.createSession({
|
const directorySession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-1',
|
ownerSessionId: 'plugin-session-1',
|
||||||
pathPrefix: 'assets/',
|
pathPrefix: 'assets/',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
const exactSession = store.createSession({
|
const exactSession = store.createSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
ownerSessionId: 'plugin-session-2',
|
ownerSessionId: 'plugin-session-2',
|
||||||
pathPrefix: 'assets',
|
pathPrefix: 'assets',
|
||||||
ttlMs: 30_000,
|
ttlMs: 30_000,
|
||||||
version: '0.1.0',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.validateRequest({
|
expect(store.validateRequest({
|
||||||
assetPath: 'assets/index.js',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
assetSessionId: directorySession.assetSessionId,
|
assetSessionId: directorySession.assetSessionId,
|
||||||
cookieValue: directorySession.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
|
||||||
version: '0.1.0',
|
|
||||||
}).ok).toBe(true)
|
|
||||||
|
|
||||||
expect(store.validateRequest({
|
|
||||||
assetPath: 'assets',
|
|
||||||
assetSessionId: exactSession.assetSessionId,
|
|
||||||
cookieValue: exactSession.cookieValue,
|
|
||||||
extensionId: 'airi-plugin-game-chess',
|
|
||||||
version: '0.1.0',
|
|
||||||
}).ok).toBe(true)
|
|
||||||
|
|
||||||
expect(store.validateRequest({
|
|
||||||
assetPath: 'assets/index.js',
|
assetPath: 'assets/index.js',
|
||||||
assetSessionId: exactSession.assetSessionId,
|
cookieValue: directorySession.cookieValue,
|
||||||
cookieValue: exactSession.cookieValue,
|
}).ok).toBe(true)
|
||||||
|
|
||||||
|
expect(store.validateRequest({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
assetSessionId: exactSession.assetSessionId,
|
||||||
|
assetPath: 'assets',
|
||||||
|
cookieValue: exactSession.cookieValue,
|
||||||
|
}).ok).toBe(true)
|
||||||
|
|
||||||
|
expect(store.validateRequest({
|
||||||
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '0.1.0',
|
||||||
|
assetSessionId: exactSession.assetSessionId,
|
||||||
|
assetPath: 'assets/index.js',
|
||||||
|
cookieValue: exactSession.cookieValue,
|
||||||
})).toMatchObject({
|
})).toMatchObject({
|
||||||
error: {
|
|
||||||
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
status: 401,
|
||||||
|
code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+99
-99
@@ -14,15 +14,57 @@ import { normalizeStaticAssetPath } from './paths'
|
|||||||
|
|
||||||
interface StaticAssetSessionRecord {
|
interface StaticAssetSessionRecord {
|
||||||
assetSessionId: string
|
assetSessionId: string
|
||||||
cookieName: string
|
|
||||||
cookiePath: string
|
|
||||||
cookieValue: string
|
|
||||||
expiresAt: number
|
|
||||||
extensionId: string
|
extensionId: string
|
||||||
|
version: string
|
||||||
ownerSessionId: string
|
ownerSessionId: string
|
||||||
pathPrefix: string
|
pathPrefix: string
|
||||||
ttlMs: number
|
ttlMs: number
|
||||||
version: string
|
cookieName: string
|
||||||
|
cookieValue: string
|
||||||
|
cookiePath: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes asset path prefixes used to constrain a session.
|
||||||
|
*
|
||||||
|
* Before:
|
||||||
|
* - " assets\\ "
|
||||||
|
*
|
||||||
|
* After:
|
||||||
|
* - "assets/"
|
||||||
|
*/
|
||||||
|
function normalizePathPrefix(pathPrefix: string) {
|
||||||
|
const normalizedInput = pathPrefix.trim().replaceAll('\\', '/')
|
||||||
|
if (!normalizedInput) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectoryPrefix = normalizedInput.endsWith('/')
|
||||||
|
const normalized = normalizeStaticAssetPath(normalizedInput)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path')
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDirectoryPrefix ? `${normalized}/` : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes requested asset paths before comparing them to session prefixes.
|
||||||
|
*
|
||||||
|
* Before:
|
||||||
|
* - " assets\\index.js "
|
||||||
|
*
|
||||||
|
* After:
|
||||||
|
* - "assets/index.js"
|
||||||
|
*/
|
||||||
|
function normalizeAssetPath(assetPath: string) {
|
||||||
|
return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpaqueToken() {
|
||||||
|
// Node's base64url alphabet is route/cookie friendly while staying opaque.
|
||||||
|
return randomBytes(18).toString('base64url')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +84,42 @@ export function createStaticAssetSessionCookieName(assetSessionId: string) {
|
|||||||
return `airi_extension_asset_session_${assetSessionId}`
|
return `airi_extension_asset_session_${assetSessionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCookiePath(extensionId: string, assetSessionId: string) {
|
||||||
|
return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession {
|
||||||
|
return Object.freeze({
|
||||||
|
assetSessionId: record.assetSessionId,
|
||||||
|
cookieName: record.cookieName,
|
||||||
|
cookieValue: record.cookieValue,
|
||||||
|
cookiePath: record.cookiePath,
|
||||||
|
expiresAt: record.expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieValuesMatch(expected: string, actual: string) {
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'utf8')
|
||||||
|
const actualBuffer = Buffer.from(actual, 'utf8')
|
||||||
|
if (expectedBuffer.length !== actualBuffer.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(expectedBuffer, actualBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(code: string, reason: string) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: new HttpError({
|
||||||
|
status: 401,
|
||||||
|
code,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an in-memory cookie-backed session store for extension static assets.
|
* Creates an in-memory cookie-backed session store for extension static assets.
|
||||||
*
|
*
|
||||||
@@ -69,7 +147,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { error: HttpError, ok: false } } | { ok: true, record: StaticAssetSessionRecord } => {
|
const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { ok: false, error: HttpError } } | { ok: true, record: StaticAssetSessionRecord } => {
|
||||||
const record = records.get(assetSessionId)
|
const record = records.get(assetSessionId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return {
|
return {
|
||||||
@@ -99,15 +177,15 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
|||||||
const assetSessionId = createOpaqueToken()
|
const assetSessionId = createOpaqueToken()
|
||||||
const record: StaticAssetSessionRecord = {
|
const record: StaticAssetSessionRecord = {
|
||||||
assetSessionId,
|
assetSessionId,
|
||||||
cookieName: createStaticAssetSessionCookieName(assetSessionId),
|
|
||||||
cookiePath: createCookiePath(input.extensionId, assetSessionId),
|
|
||||||
cookieValue: createOpaqueToken(),
|
|
||||||
expiresAt: now() + input.ttlMs,
|
|
||||||
extensionId: input.extensionId,
|
extensionId: input.extensionId,
|
||||||
|
version: input.version,
|
||||||
ownerSessionId: input.ownerSessionId,
|
ownerSessionId: input.ownerSessionId,
|
||||||
pathPrefix: normalizePathPrefix(input.pathPrefix),
|
pathPrefix: normalizePathPrefix(input.pathPrefix),
|
||||||
ttlMs: input.ttlMs,
|
ttlMs: input.ttlMs,
|
||||||
version: input.version,
|
cookieName: createStaticAssetSessionCookieName(assetSessionId),
|
||||||
|
cookieValue: createOpaqueToken(),
|
||||||
|
cookiePath: createCookiePath(input.extensionId, assetSessionId),
|
||||||
|
expiresAt: now() + input.ttlMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
records.set(assetSessionId, record)
|
records.set(assetSessionId, record)
|
||||||
@@ -175,6 +253,7 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
createSession,
|
createSession,
|
||||||
|
validateRequest,
|
||||||
refreshSession(assetSessionId) {
|
refreshSession(assetSessionId) {
|
||||||
const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED')
|
const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED')
|
||||||
if (!active.ok) {
|
if (!active.ok) {
|
||||||
@@ -184,15 +263,6 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
|||||||
active.record.expiresAt = now() + active.record.ttlMs
|
active.record.expiresAt = now() + active.record.ttlMs
|
||||||
return createSessionSnapshot(active.record)
|
return createSessionSnapshot(active.record)
|
||||||
},
|
},
|
||||||
revokeAll() {
|
|
||||||
return revokeWhere(() => true)
|
|
||||||
},
|
|
||||||
revokeByExtensionId(extensionId) {
|
|
||||||
return revokeWhere(record => record.extensionId === extensionId)
|
|
||||||
},
|
|
||||||
revokeByOwnerSessionId(ownerSessionId) {
|
|
||||||
return revokeWhere(record => record.ownerSessionId === ownerSessionId)
|
|
||||||
},
|
|
||||||
revokeSession(assetSessionId) {
|
revokeSession(assetSessionId) {
|
||||||
const record = records.get(assetSessionId)
|
const record = records.get(assetSessionId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -202,84 +272,14 @@ export function createStaticAssetSessionStore(options: { now?: () => number } =
|
|||||||
records.delete(assetSessionId)
|
records.delete(assetSessionId)
|
||||||
return createSessionSnapshot(record)
|
return createSessionSnapshot(record)
|
||||||
},
|
},
|
||||||
validateRequest,
|
revokeByOwnerSessionId(ownerSessionId) {
|
||||||
}
|
return revokeWhere(record => record.ownerSessionId === ownerSessionId)
|
||||||
}
|
},
|
||||||
|
revokeByExtensionId(extensionId) {
|
||||||
function cookieValuesMatch(expected: string, actual: string) {
|
return revokeWhere(record => record.extensionId === extensionId)
|
||||||
const expectedBuffer = Buffer.from(expected, 'utf8')
|
},
|
||||||
const actualBuffer = Buffer.from(actual, 'utf8')
|
revokeAll() {
|
||||||
if (expectedBuffer.length !== actualBuffer.length) {
|
return revokeWhere(() => true)
|
||||||
return false
|
},
|
||||||
}
|
|
||||||
|
|
||||||
return timingSafeEqual(expectedBuffer, actualBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCookiePath(extensionId: string, assetSessionId: string) {
|
|
||||||
return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOpaqueToken() {
|
|
||||||
// Node's base64url alphabet is route/cookie friendly while staying opaque.
|
|
||||||
return randomBytes(18).toString('base64url')
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession {
|
|
||||||
return Object.freeze({
|
|
||||||
assetSessionId: record.assetSessionId,
|
|
||||||
cookieName: record.cookieName,
|
|
||||||
cookiePath: record.cookiePath,
|
|
||||||
cookieValue: record.cookieValue,
|
|
||||||
expiresAt: record.expiresAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes requested asset paths before comparing them to session prefixes.
|
|
||||||
*
|
|
||||||
* Before:
|
|
||||||
* - " assets\\index.js "
|
|
||||||
*
|
|
||||||
* After:
|
|
||||||
* - "assets/index.js"
|
|
||||||
*/
|
|
||||||
function normalizeAssetPath(assetPath: string) {
|
|
||||||
return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes asset path prefixes used to constrain a session.
|
|
||||||
*
|
|
||||||
* Before:
|
|
||||||
* - " assets\\ "
|
|
||||||
*
|
|
||||||
* After:
|
|
||||||
* - "assets/"
|
|
||||||
*/
|
|
||||||
function normalizePathPrefix(pathPrefix: string) {
|
|
||||||
const normalizedInput = pathPrefix.trim().replaceAll('\\', '/')
|
|
||||||
if (!normalizedInput) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirectoryPrefix = normalizedInput.endsWith('/')
|
|
||||||
const normalized = normalizeStaticAssetPath(normalizedInput)
|
|
||||||
if (!normalized) {
|
|
||||||
throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path')
|
|
||||||
}
|
|
||||||
|
|
||||||
return isDirectoryPrefix ? `${normalized}/` : normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
function unauthorized(code: string, reason: string) {
|
|
||||||
return {
|
|
||||||
error: new HttpError({
|
|
||||||
code,
|
|
||||||
message: 'Unauthorized',
|
|
||||||
reason,
|
|
||||||
status: 401,
|
|
||||||
}),
|
|
||||||
ok: false as const,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import type { HttpError } from '../errors'
|
import type { HttpError } from '../errors'
|
||||||
|
|
||||||
export type StaticAssetResolveResult
|
|
||||||
= | { error: HttpError, ok: false }
|
|
||||||
| { filePath: string, mtime: number, ok: true, size: number }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cookie data returned after creating a static asset session.
|
|
||||||
*/
|
|
||||||
export interface StaticAssetSession {
|
|
||||||
/** Opaque server-side session id embedded in extension asset routes. */
|
|
||||||
readonly assetSessionId: string
|
|
||||||
/** Cookie name callers set on the asset route path. */
|
|
||||||
readonly cookieName: string
|
|
||||||
/** Cookie path scope for browser requests. */
|
|
||||||
readonly cookiePath: string
|
|
||||||
/** Opaque cookie value required to validate asset requests. */
|
|
||||||
readonly cookieValue: string
|
|
||||||
/** Unix timestamp in milliseconds when the session expires. */
|
|
||||||
readonly expiresAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input required to create a cookie-backed static asset session.
|
* Input required to create a cookie-backed static asset session.
|
||||||
*/
|
*/
|
||||||
export interface StaticAssetSessionCreateInput {
|
export interface StaticAssetSessionCreateInput {
|
||||||
/** Plugin extension id that owns the served static assets. */
|
/** Plugin extension id that owns the served static assets. */
|
||||||
extensionId: string
|
extensionId: string
|
||||||
|
/** Extension version expected by requests using this asset session. */
|
||||||
|
version: string
|
||||||
/** Parent plugin session id used for owner-scoped revocation. */
|
/** Parent plugin session id used for owner-scoped revocation. */
|
||||||
ownerSessionId: string
|
ownerSessionId: string
|
||||||
/**
|
/**
|
||||||
@@ -37,49 +19,67 @@ export interface StaticAssetSessionCreateInput {
|
|||||||
pathPrefix: string
|
pathPrefix: string
|
||||||
/** Session lifetime in milliseconds from creation or refresh time. */
|
/** Session lifetime in milliseconds from creation or refresh time. */
|
||||||
ttlMs: number
|
ttlMs: number
|
||||||
/** Extension version expected by requests using this asset session. */
|
|
||||||
version: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie data returned after creating a static asset session.
|
||||||
|
*/
|
||||||
|
export interface StaticAssetSession {
|
||||||
|
/** Opaque server-side session id embedded in extension asset routes. */
|
||||||
|
readonly assetSessionId: string
|
||||||
|
/** Cookie name callers set on the asset route path. */
|
||||||
|
readonly cookieName: string
|
||||||
|
/** Opaque cookie value required to validate asset requests. */
|
||||||
|
readonly cookieValue: string
|
||||||
|
/** Cookie path scope for browser requests. */
|
||||||
|
readonly cookiePath: string
|
||||||
|
/** Unix timestamp in milliseconds when the session expires. */
|
||||||
|
readonly expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request data required to validate a cookie-backed static asset session.
|
||||||
|
*/
|
||||||
|
export interface StaticAssetSessionValidateInput {
|
||||||
|
/** Plugin extension id from the requested route. */
|
||||||
|
extensionId: string
|
||||||
|
/** Extension version from the requested route. */
|
||||||
|
version: string
|
||||||
|
/** Opaque session id from the requested route. */
|
||||||
|
assetSessionId: string
|
||||||
|
/** Static asset path being requested. */
|
||||||
|
assetPath: string
|
||||||
|
/** Cookie value provided by the request, if any. */
|
||||||
|
cookieValue: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of validating a cookie-backed static asset request.
|
||||||
|
*/
|
||||||
|
export type StaticAssetSessionValidationResult
|
||||||
|
= | { ok: true, session: StaticAssetSession }
|
||||||
|
| { ok: false, error: HttpError }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory store for cookie-backed extension static asset sessions.
|
* In-memory store for cookie-backed extension static asset sessions.
|
||||||
*/
|
*/
|
||||||
export interface StaticAssetSessionStore {
|
export interface StaticAssetSessionStore {
|
||||||
/** Creates a new cookie-backed static asset session. */
|
/** Creates a new cookie-backed static asset session. */
|
||||||
createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession
|
createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession
|
||||||
/** Extends an existing session using its original TTL. */
|
|
||||||
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
|
|
||||||
/** Revokes every static asset session. */
|
|
||||||
revokeAll: () => StaticAssetSession[]
|
|
||||||
/** Revokes all static asset sessions for one extension. */
|
|
||||||
revokeByExtensionId: (extensionId: string) => StaticAssetSession[]
|
|
||||||
/** Revokes all static asset sessions owned by a plugin session. */
|
|
||||||
revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[]
|
|
||||||
/** Revokes one static asset session by id. */
|
|
||||||
revokeSession: (assetSessionId: string) => StaticAssetSession | undefined
|
|
||||||
/** Validates route and cookie data for a static asset request. */
|
/** Validates route and cookie data for a static asset request. */
|
||||||
validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult
|
validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult
|
||||||
|
/** Extends an existing session using its original TTL. */
|
||||||
|
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
|
||||||
|
/** Revokes one static asset session by id. */
|
||||||
|
revokeSession: (assetSessionId: string) => StaticAssetSession | undefined
|
||||||
|
/** Revokes all static asset sessions owned by a plugin session. */
|
||||||
|
revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[]
|
||||||
|
/** Revokes all static asset sessions for one extension. */
|
||||||
|
revokeByExtensionId: (extensionId: string) => StaticAssetSession[]
|
||||||
|
/** Revokes every static asset session. */
|
||||||
|
revokeAll: () => StaticAssetSession[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type StaticAssetResolveResult
|
||||||
* Request data required to validate a cookie-backed static asset session.
|
= | { ok: true, filePath: string, size: number, mtime: number }
|
||||||
*/
|
| { ok: false, error: HttpError }
|
||||||
export interface StaticAssetSessionValidateInput {
|
|
||||||
/** Static asset path being requested. */
|
|
||||||
assetPath: string
|
|
||||||
/** Opaque session id from the requested route. */
|
|
||||||
assetSessionId: string
|
|
||||||
/** Cookie value provided by the request, if any. */
|
|
||||||
cookieValue: string | undefined
|
|
||||||
/** Plugin extension id from the requested route. */
|
|
||||||
extensionId: string
|
|
||||||
/** Extension version from the requested route. */
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of validating a cookie-backed static asset request.
|
|
||||||
*/
|
|
||||||
export type StaticAssetSessionValidationResult
|
|
||||||
= | { error: HttpError, ok: false }
|
|
||||||
| { ok: true, session: StaticAssetSession }
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { injeca } from 'injeca'
|
|||||||
|
|
||||||
import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa'
|
import { i18nGetLocale, i18nSetLocale } from '../../../../shared/eventa'
|
||||||
|
|
||||||
export async function createI18nService(params: { context: ReturnType<typeof createContext>['context'], i18n: I18n, window: BrowserWindow }) {
|
export async function createI18nService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow, i18n: I18n }) {
|
||||||
const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy<Config<typeof globalAppConfigSchema>> })
|
const { config } = await injeca.resolve({ config: 'configs:app' } as { config: ProvidedBy<Config<typeof globalAppConfigSchema>> })
|
||||||
params.i18n.locale(config.get()?.language || 'en')
|
params.i18n.locale(config.get()?.language || 'en')
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', async () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
StdioClientTransport: class {
|
StdioClientTransport: class {
|
||||||
close = vi.fn(async () => undefined)
|
|
||||||
|
|
||||||
stderr = new PassThrough()
|
stderr = new PassThrough()
|
||||||
|
|
||||||
constructor(readonly server: unknown) {}
|
constructor(readonly server: unknown) {}
|
||||||
|
|
||||||
|
close = vi.fn(async () => undefined)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -76,10 +76,10 @@ describe('createMcpStdioManager', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = await manager.testServer({
|
const result = await manager.testServer({
|
||||||
|
name: 'broken-server',
|
||||||
config: {
|
config: {
|
||||||
command: 'broken-mcp-server',
|
command: 'broken-mcp-server',
|
||||||
},
|
},
|
||||||
name: 'broken-server',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
|
|||||||
@@ -36,23 +36,23 @@ import {
|
|||||||
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
|
import { parseElectronMcpConfigText } from '../../../../shared/mcp-config'
|
||||||
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
|
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
|
||||||
|
|
||||||
export interface McpStdioManager {
|
|
||||||
applyAndRestart: () => Promise<ElectronMcpStdioApplyResult>
|
|
||||||
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
|
|
||||||
ensureConfigFile: () => Promise<{ path: string }>
|
|
||||||
getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus
|
|
||||||
listTools: () => Promise<ElectronMcpToolDescriptor[]>
|
|
||||||
openConfigFile: () => Promise<{ path: string }>
|
|
||||||
readConfigText: () => Promise<ElectronMcpStdioConfigText>
|
|
||||||
stopAll: () => Promise<void>
|
|
||||||
testServer: (payload: ElectronMcpStdioTestPayload) => Promise<ElectronMcpStdioTestResult>
|
|
||||||
writeConfigText: (text: string) => Promise<ElectronMcpStdioConfigText>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface McpServerSession {
|
interface McpServerSession {
|
||||||
client: Client
|
client: Client
|
||||||
config: ElectronMcpStdioServerConfig
|
|
||||||
transport: StdioClientTransport
|
transport: StdioClientTransport
|
||||||
|
config: ElectronMcpStdioServerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpStdioManager {
|
||||||
|
ensureConfigFile: () => Promise<{ path: string }>
|
||||||
|
openConfigFile: () => Promise<{ path: string }>
|
||||||
|
applyAndRestart: () => Promise<ElectronMcpStdioApplyResult>
|
||||||
|
listTools: () => Promise<ElectronMcpToolDescriptor[]>
|
||||||
|
callTool: (payload: ElectronMcpCallToolPayload) => Promise<ElectronMcpCallToolResult>
|
||||||
|
stopAll: () => Promise<void>
|
||||||
|
getRuntimeStatus: () => ElectronMcpStdioRuntimeStatus
|
||||||
|
readConfigText: () => Promise<ElectronMcpStdioConfigText>
|
||||||
|
writeConfigText: (text: string) => Promise<ElectronMcpStdioConfigText>
|
||||||
|
testServer: (payload: ElectronMcpStdioTestPayload) => Promise<ElectronMcpStdioTestResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMcpConfig: ElectronMcpStdioConfigFile = {
|
const defaultMcpConfig: ElectronMcpStdioConfigFile = {
|
||||||
@@ -63,38 +63,53 @@ const mcpRequestTimeoutMsec = 10_000
|
|||||||
const mcpRequestMaxTotalTimeoutMsec = 15_000
|
const mcpRequestMaxTotalTimeoutMsec = 15_000
|
||||||
const mcpTestStderrMaxChars = 16_000
|
const mcpTestStderrMaxChars = 16_000
|
||||||
|
|
||||||
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
|
function stringifyError(error: unknown) {
|
||||||
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
|
if (error instanceof Error) {
|
||||||
return params.manager.openConfigFile()
|
return error.message
|
||||||
})
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
|
return String(error)
|
||||||
return params.manager.applyAndRestart()
|
}
|
||||||
})
|
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
|
function getConfigPath() {
|
||||||
return params.manager.getRuntimeStatus()
|
return join(app.getPath('userData'), 'mcp.json')
|
||||||
})
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpListTools, async () => {
|
function parseQualifiedToolName(name: string) {
|
||||||
return params.manager.listTools()
|
const separatorIndex = name.indexOf(toolNameSeparator)
|
||||||
})
|
if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) {
|
||||||
|
throw new Error(`invalid qualified tool name: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => {
|
return {
|
||||||
return params.manager.callTool(payload)
|
serverName: name.slice(0, separatorIndex),
|
||||||
})
|
toolName: name.slice(separatorIndex + toolNameSeparator.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpReadConfigText, async () => {
|
function resolveFallbackToolName(toolName: string): string | undefined {
|
||||||
return params.manager.readConfigText()
|
const normalizedTransportPrefix = toolName
|
||||||
})
|
.replace(/^\.(?:stdio|stdo)::/, '')
|
||||||
|
.replace(/^(?:stdio|stdo)::/, '')
|
||||||
|
if (normalizedTransportPrefix !== toolName) {
|
||||||
|
return normalizedTransportPrefix
|
||||||
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
|
const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
|
||||||
return params.manager.writeConfigText(payload.text)
|
if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
|
||||||
})
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
|
return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
|
||||||
return params.manager.testServer(payload)
|
}
|
||||||
})
|
|
||||||
|
async function closeSession(session: McpServerSession) {
|
||||||
|
try {
|
||||||
|
await session.client.close()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
await session.transport.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMcpStdioManager(): McpStdioManager {
|
export function createMcpStdioManager(): McpStdioManager {
|
||||||
@@ -138,11 +153,11 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
for (const [name, session] of entries) {
|
for (const [name, session] of entries) {
|
||||||
await closeSession(session)
|
await closeSession(session)
|
||||||
setRuntimeStatus({
|
setRuntimeStatus({
|
||||||
args: session.config.args ?? [],
|
|
||||||
command: session.config.command,
|
|
||||||
name,
|
name,
|
||||||
pid: null,
|
|
||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
|
command: session.config.command,
|
||||||
|
args: session.config.args ?? [],
|
||||||
|
pid: null,
|
||||||
})
|
})
|
||||||
sessions.delete(name)
|
sessions.delete(name)
|
||||||
}
|
}
|
||||||
@@ -150,10 +165,10 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
|
|
||||||
const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => {
|
const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => {
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
args: config.args ?? [],
|
|
||||||
command: config.command,
|
command: config.command,
|
||||||
cwd: config.cwd,
|
args: config.args ?? [],
|
||||||
env: config.env,
|
env: config.env,
|
||||||
|
cwd: config.cwd,
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
@@ -169,13 +184,13 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
log.withFields({ serverName: name }).warn(text)
|
log.withFields({ serverName: name }).warn(text)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
sessions.set(name, { client, config, transport })
|
sessions.set(name, { client, transport, config })
|
||||||
setRuntimeStatus({
|
setRuntimeStatus({
|
||||||
args: config.args ?? [],
|
|
||||||
command: config.command,
|
|
||||||
name,
|
name,
|
||||||
pid: transport.pid,
|
|
||||||
state: 'running',
|
state: 'running',
|
||||||
|
command: config.command,
|
||||||
|
args: config.args ?? [],
|
||||||
|
pid: transport.pid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -192,21 +207,21 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
runtimeStatuses.clear()
|
runtimeStatuses.clear()
|
||||||
|
|
||||||
const result: ElectronMcpStdioApplyResult = {
|
const result: ElectronMcpStdioApplyResult = {
|
||||||
failed: [],
|
|
||||||
path,
|
path,
|
||||||
skipped: [],
|
|
||||||
started: [],
|
started: [],
|
||||||
|
failed: [],
|
||||||
|
skipped: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||||
if (server.enabled === false) {
|
if (server.enabled === false) {
|
||||||
result.skipped.push({ name, reason: 'disabled' })
|
result.skipped.push({ name, reason: 'disabled' })
|
||||||
setRuntimeStatus({
|
setRuntimeStatus({
|
||||||
args: server.args ?? [],
|
|
||||||
command: server.command,
|
|
||||||
name,
|
name,
|
||||||
pid: null,
|
|
||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
|
command: server.command,
|
||||||
|
args: server.args ?? [],
|
||||||
|
pid: null,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -217,14 +232,14 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
const message = stringifyError(error)
|
const message = stringifyError(error)
|
||||||
result.failed.push({ error: message, name })
|
result.failed.push({ name, error: message })
|
||||||
setRuntimeStatus({
|
setRuntimeStatus({
|
||||||
args: server.args ?? [],
|
|
||||||
command: server.command,
|
|
||||||
lastError: message,
|
|
||||||
name,
|
name,
|
||||||
pid: null,
|
|
||||||
state: 'error',
|
state: 'error',
|
||||||
|
command: server.command,
|
||||||
|
args: server.args ?? [],
|
||||||
|
pid: null,
|
||||||
|
lastError: message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,15 +254,15 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
const listResult = await Promise.all(entries.map(async ([serverName, session]) => {
|
const listResult = await Promise.all(entries.map(async ([serverName, session]) => {
|
||||||
try {
|
try {
|
||||||
const response = await session.client.listTools(undefined, {
|
const response = await session.client.listTools(undefined, {
|
||||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
|
||||||
timeout: mcpRequestTimeoutMsec,
|
timeout: mcpRequestTimeoutMsec,
|
||||||
|
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||||
})
|
})
|
||||||
return response.tools.map<ElectronMcpToolDescriptor>(item => ({
|
return response.tools.map<ElectronMcpToolDescriptor>(item => ({
|
||||||
|
serverName,
|
||||||
|
name: `${serverName}${toolNameSeparator}${item.name}`,
|
||||||
|
toolName: item.name,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
inputSchema: item.inputSchema,
|
inputSchema: item.inputSchema,
|
||||||
name: `${serverName}${toolNameSeparator}${item.name}`,
|
|
||||||
serverName,
|
|
||||||
toolName: item.name,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -269,11 +284,11 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
let result
|
let result
|
||||||
try {
|
try {
|
||||||
result = await session.client.callTool({
|
result = await session.client.callTool({
|
||||||
arguments: payload.arguments ?? {},
|
|
||||||
name: toolName,
|
name: toolName,
|
||||||
|
arguments: payload.arguments ?? {},
|
||||||
}, undefined, {
|
}, undefined, {
|
||||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
|
||||||
timeout: mcpRequestTimeoutMsec,
|
timeout: mcpRequestTimeoutMsec,
|
||||||
|
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -283,17 +298,17 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.withFields({
|
log.withFields({
|
||||||
fallbackToolName,
|
|
||||||
requestedToolName: toolName,
|
|
||||||
serverName,
|
serverName,
|
||||||
|
requestedToolName: toolName,
|
||||||
|
fallbackToolName,
|
||||||
}).warn('retrying mcp tool call with normalized tool name')
|
}).warn('retrying mcp tool call with normalized tool name')
|
||||||
|
|
||||||
result = await session.client.callTool({
|
result = await session.client.callTool({
|
||||||
arguments: payload.arguments ?? {},
|
|
||||||
name: fallbackToolName,
|
name: fallbackToolName,
|
||||||
|
arguments: payload.arguments ?? {},
|
||||||
}, undefined, {
|
}, undefined, {
|
||||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
|
||||||
timeout: mcpRequestTimeoutMsec,
|
timeout: mcpRequestTimeoutMsec,
|
||||||
|
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +353,7 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
|
|
||||||
const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => {
|
const testServer = async (payload: ElectronMcpStdioTestPayload): Promise<ElectronMcpStdioTestResult> => {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
let transport: null | StdioClientTransport = null
|
let transport: StdioClientTransport | null = null
|
||||||
let client: Client | null = null
|
let client: Client | null = null
|
||||||
const stderrChunks: string[] = []
|
const stderrChunks: string[] = []
|
||||||
|
|
||||||
@@ -355,10 +370,10 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
transport = new StdioClientTransport({
|
transport = new StdioClientTransport({
|
||||||
args: payload.config.args ?? [],
|
|
||||||
command: payload.config.command,
|
command: payload.config.command,
|
||||||
cwd: payload.config.cwd,
|
args: payload.config.args ?? [],
|
||||||
env: payload.config.env,
|
env: payload.config.env,
|
||||||
|
cwd: payload.config.cwd,
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
client = new Client({
|
client = new Client({
|
||||||
@@ -375,8 +390,8 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect')
|
await withDeadline(client.connect(transport), mcpRequestMaxTotalTimeoutMsec, 'connect')
|
||||||
|
|
||||||
const response = await client.listTools(undefined, {
|
const response = await client.listTools(undefined, {
|
||||||
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
|
||||||
timeout: mcpRequestTimeoutMsec,
|
timeout: mcpRequestTimeoutMsec,
|
||||||
|
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (stderrChunks.length > 0) {
|
if (stderrChunks.length > 0) {
|
||||||
@@ -384,9 +399,9 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
ok: true,
|
ok: true,
|
||||||
tools: response.tools.map(tool => tool.name),
|
tools: response.tools.map(tool => tool.name),
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -394,9 +409,9 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
// Keep only the tail so a noisy failed server cannot flood the settings UI.
|
// Keep only the tail so a noisy failed server cannot flood the settings UI.
|
||||||
const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars)
|
const stderr = stderrChunks.join('').trim().slice(-mcpTestStderrMaxChars)
|
||||||
return {
|
return {
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
error: stderr ? `${message}\n\n${stderr}` : message,
|
|
||||||
ok: false,
|
ok: false,
|
||||||
|
error: stderr ? `${message}\n\n${stderr}` : message,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -410,16 +425,16 @@ export function createMcpStdioManager(): McpStdioManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyAndRestart,
|
|
||||||
callTool,
|
|
||||||
ensureConfigFile,
|
ensureConfigFile,
|
||||||
getRuntimeStatus,
|
|
||||||
listTools,
|
|
||||||
openConfigFile,
|
openConfigFile,
|
||||||
readConfigText,
|
applyAndRestart,
|
||||||
|
listTools,
|
||||||
|
callTool,
|
||||||
stopAll,
|
stopAll,
|
||||||
testServer,
|
getRuntimeStatus,
|
||||||
|
readConfigText,
|
||||||
writeConfigText,
|
writeConfigText,
|
||||||
|
testServer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,51 +458,36 @@ export async function setupMcpStdioManager() {
|
|||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSession(session: McpServerSession) {
|
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
|
||||||
try {
|
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
|
||||||
await session.client.close()
|
return params.manager.openConfigFile()
|
||||||
}
|
})
|
||||||
catch {
|
|
||||||
await session.transport.close()
|
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
|
||||||
}
|
return params.manager.applyAndRestart()
|
||||||
}
|
})
|
||||||
|
|
||||||
function getConfigPath() {
|
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
|
||||||
return join(app.getPath('userData'), 'mcp.json')
|
return params.manager.getRuntimeStatus()
|
||||||
}
|
})
|
||||||
|
|
||||||
function parseQualifiedToolName(name: string) {
|
defineInvokeHandler(params.context, electronMcpListTools, async () => {
|
||||||
const separatorIndex = name.indexOf(toolNameSeparator)
|
return params.manager.listTools()
|
||||||
if (separatorIndex <= 0 || separatorIndex === name.length - toolNameSeparator.length) {
|
})
|
||||||
throw new Error(`invalid qualified tool name: ${name}`)
|
|
||||||
}
|
defineInvokeHandler(params.context, electronMcpCallTool, async (payload) => {
|
||||||
|
return params.manager.callTool(payload)
|
||||||
return {
|
})
|
||||||
serverName: name.slice(0, separatorIndex),
|
|
||||||
toolName: name.slice(separatorIndex + toolNameSeparator.length),
|
defineInvokeHandler(params.context, electronMcpReadConfigText, async () => {
|
||||||
}
|
return params.manager.readConfigText()
|
||||||
}
|
})
|
||||||
|
|
||||||
function resolveFallbackToolName(toolName: string): string | undefined {
|
defineInvokeHandler(params.context, electronMcpWriteConfigText, async (payload) => {
|
||||||
const normalizedTransportPrefix = toolName
|
return params.manager.writeConfigText(payload.text)
|
||||||
.replace(/^\.(?:stdio|stdo)::/, '')
|
})
|
||||||
.replace(/^(?:stdio|stdo)::/, '')
|
|
||||||
if (normalizedTransportPrefix !== toolName) {
|
defineInvokeHandler(params.context, electronMcpTestServer, async (payload) => {
|
||||||
return normalizedTransportPrefix
|
return params.manager.testServer(payload)
|
||||||
}
|
})
|
||||||
|
|
||||||
const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
|
|
||||||
if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyError(error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const ANIMATION_DURATION = 350
|
|||||||
|
|
||||||
export function createOnboardingService(params: {
|
export function createOnboardingService(params: {
|
||||||
context: ReturnType<typeof createContext>['context']
|
context: ReturnType<typeof createContext>['context']
|
||||||
mainWindow: BrowserWindow
|
|
||||||
onboardingWindowManager: OnboardingWindowManager
|
onboardingWindowManager: OnboardingWindowManager
|
||||||
|
mainWindow: BrowserWindow
|
||||||
}) {
|
}) {
|
||||||
const mainWindowAnimator = new Animator(params.mainWindow)
|
const mainWindowAnimator = new Animator(params.mainWindow)
|
||||||
let cleanupOnClosed: (() => void) | undefined
|
let cleanupOnClosed: (() => void) | undefined
|
||||||
@@ -29,15 +29,15 @@ export function createOnboardingService(params: {
|
|||||||
|
|
||||||
const adjacent = computeAdjacentPosition(
|
const adjacent = computeAdjacentPosition(
|
||||||
onboardingBounds,
|
onboardingBounds,
|
||||||
{ height: savedBounds.height, width: savedBounds.width },
|
{ width: savedBounds.width, height: savedBounds.height },
|
||||||
display.workArea,
|
display.workArea,
|
||||||
)
|
)
|
||||||
|
|
||||||
mainWindowAnimator.windowBoundsAnimateTo({
|
mainWindowAnimator.windowBoundsAnimateTo({
|
||||||
height: adjacent.height,
|
|
||||||
width: adjacent.width,
|
|
||||||
x: adjacent.x,
|
x: adjacent.x,
|
||||||
y: adjacent.y,
|
y: adjacent.y,
|
||||||
|
width: adjacent.width,
|
||||||
|
height: adjacent.height,
|
||||||
}, { duration: ANIMATION_DURATION })
|
}, { duration: ANIMATION_DURATION })
|
||||||
|
|
||||||
let userMovedManually = false
|
let userMovedManually = false
|
||||||
|
|||||||
+19
-19
@@ -24,12 +24,12 @@ import { manifestIdOf } from '../../host/registry'
|
|||||||
* - N/A
|
* - N/A
|
||||||
*/
|
*/
|
||||||
export interface ExtensionAutoReloadFeatureOptions {
|
export interface ExtensionAutoReloadFeatureOptions {
|
||||||
getConfig: () => ExtensionConfig
|
|
||||||
isLoaded: (extensionId: string) => boolean
|
|
||||||
listEntries: () => ManifestEntry[]
|
|
||||||
log: ReturnType<typeof useLogg>
|
log: ReturnType<typeof useLogg>
|
||||||
reload: (extensionId: string, changedPath: string) => Promise<void>
|
getConfig: () => ExtensionConfig
|
||||||
|
listEntries: () => ManifestEntry[]
|
||||||
|
isLoaded: (extensionId: string) => boolean
|
||||||
resolveWatchPaths: (extensionId: string) => string[]
|
resolveWatchPaths: (extensionId: string) => string[]
|
||||||
|
reload: (extensionId: string, changedPath: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,21 +102,6 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clearExtension(extensionId: string) {
|
|
||||||
clearTimer(extensionId)
|
|
||||||
closeWatchers(extensionId)
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
const managedNames = new Set([
|
|
||||||
...autoReloadTimers.keys(),
|
|
||||||
...autoReloadWatchers.keys(),
|
|
||||||
])
|
|
||||||
|
|
||||||
for (const extensionId of managedNames) {
|
|
||||||
clearTimer(extensionId)
|
|
||||||
closeWatchers(extensionId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sync() {
|
sync() {
|
||||||
const enabledExtensionIds = new Set(options.getConfig().autoReload)
|
const enabledExtensionIds = new Set(options.getConfig().autoReload)
|
||||||
const desiredExtensionIds = new Set(options.listEntries()
|
const desiredExtensionIds = new Set(options.listEntries()
|
||||||
@@ -159,5 +144,20 @@ export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
clearExtension(extensionId: string) {
|
||||||
|
clearTimer(extensionId)
|
||||||
|
closeWatchers(extensionId)
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
const managedNames = new Set([
|
||||||
|
...autoReloadTimers.keys(),
|
||||||
|
...autoReloadWatchers.keys(),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const extensionId of managedNames) {
|
||||||
|
clearTimer(extensionId)
|
||||||
|
closeWatchers(extensionId)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-71
@@ -14,51 +14,51 @@ vi.mock('../../../http-server/static-assets', () => ({
|
|||||||
createStaticAssetService: mockState.createStaticAssetService,
|
createStaticAssetService: mockState.createStaticAssetService,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function createFakeCookieAdapter() {
|
function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession {
|
||||||
const setCookies: ExtensionAssetCookie[] = []
|
|
||||||
const removedCookies: ExtensionAssetCookie[] = []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adapter: {
|
assetSessionId,
|
||||||
removeCookie: vi.fn(async (cookie) => {
|
cookieName: `airi_extension_asset_session_${assetSessionId}`,
|
||||||
removedCookies.push(cookie)
|
cookieValue: `cookie-value-${assetSessionId}`,
|
||||||
}),
|
cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`,
|
||||||
setCookie: vi.fn(async (cookie) => {
|
expiresAt: 123_456,
|
||||||
setCookies.push(cookie)
|
|
||||||
}),
|
|
||||||
} satisfies ExtensionAssetCookieAdapter,
|
|
||||||
removedCookies,
|
|
||||||
setCookies,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFakeServer(options: {
|
function createFakeServer(options: {
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
createSessionResult?: StaticAssetSession
|
createSessionResult?: StaticAssetSession
|
||||||
revokeAllResult?: StaticAssetSession[]
|
|
||||||
revokeByExtensionIdResult?: StaticAssetSession[]
|
|
||||||
revokeByOwnerSessionIdResult?: StaticAssetSession[]
|
revokeByOwnerSessionIdResult?: StaticAssetSession[]
|
||||||
|
revokeByExtensionIdResult?: StaticAssetSession[]
|
||||||
|
revokeAllResult?: StaticAssetSession[]
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')),
|
|
||||||
getBaseUrl: vi.fn(() => options.baseUrl),
|
|
||||||
key: 'static-assets',
|
key: 'static-assets',
|
||||||
revokeAll: vi.fn(() => options.revokeAllResult ?? []),
|
|
||||||
revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []),
|
|
||||||
revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []),
|
|
||||||
revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)),
|
|
||||||
start: vi.fn(async () => {}),
|
start: vi.fn(async () => {}),
|
||||||
stop: vi.fn(async () => {}),
|
stop: vi.fn(async () => {}),
|
||||||
|
getBaseUrl: vi.fn(() => options.baseUrl),
|
||||||
|
createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')),
|
||||||
|
revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)),
|
||||||
|
revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []),
|
||||||
|
revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []),
|
||||||
|
revokeAll: vi.fn(() => options.revokeAllResult ?? []),
|
||||||
} satisfies StaticAssetService
|
} satisfies StaticAssetService
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession {
|
function createFakeCookieAdapter() {
|
||||||
|
const setCookies: ExtensionAssetCookie[] = []
|
||||||
|
const removedCookies: ExtensionAssetCookie[] = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetSessionId,
|
adapter: {
|
||||||
cookieName: `airi_extension_asset_session_${assetSessionId}`,
|
setCookie: vi.fn(async (cookie) => {
|
||||||
cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`,
|
setCookies.push(cookie)
|
||||||
cookieValue: `cookie-value-${assetSessionId}`,
|
}),
|
||||||
expiresAt: 123_456,
|
removeCookie: vi.fn(async (cookie) => {
|
||||||
|
removedCookies.push(cookie)
|
||||||
|
}),
|
||||||
|
} satisfies ExtensionAssetCookieAdapter,
|
||||||
|
removedCookies,
|
||||||
|
setCookies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,41 +76,41 @@ describe('createExtensionAssetService', () => {
|
|||||||
mockState.createStaticAssetService.mockReturnValue(server)
|
mockState.createStaticAssetService.mockReturnValue(server)
|
||||||
|
|
||||||
const service = createExtensionAssetService({
|
const service = createExtensionAssetService({
|
||||||
cookieAdapter: adapter,
|
|
||||||
getManifestEntryByExtensionId: () => new Map(),
|
getManifestEntryByExtensionId: () => new Map(),
|
||||||
|
cookieAdapter: adapter,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await service.createAssetSession({
|
const result = await service.createAssetSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
ownerSessionId: 'owner-session-1',
|
|
||||||
pathPrefix: 'assets/',
|
|
||||||
routeAssetPath: 'assets/app.js',
|
|
||||||
ttlMs: 60_000,
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
ownerSessionId: 'owner-session-1',
|
||||||
|
routeAssetPath: 'assets/app.js',
|
||||||
|
pathPrefix: 'assets/',
|
||||||
|
ttlMs: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(server.createSession).toHaveBeenCalledWith({
|
expect(server.createSession).toHaveBeenCalledWith({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
|
version: '1.0.0',
|
||||||
ownerSessionId: 'owner-session-1',
|
ownerSessionId: 'owner-session-1',
|
||||||
pathPrefix: 'assets/',
|
pathPrefix: 'assets/',
|
||||||
ttlMs: 60_000,
|
ttlMs: 60_000,
|
||||||
version: '1.0.0',
|
|
||||||
})
|
})
|
||||||
expect(adapter.setCookie).toHaveBeenCalledOnce()
|
expect(adapter.setCookie).toHaveBeenCalledOnce()
|
||||||
expect(setCookies).toEqual([
|
expect(setCookies).toEqual([
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_asset-session-1',
|
name: 'airi_extension_asset_session_asset-session-1',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
|
|
||||||
value: 'cookie-value-asset-session-1',
|
value: 'cookie-value-asset-session-1',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js',
|
||||||
assetSessionId: 'asset-session-1',
|
assetSessionId: 'asset-session-1',
|
||||||
cookie: setCookies[0],
|
cookie: setCookies[0],
|
||||||
expiresAt: 123_456,
|
expiresAt: 123_456,
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,17 +123,17 @@ describe('createExtensionAssetService', () => {
|
|||||||
mockState.createStaticAssetService.mockReturnValue(server)
|
mockState.createStaticAssetService.mockReturnValue(server)
|
||||||
|
|
||||||
const service = createExtensionAssetService({
|
const service = createExtensionAssetService({
|
||||||
cookieAdapter: adapter,
|
|
||||||
getManifestEntryByExtensionId: () => new Map(),
|
getManifestEntryByExtensionId: () => new Map(),
|
||||||
|
cookieAdapter: adapter,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(service.createAssetSession({
|
await expect(service.createAssetSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
ownerSessionId: 'owner-session-1',
|
|
||||||
pathPrefix: 'assets/',
|
|
||||||
routeAssetPath: 'assets/app.js',
|
|
||||||
ttlMs: 60_000,
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
ownerSessionId: 'owner-session-1',
|
||||||
|
routeAssetPath: 'assets/app.js',
|
||||||
|
pathPrefix: 'assets/',
|
||||||
|
ttlMs: 60_000,
|
||||||
})).rejects.toThrow('Extension asset server base URL is unavailable')
|
})).rejects.toThrow('Extension asset server base URL is unavailable')
|
||||||
|
|
||||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2')
|
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2')
|
||||||
@@ -149,17 +149,17 @@ describe('createExtensionAssetService', () => {
|
|||||||
const { adapter } = createFakeCookieAdapter()
|
const { adapter } = createFakeCookieAdapter()
|
||||||
mockState.createStaticAssetService.mockReturnValue(server)
|
mockState.createStaticAssetService.mockReturnValue(server)
|
||||||
const service = createExtensionAssetService({
|
const service = createExtensionAssetService({
|
||||||
cookieAdapter: adapter,
|
|
||||||
getManifestEntryByExtensionId: () => new Map(),
|
getManifestEntryByExtensionId: () => new Map(),
|
||||||
|
cookieAdapter: adapter,
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(service.createAssetSession({
|
await expect(service.createAssetSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
ownerSessionId: 'owner-session-1',
|
|
||||||
pathPrefix: '',
|
|
||||||
routeAssetPath: '../secret.txt',
|
|
||||||
ttlMs: 60_000,
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
ownerSessionId: 'owner-session-1',
|
||||||
|
routeAssetPath: '../secret.txt',
|
||||||
|
pathPrefix: '',
|
||||||
|
ttlMs: 60_000,
|
||||||
})).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path')
|
})).rejects.toThrow('Extension asset session routeAssetPath must be a safe extension asset path')
|
||||||
|
|
||||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3')
|
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3')
|
||||||
@@ -170,11 +170,11 @@ describe('createExtensionAssetService', () => {
|
|||||||
|
|
||||||
await expect(service.createAssetSession({
|
await expect(service.createAssetSession({
|
||||||
extensionId: 'airi-plugin-game-chess',
|
extensionId: 'airi-plugin-game-chess',
|
||||||
ownerSessionId: 'owner-session-1',
|
|
||||||
pathPrefix: 'assets/',
|
|
||||||
routeAssetPath: 'assets/app.js',
|
|
||||||
ttlMs: 60_000,
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
ownerSessionId: 'owner-session-1',
|
||||||
|
routeAssetPath: 'assets/app.js',
|
||||||
|
pathPrefix: 'assets/',
|
||||||
|
ttlMs: 60_000,
|
||||||
})).rejects.toThrow('cookie jar unavailable')
|
})).rejects.toThrow('cookie jar unavailable')
|
||||||
|
|
||||||
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4')
|
expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4')
|
||||||
@@ -187,17 +187,17 @@ describe('createExtensionAssetService', () => {
|
|||||||
const allSession = createSession('all-asset-session')
|
const allSession = createSession('all-asset-session')
|
||||||
const server = createFakeServer({
|
const server = createFakeServer({
|
||||||
baseUrl: 'http://127.0.0.1:48123',
|
baseUrl: 'http://127.0.0.1:48123',
|
||||||
revokeAllResult: [allSession],
|
|
||||||
revokeByExtensionIdResult: [pluginSession],
|
|
||||||
revokeByOwnerSessionIdResult: [ownerSession],
|
revokeByOwnerSessionIdResult: [ownerSession],
|
||||||
|
revokeByExtensionIdResult: [pluginSession],
|
||||||
|
revokeAllResult: [allSession],
|
||||||
})
|
})
|
||||||
server.revokeSession.mockReturnValue(directSession)
|
server.revokeSession.mockReturnValue(directSession)
|
||||||
const { adapter, removedCookies } = createFakeCookieAdapter()
|
const { adapter, removedCookies } = createFakeCookieAdapter()
|
||||||
mockState.createStaticAssetService.mockReturnValue(server)
|
mockState.createStaticAssetService.mockReturnValue(server)
|
||||||
|
|
||||||
const service = createExtensionAssetService({
|
const service = createExtensionAssetService({
|
||||||
cookieAdapter: adapter,
|
|
||||||
getManifestEntryByExtensionId: () => new Map(),
|
getManifestEntryByExtensionId: () => new Map(),
|
||||||
|
cookieAdapter: adapter,
|
||||||
})
|
})
|
||||||
|
|
||||||
await service.revokeSession('direct-asset-session')
|
await service.revokeSession('direct-asset-session')
|
||||||
@@ -212,32 +212,32 @@ describe('createExtensionAssetService', () => {
|
|||||||
expect(adapter.removeCookie).toHaveBeenCalledTimes(4)
|
expect(adapter.removeCookie).toHaveBeenCalledTimes(4)
|
||||||
expect(removedCookies).toEqual([
|
expect(removedCookies).toEqual([
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_direct-asset-session',
|
name: 'airi_extension_asset_session_direct-asset-session',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
|
|
||||||
value: 'cookie-value-direct-asset-session',
|
value: 'cookie-value-direct-asset-session',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_owner-asset-session',
|
name: 'airi_extension_asset_session_owner-asset-session',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
|
|
||||||
value: 'cookie-value-owner-asset-session',
|
value: 'cookie-value-owner-asset-session',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_plugin-asset-session',
|
name: 'airi_extension_asset_session_plugin-asset-session',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
|
|
||||||
value: 'cookie-value-plugin-asset-session',
|
value: 'cookie-value-plugin-asset-session',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_all-asset-session',
|
name: 'airi_extension_asset_session_all-asset-session',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
|
|
||||||
value: 'cookie-value-all-asset-session',
|
value: 'cookie-value-all-asset-session',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -252,8 +252,8 @@ describe('createExtensionAssetService', () => {
|
|||||||
mockState.createStaticAssetService.mockReturnValue(server)
|
mockState.createStaticAssetService.mockReturnValue(server)
|
||||||
|
|
||||||
const service = createExtensionAssetService({
|
const service = createExtensionAssetService({
|
||||||
cookieAdapter: adapter,
|
|
||||||
getManifestEntryByExtensionId: () => new Map(),
|
getManifestEntryByExtensionId: () => new Map(),
|
||||||
|
cookieAdapter: adapter,
|
||||||
})
|
})
|
||||||
|
|
||||||
await service.stop()
|
await service.stop()
|
||||||
@@ -263,11 +263,11 @@ describe('createExtensionAssetService', () => {
|
|||||||
expect(server.stop).toHaveBeenCalledOnce()
|
expect(server.stop).toHaveBeenCalledOnce()
|
||||||
expect(removedCookies).toEqual([
|
expect(removedCookies).toEqual([
|
||||||
{
|
{
|
||||||
expiresAt: 123_456,
|
|
||||||
name: 'airi_extension_asset_session_stop-asset-session',
|
name: 'airi_extension_asset_session_stop-asset-session',
|
||||||
path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
|
|
||||||
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
|
|
||||||
value: 'cookie-value-stop-asset-session',
|
value: 'cookie-value-stop-asset-session',
|
||||||
|
url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
|
||||||
|
path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui',
|
||||||
|
expiresAt: 123_456,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
+133
-133
@@ -5,98 +5,6 @@ import type { StaticAssetSession } from '../../../http-server/static-assets/type
|
|||||||
import { createStaticAssetService } from '../../../http-server/static-assets'
|
import { createStaticAssetService } from '../../../http-server/static-assets'
|
||||||
import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths'
|
import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths'
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes the cookie material Electron must apply before loading an extension asset URL.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Main process bridges server-side asset sessions into Electron's cookie jar
|
|
||||||
* - Revocation needs enough cookie identity to remove previously issued asset cookies
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `url` belongs to the local asset server origin
|
|
||||||
* - `path` matches the server-issued cookie path for the asset session route
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
export interface ExtensionAssetCookie {
|
|
||||||
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
|
|
||||||
expiresAt: number
|
|
||||||
/** Cookie name generated for the asset session. */
|
|
||||||
name: string
|
|
||||||
/** Route path scope generated by the asset session store. */
|
|
||||||
path: string
|
|
||||||
/** Absolute URL on the local asset server used by Electron cookie APIs. */
|
|
||||||
url: string
|
|
||||||
/** Opaque cookie value required by the static asset route. */
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies and removes extension asset cookies from the Electron host.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL
|
|
||||||
* - Asset session revocation must remove browser-visible cookie state
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `setCookie` resolves only after Electron can send the cookie for matching asset URLs
|
|
||||||
* - `removeCookie` is idempotent for already-removed cookies
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
export interface ExtensionAssetCookieAdapter {
|
|
||||||
removeCookie: (cookie: ExtensionAssetCookie) => Promise<void>
|
|
||||||
setCookie: (cookie: ExtensionAssetCookie) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines the extension-owned asset hosting service used by the extension host.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
|
|
||||||
* - Host teardown must revoke extension asset access independently from widget/gamelet logic
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - Implementations own the underlying transport, cookie, and session lifecycle
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A startable/stoppable asset-hosting service with generic extension-facing methods
|
|
||||||
*/
|
|
||||||
export interface ExtensionAssetService extends ServerManager {
|
|
||||||
createAssetSession: (input: ExtensionAssetSessionInput) => Promise<ExtensionAssetSession>
|
|
||||||
getBaseUrl: () => string | undefined
|
|
||||||
revokeAll: () => Promise<void>
|
|
||||||
revokeByExtensionId: (extensionId: string) => Promise<void>
|
|
||||||
revokeByOwnerSessionId: (ownerSessionId: string) => Promise<void>
|
|
||||||
revokeSession: (assetSessionId: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes an extension asset session prepared for renderer iframe navigation.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - A plugin iframe needs a mounted static asset URL and pre-applied cookie state
|
|
||||||
* - Callers need the opaque session id for later targeted revocation
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `cookie` was set through the host adapter before the value is returned
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Renderer-facing URL plus server and cookie metadata
|
|
||||||
*/
|
|
||||||
export interface ExtensionAssetSession {
|
|
||||||
/** Opaque server-side asset session id embedded in mounted asset routes. */
|
|
||||||
assetSessionId: string
|
|
||||||
/** Cookie data that was applied through the host adapter. */
|
|
||||||
cookie: ExtensionAssetCookie
|
|
||||||
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
|
|
||||||
expiresAt: number
|
|
||||||
/** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes one extension asset session creation request.
|
* Describes one extension asset session creation request.
|
||||||
*
|
*
|
||||||
@@ -115,16 +23,62 @@ export interface ExtensionAssetSession {
|
|||||||
export interface ExtensionAssetSessionInput {
|
export interface ExtensionAssetSessionInput {
|
||||||
/** Extension manifest id that owns the static asset root. */
|
/** Extension manifest id that owns the static asset root. */
|
||||||
extensionId: string
|
extensionId: string
|
||||||
/** Parent extension session id used for owner-scoped revocation. */
|
|
||||||
ownerSessionId: string
|
|
||||||
/** Allowed asset path prefix enforced by the server-side session store. */
|
|
||||||
pathPrefix: string
|
|
||||||
/** Asset path to mount in the returned renderer-facing URL. */
|
|
||||||
routeAssetPath: string
|
|
||||||
/** Session lifetime in milliseconds from creation or refresh time. */
|
|
||||||
ttlMs: number
|
|
||||||
/** Extension/plugin version expected by the server-side session validator. */
|
/** Extension/plugin version expected by the server-side session validator. */
|
||||||
version: string
|
version: string
|
||||||
|
/** Parent extension session id used for owner-scoped revocation. */
|
||||||
|
ownerSessionId: string
|
||||||
|
/** Asset path to mount in the returned renderer-facing URL. */
|
||||||
|
routeAssetPath: string
|
||||||
|
/** Allowed asset path prefix enforced by the server-side session store. */
|
||||||
|
pathPrefix: string
|
||||||
|
/** Session lifetime in milliseconds from creation or refresh time. */
|
||||||
|
ttlMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the cookie material Electron must apply before loading an extension asset URL.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Main process bridges server-side asset sessions into Electron's cookie jar
|
||||||
|
* - Revocation needs enough cookie identity to remove previously issued asset cookies
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `url` belongs to the local asset server origin
|
||||||
|
* - `path` matches the server-issued cookie path for the asset session route
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface ExtensionAssetCookie {
|
||||||
|
/** Cookie name generated for the asset session. */
|
||||||
|
name: string
|
||||||
|
/** Opaque cookie value required by the static asset route. */
|
||||||
|
value: string
|
||||||
|
/** Absolute URL on the local asset server used by Electron cookie APIs. */
|
||||||
|
url: string
|
||||||
|
/** Route path scope generated by the asset session store. */
|
||||||
|
path: string
|
||||||
|
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies and removes extension asset cookies from the Electron host.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL
|
||||||
|
* - Asset session revocation must remove browser-visible cookie state
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `setCookie` resolves only after Electron can send the cookie for matching asset URLs
|
||||||
|
* - `removeCookie` is idempotent for already-removed cookies
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface ExtensionAssetCookieAdapter {
|
||||||
|
setCookie: (cookie: ExtensionAssetCookie) => Promise<void>
|
||||||
|
removeCookie: (cookie: ExtensionAssetCookie) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,8 +96,64 @@ export interface ExtensionAssetSessionInput {
|
|||||||
* - A mounted asset URL and cookie-backed session metadata
|
* - A mounted asset URL and cookie-backed session metadata
|
||||||
*/
|
*/
|
||||||
export interface ExtensionAssetSnapshotService {
|
export interface ExtensionAssetSnapshotService {
|
||||||
createAssetSession: (input: Omit<ExtensionAssetSessionInput, 'ttlMs'>) => Promise<ExtensionAssetSession>
|
|
||||||
getBaseUrl: () => string | undefined
|
getBaseUrl: () => string | undefined
|
||||||
|
createAssetSession: (input: Omit<ExtensionAssetSessionInput, 'ttlMs'>) => Promise<ExtensionAssetSession>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes an extension asset session prepared for renderer iframe navigation.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - A plugin iframe needs a mounted static asset URL and pre-applied cookie state
|
||||||
|
* - Callers need the opaque session id for later targeted revocation
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `cookie` was set through the host adapter before the value is returned
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Renderer-facing URL plus server and cookie metadata
|
||||||
|
*/
|
||||||
|
export interface ExtensionAssetSession {
|
||||||
|
/** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */
|
||||||
|
url: string
|
||||||
|
/** Opaque server-side asset session id embedded in mounted asset routes. */
|
||||||
|
assetSessionId: string
|
||||||
|
/** Cookie data that was applied through the host adapter. */
|
||||||
|
cookie: ExtensionAssetCookie
|
||||||
|
/** Unix timestamp in milliseconds when the cookie-backed asset session expires. */
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the extension-owned asset hosting service used by the extension host.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
|
||||||
|
* - Host teardown must revoke extension asset access independently from widget/gamelet logic
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Implementations own the underlying transport, cookie, and session lifecycle
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A startable/stoppable asset-hosting service with generic extension-facing methods
|
||||||
|
*/
|
||||||
|
export interface ExtensionAssetService extends ServerManager {
|
||||||
|
getBaseUrl: () => string | undefined
|
||||||
|
createAssetSession: (input: ExtensionAssetSessionInput) => Promise<ExtensionAssetSession>
|
||||||
|
revokeSession: (assetSessionId: string) => Promise<void>
|
||||||
|
revokeByOwnerSessionId: (ownerSessionId: string) => Promise<void>
|
||||||
|
revokeByExtensionId: (extensionId: string) => Promise<void>
|
||||||
|
revokeAll: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie {
|
||||||
|
return {
|
||||||
|
name: session.cookieName,
|
||||||
|
value: session.cookieValue,
|
||||||
|
url: new URL(session.cookiePath, baseUrl).toString(),
|
||||||
|
path: session.cookiePath,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,8 +171,8 @@ export interface ExtensionAssetSnapshotService {
|
|||||||
* - An extension-facing asset host service with generic extension asset methods
|
* - An extension-facing asset host service with generic extension asset methods
|
||||||
*/
|
*/
|
||||||
export function createExtensionAssetService(options: {
|
export function createExtensionAssetService(options: {
|
||||||
cookieAdapter: ExtensionAssetCookieAdapter
|
|
||||||
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
getManifestEntryByExtensionId: () => Map<string, StaticAssetManifestEntry>
|
||||||
|
cookieAdapter: ExtensionAssetCookieAdapter
|
||||||
}): ExtensionAssetService {
|
}): ExtensionAssetService {
|
||||||
const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId })
|
const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId })
|
||||||
let lastBaseUrl: string | undefined
|
let lastBaseUrl: string | undefined
|
||||||
@@ -185,13 +195,24 @@ export function createExtensionAssetService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
key: 'extension-assets',
|
||||||
|
async start() {
|
||||||
|
await server.start()
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
await revokeSessions(server.revokeAll())
|
||||||
|
await server.stop()
|
||||||
|
},
|
||||||
|
getBaseUrl() {
|
||||||
|
return readBaseUrl()
|
||||||
|
},
|
||||||
async createAssetSession(input) {
|
async createAssetSession(input) {
|
||||||
const session = server.createSession({
|
const session = server.createSession({
|
||||||
extensionId: input.extensionId,
|
extensionId: input.extensionId,
|
||||||
|
version: input.version,
|
||||||
ownerSessionId: input.ownerSessionId,
|
ownerSessionId: input.ownerSessionId,
|
||||||
pathPrefix: input.pathPrefix,
|
pathPrefix: input.pathPrefix,
|
||||||
ttlMs: input.ttlMs,
|
ttlMs: input.ttlMs,
|
||||||
version: input.version,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -201,9 +222,9 @@ export function createExtensionAssetService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mountedPath = buildMountedStaticAssetPath({
|
const mountedPath = buildMountedStaticAssetPath({
|
||||||
assetPath: input.routeAssetPath,
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
extensionId: input.extensionId,
|
extensionId: input.extensionId,
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: input.routeAssetPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!mountedPath) {
|
if (!mountedPath) {
|
||||||
@@ -214,10 +235,10 @@ export function createExtensionAssetService(options: {
|
|||||||
await options.cookieAdapter.setCookie(cookie)
|
await options.cookieAdapter.setCookie(cookie)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
url: new URL(mountedPath, baseUrl).toString(),
|
||||||
assetSessionId: session.assetSessionId,
|
assetSessionId: session.assetSessionId,
|
||||||
cookie,
|
cookie,
|
||||||
expiresAt: session.expiresAt,
|
expiresAt: session.expiresAt,
|
||||||
url: new URL(mountedPath, baseUrl).toString(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -225,19 +246,6 @@ export function createExtensionAssetService(options: {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getBaseUrl() {
|
|
||||||
return readBaseUrl()
|
|
||||||
},
|
|
||||||
key: 'extension-assets',
|
|
||||||
async revokeAll() {
|
|
||||||
await revokeSessions(server.revokeAll())
|
|
||||||
},
|
|
||||||
async revokeByExtensionId(extensionId) {
|
|
||||||
await revokeSessions(server.revokeByExtensionId(extensionId))
|
|
||||||
},
|
|
||||||
async revokeByOwnerSessionId(ownerSessionId) {
|
|
||||||
await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
|
|
||||||
},
|
|
||||||
async revokeSession(assetSessionId) {
|
async revokeSession(assetSessionId) {
|
||||||
const session = server.revokeSession(assetSessionId)
|
const session = server.revokeSession(assetSessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -246,22 +254,14 @@ export function createExtensionAssetService(options: {
|
|||||||
|
|
||||||
await revokeSessions([session])
|
await revokeSessions([session])
|
||||||
},
|
},
|
||||||
async start() {
|
async revokeByOwnerSessionId(ownerSessionId) {
|
||||||
await server.start()
|
await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
|
||||||
},
|
},
|
||||||
async stop() {
|
async revokeByExtensionId(extensionId) {
|
||||||
|
await revokeSessions(server.revokeByExtensionId(extensionId))
|
||||||
|
},
|
||||||
|
async revokeAll() {
|
||||||
await revokeSessions(server.revokeAll())
|
await revokeSessions(server.revokeAll())
|
||||||
await server.stop()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie {
|
|
||||||
return {
|
|
||||||
expiresAt: session.expiresAt,
|
|
||||||
name: session.cookieName,
|
|
||||||
path: session.cookiePath,
|
|
||||||
url: new URL(session.cookiePath, baseUrl).toString(),
|
|
||||||
value: session.cookieValue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import { array, object, record, string } from 'valibot'
|
|||||||
import { createConfig } from '../../../../libs/electron/persistence'
|
import { createConfig } from '../../../../libs/electron/persistence'
|
||||||
|
|
||||||
const extensionConfigSchema = object({
|
const extensionConfigSchema = object({
|
||||||
autoReload: array(string()),
|
|
||||||
enabled: array(string()),
|
enabled: array(string()),
|
||||||
|
autoReload: array(string()),
|
||||||
known: record(string(), object({
|
known: record(string(), object({
|
||||||
path: string(),
|
path: string(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function createDefaultExtensionConfig(): ExtensionConfig {
|
||||||
|
return {
|
||||||
|
enabled: [],
|
||||||
|
autoReload: [],
|
||||||
|
known: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists extension host enablement and discovery metadata.
|
* Persists extension host enablement and discovery metadata.
|
||||||
*
|
*
|
||||||
@@ -27,8 +35,8 @@ const extensionConfigSchema = object({
|
|||||||
* - Accessors around the persisted extension config document
|
* - Accessors around the persisted extension config document
|
||||||
*/
|
*/
|
||||||
export interface ExtensionHostConfigStore {
|
export interface ExtensionHostConfigStore {
|
||||||
get: () => ExtensionConfig
|
|
||||||
setup: () => void
|
setup: () => void
|
||||||
|
get: () => ExtensionConfig
|
||||||
update: (config: ExtensionConfig) => void
|
update: (config: ExtensionConfig) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,27 +54,19 @@ export interface ExtensionHostConfigStore {
|
|||||||
*/
|
*/
|
||||||
export function createExtensionHostConfigStore(): ExtensionHostConfigStore {
|
export function createExtensionHostConfigStore(): ExtensionHostConfigStore {
|
||||||
const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, {
|
const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, {
|
||||||
autoHeal: true,
|
|
||||||
default: createDefaultExtensionConfig(),
|
default: createDefaultExtensionConfig(),
|
||||||
|
autoHeal: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get() {
|
|
||||||
return extensionConfig.get() ?? createDefaultExtensionConfig()
|
|
||||||
},
|
|
||||||
setup() {
|
setup() {
|
||||||
extensionConfig.setup()
|
extensionConfig.setup()
|
||||||
},
|
},
|
||||||
|
get() {
|
||||||
|
return extensionConfig.get() ?? createDefaultExtensionConfig()
|
||||||
|
},
|
||||||
update(config) {
|
update(config) {
|
||||||
extensionConfig.update(config)
|
extensionConfig.update(config)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultExtensionConfig(): ExtensionConfig {
|
|
||||||
return {
|
|
||||||
autoReload: [],
|
|
||||||
enabled: [],
|
|
||||||
known: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ import { buildPluginRegistrySnapshot } from './registry'
|
|||||||
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities
|
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities
|
||||||
*/
|
*/
|
||||||
export function buildPluginHostDebugSnapshot(options: {
|
export function buildPluginHostDebugSnapshot(options: {
|
||||||
config: ExtensionConfig
|
|
||||||
entries: ManifestEntry[]
|
|
||||||
extensionAssetService?: ExtensionAssetSnapshotService
|
|
||||||
extensionsRoot: string
|
|
||||||
host: ExtensionHost
|
host: ExtensionHost
|
||||||
|
extensionsRoot: string
|
||||||
|
entries: ManifestEntry[]
|
||||||
|
config: ExtensionConfig
|
||||||
loaded: Set<string>
|
loaded: Set<string>
|
||||||
manifestEntryByExtensionId: Map<string, ManifestEntry>
|
manifestEntryByExtensionId: Map<string, ManifestEntry>
|
||||||
|
extensionAssetService?: ExtensionAssetSnapshotService
|
||||||
}): Promise<PluginHostDebugSnapshot> {
|
}): Promise<PluginHostDebugSnapshot> {
|
||||||
const extensionAssetService = options.extensionAssetService
|
const extensionAssetService = options.extensionAssetService
|
||||||
const modules = Promise.all(options.host
|
const modules = Promise.all(options.host
|
||||||
@@ -44,18 +44,18 @@ export function buildPluginHostDebugSnapshot(options: {
|
|||||||
extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(),
|
extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(),
|
||||||
...(extensionAssetService
|
...(extensionAssetService
|
||||||
? {
|
? {
|
||||||
createAssetSession: ({ extensionId, routeAssetPath, sessionId, sessionPathPrefix, version }: {
|
createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: {
|
||||||
extensionId: string
|
extensionId: string
|
||||||
routeAssetPath: string
|
|
||||||
sessionId: string
|
|
||||||
sessionPathPrefix: string
|
|
||||||
version: string
|
version: string
|
||||||
|
sessionId: string
|
||||||
|
routeAssetPath: string
|
||||||
|
sessionPathPrefix: string
|
||||||
}) => extensionAssetService.createAssetSession({
|
}) => extensionAssetService.createAssetSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
ownerSessionId: sessionId,
|
|
||||||
pathPrefix: sessionPathPrefix,
|
|
||||||
routeAssetPath,
|
|
||||||
version,
|
version,
|
||||||
|
ownerSessionId: sessionId,
|
||||||
|
routeAssetPath,
|
||||||
|
pathPrefix: sessionPathPrefix,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -64,22 +64,22 @@ export function buildPluginHostDebugSnapshot(options: {
|
|||||||
))
|
))
|
||||||
|
|
||||||
return modules.then(resolvedModules => ({
|
return modules.then(resolvedModules => ({
|
||||||
capabilities: options.host.listCapabilities(),
|
|
||||||
kits: options.host.listKits(),
|
|
||||||
modules: resolvedModules,
|
|
||||||
refreshedAt: Date.now(),
|
|
||||||
registry: buildPluginRegistrySnapshot({
|
registry: buildPluginRegistrySnapshot({
|
||||||
config: options.config,
|
|
||||||
entries: options.entries,
|
|
||||||
extensionsRoot: options.extensionsRoot,
|
extensionsRoot: options.extensionsRoot,
|
||||||
|
entries: options.entries,
|
||||||
|
config: options.config,
|
||||||
loaded: options.loaded,
|
loaded: options.loaded,
|
||||||
}),
|
}),
|
||||||
sessions: options.host.listSessions().map(session => ({
|
sessions: options.host.listSessions().map(session => ({
|
||||||
extensionId: session.manifest.id,
|
|
||||||
id: session.id,
|
id: session.id,
|
||||||
moduleId: session.extension.id,
|
extensionId: session.manifest.id,
|
||||||
phase: session.phase,
|
phase: session.phase,
|
||||||
runtime: session.runtime ?? 'electron',
|
runtime: session.runtime ?? 'electron',
|
||||||
|
moduleId: session.extension.id,
|
||||||
})),
|
})),
|
||||||
|
kits: options.host.listKits(),
|
||||||
|
modules: resolvedModules,
|
||||||
|
capabilities: options.host.listCapabilities(),
|
||||||
|
refreshedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ import {
|
|||||||
|
|
||||||
const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function createElectronExtensionAssetCookieAdapter() {
|
||||||
|
return {
|
||||||
|
async setCookie(cookie: ExtensionAssetCookie) {
|
||||||
|
await electronSession.defaultSession.cookies.set({
|
||||||
|
url: cookie.url,
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
path: cookie.path,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'no_restriction',
|
||||||
|
secure: true,
|
||||||
|
expirationDate: Math.floor(cookie.expiresAt / 1000),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async removeCookie(cookie: ExtensionAssetCookie) {
|
||||||
|
await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade.
|
* Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade.
|
||||||
*
|
*
|
||||||
@@ -47,50 +67,8 @@ const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
|||||||
* - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose
|
* - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose
|
||||||
*/
|
*/
|
||||||
export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||||
/**
|
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
|
||||||
* Disposes optional host features and asset hosting resources.
|
tools: TamagotchiToolRegistry
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Electron shutdown needs to stop extension-owned background work
|
|
||||||
* - Tests need to release watchers and local asset servers deterministically
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - Disposal may be called after partial startup or after prior plugin failures
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A promise that resolves after feature and asset cleanup finish
|
|
||||||
*/
|
|
||||||
dispose: () => Promise<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the mounted base URL for plugin-served assets.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Renderer code needs to construct extension asset URLs
|
|
||||||
* - Snapshot consumers need the current loopback asset mount base
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - The extension asset service may be started before this is called
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - The current extension asset base URL, or an empty string when unavailable
|
|
||||||
*/
|
|
||||||
getAssetBaseUrl: () => string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the full extension host debug snapshot.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
|
|
||||||
* - Host debugging needs a fresh runtime snapshot after registry refresh
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - The host and extension asset service are both initialized
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - The full debug snapshot exposed through plugin inspection IPC
|
|
||||||
*/
|
|
||||||
inspect: () => Promise<PluginHostDebugSnapshot>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists the current extension registry snapshot.
|
* Lists the current extension registry snapshot.
|
||||||
@@ -108,19 +86,35 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
|||||||
list: () => Promise<PluginRegistrySnapshot>
|
list: () => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads one extension by manifest id.
|
* Persists whether one plugin is enabled.
|
||||||
*
|
*
|
||||||
* Use when:
|
* Use when:
|
||||||
* - Renderer explicitly requests one plugin to start
|
* - Renderer controls toggle plugin enablement
|
||||||
* - Host features need to restart a plugin after manifest or entrypoint changes
|
* - Host state must remember a known manifest path for a plugin name
|
||||||
*
|
*
|
||||||
* Expects:
|
* Expects:
|
||||||
* - `extensionId` resolves to a manifest entry in the current registry
|
* - `payload.extensionId` matches a discovered or previously known extension
|
||||||
|
* - `payload.path` is only needed when the manifest is not currently discoverable
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* - The extension registry snapshot after the load completes
|
* - The updated extension registry snapshot after persistence
|
||||||
*/
|
*/
|
||||||
load: (extensionId: string) => Promise<PluginRegistrySnapshot>
|
setEnabled: (payload: { extensionId: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists whether one loaded plugin should use auto-reload.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Renderer controls toggle plugin file watching during development
|
||||||
|
* - Host features need to resync optional watcher state after config changes
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `payload.extensionId` matches one extension entry in config or discovery state
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - The updated extension registry snapshot after persistence
|
||||||
|
*/
|
||||||
|
setAutoReload: (payload: { extensionId: string, enabled: boolean }) => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads every plugin currently marked as enabled.
|
* Loads every plugin currently marked as enabled.
|
||||||
@@ -138,38 +132,19 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
|||||||
loadEnabled: () => Promise<PluginRegistrySnapshot>
|
loadEnabled: () => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists whether one loaded plugin should use auto-reload.
|
* Loads one extension by manifest id.
|
||||||
*
|
*
|
||||||
* Use when:
|
* Use when:
|
||||||
* - Renderer controls toggle plugin file watching during development
|
* - Renderer explicitly requests one plugin to start
|
||||||
* - Host features need to resync optional watcher state after config changes
|
* - Host features need to restart a plugin after manifest or entrypoint changes
|
||||||
*
|
*
|
||||||
* Expects:
|
* Expects:
|
||||||
* - `payload.extensionId` matches one extension entry in config or discovery state
|
* - `extensionId` resolves to a manifest entry in the current registry
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* - The updated extension registry snapshot after persistence
|
* - The extension registry snapshot after the load completes
|
||||||
*/
|
*/
|
||||||
setAutoReload: (payload: { enabled: boolean, extensionId: string }) => Promise<PluginRegistrySnapshot>
|
load: (extensionId: string) => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists whether one plugin is enabled.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Renderer controls toggle plugin enablement
|
|
||||||
* - Host state must remember a known manifest path for a plugin name
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `payload.extensionId` matches a discovered or previously known extension
|
|
||||||
* - `payload.path` is only needed when the manifest is not currently discoverable
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - The updated extension registry snapshot after persistence
|
|
||||||
*/
|
|
||||||
setEnabled: (payload: { enabled: boolean, extensionId: string, path?: string }) => Promise<PluginRegistrySnapshot>
|
|
||||||
|
|
||||||
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
|
|
||||||
tools: TamagotchiToolRegistry
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops one loaded extension by manifest id.
|
* Stops one loaded extension by manifest id.
|
||||||
@@ -185,6 +160,51 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
|||||||
* - The extension registry snapshot after unload bookkeeping completes
|
* - The extension registry snapshot after unload bookkeeping completes
|
||||||
*/
|
*/
|
||||||
unload: (extensionId: string) => Promise<PluginRegistrySnapshot>
|
unload: (extensionId: string) => Promise<PluginRegistrySnapshot>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full extension host debug snapshot.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
|
||||||
|
* - Host debugging needs a fresh runtime snapshot after registry refresh
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - The host and extension asset service are both initialized
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - The full debug snapshot exposed through plugin inspection IPC
|
||||||
|
*/
|
||||||
|
inspect: () => Promise<PluginHostDebugSnapshot>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the mounted base URL for plugin-served assets.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Renderer code needs to construct extension asset URLs
|
||||||
|
* - Snapshot consumers need the current loopback asset mount base
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - The extension asset service may be started before this is called
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - The current extension asset base URL, or an empty string when unavailable
|
||||||
|
*/
|
||||||
|
getAssetBaseUrl: () => string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes optional host features and asset hosting resources.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Electron shutdown needs to stop extension-owned background work
|
||||||
|
* - Tests need to release watchers and local asset servers deterministically
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Disposal may be called after partial startup or after prior plugin failures
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A promise that resolves after feature and asset cleanup finish
|
||||||
|
*/
|
||||||
|
dispose: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,8 +248,8 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
|
|
||||||
// Extension feature: Static Assets serving
|
// Extension feature: Static Assets serving
|
||||||
const extensionAssetService = createExtensionAssetService({
|
const extensionAssetService = createExtensionAssetService({
|
||||||
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
|
||||||
getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(),
|
getManifestEntryByExtensionId: () => extensionRegistry.getManifestEntryByExtensionId(),
|
||||||
|
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
||||||
})
|
})
|
||||||
await extensionAssetService.start()
|
await extensionAssetService.start()
|
||||||
|
|
||||||
@@ -262,21 +282,21 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
|
|
||||||
const listSnapshot = (): PluginRegistrySnapshot => {
|
const listSnapshot = (): PluginRegistrySnapshot => {
|
||||||
return buildPluginRegistrySnapshot({
|
return buildPluginRegistrySnapshot({
|
||||||
config: getConfig(),
|
|
||||||
entries: extensionRegistry.listEntries(),
|
|
||||||
extensionsRoot,
|
extensionsRoot,
|
||||||
|
entries: extensionRegistry.listEntries(),
|
||||||
|
config: getConfig(),
|
||||||
loaded,
|
loaded,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const createModuleAssetSession = async (input: {
|
const createModuleAssetSession = async (input: {
|
||||||
extensionId: string
|
extensionId: string
|
||||||
ownerSessionId: string
|
|
||||||
pathPrefix: string
|
|
||||||
routeAssetPath: string
|
|
||||||
version: string
|
version: string
|
||||||
|
ownerSessionId: string
|
||||||
|
routeAssetPath: string
|
||||||
|
pathPrefix: string
|
||||||
}) => {
|
}) => {
|
||||||
const { extensionId, ownerSessionId, pathPrefix, routeAssetPath, version } = input
|
const { extensionId, version, ownerSessionId, routeAssetPath, pathPrefix } = input
|
||||||
const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}`
|
const cacheKey = `${extensionId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}`
|
||||||
const cachedSession = moduleAssetSessionCache.get(cacheKey)
|
const cachedSession = moduleAssetSessionCache.get(cacheKey)
|
||||||
if (cachedSession) {
|
if (cachedSession) {
|
||||||
@@ -285,38 +305,38 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
|
|
||||||
const session = await extensionAssetService.createAssetSession({
|
const session = await extensionAssetService.createAssetSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
ownerSessionId,
|
|
||||||
pathPrefix,
|
|
||||||
routeAssetPath,
|
|
||||||
ttlMs: extensionAssetSessionTtlMs,
|
|
||||||
version,
|
version,
|
||||||
|
ownerSessionId,
|
||||||
|
routeAssetPath,
|
||||||
|
pathPrefix,
|
||||||
|
ttlMs: extensionAssetSessionTtlMs,
|
||||||
})
|
})
|
||||||
moduleAssetSessionCache.set(cacheKey, session)
|
moduleAssetSessionCache.set(cacheKey, session)
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionAssetSnapshotService: ExtensionAssetSnapshotService = {
|
const extensionAssetSnapshotService: ExtensionAssetSnapshotService = {
|
||||||
createAssetSession: ({ extensionId, ownerSessionId, pathPrefix, routeAssetPath, version }) => {
|
getBaseUrl: extensionAssetService.getBaseUrl,
|
||||||
|
createAssetSession: ({ extensionId, version, ownerSessionId, routeAssetPath, pathPrefix }) => {
|
||||||
return createModuleAssetSession({
|
return createModuleAssetSession({
|
||||||
extensionId,
|
extensionId,
|
||||||
ownerSessionId,
|
|
||||||
pathPrefix,
|
|
||||||
routeAssetPath,
|
|
||||||
version,
|
version,
|
||||||
|
ownerSessionId,
|
||||||
|
routeAssetPath,
|
||||||
|
pathPrefix,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getBaseUrl: extensionAssetService.getBaseUrl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => {
|
const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => {
|
||||||
return await buildPluginHostDebugSnapshot({
|
return await buildPluginHostDebugSnapshot({
|
||||||
config: getConfig(),
|
|
||||||
entries: extensionRegistry.listEntries(),
|
|
||||||
extensionAssetService: extensionAssetSnapshotService,
|
|
||||||
extensionsRoot,
|
|
||||||
host,
|
host,
|
||||||
|
extensionsRoot,
|
||||||
|
entries: extensionRegistry.listEntries(),
|
||||||
|
config: getConfig(),
|
||||||
loaded,
|
loaded,
|
||||||
manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(),
|
manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(),
|
||||||
|
extensionAssetService: extensionAssetSnapshotService,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,16 +389,16 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
|
|
||||||
// Extension feature: Auto-reload for plugins
|
// Extension feature: Auto-reload for plugins
|
||||||
const autoReloadFeature = createExtensionAutoReloadFeature({
|
const autoReloadFeature = createExtensionAutoReloadFeature({
|
||||||
getConfig,
|
|
||||||
isLoaded: extensionId => loaded.has(extensionId),
|
|
||||||
listEntries: () => extensionRegistry.listEntries(),
|
|
||||||
log,
|
log,
|
||||||
|
getConfig,
|
||||||
|
listEntries: () => extensionRegistry.listEntries(),
|
||||||
|
isLoaded: extensionId => loaded.has(extensionId),
|
||||||
|
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
||||||
reload: async (extensionId) => {
|
reload: async (extensionId) => {
|
||||||
await stopLoadedExtensionById(extensionId)
|
await stopLoadedExtensionById(extensionId)
|
||||||
await refreshManifests()
|
await refreshManifests()
|
||||||
await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` })
|
await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||||
},
|
},
|
||||||
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const unloadExtensionById = async (extensionId: string) => {
|
const unloadExtensionById = async (extensionId: string) => {
|
||||||
@@ -413,61 +433,17 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
autoReloadFeature.sync()
|
autoReloadFeature.sync()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async dispose() {
|
|
||||||
autoReloadFeature.dispose()
|
|
||||||
builtInKitRuntime.dispose()
|
|
||||||
|
|
||||||
moduleAssetSessionCache.clear()
|
|
||||||
await extensionAssetService.revokeAll()
|
|
||||||
await extensionAssetService.stop()
|
|
||||||
},
|
|
||||||
getAssetBaseUrl() {
|
|
||||||
return extensionAssetService.getBaseUrl() ?? ''
|
|
||||||
},
|
|
||||||
host,
|
host,
|
||||||
async inspect() {
|
// REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though
|
||||||
await refreshManifests()
|
// the host service also exposes it for IPC listing/invocation. Consider moving registry ownership
|
||||||
autoReloadFeature.sync()
|
// to this host service and passing it into kit registration as a dependency.
|
||||||
return await inspectSnapshot()
|
tools: builtInKitRuntime.tools,
|
||||||
},
|
manifests: extensionRegistry.listManifests(),
|
||||||
async list() {
|
async list() {
|
||||||
await refreshManifests()
|
await refreshManifests()
|
||||||
autoReloadFeature.sync()
|
autoReloadFeature.sync()
|
||||||
return listSnapshot()
|
return listSnapshot()
|
||||||
},
|
},
|
||||||
async load(extensionId) {
|
|
||||||
await refreshManifests()
|
|
||||||
await loadExtensionById(extensionId)
|
|
||||||
autoReloadFeature.sync()
|
|
||||||
return listSnapshot()
|
|
||||||
},
|
|
||||||
async loadEnabled() {
|
|
||||||
await refreshManifests()
|
|
||||||
await loadEnabledExtensions()
|
|
||||||
autoReloadFeature.sync()
|
|
||||||
return listSnapshot()
|
|
||||||
},
|
|
||||||
manifests: extensionRegistry.listManifests(),
|
|
||||||
async setAutoReload(payload) {
|
|
||||||
await refreshManifests()
|
|
||||||
|
|
||||||
const config = getConfig()
|
|
||||||
const autoReload = new Set(config.autoReload)
|
|
||||||
if (payload.enabled) {
|
|
||||||
autoReload.add(payload.extensionId)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
autoReload.delete(payload.extensionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionConfig.update({
|
|
||||||
...config,
|
|
||||||
autoReload: [...autoReload],
|
|
||||||
})
|
|
||||||
|
|
||||||
autoReloadFeature.sync()
|
|
||||||
return listSnapshot()
|
|
||||||
},
|
|
||||||
async setEnabled(payload) {
|
async setEnabled(payload) {
|
||||||
await refreshManifests()
|
await refreshManifests()
|
||||||
|
|
||||||
@@ -485,8 +461,8 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
const entry = extensionRegistry.findManifestEntry(payload.extensionId)
|
const entry = extensionRegistry.findManifestEntry(payload.extensionId)
|
||||||
const manifestPath = entry?.path ?? payload.path ?? ''
|
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||||
extensionConfig.update({
|
extensionConfig.update({
|
||||||
autoReload: config.autoReload,
|
|
||||||
enabled: [...enabled],
|
enabled: [...enabled],
|
||||||
|
autoReload: config.autoReload,
|
||||||
known: {
|
known: {
|
||||||
...config.known,
|
...config.known,
|
||||||
[payload.extensionId]: { path: manifestPath },
|
[payload.extensionId]: { path: manifestPath },
|
||||||
@@ -496,34 +472,58 @@ export async function setupExtensionHostServiceInternal(
|
|||||||
autoReloadFeature.sync()
|
autoReloadFeature.sync()
|
||||||
return listSnapshot()
|
return listSnapshot()
|
||||||
},
|
},
|
||||||
// REVIEW: Tool registry ownership is currently hidden inside the built-in kit runtime even though
|
async setAutoReload(payload) {
|
||||||
// the host service also exposes it for IPC listing/invocation. Consider moving registry ownership
|
await refreshManifests()
|
||||||
// to this host service and passing it into kit registration as a dependency.
|
|
||||||
tools: builtInKitRuntime.tools,
|
const config = getConfig()
|
||||||
|
const autoReload = new Set(config.autoReload)
|
||||||
|
if (payload.enabled) {
|
||||||
|
autoReload.add(payload.extensionId)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
autoReload.delete(payload.extensionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionConfig.update({
|
||||||
|
...config,
|
||||||
|
autoReload: [...autoReload],
|
||||||
|
})
|
||||||
|
|
||||||
|
autoReloadFeature.sync()
|
||||||
|
return listSnapshot()
|
||||||
|
},
|
||||||
|
async loadEnabled() {
|
||||||
|
await refreshManifests()
|
||||||
|
await loadEnabledExtensions()
|
||||||
|
autoReloadFeature.sync()
|
||||||
|
return listSnapshot()
|
||||||
|
},
|
||||||
|
async load(extensionId) {
|
||||||
|
await refreshManifests()
|
||||||
|
await loadExtensionById(extensionId)
|
||||||
|
autoReloadFeature.sync()
|
||||||
|
return listSnapshot()
|
||||||
|
},
|
||||||
async unload(extensionId) {
|
async unload(extensionId) {
|
||||||
await unloadExtensionById(extensionId)
|
await unloadExtensionById(extensionId)
|
||||||
autoReloadFeature.sync()
|
autoReloadFeature.sync()
|
||||||
return listSnapshot()
|
return listSnapshot()
|
||||||
},
|
},
|
||||||
}
|
async inspect() {
|
||||||
}
|
await refreshManifests()
|
||||||
|
autoReloadFeature.sync()
|
||||||
|
return await inspectSnapshot()
|
||||||
|
},
|
||||||
|
getAssetBaseUrl() {
|
||||||
|
return extensionAssetService.getBaseUrl() ?? ''
|
||||||
|
},
|
||||||
|
async dispose() {
|
||||||
|
autoReloadFeature.dispose()
|
||||||
|
builtInKitRuntime.dispose()
|
||||||
|
|
||||||
function createElectronExtensionAssetCookieAdapter() {
|
moduleAssetSessionCache.clear()
|
||||||
return {
|
await extensionAssetService.revokeAll()
|
||||||
async removeCookie(cookie: ExtensionAssetCookie) {
|
await extensionAssetService.stop()
|
||||||
await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name)
|
|
||||||
},
|
|
||||||
async setCookie(cookie: ExtensionAssetCookie) {
|
|
||||||
await electronSession.defaultSession.cookies.set({
|
|
||||||
expirationDate: Math.floor(cookie.expiresAt / 1000),
|
|
||||||
httpOnly: true,
|
|
||||||
name: cookie.name,
|
|
||||||
path: cookie.path,
|
|
||||||
sameSite: 'no_restriction',
|
|
||||||
secure: true,
|
|
||||||
url: cookie.url,
|
|
||||||
value: cookie.value,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,166 +17,30 @@ import { safeParse } from 'valibot'
|
|||||||
|
|
||||||
export const extensionManifestFileName = 'extension.airi.json'
|
export const extensionManifestFileName = 'extension.airi.json'
|
||||||
|
|
||||||
/**
|
function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 {
|
||||||
* Tracks the manifest registry state used by the Electron extension host.
|
return safeParse(extensionManifestV1Schema, value).success
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Refreshing extension manifests from disk
|
|
||||||
* - Looking up manifests by extension id during load or inspect operations
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `refresh()` is called before consumers read entries or manifests
|
|
||||||
* - `extensionsRoot` points at the extension manifest root under user data
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Read access to the current manifest entries, manifest list, and lookup map
|
|
||||||
*/
|
|
||||||
export interface ExtensionHostRegistry {
|
|
||||||
findManifestEntry: (extensionId: string) => ManifestEntry | undefined
|
|
||||||
getManifestEntryByExtensionId: () => Map<string, ManifestEntry>
|
|
||||||
getRoot: () => string
|
|
||||||
listEntries: () => ManifestEntry[]
|
|
||||||
listManifests: () => ExtensionManifestV1[]
|
|
||||||
refresh: () => Promise<ManifestEntry[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function manifestIdOf(manifest: ExtensionManifestV1) {
|
||||||
* Builds the renderer-facing extension registry snapshot.
|
return manifest.id
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - IPC clients request the plugin list
|
|
||||||
* - Internal host operations need a fresh registry view after config or load changes
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `entries`, `config`, and `loaded` come from the latest in-memory host state
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A stable registry snapshot for renderer consumption
|
|
||||||
*/
|
|
||||||
export function buildPluginRegistrySnapshot(options: {
|
|
||||||
config: ExtensionConfig
|
|
||||||
entries: ManifestEntry[]
|
|
||||||
extensionsRoot: string
|
|
||||||
loaded: Set<string>
|
|
||||||
}): PluginRegistrySnapshot {
|
|
||||||
return {
|
|
||||||
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
|
|
||||||
root: options.extensionsRoot,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
|
||||||
* Creates the manifest registry store used by the extension host bootstrap.
|
if (!entry.isSymbolicLink()) {
|
||||||
*
|
return { resolved: false }
|
||||||
* Use when:
|
|
||||||
* - Host bootstrap needs in-memory manifest lookup and refresh operations
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `log` is the plugin-host logger used for manifest loading diagnostics
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A registry wrapper around the current manifest entry array and lookup map
|
|
||||||
*/
|
|
||||||
export function createExtensionHostRegistry(options: {
|
|
||||||
extensionsRoot: string
|
|
||||||
log: ReturnType<typeof useLogg>
|
|
||||||
}): ExtensionHostRegistry {
|
|
||||||
let entries: ManifestEntry[] = []
|
|
||||||
let manifests: ExtensionManifestV1[] = []
|
|
||||||
let manifestEntryByExtensionId = new Map<string, ManifestEntry>()
|
|
||||||
|
|
||||||
return {
|
|
||||||
findManifestEntry(extensionId) {
|
|
||||||
return manifestEntryByExtensionId.get(extensionId)
|
|
||||||
},
|
|
||||||
getManifestEntryByExtensionId() {
|
|
||||||
return manifestEntryByExtensionId
|
|
||||||
},
|
|
||||||
getRoot() {
|
|
||||||
return options.extensionsRoot
|
|
||||||
},
|
|
||||||
listEntries() {
|
|
||||||
return entries
|
|
||||||
},
|
|
||||||
listManifests() {
|
|
||||||
return manifests
|
|
||||||
},
|
|
||||||
async refresh() {
|
|
||||||
entries = await loadManifestsFrom(options.extensionsRoot, options.log)
|
|
||||||
manifestEntryByExtensionId = new Map()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const id = manifestIdOf(entry.manifest)
|
|
||||||
if (!manifestEntryByExtensionId.has(id)) {
|
|
||||||
manifestEntryByExtensionId.set(id, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manifests = entries.map(entry => entry.manifest)
|
|
||||||
return entries
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Loading a plugin normally
|
|
||||||
* - Reloading a plugin after file changes to avoid stale module cache
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `cacheBustKey` is omitted for standard loads
|
|
||||||
* - `cacheBustKey` is deterministic enough for one reload cycle when provided
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Original manifest or cloned manifest with cache-busted runtime entrypoint
|
|
||||||
*/
|
|
||||||
export function createManifestForLoad(
|
|
||||||
entry: ManifestEntry,
|
|
||||||
options: { cacheBustKey?: string },
|
|
||||||
): ExtensionManifestV1 {
|
|
||||||
const loadManifest = entry.manifest
|
|
||||||
if (!options.cacheBustKey) {
|
|
||||||
return loadManifest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = structuredClone(loadManifest)
|
try {
|
||||||
if (manifest.entrypoints.electron) {
|
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
|
||||||
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey)
|
const stats = await stat(resolvedPath)
|
||||||
}
|
if (stats.isFile() || stats.isDirectory()) {
|
||||||
else if (manifest.entrypoints.default) {
|
return { resolved: true, path: resolvedPath }
|
||||||
manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey)
|
}
|
||||||
}
|
|
||||||
return manifest
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return { resolved: false }
|
||||||
* Builds a renderer-facing extension summary from manifest, config, and runtime state.
|
}
|
||||||
*
|
catch (error) {
|
||||||
* Use when:
|
return { resolved: false, error }
|
||||||
* - Registry snapshots need one UI-friendly entry per discovered plugin
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `entry` corresponds to a currently discovered manifest
|
|
||||||
* - `config` is the latest persisted extension config
|
|
||||||
* - `loaded` tracks currently running plugin names
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Stable manifest summary for UI consumption
|
|
||||||
*/
|
|
||||||
export function createPluginSummary(
|
|
||||||
entry: ManifestEntry,
|
|
||||||
config: ExtensionConfig,
|
|
||||||
loaded: Set<string>,
|
|
||||||
): PluginManifestSummary {
|
|
||||||
const extensionId = manifestIdOf(entry.manifest)
|
|
||||||
return {
|
|
||||||
autoReload: config.autoReload.includes(extensionId),
|
|
||||||
enabled: config.enabled.includes(extensionId),
|
|
||||||
entrypoints: entry.manifest.entrypoints,
|
|
||||||
extensionId,
|
|
||||||
isNew: !config.known[extensionId],
|
|
||||||
loaded: loaded.has(extensionId),
|
|
||||||
path: entry.path,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +71,7 @@ export async function loadManifestsFrom(
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
const { error, resolved } = await realPathOf(entry, { cwd: dir })
|
const { resolved, error } = await realPathOf(entry, { cwd: dir })
|
||||||
if (error) {
|
if (error) {
|
||||||
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping')
|
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping')
|
||||||
continue
|
continue
|
||||||
@@ -299,8 +163,60 @@ export async function loadManifestsFrom(
|
|||||||
return manifests
|
return manifests
|
||||||
}
|
}
|
||||||
|
|
||||||
export function manifestIdOf(manifest: ExtensionManifestV1) {
|
/**
|
||||||
return manifest.id
|
* Builds a renderer-facing extension summary from manifest, config, and runtime state.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Registry snapshots need one UI-friendly entry per discovered plugin
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `entry` corresponds to a currently discovered manifest
|
||||||
|
* - `config` is the latest persisted extension config
|
||||||
|
* - `loaded` tracks currently running plugin names
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Stable manifest summary for UI consumption
|
||||||
|
*/
|
||||||
|
export function createPluginSummary(
|
||||||
|
entry: ManifestEntry,
|
||||||
|
config: ExtensionConfig,
|
||||||
|
loaded: Set<string>,
|
||||||
|
): PluginManifestSummary {
|
||||||
|
const extensionId = manifestIdOf(entry.manifest)
|
||||||
|
return {
|
||||||
|
extensionId,
|
||||||
|
entrypoints: entry.manifest.entrypoints,
|
||||||
|
path: entry.path,
|
||||||
|
enabled: config.enabled.includes(extensionId),
|
||||||
|
autoReload: config.autoReload.includes(extensionId),
|
||||||
|
loaded: loaded.has(extensionId),
|
||||||
|
isNew: !config.known[extensionId],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the renderer-facing extension registry snapshot.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - IPC clients request the plugin list
|
||||||
|
* - Internal host operations need a fresh registry view after config or load changes
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `entries`, `config`, and `loaded` come from the latest in-memory host state
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A stable registry snapshot for renderer consumption
|
||||||
|
*/
|
||||||
|
export function buildPluginRegistrySnapshot(options: {
|
||||||
|
extensionsRoot: string
|
||||||
|
entries: ManifestEntry[]
|
||||||
|
config: ExtensionConfig
|
||||||
|
loaded: Set<string>
|
||||||
|
}): PluginRegistrySnapshot {
|
||||||
|
return {
|
||||||
|
root: options.extensionsRoot,
|
||||||
|
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,25 +247,109 @@ function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string {
|
|||||||
return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}`
|
return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 {
|
/**
|
||||||
return safeParse(extensionManifestV1Schema, value).success
|
* Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Loading a plugin normally
|
||||||
|
* - Reloading a plugin after file changes to avoid stale module cache
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `cacheBustKey` is omitted for standard loads
|
||||||
|
* - `cacheBustKey` is deterministic enough for one reload cycle when provided
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Original manifest or cloned manifest with cache-busted runtime entrypoint
|
||||||
|
*/
|
||||||
|
export function createManifestForLoad(
|
||||||
|
entry: ManifestEntry,
|
||||||
|
options: { cacheBustKey?: string },
|
||||||
|
): ExtensionManifestV1 {
|
||||||
|
const loadManifest = entry.manifest
|
||||||
|
if (!options.cacheBustKey) {
|
||||||
|
return loadManifest
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = structuredClone(loadManifest)
|
||||||
|
if (manifest.entrypoints.electron) {
|
||||||
|
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey)
|
||||||
|
}
|
||||||
|
else if (manifest.entrypoints.default) {
|
||||||
|
manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey)
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ error?: unknown, path: string, resolved: true } | { error?: unknown, path?: string, resolved: false }> {
|
/**
|
||||||
if (!entry.isSymbolicLink()) {
|
* Tracks the manifest registry state used by the Electron extension host.
|
||||||
return { resolved: false }
|
*
|
||||||
}
|
* Use when:
|
||||||
|
* - Refreshing extension manifests from disk
|
||||||
|
* - Looking up manifests by extension id during load or inspect operations
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `refresh()` is called before consumers read entries or manifests
|
||||||
|
* - `extensionsRoot` points at the extension manifest root under user data
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Read access to the current manifest entries, manifest list, and lookup map
|
||||||
|
*/
|
||||||
|
export interface ExtensionHostRegistry {
|
||||||
|
getRoot: () => string
|
||||||
|
refresh: () => Promise<ManifestEntry[]>
|
||||||
|
listEntries: () => ManifestEntry[]
|
||||||
|
listManifests: () => ExtensionManifestV1[]
|
||||||
|
findManifestEntry: (extensionId: string) => ManifestEntry | undefined
|
||||||
|
getManifestEntryByExtensionId: () => Map<string, ManifestEntry>
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
/**
|
||||||
const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name))
|
* Creates the manifest registry store used by the extension host bootstrap.
|
||||||
const stats = await stat(resolvedPath)
|
*
|
||||||
if (stats.isFile() || stats.isDirectory()) {
|
* Use when:
|
||||||
return { path: resolvedPath, resolved: true }
|
* - Host bootstrap needs in-memory manifest lookup and refresh operations
|
||||||
}
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `log` is the plugin-host logger used for manifest loading diagnostics
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A registry wrapper around the current manifest entry array and lookup map
|
||||||
|
*/
|
||||||
|
export function createExtensionHostRegistry(options: {
|
||||||
|
extensionsRoot: string
|
||||||
|
log: ReturnType<typeof useLogg>
|
||||||
|
}): ExtensionHostRegistry {
|
||||||
|
let entries: ManifestEntry[] = []
|
||||||
|
let manifests: ExtensionManifestV1[] = []
|
||||||
|
let manifestEntryByExtensionId = new Map<string, ManifestEntry>()
|
||||||
|
|
||||||
return { resolved: false }
|
return {
|
||||||
}
|
getRoot() {
|
||||||
catch (error) {
|
return options.extensionsRoot
|
||||||
return { error, resolved: false }
|
},
|
||||||
|
async refresh() {
|
||||||
|
entries = await loadManifestsFrom(options.extensionsRoot, options.log)
|
||||||
|
manifestEntryByExtensionId = new Map()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const id = manifestIdOf(entry.manifest)
|
||||||
|
if (!manifestEntryByExtensionId.has(id)) {
|
||||||
|
manifestEntryByExtensionId.set(id, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifests = entries.map(entry => entry.manifest)
|
||||||
|
return entries
|
||||||
|
},
|
||||||
|
listEntries() {
|
||||||
|
return entries
|
||||||
|
},
|
||||||
|
listManifests() {
|
||||||
|
return manifests
|
||||||
|
},
|
||||||
|
findManifestEntry(extensionId) {
|
||||||
|
return manifestEntryByExtensionId.get(extensionId)
|
||||||
|
},
|
||||||
|
getManifestEntryByExtensionId() {
|
||||||
|
return manifestEntryByExtensionId
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
|||||||
defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => {
|
defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => {
|
||||||
const result = await hostService.setEnabled(payload)
|
const result = await hostService.setEnabled(payload)
|
||||||
context.emit(electronPluginToolsChanged, {
|
context.emit(electronPluginToolsChanged, {
|
||||||
extensionId: payload.extensionId,
|
|
||||||
reason: 'enabled-state-changed',
|
reason: 'enabled-state-changed',
|
||||||
|
extensionId: payload.extensionId,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
|||||||
defineInvokeHandler(context, electronPluginLoad, async (payload) => {
|
defineInvokeHandler(context, electronPluginLoad, async (payload) => {
|
||||||
const result = await hostService.load(payload.extensionId)
|
const result = await hostService.load(payload.extensionId)
|
||||||
context.emit(electronPluginToolsChanged, {
|
context.emit(electronPluginToolsChanged, {
|
||||||
extensionId: payload.extensionId,
|
|
||||||
reason: 'loaded',
|
reason: 'loaded',
|
||||||
|
extensionId: payload.extensionId,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -86,8 +86,8 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
|||||||
defineInvokeHandler(context, electronPluginUnload, async (payload) => {
|
defineInvokeHandler(context, electronPluginUnload, async (payload) => {
|
||||||
const result = await hostService.unload(payload.extensionId)
|
const result = await hostService.unload(payload.extensionId)
|
||||||
context.emit(electronPluginToolsChanged, {
|
context.emit(electronPluginToolsChanged, {
|
||||||
extensionId: payload.extensionId,
|
|
||||||
reason: 'unloaded',
|
reason: 'unloaded',
|
||||||
|
extensionId: payload.extensionId,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -123,10 +123,10 @@ export async function setupExtensionHost(options: SetupExtensionHostOptions): Pr
|
|||||||
switch (payload.state) {
|
switch (payload.state) {
|
||||||
case 'announced':
|
case 'announced':
|
||||||
return hostService.host.announceCapability(payload.key, payload.metadata)
|
return hostService.host.announceCapability(payload.key, payload.metadata)
|
||||||
case 'degraded':
|
|
||||||
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
|
|
||||||
case 'ready':
|
case 'ready':
|
||||||
return hostService.host.markCapabilityReady(payload.key, payload.metadata)
|
return hostService.host.markCapabilityReady(payload.key, payload.metadata)
|
||||||
|
case 'degraded':
|
||||||
|
return hostService.host.markCapabilityDegraded(payload.key, payload.metadata)
|
||||||
case 'withdrawn':
|
case 'withdrawn':
|
||||||
return hostService.host.withdrawCapability(payload.key, payload.metadata)
|
return hostService.host.withdrawCapability(payload.key, payload.metadata)
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-
|
|||||||
* - The gamelet kit descriptor used for `kit.gamelet`
|
* - The gamelet kit descriptor used for `kit.gamelet`
|
||||||
*/
|
*/
|
||||||
export const gameletPluginKitDescriptor = {
|
export const gameletPluginKitDescriptor = {
|
||||||
capabilities: [
|
|
||||||
{ actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'], key: 'kit.gamelet.runtime' },
|
|
||||||
],
|
|
||||||
kitId: 'kit.gamelet',
|
kitId: 'kit.gamelet',
|
||||||
runtimes: ['electron', 'web'],
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
runtimes: ['electron', 'web'],
|
||||||
|
capabilities: [
|
||||||
|
{ key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] },
|
||||||
|
],
|
||||||
} satisfies KitDescriptor
|
} satisfies KitDescriptor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+16
-16
@@ -27,46 +27,39 @@ export function createGameletOrchestrationRuntime(
|
|||||||
widgetsManager: ExtensionHostGameletWidgetsManager,
|
widgetsManager: ExtensionHostGameletWidgetsManager,
|
||||||
): GameletOrchestrationRuntime {
|
): GameletOrchestrationRuntime {
|
||||||
return {
|
return {
|
||||||
async close(bindingId) {
|
|
||||||
await widgetsManager.removeWidget(bindingId)
|
|
||||||
},
|
|
||||||
async configure(bindingId, payload) {
|
|
||||||
await widgetsManager.updateWidget({
|
|
||||||
componentProps: createComponentProps(bindingId, payload),
|
|
||||||
id: bindingId,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
dispose() {},
|
|
||||||
async isOpen(bindingId) {
|
|
||||||
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
|
|
||||||
},
|
|
||||||
async open(bindingId, payload) {
|
async open(bindingId, payload) {
|
||||||
const componentProps = createComponentProps(bindingId, payload ?? {})
|
const componentProps = createComponentProps(bindingId, payload ?? {})
|
||||||
|
|
||||||
if (widgetsManager.getWidgetSnapshot(bindingId)) {
|
if (widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||||
await widgetsManager.updateWidget({
|
await widgetsManager.updateWidget({
|
||||||
componentProps,
|
|
||||||
id: bindingId,
|
id: bindingId,
|
||||||
|
componentProps,
|
||||||
size: 'l',
|
size: 'l',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await widgetsManager.pushWidget({
|
await widgetsManager.pushWidget({
|
||||||
|
id: bindingId,
|
||||||
componentName: 'extension-ui',
|
componentName: 'extension-ui',
|
||||||
componentProps,
|
componentProps,
|
||||||
id: bindingId,
|
|
||||||
size: 'l',
|
size: 'l',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await widgetsManager.openWindow({ id: bindingId })
|
await widgetsManager.openWindow({ id: bindingId })
|
||||||
},
|
},
|
||||||
|
async configure(bindingId, payload) {
|
||||||
|
await widgetsManager.updateWidget({
|
||||||
|
id: bindingId,
|
||||||
|
componentProps: createComponentProps(bindingId, payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
async request<TResponse = HostDataRecord>(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise<TResponse> {
|
async request<TResponse = HostDataRecord>(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise<TResponse> {
|
||||||
if (!widgetsManager.getWidgetSnapshot(bindingId)) {
|
if (!widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||||
throw new Error(`Gamelet \`${bindingId}\` is not open.`)
|
throw new Error(`Gamelet \`${bindingId}\` is not open.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await widgetsManager.requestWidgetIframe<Record<string, unknown> & TResponse>(
|
return await widgetsManager.requestWidgetIframe<TResponse & Record<string, unknown>>(
|
||||||
bindingId,
|
bindingId,
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
@@ -74,6 +67,13 @@ export function createGameletOrchestrationRuntime(
|
|||||||
},
|
},
|
||||||
) as TResponse
|
) as TResponse
|
||||||
},
|
},
|
||||||
|
async close(bindingId) {
|
||||||
|
await widgetsManager.removeWidget(bindingId)
|
||||||
|
},
|
||||||
|
async isOpen(bindingId) {
|
||||||
|
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,43 +15,7 @@ import { registerWidgetPluginKit } from './widget'
|
|||||||
type GameletKitClient = ReturnType<typeof gameletKit.createClient>
|
type GameletKitClient = ReturnType<typeof gameletKit.createClient>
|
||||||
type ToolKitClient = ReturnType<typeof toolKit.createClient>
|
type ToolKitClient = ReturnType<typeof toolKit.createClient>
|
||||||
|
|
||||||
/**
|
function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef<GameletKitClient> {
|
||||||
* Creates the built-in kit runtime installed by the Electron extension host.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
|
|
||||||
* - Built-in kit registration should remain outside the host layer
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `widgetsManager` is initialized before host construction
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Helpers to register built-in kits on the host
|
|
||||||
*/
|
|
||||||
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
|
|
||||||
dispose: () => void
|
|
||||||
registerHostKits: (host: ExtensionHost) => void
|
|
||||||
tools: TamagotchiToolRegistry
|
|
||||||
} {
|
|
||||||
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
|
|
||||||
const tools = new TamagotchiToolRegistry()
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispose() {
|
|
||||||
gamelets.dispose()
|
|
||||||
tools.clear()
|
|
||||||
},
|
|
||||||
registerHostKits(host) {
|
|
||||||
registerWidgetPluginKit(host)
|
|
||||||
registerGameletPluginKit(host)
|
|
||||||
host.registerKitApi(createHostGameletKit({ gamelets, host }))
|
|
||||||
host.registerKitApi(createHostToolKit({ tools }))
|
|
||||||
},
|
|
||||||
tools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHostGameletKit(options: { gamelets: GameletOrchestrationRuntime, host: ExtensionHost }): KitRef<GameletKitClient> {
|
|
||||||
return {
|
return {
|
||||||
...gameletKit,
|
...gameletKit,
|
||||||
createClient(runtime) {
|
createClient(runtime) {
|
||||||
@@ -92,18 +56,18 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
|
|||||||
register: (input) => {
|
register: (input) => {
|
||||||
ensureCleanup()
|
ensureCleanup()
|
||||||
options.tools.register({
|
options.tools.register({
|
||||||
|
ownerSessionId: runtime.sessionId,
|
||||||
ownerExtensionId: runtime.extensionId,
|
ownerExtensionId: runtime.extensionId,
|
||||||
ownerModuleId: runtime.moduleId,
|
ownerModuleId: runtime.moduleId,
|
||||||
ownerSessionId: runtime.sessionId,
|
|
||||||
...input,
|
...input,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
registerToolsetPrompt: (input) => {
|
registerToolsetPrompt: (input) => {
|
||||||
ensureCleanup()
|
ensureCleanup()
|
||||||
options.tools.registerToolsetPrompt({
|
options.tools.registerToolsetPrompt({
|
||||||
|
ownerSessionId: runtime.sessionId,
|
||||||
ownerExtensionId: runtime.extensionId,
|
ownerExtensionId: runtime.extensionId,
|
||||||
ownerModuleId: runtime.moduleId,
|
ownerModuleId: runtime.moduleId,
|
||||||
ownerSessionId: runtime.sessionId,
|
|
||||||
toolset: input,
|
toolset: input,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -114,3 +78,39 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the built-in kit runtime installed by the Electron extension host.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
|
||||||
|
* - Built-in kit registration should remain outside the host layer
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `widgetsManager` is initialized before host construction
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Helpers to register built-in kits on the host
|
||||||
|
*/
|
||||||
|
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
|
||||||
|
registerHostKits: (host: ExtensionHost) => void
|
||||||
|
tools: TamagotchiToolRegistry
|
||||||
|
dispose: () => void
|
||||||
|
} {
|
||||||
|
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
|
||||||
|
const tools = new TamagotchiToolRegistry()
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerHostKits(host) {
|
||||||
|
registerWidgetPluginKit(host)
|
||||||
|
registerGameletPluginKit(host)
|
||||||
|
host.registerKitApi(createHostGameletKit({ host, gamelets }))
|
||||||
|
host.registerKitApi(createHostToolKit({ tools }))
|
||||||
|
},
|
||||||
|
tools,
|
||||||
|
dispose() {
|
||||||
|
gamelets.dispose()
|
||||||
|
tools.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export interface WidgetAssetRoute {
|
|||||||
sessionPathPrefix: string
|
sessionPathPrefix: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWidgetAssetPath(assetPath: string): string | undefined {
|
||||||
|
const trimmed = assetPath.trim().replaceAll('\\', '/')
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutRelativePrefix = trimmed.startsWith('./')
|
||||||
|
? trimmed.slice(2)
|
||||||
|
: trimmed
|
||||||
|
|
||||||
|
return normalizeStaticAssetPath(withoutRelativePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a widget iframe asset path into `/ui` route semantics.
|
* Normalizes a widget iframe asset path into `/ui` route semantics.
|
||||||
*
|
*
|
||||||
@@ -38,7 +51,7 @@ export interface WidgetAssetRoute {
|
|||||||
* Returns:
|
* Returns:
|
||||||
* - The route-relative asset path and the allowed session prefix for that route
|
* - The route-relative asset path and the allowed session prefix for that route
|
||||||
*/
|
*/
|
||||||
export function resolveWidgetAssetRoute(assetPath: string): undefined | WidgetAssetRoute {
|
export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined {
|
||||||
const normalized = normalizeWidgetAssetPath(assetPath)
|
const normalized = normalizeWidgetAssetPath(assetPath)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -84,16 +97,16 @@ export function rewriteWidgetModuleAssetUrl(
|
|||||||
module: PluginHostModuleSummary,
|
module: PluginHostModuleSummary,
|
||||||
manifestEntryByExtensionId: Map<string, ManifestEntry>,
|
manifestEntryByExtensionId: Map<string, ManifestEntry>,
|
||||||
options?: {
|
options?: {
|
||||||
|
extensionAssetBaseUrl?: string
|
||||||
createAssetSession?: (input: {
|
createAssetSession?: (input: {
|
||||||
extensionId: string
|
extensionId: string
|
||||||
routeAssetPath: string
|
|
||||||
sessionId: string
|
|
||||||
sessionPathPrefix: string
|
|
||||||
version: string
|
version: string
|
||||||
|
sessionId: string
|
||||||
|
routeAssetPath: string
|
||||||
|
sessionPathPrefix: string
|
||||||
}) => Promise<{ assetSessionId: string, url?: string }>
|
}) => Promise<{ assetSessionId: string, url?: string }>
|
||||||
extensionAssetBaseUrl?: string
|
|
||||||
},
|
},
|
||||||
): PluginHostModuleSummary | Promise<PluginHostModuleSummary> {
|
): Promise<PluginHostModuleSummary> | PluginHostModuleSummary {
|
||||||
const entry = manifestEntryByExtensionId.get(module.ownerExtensionId)
|
const entry = manifestEntryByExtensionId.get(module.ownerExtensionId)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return module
|
return module
|
||||||
@@ -131,15 +144,15 @@ export function rewriteWidgetModuleAssetUrl(
|
|||||||
|
|
||||||
return options.createAssetSession({
|
return options.createAssetSession({
|
||||||
extensionId: module.ownerExtensionId,
|
extensionId: module.ownerExtensionId,
|
||||||
routeAssetPath: widgetAssetRoute.routeAssetPath,
|
|
||||||
sessionId: module.ownerSessionId,
|
|
||||||
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
|
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
|
sessionId: module.ownerSessionId,
|
||||||
|
routeAssetPath: widgetAssetRoute.routeAssetPath,
|
||||||
|
sessionPathPrefix: widgetAssetRoute.sessionPathPrefix,
|
||||||
}).then((session) => {
|
}).then((session) => {
|
||||||
const mountedPath = buildMountedStaticAssetPath({
|
const mountedPath = buildMountedStaticAssetPath({
|
||||||
assetPath: widgetAssetRoute.routeAssetPath,
|
|
||||||
assetSessionId: session.assetSessionId,
|
|
||||||
extensionId: module.ownerExtensionId,
|
extensionId: module.ownerExtensionId,
|
||||||
|
assetSessionId: session.assetSessionId,
|
||||||
|
assetPath: widgetAssetRoute.routeAssetPath,
|
||||||
})
|
})
|
||||||
const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '')
|
const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.extensionAssetBaseUrl).toString() : '')
|
||||||
if (!iframeUrl) {
|
if (!iframeUrl) {
|
||||||
@@ -161,16 +174,3 @@ export function rewriteWidgetModuleAssetUrl(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWidgetAssetPath(assetPath: string): string | undefined {
|
|
||||||
const trimmed = assetPath.trim().replaceAll('\\', '/')
|
|
||||||
if (!trimmed) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const withoutRelativePrefix = trimmed.startsWith('./')
|
|
||||||
? trimmed.slice(2)
|
|
||||||
: trimmed
|
|
||||||
|
|
||||||
return normalizeStaticAssetPath(withoutRelativePrefix)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export { resolveWidgetAssetRoute, rewriteWidgetModuleAssetUrl } from './asset-ur
|
|||||||
* - The widget kit descriptor used for `kit.widget`
|
* - The widget kit descriptor used for `kit.widget`
|
||||||
*/
|
*/
|
||||||
export const widgetPluginKitDescriptor = {
|
export const widgetPluginKitDescriptor = {
|
||||||
capabilities: [
|
|
||||||
{ actions: ['announce', 'activate', 'update', 'withdraw'], key: 'kit.widget.module' },
|
|
||||||
],
|
|
||||||
kitId: 'kit.widget',
|
kitId: 'kit.widget',
|
||||||
runtimes: ['electron', 'web'],
|
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
runtimes: ['electron', 'web'],
|
||||||
|
capabilities: [
|
||||||
|
{ key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] },
|
||||||
|
],
|
||||||
} satisfies KitDescriptor
|
} satisfies KitDescriptor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,89 +7,9 @@ import type {
|
|||||||
} from '../../../../shared/eventa'
|
} from '../../../../shared/eventa'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted extension configuration snapshot.
|
* Stable manifest id used as the runtime identity for one extension.
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Reading/writing enabled and auto-reload extension state
|
|
||||||
* - Keeping known extension manifest path metadata
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - Arrays contain extension manifest ids
|
|
||||||
* - `known` maps extension manifest ids to canonical manifest paths
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
*/
|
||||||
export interface ExtensionConfig {
|
export type ExtensionId = string
|
||||||
autoReload: ExtensionId[]
|
|
||||||
enabled: ExtensionId[]
|
|
||||||
known: Record<ExtensionId, { path: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binding announcement payload used by extension-side runtime registration.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Announcing a new module for a registered kit
|
|
||||||
* - Reusing existing module ownership with the same module identifier
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `moduleId` is unique per owner session/plugin pair
|
|
||||||
* - `kitId` and `kitModuleType` map to a registered kit descriptor
|
|
||||||
* - `config` is a JSON-compatible record
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
export interface ExtensionHostBindingAnnounceInput {
|
|
||||||
config: Record<string, unknown>
|
|
||||||
kitId: string
|
|
||||||
kitModuleType: string
|
|
||||||
moduleId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional filters for listing announced bindings.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Querying only modules from one session
|
|
||||||
* - Querying modules belonging to one kit
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - Any provided key is treated as a strict equality filter
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
export interface ExtensionHostBindingListOptions {
|
|
||||||
kitId?: string
|
|
||||||
ownerSessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes the widget manager surface required by extension-driven gamelet APIs.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - Widget ids remain stable and may be reused for the same module id
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - The minimal widget-manager contract consumed by the extension host service
|
|
||||||
*/
|
|
||||||
export interface ExtensionHostGameletWidgetsManager {
|
|
||||||
getWidgetSnapshot: (id: string) => undefined | WidgetSnapshot
|
|
||||||
openWindow: (params?: { id?: string }) => Promise<void>
|
|
||||||
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
|
||||||
removeWidget: (id: string) => Promise<void>
|
|
||||||
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
|
||||||
id: string,
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
options?: { timeoutMs?: number },
|
|
||||||
) => Promise<TResponse>
|
|
||||||
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime-facing extension host service bundle returned by setup.
|
* Runtime-facing extension host service bundle returned by setup.
|
||||||
@@ -111,9 +31,106 @@ export interface ExtensionHostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable manifest id used as the runtime identity for one extension.
|
* Describes the widget manager surface required by extension-driven gamelet APIs.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Widget ids remain stable and may be reused for the same module id
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - The minimal widget-manager contract consumed by the extension host service
|
||||||
*/
|
*/
|
||||||
export type ExtensionId = string
|
export interface ExtensionHostGameletWidgetsManager {
|
||||||
|
openWindow: (params?: { id?: string }) => Promise<void>
|
||||||
|
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
||||||
|
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
||||||
|
removeWidget: (id: string) => Promise<void>
|
||||||
|
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
|
||||||
|
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
|
||||||
|
id: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
options?: { timeoutMs?: number },
|
||||||
|
) => Promise<TResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the runtime dependencies required by `setupExtensionHost(...)`.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Wiring the extension host during Electron startup
|
||||||
|
* - Providing test doubles for extension-driven gamelet orchestration
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `widgetsManager` is already initialized and ready to manage overlay widgets
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface SetupExtensionHostOptions {
|
||||||
|
widgetsManager: ExtensionHostGameletWidgetsManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binding announcement payload used by extension-side runtime registration.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Announcing a new module for a registered kit
|
||||||
|
* - Reusing existing module ownership with the same module identifier
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `moduleId` is unique per owner session/plugin pair
|
||||||
|
* - `kitId` and `kitModuleType` map to a registered kit descriptor
|
||||||
|
* - `config` is a JSON-compatible record
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface ExtensionHostBindingAnnounceInput {
|
||||||
|
moduleId: string
|
||||||
|
kitId: string
|
||||||
|
kitModuleType: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional filters for listing announced bindings.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Querying only modules from one session
|
||||||
|
* - Querying modules belonging to one kit
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Any provided key is treated as a strict equality filter
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface ExtensionHostBindingListOptions {
|
||||||
|
ownerSessionId?: string
|
||||||
|
kitId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted extension configuration snapshot.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - Reading/writing enabled and auto-reload extension state
|
||||||
|
* - Keeping known extension manifest path metadata
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - Arrays contain extension manifest ids
|
||||||
|
* - `known` maps extension manifest ids to canonical manifest paths
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - N/A
|
||||||
|
*/
|
||||||
|
export interface ExtensionConfig {
|
||||||
|
enabled: ExtensionId[]
|
||||||
|
autoReload: ExtensionId[]
|
||||||
|
known: Record<ExtensionId, { path: string }>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal manifest record with resolved location and package version.
|
* Internal manifest record with resolved location and package version.
|
||||||
@@ -137,20 +154,3 @@ export interface ManifestEntry {
|
|||||||
rootDir: string
|
rootDir: string
|
||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures the runtime dependencies required by `setupExtensionHost(...)`.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Wiring the extension host during Electron startup
|
|
||||||
* - Providing test doubles for extension-driven gamelet orchestration
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `widgetsManager` is already initialized and ready to manage overlay widgets
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - N/A
|
|
||||||
*/
|
|
||||||
export interface SetupExtensionHostOptions {
|
|
||||||
widgetsManager: ExtensionHostGameletWidgetsManager
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ const DEFAULT_REMIX_ID = '48250602'
|
|||||||
const DEFAULT_ARTISTRY_PROVIDER = 'none'
|
const DEFAULT_ARTISTRY_PROVIDER = 'none'
|
||||||
|
|
||||||
interface ArtistrySyncSnapshot {
|
interface ArtistrySyncSnapshot {
|
||||||
globals?: Record<string, any>
|
|
||||||
model?: string
|
|
||||||
options?: Record<string, any>
|
|
||||||
promptPrefix?: string
|
|
||||||
provider?: string
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
promptPrefix?: string
|
||||||
|
options?: Record<string, any>
|
||||||
|
globals?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerConfig {
|
interface TriggerConfig {
|
||||||
globals?: Record<string, any>
|
|
||||||
model?: string
|
|
||||||
options?: Record<string, any>
|
|
||||||
promptPrefix?: string
|
|
||||||
provider?: string
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
promptPrefix?: string
|
||||||
|
options?: Record<string, any>
|
||||||
|
globals?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
function robustParse(input: unknown, context?: string): Record<string, unknown> {
|
function robustParse(input: unknown, context?: string): Record<string, unknown> {
|
||||||
@@ -66,11 +66,11 @@ const activeRunMap = new Map<string, string>()
|
|||||||
* Synced from the renderer App.vue whenever the character or settings change.
|
* Synced from the renderer App.vue whenever the character or settings change.
|
||||||
*/
|
*/
|
||||||
const cardDefaults: ArtistrySyncSnapshot = {
|
const cardDefaults: ArtistrySyncSnapshot = {
|
||||||
globals: undefined as Record<string, unknown> | undefined,
|
|
||||||
model: undefined as string | undefined,
|
|
||||||
options: undefined as Record<string, unknown> | undefined,
|
|
||||||
promptPrefix: undefined as string | undefined,
|
|
||||||
provider: undefined as string | undefined,
|
provider: undefined as string | undefined,
|
||||||
|
model: undefined as string | undefined,
|
||||||
|
promptPrefix: undefined as string | undefined,
|
||||||
|
options: undefined as Record<string, unknown> | undefined,
|
||||||
|
globals: undefined as Record<string, unknown> | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRunId(widgetId: string) {
|
function createRunId(widgetId: string) {
|
||||||
@@ -105,15 +105,15 @@ artistryProviders.set('replicate', new ReplicateProvider())
|
|||||||
artistryProviders.set('nanobanana', new NanoBananaProvider())
|
artistryProviders.set('nanobanana', new NanoBananaProvider())
|
||||||
|
|
||||||
// Deduplication map for headless requests
|
// Deduplication map for headless requests
|
||||||
const pendingHeadlessRequests = new Map<string, Promise<{ base64?: string, error?: string, imageUrl?: string }>>()
|
const pendingHeadlessRequests = new Map<string, Promise<{ imageUrl?: string, base64?: string, error?: string }>>()
|
||||||
|
|
||||||
export async function generateHeadless(params: {
|
export async function generateHeadless(params: {
|
||||||
globals?: Record<string, any>
|
|
||||||
model?: string
|
|
||||||
options?: Record<string, any>
|
|
||||||
prompt: string
|
prompt: string
|
||||||
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
}): Promise<{ base64?: string, error?: string, imageUrl?: string }> {
|
options?: Record<string, any>
|
||||||
|
globals?: Record<string, any>
|
||||||
|
}): Promise<{ imageUrl?: string, base64?: string, error?: string }> {
|
||||||
// Resolve config and effective globals early to secure the deduplication fingerprint
|
// Resolve config and effective globals early to secure the deduplication fingerprint
|
||||||
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
||||||
const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record<string, any>
|
const activeGlobals = (params.globals || artistryConfig.get()?.artistryGlobals || {}) as Record<string, any>
|
||||||
@@ -130,12 +130,12 @@ export async function generateHeadless(params: {
|
|||||||
const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex')
|
const globalsHash = createHash('sha256').update(JSON.stringify(globalsForFingerprint)).digest('hex')
|
||||||
|
|
||||||
const fingerprint = JSON.stringify({
|
const fingerprint = JSON.stringify({
|
||||||
gh: globalsHash, // Include globals hash (Issue #39)
|
|
||||||
ih: imageHash,
|
|
||||||
m: params.model,
|
|
||||||
o: params.options,
|
|
||||||
p: params.prompt,
|
p: params.prompt,
|
||||||
|
m: params.model,
|
||||||
pr: params.provider,
|
pr: params.provider,
|
||||||
|
o: params.options,
|
||||||
|
ih: imageHash,
|
||||||
|
gh: globalsHash, // Include globals hash (Issue #39)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (pendingHeadlessRequests.has(fingerprint)) {
|
if (pendingHeadlessRequests.has(fingerprint)) {
|
||||||
@@ -167,16 +167,16 @@ export async function generateHeadless(params: {
|
|||||||
log.log(`[Headless] Source image length: ${activeGlobals.image.length}`)
|
log.log(`[Headless] Source image length: ${activeGlobals.image.length}`)
|
||||||
|
|
||||||
const request: ArtistryRequest = {
|
const request: ArtistryRequest = {
|
||||||
|
prompt: params.prompt,
|
||||||
|
negativePrompt: params.options?.negativePrompt,
|
||||||
|
width: typeof params.options?.width === 'number' ? params.options.width : undefined,
|
||||||
|
height: typeof params.options?.height === 'number' ? params.options.height : undefined,
|
||||||
|
model: params.model,
|
||||||
extra: {
|
extra: {
|
||||||
...params.options,
|
...params.options,
|
||||||
image: activeGlobals?.image,
|
image: activeGlobals?.image,
|
||||||
internalJobId: createRunId('headless'),
|
internalJobId: createRunId('headless'),
|
||||||
},
|
},
|
||||||
height: typeof params.options?.height === 'number' ? params.options.height : undefined,
|
|
||||||
model: params.model,
|
|
||||||
negativePrompt: params.options?.negativePrompt,
|
|
||||||
prompt: params.prompt,
|
|
||||||
width: typeof params.options?.width === 'number' ? params.options.width : undefined,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`)
|
log.log(`[Headless] Starting generation with provider: ${requestedProvider}, model: ${params.model || 'default'}`)
|
||||||
@@ -215,12 +215,12 @@ export async function generateHeadless(params: {
|
|||||||
|
|
||||||
log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`)
|
log.log(`[Headless] Job ${job.jobId} succeeded. Image URL: ${lastStatus.imageUrl}`)
|
||||||
const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined
|
const base64 = lastStatus.imageUrl ? await downloadImageAsBase64(lastStatus.imageUrl) : undefined
|
||||||
return { base64, imageUrl: lastStatus.imageUrl }
|
return { imageUrl: lastStatus.imageUrl, base64 }
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// For providers with callbacks (like ComfyUI), we wait for the result via the callback
|
// For providers with callbacks (like ComfyUI), we wait for the result via the callback
|
||||||
log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`)
|
log.log(`[Headless] Using callback-based wait logic for provider: ${requestedProvider}`)
|
||||||
return new Promise<{ base64?: string, imageUrl?: string }>((resolve, reject) => {
|
return new Promise<{ imageUrl?: string, base64?: string }>((resolve, reject) => {
|
||||||
const timeout = 1000 * 60 * 5 // 5 minutes timeout
|
const timeout = 1000 * 60 * 5 // 5 minutes timeout
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
reject(new Error('Image generation timed out after 5 minutes.'))
|
reject(new Error('Image generation timed out after 5 minutes.'))
|
||||||
@@ -231,7 +231,7 @@ export async function generateHeadless(params: {
|
|||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
try {
|
try {
|
||||||
const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined
|
const base64 = status.imageUrl ? await downloadImageAsBase64(status.imageUrl) : undefined
|
||||||
resolve({ base64, imageUrl: status.imageUrl })
|
resolve({ imageUrl: status.imageUrl, base64 })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
reject(e)
|
reject(e)
|
||||||
@@ -260,112 +260,10 @@ export async function generateHeadless(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupArtistryBridge(params: {
|
|
||||||
artistryConfig: Config<typeof artistryConfigSchema>
|
|
||||||
context?: ReturnType<typeof createMainEventaContext>['context']
|
|
||||||
widgetsManager: WidgetsWindowManager
|
|
||||||
}) {
|
|
||||||
log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...')
|
|
||||||
|
|
||||||
if (params.context) {
|
|
||||||
defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => {
|
|
||||||
log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`)
|
|
||||||
return await generateHeadless(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
defineInvokeHandler(params.context, artistrySyncConfig, (payload) => {
|
|
||||||
log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`)
|
|
||||||
params.artistryConfig.update({
|
|
||||||
artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || {
|
|
||||||
comfyuiActiveWorkflow: '',
|
|
||||||
comfyuiSavedWorkflows: [],
|
|
||||||
comfyuiServerUrl: 'http://localhost:8188',
|
|
||||||
nanobananaApiKey: '',
|
|
||||||
nanobananaModel: 'gemini-3.1-flash-image-preview',
|
|
||||||
nanobananaResolution: '1K',
|
|
||||||
replicateApiKey: '',
|
|
||||||
replicateAspectRatio: '16:9',
|
|
||||||
replicateDefaultModel: 'black-forest-labs/flux-schnell',
|
|
||||||
replicateInferenceSteps: 4,
|
|
||||||
},
|
|
||||||
artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update character-level defaults (volatile only)
|
|
||||||
cardDefaults.provider = payload.provider
|
|
||||||
cardDefaults.model = payload.model
|
|
||||||
cardDefaults.promptPrefix = payload.promptPrefix
|
|
||||||
cardDefaults.options = payload.options
|
|
||||||
cardDefaults.globals = payload.globals
|
|
||||||
})
|
|
||||||
|
|
||||||
defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => {
|
|
||||||
log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`)
|
|
||||||
try {
|
|
||||||
const url = payload.url.replace(/\/+$/, '')
|
|
||||||
const controller = new AbortController()
|
|
||||||
const id = setTimeout(() => controller.abort(), 10000)
|
|
||||||
const resp = await fetch(`${url}/system_stats`, { signal: controller.signal })
|
|
||||||
clearTimeout(id)
|
|
||||||
|
|
||||||
if (!resp.ok)
|
|
||||||
throw new Error(`HTTP ${resp.status}`)
|
|
||||||
const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> }
|
|
||||||
const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU'
|
|
||||||
const vram = data.devices?.[0]?.vram_total
|
|
||||||
const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : ''
|
|
||||||
return {
|
|
||||||
info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`,
|
|
||||||
ok: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (e: unknown) {
|
|
||||||
const message = errorMessageFrom(e) ?? 'Unknown connection error'
|
|
||||||
log.error(`🔌 ComfyUI connection test failed: ${message}`)
|
|
||||||
return {
|
|
||||||
info: `Failed: ${message}`,
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalUpdateWidget = params.widgetsManager.updateWidget
|
|
||||||
params.widgetsManager.updateWidget = async (payload) => {
|
|
||||||
const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id)
|
|
||||||
await originalUpdateWidget.call(params.widgetsManager, payload)
|
|
||||||
await handleArtistryTrigger({
|
|
||||||
componentName: snapshot?.componentName,
|
|
||||||
componentProps: payload.componentProps,
|
|
||||||
id: payload.id,
|
|
||||||
widgetsManager: params.widgetsManager,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPushWidget = params.widgetsManager.pushWidget
|
|
||||||
params.widgetsManager.pushWidget = async (payload) => {
|
|
||||||
if (payload.componentName === 'comfy' || payload.componentName === 'artistry') {
|
|
||||||
log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`)
|
|
||||||
payload.ttlMs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultId = await originalPushWidget.call(params.widgetsManager, payload)
|
|
||||||
|
|
||||||
await handleArtistryTrigger({
|
|
||||||
componentName: payload.componentName,
|
|
||||||
componentProps: payload.componentProps,
|
|
||||||
id: resultId,
|
|
||||||
widgetsManager: params.widgetsManager,
|
|
||||||
})
|
|
||||||
|
|
||||||
return resultId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleArtistryTrigger(params: {
|
async function handleArtistryTrigger(params: {
|
||||||
|
id: string
|
||||||
componentName?: string
|
componentName?: string
|
||||||
componentProps?: unknown
|
componentProps?: unknown
|
||||||
id: string
|
|
||||||
widgetsManager: WidgetsWindowManager
|
widgetsManager: WidgetsWindowManager
|
||||||
}) {
|
}) {
|
||||||
if (params.componentName !== 'comfy' && params.componentName !== 'artistry')
|
if (params.componentName !== 'comfy' && params.componentName !== 'artistry')
|
||||||
@@ -383,16 +281,16 @@ async function handleArtistryTrigger(params: {
|
|||||||
// 1. Explicitly provided in component props (_artistryConfig)
|
// 1. Explicitly provided in component props (_artistryConfig)
|
||||||
// 2. Character-level defaults synced from renderer (cardDefaults)
|
// 2. Character-level defaults synced from renderer (cardDefaults)
|
||||||
const config: TriggerConfig = {
|
const config: TriggerConfig = {
|
||||||
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
|
provider: artistryConfigOverrides.provider as string | undefined,
|
||||||
// Older widget payloads can still send `Globals`, and dropping it now would break them.
|
|
||||||
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
|
|
||||||
model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model,
|
model: (artistryConfigOverrides.model as string | undefined) || cardDefaults.model,
|
||||||
|
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
|
||||||
options: {
|
options: {
|
||||||
...cardDefaults.options,
|
...cardDefaults.options,
|
||||||
...robustParse(artistryConfigOverrides.options, 'artistryOptions'),
|
...robustParse(artistryConfigOverrides.options, 'artistryOptions'),
|
||||||
},
|
},
|
||||||
promptPrefix: (artistryConfigOverrides.promptPrefix as string | undefined) || cardDefaults.promptPrefix,
|
// NOTICE: Keep legacy `Globals` fallback while standardizing on `globals`.
|
||||||
provider: artistryConfigOverrides.provider as string | undefined,
|
// Older widget payloads can still send `Globals`, and dropping it now would break them.
|
||||||
|
globals: robustParse(artistryConfigOverrides.globals || artistryConfigOverrides.Globals || cardDefaults.globals, 'artistryGlobals'),
|
||||||
}
|
}
|
||||||
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
const { config: artistryConfig } = await injeca.resolve({ config: 'configs:artistry' } as { config: ProvidedBy<Config<typeof artistryConfigSchema>> })
|
||||||
const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER
|
const providerId = config.provider || cardDefaults.provider || artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER
|
||||||
@@ -430,8 +328,8 @@ async function handleArtistryTrigger(params: {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
log.error(`🔴 Provider '${providerId}' not found.`)
|
log.error(`🔴 Provider '${providerId}' not found.`)
|
||||||
params.widgetsManager.updateWidget({
|
params.widgetsManager.updateWidget({
|
||||||
componentProps: { actionLabel: `Provider '${providerId}' not available`, status: 'error' },
|
|
||||||
id: params.id,
|
id: params.id,
|
||||||
|
componentProps: { status: 'error', actionLabel: `Provider '${providerId}' not available` },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -446,6 +344,8 @@ async function handleArtistryTrigger(params: {
|
|||||||
try {
|
try {
|
||||||
// Build the abstract request
|
// Build the abstract request
|
||||||
const request: ArtistryRequest = {
|
const request: ArtistryRequest = {
|
||||||
|
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
|
||||||
|
model: config.model,
|
||||||
extra: {
|
extra: {
|
||||||
...options,
|
...options,
|
||||||
...props, // Include root componentProps overrides (template, node overrides)
|
...props, // Include root componentProps overrides (template, node overrides)
|
||||||
@@ -453,8 +353,6 @@ async function handleArtistryTrigger(params: {
|
|||||||
internalJobId: runId, // Track each generation independently, even on the same widget.
|
internalJobId: runId, // Track each generation independently, even on the same widget.
|
||||||
remixId,
|
remixId,
|
||||||
},
|
},
|
||||||
model: config.model,
|
|
||||||
prompt: config.promptPrefix ? `${config.promptPrefix} ${prompt}` : (prompt || ''),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateIfActive = (statusUpdate: Record<string, any>) => {
|
const updateIfActive = (statusUpdate: Record<string, any>) => {
|
||||||
@@ -467,11 +365,11 @@ async function handleArtistryTrigger(params: {
|
|||||||
// that would otherwise be lost when the final 'done' status is sent.
|
// that would otherwise be lost when the final 'done' status is sent.
|
||||||
const existing = params.widgetsManager.getWidgetSnapshot(params.id)
|
const existing = params.widgetsManager.getWidgetSnapshot(params.id)
|
||||||
params.widgetsManager.updateWidget({
|
params.widgetsManager.updateWidget({
|
||||||
|
id: params.id,
|
||||||
componentProps: {
|
componentProps: {
|
||||||
...(existing?.componentProps as any),
|
...(existing?.componentProps as any),
|
||||||
...statusUpdate,
|
...statusUpdate,
|
||||||
},
|
},
|
||||||
id: params.id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +379,7 @@ async function handleArtistryTrigger(params: {
|
|||||||
updateIfActive(statusUpdate as Record<string, any>)
|
updateIfActive(statusUpdate as Record<string, any>)
|
||||||
if (statusUpdate.status === 'succeeded') {
|
if (statusUpdate.status === 'succeeded') {
|
||||||
log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`)
|
log.log(`🎉 Job complete (via callback) for ${params.id}. Sending final status: done`)
|
||||||
updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' })
|
updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
|
||||||
}
|
}
|
||||||
else if (statusUpdate.status === 'failed') {
|
else if (statusUpdate.status === 'failed') {
|
||||||
log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`)
|
log.log(`🔴 Job failed (via callback) for ${params.id}. Preserving error status.`)
|
||||||
@@ -502,7 +400,7 @@ async function handleArtistryTrigger(params: {
|
|||||||
// Check for timeout
|
// Check for timeout
|
||||||
if (Date.now() - startTime > timeoutLength) {
|
if (Date.now() - startTime > timeoutLength) {
|
||||||
log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`)
|
log.error(`[Artistry Bridge] Job ${job.jobId} timed out after 5 minutes.`)
|
||||||
updateIfActive({ actionLabel: 'Generation timed out', status: 'error' })
|
updateIfActive({ status: 'error', actionLabel: 'Generation timed out' })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,7 +427,7 @@ async function handleArtistryTrigger(params: {
|
|||||||
const finalStatus = await provider.getStatus(job.jobId)
|
const finalStatus = await provider.getStatus(job.jobId)
|
||||||
if (finalStatus.status === 'succeeded') {
|
if (finalStatus.status === 'succeeded') {
|
||||||
log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`)
|
log.log(`🎉 Job complete (via polling) for ${params.id}. Sending final status: done`)
|
||||||
updateIfActive({ actionLabel: undefined, progress: 100, status: 'done' })
|
updateIfActive({ status: 'done', progress: 100, actionLabel: undefined })
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`)
|
log.log(`🔴 Job failed (via polling) for ${params.id}. Preserving error status.`)
|
||||||
@@ -543,10 +441,112 @@ async function handleArtistryTrigger(params: {
|
|||||||
if (activeRunMap.get(params.id) === runId) {
|
if (activeRunMap.get(params.id) === runId) {
|
||||||
lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44)
|
lastTriggerMap.delete(params.id) // [BY DESIGN]: Clear fingerprint on failure to allow retry (Issue #44)
|
||||||
params.widgetsManager.updateWidget({
|
params.widgetsManager.updateWidget({
|
||||||
componentProps: { actionLabel: message, status: 'error' },
|
|
||||||
id: params.id,
|
id: params.id,
|
||||||
|
componentProps: { status: 'error', actionLabel: message },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setupArtistryBridge(params: {
|
||||||
|
widgetsManager: WidgetsWindowManager
|
||||||
|
context?: ReturnType<typeof createMainEventaContext>['context']
|
||||||
|
artistryConfig: Config<typeof artistryConfigSchema>
|
||||||
|
}) {
|
||||||
|
log.log('🚀 Initializing Artistry bridge (Spawn + Update Interceptor + Headless Handler)...')
|
||||||
|
|
||||||
|
if (params.context) {
|
||||||
|
defineInvokeHandler(params.context, artistryGenerateHeadless, async (payload) => {
|
||||||
|
log.log(`[Artistry Bridge] [Headless] Received invoke for prompt: ${payload.prompt.slice(0, 50)}...`)
|
||||||
|
return await generateHeadless(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineInvokeHandler(params.context, artistrySyncConfig, (payload) => {
|
||||||
|
log.log(`🔄 Syncing artistry config to main. Provider: ${payload.provider}`)
|
||||||
|
params.artistryConfig.update({
|
||||||
|
artistryProvider: payload.provider || params.artistryConfig.get()?.artistryProvider || DEFAULT_ARTISTRY_PROVIDER,
|
||||||
|
artistryGlobals: payload.globals || params.artistryConfig.get()?.artistryGlobals || {
|
||||||
|
comfyuiServerUrl: 'http://localhost:8188',
|
||||||
|
comfyuiSavedWorkflows: [],
|
||||||
|
comfyuiActiveWorkflow: '',
|
||||||
|
replicateApiKey: '',
|
||||||
|
replicateDefaultModel: 'black-forest-labs/flux-schnell',
|
||||||
|
replicateAspectRatio: '16:9',
|
||||||
|
replicateInferenceSteps: 4,
|
||||||
|
nanobananaApiKey: '',
|
||||||
|
nanobananaModel: 'gemini-3.1-flash-image-preview',
|
||||||
|
nanobananaResolution: '1K',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update character-level defaults (volatile only)
|
||||||
|
cardDefaults.provider = payload.provider
|
||||||
|
cardDefaults.model = payload.model
|
||||||
|
cardDefaults.promptPrefix = payload.promptPrefix
|
||||||
|
cardDefaults.options = payload.options
|
||||||
|
cardDefaults.globals = payload.globals
|
||||||
|
})
|
||||||
|
|
||||||
|
defineInvokeHandler(params.context, artistryTestComfyUIConnection, async (payload) => {
|
||||||
|
log.log(`🔌 Testing ComfyUI connection at: ${payload.url}`)
|
||||||
|
try {
|
||||||
|
const url = payload.url.replace(/\/+$/, '')
|
||||||
|
const controller = new AbortController()
|
||||||
|
const id = setTimeout(() => controller.abort(), 10000)
|
||||||
|
const resp = await fetch(`${url}/system_stats`, { signal: controller.signal })
|
||||||
|
clearTimeout(id)
|
||||||
|
|
||||||
|
if (!resp.ok)
|
||||||
|
throw new Error(`HTTP ${resp.status}`)
|
||||||
|
const data = await resp.json() as { devices?: Array<{ name?: string, vram_total?: number }> }
|
||||||
|
const gpus = data.devices?.map(d => d.name).join(', ') || 'Unknown GPU'
|
||||||
|
const vram = data.devices?.[0]?.vram_total
|
||||||
|
const vramStr = vram ? `${(vram / 1024 / 1024 / 1024).toFixed(1)} GB` : ''
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
info: `Connected — ${gpus}${vramStr ? ` (${vramStr} VRAM)` : ''}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: unknown) {
|
||||||
|
const message = errorMessageFrom(e) ?? 'Unknown connection error'
|
||||||
|
log.error(`🔌 ComfyUI connection test failed: ${message}`)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
info: `Failed: ${message}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalUpdateWidget = params.widgetsManager.updateWidget
|
||||||
|
params.widgetsManager.updateWidget = async (payload) => {
|
||||||
|
const snapshot = params.widgetsManager.getWidgetSnapshot(payload.id)
|
||||||
|
await originalUpdateWidget.call(params.widgetsManager, payload)
|
||||||
|
await handleArtistryTrigger({
|
||||||
|
id: payload.id,
|
||||||
|
componentName: snapshot?.componentName,
|
||||||
|
componentProps: payload.componentProps,
|
||||||
|
widgetsManager: params.widgetsManager,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPushWidget = params.widgetsManager.pushWidget
|
||||||
|
params.widgetsManager.pushWidget = async (payload) => {
|
||||||
|
if (payload.componentName === 'comfy' || payload.componentName === 'artistry') {
|
||||||
|
log.log(`🖼️ Enabling 'Living Wall' mode for ${payload.id}. Forcing infinite TTL. (Component: ${payload.componentName})`)
|
||||||
|
payload.ttlMs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultId = await originalPushWidget.call(params.widgetsManager, payload)
|
||||||
|
|
||||||
|
await handleArtistryTrigger({
|
||||||
|
id: resultId,
|
||||||
|
componentName: payload.componentName,
|
||||||
|
componentProps: payload.componentProps,
|
||||||
|
widgetsManager: params.widgetsManager,
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa'
|
import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa'
|
||||||
import { createWidgetsService } from './index'
|
import { createWidgetsService } from './index'
|
||||||
|
|
||||||
|
function createWindow(id: number): BrowserWindow {
|
||||||
|
return {
|
||||||
|
webContents: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
} as BrowserWindow
|
||||||
|
}
|
||||||
|
|
||||||
function createWidgetsManager() {
|
function createWidgetsManager() {
|
||||||
return {
|
return {
|
||||||
clearWidgets: vi.fn(),
|
clearWidgets: vi.fn(),
|
||||||
fetchWidget: vi.fn(),
|
fetchWidget: vi.fn(),
|
||||||
getWidgetSnapshot: vi.fn(),
|
|
||||||
getWindow: vi.fn(),
|
getWindow: vi.fn(),
|
||||||
|
getWidgetSnapshot: vi.fn(),
|
||||||
hideWindow: vi.fn(),
|
hideWindow: vi.fn(),
|
||||||
onWidgetEvent: vi.fn(),
|
onWidgetEvent: vi.fn(),
|
||||||
openWindow: vi.fn(),
|
openWindow: vi.fn(),
|
||||||
@@ -25,14 +33,6 @@ function createWidgetsManager() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(id: number): BrowserWindow {
|
|
||||||
return {
|
|
||||||
webContents: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
} as BrowserWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createWidgetsService', () => {
|
describe('createWidgetsService', () => {
|
||||||
it('routes iframe request results from the widgets window to the manager', () => {
|
it('routes iframe request results from the widgets window to the manager', () => {
|
||||||
const context = createContext()
|
const context = createContext()
|
||||||
@@ -46,8 +46,8 @@ describe('createWidgetsService', () => {
|
|||||||
|
|
||||||
context.emit(widgetsIframeRequestResultEvent, {
|
context.emit(widgetsIframeRequestResultEvent, {
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: true,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: true,
|
||||||
result: { fen: 'fen-after-request' },
|
result: { fen: 'fen-after-request' },
|
||||||
}, {
|
}, {
|
||||||
raw: {
|
raw: {
|
||||||
@@ -59,8 +59,8 @@ describe('createWidgetsService', () => {
|
|||||||
|
|
||||||
expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({
|
expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: true,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: true,
|
||||||
result: { fen: 'fen-after-request' },
|
result: { fen: 'fen-after-request' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,8 @@ describe('createWidgetsService', () => {
|
|||||||
|
|
||||||
context.emit(widgetsIframeRequestResultEvent, {
|
context.emit(widgetsIframeRequestResultEvent, {
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: true,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: true,
|
||||||
result: { fen: 'fen-after-request' },
|
result: { fen: 'fen-after-request' },
|
||||||
}, {
|
}, {
|
||||||
raw: {
|
raw: {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ interface InvokeOptions {
|
|||||||
raw?: { ipcMainEvent?: IpcMainEvent }
|
raw?: { ipcMainEvent?: IpcMainEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) {
|
||||||
|
const sender = options?.raw?.ipcMainEvent?.sender
|
||||||
|
if (!sender)
|
||||||
|
return false
|
||||||
|
return sender.id === window.webContents.id
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers widget-related Electron invoke handlers for one window context.
|
* Registers widget-related Electron invoke handlers for one window context.
|
||||||
*
|
*
|
||||||
@@ -64,22 +71,51 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
|||||||
defineInvokeHandlers(
|
defineInvokeHandlers(
|
||||||
params.context,
|
params.context,
|
||||||
{
|
{
|
||||||
|
widgetsPrepareWindow,
|
||||||
|
widgetsOpenWindow,
|
||||||
|
widgetsHideWindow,
|
||||||
widgetsAdd,
|
widgetsAdd,
|
||||||
|
widgetsUpdate,
|
||||||
|
widgetsRemove,
|
||||||
widgetsClear,
|
widgetsClear,
|
||||||
widgetsFetch,
|
widgetsFetch,
|
||||||
widgetsHideWindow,
|
|
||||||
widgetsIframePublish,
|
widgetsIframePublish,
|
||||||
widgetsOpenWindow,
|
|
||||||
widgetsPrepareWindow,
|
|
||||||
widgetsRemove,
|
|
||||||
widgetsUpdate,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
widgetsPrepareWindow: async (payload, options) => {
|
||||||
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
|
return undefined
|
||||||
|
const id = normalizeOptionalWidgetId(payload?.id)
|
||||||
|
return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined)
|
||||||
|
},
|
||||||
|
widgetsOpenWindow: async (payload, options) => {
|
||||||
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
|
return undefined
|
||||||
|
const id = normalizeOptionalWidgetId(payload?.id)
|
||||||
|
return params.widgetsManager.openWindow(id ? { id } : undefined)
|
||||||
|
},
|
||||||
|
widgetsHideWindow: async (payload, options) => {
|
||||||
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
|
return undefined
|
||||||
|
return params.widgetsManager!.hideWindow(payload ?? undefined)
|
||||||
|
},
|
||||||
widgetsAdd: async (payload, options) => {
|
widgetsAdd: async (payload, options) => {
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
return undefined
|
return undefined
|
||||||
return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload))
|
return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload))
|
||||||
},
|
},
|
||||||
|
widgetsUpdate: async (payload, options) => {
|
||||||
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
|
return undefined
|
||||||
|
return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload))
|
||||||
|
},
|
||||||
|
widgetsRemove: async (payload, options) => {
|
||||||
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
|
return undefined
|
||||||
|
return params.widgetsManager.removeWidget(
|
||||||
|
normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'),
|
||||||
|
)
|
||||||
|
},
|
||||||
widgetsClear: async (_payload, options) => {
|
widgetsClear: async (_payload, options) => {
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
return undefined
|
return undefined
|
||||||
@@ -92,48 +128,12 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
|||||||
normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'),
|
normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
widgetsHideWindow: async (payload, options) => {
|
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
|
||||||
return undefined
|
|
||||||
return params.widgetsManager!.hideWindow(payload ?? undefined)
|
|
||||||
},
|
|
||||||
widgetsIframePublish: async (payload, options) => {
|
widgetsIframePublish: async (payload, options) => {
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||||
return undefined
|
return undefined
|
||||||
const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.')
|
const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.')
|
||||||
params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event))
|
params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event))
|
||||||
},
|
},
|
||||||
widgetsOpenWindow: async (payload, options) => {
|
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
|
||||||
return undefined
|
|
||||||
const id = normalizeOptionalWidgetId(payload?.id)
|
|
||||||
return params.widgetsManager.openWindow(id ? { id } : undefined)
|
|
||||||
},
|
|
||||||
widgetsPrepareWindow: async (payload, options) => {
|
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
|
||||||
return undefined
|
|
||||||
const id = normalizeOptionalWidgetId(payload?.id)
|
|
||||||
return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined)
|
|
||||||
},
|
|
||||||
widgetsRemove: async (payload, options) => {
|
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
|
||||||
return undefined
|
|
||||||
return params.widgetsManager.removeWidget(
|
|
||||||
normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
widgetsUpdate: async (payload, options) => {
|
|
||||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
|
||||||
return undefined
|
|
||||||
return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload))
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) {
|
|
||||||
const sender = options?.raw?.ipcMainEvent?.sender
|
|
||||||
if (!sender)
|
|
||||||
return false
|
|
||||||
return sender.id === window.webContents.id
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
* the current AIRI card's artistry settings.
|
* the current AIRI card's artistry settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface ArtistryRequest {
|
||||||
|
/** The text prompt describing the desired image */
|
||||||
|
prompt: string
|
||||||
|
/** Negative prompt — things to avoid (provider support varies) */
|
||||||
|
negativePrompt?: string
|
||||||
|
/** Image width in pixels */
|
||||||
|
width?: number
|
||||||
|
/** Image height in pixels */
|
||||||
|
height?: number
|
||||||
|
/** Provider-specific model identifier */
|
||||||
|
model?: string
|
||||||
|
/** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */
|
||||||
|
extra?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ArtistryJob {
|
export interface ArtistryJob {
|
||||||
/** Internal job ID for tracking */
|
/** Internal job ID for tracking */
|
||||||
jobId: string
|
jobId: string
|
||||||
@@ -13,47 +28,34 @@ export interface ArtistryJob {
|
|||||||
providerJobId: string
|
providerJobId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArtistryJobStatusType = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||||
|
|
||||||
export interface ArtistryJobStatus {
|
export interface ArtistryJobStatus {
|
||||||
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */
|
status: ArtistryJobStatusType
|
||||||
actionLabel?: string
|
|
||||||
/** Error message if failed */
|
|
||||||
error?: string
|
|
||||||
/** Final output image URL */
|
|
||||||
imageUrl?: string
|
|
||||||
/** Generation progress 0-100 (not all providers support this) */
|
/** Generation progress 0-100 (not all providers support this) */
|
||||||
progress?: number
|
progress?: number
|
||||||
status: ArtistryJobStatusType
|
/** Final output image URL */
|
||||||
|
imageUrl?: string
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string
|
||||||
|
/** Human-readable label of current stage (e.g. "Sampling", "VAE Decode") */
|
||||||
|
actionLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArtistryJobStatusType = 'cancelled' | 'failed' | 'queued' | 'running' | 'succeeded'
|
export interface ArtistryProviderConfig {
|
||||||
|
/** Unique provider ID (e.g. "comfyui", "replicate") */
|
||||||
/**
|
id: string
|
||||||
* Per-card artistry settings stored in AiriExtension.modules.artistry
|
/** Human-readable display name */
|
||||||
*/
|
name: string
|
||||||
export interface ArtistryModuleSettings {
|
/** Provider-specific configuration (API keys, paths, etc.) */
|
||||||
/** String prepended to every LLM-generated prompt for style consistency */
|
settings: Record<string, any>
|
||||||
defaultPromptPrefix?: string
|
|
||||||
/** Provider-specific model identifier */
|
|
||||||
model?: string
|
|
||||||
/** Active provider ID (e.g. "comfyui", "replicate") */
|
|
||||||
provider?: string
|
|
||||||
/**
|
|
||||||
* Free-form provider-specific options as a JSON object.
|
|
||||||
* For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... }
|
|
||||||
* For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" }
|
|
||||||
*/
|
|
||||||
providerOptions?: Record<string, any>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtistryProvider {
|
export interface ArtistryProvider {
|
||||||
/**
|
/** Unique provider ID */
|
||||||
* Cancel a running job (optional — not all providers support this).
|
readonly id: string
|
||||||
*/
|
/** Human-readable display name */
|
||||||
cancel?: (jobId: string) => Promise<void>
|
readonly name: string
|
||||||
/**
|
|
||||||
* Clean up resources when the provider is being switched out.
|
|
||||||
*/
|
|
||||||
dispose?: () => void
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start an image generation job.
|
* Start an image generation job.
|
||||||
@@ -67,43 +69,41 @@ export interface ArtistryProvider {
|
|||||||
*/
|
*/
|
||||||
getStatus: (jobId: string) => Promise<ArtistryJobStatus>
|
getStatus: (jobId: string) => Promise<ArtistryJobStatus>
|
||||||
|
|
||||||
/** Unique provider ID */
|
/**
|
||||||
readonly id: string
|
* Cancel a running job (optional — not all providers support this).
|
||||||
|
*/
|
||||||
|
cancel?: (jobId: string) => Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the provider is first initialized with its config.
|
* Called when the provider is first initialized with its config.
|
||||||
*/
|
*/
|
||||||
initialize?: (config: Record<string, any>) => Promise<void>
|
initialize?: (config: Record<string, any>) => Promise<void>
|
||||||
|
|
||||||
/** Human-readable display name */
|
|
||||||
readonly name: string
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional push callback for providers that stream or callback status updates.
|
* Optional push callback for providers that stream or callback status updates.
|
||||||
*/
|
*/
|
||||||
setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void
|
setJobCallback?: (jobId: string, callback: (status: ArtistryJobStatus) => void) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources when the provider is being switched out.
|
||||||
|
*/
|
||||||
|
dispose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArtistryProviderConfig {
|
/**
|
||||||
/** Unique provider ID (e.g. "comfyui", "replicate") */
|
* Per-card artistry settings stored in AiriExtension.modules.artistry
|
||||||
id: string
|
*/
|
||||||
/** Human-readable display name */
|
export interface ArtistryModuleSettings {
|
||||||
name: string
|
/** Active provider ID (e.g. "comfyui", "replicate") */
|
||||||
/** Provider-specific configuration (API keys, paths, etc.) */
|
provider?: string
|
||||||
settings: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArtistryRequest {
|
|
||||||
/** Provider-specific extras (e.g. remixId, checkpoint, seed, aspect_ratio) */
|
|
||||||
extra?: Record<string, any>
|
|
||||||
/** Image height in pixels */
|
|
||||||
height?: number
|
|
||||||
/** Provider-specific model identifier */
|
/** Provider-specific model identifier */
|
||||||
model?: string
|
model?: string
|
||||||
/** Negative prompt — things to avoid (provider support varies) */
|
/** String prepended to every LLM-generated prompt for style consistency */
|
||||||
negativePrompt?: string
|
defaultPromptPrefix?: string
|
||||||
/** The text prompt describing the desired image */
|
/**
|
||||||
prompt: string
|
* Free-form provider-specific options as a JSON object.
|
||||||
/** Image width in pixels */
|
* For Replicate: { go_fast: true, megapixels: "1", aspect_ratio: "16:9", ... }
|
||||||
width?: number
|
* For ComfyUI: { remixId: 48250602, checkpoint: "bunnyMint.safetensors" }
|
||||||
|
*/
|
||||||
|
providerOptions?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,37 +13,43 @@ export class ComfyUIProvider implements ArtistryProvider {
|
|||||||
readonly id = 'comfyui'
|
readonly id = 'comfyui'
|
||||||
readonly name = 'ComfyUI (Local)'
|
readonly name = 'ComfyUI (Local)'
|
||||||
|
|
||||||
private activeWorkflowId = ''
|
|
||||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
|
||||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
|
||||||
|
|
||||||
private savedWorkflows: any[] = []
|
|
||||||
private serverUrl = 'http://localhost:8188'
|
private serverUrl = 'http://localhost:8188'
|
||||||
|
private savedWorkflows: any[] = []
|
||||||
|
private activeWorkflowId = ''
|
||||||
|
|
||||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||||
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||||
|
|
||||||
// Resolve which workflow template to use --- per-request template override takes precedence over card model default
|
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
|
||||||
const templateId = request.extra?.template || request.model || this.activeWorkflowId
|
const controller = new AbortController()
|
||||||
const template = this.savedWorkflows.find((w: any) => w.id === templateId)
|
const id = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
if (!template) {
|
const response = await fetch(url, {
|
||||||
this.updateStatus(jobId, {
|
...options,
|
||||||
actionLabel: 'Error: No workflow configured',
|
signal: controller.signal,
|
||||||
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
|
|
||||||
status: 'failed',
|
|
||||||
})
|
})
|
||||||
return { jobId, providerJobId: jobId }
|
clearTimeout(id)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
clearTimeout(id)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start async generation
|
|
||||||
this.pollForResult(jobId, template, request)
|
|
||||||
|
|
||||||
return { jobId, providerJobId: jobId }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
this.callbacks.set(jobId, callback)
|
||||||
|
// If we already have a result, fire it immediately
|
||||||
|
const result = this.jobResults.get(jobId)
|
||||||
|
if (result)
|
||||||
|
callback(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||||
|
this.jobResults.set(jobId, status)
|
||||||
|
const callback = this.callbacks.get(jobId)
|
||||||
|
if (callback)
|
||||||
|
callback(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(config: any): Promise<void> {
|
async initialize(config: any): Promise<void> {
|
||||||
@@ -55,12 +61,195 @@ export class ComfyUIProvider implements ArtistryProvider {
|
|||||||
this.activeWorkflowId = config.comfyuiActiveWorkflow
|
this.activeWorkflowId = config.comfyuiActiveWorkflow
|
||||||
}
|
}
|
||||||
|
|
||||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||||
this.callbacks.set(jobId, callback)
|
const jobId = request.extra?.internalJobId || Math.random().toString(36).slice(2)
|
||||||
// If we already have a result, fire it immediately
|
|
||||||
const result = this.jobResults.get(jobId)
|
// Resolve which workflow template to use --- per-request template override takes precedence over card model default
|
||||||
if (result)
|
const templateId = request.extra?.template || request.model || this.activeWorkflowId
|
||||||
callback(result)
|
const template = this.savedWorkflows.find((w: any) => w.id === templateId)
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
this.updateStatus(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: 'No workflow template configured. Upload a workflow in Settings > Providers > ComfyUI.',
|
||||||
|
actionLabel: 'Error: No workflow configured',
|
||||||
|
})
|
||||||
|
return { jobId, providerJobId: jobId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start async generation
|
||||||
|
this.pollForResult(jobId, template, request)
|
||||||
|
|
||||||
|
return { jobId, providerJobId: jobId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollForResult(
|
||||||
|
jobId: string,
|
||||||
|
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
|
||||||
|
request: ArtistryRequest,
|
||||||
|
) {
|
||||||
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Preparing workflow...' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 0. Handle potential image and prompt upload bidirectional flow
|
||||||
|
const extraStr = JSON.stringify(request.extra || {})
|
||||||
|
const workflowStr = JSON.stringify(template.workflow || {})
|
||||||
|
const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}')
|
||||||
|
const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}')
|
||||||
|
|
||||||
|
let uploadedImageName = ''
|
||||||
|
if (hasImagePlaceholder && request.extra?.image) {
|
||||||
|
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`)
|
||||||
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Uploading texture to ComfyUI...' })
|
||||||
|
try {
|
||||||
|
uploadedImageName = await this.uploadImage(request.extra.image)
|
||||||
|
log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`)
|
||||||
|
}
|
||||||
|
catch (e: any) {
|
||||||
|
log.error(`[ComfyUI] Texture upload failed: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Apply overrides to the workflow template (standard injection)
|
||||||
|
let resolvedPrompt = this.applyOverrides(template, request)
|
||||||
|
|
||||||
|
// 2. Perform final placeholder resolution across the ENTIRE resolved prompt
|
||||||
|
if (hasImagePlaceholder || hasPromptPlaceholder) {
|
||||||
|
log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`)
|
||||||
|
const replacements: Record<string, string> = {
|
||||||
|
'{{PROMPT}}': request.prompt || '',
|
||||||
|
}
|
||||||
|
if (uploadedImageName) {
|
||||||
|
replacements['{{IMAGE}}'] = uploadedImageName
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2))
|
||||||
|
|
||||||
|
// 2. POST /prompt to queue the workflow
|
||||||
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Queuing in ComfyUI...' })
|
||||||
|
|
||||||
|
let queueResp: Response
|
||||||
|
try {
|
||||||
|
queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: resolvedPrompt }),
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
catch (e: any) {
|
||||||
|
throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queueResp.ok) {
|
||||||
|
const errorBody = await queueResp.text()
|
||||||
|
throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueData = await queueResp.json()
|
||||||
|
const promptId = queueData.prompt_id
|
||||||
|
if (!promptId) {
|
||||||
|
throw new Error('ComfyUI returned no prompt_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`)
|
||||||
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Generating...' })
|
||||||
|
|
||||||
|
// 3. Poll /history/{prompt_id} until completion
|
||||||
|
let historyDone = false
|
||||||
|
let attempt = 0
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (!historyDone) {
|
||||||
|
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
||||||
|
attempt++
|
||||||
|
|
||||||
|
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
||||||
|
throw new Error('Generation timed out after 5 minutes')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt % 3 === 0) {
|
||||||
|
log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let histResp: Response
|
||||||
|
try {
|
||||||
|
histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
||||||
|
}
|
||||||
|
catch (e: any) {
|
||||||
|
throw new Error(`ComfyUI disconnected during polling: ${e.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (histResp.ok) {
|
||||||
|
const histData = await histResp.json()
|
||||||
|
if (histData[promptId]) {
|
||||||
|
let outputs = histData[promptId].outputs
|
||||||
|
const stats = histData[promptId].status
|
||||||
|
|
||||||
|
// 3.1. Race condition protection: If outputs are missing, wait a beat and retry once
|
||||||
|
if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) {
|
||||||
|
log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`)
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
||||||
|
if (retryResp.ok) {
|
||||||
|
const retryData = await retryResp.json()
|
||||||
|
if (retryData[promptId] && retryData[promptId].outputs) {
|
||||||
|
log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`)
|
||||||
|
outputs = retryData[promptId].outputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log raw history if no images found or if there are status messages
|
||||||
|
if (stats?.messages && stats.messages.length > 0) {
|
||||||
|
log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first image in any node's output
|
||||||
|
for (const nodeId in outputs) {
|
||||||
|
const nodeOutput = outputs[nodeId]
|
||||||
|
if (nodeOutput.images && nodeOutput.images.length > 0) {
|
||||||
|
const img = nodeOutput.images[0]
|
||||||
|
const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`
|
||||||
|
log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`)
|
||||||
|
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
|
||||||
|
historyDone = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job finished but no images
|
||||||
|
if (!historyDone) {
|
||||||
|
log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2))
|
||||||
|
this.updateStatus(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Job completed but no images were generated',
|
||||||
|
actionLabel: 'Error: No images generated',
|
||||||
|
})
|
||||||
|
historyDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
const errorMessage = error.message || String(error)
|
||||||
|
log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`)
|
||||||
|
this.updateStatus(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
error: errorMessage,
|
||||||
|
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// Clean up callback and job result after completion to prevent memory leaks
|
||||||
|
setTimeout(() => {
|
||||||
|
this.callbacks.delete(jobId)
|
||||||
|
this.jobResults.delete(jobId)
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +258,7 @@ export class ComfyUIProvider implements ArtistryProvider {
|
|||||||
* Mirrors the logic from CUIPP's getComfyTemplate.js.
|
* Mirrors the logic from CUIPP's getComfyTemplate.js.
|
||||||
*/
|
*/
|
||||||
private applyOverrides(
|
private applyOverrides(
|
||||||
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> },
|
template: { workflow: Record<string, any>, exposedFields: Record<string, string[]> },
|
||||||
request: ArtistryRequest,
|
request: ArtistryRequest,
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
// Deep clone the workflow so we don't mutate the stored template
|
// Deep clone the workflow so we don't mutate the stored template
|
||||||
@@ -149,190 +338,36 @@ export class ComfyUIProvider implements ArtistryProvider {
|
|||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 30000) {
|
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||||
const controller = new AbortController()
|
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||||
const id = setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
clearTimeout(id)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
clearTimeout(id)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pollForResult(
|
private async uploadImage(base64Data: string): Promise<string> {
|
||||||
jobId: string,
|
// 1. Clean data URL prefix if present
|
||||||
template: { exposedFields: Record<string, string[]>, workflow: Record<string, any> },
|
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
|
||||||
request: ArtistryRequest,
|
const buffer = Buffer.from(base64, 'base64')
|
||||||
) {
|
|
||||||
this.updateStatus(jobId, { actionLabel: 'Preparing workflow...', status: 'running' })
|
|
||||||
|
|
||||||
try {
|
// 2. Prepare multipart form data
|
||||||
// 0. Handle potential image and prompt upload bidirectional flow
|
const formData = new FormData()
|
||||||
const extraStr = JSON.stringify(request.extra || {})
|
const fileName = `vhack_${Date.now()}.png`
|
||||||
const workflowStr = JSON.stringify(template.workflow || {})
|
|
||||||
const hasImagePlaceholder = extraStr.includes('{{IMAGE}}') || workflowStr.includes('{{IMAGE}}')
|
|
||||||
const hasPromptPlaceholder = extraStr.includes('{{PROMPT}}') || workflowStr.includes('{{PROMPT}}')
|
|
||||||
|
|
||||||
let uploadedImageName = ''
|
// Electron/Node 18+ fetch handles Blobs in FormData
|
||||||
if (hasImagePlaceholder && request.extra?.image) {
|
const blob = new Blob([buffer], { type: 'image/png' })
|
||||||
log.log(`[ComfyUI] Bidirectional flow detected. Uploading texture for job ${jobId}...`)
|
formData.append('image', blob, fileName)
|
||||||
this.updateStatus(jobId, { actionLabel: 'Uploading texture to ComfyUI...', status: 'running' })
|
formData.append('overwrite', 'true')
|
||||||
try {
|
|
||||||
uploadedImageName = await this.uploadImage(request.extra.image)
|
|
||||||
log.log(`[ComfyUI] Texture uploaded as: ${uploadedImageName}`)
|
|
||||||
}
|
|
||||||
catch (e: any) {
|
|
||||||
log.error(`[ComfyUI] Texture upload failed: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Apply overrides to the workflow template (standard injection)
|
const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
|
||||||
let resolvedPrompt = this.applyOverrides(template, request)
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}, 60000) // 1 minute timeout for uploads
|
||||||
|
|
||||||
// 2. Perform final placeholder resolution across the ENTIRE resolved prompt
|
if (!response.ok) {
|
||||||
if (hasImagePlaceholder || hasPromptPlaceholder) {
|
const error = await response.text()
|
||||||
log.log(`[ComfyUI] Performing final placeholder resolution for ${jobId}...`)
|
throw new Error(`ComfyUI upload failed: ${error}`)
|
||||||
const replacements: Record<string, string> = {
|
|
||||||
'{{PROMPT}}': request.prompt || '',
|
|
||||||
}
|
|
||||||
if (uploadedImageName) {
|
|
||||||
replacements['{{IMAGE}}'] = uploadedImageName
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPrompt = this.replacePlaceholders(resolvedPrompt, replacements)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.log(`[ComfyUI] Resolved prompt for ${jobId}:`, JSON.stringify(resolvedPrompt, null, 2))
|
|
||||||
|
|
||||||
// 2. POST /prompt to queue the workflow
|
|
||||||
this.updateStatus(jobId, { actionLabel: 'Queuing in ComfyUI...', status: 'running' })
|
|
||||||
|
|
||||||
let queueResp: Response
|
|
||||||
try {
|
|
||||||
queueResp = await this.fetchWithTimeout(`${this.serverUrl}/prompt`, {
|
|
||||||
body: JSON.stringify({ prompt: resolvedPrompt }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
method: 'POST',
|
|
||||||
}, 15000)
|
|
||||||
}
|
|
||||||
catch (e: any) {
|
|
||||||
throw new Error(`Cannot connect to ComfyUI at ${this.serverUrl}: ${e.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!queueResp.ok) {
|
|
||||||
const errorBody = await queueResp.text()
|
|
||||||
throw new Error(`Workflow error: ${errorBody.slice(0, 200)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueData = await queueResp.json()
|
|
||||||
const promptId = queueData.prompt_id
|
|
||||||
if (!promptId) {
|
|
||||||
throw new Error('ComfyUI returned no prompt_id')
|
|
||||||
}
|
|
||||||
|
|
||||||
log.log(`[ComfyUI] Queued prompt ${promptId} for job ${jobId}`)
|
|
||||||
this.updateStatus(jobId, { actionLabel: 'Generating...', status: 'running' })
|
|
||||||
|
|
||||||
// 3. Poll /history/{prompt_id} until completion
|
|
||||||
let historyDone = false
|
|
||||||
let attempt = 0
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
while (!historyDone) {
|
|
||||||
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS))
|
|
||||||
attempt++
|
|
||||||
|
|
||||||
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
|
|
||||||
throw new Error('Generation timed out after 5 minutes')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt % 3 === 0) {
|
|
||||||
log.log(`[ComfyUI] Polling history for ${promptId}... attempt ${attempt}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let histResp: Response
|
|
||||||
try {
|
|
||||||
histResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
|
||||||
}
|
|
||||||
catch (e: any) {
|
|
||||||
throw new Error(`ComfyUI disconnected during polling: ${e.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (histResp.ok) {
|
|
||||||
const histData = await histResp.json()
|
|
||||||
if (histData[promptId]) {
|
|
||||||
let outputs = histData[promptId].outputs
|
|
||||||
const stats = histData[promptId].status
|
|
||||||
|
|
||||||
// 3.1. Race condition protection: If outputs are missing, wait a beat and retry once
|
|
||||||
if ((!outputs || Object.keys(outputs).length === 0) && !historyDone) {
|
|
||||||
log.warn(`[ComfyUI] Job ${jobId} finished but outputs are empty. Retrying history in 1s...`)
|
|
||||||
await new Promise(r => setTimeout(r, 1000))
|
|
||||||
const retryResp = await this.fetchWithTimeout(`${this.serverUrl}/history/${promptId}`, {}, 10000)
|
|
||||||
if (retryResp.ok) {
|
|
||||||
const retryData = await retryResp.json()
|
|
||||||
if (retryData[promptId] && retryData[promptId].outputs) {
|
|
||||||
log.log(`[ComfyUI] Retry successful for ${jobId}. Managed to find outputs!`)
|
|
||||||
outputs = retryData[promptId].outputs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log raw history if no images found or if there are status messages
|
|
||||||
if (stats?.messages && stats.messages.length > 0) {
|
|
||||||
log.warn(`[ComfyUI] History messages for ${promptId}:`, stats.messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find first image in any node's output
|
|
||||||
for (const nodeId in outputs) {
|
|
||||||
const nodeOutput = outputs[nodeId]
|
|
||||||
if (nodeOutput.images && nodeOutput.images.length > 0) {
|
|
||||||
const img = nodeOutput.images[0]
|
|
||||||
const imageUrl = `${this.serverUrl}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`
|
|
||||||
log.log(`[ComfyUI] Generation complete for job ${jobId}. Image: ${imageUrl}`)
|
|
||||||
this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' })
|
|
||||||
historyDone = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job finished but no images
|
|
||||||
if (!historyDone) {
|
|
||||||
log.error(`[ComfyUI] Job finished for ${jobId} (Prompt ${promptId}) but no output images found. Raw History:`, JSON.stringify(histData[promptId], null, 2))
|
|
||||||
this.updateStatus(jobId, {
|
|
||||||
actionLabel: 'Error: No images generated',
|
|
||||||
error: 'Job completed but no images were generated',
|
|
||||||
status: 'failed',
|
|
||||||
})
|
|
||||||
historyDone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
const errorMessage = error.message || String(error)
|
|
||||||
log.error(`[ComfyUI] Generation failed for job ${jobId}: ${errorMessage}`)
|
|
||||||
this.updateStatus(jobId, {
|
|
||||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
|
||||||
error: errorMessage,
|
|
||||||
status: 'failed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Clean up callback and job result after completion to prevent memory leaks
|
|
||||||
setTimeout(() => {
|
|
||||||
this.callbacks.delete(jobId)
|
|
||||||
this.jobResults.delete(jobId)
|
|
||||||
}, 10000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return data.name // Returns the filename in ComfyUI's input folder
|
||||||
}
|
}
|
||||||
|
|
||||||
private replacePlaceholders(obj: any, replacements: Record<string, string>): any {
|
private replacePlaceholders(obj: any, replacements: Record<string, string>): any {
|
||||||
@@ -356,39 +391,4 @@ export class ComfyUIProvider implements ArtistryProvider {
|
|||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
|
||||||
this.jobResults.set(jobId, status)
|
|
||||||
const callback = this.callbacks.get(jobId)
|
|
||||||
if (callback)
|
|
||||||
callback(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async uploadImage(base64Data: string): Promise<string> {
|
|
||||||
// 1. Clean data URL prefix if present
|
|
||||||
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
|
|
||||||
const buffer = Buffer.from(base64, 'base64')
|
|
||||||
|
|
||||||
// 2. Prepare multipart form data
|
|
||||||
const formData = new FormData()
|
|
||||||
const fileName = `vhack_${Date.now()}.png`
|
|
||||||
|
|
||||||
// Electron/Node 18+ fetch handles Blobs in FormData
|
|
||||||
const blob = new Blob([buffer], { type: 'image/png' })
|
|
||||||
formData.append('image', blob, fileName)
|
|
||||||
formData.append('overwrite', 'true')
|
|
||||||
|
|
||||||
const response = await this.fetchWithTimeout(`${this.serverUrl}/upload/image`, {
|
|
||||||
body: formData,
|
|
||||||
method: 'POST',
|
|
||||||
}, 60000) // 1 minute timeout for uploads
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text()
|
|
||||||
throw new Error(`ComfyUI upload failed: ${error}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return data.name // Returns the filename in ComfyUI's input folder
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,34 @@ export class NanoBananaProvider implements ArtistryProvider {
|
|||||||
readonly id = 'nanobanana'
|
readonly id = 'nanobanana'
|
||||||
readonly name = 'Nano Banana (Google AI Studio)'
|
readonly name = 'Nano Banana (Google AI Studio)'
|
||||||
private apiKey = ''
|
private apiKey = ''
|
||||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
|
||||||
private defaultModel = 'gemini-1.5-flash'
|
private defaultModel = 'gemini-1.5-flash'
|
||||||
|
|
||||||
private defaultResolution = '1K'
|
private defaultResolution = '1K'
|
||||||
|
|
||||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||||
|
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||||
|
|
||||||
|
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||||
|
this.callbacks.set(jobId, callback)
|
||||||
|
const result = this.jobResults.get(jobId)
|
||||||
|
if (result)
|
||||||
|
callback(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||||
|
this.jobResults.set(jobId, status)
|
||||||
|
const callback = this.callbacks.get(jobId)
|
||||||
|
if (callback)
|
||||||
|
callback(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(config: any) {
|
||||||
|
this.apiKey = config.nanobananaApiKey || config.apiKey || ''
|
||||||
|
if (config.nanobananaModel)
|
||||||
|
this.defaultModel = config.nanobananaModel
|
||||||
|
if (config.nanobananaResolution)
|
||||||
|
this.defaultResolution = config.nanobananaResolution
|
||||||
|
log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
@@ -36,43 +59,23 @@ export class NanoBananaProvider implements ArtistryProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
|
||||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(config: any) {
|
|
||||||
this.apiKey = config.nanobananaApiKey || config.apiKey || ''
|
|
||||||
if (config.nanobananaModel)
|
|
||||||
this.defaultModel = config.nanobananaModel
|
|
||||||
if (config.nanobananaResolution)
|
|
||||||
this.defaultResolution = config.nanobananaResolution
|
|
||||||
log.log(`[Nano Banana] Initialized. API Key present: ${!!this.apiKey}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
|
||||||
this.callbacks.set(jobId, callback)
|
|
||||||
const result = this.jobResults.get(jobId)
|
|
||||||
if (result)
|
|
||||||
callback(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) {
|
private async runGeneration(jobId: string, model: string, resolution: string, prompt: string, base64Image: string) {
|
||||||
this.updateStatus(jobId, { actionLabel: 'Inscribing with Nano Banana...', status: 'running' })
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Inscribing with Nano Banana...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}`
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.apiKey}`
|
||||||
const generationParts: any[] = [{ text: prompt }]
|
const generationParts: any[] = [{ text: prompt }]
|
||||||
if (base64Image) {
|
if (base64Image) {
|
||||||
generationParts.push({ inline_data: { data: base64Image, mime_type: 'image/jpeg' } })
|
generationParts.push({ inline_data: { mime_type: 'image/jpeg', data: base64Image } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
contents: [{ parts: generationParts }],
|
contents: [{ parts: generationParts }],
|
||||||
generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } },
|
generationConfig: { imageConfig: { aspectRatio: '1:1', imageSize: resolution } },
|
||||||
}),
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
method: 'POST',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
@@ -87,7 +90,7 @@ export class NanoBananaProvider implements ArtistryProvider {
|
|||||||
|
|
||||||
if (inlineData?.data) {
|
if (inlineData?.data) {
|
||||||
const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`
|
const dataUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`
|
||||||
this.updateStatus(jobId, { imageUrl: dataUrl, progress: 100, status: 'succeeded' })
|
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl: dataUrl })
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw new Error('No image data returned from Nano Banana')
|
throw new Error('No image data returned from Nano Banana')
|
||||||
@@ -95,7 +98,7 @@ export class NanoBananaProvider implements ArtistryProvider {
|
|||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
log.error(`[Nano Banana] Generation failed: ${e.message}`)
|
log.error(`[Nano Banana] Generation failed: ${e.message}`)
|
||||||
this.updateStatus(jobId, { error: e.message, status: 'failed' })
|
this.updateStatus(jobId, { status: 'failed', error: e.message })
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
// Clean up callback and job result after completion to prevent memory leaks
|
// Clean up callback and job result after completion to prevent memory leaks
|
||||||
@@ -106,10 +109,7 @@ export class NanoBananaProvider implements ArtistryProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||||
this.jobResults.set(jobId, status)
|
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||||
const callback = this.callbacks.get(jobId)
|
|
||||||
if (callback)
|
|
||||||
callback(status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,44 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
readonly name = 'Replicate.ai (Cloud)'
|
readonly name = 'Replicate.ai (Cloud)'
|
||||||
|
|
||||||
private apiKey = ''
|
private apiKey = ''
|
||||||
private aspectRatio = '16:9'
|
|
||||||
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
|
||||||
private defaultModel = 'black-forest-labs/flux-schnell'
|
private defaultModel = 'black-forest-labs/flux-schnell'
|
||||||
|
private aspectRatio = '16:9'
|
||||||
private inferenceSteps = 4
|
private inferenceSteps = 4
|
||||||
|
private replicate: Replicate | null = null
|
||||||
|
|
||||||
private jobResults = new Map<string, ArtistryJobStatus>()
|
private jobResults = new Map<string, ArtistryJobStatus>()
|
||||||
private replicate: null | Replicate = null
|
private callbacks = new Map<string, (status: ArtistryJobStatus) => void>()
|
||||||
|
|
||||||
|
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
||||||
|
this.callbacks.set(jobId, callback)
|
||||||
|
const result = this.jobResults.get(jobId)
|
||||||
|
if (result)
|
||||||
|
callback(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
||||||
|
this.jobResults.set(jobId, status)
|
||||||
|
const callback = this.callbacks.get(jobId)
|
||||||
|
if (callback)
|
||||||
|
callback(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(config: any): Promise<void> {
|
||||||
|
if (config?.replicateApiKey) {
|
||||||
|
this.apiKey = config.replicateApiKey
|
||||||
|
this.replicate = new Replicate({ auth: this.apiKey })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.apiKey = ''
|
||||||
|
this.replicate = null
|
||||||
|
}
|
||||||
|
if (config?.replicateDefaultModel)
|
||||||
|
this.defaultModel = config.replicateDefaultModel
|
||||||
|
if (config?.replicateAspectRatio)
|
||||||
|
this.aspectRatio = config.replicateAspectRatio
|
||||||
|
if (config?.replicateInferenceSteps)
|
||||||
|
this.inferenceSteps = config.replicateInferenceSteps
|
||||||
|
}
|
||||||
|
|
||||||
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
async generate(request: ArtistryRequest): Promise<ArtistryJob> {
|
||||||
if (!this.replicate) {
|
if (!this.replicate) {
|
||||||
@@ -30,11 +61,11 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
// 1. Start with defaults
|
// 1. Start with defaults
|
||||||
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
|
const hasPromptPlaceholder = JSON.stringify(request.extra).includes('{{PROMPT}}')
|
||||||
let inputOptions: Record<string, any> = {
|
let inputOptions: Record<string, any> = {
|
||||||
aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
|
|
||||||
go_fast: request.extra?.go_fast ?? true,
|
go_fast: request.extra?.go_fast ?? true,
|
||||||
num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps,
|
aspect_ratio: request.extra?.aspect_ratio ?? this.aspectRatio,
|
||||||
output_format: request.extra?.output_format ?? 'png',
|
output_format: request.extra?.output_format ?? 'png',
|
||||||
output_quality: request.extra?.output_quality ?? 80,
|
output_quality: request.extra?.output_quality ?? 80,
|
||||||
|
num_inference_steps: request.extra?.num_inference_steps ?? this.inferenceSteps,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default prompt injection if NO placeholder is used in overrides
|
// Default prompt injection if NO placeholder is used in overrides
|
||||||
@@ -96,36 +127,8 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
return { jobId, providerJobId: jobId }
|
return { jobId, providerJobId: jobId }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
|
||||||
return this.jobResults.get(jobId) || { status: 'queued' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(config: any): Promise<void> {
|
|
||||||
if (config?.replicateApiKey) {
|
|
||||||
this.apiKey = config.replicateApiKey
|
|
||||||
this.replicate = new Replicate({ auth: this.apiKey })
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.apiKey = ''
|
|
||||||
this.replicate = null
|
|
||||||
}
|
|
||||||
if (config?.replicateDefaultModel)
|
|
||||||
this.defaultModel = config.replicateDefaultModel
|
|
||||||
if (config?.replicateAspectRatio)
|
|
||||||
this.aspectRatio = config.replicateAspectRatio
|
|
||||||
if (config?.replicateInferenceSteps)
|
|
||||||
this.inferenceSteps = config.replicateInferenceSteps
|
|
||||||
}
|
|
||||||
|
|
||||||
setJobCallback(jobId: string, callback: (status: ArtistryJobStatus) => void) {
|
|
||||||
this.callbacks.set(jobId, callback)
|
|
||||||
const result = this.jobResults.get(jobId)
|
|
||||||
if (result)
|
|
||||||
callback(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) {
|
private async runGeneration(jobId: string, model: `${string}/${string}`, input: object) {
|
||||||
this.updateStatus(jobId, { actionLabel: 'Requesting cloud generation...', status: 'running' })
|
this.updateStatus(jobId, { status: 'running', actionLabel: 'Requesting cloud generation...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await this.replicate!.run(model, { input })
|
const output = await this.replicate!.run(model, { input })
|
||||||
@@ -157,7 +160,7 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
|
|
||||||
if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) {
|
if (imageUrl && (imageUrl.startsWith('http') || imageUrl.startsWith('data:'))) {
|
||||||
log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`)
|
log.log(`[Replicate] EXTRACTED IMAGE: ${imageUrl.startsWith('data:') ? 'DATA_URL' : imageUrl}`)
|
||||||
this.updateStatus(jobId, { imageUrl, progress: 100, status: 'succeeded' })
|
this.updateStatus(jobId, { status: 'succeeded', progress: 100, imageUrl })
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`)
|
log.error(`[Replicate] Failed to extract URL from output: ${JSON.stringify(first)}`)
|
||||||
@@ -172,9 +175,9 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error))
|
const errorMessage = error.message || (typeof error === 'object' ? JSON.stringify(error) : String(error))
|
||||||
log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`)
|
log.error(`[Replicate] Generation Failed for ${jobId}: ${errorMessage}`)
|
||||||
this.updateStatus(jobId, {
|
this.updateStatus(jobId, {
|
||||||
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
|
||||||
error: errorMessage,
|
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
error: errorMessage,
|
||||||
|
actionLabel: `Error: ${errorMessage.slice(0, 50)}${errorMessage.length > 50 ? '...' : ''}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -186,17 +189,14 @@ export class ReplicateProvider implements ArtistryProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStatus(jobId: string): Promise<ArtistryJobStatus> {
|
||||||
|
return this.jobResults.get(jobId) || { status: 'queued' }
|
||||||
|
}
|
||||||
|
|
||||||
private truncatePrompt(prompt: string, maxChars: number = 380): string {
|
private truncatePrompt(prompt: string, maxChars: number = 380): string {
|
||||||
if (prompt.length <= maxChars)
|
if (prompt.length <= maxChars)
|
||||||
return prompt
|
return prompt
|
||||||
log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`)
|
log.log(`[Replicate] Truncating prompt from ${prompt.length} to ${maxChars} chars.`)
|
||||||
return `${prompt.slice(0, maxChars)}...`
|
return `${prompt.slice(0, maxChars)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(jobId: string, status: ArtistryJobStatus) {
|
|
||||||
this.jobResults.set(jobId, status)
|
|
||||||
const callback = this.callbacks.get(jobId)
|
|
||||||
if (callback)
|
|
||||||
callback(status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,26 +12,26 @@ describe('widget invoke validation', () => {
|
|||||||
describe('validateWidgetsAddPayload', () => {
|
describe('validateWidgetsAddPayload', () => {
|
||||||
it('normalizes add payloads for the widgets manager', () => {
|
it('normalizes add payloads for the widgets manager', () => {
|
||||||
expect(validateWidgetsAddPayload({
|
expect(validateWidgetsAddPayload({
|
||||||
alwaysOnTop: true,
|
id: ' widget-1 ',
|
||||||
componentName: ' weather ',
|
componentName: ' weather ',
|
||||||
componentProps: { city: 'Tokyo' },
|
componentProps: { city: 'Tokyo' },
|
||||||
id: ' widget-1 ',
|
alwaysOnTop: true,
|
||||||
ttlMs: 2500.9,
|
ttlMs: 2500.9,
|
||||||
windowSize: {
|
windowSize: {
|
||||||
|
width: 620.8,
|
||||||
height: 480.2,
|
height: 480.2,
|
||||||
minWidth: 320.9,
|
minWidth: 320.9,
|
||||||
width: 620.8,
|
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
alwaysOnTop: true,
|
id: 'widget-1',
|
||||||
componentName: 'weather',
|
componentName: 'weather',
|
||||||
componentProps: { city: 'Tokyo' },
|
componentProps: { city: 'Tokyo' },
|
||||||
id: 'widget-1',
|
alwaysOnTop: true,
|
||||||
ttlMs: 2500,
|
ttlMs: 2500,
|
||||||
windowSize: {
|
windowSize: {
|
||||||
|
width: 620,
|
||||||
height: 480,
|
height: 480,
|
||||||
minWidth: 320,
|
minWidth: 320,
|
||||||
width: 620,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -53,12 +53,12 @@ describe('widget invoke validation', () => {
|
|||||||
|
|
||||||
expect(() => validateWidgetsAddPayload({
|
expect(() => validateWidgetsAddPayload({
|
||||||
componentName: 'weather',
|
componentName: 'weather',
|
||||||
windowSize: { height: 320, width: 0 },
|
windowSize: { width: 0, height: 320 },
|
||||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||||
|
|
||||||
expect(() => validateWidgetsAddPayload({
|
expect(() => validateWidgetsAddPayload({
|
||||||
alwaysOnTop: 'yes' as any,
|
|
||||||
componentName: 'weather',
|
componentName: 'weather',
|
||||||
|
alwaysOnTop: 'yes' as any,
|
||||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -66,14 +66,14 @@ describe('widget invoke validation', () => {
|
|||||||
describe('validateWidgetsUpdatePayload', () => {
|
describe('validateWidgetsUpdatePayload', () => {
|
||||||
it('normalizes widget updates and keeps optional fields optional', () => {
|
it('normalizes widget updates and keeps optional fields optional', () => {
|
||||||
expect(validateWidgetsUpdatePayload({
|
expect(validateWidgetsUpdatePayload({
|
||||||
alwaysOnTop: false,
|
|
||||||
componentProps: { city: 'Taipei' },
|
|
||||||
id: ' widget-1 ',
|
id: ' widget-1 ',
|
||||||
|
componentProps: { city: 'Taipei' },
|
||||||
|
alwaysOnTop: false,
|
||||||
ttlMs: 1500.4,
|
ttlMs: 1500.4,
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
alwaysOnTop: false,
|
|
||||||
componentProps: { city: 'Taipei' },
|
|
||||||
id: 'widget-1',
|
id: 'widget-1',
|
||||||
|
componentProps: { city: 'Taipei' },
|
||||||
|
alwaysOnTop: false,
|
||||||
ttlMs: 1500,
|
ttlMs: 1500,
|
||||||
windowSize: undefined,
|
windowSize: undefined,
|
||||||
})
|
})
|
||||||
@@ -85,18 +85,18 @@ describe('widget invoke validation', () => {
|
|||||||
} as any)).toThrow('id is required to update a widget.')
|
} as any)).toThrow('id is required to update a widget.')
|
||||||
|
|
||||||
expect(() => validateWidgetsUpdatePayload({
|
expect(() => validateWidgetsUpdatePayload({
|
||||||
componentProps: [] as any,
|
|
||||||
id: 'widget-1',
|
id: 'widget-1',
|
||||||
|
componentProps: [] as any,
|
||||||
})).toThrow('componentProps must be a plain object.')
|
})).toThrow('componentProps must be a plain object.')
|
||||||
|
|
||||||
expect(() => validateWidgetsUpdatePayload({
|
expect(() => validateWidgetsUpdatePayload({
|
||||||
id: 'widget-1',
|
id: 'widget-1',
|
||||||
windowSize: { height: 400, width: Number.NaN },
|
windowSize: { width: Number.NaN, height: 400 },
|
||||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||||
|
|
||||||
expect(() => validateWidgetsUpdatePayload({
|
expect(() => validateWidgetsUpdatePayload({
|
||||||
alwaysOnTop: 'yes' as any,
|
|
||||||
id: 'widget-1',
|
id: 'widget-1',
|
||||||
|
alwaysOnTop: 'yes' as any,
|
||||||
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
})).toThrow('alwaysOnTop must be a boolean when provided.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -117,28 +117,28 @@ describe('widget invoke validation', () => {
|
|||||||
it('normalizes successful iframe request results', () => {
|
it('normalizes successful iframe request results', () => {
|
||||||
expect(validateWidgetIframeRequestResult({
|
expect(validateWidgetIframeRequestResult({
|
||||||
id: ' kit-module:board ',
|
id: ' kit-module:board ',
|
||||||
ok: true,
|
|
||||||
requestId: ' req-1 ',
|
requestId: ' req-1 ',
|
||||||
|
ok: true,
|
||||||
result: { fen: 'fen-after-request' },
|
result: { fen: 'fen-after-request' },
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: true,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: true,
|
||||||
result: { fen: 'fen-after-request' },
|
result: { fen: 'fen-after-request' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes failed iframe request results', () => {
|
it('normalizes failed iframe request results', () => {
|
||||||
expect(validateWidgetIframeRequestResult({
|
expect(validateWidgetIframeRequestResult({
|
||||||
error: 'Board rejected request.',
|
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: false,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: false,
|
||||||
|
error: 'Board rejected request.',
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
error: 'Board rejected request.',
|
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: false,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: false,
|
||||||
|
error: 'Board rejected request.',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,13 +146,13 @@ describe('widget invoke validation', () => {
|
|||||||
expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.')
|
expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.')
|
||||||
expect(() => validateWidgetIframeRequestResult({
|
expect(() => validateWidgetIframeRequestResult({
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: true,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: true,
|
||||||
})).toThrow('iframe request result payload must be a plain object.')
|
})).toThrow('iframe request result payload must be a plain object.')
|
||||||
expect(() => validateWidgetIframeRequestResult({
|
expect(() => validateWidgetIframeRequestResult({
|
||||||
id: 'kit-module:board',
|
id: 'kit-module:board',
|
||||||
ok: false,
|
|
||||||
requestId: 'req-1',
|
requestId: 'req-1',
|
||||||
|
ok: false,
|
||||||
})).toThrow('iframe request result error is required.')
|
})).toThrow('iframe request result error is required.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,19 +8,126 @@ import { isPlainObject } from 'es-toolkit'
|
|||||||
|
|
||||||
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
|
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
|
||||||
|
|
||||||
|
function normalizeWidgetId(value?: string): string | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
const normalized = value.trim()
|
||||||
|
return normalized || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTtlMs(ttlMs?: number): number {
|
||||||
|
if (ttlMs === undefined)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if (!Number.isFinite(ttlMs) || ttlMs < 0)
|
||||||
|
throw new Error('ttlMs must be a non-negative finite number.')
|
||||||
|
|
||||||
|
return Math.floor(ttlMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComponentProps(componentProps?: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (componentProps === undefined)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if (!isPlainObject(componentProps))
|
||||||
|
throw new Error('componentProps must be a plain object.')
|
||||||
|
|
||||||
|
return componentProps
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
|
||||||
|
if (value === undefined)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
if (typeof value !== 'boolean')
|
||||||
|
throw new Error(`${fieldName} must be a boolean when provided.`)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes optional widget ids for open/prepare operations.
|
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
|
||||||
*
|
*
|
||||||
* Before:
|
* Use when:
|
||||||
* - `" widget-1 "`
|
* - `defineInvokeHandler(...)` receives a widgets add request from a renderer
|
||||||
* - `""`
|
|
||||||
*
|
*
|
||||||
* After:
|
* Expects:
|
||||||
* - `"widget-1"`
|
* - `componentName` is a non-empty string
|
||||||
* - `undefined`
|
* - `componentProps`, when provided, is a plain object
|
||||||
|
* - `alwaysOnTop`, when provided, is a boolean
|
||||||
|
* - `ttlMs`, when provided, is a non-negative finite number
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A normalized payload safe to pass into the widgets manager
|
||||||
*/
|
*/
|
||||||
export function normalizeOptionalWidgetId(id?: string): string | undefined {
|
export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload {
|
||||||
return normalizeWidgetId(id)
|
if (!payload)
|
||||||
|
throw new Error('widgets.add requires a payload.')
|
||||||
|
|
||||||
|
const componentName = payload.componentName?.trim()
|
||||||
|
if (!componentName)
|
||||||
|
throw new Error('componentName is required to spawn a widget.')
|
||||||
|
|
||||||
|
const normalizedWindowSize = payload.windowSize === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeWidgetWindowSize(payload.windowSize)
|
||||||
|
|
||||||
|
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
||||||
|
throw new Error('windowSize must contain a positive finite width and height.')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
id: normalizeWidgetId(payload.id),
|
||||||
|
componentName,
|
||||||
|
componentProps: normalizeComponentProps(payload.componentProps),
|
||||||
|
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
||||||
|
ttlMs: normalizeTtlMs(payload.ttlMs),
|
||||||
|
windowSize: normalizedWindowSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and normalizes widget update payloads at the Electron invoke boundary.
|
||||||
|
*
|
||||||
|
* Use when:
|
||||||
|
* - `defineInvokeHandler(...)` receives a widgets update request from a renderer
|
||||||
|
*
|
||||||
|
* Expects:
|
||||||
|
* - `id` is a non-empty string after trimming
|
||||||
|
* - `componentProps`, when provided, is a plain object
|
||||||
|
* - `alwaysOnTop`, when provided, is a boolean
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - A normalized payload safe to pass into the widgets manager
|
||||||
|
*/
|
||||||
|
export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload {
|
||||||
|
if (!payload)
|
||||||
|
throw new Error('widgets.update requires a payload.')
|
||||||
|
|
||||||
|
const id = normalizeWidgetId(payload.id)
|
||||||
|
if (!id)
|
||||||
|
throw new Error('id is required to update a widget.')
|
||||||
|
|
||||||
|
const normalizedWindowSize = payload.windowSize === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeWidgetWindowSize(payload.windowSize)
|
||||||
|
|
||||||
|
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
||||||
|
throw new Error('windowSize must contain a positive finite width and height.')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
id,
|
||||||
|
componentProps: payload.componentProps === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeComponentProps(payload.componentProps),
|
||||||
|
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
||||||
|
ttlMs: payload.ttlMs === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeTtlMs(payload.ttlMs),
|
||||||
|
windowSize: normalizedWindowSize,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +150,21 @@ export function normalizeRequiredWidgetId(id?: string, reason = 'id is required.
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes optional widget ids for open/prepare operations.
|
||||||
|
*
|
||||||
|
* Before:
|
||||||
|
* - `" widget-1 "`
|
||||||
|
* - `""`
|
||||||
|
*
|
||||||
|
* After:
|
||||||
|
* - `"widget-1"`
|
||||||
|
* - `undefined`
|
||||||
|
*/
|
||||||
|
export function normalizeOptionalWidgetId(id?: string): string | undefined {
|
||||||
|
return normalizeWidgetId(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates iframe-published widget events at the Electron invoke boundary.
|
* Validates iframe-published widget events at the Electron invoke boundary.
|
||||||
*
|
*
|
||||||
@@ -99,8 +221,8 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
ok: true,
|
|
||||||
requestId,
|
requestId,
|
||||||
|
ok: true,
|
||||||
result: result.result,
|
result: result.result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,134 +233,12 @@ export function validateWidgetIframeRequestResult(result: unknown): WidgetsIfram
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: result.error,
|
|
||||||
id,
|
id,
|
||||||
ok: false,
|
|
||||||
requestId,
|
requestId,
|
||||||
|
ok: false,
|
||||||
|
error: result.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('iframe request result ok must be a boolean.')
|
throw new Error('iframe request result ok must be a boolean.')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - `defineInvokeHandler(...)` receives a widgets add request from a renderer
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `componentName` is a non-empty string
|
|
||||||
* - `componentProps`, when provided, is a plain object
|
|
||||||
* - `alwaysOnTop`, when provided, is a boolean
|
|
||||||
* - `ttlMs`, when provided, is a non-negative finite number
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A normalized payload safe to pass into the widgets manager
|
|
||||||
*/
|
|
||||||
export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload {
|
|
||||||
if (!payload)
|
|
||||||
throw new Error('widgets.add requires a payload.')
|
|
||||||
|
|
||||||
const componentName = payload.componentName?.trim()
|
|
||||||
if (!componentName)
|
|
||||||
throw new Error('componentName is required to spawn a widget.')
|
|
||||||
|
|
||||||
const normalizedWindowSize = payload.windowSize === undefined
|
|
||||||
? undefined
|
|
||||||
: normalizeWidgetWindowSize(payload.windowSize)
|
|
||||||
|
|
||||||
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
|
||||||
throw new Error('windowSize must contain a positive finite width and height.')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
|
||||||
componentName,
|
|
||||||
componentProps: normalizeComponentProps(payload.componentProps),
|
|
||||||
id: normalizeWidgetId(payload.id),
|
|
||||||
ttlMs: normalizeTtlMs(payload.ttlMs),
|
|
||||||
windowSize: normalizedWindowSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates and normalizes widget update payloads at the Electron invoke boundary.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - `defineInvokeHandler(...)` receives a widgets update request from a renderer
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `id` is a non-empty string after trimming
|
|
||||||
* - `componentProps`, when provided, is a plain object
|
|
||||||
* - `alwaysOnTop`, when provided, is a boolean
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A normalized payload safe to pass into the widgets manager
|
|
||||||
*/
|
|
||||||
export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload {
|
|
||||||
if (!payload)
|
|
||||||
throw new Error('widgets.update requires a payload.')
|
|
||||||
|
|
||||||
const id = normalizeWidgetId(payload.id)
|
|
||||||
if (!id)
|
|
||||||
throw new Error('id is required to update a widget.')
|
|
||||||
|
|
||||||
const normalizedWindowSize = payload.windowSize === undefined
|
|
||||||
? undefined
|
|
||||||
: normalizeWidgetWindowSize(payload.windowSize)
|
|
||||||
|
|
||||||
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
|
||||||
throw new Error('windowSize must contain a positive finite width and height.')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'),
|
|
||||||
componentProps: payload.componentProps === undefined
|
|
||||||
? undefined
|
|
||||||
: normalizeComponentProps(payload.componentProps),
|
|
||||||
id,
|
|
||||||
ttlMs: payload.ttlMs === undefined
|
|
||||||
? undefined
|
|
||||||
: normalizeTtlMs(payload.ttlMs),
|
|
||||||
windowSize: normalizedWindowSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeComponentProps(componentProps?: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
if (componentProps === undefined)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if (!isPlainObject(componentProps))
|
|
||||||
throw new Error('componentProps must be a plain object.')
|
|
||||||
|
|
||||||
return componentProps
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined {
|
|
||||||
if (value === undefined)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
if (typeof value !== 'boolean')
|
|
||||||
throw new Error(`${fieldName} must be a boolean when provided.`)
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTtlMs(ttlMs?: number): number {
|
|
||||||
if (ttlMs === undefined)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if (!Number.isFinite(ttlMs) || ttlMs < 0)
|
|
||||||
throw new Error('ttlMs must be a non-negative finite number.')
|
|
||||||
|
|
||||||
return Math.floor(ttlMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWidgetId(value?: string): string | undefined {
|
|
||||||
if (!value)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
const normalized = value.trim()
|
|
||||||
return normalized || undefined
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const appMock = vi.hoisted(() => ({
|
const appMock = vi.hoisted(() => ({
|
||||||
getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
|
|
||||||
getVersion: vi.fn(() => '0.9.0-beta.4'),
|
getVersion: vi.fn(() => '0.9.0-beta.4'),
|
||||||
isPackaged: false,
|
getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
|
isPackaged: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isDevState = vi.hoisted(() => ({
|
const isDevState = vi.hoisted(() => ({
|
||||||
@@ -21,16 +21,16 @@ const updaterState = vi.hoisted(() => ({
|
|||||||
|
|
||||||
function createUpdaterMock() {
|
function createUpdaterMock() {
|
||||||
return {
|
return {
|
||||||
allowPrerelease: false,
|
on: vi.fn(),
|
||||||
autoDownload: true,
|
autoDownload: true,
|
||||||
|
allowPrerelease: false,
|
||||||
channel: undefined as string | undefined,
|
channel: undefined as string | undefined,
|
||||||
|
logger: undefined as any,
|
||||||
|
forceDevUpdateConfig: false,
|
||||||
|
setFeedURL: vi.fn(),
|
||||||
checkForUpdates: vi.fn().mockResolvedValue(undefined),
|
checkForUpdates: vi.fn().mockResolvedValue(undefined),
|
||||||
downloadUpdate: vi.fn().mockResolvedValue(undefined),
|
downloadUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
forceDevUpdateConfig: false,
|
|
||||||
logger: undefined as any,
|
|
||||||
on: vi.fn(),
|
|
||||||
quitAndInstall: vi.fn(),
|
quitAndInstall: vi.fn(),
|
||||||
setFeedURL: vi.fn(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ vi.mock('std-env', () => ({
|
|||||||
vi.mock('@guiiai/logg', () => ({
|
vi.mock('@guiiai/logg', () => ({
|
||||||
useLogg: () => ({
|
useLogg: () => ({
|
||||||
useGlobalConfig: () => ({
|
useGlobalConfig: () => ({
|
||||||
debug: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
withError: () => ({
|
withError: () => ({
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@@ -82,35 +82,35 @@ describe('setupAutoUpdater', () => {
|
|||||||
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
|
const expectedChannelByArch = process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
|
||||||
|
|
||||||
const laneReleaseTagMap = {
|
const laneReleaseTagMap = {
|
||||||
alpha: 'v0.9.11-alpha.4',
|
|
||||||
beta: 'v0.9.10-beta.3',
|
|
||||||
latest: 'v0.9.12-nightly.7',
|
latest: 'v0.9.12-nightly.7',
|
||||||
nightly: 'v0.9.12-nightly.7',
|
|
||||||
stable: 'v0.9.9',
|
stable: 'v0.9.9',
|
||||||
|
beta: 'v0.9.10-beta.3',
|
||||||
|
alpha: 'v0.9.11-alpha.4',
|
||||||
|
nightly: 'v0.9.12-nightly.7',
|
||||||
} as const
|
} as const
|
||||||
const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const
|
const bundleVersions = ['0.9.0', '0.9.0-beta.4', '0.9.0-alpha.2'] as const
|
||||||
const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const
|
const laneMatrix = ['latest', 'stable', 'beta', 'alpha', 'nightly'] as const
|
||||||
|
|
||||||
const defaultReleases = [
|
const defaultReleases = [
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' },
|
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
|
||||||
]
|
]
|
||||||
const matrixReleases = [
|
const matrixReleases = [
|
||||||
{ draft: false, prerelease: false, tag_name: 'v0.9.7' },
|
{ tag_name: 'v0.9.7', draft: false, prerelease: false },
|
||||||
{ draft: false, prerelease: false, tag_name: 'v0.9.9' },
|
{ tag_name: 'v0.9.9', draft: false, prerelease: false },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.9-beta.1' },
|
{ tag_name: 'v0.9.9-beta.1', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.10-beta.3' },
|
{ tag_name: 'v0.9.10-beta.3', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.10-alpha.5' },
|
{ tag_name: 'v0.9.10-alpha.5', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.11-alpha.4' },
|
{ tag_name: 'v0.9.11-alpha.4', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.11-nightly.1' },
|
{ tag_name: 'v0.9.11-nightly.1', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.12-nightly.7' },
|
{ tag_name: 'v0.9.12-nightly.7', draft: false, prerelease: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
function mockGitHubReleasesFetch(releases = defaultReleases) {
|
function mockGitHubReleasesFetch(releases = defaultReleases) {
|
||||||
const fetchSpy = vi.fn().mockResolvedValue({
|
const fetchSpy = vi.fn().mockResolvedValue({
|
||||||
json: async () => releases,
|
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
|
json: async () => releases,
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchSpy)
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
return fetchSpy
|
return fetchSpy
|
||||||
@@ -131,8 +131,8 @@ describe('setupAutoUpdater', () => {
|
|||||||
|
|
||||||
it('resolves release tag from GitHub API and configures generic provider for checks', async () => {
|
it('resolves release tag from GitHub API and configures generic provider for checks', async () => {
|
||||||
const fetchSpy = mockGitHubReleasesFetch([
|
const fetchSpy = mockGitHubReleasesFetch([
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' },
|
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.5' },
|
{ tag_name: 'v0.9.0-beta.5', draft: false, prerelease: true },
|
||||||
])
|
])
|
||||||
const { setupAutoUpdater } = await import('./auto-updater')
|
const { setupAutoUpdater } = await import('./auto-updater')
|
||||||
const service = setupAutoUpdater()
|
const service = setupAutoUpdater()
|
||||||
@@ -198,9 +198,9 @@ describe('setupAutoUpdater', () => {
|
|||||||
it('supports explicit stable lane selection for future dynamic channel switching', async () => {
|
it('supports explicit stable lane selection for future dynamic channel switching', async () => {
|
||||||
process.env.AIRI_UPDATE_CHANNEL = 'stable'
|
process.env.AIRI_UPDATE_CHANNEL = 'stable'
|
||||||
mockGitHubReleasesFetch([
|
mockGitHubReleasesFetch([
|
||||||
{ draft: false, prerelease: true, tag_name: 'v0.9.0-beta.6' },
|
{ tag_name: 'v0.9.0-beta.6', draft: false, prerelease: true },
|
||||||
{ draft: false, prerelease: false, tag_name: 'v0.8.9' },
|
{ tag_name: 'v0.8.9', draft: false, prerelease: false },
|
||||||
{ draft: false, prerelease: false, tag_name: 'v0.8.8' },
|
{ tag_name: 'v0.8.8', draft: false, prerelease: false },
|
||||||
])
|
])
|
||||||
|
|
||||||
const { setupAutoUpdater } = await import('./auto-updater')
|
const { setupAutoUpdater } = await import('./auto-updater')
|
||||||
@@ -274,12 +274,12 @@ describe('setupAutoUpdater', () => {
|
|||||||
const service = setupAutoUpdater()
|
const service = setupAutoUpdater()
|
||||||
|
|
||||||
expect(service.state.diagnostics).toEqual(expect.objectContaining({
|
expect(service.state.diagnostics).toEqual(expect.objectContaining({
|
||||||
|
platform: process.platform,
|
||||||
arch: process.arch,
|
arch: process.arch,
|
||||||
channel: expectedChannelByArch,
|
channel: expectedChannelByArch,
|
||||||
executablePath: expect.any(String),
|
executablePath: expect.any(String),
|
||||||
isOverrideActive: false,
|
|
||||||
logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/),
|
logFilePath: expect.stringMatching(/stage-tamagotchi-updater[\\/]updater-log\.txt$/),
|
||||||
platform: process.platform,
|
isOverrideActive: false,
|
||||||
}))
|
}))
|
||||||
expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir')
|
expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir')
|
||||||
expect(service.state.diagnostics).not.toHaveProperty('pendingDir')
|
expect(service.state.diagnostics).not.toHaveProperty('pendingDir')
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ function getCacheRoot() {
|
|||||||
|
|
||||||
function getLegacyCacheRoot() {
|
function getLegacyCacheRoot() {
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case 'darwin':
|
|
||||||
return join(process.env.HOME || '', 'Library', 'Caches')
|
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local')
|
return process.env.LOCALAPPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Local')
|
||||||
|
case 'darwin':
|
||||||
|
return join(process.env.HOME || '', 'Library', 'Caches')
|
||||||
default:
|
default:
|
||||||
return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache')
|
return process.env.XDG_CACHE_HOME || join(process.env.HOME || '', '.cache')
|
||||||
}
|
}
|
||||||
@@ -63,30 +63,199 @@ const UPDATER_LOG_FILE = join(UPDATER_DEBUG_CACHE_DIR, 'updater-log.txt')
|
|||||||
const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater')
|
const OFFICIAL_UPDATER_CACHE_DIR = join(getCacheRoot(), 'ai.moeru.airi-updater')
|
||||||
const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater')
|
const LEGACY_OFFICIAL_UPDATER_CACHE_DIR = join(getLegacyCacheRoot(), 'ai.moeru.airi-updater')
|
||||||
const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([
|
const OFFICIAL_UPDATER_CACHE_DIRS = Array.from(new Set([
|
||||||
LEGACY_OFFICIAL_UPDATER_CACHE_DIR,
|
|
||||||
OFFICIAL_UPDATER_CACHE_DIR,
|
OFFICIAL_UPDATER_CACHE_DIR,
|
||||||
|
LEGACY_OFFICIAL_UPDATER_CACHE_DIR,
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
async function logToFile(level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG', message: string) {
|
||||||
|
await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
|
||||||
|
await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupStaleUpdateFiles() {
|
||||||
|
// Remove both current and legacy updater cache roots so stale installers do not linger.
|
||||||
|
await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { recursive: true, force: true })))
|
||||||
|
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateLane = ElectronUpdaterChannel
|
||||||
|
interface GitHubReleaseRecord {
|
||||||
|
tag_name?: string
|
||||||
|
draft?: boolean
|
||||||
|
prerelease?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateServerOverride() {
|
||||||
|
// NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness.
|
||||||
|
// Production update routing must not depend on this variable.
|
||||||
|
if (!is.dev)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
const value = process.env.UPDATE_SERVER_URL?.trim()
|
||||||
|
return value || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLane(value: string | undefined): UpdateLane | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
switch (value.toLowerCase()) {
|
||||||
|
case 'stable':
|
||||||
|
case 'latest':
|
||||||
|
case 'alpha':
|
||||||
|
case 'beta':
|
||||||
|
case 'nightly':
|
||||||
|
case 'canary':
|
||||||
|
return value.toLowerCase() as UpdateLane
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFromVersion(version: string): UpdateLane {
|
||||||
|
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
|
||||||
|
return normalizeLane(prerelease) ?? 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredUpdateLane(params: { version: string, storedLane?: UpdateLane }): UpdateLane {
|
||||||
|
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSemverFromTag(tag: string) {
|
||||||
|
return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTagInLane(tag: string, lane: UpdateLane) {
|
||||||
|
const version = getSemverFromTag(tag)
|
||||||
|
if (!version)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (lane === 'latest')
|
||||||
|
return true
|
||||||
|
|
||||||
|
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
|
||||||
|
if (lane === 'stable')
|
||||||
|
return !prerelease
|
||||||
|
|
||||||
|
return prerelease === lane
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parentPath: string, targetPath: string) {
|
||||||
|
const normalizedParent = normalize(parentPath)
|
||||||
|
const normalizedTarget = normalize(targetPath)
|
||||||
|
const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\`
|
||||||
|
return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsProtectedInstallRoots() {
|
||||||
|
return [
|
||||||
|
process.env.ProgramFiles,
|
||||||
|
process.env['ProgramFiles(x86)'],
|
||||||
|
process.env.ProgramW6432,
|
||||||
|
process.env.SystemRoot,
|
||||||
|
process.env.windir,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.map(value => normalize(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresAdminForInstallPath(executablePath: string) {
|
||||||
|
if (!isWindows)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const installDirectory = dirname(executablePath)
|
||||||
|
return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
|
||||||
|
const candidates = releases
|
||||||
|
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
|
||||||
|
.map((release) => {
|
||||||
|
const tag = release.tag_name as string
|
||||||
|
const version = getSemverFromTag(tag)
|
||||||
|
return version ? { tag, version } : null
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ tag: string, version: string }>
|
||||||
|
|
||||||
|
candidates.sort((a, b) => semver.rcompare(a.version, b.version))
|
||||||
|
return candidates[0]?.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies.
|
||||||
|
*
|
||||||
|
* The current feed contains entries like:
|
||||||
|
* `<entry><link rel="alternate" type="text/html" href="https://github.com/moeru-ai/airi/releases/tag/v0.9.0-beta.6"/></entry>`
|
||||||
|
* and
|
||||||
|
* `<entry><id>tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36</id></entry>`
|
||||||
|
*
|
||||||
|
* We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links.
|
||||||
|
*/
|
||||||
|
function extractReleaseTagsFromAtom(atom: string) {
|
||||||
|
const tags: string[] = []
|
||||||
|
const marker = '/moeru-ai/airi/releases/tag/'
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (offset < atom.length) {
|
||||||
|
const markerIndex = atom.indexOf(marker, offset)
|
||||||
|
if (markerIndex === -1)
|
||||||
|
break
|
||||||
|
|
||||||
|
const start = markerIndex + marker.length
|
||||||
|
let end = start
|
||||||
|
while (end < atom.length) {
|
||||||
|
const char = atom[end]
|
||||||
|
if (char === '"' || char === '<' || char === '?' || char === '&')
|
||||||
|
break
|
||||||
|
end += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`.
|
||||||
|
const rawTag = atom.slice(start, end).trim()
|
||||||
|
// Atom encodes URLs, so decode in case future tags contain escaped characters.
|
||||||
|
const decodedTag = decodeURIComponent(rawTag)
|
||||||
|
// Feed entries can repeat across updates; keep a unique ordered tag list.
|
||||||
|
if (decodedTag && !tags.includes(decodedTag))
|
||||||
|
tags.push(decodedTag)
|
||||||
|
|
||||||
|
offset = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppUpdaterLike {
|
export interface AppUpdaterLike {
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => any
|
||||||
|
checkForUpdates: () => Promise<any>
|
||||||
|
downloadUpdate: () => Promise<any>
|
||||||
|
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise<void> | void
|
||||||
|
setFeedURL?: (options: { provider: 'generic', url: string }) => void
|
||||||
|
logger?: any
|
||||||
allowPrerelease?: boolean
|
allowPrerelease?: boolean
|
||||||
autoDownload?: boolean
|
autoDownload?: boolean
|
||||||
channel?: string
|
channel?: string
|
||||||
checkForUpdates: () => Promise<any>
|
|
||||||
downloadUpdate: () => Promise<any>
|
|
||||||
forceDevUpdateConfig?: boolean
|
forceDevUpdateConfig?: boolean
|
||||||
logger?: any
|
|
||||||
on: (event: string, listener: (...args: any[]) => void) => any
|
|
||||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise<void> | void
|
|
||||||
setFeedURL?: (options: { provider: 'generic', url: string }) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTICE: this part of code is copied from https://www.electron.build/auto-update
|
||||||
|
// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66
|
||||||
|
export function fromImported(): AppUpdaterLike {
|
||||||
|
if (is.dev && !getUpdateServerOverride())
|
||||||
|
return new MockAutoUpdater()
|
||||||
|
|
||||||
|
const { autoUpdater } = electronUpdater
|
||||||
|
return autoUpdater as unknown as AppUpdaterLike
|
||||||
|
}
|
||||||
|
|
||||||
|
type MainContext = ReturnType<typeof createContext>['context']
|
||||||
|
|
||||||
export interface AutoUpdater {
|
export interface AutoUpdater {
|
||||||
|
state: AutoUpdaterState
|
||||||
checkForUpdates: () => Promise<void>
|
checkForUpdates: () => Promise<void>
|
||||||
downloadUpdate: () => Promise<void>
|
downloadUpdate: () => Promise<void>
|
||||||
getPreferredUpdateLane: () => undefined | UpdateLane
|
|
||||||
quitAndInstall: () => Promise<void>
|
quitAndInstall: () => Promise<void>
|
||||||
setPreferredUpdateLane: (lane: undefined | UpdateLane) => Promise<void>
|
getPreferredUpdateLane: () => UpdateLane | undefined
|
||||||
state: AutoUpdaterState
|
setPreferredUpdateLane: (lane: UpdateLane | undefined) => Promise<void>
|
||||||
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
|
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,74 +267,42 @@ export interface AutoUpdaterOptions {
|
|||||||
*/
|
*/
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** Reads the release channel persisted by the application configuration. */
|
/** Reads the release channel persisted by the application configuration. */
|
||||||
getStoredUpdateLane?: () => undefined | UpdateLane
|
getStoredUpdateLane?: () => UpdateLane | undefined
|
||||||
/** Persists a release-channel change requested through updater IPC. */
|
/** Persists a release-channel change requested through updater IPC. */
|
||||||
setStoredUpdateLane?: (lane: undefined | UpdateLane) => void
|
setStoredUpdateLane?: (lane: UpdateLane | undefined) => void
|
||||||
}
|
|
||||||
export type UpdateLane = ElectronUpdaterChannel
|
|
||||||
|
|
||||||
interface GitHubReleaseRecord {
|
|
||||||
draft?: boolean
|
|
||||||
prerelease?: boolean
|
|
||||||
tag_name?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MainContext = ReturnType<typeof createContext>['context']
|
function isPrereleaseVersion(version: string) {
|
||||||
|
return (semver.prerelease(version)?.length ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
export function createAutoUpdaterService(params: { context: MainContext, service: AutoUpdater, window: BrowserWindow }) {
|
/**
|
||||||
const { context, service, window } = params
|
* Preserves the updater IPC contract when the storefront owns application updates.
|
||||||
|
*
|
||||||
|
* No method reaches Electron Updater or a release feed, while preference reads and
|
||||||
|
* subscriptions remain available to existing renderer consumers.
|
||||||
|
*/
|
||||||
|
function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater {
|
||||||
|
const state: AutoUpdaterState = { status: 'disabled' }
|
||||||
|
let storedPreferredLane = options.getStoredUpdateLane?.()
|
||||||
|
|
||||||
const log = useLogg('auto-updater-service').useGlobalConfig()
|
return {
|
||||||
|
state,
|
||||||
const unsubscribe = service.subscribe((state) => {
|
async checkForUpdates() {},
|
||||||
if (window.isDestroyed())
|
async downloadUpdate() {},
|
||||||
return
|
async quitAndInstall() {},
|
||||||
|
getPreferredUpdateLane() {
|
||||||
tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state))
|
return storedPreferredLane
|
||||||
})
|
},
|
||||||
|
async setPreferredUpdateLane(lane) {
|
||||||
const cleanups: Array<() => void> = [
|
storedPreferredLane = lane
|
||||||
unsubscribe,
|
options.setStoredUpdateLane?.(lane)
|
||||||
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
|
},
|
||||||
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
|
subscribe(callback) {
|
||||||
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
|
callback(state)
|
||||||
return service.state
|
return () => {}
|
||||||
}),
|
},
|
||||||
defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => {
|
|
||||||
await service.downloadUpdate()
|
|
||||||
return service.state
|
|
||||||
}),
|
|
||||||
defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({
|
|
||||||
channel: service.getPreferredUpdateLane(),
|
|
||||||
})),
|
|
||||||
defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => {
|
|
||||||
await service.setPreferredUpdateLane(payload?.channel)
|
|
||||||
return {
|
|
||||||
channel: service.getPreferredUpdateLane(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
|
|
||||||
await service.quitAndInstall()
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
for (const fn of cleanups)
|
|
||||||
fn()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.on('closed', cleanup)
|
|
||||||
return cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTICE: this part of code is copied from https://www.electron.build/auto-update
|
|
||||||
// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66
|
|
||||||
export function fromImported(): AppUpdaterLike {
|
|
||||||
if (is.dev && !getUpdateServerOverride())
|
|
||||||
return new MockAutoUpdater()
|
|
||||||
|
|
||||||
const { autoUpdater } = electronUpdater
|
|
||||||
return autoUpdater as unknown as AppUpdaterLike
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
|
export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater {
|
||||||
@@ -191,14 +328,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
autoUpdater.channel = releaseChannelName
|
autoUpdater.channel = releaseChannelName
|
||||||
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
|
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
|
||||||
autoUpdater.logger = {
|
autoUpdater.logger = {
|
||||||
debug: (message: string) => {
|
|
||||||
log.debug(message)
|
|
||||||
void logToFile('DEBUG', message)
|
|
||||||
},
|
|
||||||
error: (message: string) => {
|
|
||||||
log.error(message)
|
|
||||||
void logToFile('ERROR', message)
|
|
||||||
},
|
|
||||||
info: (message: string) => {
|
info: (message: string) => {
|
||||||
log.log(message)
|
log.log(message)
|
||||||
void logToFile('INFO', message)
|
void logToFile('INFO', message)
|
||||||
@@ -207,6 +336,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
log.warn(message)
|
log.warn(message)
|
||||||
void logToFile('WARN', message)
|
void logToFile('WARN', message)
|
||||||
},
|
},
|
||||||
|
error: (message: string) => {
|
||||||
|
log.error(message)
|
||||||
|
void logToFile('ERROR', message)
|
||||||
|
},
|
||||||
|
debug: (message: string) => {
|
||||||
|
log.debug(message)
|
||||||
|
void logToFile('DEBUG', message)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeFeedUrlOverride)
|
if (activeFeedUrlOverride)
|
||||||
@@ -215,14 +352,14 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
|
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
|
||||||
...next,
|
...next,
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
|
platform: process.platform,
|
||||||
arch: process.arch,
|
arch: process.arch,
|
||||||
channel: autoUpdater.channel || releaseChannelName,
|
channel: autoUpdater.channel || releaseChannelName,
|
||||||
|
logFilePath: UPDATER_LOG_FILE,
|
||||||
executablePath: process.execPath,
|
executablePath: process.execPath,
|
||||||
installDirectory: dirname(process.execPath),
|
installDirectory: dirname(process.execPath),
|
||||||
isOverrideActive: !!activeFeedUrlOverride,
|
|
||||||
logFilePath: UPDATER_LOG_FILE,
|
|
||||||
platform: process.platform,
|
|
||||||
requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath),
|
requiresAdminForInstallPath: requiresAdminForInstallPath(process.execPath),
|
||||||
|
isOverrideActive: !!activeFeedUrlOverride,
|
||||||
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
|
...(activeFeedUrlOverride ? { feedUrl: activeFeedUrlOverride } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -245,8 +382,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
|
|
||||||
function broadcastUpdaterError(error: unknown, reason: string) {
|
function broadcastUpdaterError(error: unknown, reason: string) {
|
||||||
broadcast({
|
broadcast({
|
||||||
error: { message: errorMessageFromValue(error) },
|
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
error: { message: errorMessageFromValue(error) },
|
||||||
})
|
})
|
||||||
log.withError(error).error(reason)
|
log.withError(error).error(reason)
|
||||||
}
|
}
|
||||||
@@ -315,7 +452,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepareFeedPromise = (async () => {
|
prepareFeedPromise = (async () => {
|
||||||
const preferredLane = getPreferredUpdateLane({ storedLane: storedPreferredLane, version: appVersion })
|
const preferredLane = getPreferredUpdateLane({ version: appVersion, storedLane: storedPreferredLane })
|
||||||
const tag = await resolveGitHubReleaseTagForLane(preferredLane)
|
const tag = await resolveGitHubReleaseTagForLane(preferredLane)
|
||||||
resolvedReleaseTag = tag
|
resolvedReleaseTag = tag
|
||||||
applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`)
|
applyGenericFeedOverride(`${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${tag}`, `github-release-lane:${preferredLane}`)
|
||||||
@@ -336,31 +473,34 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
|
|
||||||
autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error'))
|
autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater error'))
|
||||||
autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' }))
|
autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' }))
|
||||||
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ info, status: 'available' }))
|
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info }))
|
||||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ info, status: 'downloaded' }))
|
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info }))
|
||||||
autoUpdater.on('update-not-available', () => broadcast({
|
autoUpdater.on('update-not-available', () => broadcast({
|
||||||
|
status: 'not-available',
|
||||||
info: {
|
info: {
|
||||||
|
version: app.getVersion(),
|
||||||
files: [],
|
files: [],
|
||||||
releaseDate: committerDate,
|
releaseDate: committerDate,
|
||||||
version: app.getVersion(),
|
|
||||||
},
|
},
|
||||||
status: 'not-available',
|
|
||||||
}))
|
}))
|
||||||
autoUpdater.on('download-progress', progress => broadcast({
|
autoUpdater.on('download-progress', progress => broadcast({
|
||||||
...state,
|
...state,
|
||||||
progress: {
|
|
||||||
bytesPerSecond: progress.bytesPerSecond,
|
|
||||||
percent: progress.percent,
|
|
||||||
total: progress.total,
|
|
||||||
transferred: progress.transferred,
|
|
||||||
},
|
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
|
progress: {
|
||||||
|
percent: progress.percent,
|
||||||
|
bytesPerSecond: progress.bytesPerSecond,
|
||||||
|
transferred: progress.transferred,
|
||||||
|
total: progress.total,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
void checkForUpdatesWithPreparedFeed()
|
void checkForUpdatesWithPreparedFeed()
|
||||||
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
get state() {
|
||||||
|
return state
|
||||||
|
},
|
||||||
async checkForUpdates() {
|
async checkForUpdates() {
|
||||||
broadcast({ status: 'checking' })
|
broadcast({ status: 'checking' })
|
||||||
await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
await checkForUpdatesWithPreparedFeed().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
||||||
@@ -378,9 +518,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
semaphore.release()
|
semaphore.release()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getPreferredUpdateLane() {
|
|
||||||
return storedPreferredLane
|
|
||||||
},
|
|
||||||
async quitAndInstall() {
|
async quitAndInstall() {
|
||||||
await semaphore.acquire()
|
await semaphore.acquire()
|
||||||
|
|
||||||
@@ -394,6 +531,9 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
semaphore.release()
|
semaphore.release()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getPreferredUpdateLane() {
|
||||||
|
return storedPreferredLane
|
||||||
|
},
|
||||||
async setPreferredUpdateLane(lane) {
|
async setPreferredUpdateLane(lane) {
|
||||||
if (storedPreferredLane === lane)
|
if (storedPreferredLane === lane)
|
||||||
return
|
return
|
||||||
@@ -405,9 +545,6 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
// A fresh check runs right after channel update from renderer.
|
// A fresh check runs right after channel update from renderer.
|
||||||
broadcast({ status: 'idle' })
|
broadcast({ status: 'idle' })
|
||||||
},
|
},
|
||||||
get state() {
|
|
||||||
return state
|
|
||||||
},
|
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
hooks.add(callback)
|
hooks.add(callback)
|
||||||
|
|
||||||
@@ -423,185 +560,48 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupStaleUpdateFiles() {
|
export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) {
|
||||||
// Remove both current and legacy updater cache roots so stale installers do not linger.
|
const { context, window, service } = params
|
||||||
await Promise.allSettled(OFFICIAL_UPDATER_CACHE_DIRS.map(cacheDir => rm(cacheDir, { force: true, recursive: true })))
|
|
||||||
await logToFile('INFO', `Updater cache cleanup attempted: ${OFFICIAL_UPDATER_CACHE_DIRS.join(', ')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const log = useLogg('auto-updater-service').useGlobalConfig()
|
||||||
* Preserves the updater IPC contract when the storefront owns application updates.
|
|
||||||
*
|
|
||||||
* No method reaches Electron Updater or a release feed, while preference reads and
|
|
||||||
* subscriptions remain available to existing renderer consumers.
|
|
||||||
*/
|
|
||||||
function createDisabledAutoUpdater(options: AutoUpdaterOptions): AutoUpdater {
|
|
||||||
const state: AutoUpdaterState = { status: 'disabled' }
|
|
||||||
let storedPreferredLane = options.getStoredUpdateLane?.()
|
|
||||||
|
|
||||||
return {
|
const unsubscribe = service.subscribe((state) => {
|
||||||
async checkForUpdates() {},
|
if (window.isDestroyed())
|
||||||
async downloadUpdate() {},
|
return
|
||||||
getPreferredUpdateLane() {
|
|
||||||
return storedPreferredLane
|
|
||||||
},
|
|
||||||
async quitAndInstall() {},
|
|
||||||
async setPreferredUpdateLane(lane) {
|
|
||||||
storedPreferredLane = lane
|
|
||||||
options.setStoredUpdateLane?.(lane)
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
subscribe(callback) {
|
|
||||||
callback(state)
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
tryCatch(() => context.emit(electronAutoUpdaterStateChanged, state))
|
||||||
* Extract release tags from GitHub releases Atom feed without adding XML-parser dependencies.
|
})
|
||||||
*
|
|
||||||
* The current feed contains entries like:
|
|
||||||
* `<entry><link rel="alternate" type="text/html" href="https://github.com/moeru-ai/airi/releases/tag/v0.9.0-beta.6"/></entry>`
|
|
||||||
* and
|
|
||||||
* `<entry><id>tag:github.com,2008:Repository/963495975/v0.9.0-alpha.36</id></entry>`
|
|
||||||
*
|
|
||||||
* We intentionally scan for `/moeru-ai/airi/releases/tag/` so we only consume actual release tag links.
|
|
||||||
*/
|
|
||||||
function extractReleaseTagsFromAtom(atom: string) {
|
|
||||||
const tags: string[] = []
|
|
||||||
const marker = '/moeru-ai/airi/releases/tag/'
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
while (offset < atom.length) {
|
const cleanups: Array<() => void> = [
|
||||||
const markerIndex = atom.indexOf(marker, offset)
|
unsubscribe,
|
||||||
if (markerIndex === -1)
|
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
|
||||||
break
|
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
|
||||||
|
await service.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
|
||||||
const start = markerIndex + marker.length
|
return service.state
|
||||||
let end = start
|
}),
|
||||||
while (end < atom.length) {
|
defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => {
|
||||||
const char = atom[end]
|
await service.downloadUpdate()
|
||||||
if (char === '"' || char === '<' || char === '?' || char === '&')
|
return service.state
|
||||||
break
|
}),
|
||||||
end += 1
|
defineInvokeHandler(context, electronGetUpdaterPreferences, async () => ({
|
||||||
}
|
channel: service.getPreferredUpdateLane(),
|
||||||
|
})),
|
||||||
// Slice the raw path segment after the marker, e.g. `v0.9.0-beta.6`.
|
defineInvokeHandler(context, electronSetUpdaterPreferences, async (payload) => {
|
||||||
const rawTag = atom.slice(start, end).trim()
|
await service.setPreferredUpdateLane(payload?.channel)
|
||||||
// Atom encodes URLs, so decode in case future tags contain escaped characters.
|
return {
|
||||||
const decodedTag = decodeURIComponent(rawTag)
|
channel: service.getPreferredUpdateLane(),
|
||||||
// Feed entries can repeat across updates; keep a unique ordered tag list.
|
}
|
||||||
if (decodedTag && !tags.includes(decodedTag))
|
}),
|
||||||
tags.push(decodedTag)
|
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
|
||||||
|
await service.quitAndInstall()
|
||||||
offset = end + 1
|
}),
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPreferredUpdateLane(params: { storedLane?: UpdateLane, version: string }): UpdateLane {
|
|
||||||
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? params.storedLane ?? laneFromVersion(params.version)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSemverFromTag(tag: string) {
|
|
||||||
return semver.valid(tag) ?? semver.valid(tag.startsWith('v') ? tag.slice(1) : tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUpdateServerOverride() {
|
|
||||||
// NOTICE: UPDATE_SERVER_URL is intentionally development-only for local update-test harness.
|
|
||||||
// Production update routing must not depend on this variable.
|
|
||||||
if (!is.dev)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
const value = process.env.UPDATE_SERVER_URL?.trim()
|
|
||||||
return value || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWindowsProtectedInstallRoots() {
|
|
||||||
return [
|
|
||||||
process.env.ProgramFiles,
|
|
||||||
process.env['ProgramFiles(x86)'],
|
|
||||||
process.env.ProgramW6432,
|
|
||||||
process.env.SystemRoot,
|
|
||||||
process.env.windir,
|
|
||||||
]
|
]
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
.map(value => normalize(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPathInside(parentPath: string, targetPath: string) {
|
const cleanup = () => {
|
||||||
const normalizedParent = normalize(parentPath)
|
for (const fn of cleanups)
|
||||||
const normalizedTarget = normalize(targetPath)
|
fn()
|
||||||
const parentWithSeparator = normalizedParent.endsWith('\\') ? normalizedParent : `${normalizedParent}\\`
|
|
||||||
return normalizedTarget === normalizedParent || normalizedTarget.startsWith(parentWithSeparator)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrereleaseVersion(version: string) {
|
|
||||||
return (semver.prerelease(version)?.length ?? 0) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTagInLane(tag: string, lane: UpdateLane) {
|
|
||||||
const version = getSemverFromTag(tag)
|
|
||||||
if (!version)
|
|
||||||
return false
|
|
||||||
|
|
||||||
if (lane === 'latest')
|
|
||||||
return true
|
|
||||||
|
|
||||||
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
|
|
||||||
if (lane === 'stable')
|
|
||||||
return !prerelease
|
|
||||||
|
|
||||||
return prerelease === lane
|
|
||||||
}
|
|
||||||
|
|
||||||
function laneFromVersion(version: string): UpdateLane {
|
|
||||||
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
|
|
||||||
return normalizeLane(prerelease) ?? 'stable'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logToFile(level: 'DEBUG' | 'ERROR' | 'INFO' | 'WARN', message: string) {
|
|
||||||
await mkdir(UPDATER_DEBUG_CACHE_DIR, { recursive: true }).catch(() => {})
|
|
||||||
await appendFile(UPDATER_LOG_FILE, `${new Date().toISOString()} [${level}] ${message}\n`).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLane(value: string | undefined): undefined | UpdateLane {
|
|
||||||
if (!value)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
switch (value.toLowerCase()) {
|
|
||||||
case 'alpha':
|
|
||||||
case 'beta':
|
|
||||||
case 'canary':
|
|
||||||
case 'latest':
|
|
||||||
case 'nightly':
|
|
||||||
case 'stable':
|
|
||||||
return value.toLowerCase() as UpdateLane
|
|
||||||
default:
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function requiresAdminForInstallPath(executablePath: string) {
|
window.on('closed', cleanup)
|
||||||
if (!isWindows)
|
return cleanup
|
||||||
return false
|
|
||||||
|
|
||||||
const installDirectory = dirname(executablePath)
|
|
||||||
return getWindowsProtectedInstallRoots().some(root => isPathInside(root, installDirectory))
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectLatestTagForLane(releases: GitHubReleaseRecord[], lane: UpdateLane) {
|
|
||||||
const candidates = releases
|
|
||||||
.filter(release => !release.draft && typeof release.tag_name === 'string' && isTagInLane(release.tag_name, lane))
|
|
||||||
.map((release) => {
|
|
||||||
const tag = release.tag_name as string
|
|
||||||
const version = getSemverFromTag(tag)
|
|
||||||
return version ? { tag, version } : null
|
|
||||||
})
|
|
||||||
.filter(Boolean) as Array<{ tag: string, version: string }>
|
|
||||||
|
|
||||||
candidates.sort((a, b) => semver.rcompare(a.version, b.version))
|
|
||||||
return candidates[0]?.tag
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,6 @@ import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
|
|||||||
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
interface KeyboardEvent {
|
|
||||||
altKey: boolean
|
|
||||||
ctrlKey: boolean
|
|
||||||
keycode: number
|
|
||||||
metaKey: boolean
|
|
||||||
shiftKey: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>): KeyboardEvent {
|
|
||||||
return {
|
|
||||||
altKey: false,
|
|
||||||
ctrlKey: false,
|
|
||||||
metaKey: false,
|
|
||||||
shiftKey: false,
|
|
||||||
...partial,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a binding for the uiohook driver.
|
* Builds a binding for the uiohook driver.
|
||||||
*
|
*
|
||||||
@@ -35,10 +17,28 @@ function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>)
|
|||||||
*/
|
*/
|
||||||
function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding {
|
function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding {
|
||||||
return {
|
return {
|
||||||
accelerator: { key, modifiers },
|
|
||||||
id,
|
id,
|
||||||
receiveKeyUps: true,
|
accelerator: { modifiers, key },
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
|
receiveKeyUps: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyboardEvent {
|
||||||
|
keycode: number
|
||||||
|
altKey: boolean
|
||||||
|
ctrlKey: boolean
|
||||||
|
metaKey: boolean
|
||||||
|
shiftKey: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>): KeyboardEvent {
|
||||||
|
return {
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...partial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +78,8 @@ async function setupMocks() {
|
|||||||
// mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so
|
// mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so
|
||||||
// tests assert real keycodes, not arbitrary numbers).
|
// tests assert real keycodes, not arbitrary numbers).
|
||||||
const UiohookKey = {
|
const UiohookKey = {
|
||||||
A: 30,
|
|
||||||
K: 37,
|
K: 37,
|
||||||
|
A: 30,
|
||||||
Q: 16,
|
Q: 16,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -118,17 +118,17 @@ async function setupMocks() {
|
|||||||
platform: overrides.platform ?? 'darwin',
|
platform: overrides.platform ?? 'darwin',
|
||||||
sessionType: overrides.sessionType,
|
sessionType: overrides.sessionType,
|
||||||
})
|
})
|
||||||
return { broadcastTriggered, driver, logger }
|
return { driver, broadcastTriggered, logger }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createDriver,
|
|
||||||
fire,
|
|
||||||
isTrustedAccessibilityClientMock,
|
|
||||||
onMock,
|
onMock,
|
||||||
removeListenerMock,
|
removeListenerMock,
|
||||||
startMock,
|
startMock,
|
||||||
stopMock,
|
stopMock,
|
||||||
|
isTrustedAccessibilityClientMock,
|
||||||
|
fire,
|
||||||
|
createDriver,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('broadcasts a "down" event when a matching keydown arrives', async () => {
|
it('broadcasts a "down" event when a matching keydown arrives', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
||||||
@@ -189,7 +189,7 @@ describe('createUiohookDriver', () => {
|
|||||||
// would emit hundreds of `down` broadcasts per second and the mic
|
// would emit hundreds of `down` broadcasts per second and the mic
|
||||||
// would start/stop frantically.
|
// would start/stop frantically.
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
||||||
@@ -202,7 +202,7 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => {
|
it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
|
||||||
@@ -222,7 +222,7 @@ describe('createUiohookDriver', () => {
|
|||||||
// The driver keys the "up" broadcast off the prior `pressed`
|
// The driver keys the "up" broadcast off the prior `pressed`
|
||||||
// state rather than the modifier predicate.
|
// state rather than the modifier predicate.
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
||||||
@@ -234,7 +234,7 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('ignores keyup when no matching keydown was tracked', async () => {
|
it('ignores keyup when no matching keydown was tracked', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keyup', event({ keycode: 37, shiftKey: true }))
|
m.fire('keyup', event({ keycode: 37, shiftKey: true }))
|
||||||
@@ -246,21 +246,21 @@ describe('createUiohookDriver', () => {
|
|||||||
// Strict matching mirrors Electron's accelerator semantics: a
|
// Strict matching mirrors Electron's accelerator semantics: a
|
||||||
// `Shift+K` binding must not fire on `Cmd+Shift+K`.
|
// `Shift+K` binding must not fire on `Cmd+Shift+K`.
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, metaKey: true, shiftKey: true }))
|
m.fire('keydown', event({ keycode: 37, shiftKey: true, metaKey: true }))
|
||||||
|
|
||||||
expect(broadcastTriggered).not.toHaveBeenCalled()
|
expect(broadcastTriggered).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps cmd-or-ctrl to metaKey on darwin', async () => {
|
it('maps cmd-or-ctrl to metaKey on darwin', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver({ platform: 'darwin' })
|
const { driver, broadcastTriggered } = m.createDriver({ platform: 'darwin' })
|
||||||
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
||||||
m.fire('keydown', event({ ctrlKey: true, keycode: 37 }))
|
m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
|
||||||
|
|
||||||
expect(broadcastTriggered).toHaveBeenCalledTimes(1)
|
expect(broadcastTriggered).toHaveBeenCalledTimes(1)
|
||||||
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
|
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
|
||||||
@@ -268,10 +268,10 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => {
|
it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver({ platform: 'win32' })
|
const { driver, broadcastTriggered } = m.createDriver({ platform: 'win32' })
|
||||||
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
|
||||||
|
|
||||||
m.fire('keydown', event({ ctrlKey: true, keycode: 37 }))
|
m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
|
||||||
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
m.fire('keydown', event({ keycode: 37, metaKey: true }))
|
||||||
|
|
||||||
// First (ctrl) matches; second (meta) does not — the pressed
|
// First (ctrl) matches; second (meta) does not — the pressed
|
||||||
@@ -327,7 +327,7 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('unregisterAll clears every binding and stops the hook in one shot', async () => {
|
it('unregisterAll clears every binding and stops the hook in one shot', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
|
|
||||||
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
||||||
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
||||||
@@ -353,7 +353,7 @@ describe('createUiohookDriver', () => {
|
|||||||
|
|
||||||
it('keeps per-binding pressed state independent across multiple bindings', async () => {
|
it('keeps per-binding pressed state independent across multiple bindings', async () => {
|
||||||
const m = await setupMocks()
|
const m = await setupMocks()
|
||||||
const { broadcastTriggered, driver } = m.createDriver()
|
const { driver, broadcastTriggered } = m.createDriver()
|
||||||
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
|
||||||
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
|
||||||
|
|
||||||
|
|||||||
@@ -17,26 +17,128 @@ import { uIOhook, UiohookKey } from 'uiohook-napi'
|
|||||||
type Logger = ReturnType<ReturnType<typeof useLogg>['useGlobalConfig']>
|
type Logger = ReturnType<ReturnType<typeof useLogg>['useGlobalConfig']>
|
||||||
|
|
||||||
interface ModifierMask {
|
interface ModifierMask {
|
||||||
alt: boolean
|
|
||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
meta: boolean
|
|
||||||
shift: boolean
|
shift: boolean
|
||||||
|
alt: boolean
|
||||||
|
meta: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiohookEntry {
|
interface UiohookEntry {
|
||||||
binding: ShortcutBinding
|
binding: ShortcutBinding
|
||||||
expectedKeycode: number
|
|
||||||
predicate: (event: UiohookKeyboardEvent) => boolean
|
predicate: (event: UiohookKeyboardEvent) => boolean
|
||||||
|
expectedKeycode: number
|
||||||
pressed: boolean
|
pressed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const W3C_TO_UIOHOOK: Readonly<Record<ShortcutKey, number>> = buildKeycodeMap()
|
const W3C_TO_UIOHOOK: Readonly<Record<ShortcutKey, number>> = buildKeycodeMap()
|
||||||
|
|
||||||
export interface UiohookDriver {
|
function buildKeycodeMap(): Record<ShortcutKey, number> {
|
||||||
dispose: () => void
|
const map: Record<string, number> = {}
|
||||||
tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult
|
|
||||||
unregisterAll: () => void
|
for (let i = 0; i < 26; i++) {
|
||||||
unregisterById: (id: string) => void
|
const letter = String.fromCharCode(65 + i)
|
||||||
|
map[`Key${letter}`] = (UiohookKey as unknown as Record<string, number>)[letter]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= 9; i++) {
|
||||||
|
map[`Digit${i}`] = (UiohookKey as unknown as Record<string, number>)[String(i)]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 24; i++) {
|
||||||
|
map[`F${i}`] = (UiohookKey as unknown as Record<string, number>)[`F${i}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
const named: Record<string, keyof typeof UiohookKey> = {
|
||||||
|
Space: 'Space',
|
||||||
|
Tab: 'Tab',
|
||||||
|
Enter: 'Enter',
|
||||||
|
Escape: 'Escape',
|
||||||
|
Backspace: 'Backspace',
|
||||||
|
Delete: 'Delete',
|
||||||
|
Insert: 'Insert',
|
||||||
|
ArrowUp: 'ArrowUp',
|
||||||
|
ArrowDown: 'ArrowDown',
|
||||||
|
ArrowLeft: 'ArrowLeft',
|
||||||
|
ArrowRight: 'ArrowRight',
|
||||||
|
Home: 'Home',
|
||||||
|
End: 'End',
|
||||||
|
PageUp: 'PageUp',
|
||||||
|
PageDown: 'PageDown',
|
||||||
|
Backquote: 'Backquote',
|
||||||
|
Minus: 'Minus',
|
||||||
|
Equal: 'Equal',
|
||||||
|
BracketLeft: 'BracketLeft',
|
||||||
|
BracketRight: 'BracketRight',
|
||||||
|
Backslash: 'Backslash',
|
||||||
|
Semicolon: 'Semicolon',
|
||||||
|
Quote: 'Quote',
|
||||||
|
Comma: 'Comma',
|
||||||
|
Period: 'Period',
|
||||||
|
Slash: 'Slash',
|
||||||
|
}
|
||||||
|
for (const [w3c, uioName] of Object.entries(named))
|
||||||
|
map[w3c] = (UiohookKey as unknown as Record<string, number>)[uioName as string]
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask {
|
||||||
|
const mask: ModifierMask = { ctrl: false, shift: false, alt: false, meta: false }
|
||||||
|
for (const m of modifiers) {
|
||||||
|
switch (m) {
|
||||||
|
case 'cmd-or-ctrl':
|
||||||
|
if (platform === 'darwin')
|
||||||
|
mask.meta = true
|
||||||
|
else
|
||||||
|
mask.ctrl = true
|
||||||
|
break
|
||||||
|
case 'cmd':
|
||||||
|
case 'super':
|
||||||
|
// libuiohook surfaces macOS Cmd, Windows key, and X11 Super
|
||||||
|
// through the same `metaKey` flag.
|
||||||
|
mask.meta = true
|
||||||
|
break
|
||||||
|
case 'ctrl':
|
||||||
|
mask.ctrl = true
|
||||||
|
break
|
||||||
|
case 'alt':
|
||||||
|
mask.alt = true
|
||||||
|
break
|
||||||
|
case 'shift':
|
||||||
|
mask.shift = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): { predicate: UiohookEntry['predicate'], expectedKeycode: number } | undefined {
|
||||||
|
const expectedKeycode = W3C_TO_UIOHOOK[acc.key]
|
||||||
|
if (expectedKeycode === undefined)
|
||||||
|
return undefined
|
||||||
|
const required = resolveModifierMask(acc.modifiers, platform)
|
||||||
|
const predicate: UiohookEntry['predicate'] = e =>
|
||||||
|
e.keycode === expectedKeycode
|
||||||
|
&& e.ctrlKey === required.ctrl
|
||||||
|
&& e.shiftKey === required.shift
|
||||||
|
&& e.altKey === required.alt
|
||||||
|
&& e.metaKey === required.meta
|
||||||
|
return { predicate, expectedKeycode }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean {
|
||||||
|
return platform === 'linux' && sessionType === 'wayland'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean {
|
||||||
|
if (platform !== 'darwin')
|
||||||
|
return true
|
||||||
|
try {
|
||||||
|
return systemPreferences.isTrustedAccessibilityClient(prompt)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UiohookDriverOptions {
|
export interface UiohookDriverOptions {
|
||||||
@@ -58,6 +160,13 @@ export interface UiohookDriverOptions {
|
|||||||
sessionType?: string
|
sessionType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UiohookDriver {
|
||||||
|
tryRegister: (binding: ShortcutBinding) => ShortcutRegistrationResult
|
||||||
|
unregisterById: (id: string) => void
|
||||||
|
unregisterAll: () => void
|
||||||
|
dispose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driver that captures global key-down and key-up events via
|
* Driver that captures global key-down and key-up events via
|
||||||
* libuiohook (through `uiohook-napi`).
|
* libuiohook (through `uiohook-napi`).
|
||||||
@@ -173,8 +282,8 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
|
|||||||
|
|
||||||
entries.set(binding.id, {
|
entries.set(binding.id, {
|
||||||
binding,
|
binding,
|
||||||
expectedKeycode: built.expectedKeycode,
|
|
||||||
predicate: built.predicate,
|
predicate: built.predicate,
|
||||||
|
expectedKeycode: built.expectedKeycode,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
})
|
})
|
||||||
ensureListeners()
|
ensureListeners()
|
||||||
@@ -204,114 +313,5 @@ export function createUiohookDriver(options: UiohookDriverOptions): UiohookDrive
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dispose, tryRegister, unregisterAll, unregisterById }
|
return { tryRegister, unregisterById, unregisterAll, dispose }
|
||||||
}
|
|
||||||
|
|
||||||
function buildKeycodeMap(): Record<ShortcutKey, number> {
|
|
||||||
const map: Record<string, number> = {}
|
|
||||||
|
|
||||||
for (let i = 0; i < 26; i++) {
|
|
||||||
const letter = String.fromCharCode(65 + i)
|
|
||||||
map[`Key${letter}`] = (UiohookKey as unknown as Record<string, number>)[letter]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i <= 9; i++) {
|
|
||||||
map[`Digit${i}`] = (UiohookKey as unknown as Record<string, number>)[String(i)]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i <= 24; i++) {
|
|
||||||
map[`F${i}`] = (UiohookKey as unknown as Record<string, number>)[`F${i}`]
|
|
||||||
}
|
|
||||||
|
|
||||||
const named: Record<string, keyof typeof UiohookKey> = {
|
|
||||||
ArrowDown: 'ArrowDown',
|
|
||||||
ArrowLeft: 'ArrowLeft',
|
|
||||||
ArrowRight: 'ArrowRight',
|
|
||||||
ArrowUp: 'ArrowUp',
|
|
||||||
Backquote: 'Backquote',
|
|
||||||
Backslash: 'Backslash',
|
|
||||||
Backspace: 'Backspace',
|
|
||||||
BracketLeft: 'BracketLeft',
|
|
||||||
BracketRight: 'BracketRight',
|
|
||||||
Comma: 'Comma',
|
|
||||||
Delete: 'Delete',
|
|
||||||
End: 'End',
|
|
||||||
Enter: 'Enter',
|
|
||||||
Equal: 'Equal',
|
|
||||||
Escape: 'Escape',
|
|
||||||
Home: 'Home',
|
|
||||||
Insert: 'Insert',
|
|
||||||
Minus: 'Minus',
|
|
||||||
PageDown: 'PageDown',
|
|
||||||
PageUp: 'PageUp',
|
|
||||||
Period: 'Period',
|
|
||||||
Quote: 'Quote',
|
|
||||||
Semicolon: 'Semicolon',
|
|
||||||
Slash: 'Slash',
|
|
||||||
Space: 'Space',
|
|
||||||
Tab: 'Tab',
|
|
||||||
}
|
|
||||||
for (const [w3c, uioName] of Object.entries(named))
|
|
||||||
map[w3c] = (UiohookKey as unknown as Record<string, number>)[uioName as string]
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPredicate(acc: ShortcutAccelerator, platform: NodeJS.Platform): undefined | { expectedKeycode: number, predicate: UiohookEntry['predicate'] } {
|
|
||||||
const expectedKeycode = W3C_TO_UIOHOOK[acc.key]
|
|
||||||
if (expectedKeycode === undefined)
|
|
||||||
return undefined
|
|
||||||
const required = resolveModifierMask(acc.modifiers, platform)
|
|
||||||
const predicate: UiohookEntry['predicate'] = e =>
|
|
||||||
e.keycode === expectedKeycode
|
|
||||||
&& e.ctrlKey === required.ctrl
|
|
||||||
&& e.shiftKey === required.shift
|
|
||||||
&& e.altKey === required.alt
|
|
||||||
&& e.metaKey === required.meta
|
|
||||||
return { expectedKeycode, predicate }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMacAccessibilityTrusted(platform: NodeJS.Platform, prompt: boolean): boolean {
|
|
||||||
if (platform !== 'darwin')
|
|
||||||
return true
|
|
||||||
try {
|
|
||||||
return systemPreferences.isTrustedAccessibilityClient(prompt)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNativeWayland(platform: NodeJS.Platform, sessionType: string | undefined): boolean {
|
|
||||||
return platform === 'linux' && sessionType === 'wayland'
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModifierMask(modifiers: readonly ShortcutModifier[], platform: NodeJS.Platform): ModifierMask {
|
|
||||||
const mask: ModifierMask = { alt: false, ctrl: false, meta: false, shift: false }
|
|
||||||
for (const m of modifiers) {
|
|
||||||
switch (m) {
|
|
||||||
case 'alt':
|
|
||||||
mask.alt = true
|
|
||||||
break
|
|
||||||
case 'cmd':
|
|
||||||
case 'super':
|
|
||||||
// libuiohook surfaces macOS Cmd, Windows key, and X11 Super
|
|
||||||
// through the same `metaKey` flag.
|
|
||||||
mask.meta = true
|
|
||||||
break
|
|
||||||
case 'cmd-or-ctrl':
|
|
||||||
if (platform === 'darwin')
|
|
||||||
mask.meta = true
|
|
||||||
else
|
|
||||||
mask.ctrl = true
|
|
||||||
break
|
|
||||||
case 'ctrl':
|
|
||||||
mask.ctrl = true
|
|
||||||
break
|
|
||||||
case 'shift':
|
|
||||||
mask.shift = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mask
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,23 @@ import type { EventaContext } from './global-shortcut'
|
|||||||
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
accelerator: { modifiers: ['cmd-or-ctrl', 'shift'], key },
|
||||||
|
scope: 'global',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface MockContext {
|
interface MockContext {
|
||||||
emit: ReturnType<typeof vi.fn>
|
emit: ReturnType<typeof vi.fn>
|
||||||
invokeHandlers: Map<string, (payload: unknown) => unknown>
|
invokeHandlers: Map<string, (payload: unknown) => unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockWindow {
|
interface MockWindow {
|
||||||
|
on: ReturnType<typeof vi.fn>
|
||||||
/** Manually trigger the registered `closed` handler. */
|
/** Manually trigger the registered `closed` handler. */
|
||||||
close: () => void
|
close: () => void
|
||||||
on: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
|
|
||||||
function asBrowserWindow(window: MockWindow): BrowserWindow {
|
|
||||||
return window as unknown as BrowserWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTICE:
|
|
||||||
// MockContext / MockWindow are intentionally minimal — only what the
|
|
||||||
// driver touches. Casting through `unknown` lets us pass them to
|
|
||||||
// `service.registerWindow` whose typed signature wants the full
|
|
||||||
// `EventaContext` and `BrowserWindow` types.
|
|
||||||
function asEventaContext(ctx: MockContext): EventaContext {
|
|
||||||
return ctx as unknown as EventaContext
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(): MockContext {
|
function createMockContext(): MockContext {
|
||||||
@@ -44,22 +39,27 @@ function createMockContext(): MockContext {
|
|||||||
function createMockWindow(): MockWindow {
|
function createMockWindow(): MockWindow {
|
||||||
let closedHandler: (() => void) | undefined
|
let closedHandler: (() => void) | undefined
|
||||||
return {
|
return {
|
||||||
close() {
|
|
||||||
closedHandler?.()
|
|
||||||
},
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
if (event === 'closed')
|
if (event === 'closed')
|
||||||
closedHandler = handler
|
closedHandler = handler
|
||||||
}),
|
}),
|
||||||
|
close() {
|
||||||
|
closedHandler?.()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding {
|
// NOTICE:
|
||||||
return {
|
// MockContext / MockWindow are intentionally minimal — only what the
|
||||||
accelerator: { key, modifiers: ['cmd-or-ctrl', 'shift'] },
|
// driver touches. Casting through `unknown` lets us pass them to
|
||||||
id,
|
// `service.registerWindow` whose typed signature wants the full
|
||||||
scope: 'global',
|
// `EventaContext` and `BrowserWindow` types.
|
||||||
}
|
function asEventaContext(ctx: MockContext): EventaContext {
|
||||||
|
return ctx as unknown as EventaContext
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBrowserWindow(window: MockWindow): BrowserWindow {
|
||||||
|
return window as unknown as BrowserWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow {
|
function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow {
|
||||||
@@ -93,7 +93,7 @@ async function setupMocks() {
|
|||||||
triggerCallbacks.clear()
|
triggerCallbacks.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
const onAppBeforeQuitMock = vi.fn<(fn: () => Promise<void> | void) => void>()
|
const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise<void>) => void>()
|
||||||
|
|
||||||
vi.doMock('electron', () => ({
|
vi.doMock('electron', () => ({
|
||||||
globalShortcut: {
|
globalShortcut: {
|
||||||
@@ -108,10 +108,10 @@ async function setupMocks() {
|
|||||||
|
|
||||||
vi.doMock('./global-shortcut-uiohook', () => ({
|
vi.doMock('./global-shortcut-uiohook', () => ({
|
||||||
createUiohookDriver: () => ({
|
createUiohookDriver: () => ({
|
||||||
dispose: vi.fn(),
|
|
||||||
tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })),
|
tryRegister: vi.fn(async (binding: ShortcutBinding) => ({ id: binding.id, ok: true })),
|
||||||
unregisterAll: vi.fn(),
|
|
||||||
unregisterById: vi.fn(),
|
unregisterById: vi.fn(),
|
||||||
|
unregisterAll: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -144,12 +144,12 @@ async function setupMocks() {
|
|||||||
const { setupGlobalShortcutService } = await import('./global-shortcut')
|
const { setupGlobalShortcutService } = await import('./global-shortcut')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onAppBeforeQuitMock,
|
|
||||||
registerMock,
|
|
||||||
setupGlobalShortcutService,
|
setupGlobalShortcutService,
|
||||||
triggerCallbacks,
|
registerMock,
|
||||||
unregisterAllMock,
|
|
||||||
unregisterMock,
|
unregisterMock,
|
||||||
|
unregisterAllMock,
|
||||||
|
triggerCallbacks,
|
||||||
|
onAppBeforeQuitMock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ describe('setupGlobalShortcutService', () => {
|
|||||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||||
expect(() => reg({})).toThrow(TypeError)
|
expect(() => reg({})).toThrow(TypeError)
|
||||||
expect(() => reg({ id: 'no-accel' })).toThrow(TypeError)
|
expect(() => reg({ id: 'no-accel' })).toThrow(TypeError)
|
||||||
expect(() => reg({ accelerator: { key: 'KeyK', modifiers: [] } })).toThrow(TypeError)
|
expect(() => reg({ accelerator: { modifiers: [], key: 'KeyK' } })).toThrow(TypeError)
|
||||||
expect(m.registerMock).not.toHaveBeenCalled()
|
expect(m.registerMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
|
|||||||
|
|
||||||
export type EventaContext = ReturnType<typeof createContext>['context']
|
export type EventaContext = ReturnType<typeof createContext>['context']
|
||||||
|
|
||||||
export interface GlobalShortcutService {
|
export interface RegisterWindowParams {
|
||||||
dispose: () => void
|
context: EventaContext
|
||||||
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
|
window: BrowserWindow
|
||||||
registerWindow: (params: RegisterWindowParams) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterMainShortcutParams {
|
export interface RegisterMainShortcutParams {
|
||||||
@@ -31,15 +30,16 @@ export interface RegisterMainShortcutParams {
|
|||||||
onTriggered: () => void
|
onTriggered: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterWindowParams {
|
export interface GlobalShortcutService {
|
||||||
context: EventaContext
|
registerWindow: (params: RegisterWindowParams) => void
|
||||||
window: BrowserWindow
|
registerMainShortcut: (params: RegisterMainShortcutParams) => ShortcutRegistrationResult
|
||||||
|
dispose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveBinding
|
type ActiveBinding
|
||||||
= | { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, onTriggered: () => void, owner: 'main' }
|
= | { binding: ShortcutBinding, owner: 'renderer', driver: 'electron', electronAccelerator: string }
|
||||||
| { binding: ShortcutBinding, driver: 'electron', electronAccelerator: string, owner: 'renderer' }
|
| { binding: ShortcutBinding, owner: 'main', driver: 'electron', electronAccelerator: string, onTriggered: () => void }
|
||||||
| { binding: ShortcutBinding, driver: 'uiohook', owner: 'renderer' }
|
| { binding: ShortcutBinding, owner: 'renderer', driver: 'uiohook' }
|
||||||
|
|
||||||
export function setupGlobalShortcutService(): GlobalShortcutService {
|
export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||||
const log = useLogg('global-shortcut').useGlobalConfig()
|
const log = useLogg('global-shortcut').useGlobalConfig()
|
||||||
@@ -73,7 +73,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
|||||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
|
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
|
||||||
}
|
}
|
||||||
|
|
||||||
active.set(binding.id, { binding, driver: 'electron', electronAccelerator, owner: 'renderer' })
|
active.set(binding.id, { binding, owner: 'renderer', driver: 'electron', electronAccelerator })
|
||||||
return { id: binding.id, ok: true }
|
return { id: binding.id, ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
|||||||
|
|
||||||
const result = uiohookDriver.tryRegister(binding)
|
const result = uiohookDriver.tryRegister(binding)
|
||||||
if (result.ok)
|
if (result.ok)
|
||||||
active.set(binding.id, { binding, driver: 'uiohook', owner: 'renderer' })
|
active.set(binding.id, { binding, owner: 'renderer', driver: 'uiohook' })
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
|||||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
||||||
|
|
||||||
const electronAccelerator = formatElectronAccelerator(binding.accelerator)
|
const electronAccelerator = formatElectronAccelerator(binding.accelerator)
|
||||||
const nextEntry: ActiveBinding = { binding, driver: 'electron', electronAccelerator, onTriggered, owner: 'main' }
|
const nextEntry: ActiveBinding = { binding, owner: 'main', driver: 'electron', electronAccelerator, onTriggered }
|
||||||
if (existing?.electronAccelerator === electronAccelerator) {
|
if (existing?.electronAccelerator === electronAccelerator) {
|
||||||
releaseEntry(binding.id, existing)
|
releaseEntry(binding.id, existing)
|
||||||
if (globalShortcut.register(electronAccelerator, onTriggered)) {
|
if (globalShortcut.register(electronAccelerator, onTriggered)) {
|
||||||
@@ -146,7 +146,7 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
|||||||
active.delete(id)
|
active.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryRegister(binding: ShortcutBinding): Promise<ShortcutRegistrationResult> | ShortcutRegistrationResult {
|
function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult | Promise<ShortcutRegistrationResult> {
|
||||||
if (active.has(binding.id)) {
|
if (active.has(binding.id)) {
|
||||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
||||||
}
|
}
|
||||||
@@ -209,5 +209,5 @@ export function setupGlobalShortcutService(): GlobalShortcutService {
|
|||||||
|
|
||||||
onAppBeforeQuit(() => dispose())
|
onAppBeforeQuit(() => dispose())
|
||||||
|
|
||||||
return { dispose, registerMainShortcut, registerWindow }
|
return { registerWindow, registerMainShortcut, dispose }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,73 @@ import type { Session, WebContents } from 'electron'
|
|||||||
|
|
||||||
import { isLocalAppURL } from '../../libs/electron/url'
|
import { isLocalAppURL } from '../../libs/electron/url'
|
||||||
|
|
||||||
|
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
|
||||||
|
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
|
||||||
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
|
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
|
||||||
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
|
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
|
||||||
type LocalAppWebContents = Pick<WebContents, 'getURL'>
|
type LocalAppWebContents = Pick<WebContents, 'getURL'>
|
||||||
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
|
|
||||||
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
|
|
||||||
|
|
||||||
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
|
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
|
||||||
'clipboard-sanitized-write',
|
|
||||||
'display-capture',
|
'display-capture',
|
||||||
|
'clipboard-sanitized-write',
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the paired Electron session handlers required for complete permission policy.
|
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - Initializing Electron's default session after app readiness
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - The session is the one used by AIRI renderer windows
|
|
||||||
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
|
|
||||||
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Nothing; both handlers are installed on the supplied session
|
|
||||||
*/
|
*/
|
||||||
export function setupMediaPermissionHandlers(
|
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
|
||||||
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
return !!rawURL && rawURL !== 'null'
|
||||||
isDesktopCaptureAuthorized: () => boolean,
|
}
|
||||||
): void {
|
|
||||||
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
|
||||||
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
/**
|
||||||
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
|
* Checks whether Electron described an audio-only media permission operation.
|
||||||
})
|
*/
|
||||||
|
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
||||||
|
if (permission !== 'media' || !details)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if ('mediaTypes' in details && details.mediaTypes?.length) {
|
||||||
|
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'mediaType' in details && details.mediaType === 'audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether Electron described a desktop capture operation of any kind.
|
||||||
|
*
|
||||||
|
* Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to
|
||||||
|
* `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type
|
||||||
|
* at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like
|
||||||
|
* this, so the permission details alone cannot tell them apart.
|
||||||
|
* See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}.
|
||||||
|
*/
|
||||||
|
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
||||||
|
if (permission !== 'media' || !details)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return 'mediaTypes' in details && details.mediaTypes?.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether every requester identity supplied by Electron is local to AIRI.
|
||||||
|
*/
|
||||||
|
function shouldGrantLocalAppPermission(
|
||||||
|
webContents: LocalAppWebContents | null,
|
||||||
|
requestingOrigin?: string,
|
||||||
|
details?: ElectronPermissionDetails,
|
||||||
|
): boolean {
|
||||||
|
const requesterURLs = [
|
||||||
|
requestingOrigin,
|
||||||
|
details?.requestingUrl,
|
||||||
|
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
|
||||||
|
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
|
||||||
|
].filter(isUsableRequesterURL)
|
||||||
|
|
||||||
|
if (requesterURLs.length)
|
||||||
|
return requesterURLs.every(isLocalAppURL)
|
||||||
|
|
||||||
|
return isLocalAppURL(webContents?.getURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,59 +140,28 @@ export function shouldGrantElectronPermission(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether Electron described an audio-only media permission operation.
|
* Registers the paired Electron session handlers required for complete permission policy.
|
||||||
*/
|
|
||||||
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
|
||||||
if (permission !== 'media' || !details)
|
|
||||||
return false
|
|
||||||
|
|
||||||
if ('mediaTypes' in details && details.mediaTypes?.length) {
|
|
||||||
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'mediaType' in details && details.mediaType === 'audio'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether Electron described a desktop capture operation of any kind.
|
|
||||||
*
|
*
|
||||||
* Electron routes desktop capture through the `media` permission and only appends `audio` or `video` to
|
* Use when:
|
||||||
* `mediaTypes` for device capture, so desktop capture is the media operation that declares no media type
|
* - Initializing Electron's default session after app readiness
|
||||||
* at all. Both `getDisplayMedia()` and the legacy `chromeMediaSource: 'desktop'` constraint look like
|
*
|
||||||
* this, so the permission details alone cannot tell them apart.
|
* Expects:
|
||||||
* See {@link https://github.com/electron/electron/blob/v41.2.1/shell/browser/web_contents_permission_helper.cc#L249-L274}.
|
* - The session is the one used by AIRI renderer windows
|
||||||
|
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
|
||||||
|
* - `isDesktopCaptureAuthorized` reports whether a renderer already selected a capture source
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - Nothing; both handlers are installed on the supplied session
|
||||||
*/
|
*/
|
||||||
function isDesktopCaptureMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
|
export function setupMediaPermissionHandlers(
|
||||||
if (permission !== 'media' || !details)
|
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
|
||||||
return false
|
isDesktopCaptureAuthorized: () => boolean,
|
||||||
|
): void {
|
||||||
|
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
callback(shouldGrantElectronPermission(webContents, permission, undefined, details, isDesktopCaptureAuthorized))
|
||||||
|
})
|
||||||
|
|
||||||
return 'mediaTypes' in details && details.mediaTypes?.length === 0
|
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||||
}
|
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details, isDesktopCaptureAuthorized)
|
||||||
|
})
|
||||||
/**
|
|
||||||
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
|
|
||||||
*/
|
|
||||||
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
|
|
||||||
return !!rawURL && rawURL !== 'null'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether every requester identity supplied by Electron is local to AIRI.
|
|
||||||
*/
|
|
||||||
function shouldGrantLocalAppPermission(
|
|
||||||
webContents: LocalAppWebContents | null,
|
|
||||||
requestingOrigin?: string,
|
|
||||||
details?: ElectronPermissionDetails,
|
|
||||||
): boolean {
|
|
||||||
const requesterURLs = [
|
|
||||||
requestingOrigin,
|
|
||||||
details?.requestingUrl,
|
|
||||||
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
|
|
||||||
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
|
|
||||||
].filter(isUsableRequesterURL)
|
|
||||||
|
|
||||||
if (requesterURLs.length)
|
|
||||||
return requesterURLs.every(isLocalAppURL)
|
|
||||||
|
|
||||||
return isLocalAppURL(webContents?.getURL())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ export class MockAutoUpdater extends EventEmitter {
|
|||||||
// Simulate update available
|
// Simulate update available
|
||||||
// We can toggle this based on some logic if needed, but for now let's assume update is always available in mock
|
// We can toggle this based on some logic if needed, but for now let's assume update is always available in mock
|
||||||
const updateInfo = {
|
const updateInfo = {
|
||||||
|
version: '9.9.9-mock',
|
||||||
files: [],
|
files: [],
|
||||||
path: 'mock-path',
|
path: 'mock-path',
|
||||||
|
sha512: 'mock-sha',
|
||||||
releaseDate: new Date().toISOString(),
|
releaseDate: new Date().toISOString(),
|
||||||
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
|
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
|
||||||
sha512: 'mock-sha',
|
|
||||||
version: '9.9.9-mock',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update-available', updateInfo)
|
this.emit('update-available', updateInfo)
|
||||||
@@ -41,10 +41,10 @@ export class MockAutoUpdater extends EventEmitter {
|
|||||||
transferred = total
|
transferred = total
|
||||||
|
|
||||||
const progress = {
|
const progress = {
|
||||||
bytesPerSecond: speed,
|
|
||||||
percent: (transferred / total) * 100,
|
|
||||||
total,
|
total,
|
||||||
transferred,
|
transferred,
|
||||||
|
percent: (transferred / total) * 100,
|
||||||
|
bytesPerSecond: speed,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('download-progress', progress)
|
this.emit('download-progress', progress)
|
||||||
@@ -52,12 +52,12 @@ export class MockAutoUpdater extends EventEmitter {
|
|||||||
if (transferred >= total) {
|
if (transferred >= total) {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
this.emit('update-downloaded', {
|
this.emit('update-downloaded', {
|
||||||
|
version: '9.9.9-mock',
|
||||||
files: [],
|
files: [],
|
||||||
path: 'mock-path',
|
path: 'mock-path',
|
||||||
|
sha512: 'mock-sha',
|
||||||
releaseDate: new Date().toISOString(),
|
releaseDate: new Date().toISOString(),
|
||||||
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
|
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
|
||||||
sha512: 'mock-sha',
|
|
||||||
version: '9.9.9-mock',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecy
|
|||||||
|
|
||||||
export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
|
export function createScreenService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
|
||||||
const { start, stop } = createRendererLoop({
|
const { start, stop } = createRendererLoop({
|
||||||
|
window: params.window,
|
||||||
run: () => {
|
run: () => {
|
||||||
const dipPos = screen.getCursorScreenPoint()
|
const dipPos = screen.getCursorScreenPoint()
|
||||||
params.context.emit(cursorScreenPoint, dipPos)
|
params.context.emit(cursorScreenPoint, dipPos)
|
||||||
},
|
},
|
||||||
window: params.window,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onAppWindowAllClosed(() => stop())
|
onAppWindowAllClosed(() => stop())
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { start, stop } = createRendererLoop({
|
const { start, stop } = createRendererLoop({
|
||||||
|
window: params.window,
|
||||||
run: () => {
|
run: () => {
|
||||||
params.context.emit(bounds, params.window.getBounds())
|
params.context.emit(bounds, params.window.getBounds())
|
||||||
},
|
},
|
||||||
window: params.window,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onAppWindowAllClosed(() => stop())
|
onAppWindowAllClosed(() => stop())
|
||||||
@@ -61,10 +61,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height: 0,
|
|
||||||
width: 0,
|
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,10 +109,10 @@ export function createWindowService(params: { context: ReturnType<typeof createC
|
|||||||
}
|
}
|
||||||
|
|
||||||
resizeWindowByDelta({
|
resizeWindowByDelta({
|
||||||
|
window: params.window,
|
||||||
deltaX: payload.deltaX,
|
deltaX: payload.deltaX,
|
||||||
deltaY: payload.deltaY,
|
deltaY: payload.deltaY,
|
||||||
direction: payload.direction,
|
direction: payload.direction,
|
||||||
window: params.window,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,82 @@ const RECOMMENDED_WIDTH = 450
|
|||||||
const RECOMMENDED_HEIGHT = 600
|
const RECOMMENDED_HEIGHT = 600
|
||||||
const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT
|
const ASPECT_RATIO = RECOMMENDED_WIDTH / RECOMMENDED_HEIGHT
|
||||||
|
|
||||||
|
function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void {
|
||||||
|
if (isRendererUnavailable(window)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setResizable(true)
|
||||||
|
|
||||||
|
const bounds = x !== undefined && y !== undefined
|
||||||
|
? {
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
}
|
||||||
|
: computeResizedBoundsAnchoredToDominantDisplay({
|
||||||
|
currentBounds: window.getBounds(),
|
||||||
|
targetSize: { width, height },
|
||||||
|
displays: screen.getAllDisplays(),
|
||||||
|
})
|
||||||
|
|
||||||
|
window.setBounds(bounds)
|
||||||
|
window.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlignedWindowBounds(
|
||||||
|
window: BrowserWindow,
|
||||||
|
workArea: Rectangle,
|
||||||
|
position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
|
||||||
|
): Rectangle {
|
||||||
|
const { width: windowWidth, height: windowHeight } = window.getBounds()
|
||||||
|
const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = workArea
|
||||||
|
|
||||||
|
let x = areaX
|
||||||
|
let y = areaY
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'center':
|
||||||
|
x = areaX + Math.floor((areaWidth - windowWidth) / 2)
|
||||||
|
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
|
||||||
|
break
|
||||||
|
case 'top-left':
|
||||||
|
break
|
||||||
|
case 'top-right':
|
||||||
|
x = areaX + areaWidth - windowWidth
|
||||||
|
break
|
||||||
|
case 'bottom-left':
|
||||||
|
y = areaY + areaHeight - windowHeight
|
||||||
|
break
|
||||||
|
case 'bottom-right':
|
||||||
|
x = areaX + areaWidth - windowWidth
|
||||||
|
y = areaY + areaHeight - windowHeight
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y, width: windowWidth, height: windowHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
|
||||||
|
const { width, height } = window.getBounds()
|
||||||
|
return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean {
|
||||||
|
const { x, y } = window.getBounds()
|
||||||
|
return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5
|
||||||
|
}
|
||||||
|
|
||||||
export function setupTray(params: {
|
export function setupTray(params: {
|
||||||
aboutWindow: () => Promise<BrowserWindow>
|
|
||||||
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
|
|
||||||
captionWindow: ReturnType<typeof setupCaptionWindowManager>
|
|
||||||
i18n: I18n
|
|
||||||
mainWindow: BrowserWindow
|
mainWindow: BrowserWindow
|
||||||
serverChannel: ServerChannel
|
|
||||||
settingsWindow: SettingsWindowManager
|
settingsWindow: SettingsWindowManager
|
||||||
|
captionWindow: ReturnType<typeof setupCaptionWindowManager>
|
||||||
widgetsWindow: WidgetsWindowManager
|
widgetsWindow: WidgetsWindowManager
|
||||||
|
beatSyncBgWindow: Awaited<ReturnType<typeof setupBeatSync>>
|
||||||
|
aboutWindow: () => Promise<BrowserWindow>
|
||||||
|
serverChannel: ServerChannel
|
||||||
|
i18n: I18n
|
||||||
}): void {
|
}): void {
|
||||||
once(() => {
|
once(() => {
|
||||||
const mainWindowAnimator = new Animator(params.mainWindow)
|
const mainWindowAnimator = new Animator(params.mainWindow)
|
||||||
@@ -67,8 +134,8 @@ export function setupTray(params: {
|
|||||||
|
|
||||||
const mainWindowBounds = params.mainWindow.getBounds()
|
const mainWindowBounds = params.mainWindow.getBounds()
|
||||||
const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds)
|
const currentDisplay = findDominantDisplayArea(mainWindowBounds, screen.getAllDisplays()) ?? screen.getDisplayMatching(mainWindowBounds)
|
||||||
const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = currentDisplay.workArea
|
const { x: areaX, y: areaY, width: areaWidth, height: areaHeight } = currentDisplay.workArea
|
||||||
const { height: windowHeight, width: windowWidth } = mainWindowBounds
|
const { width: windowWidth, height: windowHeight } = mainWindowBounds
|
||||||
|
|
||||||
const fullHeightTarget = areaHeight
|
const fullHeightTarget = areaHeight
|
||||||
const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO)
|
const fullWidthTarget = Math.floor(areaHeight * ASPECT_RATIO)
|
||||||
@@ -76,34 +143,34 @@ export function setupTray(params: {
|
|||||||
const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO)
|
const halfWidthTarget = Math.floor(halfHeightTarget * ASPECT_RATIO)
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
{ click: () => toggleWindowShow(params.mainWindow), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.show'), click: () => toggleWindowShow(params.mainWindow) },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.adjust_sizes'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
|
|
||||||
click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.recommended_size'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isSizeMatch(params.mainWindow, RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
|
||||||
|
click: () => applyMainWindowSize(RECOMMENDED_WIDTH, RECOMMENDED_HEIGHT),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget),
|
|
||||||
click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_height'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isSizeMatch(params.mainWindow, fullWidthTarget, fullHeightTarget),
|
||||||
|
click: () => applyMainWindowSize(fullWidthTarget, fullHeightTarget),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget),
|
|
||||||
click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.half_height'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isSizeMatch(params.mainWindow, halfWidthTarget, halfHeightTarget),
|
||||||
|
click: () => applyMainWindowSize(halfWidthTarget, halfHeightTarget),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight),
|
|
||||||
click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.full_screen'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isSizeMatch(params.mainWindow, areaWidth, areaHeight),
|
||||||
|
click: () => applyMainWindowSize(areaWidth, areaHeight, areaX, areaY),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -111,69 +178,69 @@ export function setupTray(params: {
|
|||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.align_to'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)),
|
|
||||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'center'),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.center'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isPositionMatch(params.mainWindow, areaX + Math.floor((areaWidth - windowWidth) / 2), areaY + Math.floor((areaHeight - windowHeight) / 2)),
|
||||||
|
click: () => animateMainWindowTo(currentDisplay.workArea, 'center'),
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
checked: isPositionMatch(params.mainWindow, areaX, areaY),
|
|
||||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_left'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isPositionMatch(params.mainWindow, areaX, areaY),
|
||||||
|
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-left'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY),
|
|
||||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.top_right'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY),
|
||||||
|
click: () => animateMainWindowTo(currentDisplay.workArea, 'top-right'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight),
|
|
||||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_left'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isPositionMatch(params.mainWindow, areaX, areaY + areaHeight - windowHeight),
|
||||||
|
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-left'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
|
|
||||||
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
|
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.bottom_right'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
checked: isPositionMatch(params.mainWindow, areaX + areaWidth - windowWidth, areaY + areaHeight - windowHeight),
|
||||||
|
click: () => animateMainWindowTo(currentDisplay.workArea, 'bottom-right'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ click: () => void params.settingsWindow.openWindow('/settings'), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.settings'), click: () => void params.settingsWindow.openWindow('/settings') },
|
||||||
{ click: () => params.aboutWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.about'), click: () => params.aboutWindow().then(window => toggleWindowShow(window)) },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_inlay'), click: () => setupInlayWindow({ i18n: params.i18n, serverChannel: params.serverChannel }) },
|
||||||
{ click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.open_widgets'), click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) },
|
||||||
{
|
{
|
||||||
click: () => {
|
|
||||||
void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu())
|
|
||||||
},
|
|
||||||
label: params.i18n.t(params.captionWindow.isVisible()
|
label: params.i18n.t(params.captionWindow.isVisible()
|
||||||
? 'tamagotchi.electron.tray.menu.labels.label.close_caption'
|
? 'tamagotchi.electron.tray.menu.labels.label.close_caption'
|
||||||
: 'tamagotchi.electron.tray.menu.labels.label.open_caption'),
|
: 'tamagotchi.electron.tray.menu.labels.label.open_caption'),
|
||||||
|
click: () => {
|
||||||
|
void params.captionWindow.toggleVisibility().then(() => rebuildContextMenu())
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: 'submenu',
|
||||||
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'),
|
label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.caption_overlay'),
|
||||||
submenu: Menu.buildFromTemplate([
|
submenu: Menu.buildFromTemplate([
|
||||||
{ checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), type: 'checkbox' },
|
{ type: 'checkbox', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.follow_window'), checked: params.captionWindow.getIsFollowingWindow(), click: async menuItem => await params.captionWindow.setFollowWindow(Boolean(menuItem.checked)) },
|
||||||
{ click: async () => await params.captionWindow.resetToSide(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.reset_position'), click: async () => await params.captionWindow.resetToSide() },
|
||||||
]),
|
]),
|
||||||
type: 'submenu',
|
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG
|
...is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG
|
||||||
? [
|
? [
|
||||||
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools'), type: 'header' },
|
{ type: 'header', label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.devtools') },
|
||||||
{ click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.troubleshoot_beatsync'), click: () => params.beatSyncBgWindow.webContents.openDevTools({ mode: 'detach' }) },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
] as const
|
] as const
|
||||||
: [],
|
: [],
|
||||||
{ click: () => app.quit(), label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit') },
|
{ label: params.i18n.t('tamagotchi.electron.tray.menu.labels.label.quit'), click: () => app.quit() },
|
||||||
])
|
])
|
||||||
|
|
||||||
appTray.setContextMenu(contextMenu)
|
appTray.setContextMenu(contextMenu)
|
||||||
@@ -186,7 +253,7 @@ export function setupTray(params: {
|
|||||||
rebuildContextMenu()
|
rebuildContextMenu()
|
||||||
|
|
||||||
const stopLocaleEffect = effect(() => {
|
const stopLocaleEffect = effect(() => {
|
||||||
const locale = params.i18n.locale as (() => LocaleDetector<any[]> | string | undefined)
|
const locale = params.i18n.locale as (() => string | LocaleDetector<any[]> | undefined)
|
||||||
locale()
|
locale()
|
||||||
rebuildContextMenu()
|
rebuildContextMenu()
|
||||||
})
|
})
|
||||||
@@ -215,70 +282,3 @@ export function setupTray(params: {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWindowSize(window: BrowserWindow, width: number, height: number, x?: number, y?: number): void {
|
|
||||||
if (isRendererUnavailable(window)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setResizable(true)
|
|
||||||
|
|
||||||
const bounds = x !== undefined && y !== undefined
|
|
||||||
? {
|
|
||||||
height: Math.round(height),
|
|
||||||
width: Math.round(width),
|
|
||||||
x: Math.round(x),
|
|
||||||
y: Math.round(y),
|
|
||||||
}
|
|
||||||
: computeResizedBoundsAnchoredToDominantDisplay({
|
|
||||||
currentBounds: window.getBounds(),
|
|
||||||
displays: screen.getAllDisplays(),
|
|
||||||
targetSize: { height, width },
|
|
||||||
})
|
|
||||||
|
|
||||||
window.setBounds(bounds)
|
|
||||||
window.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPositionMatch(window: BrowserWindow, targetX: number, targetY: number): boolean {
|
|
||||||
const { x, y } = window.getBounds()
|
|
||||||
return Math.abs(x - targetX) <= 5 && Math.abs(y - targetY) <= 5
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSizeMatch(window: BrowserWindow, targetWidth: number, targetHeight: number): boolean {
|
|
||||||
const { height, width } = window.getBounds()
|
|
||||||
return Math.abs(width - Math.round(targetWidth)) <= 2 && Math.abs(height - Math.round(targetHeight)) <= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAlignedWindowBounds(
|
|
||||||
window: BrowserWindow,
|
|
||||||
workArea: Rectangle,
|
|
||||||
position: 'bottom-left' | 'bottom-right' | 'center' | 'top-left' | 'top-right',
|
|
||||||
): Rectangle {
|
|
||||||
const { height: windowHeight, width: windowWidth } = window.getBounds()
|
|
||||||
const { height: areaHeight, width: areaWidth, x: areaX, y: areaY } = workArea
|
|
||||||
|
|
||||||
let x = areaX
|
|
||||||
let y = areaY
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case 'bottom-left':
|
|
||||||
y = areaY + areaHeight - windowHeight
|
|
||||||
break
|
|
||||||
case 'bottom-right':
|
|
||||||
x = areaX + areaWidth - windowWidth
|
|
||||||
y = areaY + areaHeight - windowHeight
|
|
||||||
break
|
|
||||||
case 'center':
|
|
||||||
x = areaX + Math.floor((areaWidth - windowWidth) / 2)
|
|
||||||
y = areaY + Math.floor((areaHeight - windowHeight) / 2)
|
|
||||||
break
|
|
||||||
case 'top-left':
|
|
||||||
break
|
|
||||||
case 'top-right':
|
|
||||||
x = areaX + areaWidth - windowWidth
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return { height: windowHeight, width: windowWidth, x, y }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,28 +20,28 @@ export function setupAboutWindowReusable(params: {
|
|||||||
}) {
|
}) {
|
||||||
return createReusableWindow(async () => {
|
return createReusableWindow(async () => {
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
|
title: 'About AIRI',
|
||||||
|
width: 670,
|
||||||
height: 880,
|
height: 880,
|
||||||
icon,
|
show: false,
|
||||||
|
resizable: true,
|
||||||
maximizable: false,
|
maximizable: false,
|
||||||
minimizable: false,
|
minimizable: false,
|
||||||
resizable: true,
|
icon,
|
||||||
show: false,
|
|
||||||
title: 'About AIRI',
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(getElectronMainDirname(), '../preload/index.mjs'),
|
preload: join(getElectronMainDirname(), '../preload/index.mjs'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
},
|
},
|
||||||
width: 670,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
window.on('ready-to-show', () => window.show())
|
window.on('ready-to-show', () => window.show())
|
||||||
protectPrivilegedWindowNavigation(window)
|
protectPrivilegedWindowNavigation(window)
|
||||||
|
|
||||||
await setupAboutWindowElectronInvokes({
|
await setupAboutWindowElectronInvokes({
|
||||||
|
window,
|
||||||
autoUpdater: params.autoUpdater,
|
autoUpdater: params.autoUpdater,
|
||||||
i18n: params.i18n,
|
i18n: params.i18n,
|
||||||
serverChannel: params.serverChannel,
|
serverChannel: params.serverChannel,
|
||||||
window,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', {
|
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user