tailscale install check!
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"deb": {
|
||||
"compression": "gz",
|
||||
"afterRemove": "build/linux/after-remove.sh",
|
||||
"depends": [
|
||||
"xdotool"
|
||||
]
|
||||
|
||||
@@ -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<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) =>
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<ReturnType<NonNullable<typeof window.launcherTailscale>['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<UserModal>(null)
|
||||
@@ -369,16 +376,16 @@ 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<VpnPlatformPeer[]>([])
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadPlatforms = async (): Promise<void> => {
|
||||
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setTailscaleMissing(false)
|
||||
|
||||
try {
|
||||
if (!window.launcherTailscale) {
|
||||
@@ -386,12 +393,21 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
|
||||
}
|
||||
|
||||
const status = await window.launcherTailscale.status()
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
if (!cancelledRef?.current) {
|
||||
setTailscaleMissing(true)
|
||||
setPlatforms([])
|
||||
setSelectedId('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!cancelledRef?.current) {
|
||||
setPlatforms(nextPlatforms)
|
||||
setSelectedId((current) => {
|
||||
if (current && nextPlatforms.some((platform) => platform.id === current)) return current
|
||||
@@ -399,23 +415,40 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
|
||||
})
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
if (!cancelledRef?.current) {
|
||||
setError(loadError instanceof Error ? loadError.message : '플랫폼 목록을 불러오지 못했습니다.')
|
||||
setPlatforms([])
|
||||
setSelectedId('')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadPlatforms()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (!cancelledRef?.current) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const cancelledRef = { current: false }
|
||||
void loadPlatforms(cancelledRef)
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [loadPlatforms])
|
||||
|
||||
const installTailscale = async (): Promise<void> => {
|
||||
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
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-8 text-center text-[12px] font-semibold leading-5 text-[#ff8c8c]">{error}</div>
|
||||
) : tailscaleMissing ? (
|
||||
<div className="px-2 py-8 text-center">
|
||||
<p className="text-[12px] font-bold leading-5 text-white/72">원격 연결 구성 요소 설치가 필요합니다.</p>
|
||||
<p className="mt-2 text-[11px] font-medium leading-5 text-white/45">플랫폼 연결을 위해 Tailscale을 설치해 주세요.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installTailscale()}
|
||||
disabled={installing}
|
||||
className="mt-4 h-8 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-3 text-[11px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
{installing ? '설치 중...' : '설치'}
|
||||
</button>
|
||||
</div>
|
||||
) : platforms.length === 0 ? (
|
||||
<div className="px-2 py-8 text-center text-[12px] font-semibold leading-5 text-white/45">연결된 플랫폼이 없습니다.</div>
|
||||
) : (
|
||||
@@ -473,7 +519,22 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
|
||||
</aside>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
{!selectedPlatform ? (
|
||||
{tailscaleMissing ? (
|
||||
<div className="grid h-full place-items-center px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[15px] font-bold text-white/86">Tailscale 설치가 필요합니다.</p>
|
||||
<p className="mt-2 text-[12px] font-medium leading-5 text-white/50">Autonomy Studio는 원격 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치에는 관리자 권한이 필요합니다.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installTailscale()}
|
||||
disabled={installing}
|
||||
className="mt-5 h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
{installing ? '설치 중...' : '설치'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : !selectedPlatform ? (
|
||||
<div className="grid h-full place-items-center text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
@@ -598,7 +598,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
<div className="relative" ref={noticeRef}>
|
||||
<Tooltip label="공지사항">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.currentTarget.blur()
|
||||
markAppNoticesRead()
|
||||
setNoticeOpen((v) => !v)
|
||||
}}
|
||||
|
||||
@@ -59,8 +59,24 @@ type TailscaleProvisionResponse = {
|
||||
type PendingTailscaleConfirm = {
|
||||
session: AuthSession
|
||||
email: string
|
||||
password: string
|
||||
keepLogin: boolean
|
||||
message: string
|
||||
reason: 'install-required' | 'connection-failed'
|
||||
}
|
||||
|
||||
class TailscaleInstallRequiredError extends Error {
|
||||
constructor() {
|
||||
super('원격 연결 구성 요소 설치가 필요합니다.')
|
||||
this.name = 'TailscaleInstallRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||
return Boolean(
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
@@ -134,11 +150,8 @@ async function ensureLauncherTailscale(account: string, password: string): Promi
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||
) {
|
||||
return
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
throw new TailscaleInstallRequiredError()
|
||||
}
|
||||
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
|
||||
@@ -185,6 +198,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
||||
const [tailscaleInstalling, setTailscaleInstalling] = useState(false)
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
|
||||
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||
@@ -282,7 +296,9 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
setPendingTailscaleConfirm({
|
||||
session,
|
||||
email,
|
||||
password,
|
||||
keepLogin,
|
||||
reason: tailscaleError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
|
||||
})
|
||||
return
|
||||
@@ -313,6 +329,40 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
}
|
||||
}
|
||||
|
||||
const installTailscaleAndContinue = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm || !window.launcherTailscale?.install) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setTailscaleInstalling(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const installResult = await window.launcherTailscale.install()
|
||||
if (!installResult.ok) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
message: installResult.message || 'Tailscale 설치에 실패했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ensureLauncherTailscale(pending.email, pending.password)
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
onLoadingChange(true)
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (installError) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
reason: installError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: installError instanceof Error ? installError.message : 'Tailscale 설치 또는 연결에 실패했습니다.',
|
||||
})
|
||||
} finally {
|
||||
setTailscaleInstalling(false)
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
||||
@@ -394,26 +444,41 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
</form>
|
||||
{pendingTailscaleConfirm && createPortal(
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[380px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||
<section className="w-full max-w-[400px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<h3 className="text-[15px] font-bold text-white">Tailscale 연결 실패</h3>
|
||||
<h3 className="text-[15px] font-bold text-white">
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? '원격 연결 구성 요소 설치' : 'Tailscale 연결 실패'}
|
||||
</h3>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
|
||||
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.
|
||||
{pendingTailscaleConfirm.reason === 'install-required'
|
||||
? 'Autonomy Studio는 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치를 진행하려면 관리자 권한이 필요합니다.'
|
||||
: '플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
|
||||
{pendingTailscaleConfirm.message}
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? 'Tailscale이 설치되어 있지 않습니다.' : pendingTailscaleConfirm.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingTailscaleConfirm(null)}
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
취소
|
||||
나중에
|
||||
</button>
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installTailscaleAndContinue()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
{tailscaleInstalling ? '설치 중...' : '설치'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
@@ -421,6 +486,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
>
|
||||
계속 진행
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
|
||||
Reference in New Issue
Block a user