diff --git a/package.json b/package.json index cae28a9..77166e0 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "deb": { "compression": "gz", + "afterRemove": "build/linux/after-remove.sh", "depends": [ "xdotool" ] diff --git a/src/main/index.ts b/src/main/index.ts index 9c5ce7b..a7ea959 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -487,6 +487,26 @@ 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) => @@ -935,6 +955,7 @@ function createWindow(): void { ipcMain.handle('clear-launcher-cache', () => clearLauncherCache()) ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url)) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) + ipcMain.handle('launcher-tailscale-install', () => installTailscale()) ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey()) ipcMain.handle( 'launcher-tailscale-login', diff --git a/src/preload/index.ts b/src/preload/index.ts index 414ed35..f48f770 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ const appUrl = decodeURIComponent( const appWindowMeta = { isAppWindow, appName, url: appUrl } const launcherTailscale = { status: () => ipcRenderer.invoke('launcher-tailscale-status'), + install: () => ipcRenderer.invoke('launcher-tailscale-install'), login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset), logout: () => ipcRenderer.invoke('launcher-tailscale-logout'), diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 63c084e..c119b3f 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Code2, BarChart3, @@ -92,6 +92,13 @@ type VpnPlatformPeer = { currentAddress?: string } +function isTailscaleMissingStatus(status: Awaited['status']>> | null): boolean { + return Boolean( + status?.backendState === 'Unavailable' && + /command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '') + ) +} + function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken }: SidebarProps): React.JSX.Element { const [menuOpen, setMenuOpen] = useState(false) const [modal, setModal] = useState(null) @@ -369,53 +376,79 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JSX.Element { const [loading, setLoading] = useState(true) + const [installing, setInstalling] = useState(false) + const [tailscaleMissing, setTailscaleMissing] = useState(false) const [error, setError] = useState('') const [platforms, setPlatforms] = useState([]) const [selectedId, setSelectedId] = useState('') - useEffect(() => { - let cancelled = false + const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise => { + setLoading(true) + setError('') + setTailscaleMissing(false) - const loadPlatforms = async (): Promise => { - setLoading(true) - setError('') + try { + if (!window.launcherTailscale) { + throw new Error('VPN 상태 조회 기능을 사용할 수 없습니다.') + } - try { - if (!window.launcherTailscale) { - throw new Error('VPN 상태 조회 기능을 사용할 수 없습니다.') - } - - const status = await window.launcherTailscale.status() - const peers = status.peers ?? [] - const nextPlatforms = peers - .filter((peer) => !peer.hostName.toLowerCase().includes('launcher-client')) - .sort((a, b) => Number(b.online || b.active) - Number(a.online || a.active) || a.hostName.localeCompare(b.hostName)) - - if (!cancelled) { - setPlatforms(nextPlatforms) - setSelectedId((current) => { - if (current && nextPlatforms.some((platform) => platform.id === current)) return current - return nextPlatforms[0]?.id ?? '' - }) - } - } catch (loadError) { - if (!cancelled) { - setError(loadError instanceof Error ? loadError.message : '플랫폼 목록을 불러오지 못했습니다.') + const status = await window.launcherTailscale.status() + if (isTailscaleMissingStatus(status)) { + if (!cancelledRef?.current) { + setTailscaleMissing(true) setPlatforms([]) setSelectedId('') } - } finally { - if (!cancelled) setLoading(false) + return } - } - void loadPlatforms() + const peers = status.peers ?? [] + const nextPlatforms = peers + .filter((peer) => !peer.hostName.toLowerCase().includes('launcher-client')) + .sort((a, b) => Number(b.online || b.active) - Number(a.online || a.active) || a.hostName.localeCompare(b.hostName)) - return () => { - cancelled = true + if (!cancelledRef?.current) { + setPlatforms(nextPlatforms) + setSelectedId((current) => { + if (current && nextPlatforms.some((platform) => platform.id === current)) return current + return nextPlatforms[0]?.id ?? '' + }) + } + } catch (loadError) { + if (!cancelledRef?.current) { + setError(loadError instanceof Error ? loadError.message : '플랫폼 목록을 불러오지 못했습니다.') + setPlatforms([]) + setSelectedId('') + } + } finally { + if (!cancelledRef?.current) setLoading(false) } }, []) + useEffect(() => { + const cancelledRef = { current: false } + void loadPlatforms(cancelledRef) + return () => { + cancelledRef.current = true + } + }, [loadPlatforms]) + + const installTailscale = async (): Promise => { + if (!window.launcherTailscale?.install) return + setInstalling(true) + setError('') + try { + const result = await window.launcherTailscale.install() + if (!result.ok) { + setError(result.message || 'Tailscale 설치에 실패했습니다.') + return + } + await loadPlatforms() + } finally { + setInstalling(false) + } + } + const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0] return ( @@ -441,6 +474,19 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS ) : error ? (
{error}
+ ) : tailscaleMissing ? ( +
+

원격 연결 구성 요소 설치가 필요합니다.

+

플랫폼 연결을 위해 Tailscale을 설치해 주세요.

+ +
) : platforms.length === 0 ? (
연결된 플랫폼이 없습니다.
) : ( @@ -473,7 +519,22 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
- {!selectedPlatform ? ( + {tailscaleMissing ? ( +
+
+

Tailscale 설치가 필요합니다.

+

Autonomy Studio는 원격 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치에는 관리자 권한이 필요합니다.

+ +
+
+ ) : !selectedPlatform ? (
플랫폼을 선택하세요.
) : ( <> diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 2e5bd24..a2f3158 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -36,6 +36,10 @@ declare global { }> error?: string }> + install: () => Promise<{ + ok: boolean + message: string + }> login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{ ok: boolean message: string diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 0748ad8..694710c 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -598,7 +598,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
+ {pendingTailscaleConfirm.reason === 'install-required' ? ( + + ) : ( + + )}
,