From 3bf433a1298f006497c58762cb0c0048422109d7 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 30 Sep 2026 11:36:14 +0900 Subject: [PATCH 1/2] fix(launcher): check and install FUSE dependencies before LMS setup and launch --- electron.vite.config.ts | 5 ++ package.json | 10 ++-- scripts/test-appimage-fuse.cjs | 70 ++++++++++++++++++++++++++++ src/main/appImageFuse.ts | 65 ++++++++++++++++++++++++++ src/main/index.ts | 48 ++++++++++++++++++- src/renderer/src/pages/Dashboard.tsx | 7 +++ 6 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 scripts/test-appimage-fuse.cjs create mode 100644 src/main/appImageFuse.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index bedaab9..65d5364 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,9 +8,13 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8')) export default defineConfig({ main: { + resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] }, + build: { rollupOptions: { input: resolve('src/main/index.ts') } }, plugins: [externalizeDepsPlugin()] }, preload: { + resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] }, + build: { rollupOptions: { input: resolve('src/preload/index.ts') } }, plugins: [externalizeDepsPlugin()] }, renderer: { @@ -19,6 +23,7 @@ export default defineConfig({ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(version) }, resolve: { + extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'], alias: { '@renderer': resolve('src/renderer/src') } diff --git a/package.json b/package.json index b3542fe..a152335 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autonomy-studio-launcher", - "version": "1.1.2", + "version": "1.1.3", "description": "Autonomy Studio v2 Frontend", "main": "./out/main/index.js", "author": { @@ -14,13 +14,13 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", - "start": "electron-vite preview", - "dev": "electron-vite dev", - "build": "npm run typecheck && electron-vite build", + "start": "electron-vite preview --config electron.vite.config.ts", + "dev": "electron-vite dev --config electron.vite.config.ts", + "build": "npm run typecheck && electron-vite build --config electron.vite.config.ts", "postinstall": "electron-builder install-app-deps", "build:unpack": "npm run build && electron-builder --dir", "build:win": "npm run build && electron-builder --win", - "build:mac": "electron-vite build && electron-builder --mac", + "build:mac": "electron-vite build --config electron.vite.config.ts && electron-builder --mac", "build:linux": "npm run build && electron-builder --linux" }, "build": { diff --git a/scripts/test-appimage-fuse.cjs b/scripts/test-appimage-fuse.cjs new file mode 100644 index 0000000..bcd6b9a --- /dev/null +++ b/scripts/test-appimage-fuse.cjs @@ -0,0 +1,70 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const ts = require('typescript') +const source = readFileSync(join(__dirname, '../src/main/appImageFuse.ts'), 'utf8') +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText +const api = {} +new Function('exports', compiled)(api) +const cache = 'libfuse.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libfuse.so.2' + +function fixture(options = {}) { + let installed = Boolean(options.installed) + const calls = [] + const prepare = api.createFusePreparation({ + platform: options.platform ?? 'linux', arch: 'x64', + readOsRelease: () => `ID=ubuntu\nVERSION_ID="${options.version ?? '24.04'}"\nPRETTY_NAME="Ubuntu"`, + confirm: async () => { calls.push('confirm'); return options.accept !== false }, + run: async (command, args) => { + calls.push([command, ...args]) + if (command === '/sbin/ldconfig') return { stdout: installed ? cache : '', stderr: '' } + if (command === '/usr/bin/apt-cache') return { stdout: options.noCandidate ? 'Candidate: (none)' : 'Candidate: 2.9.9', stderr: '' } + if (command === 'pkexec') { + if (options.fail) throw new Error('authentication cancelled') + installed = options.stillMissing !== true + return { stdout: '', stderr: '' } + } + throw new Error(`Unexpected command ${command}`) + }, + }) + return { prepare, calls } +} + +test('FUSE 3 and a foreign architecture do not satisfy FUSE 2', () => { + assert.equal(api.hasFuseLibrary(cache, 'x64'), true) + assert.equal(api.hasFuseLibrary(cache, 'arm64'), false) + assert.equal(api.hasFuseLibrary('libfuse3.so.3 (libc6,x86-64)', 'x64'), false) +}) +test('existing library and non-Linux skip installation', async () => { + for (const options of [{ installed: true }, { platform: 'darwin' }]) { + const f = fixture(options) + await f.prepare() + assert.equal(f.calls.includes('confirm'), false) + } +}) +test('Ubuntu versions choose the appropriate package and recheck installation', async () => { + for (const [version, pkg] of [['22.04', 'libfuse2'], ['24.04', 'libfuse2t64'], ['26.04', 'libfuse2t64']]) { + const f = fixture({ version }) + await f.prepare() + assert.deepEqual(f.calls.find((call) => call[0] === 'pkexec'), ['pkexec', '/usr/bin/apt-get', 'install', '-y', '--no-remove', pkg]) + assert.equal(f.calls.filter((call) => call[0] === '/sbin/ldconfig').length, 2) + } +}) +test('cancel does not install and can be retried', async () => { + const f = fixture({ accept: false }) + await assert.rejects(f.prepare(), /취소/) + await assert.rejects(f.prepare(), /취소/) + assert.equal(f.calls.some((call) => call[0] === 'pkexec'), false) +}) +test('missing candidate, failed authentication and failed postcheck reject', async () => { + for (const options of [{ noCandidate: true }, { fail: true }, { stillMissing: true }]) { + await assert.rejects(fixture(options).prepare()) + } +}) +test('concurrent preparations share one prompt and installation', async () => { + const f = fixture() + await Promise.all([f.prepare(), f.prepare()]) + assert.equal(f.calls.filter((call) => call === 'confirm').length, 1) + assert.equal(f.calls.filter((call) => call[0] === 'pkexec').length, 1) +}) diff --git a/src/main/appImageFuse.ts b/src/main/appImageFuse.ts new file mode 100644 index 0000000..367b540 --- /dev/null +++ b/src/main/appImageFuse.ts @@ -0,0 +1,65 @@ +export type FusePackage = 'libfuse2' | 'libfuse2t64' + +export function parseOsRelease(content: string): Record { + return Object.fromEntries(content.split('\n').flatMap((line) => { + const match = line.match(/^([A-Z_]+)=(.*)$/) + return match ? [[match[1], match[2].replace(/^(["'])(.*)\1$/, '$2')]] : [] + })) +} + +export function fusePackageCandidates(os: Record): FusePackage[] { + const modern = (os.ID === 'ubuntu' && Number.parseFloat(os.VERSION_ID) >= 24.04) + || (os.ID === 'debian' && Number.parseFloat(os.VERSION_ID) >= 13) + return modern ? ['libfuse2t64', 'libfuse2'] : ['libfuse2', 'libfuse2t64'] +} + +export function hasFuseLibrary(cache: string, arch: string): boolean { + const architecture = arch === 'x64' ? 'x86-64' : arch === 'arm64' ? 'AArch64' : null + return cache.split('\n').some((line) => /^\s*libfuse\.so\.2\s/.test(line) + && (!architecture || line.includes(architecture))) +} + +type Dependencies = { + platform: string + arch: string + readOsRelease: () => string + run: (command: string, args: string[], timeout?: number) => Promise<{ stdout: string; stderr: string }> + confirm: (details: string) => Promise +} + +export function createFusePreparation(deps: Dependencies): () => Promise { + let pending: Promise | null = null + const ready = async (): Promise => { + const result = await deps.run('/sbin/ldconfig', ['-p'], 10000) + return hasFuseLibrary(result.stdout, deps.arch) + } + const prepare = async (): Promise => { + if (deps.platform !== 'linux' || await ready()) return + const os = parseOsRelease(deps.readOsRelease()) + if (![os.ID, ...(os.ID_LIKE ?? '').split(/\s+/)].some((id) => id === 'ubuntu' || id === 'debian')) { + throw new Error('LMS 실행에 libfuse.so.2가 필요합니다. 이 운영체제에서는 자동 설치를 지원하지 않습니다. 배포판의 FUSE 2 라이브러리를 설치한 뒤 다시 실행하세요.') + } + let selected: FusePackage | undefined + for (const candidate of fusePackageCandidates(os)) { + const result = await deps.run('/usr/bin/apt-cache', ['policy', candidate], 10000) + if (/Candidate:\s*(?!\(none\))\S+/.test(result.stdout)) { + selected = candidate + break + } + } + if (!selected) throw new Error('FUSE 2 설치 패키지를 찾지 못했습니다. 시스템 패키지 목록과 Ubuntu 저장소 설정을 확인한 뒤 다시 시도하세요.') + if (!await deps.confirm(`${os.PRETTY_NAME ?? os.ID}\n\nLMS 실행에 필요한 ${selected} 라이브러리가 없습니다. 설치를 누르면 관리자 인증 후 설치합니다.`)) { + throw new Error('필수 구성요소 설치를 취소했습니다. 다음 설치 또는 실행 시 다시 진행할 수 있습니다.') + } + try { + await deps.run('pkexec', ['/usr/bin/apt-get', 'install', '-y', '--no-remove', selected], 600000) + } catch (error) { + throw new Error(`필수 구성요소 설치가 완료되지 않았습니다. 관리자 인증 취소, 네트워크 또는 패키지 관리자 잠금 상태를 확인하세요.\n${error instanceof Error ? error.message : String(error)}`) + } + if (!await ready()) throw new Error('설치 후에도 libfuse.so.2를 확인하지 못했습니다. 시스템 패키지 상태를 확인한 뒤 다시 시도하세요.') + } + return () => { + if (!pending) pending = prepare().finally(() => { pending = null }) + return pending + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 8c7a48d..342b317 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { get as httpsGet } from 'https' import { spawn, exec, execFile, ChildProcess } from 'child_process' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { LmsWebBridge } from './lms-web-bridge' +import { createFusePreparation } from './appImageFuse' protocol.registerSchemesAsPrivileged([ { scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } } @@ -14,6 +15,35 @@ protocol.registerSchemesAsPrivileged([ let splash: BrowserWindow | null = null let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null const lmsWebBridge = new LmsWebBridge() +const prepareLmsFuse = createFusePreparation({ + platform: process.platform, + arch: process.arch, + readOsRelease: () => readFileSync('/etc/os-release', 'utf8'), + run: (command, args, timeout) => runCommand(command, args, timeout, { LC_ALL: 'C', LANG: 'C' }), + confirm: async (detail) => { + const options = { + type: 'info' as const, + title: 'LMS 실행 환경 준비', + message: '필수 구성요소를 설치하시겠습니까?', + detail, + buttons: ['설치', '취소'], + defaultId: 0, + cancelId: 1, + noLink: true, + } + const parent = BrowserWindow.getFocusedWindow() + const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options) + return result.response === 0 + }, +}) + +async function prepareAppRuntime(payload: { appName: string; execPath?: string; devAppPath?: string }): Promise { + if (payload.appName.trim().toLowerCase() !== 'lms') return + const devPath = resolveDevAppPath(payload.devAppPath) ?? getDefaultDevAppPath(payload.appName) + if (is.dev && devPath && isRunnableDevAppPath(devPath)) return + const path = resolveExecPath(payload.execPath, payload.appName) + if (path?.toLowerCase().endsWith('.appimage')) await prepareLmsFuse() +} type CommandResult = { ok: boolean @@ -952,6 +982,10 @@ function createWindow(): void { const targetPath = resolveExecPath(payload.execPath, payload.appName) if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.') + if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) { + await prepareLmsFuse() + } + await downloadFile(payload.downloadUrl, targetPath, (progress) => { event.sender.send('app-download-progress', { appName: payload.appName, @@ -1094,7 +1128,12 @@ function createWindow(): void { return { ok: true } }) - ipcMain.on('launch-app', (_, payload: { + ipcMain.handle('prepare-app-runtime', async (_event, payload: { appName: string; execPath?: string; devAppPath?: string }) => { + await prepareAppRuntime(payload) + return { ok: true } + }) + + ipcMain.on('launch-app', async (_, payload: { appName: string url?: string width?: number @@ -1104,6 +1143,13 @@ function createWindow(): void { handoffCode?: string audienceApp?: string }) => { + try { + await prepareAppRuntime(payload) + } catch (error) { + mainWindow.webContents.send('app-window-closed', payload.appName) + dialog.showErrorBox('LMS 실행 환경 준비 실패', error instanceof Error ? error.message : String(error)) + return + } const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined const authLaunchArgs = handoffCode diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 5e2dd0c..a239182 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -778,6 +778,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: let handoffCode: string | undefined try { + await window.electron.ipcRenderer.invoke('prepare-app-runtime', { + appName: activeApp, + execPath: appConfig.execPath, + devAppPath: appConfig.devAppPath, + }) const handoffAudienceApp = resolveHandoffAudienceApp(activeApp) if (handoffAudienceApp) { const [handoffToken] = await Promise.all([ @@ -803,6 +808,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: }) window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp) setRunningApps((prev) => new Set([...prev, activeApp])) + } catch (error) { + alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.') } finally { setLaunchingApp(null) } From 9bbad61fc574dacdae7ec695405e5fa8624288ed Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 12 Oct 2026 17:37:19 +0900 Subject: [PATCH 2/2] fix(launcher): prevent unnecessary Tailscale resets and improve launch status --- package.json | 2 +- src/renderer/src/assets/main.css | 1 + src/renderer/src/pages/Dashboard.tsx | 16 ++++++++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a152335..fb92d07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autonomy-studio-launcher", - "version": "1.1.3", + "version": "1.1.4", "description": "Autonomy Studio v2 Frontend", "main": "./out/main/index.js", "author": { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0cfc2ed..91e2193 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -355,6 +355,7 @@ body, display: flex; align-items: center; justify-content: center; + gap: 7px; padding: 7px 16px; min-width: 78px; background: rgba(255, 255, 255, 0.08); diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index a239182..a8eedd4 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -273,6 +273,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: return } + const hasAccountIdentity = Boolean( + status?.loginName?.trim() || status?.userName?.trim(), + ) const isSameAccount = Boolean( status && ( @@ -280,7 +283,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: isSameTailscaleAccount(status.userName, userEmail) ), ) - if (status?.connected && isSameAccount) { + if (status?.connected && (!hasAccountIdentity || isSameAccount)) { setTailscaleIssue(null) return } @@ -315,11 +318,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: return } + const shouldResetSession = Boolean( + status && + status.backendState !== 'Unavailable' && + hasAccountIdentity && + !isSameAccount, + ) const result = await launcherTailscale.login( provision.tailscaleAuthKey, provision.tailscaleLoginServer ?? '', provision.tailscaleHostname, - Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount), + shouldResetSession, ) if (!result.ok) { setTailscaleIssue({ @@ -996,6 +1005,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: ) : canLaunch ? ( activeAppLaunching ? ( ) : (