mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(stage-tamagotchi-godot): add Godot stage G0 sidecar preview (#1724)
## Summary Add the G0 Godot Stage sidecar preview for `stage-tamagotchi`. ## Design - Keep Electron as the desktop host and run Godot as a separate sidecar window by design. - Use the settings window as the entry point for starting/stopping the experimental Godot Stage. - Require explicit `GODOT4` in development mode instead of auto-discovering local Godot installs. - Use the packaged sidecar under `process.resourcesPath/godot-stage` in the packaged runtime. - Close the Godot WebSocket lifecycle channel on startup failure and stage shutdown. ## Changes - Add the Electron main-side Godot Stage lifecycle service. - Add renderer/settings wiring for switching to the experimental Godot Stage. - Add the minimal Godot stage runtime skeleton and WebSocket handshake path. - Add Godot export presets for sidecar builds. - Add sidecar path verification for the Godot export preset and `electron-builder` resource contract. - Document the development runtime setup and packaging expectations. ## CI/CD - Align Godot CI to `4.6.2`. - Add Linux Godot export smoke coverage as the fastest CI export path. - Add sidecar contract verification to CI. - Run packaged sidecar verification after `electron-builder --dir`. CD is intentionally deferred in this PR. The release workflow still needs a later step to export the Godot sidecar before packaging release artefacts. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
255ce71cba
commit
c6c0494998
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: stage-tamagotchi-godot-csharp
|
||||
description: >-
|
||||
Apply engine-local C# development method and code style only when working in
|
||||
`engines/stage-tamagotchi-godot`, including its `.cs` files, `.csproj`,
|
||||
engine-local `.editorconfig`, and Godot-specific C# structure decisions. Do
|
||||
not use for TypeScript, Electron, renderer code, shared workspace config,
|
||||
repo-wide C# conventions, or any file outside
|
||||
`engines/stage-tamagotchi-godot`.
|
||||
---
|
||||
|
||||
# Stage Tamagotchi Godot C#
|
||||
|
||||
1. Confirm every touched file is under `engines/stage-tamagotchi-godot`.
|
||||
If the task crosses that boundary, do not use this skill as the governing
|
||||
instruction set.
|
||||
2. Before editing C# files, read:
|
||||
- `engines/stage-tamagotchi-godot/docs/csharp-development-method.md`
|
||||
- `engines/stage-tamagotchi-godot/.editorconfig`
|
||||
- `engines/stage-tamagotchi-godot/docs/csharp-style.md`
|
||||
3. Treat the development-method document as the primary source of truth for
|
||||
structure and feature usage. Treat `.editorconfig` and `csharp-style.md` as
|
||||
secondary formatting and naming guidance.
|
||||
4. Classify the change before coding:
|
||||
- scene script
|
||||
- runtime core
|
||||
- contract and transport
|
||||
- registry and discovery
|
||||
- tooling and editor support
|
||||
5. Apply the local design method:
|
||||
- keep scene scripts thin
|
||||
- push durable logic into plain C# runtime objects
|
||||
- make subsystem boundaries explicit through types
|
||||
- use reflection for discovery, not steady-state execution
|
||||
- use LINQ for cold-path querying and shaping, not hot-path loops
|
||||
- use async at I/O and process boundaries, not as a default runtime model
|
||||
6. Apply the local low-level style baseline:
|
||||
- `engines/stage-tamagotchi-godot/.editorconfig`
|
||||
- 4 spaces, LF, UTF-8, 100 columns
|
||||
- Allman braces
|
||||
- `System.*` usings first
|
||||
- keyword types such as `string` and `int`
|
||||
- `var` only when the type is obvious
|
||||
- `PascalCase` for types and members
|
||||
- `camelCase` for locals and parameters
|
||||
- `_camelCase` for private fields
|
||||
7. Keep changes local to the engine. Do not push these C# rules into repo
|
||||
root config or other workspaces.
|
||||
8. After changing C# files or the engine-local `.editorconfig`, run the
|
||||
verification command from `engines/stage-tamagotchi-godot`:
|
||||
|
||||
```powershell
|
||||
dotnet format --verify-no-changes
|
||||
```
|
||||
|
||||
If verification fails because of pre-existing files outside the intended change
|
||||
scope, report that clearly instead of broadening the edit set silently.
|
||||
@@ -92,8 +92,9 @@ jobs:
|
||||
if: matrix.app_name == 'stage-tamagotchi-godot'
|
||||
uses: chickensoft-games/setup-godot@v2
|
||||
with:
|
||||
version: 4.6.1
|
||||
version: 4.6.2
|
||||
use-dotnet: true
|
||||
include-templates: true
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run build:packages
|
||||
@@ -101,6 +102,13 @@ jobs:
|
||||
- name: Build App
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Export Godot Linux sidecar
|
||||
if: matrix.app_name == 'stage-tamagotchi-godot'
|
||||
working-directory: ./engines/stage-tamagotchi-godot
|
||||
run: |
|
||||
mkdir -p build/linux
|
||||
godot --headless --export-release "Linux" build/linux/godot-stage
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -133,4 +141,4 @@ jobs:
|
||||
# base-ref: origin/main # optional, default: origin/main
|
||||
# fail-on-downgrade: true # optional, default: true
|
||||
- name: Print result
|
||||
run: "echo 'Downgraded: ${{ steps.check.outputs.downgraded }}'"
|
||||
run: "echo 'Downgraded: ${{ steps.check.outputs.downgraded }}'"
|
||||
|
||||
@@ -97,6 +97,13 @@ export default {
|
||||
asarUnpack: [
|
||||
'**/*.node',
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: '../../engines/stage-tamagotchi-godot/build/${os}',
|
||||
to: 'godot-stage',
|
||||
filter: ['**/*'],
|
||||
},
|
||||
],
|
||||
extraMetadata: {
|
||||
name: 'ai.moeru.airi',
|
||||
main: 'out/main/index.js',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { setElectronMainDirname } from './libs/electron/location'
|
||||
import { createI18n } from './libs/i18n'
|
||||
import { createWindowAuthManagerService } from './services/airi/auth'
|
||||
import { setupServerChannel } from './services/airi/channel-server'
|
||||
import { setupGodotStageManager } from './services/airi/godot-stage'
|
||||
import { setupBuiltInServer } from './services/airi/http-server'
|
||||
import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupPluginHost } from './services/airi/plugins'
|
||||
@@ -134,6 +135,10 @@ app.whenReady().then(async () => {
|
||||
build: async () => setupBuiltInServer({ servers: [] }),
|
||||
})
|
||||
|
||||
const godotStageManager = injeca.provide('modules:godot-stage-manager', {
|
||||
build: async () => setupGodotStageManager(),
|
||||
})
|
||||
|
||||
const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', {
|
||||
build: async () => setupMcpStdioManager(),
|
||||
})
|
||||
@@ -176,12 +181,12 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow, serverChannel, mcpStdioManager, i18n, windowAuthManager },
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager },
|
||||
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
|
||||
})
|
||||
|
||||
const mainWindow = injeca.provide('windows:main', {
|
||||
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
|
||||
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager },
|
||||
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
|
||||
})
|
||||
|
||||
@@ -212,7 +217,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
injeca.invoke({
|
||||
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig },
|
||||
dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig },
|
||||
callback: async (deps) => {
|
||||
const { context } = createContext(ipcMain)
|
||||
await setupArtistryBridge({
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface TestWebSocketMessage {
|
||||
text: () => string
|
||||
}
|
||||
|
||||
interface TestWebSocketPeer {
|
||||
close: ReturnType<typeof vi.fn>
|
||||
id: string
|
||||
request: {
|
||||
url?: string
|
||||
}
|
||||
send: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface TestWebSocketHooks {
|
||||
close?: (peer: TestWebSocketPeer) => void
|
||||
message?: (peer: TestWebSocketPeer, message: TestWebSocketMessage) => void
|
||||
open?: (peer: TestWebSocketPeer) => void
|
||||
}
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
getPath: vi.fn((name: string) => `/tmp/airi/${name}`),
|
||||
isPackaged: false,
|
||||
}))
|
||||
|
||||
const serverState = vi.hoisted(() => ({
|
||||
close: vi.fn(async () => {}),
|
||||
serve: vi.fn(async () => {}),
|
||||
webSocketHooks: undefined as TestWebSocketHooks | undefined,
|
||||
}))
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
const logMock = vi.hoisted(() => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
withError: vi.fn(),
|
||||
withFields: vi.fn(),
|
||||
}
|
||||
logger.withError.mockReturnValue(logger)
|
||||
logger.withFields.mockReturnValue(logger)
|
||||
return logger
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: appMock,
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
stat: vi.fn(async () => ({ isFile: () => true })),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock('@guiiai/logg', () => ({
|
||||
useLogg: () => ({
|
||||
useGlobalConfig: () => logMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('crossws/server', () => ({
|
||||
plugin: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('get-port-please', () => ({
|
||||
getRandomPort: vi.fn(async () => 48123),
|
||||
}))
|
||||
|
||||
vi.mock('h3', () => ({
|
||||
H3: class {
|
||||
get = vi.fn()
|
||||
},
|
||||
defineWebSocketHandler: vi.fn((hooks: TestWebSocketHooks) => {
|
||||
serverState.webSocketHooks = hooks
|
||||
return hooks
|
||||
}),
|
||||
serve: vi.fn(() => ({
|
||||
close: serverState.close,
|
||||
serve: serverState.serve,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../../libs/bootkit/lifecycle', () => ({
|
||||
onAppBeforeQuit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../libs/electron/location', () => ({
|
||||
getElectronMainDirname: () => '/tmp/airi/out/main',
|
||||
}))
|
||||
|
||||
function createFakeGodotProcess() {
|
||||
const processHandle = new EventEmitter() as EventEmitter & {
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pid: number
|
||||
stderr: EventEmitter
|
||||
stdout: EventEmitter
|
||||
}
|
||||
|
||||
processHandle.pid = 4321
|
||||
processHandle.stdout = new EventEmitter()
|
||||
processHandle.stderr = new EventEmitter()
|
||||
processHandle.kill = vi.fn(() => {
|
||||
queueMicrotask(() => processHandle.emit('close', null, 'SIGTERM'))
|
||||
return true
|
||||
})
|
||||
|
||||
return processHandle
|
||||
}
|
||||
|
||||
function createTestPeer(url: string): TestWebSocketPeer {
|
||||
return {
|
||||
id: 'godot-test-peer',
|
||||
request: { url },
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function readSpawnedWebSocketUrl() {
|
||||
const spawnArgs = spawnMock.mock.calls.at(-1)?.[1]
|
||||
if (!Array.isArray(spawnArgs)) {
|
||||
throw new TypeError('Expected Godot spawn arguments to be recorded.')
|
||||
}
|
||||
|
||||
const websocketArgument = spawnArgs.find((arg): arg is string => (
|
||||
typeof arg === 'string' && arg.startsWith('--airi-ws-url=')
|
||||
))
|
||||
if (!websocketArgument) {
|
||||
throw new Error('Expected Godot spawn arguments to include --airi-ws-url.')
|
||||
}
|
||||
|
||||
return websocketArgument.slice('--airi-ws-url='.length)
|
||||
}
|
||||
|
||||
async function waitForSpawnedGodotProcess() {
|
||||
await waitForSpawnedGodotProcessCount(1)
|
||||
}
|
||||
|
||||
async function waitForSpawnedGodotProcessCount(expectedCount: number) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (spawnMock.mock.calls.length >= expectedCount) {
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
throw new Error('Expected Godot process to be spawned.')
|
||||
}
|
||||
|
||||
async function startRunningGodotStage() {
|
||||
const { createGodotStageManager } = await import('./index')
|
||||
const manager = createGodotStageManager()
|
||||
const startPromise = manager.start()
|
||||
|
||||
await waitForSpawnedGodotProcess()
|
||||
|
||||
const peer = createTestPeer(readSpawnedWebSocketUrl())
|
||||
serverState.webSocketHooks?.open?.(peer)
|
||||
serverState.webSocketHooks?.message?.(peer, {
|
||||
text: () => JSON.stringify({ type: 'stage.ready' }),
|
||||
})
|
||||
|
||||
await startPromise
|
||||
|
||||
return {
|
||||
manager,
|
||||
peer,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createGodotStageManager lifecycle cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
appMock.isPackaged = false
|
||||
serverState.webSocketHooks = undefined
|
||||
delete process.env.GODOT4
|
||||
})
|
||||
|
||||
it('closes the websocket runtime when dev-mode Godot binary resolution fails', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// `start()` creates the websocket runtime before resolving the Godot binary.
|
||||
// If `GODOT4` is missing, binary resolution throws and previously left the
|
||||
// websocket server alive until the next start attempt or app quit.
|
||||
const { createGodotStageManager } = await import('./index')
|
||||
const manager = createGodotStageManager()
|
||||
|
||||
await expect(manager.start()).rejects.toThrow('GODOT4 is required')
|
||||
expect(serverState.close).toHaveBeenCalledWith(true)
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: 'error',
|
||||
pid: null,
|
||||
lastError: expect.stringContaining('GODOT4 is required'),
|
||||
})
|
||||
})
|
||||
|
||||
it('kills the Godot process and closes the websocket runtime when startup readiness times out', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If Godot starts but never sends `stage.ready`, `start()` rejects after the
|
||||
// readiness timeout. The startup transaction must still release the process
|
||||
// and websocket runtime created for that failed attempt.
|
||||
vi.useFakeTimers()
|
||||
process.env.GODOT4 = '/tmp/godot'
|
||||
|
||||
const processHandle = createFakeGodotProcess()
|
||||
spawnMock.mockReturnValue(processHandle)
|
||||
|
||||
const { createGodotStageManager } = await import('./index')
|
||||
const manager = createGodotStageManager()
|
||||
const startPromise = manager.start()
|
||||
const startExpectation = expect(startPromise).rejects.toThrow('Godot stage did not report ready in time.')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
await startExpectation
|
||||
expect(processHandle.kill).toHaveBeenCalled()
|
||||
expect(serverState.close).toHaveBeenCalledWith(true)
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: 'error',
|
||||
pid: null,
|
||||
lastError: expect.stringContaining('Godot stage did not report ready in time.'),
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the websocket runtime when stop fails while force-killing Godot', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// `stop()` can enter the force-kill path after waiting for graceful shutdown.
|
||||
// Cleanup must not depend on that branch completing successfully; the
|
||||
// websocket runtime belongs to the stopping session and must be released.
|
||||
vi.useFakeTimers()
|
||||
process.env.GODOT4 = '/tmp/godot'
|
||||
|
||||
const processHandle = createFakeGodotProcess()
|
||||
processHandle.kill.mockImplementation(() => {
|
||||
throw new Error('kill failed')
|
||||
})
|
||||
spawnMock.mockReturnValue(processHandle)
|
||||
|
||||
const { manager } = await startRunningGodotStage()
|
||||
const stopPromise = manager.stop()
|
||||
const stopExpectation = expect(stopPromise).rejects.toThrow('kill failed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
|
||||
await stopExpectation
|
||||
expect(serverState.close).toHaveBeenCalledWith(true)
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: 'error',
|
||||
pid: processHandle.pid,
|
||||
lastError: expect.stringContaining('kill failed'),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not spawn a second process while a failed startup process is still shutting down', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// A timed-out startup kills the old Godot process, but only waits a bounded
|
||||
// 2 seconds for its close event. A retry can start a new process before the
|
||||
// old process emits close. The retry must not spawn another child process
|
||||
// while the previous process is still tracked by the manager.
|
||||
vi.useFakeTimers()
|
||||
process.env.GODOT4 = '/tmp/godot'
|
||||
|
||||
const staleProcess = createFakeGodotProcess()
|
||||
staleProcess.pid = 1001
|
||||
staleProcess.kill.mockImplementation(() => true)
|
||||
|
||||
const unexpectedProcess = createFakeGodotProcess()
|
||||
unexpectedProcess.pid = 1002
|
||||
|
||||
spawnMock.mockReturnValueOnce(staleProcess).mockReturnValueOnce(unexpectedProcess)
|
||||
|
||||
const { createGodotStageManager } = await import('./index')
|
||||
const manager = createGodotStageManager()
|
||||
const failedStartPromise = manager.start()
|
||||
const failedStartExpectation = expect(failedStartPromise).rejects.toThrow('Godot stage did not report ready in time.')
|
||||
|
||||
await waitForSpawnedGodotProcess()
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await failedStartExpectation
|
||||
|
||||
const retryStartPromise = manager.start()
|
||||
const retryStartExpectation = expect(retryStartPromise).rejects.toThrow('Previous Godot stage process is still shutting down')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await retryStartExpectation
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: 'error',
|
||||
pid: null,
|
||||
lastError: expect.stringContaining('Previous Godot stage process is still shutting down'),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,825 @@
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
import type { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type {
|
||||
ElectronGodotStageSceneInputPayload,
|
||||
ElectronGodotStageStatus,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { access, mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { plugin as ws } from 'crossws/server'
|
||||
import { app } from 'electron'
|
||||
import { getRandomPort } from 'get-port-please'
|
||||
import { defineWebSocketHandler, H3, serve } from 'h3'
|
||||
|
||||
import {
|
||||
electronGodotStageApplySceneInput,
|
||||
electronGodotStageGetStatus,
|
||||
electronGodotStageStart,
|
||||
electronGodotStageStatusChanged,
|
||||
electronGodotStageStop,
|
||||
} from '../../../../shared/eventa'
|
||||
import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle'
|
||||
import { getElectronMainDirname } from '../../../libs/electron/location'
|
||||
|
||||
type MainContext = ReturnType<typeof createContext>['context']
|
||||
type GodotStageWebSocketHooks = Exclude<Parameters<typeof defineWebSocketHandler>[0], (...args: never[]) => unknown>
|
||||
type GodotStagePeer = Parameters<NonNullable<GodotStageWebSocketHooks['open']>>[0]
|
||||
type GodotStageMessage = Parameters<NonNullable<GodotStageWebSocketHooks['message']>>[1]
|
||||
type GodotStageProcess = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
reject: (error?: unknown) => void
|
||||
resolve: (value: T | PromiseLike<T>) => void
|
||||
}
|
||||
|
||||
interface GodotStageSocketRuntime {
|
||||
port: number
|
||||
server: ReturnType<typeof serve>
|
||||
token: string
|
||||
}
|
||||
|
||||
interface GodotStageSceneApplyPayload {
|
||||
format: string
|
||||
modelId: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface GodotStageSocketEnvelope {
|
||||
payload?: unknown
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Godot sidecar lifecycle controller owned by Electron main.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer windows need to start or stop the external Godot stage
|
||||
* - The selected model should be materialized and forwarded to the Godot runtime
|
||||
*
|
||||
* Expects:
|
||||
* - Production: pre-exported binary in `extraResources/godot-stage/`
|
||||
* - Dev: `GODOT4` env var points to a local Godot 4.x .NET/Mono executable
|
||||
* - The current workspace contains `engines/stage-tamagotchi-godot/project.godot` (dev mode only)
|
||||
*
|
||||
* Returns:
|
||||
* - Lifecycle helpers, scene-input forwarding, and status subscriptions
|
||||
*/
|
||||
export interface GodotStageManager {
|
||||
applySceneInput: (payload: ElectronGodotStageSceneInputPayload) => Promise<void>
|
||||
getStatus: () => ElectronGodotStageStatus
|
||||
start: () => Promise<ElectronGodotStageStatus>
|
||||
stop: () => Promise<ElectronGodotStageStatus>
|
||||
subscribe: (callback: (status: ElectronGodotStageStatus) => void) => () => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: Deferred<T>['resolve']
|
||||
let reject!: Deferred<T>['reject']
|
||||
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
reject,
|
||||
resolve,
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialStatus(): ElectronGodotStageStatus {
|
||||
return {
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createSocketEnvelope(type: string, payload?: unknown) {
|
||||
return JSON.stringify({ type, payload })
|
||||
}
|
||||
|
||||
function normalizeFileName(fileName: string) {
|
||||
const normalized = basename(fileName.trim())
|
||||
return normalized || 'model.bin'
|
||||
}
|
||||
|
||||
function parseSocketMessage(message: GodotStageMessage): GodotStageSocketEnvelope {
|
||||
const text = message.text()
|
||||
return JSON.parse(text) as GodotStageSocketEnvelope
|
||||
}
|
||||
|
||||
function getPayloadMessage(payload: unknown) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const message = (payload as Record<string, unknown>).message
|
||||
return typeof message === 'string' ? message : undefined
|
||||
}
|
||||
|
||||
interface GodotBinaryResolution {
|
||||
executable: string
|
||||
mode: 'engine' | 'exported'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Godot project path by walking up from the Electron main
|
||||
* bundle directory until `engines/stage-tamagotchi-godot/project.godot` is found.
|
||||
*
|
||||
* Use when:
|
||||
* - Dev mode needs to point the Godot engine at the project directory
|
||||
*
|
||||
* Returns:
|
||||
* - Absolute path to the Godot project directory
|
||||
*/
|
||||
async function resolveGodotProjectPath() {
|
||||
let currentDirectory = getElectronMainDirname()
|
||||
|
||||
while (true) {
|
||||
const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot')
|
||||
|
||||
try {
|
||||
await access(join(projectPath, 'project.godot'))
|
||||
return projectPath
|
||||
}
|
||||
catch {}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory)
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory
|
||||
}
|
||||
|
||||
throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Godot binary for production mode.
|
||||
*
|
||||
* Looks for the pre-exported standalone binary bundled via electron-builder
|
||||
* `extraResources` at `<resourcesPath>/godot-stage/`.
|
||||
*
|
||||
* Returns:
|
||||
* - Path to the exported binary, or undefined if not found
|
||||
*/
|
||||
async function resolveExportedGodotBinary(): Promise<string | undefined> {
|
||||
const platform = process.platform
|
||||
let binaryName: string
|
||||
|
||||
if (platform === 'win32') {
|
||||
binaryName = 'godot-stage.exe'
|
||||
}
|
||||
else if (platform === 'darwin') {
|
||||
binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage')
|
||||
}
|
||||
else {
|
||||
binaryName = 'godot-stage'
|
||||
}
|
||||
|
||||
const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName)
|
||||
|
||||
try {
|
||||
await access(binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the explicitly configured dev-mode Godot executable.
|
||||
*
|
||||
* Use when:
|
||||
* - Dev mode is about to spawn the local Godot engine
|
||||
*
|
||||
* Expects:
|
||||
* - `GODOT4` points to a Godot 4.x .NET/Mono executable file
|
||||
*
|
||||
* Returns:
|
||||
* - Throws a configuration error before spawn when the path is invalid
|
||||
*/
|
||||
async function validateConfiguredGodotEnginePath(executable: string) {
|
||||
let executableStats
|
||||
try {
|
||||
executableStats = await stat(executable)
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(
|
||||
'GODOT4 points to a missing Godot executable.\n'
|
||||
+ `Configured path: ${executable}\n`
|
||||
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n'
|
||||
+ `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!executableStats.isFile()) {
|
||||
throw new Error(
|
||||
'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n'
|
||||
+ `Configured path: ${executable}\n`
|
||||
+ 'Examples:\n'
|
||||
+ ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n'
|
||||
+ ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n'
|
||||
+ ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Godot binary and execution mode.
|
||||
*
|
||||
* Use when:
|
||||
* - The Godot stage is about to be spawned
|
||||
*
|
||||
* Expects:
|
||||
* - Production: exported binary in `extraResources/godot-stage/`
|
||||
* - Dev: `GODOT4` env var points to a local Godot 4.x .NET/Mono executable
|
||||
*
|
||||
* Returns:
|
||||
* - `{ executable, mode }` where mode determines spawn arguments
|
||||
*/
|
||||
async function resolveGodotBinary(): Promise<GodotBinaryResolution> {
|
||||
if (app.isPackaged) {
|
||||
const exported = await resolveExportedGodotBinary()
|
||||
if (exported) {
|
||||
return { executable: exported, mode: 'exported' }
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Godot stage exported binary not found. '
|
||||
+ `Expected at: ${join(process.resourcesPath, 'godot-stage')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const envPath = process.env.GODOT4?.trim()
|
||||
if (!envPath) {
|
||||
throw new Error(
|
||||
'GODOT4 is required to start Godot Stage in development mode.\n'
|
||||
+ 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n'
|
||||
+ 'Examples:\n'
|
||||
+ ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n'
|
||||
+ ' Bash: export GODOT4="/path/to/godot"',
|
||||
)
|
||||
}
|
||||
|
||||
await validateConfiguredGodotEnginePath(envPath)
|
||||
return { executable: envPath, mode: 'engine' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the shared Godot stage manager.
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* setupGodotStageManager
|
||||
* -> {@link createGodotStageManager}
|
||||
* -> renderer invoke handlers
|
||||
* -> Godot sidecar process + websocket bridge
|
||||
*/
|
||||
export function createGodotStageManager(): GodotStageManager {
|
||||
const log = useLogg('main/godot-stage').useGlobalConfig()
|
||||
const lifecycleMutex = new Mutex()
|
||||
const listeners = new Set<(status: ElectronGodotStageStatus) => void>()
|
||||
let currentStatus = createInitialStatus()
|
||||
let currentProcess: GodotStageProcess | undefined
|
||||
let currentProcessExit = createDeferred<void>()
|
||||
let currentReady: Deferred<void> | undefined
|
||||
let currentSceneInput: GodotStageSceneApplyPayload | undefined
|
||||
let currentSocketRuntime: GodotStageSocketRuntime | undefined
|
||||
let currentSocketPeer: GodotStagePeer | undefined
|
||||
let expectedProcessExit = false
|
||||
|
||||
function broadcastStatus(status: ElectronGodotStageStatus) {
|
||||
currentStatus = status
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(currentStatus)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).warn('failed to publish Godot stage status change')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(next: Partial<ElectronGodotStageStatus> & Pick<ElectronGodotStageStatus, 'state'>) {
|
||||
broadcastStatus({
|
||||
...currentStatus,
|
||||
...next,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
function clearProcessState() {
|
||||
currentProcess = undefined
|
||||
currentSocketPeer = undefined
|
||||
currentProcessExit.resolve()
|
||||
currentProcessExit = createDeferred<void>()
|
||||
}
|
||||
|
||||
async function stopSocketRuntime() {
|
||||
const runtime = currentSocketRuntime
|
||||
currentSocketRuntime = undefined
|
||||
currentSocketPeer = undefined
|
||||
|
||||
if (!runtime) {
|
||||
return
|
||||
}
|
||||
|
||||
await runtime.server.close(true).catch(() => {})
|
||||
}
|
||||
|
||||
async function stopProcessAfterFailedStart() {
|
||||
if (!currentProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeProcess = currentProcess
|
||||
const exitPromise = currentProcessExit.promise
|
||||
expectedProcessExit = true
|
||||
|
||||
// Startup failed after spawning Godot; release the child process before
|
||||
// allowing the renderer to retry and create another stage runtime.
|
||||
activeProcess.kill()
|
||||
|
||||
await Promise.race([
|
||||
exitPromise,
|
||||
new Promise<void>(resolve => setTimeout(resolve, 2_000)),
|
||||
]).catch(() => {})
|
||||
}
|
||||
|
||||
function sendSocketMessage(type: string, payload?: unknown) {
|
||||
if (!currentSocketPeer) {
|
||||
return
|
||||
}
|
||||
|
||||
currentSocketPeer.send(createSocketEnvelope(type, payload))
|
||||
}
|
||||
|
||||
async function sendSceneInputToGodot(payload: GodotStageSceneApplyPayload) {
|
||||
currentSceneInput = payload
|
||||
|
||||
if (!currentSocketPeer) {
|
||||
return
|
||||
}
|
||||
|
||||
sendSocketMessage('host.scene.apply', payload)
|
||||
}
|
||||
|
||||
function handleSocketMessage(message: GodotStageSocketEnvelope) {
|
||||
switch (message.type) {
|
||||
case 'stage.ready': {
|
||||
setStatus({
|
||||
state: 'running',
|
||||
pid: currentProcess?.pid ?? null,
|
||||
lastError: undefined,
|
||||
})
|
||||
currentReady?.resolve()
|
||||
currentReady = undefined
|
||||
|
||||
if (currentSceneInput) {
|
||||
void sendSceneInputToGodot(currentSceneInput)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'stage.fatal': {
|
||||
const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.'
|
||||
setStatus({
|
||||
state: 'error',
|
||||
pid: currentProcess?.pid ?? null,
|
||||
lastError: error,
|
||||
})
|
||||
currentReady?.reject(new Error(error))
|
||||
currentReady = undefined
|
||||
currentProcess?.kill()
|
||||
return
|
||||
}
|
||||
case 'scene.applied': {
|
||||
if (currentStatus.state === 'running' && currentStatus.lastError) {
|
||||
setStatus({
|
||||
state: 'running',
|
||||
pid: currentProcess?.pid ?? null,
|
||||
lastError: undefined,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'scene.error': {
|
||||
const error = getPayloadMessage(message.payload) ?? 'Godot stage failed to apply scene input.'
|
||||
setStatus({
|
||||
state: currentStatus.state === 'running' ? 'running' : currentStatus.state,
|
||||
pid: currentProcess?.pid ?? null,
|
||||
lastError: error,
|
||||
})
|
||||
return
|
||||
}
|
||||
default: {
|
||||
log.withFields({ type: message.type }).debug('received unknown Godot stage message')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startSocketRuntime() {
|
||||
if (currentSocketRuntime) {
|
||||
return currentSocketRuntime
|
||||
}
|
||||
|
||||
const host = '127.0.0.1'
|
||||
const port = await getRandomPort(host)
|
||||
const token = randomUUID()
|
||||
const appServer = new H3()
|
||||
|
||||
appServer.get('/ws', defineWebSocketHandler({
|
||||
open: (peer) => {
|
||||
const requestUrl = peer.request.url ?? ''
|
||||
const url = new URL(requestUrl, `ws://${host}:${port}`)
|
||||
if (url.searchParams.get('token') !== token) {
|
||||
peer.close?.()
|
||||
return
|
||||
}
|
||||
|
||||
currentSocketPeer = peer
|
||||
log.withFields({ peer: peer.id }).debug('Godot websocket connected')
|
||||
},
|
||||
message: (_peer, message) => {
|
||||
try {
|
||||
handleSocketMessage(parseSocketMessage(message))
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).warn('failed to parse Godot websocket message')
|
||||
}
|
||||
},
|
||||
close: (peer) => {
|
||||
if (currentSocketPeer?.id === peer.id) {
|
||||
currentSocketPeer = undefined
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const server = serve(appServer, {
|
||||
// @ts-expect-error - h3 does not extend the crossws response type.
|
||||
plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })],
|
||||
port,
|
||||
hostname: host,
|
||||
manual: true,
|
||||
reusePort: false,
|
||||
silent: true,
|
||||
gracefulShutdown: {
|
||||
forceTimeout: 0.25,
|
||||
gracefulTimeout: 0.25,
|
||||
},
|
||||
})
|
||||
|
||||
await server.serve()
|
||||
|
||||
currentSocketRuntime = {
|
||||
port,
|
||||
server,
|
||||
token,
|
||||
}
|
||||
|
||||
return currentSocketRuntime
|
||||
}
|
||||
|
||||
function attachProcessListeners(processHandle: GodotStageProcess) {
|
||||
processHandle.stdout.on('data', (data) => {
|
||||
const message = data.toString('utf-8').trim()
|
||||
if (message) {
|
||||
log.log(message)
|
||||
}
|
||||
})
|
||||
|
||||
processHandle.stderr.on('data', (data) => {
|
||||
const message = data.toString('utf-8').trim()
|
||||
if (message) {
|
||||
log.warn(message)
|
||||
}
|
||||
})
|
||||
|
||||
processHandle.on('error', (error) => {
|
||||
if (currentProcess !== processHandle) {
|
||||
log.withError(error).debug('ignored stale Godot stage process error')
|
||||
return
|
||||
}
|
||||
|
||||
const message = errorMessageFrom(error) ?? 'Failed to spawn Godot stage process.'
|
||||
setStatus({
|
||||
state: 'error',
|
||||
pid: processHandle.pid ?? null,
|
||||
lastError: message,
|
||||
})
|
||||
currentReady?.reject(error)
|
||||
currentReady = undefined
|
||||
})
|
||||
|
||||
processHandle.on('close', (code, signal) => {
|
||||
if (currentProcess !== processHandle) {
|
||||
log.withFields({
|
||||
code,
|
||||
pid: processHandle.pid ?? null,
|
||||
signal,
|
||||
}).debug('ignored stale Godot stage process close')
|
||||
return
|
||||
}
|
||||
|
||||
const exitMessage = signal
|
||||
? `Godot stage exited with signal ${signal}.`
|
||||
: `Godot stage exited with code ${code ?? 0}.`
|
||||
|
||||
clearProcessState()
|
||||
void stopSocketRuntime()
|
||||
|
||||
if (expectedProcessExit) {
|
||||
setStatus({
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
lastError: undefined,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
pid: null,
|
||||
lastError: exitMessage,
|
||||
})
|
||||
}
|
||||
|
||||
currentReady?.reject(new Error(exitMessage))
|
||||
currentReady = undefined
|
||||
expectedProcessExit = false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(callback) {
|
||||
listeners.add(callback)
|
||||
callback(currentStatus)
|
||||
|
||||
return () => {
|
||||
listeners.delete(callback)
|
||||
}
|
||||
},
|
||||
getStatus() {
|
||||
return currentStatus
|
||||
},
|
||||
async start() {
|
||||
return await lifecycleMutex.runExclusive(async () => {
|
||||
let spawnedProcess: GodotStageProcess | undefined
|
||||
|
||||
try {
|
||||
if (currentProcess && currentStatus.state === 'running') {
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
if (currentProcess && currentStatus.state === 'starting' && currentReady) {
|
||||
await currentReady.promise
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
if (currentProcess) {
|
||||
const activeProcess = currentProcess
|
||||
await stopProcessAfterFailedStart()
|
||||
|
||||
if (currentProcess === activeProcess) {
|
||||
throw new Error('Previous Godot stage process is still shutting down. Retry after it exits.')
|
||||
}
|
||||
}
|
||||
|
||||
await stopSocketRuntime()
|
||||
|
||||
const socketRuntime = await startSocketRuntime()
|
||||
const godotBinary = await resolveGodotBinary()
|
||||
const websocketUrl = `ws://127.0.0.1:${socketRuntime.port}/ws?token=${socketRuntime.token}`
|
||||
const readyDeferred = createDeferred<void>()
|
||||
const readyTimeout = setTimeout(() => {
|
||||
readyDeferred.reject(new Error('Godot stage did not report ready in time.'))
|
||||
}, 20_000)
|
||||
|
||||
currentReady = readyDeferred
|
||||
expectedProcessExit = false
|
||||
setStatus({
|
||||
state: 'starting',
|
||||
pid: null,
|
||||
lastError: undefined,
|
||||
})
|
||||
|
||||
let spawnArgs: string[]
|
||||
let spawnCwd: string | undefined
|
||||
|
||||
if (godotBinary.mode === 'engine') {
|
||||
const godotProjectPath = await resolveGodotProjectPath()
|
||||
spawnArgs = ['--path', godotProjectPath, '--', `--airi-ws-url=${websocketUrl}`]
|
||||
spawnCwd = godotProjectPath
|
||||
}
|
||||
else {
|
||||
spawnArgs = ['--', `--airi-ws-url=${websocketUrl}`]
|
||||
}
|
||||
|
||||
log.withFields({ executable: godotBinary.executable, mode: godotBinary.mode }).log('spawning Godot stage')
|
||||
|
||||
const processHandle = spawn(
|
||||
godotBinary.executable,
|
||||
spawnArgs,
|
||||
{
|
||||
cwd: spawnCwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: false,
|
||||
},
|
||||
)
|
||||
|
||||
spawnedProcess = processHandle
|
||||
currentProcess = processHandle
|
||||
attachProcessListeners(processHandle)
|
||||
|
||||
setStatus({
|
||||
state: 'starting',
|
||||
pid: processHandle.pid ?? null,
|
||||
lastError: undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
await readyDeferred.promise
|
||||
}
|
||||
finally {
|
||||
if (currentReady === readyDeferred)
|
||||
currentReady = undefined
|
||||
clearTimeout(readyTimeout)
|
||||
}
|
||||
|
||||
return currentStatus
|
||||
}
|
||||
catch (error) {
|
||||
if (spawnedProcess && currentProcess === spawnedProcess) {
|
||||
await stopProcessAfterFailedStart()
|
||||
}
|
||||
await stopSocketRuntime()
|
||||
setStatus({
|
||||
state: 'error',
|
||||
pid: null,
|
||||
lastError: errorMessageFrom(error) ?? 'Failed to start Godot stage.',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
},
|
||||
async stop() {
|
||||
return await lifecycleMutex.runExclusive(async () => {
|
||||
if (!currentProcess) {
|
||||
await stopSocketRuntime()
|
||||
setStatus({
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
lastError: undefined,
|
||||
})
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
const activeProcess = currentProcess
|
||||
const exitPromise = currentProcessExit.promise
|
||||
|
||||
expectedProcessExit = true
|
||||
setStatus({
|
||||
state: 'stopping',
|
||||
pid: activeProcess.pid ?? null,
|
||||
lastError: undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
sendSocketMessage('host.shutdown')
|
||||
|
||||
const exited = await Promise.race([
|
||||
exitPromise.then(() => true),
|
||||
new Promise<boolean>(resolve => setTimeout(resolve, 2_000, false)),
|
||||
])
|
||||
|
||||
if (!exited) {
|
||||
activeProcess.kill()
|
||||
await exitPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
pid: activeProcess.pid ?? null,
|
||||
lastError: errorMessageFrom(error) ?? 'Failed to stop Godot stage.',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await stopSocketRuntime()
|
||||
}
|
||||
|
||||
setStatus({
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
lastError: undefined,
|
||||
})
|
||||
|
||||
return currentStatus
|
||||
})
|
||||
},
|
||||
async applySceneInput(payload) {
|
||||
await lifecycleMutex.runExclusive(async () => {
|
||||
if (currentStatus.state !== 'starting' && currentStatus.state !== 'running') {
|
||||
throw new Error('Godot stage is not running.')
|
||||
}
|
||||
|
||||
const fileName = normalizeFileName(payload.fileName)
|
||||
const modelDirectory = join(app.getPath('userData'), 'godot-stage', 'models', payload.modelId)
|
||||
const materializedPath = join(modelDirectory, fileName)
|
||||
|
||||
await mkdir(modelDirectory, { recursive: true })
|
||||
await writeFile(materializedPath, payload.data)
|
||||
|
||||
await sendSceneInputToGodot({
|
||||
modelId: payload.modelId,
|
||||
format: payload.format,
|
||||
name: payload.name,
|
||||
path: materializedPath,
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and wires the shared Godot stage manager into app lifecycle hooks.
|
||||
*
|
||||
* Use when:
|
||||
* - Electron main needs one app-wide Godot sidecar lifecycle owner
|
||||
*
|
||||
* Expects:
|
||||
* - App shutdown to call the registered `onAppBeforeQuit` hook
|
||||
*
|
||||
* Returns:
|
||||
* - The ready-to-use Godot stage manager
|
||||
*/
|
||||
export function setupGodotStageManager() {
|
||||
const manager = createGodotStageManager()
|
||||
|
||||
onAppBeforeQuit(async () => {
|
||||
await manager.stop()
|
||||
})
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Godot stage invoke handlers for one Electron window context.
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* createGodotStageService
|
||||
* -> renderer invoke/eventa handlers
|
||||
* -> {@link GodotStageManager}
|
||||
*/
|
||||
export function createGodotStageService(params: {
|
||||
context: MainContext
|
||||
manager: GodotStageManager
|
||||
window: BrowserWindow
|
||||
}) {
|
||||
const unsubscribe = params.manager.subscribe((status) => {
|
||||
if (!params.window.isDestroyed()) {
|
||||
params.context.emit(electronGodotStageStatusChanged, status)
|
||||
}
|
||||
})
|
||||
|
||||
const cleanups: Array<() => void> = [
|
||||
unsubscribe,
|
||||
defineInvokeHandler(params.context, electronGodotStageStart, async () => await params.manager.start()),
|
||||
defineInvokeHandler(params.context, electronGodotStageStop, async () => await params.manager.stop()),
|
||||
defineInvokeHandler(params.context, electronGodotStageGetStatus, async () => params.manager.getStatus()),
|
||||
defineInvokeHandler(params.context, electronGodotStageApplySceneInput, async (payload) => {
|
||||
await params.manager.applySceneInput(payload)
|
||||
}),
|
||||
]
|
||||
|
||||
const cleanup = () => {
|
||||
for (const fn of cleanups) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
params.window.on('closed', cleanup)
|
||||
return cleanup
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot'
|
||||
import type { I18n } from '../../libs/i18n'
|
||||
import type { WindowAuthManager } from '../../services/airi/auth'
|
||||
import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { NoticeWindowManager } from '../notice'
|
||||
@@ -56,6 +57,7 @@ export async function setupMainWindow(params: {
|
||||
autoUpdater: AutoUpdater
|
||||
onWindowCreated?: (window: BrowserWindow) => void
|
||||
serverChannel: ServerChannel
|
||||
godotStageManager: GodotStageManager
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
@@ -184,6 +186,7 @@ export async function setupMainWindow(params: {
|
||||
noticeWindow: params.noticeWindow,
|
||||
autoUpdater: params.autoUpdater,
|
||||
serverChannel: params.serverChannel,
|
||||
godotStageManager: params.godotStageManager,
|
||||
mcpStdioManager: params.mcpStdioManager,
|
||||
i18n: params.i18n,
|
||||
onboardingWindowManager: params.onboardingWindowManager,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
|
||||
import type { I18n } from '../../../libs/i18n'
|
||||
import type { WindowAuthManager } from '../../../services/airi/auth'
|
||||
import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { NoticeWindowManager } from '../../notice'
|
||||
@@ -16,6 +17,7 @@ import { ipcMain } from 'electron'
|
||||
|
||||
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
|
||||
import { createAuthService } from '../../../services/airi/auth'
|
||||
import { createGodotStageService } from '../../../services/airi/godot-stage'
|
||||
import { createMcpServersService } from '../../../services/airi/mcp-servers'
|
||||
import { createOnboardingService } from '../../../services/airi/onboarding'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
@@ -31,6 +33,7 @@ export async function setupMainWindowElectronInvokes(params: {
|
||||
noticeWindow: NoticeWindowManager
|
||||
autoUpdater: AutoUpdater
|
||||
serverChannel: ServerChannel
|
||||
godotStageManager: GodotStageManager
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
@@ -47,6 +50,7 @@ export async function setupMainWindowElectronInvokes(params: {
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
|
||||
createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater })
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
createGodotStageService({ context, manager: params.godotStageManager, window: params.window })
|
||||
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
|
||||
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { I18n } from '../../libs/i18n'
|
||||
import type { WindowAuthManager } from '../../services/airi/auth'
|
||||
import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { DevtoolsWindowManager } from '../devtools'
|
||||
@@ -30,6 +31,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
devtoolsWindow: DevtoolsWindowManager
|
||||
onWindowCreated?: (window: BrowserWindow) => void
|
||||
serverChannel: ServerChannel
|
||||
godotStageManager: GodotStageManager
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
windowAuthManager: WindowAuthManager
|
||||
@@ -69,6 +71,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
autoUpdater: params.autoUpdater,
|
||||
devtoolsWindow: params.devtoolsWindow,
|
||||
serverChannel: params.serverChannel,
|
||||
godotStageManager: params.godotStageManager,
|
||||
mcpStdioManager: params.mcpStdioManager,
|
||||
i18n: params.i18n,
|
||||
windowAuthManager: params.windowAuthManager,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
|
||||
import type { I18n } from '../../../libs/i18n'
|
||||
import type { WindowAuthManager } from '../../../services/airi/auth'
|
||||
import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { DevtoolsWindowManager } from '../../devtools'
|
||||
@@ -14,6 +15,7 @@ import { ipcMain } from 'electron'
|
||||
|
||||
import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa'
|
||||
import { createAuthService } from '../../../services/airi/auth'
|
||||
import { createGodotStageService } from '../../../services/airi/godot-stage'
|
||||
import { createMcpServersService } from '../../../services/airi/mcp-servers'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createAutoUpdaterService } from '../../../services/electron'
|
||||
@@ -25,6 +27,7 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
autoUpdater: AutoUpdater
|
||||
devtoolsWindow: DevtoolsWindowManager
|
||||
serverChannel: ServerChannel
|
||||
godotStageManager: GodotStageManager
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
windowAuthManager: WindowAuthManager
|
||||
@@ -41,6 +44,7 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow })
|
||||
createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater })
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
|
||||
createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager })
|
||||
|
||||
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
|
||||
|
||||
@@ -28,6 +28,8 @@ import ResizeHandler from './components/ResizeHandler.vue'
|
||||
|
||||
import {
|
||||
electronGetServerChannelConfig,
|
||||
electronGodotStageGetStatus,
|
||||
electronGodotStageStatusChanged,
|
||||
electronSettingsNavigate,
|
||||
electronStartTrackMousePosition,
|
||||
i18nSetLocale,
|
||||
@@ -91,10 +93,22 @@ const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
|
||||
const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition)
|
||||
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
|
||||
const setLocale = useElectronEventaInvoke(i18nSetLocale)
|
||||
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
|
||||
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
|
||||
const isChatWindowRoute = () => route.path === '/chat'
|
||||
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
|
||||
const isWidgetsWindowRoute = () => route.path === '/widgets'
|
||||
|
||||
function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) {
|
||||
if (state.state === 'running') {
|
||||
settingsStore.setStageModelRenderer('godot')
|
||||
return
|
||||
}
|
||||
|
||||
if ((state.state === 'stopped' || state.state === 'error') && settingsStore.stageModelRenderer === 'godot')
|
||||
settingsStore.restoreBuiltInStageModelRenderer()
|
||||
}
|
||||
|
||||
async function refreshPluginRuntimeTools() {
|
||||
try {
|
||||
await pluginToolsStore.refresh()
|
||||
@@ -175,6 +189,14 @@ context.value.on(electronSettingsNavigate, (event) => {
|
||||
})
|
||||
})
|
||||
|
||||
context.value.on(electronGodotStageStatusChanged, (event) => {
|
||||
if (!event.body) {
|
||||
return
|
||||
}
|
||||
|
||||
syncGodotStageRenderer(event.body)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
analyticsStore.initialize()
|
||||
await displayModelsStore.initialize()
|
||||
@@ -185,6 +207,15 @@ onMounted(async () => {
|
||||
await settingsStore.initializeStageModel()
|
||||
await settingsAudioDeviceStore.initialize()
|
||||
|
||||
if (isGodotStageRoute()) {
|
||||
try {
|
||||
syncGodotStageRenderer(await getGodotStageStatus())
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[App] Failed to fetch Godot stage status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const serverChannelConfig = await getServerChannelConfig()
|
||||
serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null
|
||||
serverChannelSettingsStore.hostname = serverChannelConfig.hostname
|
||||
|
||||
@@ -153,6 +153,18 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
renderer: 'godot',
|
||||
phase: hasModel ? 'mounted' : 'no-model',
|
||||
controlsLocked: false,
|
||||
previewAvailable: false,
|
||||
canCapturePreview: false,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -1,28 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
|
||||
import { ref } from 'vue'
|
||||
import type { DisplayModel } from '@proj-airi/stage-ui/stores/display-models'
|
||||
|
||||
import type {
|
||||
ElectronGodotStageSceneInputPayload,
|
||||
ElectronGodotStageStatus,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
|
||||
import { DisplayModelFormat } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
electronGodotStageApplySceneInput,
|
||||
electronGodotStageGetStatus,
|
||||
electronGodotStageStart,
|
||||
electronGodotStageStop,
|
||||
} from '../../../../shared/eventa'
|
||||
import { useModelSettingsRuntimeSnapshot } from '../../../composables/model-settings-runtime-snapshot'
|
||||
|
||||
const settingsStore = useSettings()
|
||||
const { stageModelRenderer, stageModelSelectedDisplayModel } = storeToRefs(settingsStore)
|
||||
const applyGodotStageSceneInput = useElectronEventaInvoke(electronGodotStageApplySceneInput)
|
||||
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
|
||||
const startGodotStage = useElectronEventaInvoke(electronGodotStageStart)
|
||||
const stopGodotStage = useElectronEventaInvoke(electronGodotStageStop)
|
||||
|
||||
const palette = ref<string[]>([])
|
||||
const godotStageError = ref<string>()
|
||||
const godotStageStatus = ref<ElectronGodotStageStatus>({
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
updatedAt: 0,
|
||||
})
|
||||
const switchingGodotStage = ref(false)
|
||||
const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot()
|
||||
|
||||
let latestSceneSyncRequest = 0
|
||||
|
||||
const usesGodotStage = computed(() => stageModelRenderer.value === 'godot')
|
||||
const godotToggleLabel = computed(() => usesGodotStage.value
|
||||
? 'Back to Built-in Stage'
|
||||
: 'Switch to Godot Stage (Experimental)')
|
||||
const godotStatusMessage = computed(() => {
|
||||
if (godotStageError.value)
|
||||
return godotStageError.value
|
||||
|
||||
if (godotStageStatus.value.state === 'error')
|
||||
return godotStageStatus.value.lastError
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
function createEmptyGodotStageStatus(): ElectronGodotStageStatus {
|
||||
return {
|
||||
state: 'stopped',
|
||||
pid: null,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function inferModelFileExtension(format: DisplayModelFormat) {
|
||||
switch (format) {
|
||||
case DisplayModelFormat.Live2dZip:
|
||||
return '.zip'
|
||||
case DisplayModelFormat.Live2dDirectory:
|
||||
return '.live2d'
|
||||
case DisplayModelFormat.VRM:
|
||||
return '.vrm'
|
||||
case DisplayModelFormat.PMXZip:
|
||||
return '.zip'
|
||||
case DisplayModelFormat.PMXDirectory:
|
||||
return '.pmxdir'
|
||||
case DisplayModelFormat.PMD:
|
||||
return '.pmd'
|
||||
default:
|
||||
return '.bin'
|
||||
}
|
||||
}
|
||||
|
||||
function inferModelFileName(model: DisplayModel) {
|
||||
if (model.type === 'file')
|
||||
return model.file.name
|
||||
|
||||
try {
|
||||
const url = new URL(model.url)
|
||||
const parsedName = url.pathname.split('/').pop()
|
||||
if (parsedName)
|
||||
return parsedName
|
||||
}
|
||||
catch {}
|
||||
|
||||
return `${model.id}${inferModelFileExtension(model.format)}`
|
||||
}
|
||||
|
||||
async function readSceneInputData(model: DisplayModel) {
|
||||
if (model.type === 'file')
|
||||
return new Uint8Array(await model.file.arrayBuffer())
|
||||
|
||||
const response = await fetch(model.url)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch model asset (${response.status} ${response.statusText})`)
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
async function createSceneInputPayload(model: DisplayModel): Promise<ElectronGodotStageSceneInputPayload> {
|
||||
return {
|
||||
modelId: model.id,
|
||||
format: model.format,
|
||||
name: model.name,
|
||||
fileName: inferModelFileName(model),
|
||||
data: await readSceneInputData(model),
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGodotStageStatus() {
|
||||
try {
|
||||
godotStageStatus.value = await getGodotStageStatus()
|
||||
}
|
||||
catch (error) {
|
||||
godotStageStatus.value = createEmptyGodotStageStatus()
|
||||
godotStageError.value = errorMessageFrom(error) ?? 'Failed to query Godot stage status.'
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGodotSceneInput(model: DisplayModel) {
|
||||
const requestId = ++latestSceneSyncRequest
|
||||
|
||||
try {
|
||||
const payload = await createSceneInputPayload(model)
|
||||
if (requestId !== latestSceneSyncRequest)
|
||||
return
|
||||
|
||||
await applyGodotStageSceneInput(payload)
|
||||
if (requestId !== latestSceneSyncRequest)
|
||||
return
|
||||
|
||||
godotStageError.value = undefined
|
||||
}
|
||||
catch (error) {
|
||||
if (requestId !== latestSceneSyncRequest)
|
||||
return
|
||||
|
||||
godotStageError.value = errorMessageFrom(error) ?? 'Failed to apply model input to Godot stage.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGodotStageToggle() {
|
||||
switchingGodotStage.value = true
|
||||
godotStageError.value = undefined
|
||||
|
||||
try {
|
||||
if (usesGodotStage.value) {
|
||||
godotStageStatus.value = await stopGodotStage()
|
||||
settingsStore.restoreBuiltInStageModelRenderer()
|
||||
return
|
||||
}
|
||||
|
||||
godotStageStatus.value = await startGodotStage()
|
||||
settingsStore.setStageModelRenderer('godot')
|
||||
}
|
||||
catch (error) {
|
||||
godotStageError.value = errorMessageFrom(error) ?? 'Failed to switch Godot stage mode.'
|
||||
await refreshGodotStageStatus()
|
||||
}
|
||||
finally {
|
||||
switchingGodotStage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[stageModelRenderer, stageModelSelectedDisplayModel, () => godotStageStatus.value.state],
|
||||
([renderer, model, stageState]) => {
|
||||
if (renderer !== 'godot' || stageState !== 'running' || !model)
|
||||
return
|
||||
|
||||
void syncGodotSceneInput(model)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshGodotStageStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['relative', 'h-full', 'flex', 'justify-center']">
|
||||
<ModelSettingsPanel
|
||||
:allow-extract-colors="false"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
:settings-class="[
|
||||
'w-full',
|
||||
'max-w-6xl',
|
||||
'h-fit',
|
||||
'sm:max-h-[80dvh]',
|
||||
'overflow-y-scroll',
|
||||
'relative',
|
||||
]"
|
||||
/>
|
||||
<div :class="['relative', 'h-full', 'flex flex-col items-center gap-3']">
|
||||
<Callout
|
||||
v-if="godotStatusMessage"
|
||||
:class="['w-full max-w-6xl']"
|
||||
label="Godot Stage"
|
||||
theme="orange"
|
||||
>
|
||||
<p>{{ godotStatusMessage }}</p>
|
||||
</Callout>
|
||||
|
||||
<div :class="['relative', 'h-full', 'flex justify-center', 'w-full']">
|
||||
<ModelSettingsPanel
|
||||
:allow-extract-colors="false"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
:settings-class="[
|
||||
'w-full',
|
||||
'max-w-6xl',
|
||||
'h-fit',
|
||||
'sm:max-h-[80dvh]',
|
||||
'overflow-y-scroll',
|
||||
'relative',
|
||||
]"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:loading="switchingGodotStage"
|
||||
:toggled="usesGodotStage"
|
||||
@click="handleGodotStageToggle"
|
||||
>
|
||||
{{ godotToggleLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</ModelSettingsPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -220,6 +220,56 @@ export const electronWindowSetAlwaysOnTop = defineInvokeEventa<void, boolean>('e
|
||||
export const electronAppOpenUserDataFolder = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:app:open-user-data-folder')
|
||||
export const electronAppQuit = defineInvokeEventa<void>('eventa:invoke:electron:app:quit')
|
||||
|
||||
export type ElectronGodotStageState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
|
||||
|
||||
/**
|
||||
* Snapshot of the Godot sidecar lifecycle owned by Electron main.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer windows need to reflect whether the external Godot window is available
|
||||
* - Settings or stage pages need lifecycle feedback after start/stop actions
|
||||
*
|
||||
* Expects:
|
||||
* - `pid` is only set while the Godot child process exists
|
||||
* - `lastError` is present for the most recent lifecycle or scene-apply failure
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface ElectronGodotStageStatus {
|
||||
state: ElectronGodotStageState
|
||||
pid: number | null
|
||||
lastError?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized scene input payload forwarded from renderer to Electron main.
|
||||
*
|
||||
* Use when:
|
||||
* - The selected model should be materialized to disk and applied to the Godot scene
|
||||
*
|
||||
* Expects:
|
||||
* - `data` contains the full model file bytes
|
||||
* - `fileName` matches the original model asset name when available
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface ElectronGodotStageSceneInputPayload {
|
||||
modelId: string
|
||||
format: string
|
||||
name: string
|
||||
fileName: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export const electronGodotStageStart = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:start')
|
||||
export const electronGodotStageStop = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:stop')
|
||||
export const electronGodotStageGetStatus = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:get-status')
|
||||
export const electronGodotStageApplySceneInput = defineInvokeEventa<void, ElectronGodotStageSceneInputPayload>('eventa:invoke:electron:godot-stage:apply-scene-input')
|
||||
export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageStatus>('eventa:event:electron:godot-stage:status-changed')
|
||||
|
||||
export type StageThreeRuntimeTraceEnvelope
|
||||
= | { type: 'three-render-info', payload: ThreeSceneRenderInfoTracePayload }
|
||||
| { type: 'three-hit-test-read', payload: ThreeHitTestReadTracePayload }
|
||||
|
||||
@@ -2,3 +2,42 @@ root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{cs,csproj,props,targets,sln}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.cs]
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
|
||||
csharp_style_var_for_built_in_types = false:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = false:suggestion
|
||||
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_prefer_braces = true:suggestion
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = false
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
|
||||
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||
|
||||
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.style = underscore_camel_case
|
||||
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.severity = suggestion
|
||||
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
@@ -46,6 +46,66 @@ Godot-native desktop stage runtime project for `stage-tamagotchi`.
|
||||
|
||||
Both commands currently run `dotnet build` against the Godot-generated C# project file.
|
||||
|
||||
## Development Runtime
|
||||
|
||||
The Electron development app does not export this project on every dev run. When
|
||||
Godot Stage is started from the Tamagotchi settings page, Electron main starts a
|
||||
local WebSocket bridge and launches a local Godot engine against this project:
|
||||
|
||||
```bash
|
||||
godot --path ./engines/stage-tamagotchi-godot -- --airi-ws-url=<runtime-url>
|
||||
```
|
||||
|
||||
Set `GODOT4` before starting the Electron development app. Dev mode requires an
|
||||
explicit Godot executable path and does not auto-discover local installations.
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:GODOT4 = "C:\Path\To\Godot_v4.x-stable_mono_win64.exe"
|
||||
pnpm dev:tamagotchi
|
||||
```
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
GODOT4="/path/to/godot" pnpm dev:tamagotchi
|
||||
```
|
||||
|
||||
With GodotEnv:
|
||||
|
||||
```bash
|
||||
GODOT4="$(godotenv godot env path)" pnpm dev:tamagotchi
|
||||
```
|
||||
|
||||
Keep machine-specific Godot paths outside the repository. The current Electron
|
||||
main service reads `process.env.GODOT4`, so the shell or local development
|
||||
environment must provide it before starting `pnpm dev:tamagotchi`.
|
||||
|
||||
## Exporting
|
||||
|
||||
Export presets produce the sidecar runtime that Electron packages for release:
|
||||
|
||||
```bash
|
||||
godot --headless --export-release "Windows Desktop" build/win/godot-stage.exe
|
||||
godot --headless --export-release "Linux" build/linux/godot-stage
|
||||
godot --headless --export-release "macOS" build/mac/godot-stage.app
|
||||
```
|
||||
|
||||
The output directories intentionally match electron-builder's `${os}` names:
|
||||
|
||||
- Windows: `build/win`
|
||||
- Linux: `build/linux`
|
||||
- macOS: `build/mac`
|
||||
|
||||
`apps/stage-tamagotchi/electron-builder.config.ts` copies the matching directory
|
||||
into `resources/godot-stage` via `extraResources`. To inspect an unpacked
|
||||
Electron build locally, run:
|
||||
|
||||
```bash
|
||||
pnpm -F @proj-airi/stage-tamagotchi run build:unpack
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
### Environment Management
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Stage Tamagotchi Godot C# Development Method
|
||||
|
||||
This guide applies only to `engines/stage-tamagotchi-godot`.
|
||||
|
||||
It defines how C# code in this Godot engine should be structured and how modern
|
||||
C# features should be used. Formatting and naming rules are secondary and live
|
||||
in the local `.editorconfig` plus `docs/csharp-style.md`.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
Use this guide for:
|
||||
|
||||
- Godot scene scripts
|
||||
- runtime coordinators and controllers
|
||||
- host-stage transport contracts
|
||||
- registry and discovery code
|
||||
- tooling and editor-support code inside this engine
|
||||
|
||||
Do not treat this guide as a repo-wide C# standard.
|
||||
|
||||
## 2. Layer Model
|
||||
|
||||
Split engine C# code into these layers before writing implementation:
|
||||
|
||||
1. Scene Script
|
||||
- Godot-owned `Node` or `Node3D` partial classes
|
||||
- lifecycle entrypoints and scene binding
|
||||
2. Runtime Core
|
||||
- plain C# runtime logic
|
||||
- controllers, coordinators, state holders, services
|
||||
3. Contract and Transport
|
||||
- message types
|
||||
- settings snapshots
|
||||
- ready, fatal, shutdown, and state-update payloads
|
||||
4. Registry and Discovery
|
||||
- descriptors
|
||||
- startup-time discovery
|
||||
- catalogues and lookup tables
|
||||
5. Tooling and Editor Support
|
||||
- inspector-facing helpers
|
||||
- import/export helpers
|
||||
- debug or editor-only data assembly
|
||||
|
||||
Do not collapse these responsibilities into a single Godot script by default.
|
||||
|
||||
## 3. Scene Script Rules
|
||||
|
||||
Scene scripts should stay thin.
|
||||
|
||||
Use scene scripts for:
|
||||
|
||||
- Godot lifecycle entrypoints such as `_Ready`
|
||||
- node lookup and scene wiring
|
||||
- handing control to runtime objects
|
||||
- bridging Godot callbacks into explicit runtime code
|
||||
|
||||
Avoid putting these directly into scene scripts unless the code is trivial:
|
||||
|
||||
- transport protocol handling
|
||||
- registry construction
|
||||
- complex state transitions
|
||||
- business or gameplay rules
|
||||
- large data transformation pipelines
|
||||
|
||||
If a scene script starts owning lifecycle, runtime state, protocol handling, and
|
||||
tool configuration at once, split the code.
|
||||
|
||||
## 4. Runtime Core Rules
|
||||
|
||||
Put durable runtime logic into plain C# objects first.
|
||||
|
||||
Prefer:
|
||||
|
||||
- small coordinators over large all-knowing classes
|
||||
- explicit state objects over hidden mutable flags
|
||||
- constructor or method injection of dependencies
|
||||
- clear call flow over implicit control transfer
|
||||
|
||||
Runtime core code should be easy to trace in a debugger. Favor explicit maps,
|
||||
state, and control flow over clever abstraction.
|
||||
|
||||
## 5. Contract and Transport Rules
|
||||
|
||||
Make cross-boundary communication type-driven.
|
||||
|
||||
Prefer explicit types for:
|
||||
|
||||
- transport messages
|
||||
- payloads
|
||||
- settings snapshots
|
||||
- descriptors
|
||||
- registry entries
|
||||
- runtime state snapshots
|
||||
|
||||
Avoid:
|
||||
|
||||
- `Dictionary<string, object?>` as a default contract shape
|
||||
- magic-string protocols spread across files
|
||||
- anonymous objects crossing subsystem boundaries
|
||||
- comments standing in for real type definitions
|
||||
|
||||
The rule is simple: define the boundary as types first, then implement the
|
||||
transport around those types.
|
||||
|
||||
## 6. Reflection and LINQ Policy
|
||||
|
||||
Reflection is for discovery, not execution.
|
||||
|
||||
Good uses of reflection:
|
||||
|
||||
- startup-time module discovery
|
||||
- attribute metadata reading
|
||||
- descriptor generation
|
||||
- editor or tooling support
|
||||
|
||||
Do not use reflection for:
|
||||
|
||||
- per-frame logic
|
||||
- runtime hot-path dispatch
|
||||
- core state-machine execution
|
||||
- repeated dynamic invocation in steady-state runtime
|
||||
|
||||
LINQ is for cold-path querying and data shaping.
|
||||
|
||||
Good uses of LINQ:
|
||||
|
||||
- building registries
|
||||
- filtering descriptors
|
||||
- configuration projection
|
||||
- debug or tooling views
|
||||
|
||||
Avoid heavy LINQ in hot paths, per-frame loops, or repeatedly executed runtime
|
||||
queries when an explicit index or dictionary would be clearer and cheaper.
|
||||
|
||||
Use this mental model:
|
||||
|
||||
- reflection builds the catalogue
|
||||
- LINQ shapes and queries the catalogue
|
||||
- runtime executes through explicit structures
|
||||
|
||||
## 7. Async Boundary Policy
|
||||
|
||||
Use async at I/O and process boundaries.
|
||||
|
||||
Good async boundaries:
|
||||
|
||||
- socket and transport setup
|
||||
- file I/O
|
||||
- host-side process interaction
|
||||
- startup loading that is naturally asynchronous
|
||||
|
||||
Avoid pushing async into:
|
||||
|
||||
- per-frame updates
|
||||
- core runtime loops
|
||||
- timing-sensitive behavior that should stay explicit
|
||||
|
||||
Do not use async to hide lifecycle or ordering problems.
|
||||
|
||||
## 8. Deferred Decisions
|
||||
|
||||
These items are intentionally deferred and should not be guessed:
|
||||
|
||||
- nullable reference type rollout policy
|
||||
- namespace strategy
|
||||
- `record` usage boundaries
|
||||
- `required` member usage boundaries
|
||||
- primary constructor usage boundaries
|
||||
- helper-layer versus scene-script feature allowances
|
||||
|
||||
When one of these becomes relevant, decide it explicitly and add it to the
|
||||
engine-local guidance instead of inferring it from style tools.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Stage Tamagotchi Godot C# Style
|
||||
|
||||
This is a secondary formatting and naming reference.
|
||||
|
||||
Read `docs/csharp-development-method.md` first for structure, layering, and
|
||||
feature-usage guidance. Use this file only for low-level code style.
|
||||
|
||||
This profile applies only to `engines/stage-tamagotchi-godot`.
|
||||
|
||||
It is a simple C# code-style baseline for this Godot engine. It does not define
|
||||
runtime structure, nullable policy, namespace policy, or broader architecture
|
||||
rules.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Microsoft: Common C# code conventions
|
||||
- Microsoft: .NET code style rule options
|
||||
- Godot: C# style guide
|
||||
|
||||
## Rules
|
||||
|
||||
- Use 4 spaces.
|
||||
- Use LF line endings.
|
||||
- Use UTF-8.
|
||||
- Keep lines at 100 columns when practical.
|
||||
- Use Allman braces.
|
||||
- Put `System.*` usings first, then sort the rest alphabetically.
|
||||
- Prefer C# keyword types such as `string`, `int`, and `bool`.
|
||||
- Use `var` only when the right-hand side makes the type obvious.
|
||||
- Use `PascalCase` for types and members.
|
||||
- Use `camelCase` for locals and parameters.
|
||||
- Use `_camelCase` for private fields.
|
||||
- Remove unused `using` directives.
|
||||
- Remove Godot template comments from touched files.
|
||||
- Remove empty lifecycle methods from touched files unless they are intentionally kept.
|
||||
- Keep `using` directives explicit. Do not enable implicit usings for this engine.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
These are intentionally not decided here:
|
||||
|
||||
- Runtime layering and design method
|
||||
- Nullable reference types
|
||||
- Namespace strategy
|
||||
- `record`, `required`, and primary constructors
|
||||
- DTO or helper-specific style rules
|
||||
|
||||
## Verification
|
||||
|
||||
When changing C# files or the local `.editorconfig`, verify from the engine
|
||||
directory with:
|
||||
|
||||
```powershell
|
||||
dotnet format --verify-no-changes
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
[preset.0]
|
||||
|
||||
name="Windows Desktop"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/win/godot-stage.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
codesign/enable=false
|
||||
application/modify_resources=true
|
||||
application/icon=""
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version=""
|
||||
application/product_version=""
|
||||
application/company_name=""
|
||||
application/product_name="Godot Stage"
|
||||
application/file_description="AIRI Godot Stage Sidecar"
|
||||
application/copyright=""
|
||||
application/trademarks=""
|
||||
application/export_angle=0
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.1]
|
||||
|
||||
name="Linux"
|
||||
platform="Linux/X11"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/linux/godot-stage"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
|
||||
[preset.1.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
ssh_remote_deploy/enabled=false
|
||||
|
||||
[preset.2]
|
||||
|
||||
name="macOS"
|
||||
platform="macOS"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="build/mac/godot-stage.app"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
|
||||
[preset.2.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
codesign/codesign=0
|
||||
codesign/entitlements/app_sandbox/enabled=false
|
||||
notarization/notarization=0
|
||||
application/bundle_identifier="ai.moeru.airi.godot-stage"
|
||||
application/short_version="1.0"
|
||||
application/version="1.0"
|
||||
application/icon=""
|
||||
ssh_remote_deploy/enabled=false
|
||||
@@ -1,16 +1,205 @@
|
||||
using Godot;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Godot;
|
||||
|
||||
public partial class StageRoot : Node3D
|
||||
{
|
||||
// Called when the node enters the scene tree for the first time.
|
||||
private const string WebSocketUrlArgumentPrefix = "--airi-ws-url=";
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly WebSocketPeer _socket = new();
|
||||
|
||||
private Label3D _statusLabel = null!;
|
||||
private bool _readyAnnounced;
|
||||
private bool _shutdownRequested;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
GD.Print("StageRoot ready");
|
||||
_statusLabel = CreateStatusLabel();
|
||||
AddChild(_statusLabel);
|
||||
|
||||
var webSocketUrl = ResolveWebSocketUrl();
|
||||
if (string.IsNullOrWhiteSpace(webSocketUrl))
|
||||
{
|
||||
UpdateStatus("Missing Electron bridge URL.");
|
||||
GD.PushError("Godot stage missing --airi-ws-url argument.");
|
||||
GetTree().Quit();
|
||||
return;
|
||||
}
|
||||
|
||||
var connectError = _socket.ConnectToUrl(webSocketUrl);
|
||||
if (connectError != Error.Ok)
|
||||
{
|
||||
UpdateStatus("Failed to connect to Electron main.");
|
||||
GD.PushError($"Godot stage failed to connect to {webSocketUrl}: {connectError}.");
|
||||
GetTree().Quit();
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatus("Connecting to Electron main...");
|
||||
GD.Print($"StageRoot connecting to {webSocketUrl}");
|
||||
}
|
||||
|
||||
// Called every frame. 'delta' is the elapsed time since the previous frame.
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
_socket.Poll();
|
||||
|
||||
switch (_socket.GetReadyState())
|
||||
{
|
||||
case WebSocketPeer.State.Open:
|
||||
if (!_readyAnnounced)
|
||||
{
|
||||
SendEnvelope("stage.ready");
|
||||
_readyAnnounced = true;
|
||||
UpdateStatus("Connected to Electron main.");
|
||||
}
|
||||
|
||||
while (_socket.GetAvailablePacketCount() > 0)
|
||||
{
|
||||
HandleMessage(_socket.GetPacket().GetStringFromUtf8());
|
||||
}
|
||||
break;
|
||||
case WebSocketPeer.State.Closed:
|
||||
if (_shutdownRequested)
|
||||
{
|
||||
GetTree().Quit();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = $"Electron bridge closed ({_socket.GetCloseCode()}).";
|
||||
UpdateStatus(message);
|
||||
GD.PushWarning(message);
|
||||
GetTree().Quit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Label3D CreateStatusLabel()
|
||||
{
|
||||
return new Label3D
|
||||
{
|
||||
Billboard = BaseMaterial3D.BillboardModeEnum.Enabled,
|
||||
FontSize = 56,
|
||||
Modulate = new Color(0.95f, 0.98f, 1.0f),
|
||||
PixelSize = 0.0035f,
|
||||
Position = new Vector3(0.0f, 1.35f, 0.0f),
|
||||
Text = "Godot Stage (experimental)",
|
||||
};
|
||||
}
|
||||
|
||||
private void HandleMessage(string rawMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<GodotEnvelope>(rawMessage, _jsonOptions);
|
||||
if (envelope == null || string.IsNullOrWhiteSpace(envelope.Type))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (envelope.Type)
|
||||
{
|
||||
case "host.scene.apply":
|
||||
ApplySceneInput(envelope.Payload);
|
||||
break;
|
||||
case "host.shutdown":
|
||||
_shutdownRequested = true;
|
||||
UpdateStatus("Shutdown requested by Electron main.");
|
||||
GetTree().Quit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var message = $"Failed to parse Electron message: {error.Message}";
|
||||
UpdateStatus(message);
|
||||
SendEnvelope("scene.error", new
|
||||
{
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySceneInput(JsonElement? payloadElement)
|
||||
{
|
||||
if (payloadElement == null)
|
||||
{
|
||||
SendEnvelope("scene.error", new
|
||||
{
|
||||
message = "Scene input payload was empty.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = payloadElement.Value.Deserialize<SceneApplyPayload>(_jsonOptions);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scene input payload could not be parsed.");
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(payload.Path);
|
||||
UpdateStatus($"Connected to Electron main.\nModel: {payload.Name}\nAsset: {fileName}");
|
||||
SendEnvelope("scene.applied", new
|
||||
{
|
||||
modelId = payload.ModelId,
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var message = $"Failed to apply scene input: {error.Message}";
|
||||
UpdateStatus(message);
|
||||
SendEnvelope("scene.error", new
|
||||
{
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWebSocketUrl()
|
||||
{
|
||||
foreach (var argument in OS.GetCmdlineUserArgs())
|
||||
{
|
||||
if (argument.StartsWith(WebSocketUrlArgumentPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return argument[WebSocketUrlArgumentPrefix.Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private void SendEnvelope(string type, object payload = null)
|
||||
{
|
||||
if (_socket.GetReadyState() != WebSocketPeer.State.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_socket.SendText(JsonSerializer.Serialize(new
|
||||
{
|
||||
type,
|
||||
payload,
|
||||
}, _jsonOptions));
|
||||
}
|
||||
|
||||
private void UpdateStatus(string message)
|
||||
{
|
||||
_statusLabel.Text = $"Godot Stage (experimental)\n{message}";
|
||||
}
|
||||
|
||||
private sealed record GodotEnvelope(string Type, JsonElement? Payload);
|
||||
|
||||
private sealed record SceneApplyPayload(
|
||||
string ModelId,
|
||||
string Format,
|
||||
string Name,
|
||||
string Path
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
runtimeSnapshot: ModelSettingsRuntimeSnapshot
|
||||
}>()
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (props.runtimeSnapshot.phase === 'no-model')
|
||||
return 'Select Model continues to update the active scene input. Godot-specific stage controls will land here later.'
|
||||
|
||||
return 'Model selection stays active while the embedded scene renderer is paused. Godot-specific stage controls will land here later.'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Callout label="Godot Stage (Experimental)">
|
||||
<p>{{ statusText }}</p>
|
||||
</Callout>
|
||||
</template>
|
||||
@@ -7,6 +7,7 @@ import { Button, Callout } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Godot from './godot.vue'
|
||||
import Live2D from './live2d.vue'
|
||||
import VRM from './vrm.vue'
|
||||
|
||||
@@ -68,12 +69,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
uses 3D model that is driven by VRM / MMD open formats.
|
||||
</p>
|
||||
</Callout>
|
||||
<div :class="['flex flex-wrap gap-2']">
|
||||
<div :class="['flex flex-wrap items-center gap-2']">
|
||||
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="currentSelectedDisplayModel" @pick="handleModelPick">
|
||||
<Button variant="secondary">
|
||||
Select Model
|
||||
</Button>
|
||||
</ModelSelectorDialog>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<Live2D
|
||||
v-if="effectiveRenderer === 'live2d'"
|
||||
@@ -89,5 +91,9 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Godot
|
||||
v-if="effectiveRenderer === 'godot'"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +106,18 @@ const runtimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
renderer: 'godot',
|
||||
phase: hasModel ? 'mounted' : 'no-model',
|
||||
controlsLocked: false,
|
||||
previewAvailable: false,
|
||||
canCapturePreview: false,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm'
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'godot'
|
||||
export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error'
|
||||
|
||||
export interface ModelSettingsRuntimeSnapshot {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { ThreeScene } from '@proj-airi/stage-ui-three'
|
||||
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
|
||||
import { createQueue } from '@proj-airi/stream-kit'
|
||||
import { Callout } from '@proj-airi/ui'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
// import { createTransformers } from '@xsai-transformers/embed'
|
||||
// import embedWorkerURL from '@xsai-transformers/embed/worker?worker&url'
|
||||
@@ -532,6 +533,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
watch([stageModelRenderer, () => props.paused], ([renderer]) => {
|
||||
if (renderer === 'godot') {
|
||||
componentState.value = 'mounted'
|
||||
}
|
||||
|
||||
if (renderer !== 'live2d') {
|
||||
resetLive2dLipSync()
|
||||
return
|
||||
@@ -673,6 +678,26 @@ defineExpose({
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
/>
|
||||
<div
|
||||
v-if="stageModelRenderer === 'godot'"
|
||||
:class="[
|
||||
'h-full w-full',
|
||||
'flex items-center justify-center',
|
||||
'px-4 py-6',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-96 max-w-full',
|
||||
'min-h-32',
|
||||
'flex items-center justify-center',
|
||||
]"
|
||||
>
|
||||
<Callout label="Godot Stage (Experimental)">
|
||||
<p>Godot Stage (experimental) is running...</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,6 +96,8 @@ export const useSettings = defineStore('settings', () => {
|
||||
applyPrimaryColorFrom: theme.applyPrimaryColorFrom,
|
||||
isColorSelectedForPrimary: theme.isColorSelectedForPrimary,
|
||||
initializeStageModel: stageModel.initializeStageModel,
|
||||
restoreBuiltInStageModelRenderer: stageModel.restoreBuiltInStageModelRenderer,
|
||||
setStageModelRenderer: stageModel.setStageModelRenderer,
|
||||
updateStageModel: stageModel.updateStageModel,
|
||||
resetState,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
|
||||
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'disabled' | undefined
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'godot' | 'disabled' | undefined
|
||||
type BuiltInStageModelRenderer = Exclude<StageModelRenderer, 'godot'>
|
||||
|
||||
export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
@@ -24,6 +25,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
const stageModelSelectedDisplayModel = refManualReset<DisplayModel | undefined>(undefined)
|
||||
const stageModelSelectedUrl = refManualReset<string | undefined>(undefined)
|
||||
const stageModelRenderer = refManualReset<StageModelRenderer>(undefined)
|
||||
const stageModelBuiltInRenderer = refManualReset<BuiltInStageModelRenderer>(undefined)
|
||||
|
||||
const stageViewControlsEnabled = refManualReset<boolean>(false)
|
||||
|
||||
@@ -40,6 +42,21 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
stageModelSelectedUrl.value = nextUrl
|
||||
}
|
||||
|
||||
function resolveBuiltInStageModelRenderer(model?: DisplayModel): BuiltInStageModelRenderer {
|
||||
if (!model) {
|
||||
return 'disabled'
|
||||
}
|
||||
|
||||
switch (model.format) {
|
||||
case DisplayModelFormat.Live2dZip:
|
||||
return 'live2d'
|
||||
case DisplayModelFormat.VRM:
|
||||
return 'vrm'
|
||||
default:
|
||||
return 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStageModel() {
|
||||
const requestId = ++stageModelUpdateSequence
|
||||
const selectedModelId = stageModelSelectedState.value
|
||||
@@ -47,7 +64,9 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
if (!selectedModelId) {
|
||||
replaceStageModelUrl(undefined)
|
||||
stageModelSelectedDisplayModel.value = undefined
|
||||
stageModelRenderer.value = 'disabled'
|
||||
stageModelBuiltInRenderer.value = 'disabled'
|
||||
if (stageModelRenderer.value !== 'godot')
|
||||
stageModelRenderer.value = 'disabled'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,21 +77,16 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
if (!model) {
|
||||
replaceStageModelUrl(undefined)
|
||||
stageModelSelectedDisplayModel.value = undefined
|
||||
stageModelRenderer.value = 'disabled'
|
||||
stageModelBuiltInRenderer.value = 'disabled'
|
||||
if (stageModelRenderer.value !== 'godot')
|
||||
stageModelRenderer.value = 'disabled'
|
||||
return
|
||||
}
|
||||
|
||||
switch (model.format) {
|
||||
case DisplayModelFormat.Live2dZip:
|
||||
stageModelRenderer.value = 'live2d'
|
||||
break
|
||||
case DisplayModelFormat.VRM:
|
||||
stageModelRenderer.value = 'vrm'
|
||||
break
|
||||
default:
|
||||
stageModelRenderer.value = 'disabled'
|
||||
break
|
||||
}
|
||||
const builtInRenderer = resolveBuiltInStageModelRenderer(model)
|
||||
stageModelBuiltInRenderer.value = builtInRenderer
|
||||
if (stageModelRenderer.value !== 'godot')
|
||||
stageModelRenderer.value = builtInRenderer
|
||||
|
||||
if (model.type === 'file') {
|
||||
const nextUrl = URL.createObjectURL(model.file)
|
||||
@@ -90,6 +104,14 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
stageModelSelectedDisplayModel.value = model
|
||||
}
|
||||
|
||||
function setStageModelRenderer(renderer: StageModelRenderer) {
|
||||
stageModelRenderer.value = renderer
|
||||
}
|
||||
|
||||
function restoreBuiltInStageModelRenderer() {
|
||||
stageModelRenderer.value = stageModelBuiltInRenderer.value ?? 'disabled'
|
||||
}
|
||||
|
||||
async function initializeStageModel() {
|
||||
await updateStageModel()
|
||||
}
|
||||
@@ -109,6 +131,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
stageModelSelectedDisplayModel.reset()
|
||||
stageModelSelectedUrl.reset()
|
||||
stageModelRenderer.reset()
|
||||
stageModelBuiltInRenderer.reset()
|
||||
stageViewControlsEnabled.reset()
|
||||
|
||||
await updateStageModel()
|
||||
@@ -122,6 +145,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
stageViewControlsEnabled,
|
||||
|
||||
initializeStageModel,
|
||||
restoreBuiltInStageModelRenderer,
|
||||
setStageModelRenderer,
|
||||
updateStageModel,
|
||||
resetState,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user