Files
studio-launcher/src/main/index.ts
T
2026-07-15 14:07:50 +09:00

1253 lines
39 KiB
TypeScript

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<string, string> = {
'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<void> {
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<void> {
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<string, ChildProcess>,
appWindows: Map<string, BrowserWindow>,
): 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<void> => 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<CommandResult> {
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<boolean> {
try {
await runCommand(command, args, timeoutMs, extraEnv)
return true
} catch {
return false
}
}
async function ensureLauncherSshKey(): Promise<SshKeyResult> {
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<void> {
await runCommandIfPossible('tailscale', ['logout'], 10000, commandEnv)
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
}
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
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<TailscaleStatusResult> {
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<string, {
ID?: string
HostName?: string
DNSName?: string
OS?: string
UserID?: number
TailscaleIPs?: string[]
Online?: boolean
Active?: boolean
LastSeen?: string
Relay?: string
CurAddr?: string
}>
Health?: string[]
User?: Record<string, { LoginName?: string; ProfilePicURL?: string; DisplayName?: string }>
}
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<CommandResult> {
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<void> {
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<CommandResult> {
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<string, BrowserWindow>()
const appProcesses = new Map<string, ChildProcess>()
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()
})
}