import { app, shell, BrowserWindow, ipcMain, protocol, net, session as electronSession, dialog } from 'electron' import { join, dirname, resolve, relative, isAbsolute } from 'path' import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs' import { get as httpGet } from 'http' 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' protocol.registerSchemesAsPrivileged([ { scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } } ]) let splash: BrowserWindow | null = null let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null const lmsWebBridge = new LmsWebBridge() type CommandResult = { ok: boolean message: string } type TailscaleStatusResult = { connected: boolean ip: string backendState: string online?: boolean health?: string[] loginName?: string userName?: string peers?: TailscalePeerStatus[] error?: string } type TailscalePeerStatus = { id: string hostName: string dnsName?: string os?: string userName?: string loginName?: string ip: string ips: string[] online: boolean active: boolean lastSeen?: string relay?: string currentAddress?: string } type SshKeyResult = { publicKey: string publicKeyPath: string privateKeyPath: string } function getLauncherDir(): string { if (process.env.APPIMAGE) return dirname(process.env.APPIMAGE) if (is.dev) return process.cwd() return dirname(process.execPath) } function dispatchGoogleOAuthPayload(payload: { loginToken?: string; error?: string }): void { const windows = BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()) if (windows.length === 0) { pendingGoogleOAuthPayload = payload return } pendingGoogleOAuthPayload = null for (const win of windows) { win.webContents.send('google-oauth-callback', payload) } } function handleProtocolUrl(rawUrl: string): void { let url: URL try { url = new URL(rawUrl) } catch { return } if (url.protocol !== 'autonomy-studio:' || url.hostname !== 'auth' || url.pathname !== '/google/callback') { return } dispatchGoogleOAuthPayload({ loginToken: url.searchParams.get('loginToken') ?? undefined, error: url.searchParams.get('error') ?? undefined, }) } function escapeDesktopExecValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } function registerLinuxProtocolHandler(): void { if (process.platform !== 'linux') return try { const applicationsDir = join(app.getPath('home'), '.local', 'share', 'applications') const installedDesktopFilePath = join(applicationsDir, 'autonomy-studio-launcher.desktop') if (!is.dev) { rmSync(installedDesktopFilePath, { force: true }) execFile('xdg-mime', ['default', 'autonomy-studio-launcher.desktop', 'x-scheme-handler/autonomy-studio'], () => undefined) execFile('update-desktop-database', [applicationsDir], () => undefined) return } const desktopFileName = 'autonomy-studio-launcher-dev.desktop' const desktopFilePath = join(applicationsDir, desktopFileName) const iconPath = join(process.cwd(), 'resources/icon.png') const execArgs = process.defaultApp && process.argv[1] ? `"${escapeDesktopExecValue(process.execPath)}" "${escapeDesktopExecValue(resolve(process.argv[1]))}" %u` : `"${escapeDesktopExecValue(process.execPath)}" %u` mkdirSync(applicationsDir, { recursive: true }) writeFileSync(desktopFilePath, [ '[Desktop Entry]', 'Type=Application', 'Name=Autonomy Studio Launcher', `Exec=${execArgs}`, `Icon=${iconPath}`, 'Terminal=false', 'NoDisplay=true', 'MimeType=x-scheme-handler/autonomy-studio;', '' ].join('\n'), 'utf8') execFile('xdg-mime', ['default', desktopFileName, 'x-scheme-handler/autonomy-studio'], () => undefined) execFile('update-desktop-database', [applicationsDir], () => undefined) } catch (error) { console.warn('Failed to register autonomy-studio protocol handler', error) } } function getUserAppsDir(): string { return join(app.getPath('home'), '.local', 'share', 'autonomy-studio', 'apps') } function sanitizeAppFileName(appName: string): string { return appName.trim().replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'app' } function getDefaultExecPath(appName?: string): string | undefined { if (!appName) return undefined return join(getUserAppsDir(), `${sanitizeAppFileName(appName)}.AppImage`) } function resolveExecPath(execPath?: string, appName?: string): string | undefined { const trimmed = execPath?.trim() if (!trimmed) return getDefaultExecPath(appName) if (trimmed.startsWith('~/')) return join(app.getPath('home'), trimmed.slice(2)) if (trimmed.startsWith('${HOME}/')) return join(app.getPath('home'), trimmed.slice('${HOME}/'.length)) if (trimmed.startsWith('$HOME/')) return join(app.getPath('home'), trimmed.slice('$HOME/'.length)) return trimmed.startsWith('./') || trimmed.startsWith('../') ? resolve(getUserAppsDir(), trimmed) : trimmed } function getWorkspaceRoot(): string { return is.dev ? resolve(process.cwd(), '..') : getLauncherDir() } function getLauncherSshKeyPath(): string { return join(app.getPath('home'), '.autonomy-studio', 'ssh', 'id_ed25519') } function resolveDevAppPath(devAppPath?: string): string | undefined { if (!devAppPath) return undefined return devAppPath.startsWith('./') || devAppPath.startsWith('../') ? resolve(getWorkspaceRoot(), devAppPath) : devAppPath } function getDefaultDevAppPath(appName: string): string | undefined { const devPathByApp: Record = { 'ADS IDE': './apps/ads-ide/ads-ide-fe', LMS: './apps/lms/lms-fe', } return resolveDevAppPath(devPathByApp[appName]) } function isRunnableDevAppPath(devAppPath?: string): boolean { return Boolean(devAppPath && existsSync(join(devAppPath, 'package.json'))) } function writeDevHandoffFile(appName: string, appUrl: string, handoffToken: string, audienceApp: string): string { const safeAppName = appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() const filePath = join(app.getPath('temp'), `autonomy-studio-${safeAppName}-handoff-${process.pid}.json`) writeFileSync(filePath, JSON.stringify({ appUrl, handoffToken, audienceApp, createdAt: new Date().toISOString() })) return filePath } function getVersionFilePath(targetPath: string): string { return `${targetPath}.version` } function assertUserAppPath(targetPath: string) { const relativePath = relative(getUserAppsDir(), targetPath) if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { throw new Error('앱 설치 디렉터리 밖의 파일은 삭제할 수 없습니다.') } } function readInstalledVersion(targetPath?: string): string | null { if (!targetPath) return null const versionFilePath = getVersionFilePath(targetPath) if (!existsSync(versionFilePath)) return null try { return readFileSync(versionFilePath, 'utf8').trim() || null } catch { return null } } function removeMatchingFiles(dirPath: string, shouldRemove: (fileName: string) => boolean): number { if (!existsSync(dirPath)) return 0 let removed = 0 for (const entry of readdirSync(dirPath)) { const fullPath = join(dirPath, entry) try { if (statSync(fullPath).isFile() && shouldRemove(entry)) { rmSync(fullPath, { force: true }) removed += 1 } } catch { // Ignore files that disappear while cleanup is running. } } return removed } function countMatchingFiles(dirPath: string, shouldCount: (fileName: string) => boolean): number { if (!existsSync(dirPath)) return 0 let count = 0 for (const entry of readdirSync(dirPath)) { const fullPath = join(dirPath, entry) try { if (statSync(fullPath).isFile() && shouldCount(entry)) { count += 1 } } catch { // Ignore files that disappear while cache state is being checked. } } return count } async function getLauncherCacheStatus(): Promise<{ available: boolean; files: number; rendererCacheBytes: number }> { const tempFiles = countMatchingFiles(app.getPath('temp'), (fileName) => /^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName) ) const downloadFiles = countMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download')) const rendererCacheBytes = await electronSession.defaultSession.getCacheSize() return { available: tempFiles + downloadFiles > 0 || rendererCacheBytes > 0, files: tempFiles + downloadFiles, rendererCacheBytes, } } async function clearLauncherCache(): Promise<{ ok: true; removedFiles: number }> { const removedTempFiles = removeMatchingFiles(app.getPath('temp'), (fileName) => /^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName) ) const removedDownloadFiles = removeMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download')) await electronSession.defaultSession.clearCache() return { ok: true, removedFiles: removedTempFiles + removedDownloadFiles, } } function downloadFile( url: string, targetPath: string, onProgress: (progress: number) => void, ): Promise { const tmpPath = `${targetPath}.download` const client = url.startsWith('https:') ? httpsGet : httpGet return new Promise((resolve, reject) => { mkdirSync(dirname(targetPath), { recursive: true }) rmSync(tmpPath, { force: true }) const request = client(url, (response) => { const statusCode = response.statusCode ?? 0 const redirectUrl = response.headers.location if (statusCode >= 300 && statusCode < 400 && redirectUrl) { response.resume() const nextUrl = new URL(redirectUrl, url).toString() downloadFile(nextUrl, targetPath, onProgress).then(resolve).catch(reject) return } if (statusCode < 200 || statusCode >= 300) { response.resume() reject(new Error(`Download failed with status ${statusCode}`)) return } const total = Number(response.headers['content-length'] ?? 0) let received = 0 const file = createWriteStream(tmpPath) response.on('data', (chunk: Buffer) => { received += chunk.length if (total > 0) { onProgress(Math.min(99, Math.round((received / total) * 100))) } }) response.pipe(file) file.on('finish', () => { file.close(() => { renameSync(tmpPath, targetPath) chmodSync(targetPath, 0o755) onProgress(100) resolve() }) }) file.on('error', (error) => { rmSync(tmpPath, { force: true }) reject(error) }) }) request.on('error', (error) => { rmSync(tmpPath, { force: true }) reject(error) }) }) } function installDebPackage(debPath: string): Promise { return new Promise((resolve, reject) => { const child = spawn('pkexec', ['dpkg', '-i', debPath], { stdio: 'ignore' }) child.on('error', (error) => { reject(error) }) child.on('close', (code) => { if (code === 0) { resolve() } else { reject(new Error(`Launcher update failed with exit code ${code ?? 'unknown'}`)) } }) }) } function createCleanChildEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env = { ...process.env, ...extraEnv } const blockedKeys = [ 'APPIMAGE', 'APPDIR', 'OWD', 'ARGV0', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ATTACH_CONSOLE', 'ELECTRON_ENABLE_LOGGING', 'ELECTRON_ENABLE_STACK_DUMPING', 'ELECTRON_OVERRIDE_DIST_PATH', 'ELECTRON_RENDERER_URL', 'NODE_OPTIONS', 'VITE_DEV_SERVER_URL', ] for (const key of blockedKeys) { delete env[key] } for (const key of Object.keys(env)) { if (key.startsWith('npm_') || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { delete env[key] } } return env } function stopChildApps( appProcesses: Map, appWindows: Map, ): void { for (const child of appProcesses.values()) { if (!child.killed) { child.kill('SIGTERM') setTimeout(() => { if (!child.killed) child.kill('SIGKILL') }, 1500).unref() } } appProcesses.clear() for (const win of appWindows.values()) { if (!win.isDestroyed()) win.close() } appWindows.clear() } function relaunchFromCleanProcess(): void { const child = spawn('/bin/sh', ['-lc', 'sleep 1; exec "$LAUNCHER_PATH" >/dev/null 2>&1'], { detached: true, stdio: 'ignore', env: createCleanChildEnv({ LAUNCHER_PATH: process.execPath }), }) child.unref() } function runCommand(command: string, args: string[], timeoutMs = 30000, extraEnv?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env: extraEnv ? { ...process.env, ...extraEnv } : process.env, }) let stdout = '' let stderr = '' const timer = setTimeout(() => { child.kill('SIGTERM') reject(new Error(`${command} timed out`)) }, timeoutMs) child.stdout.on('data', (chunk) => { stdout += String(chunk) }) child.stderr.on('data', (chunk) => { stderr += String(chunk) }) child.on('error', (error) => { clearTimeout(timer) reject(error) }) child.on('close', (code) => { clearTimeout(timer) if (code === 0) { resolve({ stdout, stderr }) } else { reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code ?? 'unknown'}`)) } }) }) } function commandErrorMessage(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : String(error) if (/request dismissed|dismissed|cancelled|canceled/i.test(message)) return '인증이 취소되었습니다' if (/not authorized|authentication failed/i.test(message)) return '관리자 인증에 실패했습니다' if (/No such file or directory|ENOENT/i.test(message)) return '명령을 찾을 수 없습니다' const sanitized = message .replace(/(?:tskey|hskey)-auth-[A-Za-z0-9_-]+/g, '[auth-key]') .trim() return sanitized ? `${fallback}: ${sanitized}` : fallback } const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) function isTailscalePermissionError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error) return /set --operator|not require root|permission denied|access denied|must be root|requires root/i.test(message) } function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined { return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined } async function installTailscale(): Promise { const installScript = [ 'set -e', 'if command -v tailscale >/dev/null 2>&1; then exit 0; fi', 'curl -fsSL https://tailscale.com/install.sh | sh', 'mkdir -p /var/lib/autonomy-studio', "printf '%s\\n' installed_by=autonomy-studio-launcher > /var/lib/autonomy-studio/managed-tailscale", ].join('; ') try { await runCommand('pkexec', ['/bin/sh', '-c', installScript], 120000) return { ok: true, message: 'Tailscale 설치 완료' } } catch (error) { return { ok: false, message: commandErrorMessage(error, 'Tailscale 설치 실패'), } } } function hasTailscaleCoordinationIssue(health?: string[]): boolean { return Boolean( health?.some((message) => /coordination server|synchronize the state|control server|login server|headscale/i.test(message), ), ) } async function runCommandIfPossible( command: string, args: string[], timeoutMs = 10000, extraEnv?: NodeJS.ProcessEnv, ): Promise { try { await runCommand(command, args, timeoutMs, extraEnv) return true } catch { return false } } async function ensureLauncherSshKey(): Promise { const privateKeyPath = getLauncherSshKeyPath() const publicKeyPath = `${privateKeyPath}.pub` mkdirSync(dirname(privateKeyPath), { recursive: true, mode: 0o700 }) if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) { rmSync(privateKeyPath, { force: true }) rmSync(publicKeyPath, { force: true }) await runCommand('ssh-keygen', [ '-t', 'ed25519', '-N', '', '-C', 'autonomy-studio-launcher', '-f', privateKeyPath, ]) } chmodSync(dirname(privateKeyPath), 0o700) chmodSync(privateKeyPath, 0o600) chmodSync(publicKeyPath, 0o644) return { publicKey: readFileSync(publicKeyPath, 'utf8').trim(), publicKeyPath, privateKeyPath, } } async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise { await runCommandIfPossible('tailscale', ['logout'], 10000, commandEnv) await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv) } async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise { const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv) if (!loggedOut) { await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv) } } async function readTailscaleStatus(): Promise { const attempts: Array<[string, string[]]> = [ ['tailscale', ['status', '--json']], ['tailscale', ['status']], ] let lastError = '' for (const [command, args] of attempts) { try { const { stdout } = await runCommand(command, args) if (args.includes('--json')) { const status = JSON.parse(stdout) as { BackendState?: string Self?: { TailscaleIPs?: string[]; UserID?: number; LoginName?: string; Online?: boolean } Peer?: Record Health?: string[] User?: Record } const selfUser = status.Self?.UserID != null ? status.User?.[String(status.Self.UserID)] : undefined const health = Array.isArray(status.Health) ? status.Health : [] const online = status.Self?.Online === true const connected = status.BackendState === 'Running' && online && !hasTailscaleCoordinationIssue(health) const peers = Object.entries(status.Peer ?? {}).map(([key, peer]) => { const user = peer.UserID != null ? status.User?.[String(peer.UserID)] : undefined const ips = Array.isArray(peer.TailscaleIPs) ? peer.TailscaleIPs : [] return { id: peer.ID ?? key, hostName: peer.HostName ?? peer.DNSName ?? key, dnsName: peer.DNSName, os: peer.OS, userName: user?.DisplayName, loginName: user?.LoginName, ip: ips[0] ?? '', ips, online: peer.Online === true, active: peer.Active === true, lastSeen: peer.LastSeen, relay: peer.Relay, currentAddress: peer.CurAddr, } }) return { connected, ip: status.Self?.TailscaleIPs?.[0] ?? '', backendState: status.BackendState ?? 'Unknown', online, health, loginName: status.Self?.LoginName ?? selfUser?.LoginName ?? '', userName: selfUser?.DisplayName ?? '', peers, error: connected ? undefined : health[0], } } const output = stdout.trim() const connected = Boolean(output) && !/logged out|not logged in/i.test(output) return { connected, ip: output.match(/\b100\.\d+\.\d+\.\d+\b/)?.[0] ?? '', backendState: connected ? 'Running' : 'Stopped', } } catch (error) { lastError = error instanceof Error ? error.message : String(error) } } return { connected: false, ip: '', backendState: 'Unavailable', error: lastError || 'tailscale status failed', } } async function loginTailscale( authKey: string, loginServer: string, hostname?: string, forceReset = false, ): Promise { const tailscaleArgs = ['up'] const trimmedAuthKey = authKey.trim() const trimmedLoginServer = loginServer.trim() const trimmedHostname = hostname?.trim() ?? '' const commandEnv = tailscaleCommandEnv(trimmedLoginServer) if (forceReset) { await resetTailscaleSession(commandEnv) tailscaleArgs.push('--reset') } if (trimmedLoginServer) { tailscaleArgs.push('--login-server', trimmedLoginServer) } if (trimmedAuthKey) { tailscaleArgs.push('--auth-key', trimmedAuthKey) } if (trimmedHostname) { tailscaleArgs.push('--hostname', trimmedHostname) } try { await runCommand('tailscale', tailscaleArgs, 30000, commandEnv) await waitForTailscaleConnected() return { ok: true, message: 'Tailscale 연결 완료' } } catch (error) { if (isTailscalePermissionError(error)) { try { if (forceReset) { await resetTailscaleSessionWithPkexec(commandEnv) } await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv) await waitForTailscaleConnected() return { ok: true, message: 'Tailscale 연결 완료' } } catch (pkexecError) { return { ok: false, message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')), } } } return { ok: false, message: commandErrorMessage(error, 'Tailscale 연결 실패'), } } } async function waitForTailscaleConnected(): Promise { let lastStatus: TailscaleStatusResult | null = null for (let i = 0; i < 30; i += 1) { await delay(500) const status = await readTailscaleStatus() lastStatus = status if (status.connected) return } throw new Error(lastStatus?.error || `Tailscale state is ${lastStatus?.backendState ?? 'not connected'}`) } async function logoutTailscale(): Promise { try { await runCommand('tailscale', ['logout']) return { ok: true, message: 'Tailscale 로그아웃 완료' } } catch (error) { if (isTailscalePermissionError(error)) { try { await runCommand('tailscale', ['down']) return { ok: true, message: 'Tailscale 연결 해제 완료' } } catch (downError) { return { ok: false, message: commandErrorMessage(downError, commandErrorMessage(error, 'Tailscale 로그아웃 권한이 없습니다.')), } } } return { ok: false, message: commandErrorMessage(error, 'Tailscale 로그아웃 실패'), } } } function createSplashWindow(): void { splash = new BrowserWindow({ width: 580, height: 360, show: true, frame: false, resizable: false, center: true, autoHideMenuBar: true, backgroundColor: '#07111f' }) const splashPath = is.dev ? join(process.cwd(), 'resources/splash.html') : join(__dirname, '../renderer/splash.html') splash.loadFile(splashPath, { query: { version: app.getVersion() } }) } function attachDevToolsShortcut(window: BrowserWindow): void { window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() if (input.key === 'F12' || (input.control && input.shift && key === 'i')) { event.preventDefault() window.webContents.toggleDevTools() } }) } function suppressSystemContextMenu(window: BrowserWindow): void { type SystemContextMenuWindow = BrowserWindow & { on(event: 'system-context-menu', listener: (event: { preventDefault: () => void }) => void): BrowserWindow } ;(window as SystemContextMenuWindow).on('system-context-menu', (event) => { event.preventDefault() }) } function createWindow(): void { const appWindows = new Map() const appProcesses = new Map() const mainWindow = new BrowserWindow({ width: 1366, height: 860, icon: join(process.cwd(), 'resources/icon.png'), show: false, frame: false, autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false } }) attachDevToolsShortcut(mainWindow) suppressSystemContextMenu(mainWindow) mainWindow.on('ready-to-show', () => { setTimeout(() => { splash?.close() splash = null mainWindow.show() if (pendingGoogleOAuthPayload) { dispatchGoogleOAuthPayload(pendingGoogleOAuthPayload) } }, 1500) }) mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) ipcMain.on('window-minimize', (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() }) ipcMain.on('window-maximize', (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return win.isMaximized() ? win.unmaximize() : win.maximize() }) ipcMain.on('window-close', (event) => { BrowserWindow.fromWebContents(event.sender)?.close() }) ipcMain.handle('get-app-installation', (_, payload: { appName?: string execPath?: string devAppPath?: string }) => { const devAppPath = resolveDevAppPath(payload.devAppPath) ?? (payload.appName ? getDefaultDevAppPath(payload.appName) : undefined) if (is.dev && devAppPath) { const packagePath = join(devAppPath, 'package.json') let version: string | null = null try { version = JSON.parse(readFileSync(packagePath, 'utf8')).version ?? null } catch { version = null } return { installed: existsSync(packagePath), version, path: devAppPath, } } const targetPath = resolveExecPath(payload.execPath, payload.appName) return { installed: targetPath ? existsSync(targetPath) : false, version: readInstalledVersion(targetPath), path: targetPath ?? null, } }) ipcMain.handle('download-app', async (event, payload: { appName: string downloadUrl: string execPath?: string latestVersion?: string }) => { const targetPath = resolveExecPath(payload.execPath, payload.appName) if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.') await downloadFile(payload.downloadUrl, targetPath, (progress) => { event.sender.send('app-download-progress', { appName: payload.appName, progress, }) }) if (payload.latestVersion) { writeFileSync(getVersionFilePath(targetPath), `${payload.latestVersion}\n`, 'utf8') } return { installed: true, version: payload.latestVersion ?? readInstalledVersion(targetPath), path: targetPath, } }) ipcMain.handle('uninstall-app', (_, payload: { appName: string execPath?: string }) => { const targetPath = resolveExecPath(payload.execPath, payload.appName) if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.') assertUserAppPath(targetPath) const existingWin = appWindows.get(payload.appName) if (existingWin && !existingWin.isDestroyed()) { existingWin.close() } const existingProc = appProcesses.get(payload.appName) if (existingProc) { existingProc.kill() appProcesses.delete(payload.appName) } rmSync(targetPath, { force: true }) rmSync(getVersionFilePath(targetPath), { force: true }) return { installed: false, version: null, path: targetPath, } }) ipcMain.handle('install-launcher-update', async (event, payload: { downloadUrl: string latestVersion?: string }) => { const version = payload.latestVersion?.replace(/[^0-9A-Za-z._-]/g, '') || String(Date.now()) const debPath = join(app.getPath('temp'), `autonomy-studio-launcher-${version}.deb`) await downloadFile(payload.downloadUrl, debPath, (progress) => { event.sender.send('app-download-progress', { appName: 'launcher', progress, }) }) await installDebPackage(debPath) if (!is.dev) { stopChildApps(appProcesses, appWindows) setImmediate(() => { relaunchFromCleanProcess() app.quit() setTimeout(() => app.exit(0), 1000).unref() }) } return { installed: true, version: payload.latestVersion ?? null, path: debPath, } }) ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus()) ipcMain.handle('clear-launcher-cache', () => clearLauncherCache()) ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url)) ipcMain.handle('launch-web-app', async (_event, payload: { appName?: string; url?: string }) => { const rawUrl = payload.url?.trim() if (!rawUrl) throw new Error('웹 실행 주소가 없습니다.') try { const url = new URL(rawUrl) if (url.protocol !== 'https:' && url.protocol !== 'http:') { throw new Error('웹 실행 주소 형식이 올바르지 않습니다.') } if (payload.appName?.trim().toLowerCase() === 'lms') { const bridge = await lmsWebBridge.createSession(url.origin) const hash = new URLSearchParams(url.hash.replace(/^#/, '')) hash.set('bridgeUrl', bridge.url) hash.set('bridgeToken', bridge.token) url.hash = hash.toString() } await shell.openExternal(url.toString()) mainWindow.minimize() return { ok: true } } catch (error) { throw new Error(error instanceof Error ? error.message : '웹 브라우저를 열지 못했습니다.') } }) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) ipcMain.handle('launcher-tailscale-install', () => installTailscale()) ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey()) ipcMain.handle( 'launcher-tailscale-login', (_event, authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)), ) ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale()) const onAppClosed = (appName: string): void => { mainWindow.webContents.send('app-window-closed', appName) if (appWindows.size === 0 && appProcesses.size === 0) { mainWindow.restore() mainWindow.focus() } } ipcMain.on('launch-app', (_, payload: { appName: string url?: string width?: number height?: number devAppPath?: string execPath?: string handoffCode?: string audienceApp?: string }) => { 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 ? [ `--handoff-token=${encodeURIComponent(handoffCode)}`, `--audience-app=${encodeURIComponent(audienceApp)}` ] : [] // 이미 실행 중이면 포커스 const existingWin = appWindows.get(appName) if (existingWin && !existingWin.isDestroyed()) { if (authProtocolUrl) shell.openExternal(authProtocolUrl) existingWin.focus() return } const existingProc = appProcesses.get(appName) if (existingProc) { if (handoffCode) { existingProc.kill() appProcesses.delete(appName) } else { if (authProtocolUrl) shell.openExternal(authProtocolUrl) return } } mainWindow.minimize() // ── 별도 Electron 프로세스로 실행 ── const resolvedExecPath = resolveExecPath(execPath, appName) const resolvedDevAppPath = resolveDevAppPath(devAppPath) ?? getDefaultDevAppPath(appName) if (is.dev && resolvedDevAppPath && !isRunnableDevAppPath(resolvedDevAppPath)) { dialog.showErrorBox( '개발 앱 실행 실패', `${appName} 개발 앱 경로를 찾을 수 없습니다.\n${resolvedDevAppPath}` ) return } const spawnPath = is.dev && resolvedDevAppPath && isRunnableDevAppPath(resolvedDevAppPath) ? resolvedDevAppPath : resolvedExecPath && existsSync(resolvedExecPath) ? resolvedExecPath : resolvedExecPath if (spawnPath) { const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath const launchUrlArg = url ? [`--url=${encodeURIComponent(url)}`] : [] const noSandboxArg = !useDevLauncher && process.platform === 'linux' && spawnPath.toLowerCase().endsWith('.appimage') ? ['--no-sandbox'] : [] const args = useDevLauncher ? ['run', 'dev'] : [...noSandboxArg, ...launchUrlArg, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])] const execBin = useDevLauncher ? 'npm' : spawnPath const childEnv = createCleanChildEnv() if (useDevLauncher && url) { childEnv.ATNMY_APP_URL = url } if (useDevLauncher && handoffCode) { childEnv.ATNMY_HANDOFF_FILE = writeDevHandoffFile(appName, url ?? '', handoffCode, audienceApp) childEnv.ATNMY_HANDOFF_TOKEN = handoffCode childEnv.ATNMY_AUDIENCE_APP = audienceApp } else if (handoffCode) { childEnv.ATNMY_HANDOFF_TOKEN = handoffCode childEnv.ATNMY_AUDIENCE_APP = audienceApp } if (!useDevLauncher) { try { chmodSync(spawnPath, 0o755) } catch { // Launch will surface the actual executable error if chmod is not allowed. } } const logPath = useDevLauncher ? join(app.getPath('temp'), `autonomy-studio-${appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()}.dev.log`) : undefined const logFd = logPath ? openSync(logPath, 'a') : undefined if (logPath) { writeFileSync(logPath, [ `\n[${new Date().toISOString()}] ${execBin} ${args.join(' ')}`, `[launcher] app=${appName} useDevLauncher=${useDevLauncher ? 'true' : 'false'} hasHandoff=${handoffCode ? 'true' : 'false'} audience=${audienceApp}`, `[launcher] handoffFile=${childEnv.ATNMY_HANDOFF_FILE ?? ''} hasHandoffTokenEnv=${childEnv.ATNMY_HANDOFF_TOKEN ? 'true' : 'false'}`, '' ].join('\n'), { flag: 'a' }) } const child = spawn(execBin, args, { detached: true, stdio: useDevLauncher && logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore', cwd: useDevLauncher ? spawnPath : undefined, env: childEnv, shell: useDevLauncher && process.platform === 'win32' }) child.unref() appProcesses.set(appName, child) child.on('error', (error) => { appProcesses.delete(appName) dialog.showErrorBox( `${appName} 실행 실패`, `${error instanceof Error ? error.message : '앱 실행에 실패했습니다.'}${logPath ? `\n\n로그: ${logPath}` : ''}` ) onAppClosed(appName) }) child.on('close', () => { appProcesses.delete(appName) onAppClosed(appName) }) return } // ── fallback: 같은 프로세스 내 BrowserWindow ── const appWin = new BrowserWindow({ width, height, frame: false, autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, webSecurity: false, additionalArguments: [ '--app-window', `--app-name=${encodeURIComponent(appName)}`, `--app-url=${encodeURIComponent(url ?? '')}` ] } }) appWindows.set(appName, appWin) attachDevToolsShortcut(appWin) suppressSystemContextMenu(appWin) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { appWin.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { appWin.loadFile(join(__dirname, '../renderer/index.html')) } appWin.on('closed', () => { appWindows.delete(appName) onAppClosed(appName) }) }) ipcMain.on('focus-app-window', (_, appName: string) => { const win = appWindows.get(appName) if (win && !win.isDestroyed()) { win.focus(); return } const child = appProcesses.get(appName) if (!child || child.pid === undefined) return const pid = child.pid if (process.platform === 'linux') { exec(`xdotool search --name "${appName}" windowactivate --sync`) } else if (process.platform === 'darwin') { exec(`osascript -e 'tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true'`) } else if (process.platform === 'win32') { exec(`powershell -command "(New-Object -ComObject wscript.shell).AppActivate(${pid})"`) } }) ipcMain.on('close-app-window', (_, appName: string) => { const win = appWindows.get(appName) if (win && !win.isDestroyed()) { win.close(); return } const child = appProcesses.get(appName) if (child) child.kill() }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } const gotSingleInstanceLock = app.requestSingleInstanceLock() if (!gotSingleInstanceLock) { app.exit(0) } else { if (process.defaultApp && process.argv.length >= 2) { app.setAsDefaultProtocolClient('autonomy-studio', process.execPath, [resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient('autonomy-studio') } app.on('second-instance', (_event, argv) => { const protocolUrl = argv.find((arg) => arg.startsWith('autonomy-studio://')) if (protocolUrl) handleProtocolUrl(protocolUrl) const window = BrowserWindow.getAllWindows()[0] if (window) { if (window.isMinimized()) window.restore() window.focus() } }) app.on('open-url', (event, url) => { event.preventDefault() handleProtocolUrl(url) }) for (const arg of process.argv) { if (arg.startsWith('autonomy-studio://')) handleProtocolUrl(arg) } app.whenReady().then(() => { electronApp.setAppUserModelId('com.autonomystudio.launcher') registerLinuxProtocolHandler() protocol.handle('appdocs', (request) => { const url = new URL(request.url) const docsBase = is.dev ? join(process.cwd(), 'resources/docs') : join(__dirname, '../renderer/docs') const filePath = join(docsBase, url.pathname) return net.fetch(`file://${filePath}`) }) app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) createSplashWindow() createWindow() app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) app.on('will-quit', () => { void lmsWebBridge.stop() }) }