1061 lines
33 KiB
TypeScript
1061 lines
33 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, ChildProcess } from 'child_process'
|
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
|
])
|
|
|
|
let splash: BrowserWindow | null = null
|
|
|
|
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 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 }
|
|
|
|
delete env.APPIMAGE
|
|
delete env.APPDIR
|
|
delete env.OWD
|
|
delete env.ARGV0
|
|
delete env.ELECTRON_RENDERER_URL
|
|
delete env.VITE_DEV_SERVER_URL
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}, 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('launcher-tailscale-status', () => readTailscaleStatus())
|
|
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 args = useDevLauncher
|
|
? ['run', 'dev']
|
|
: [...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
|
|
}
|
|
|
|
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'))
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
electronApp.setAppUserModelId('com.electron')
|
|
|
|
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()
|
|
}
|
|
})
|