feat: display platform agent status and version

This commit is contained in:
2026-08-06 16:04:01 +09:00
parent ffd6ae9437
commit 3b6e2787c7
4 changed files with 71 additions and 25 deletions
+36 -24
View File
@@ -512,6 +512,38 @@ function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
} }
function getLauncherOsUser(): string {
const user = (process.env.USER ?? process.env.LOGNAME ?? '').trim()
return /^[a-z_][a-z0-9_-]*\$?$/i.test(user) ? user : ''
}
async function ensureTailscaleOperator(): Promise<CommandResult> {
const operator = getLauncherOsUser()
if (!operator) {
return { ok: false, message: '현재 OS 사용자를 확인하지 못했습니다.' }
}
const args = ['set', `--operator=${operator}`]
try {
await runCommand('tailscale', args, 15000)
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
} catch (error) {
if (!isTailscalePermissionError(error)) {
return { ok: false, message: commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패') }
}
try {
await runCommand('pkexec', ['/usr/bin/tailscale', ...args], 30000)
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
} catch (pkexecError) {
return {
ok: false,
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패')),
}
}
}
}
async function installTailscale(): Promise<CommandResult> { async function installTailscale(): Promise<CommandResult> {
const installScript = [ const installScript = [
'set -e', 'set -e',
@@ -590,13 +622,6 @@ async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<vo
await runCommandIfPossible('tailscale', ['down'], 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> { async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
const attempts: Array<[string, string[]]> = [ const attempts: Array<[string, string[]]> = [
['tailscale', ['status', '--json']], ['tailscale', ['status', '--json']],
@@ -714,25 +739,11 @@ async function loginTailscale(
await waitForTailscaleConnected() await waitForTailscaleConnected()
return { ok: true, message: 'Tailscale 연결 완료' } return { ok: true, message: 'Tailscale 연결 완료' }
} catch (error) { } 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 { return {
ok: false, ok: false,
message: commandErrorMessage(error, 'Tailscale 연결 실패'), message: isTailscalePermissionError(error)
? commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다. 다시 연결해 주세요.')
: commandErrorMessage(error, 'Tailscale 연결 실패'),
} }
} }
} }
@@ -1027,6 +1038,7 @@ function createWindow(): void {
}) })
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
ipcMain.handle('launcher-tailscale-install', () => installTailscale()) ipcMain.handle('launcher-tailscale-install', () => installTailscale())
ipcMain.handle('launcher-tailscale-ensure-operator', () => ensureTailscaleOperator())
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey()) ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
ipcMain.handle( ipcMain.handle(
'launcher-tailscale-login', 'launcher-tailscale-login',
+1
View File
@@ -14,6 +14,7 @@ const appWindowMeta = { isAppWindow, appName, url: appUrl }
const launcherTailscale = { const launcherTailscale = {
status: () => ipcRenderer.invoke('launcher-tailscale-status'), status: () => ipcRenderer.invoke('launcher-tailscale-status'),
install: () => ipcRenderer.invoke('launcher-tailscale-install'), install: () => ipcRenderer.invoke('launcher-tailscale-install'),
ensureOperator: () => ipcRenderer.invoke('launcher-tailscale-ensure-operator'),
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset), ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'), logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
+4
View File
@@ -40,6 +40,10 @@ declare global {
ok: boolean ok: boolean
message: string message: string
}> }>
ensureOperator: () => Promise<{
ok: boolean
message: string
}>
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{ login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
ok: boolean ok: boolean
message: string message: string
+30 -1
View File
@@ -24,6 +24,7 @@ const LMS_WEB_BASE_URL = `${(
).replace(/\/+$/, '')}/` ).replace(/\/+$/, '')}/`
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures' const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp' const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const TAILSCALE_DEVICE_ID_STORAGE_KEY = 'atnmy.launcher.tailscale-device-id'
const IS_DEV = import.meta.env.DEV const IS_DEV = import.meta.env.DEV
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000 const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
@@ -102,6 +103,21 @@ function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount:
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount) return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
} }
function getTailscaleDeviceId(): string {
try {
const stored = window.localStorage.getItem(TAILSCALE_DEVICE_ID_STORAGE_KEY)?.trim()
if (stored && /^[a-z0-9-]{6,24}$/i.test(stored)) return stored
const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '').slice(0, 10)
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
window.localStorage.setItem(TAILSCALE_DEVICE_ID_STORAGE_KEY, next)
return next
} catch {
return 'launcher-client'
}
}
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean { function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
return Boolean( return Boolean(
status?.backendState === 'Unavailable' && status?.backendState === 'Unavailable' &&
@@ -272,11 +288,24 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const sshKey = await window.launcherSshKey?.ensure().catch(() => null) const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
if (cancelled) return if (cancelled) return
const operator = await launcherTailscale.ensureOperator()
if (cancelled) return
if (!operator.ok) {
setTailscaleIssue({
state: 'warning',
title: '원격 연결 권한 필요',
message: operator.message || 'Tailscale 제어 권한을 설정하지 못했습니다.',
})
return
}
const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, { const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client', hostname: window.navigator.userAgent.includes('Electron')
? `launcher-client-${getTailscaleDeviceId()}`
: `client-${getTailscaleDeviceId()}`,
usageType: 'launcher-client', usageType: 'launcher-client',
publicSshKey: sshKey?.publicKey, publicSshKey: sshKey?.publicKey,
}), }),