Merge branch 'develop' into release

This commit is contained in:
2026-07-06 14:32:50 +09:00
7 changed files with 208 additions and 53 deletions
+1
View File
@@ -47,6 +47,7 @@
}, },
"deb": { "deb": {
"compression": "gz", "compression": "gz",
"afterRemove": "build/linux/after-remove.sh",
"depends": [ "depends": [
"xdotool" "xdotool"
] ]
+21
View File
@@ -487,6 +487,26 @@ 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
} }
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 { function hasTailscaleCoordinationIssue(health?: string[]): boolean {
return Boolean( return Boolean(
health?.some((message) => health?.some((message) =>
@@ -935,6 +955,7 @@ function createWindow(): void {
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache()) ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url)) ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url))
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
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
@@ -13,6 +13,7 @@ const appUrl = decodeURIComponent(
const appWindowMeta = { isAppWindow, appName, url: appUrl } 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'),
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'),
+95 -34
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { import {
Code2, Code2,
BarChart3, BarChart3,
@@ -92,6 +92,13 @@ type VpnPlatformPeer = {
currentAddress?: string 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 { 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 [menuOpen, setMenuOpen] = useState(false)
const [modal, setModal] = useState<UserModal>(null) const [modal, setModal] = useState<UserModal>(null)
@@ -369,53 +376,79 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JSX.Element { function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState(false)
const [tailscaleMissing, setTailscaleMissing] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([]) const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([])
const [selectedId, setSelectedId] = useState('') const [selectedId, setSelectedId] = useState('')
useEffect(() => { const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
let cancelled = false setLoading(true)
setError('')
setTailscaleMissing(false)
const loadPlatforms = async (): Promise<void> => { try {
setLoading(true) if (!window.launcherTailscale) {
setError('') throw new Error('VPN 상태 조회 기능을 사용할 수 없습니다.')
}
try { const status = await window.launcherTailscale.status()
if (!window.launcherTailscale) { if (isTailscaleMissingStatus(status)) {
throw new Error('VPN 상태 조회 기능을 사용할 수 없습니다.') if (!cancelledRef?.current) {
} setTailscaleMissing(true)
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 : '플랫폼 목록을 불러오지 못했습니다.')
setPlatforms([]) setPlatforms([])
setSelectedId('') setSelectedId('')
} }
} finally { return
if (!cancelled) setLoading(false)
} }
}
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 () => { if (!cancelledRef?.current) {
cancelled = true 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<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] const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0]
return ( return (
@@ -441,6 +474,19 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
</div> </div>
) : error ? ( ) : error ? (
<div className="px-2 py-8 text-center text-[12px] font-semibold leading-5 text-[#ff8c8c]">{error}</div> <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 ? ( ) : platforms.length === 0 ? (
<div className="px-2 py-8 text-center text-[12px] font-semibold leading-5 text-white/45">연결된 플랫폼이 없습니다.</div> <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> </aside>
<section className="min-h-0 overflow-y-auto px-6 py-5"> <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> <div className="grid h-full place-items-center text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div>
) : ( ) : (
<> <>
+4
View File
@@ -36,6 +36,10 @@ declare global {
}> }>
error?: string error?: string
}> }>
install: () => 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
+2 -1
View File
@@ -598,7 +598,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
<div className="relative" ref={noticeRef}> <div className="relative" ref={noticeRef}>
<Tooltip label="공지사항"> <Tooltip label="공지사항">
<button <button
onClick={() => { onClick={(event) => {
event.currentTarget.blur()
markAppNoticesRead() markAppNoticesRead()
setNoticeOpen((v) => !v) setNoticeOpen((v) => !v)
}} }}
+84 -18
View File
@@ -59,8 +59,24 @@ type TailscaleProvisionResponse = {
type PendingTailscaleConfirm = { type PendingTailscaleConfirm = {
session: AuthSession session: AuthSession
email: string email: string
password: string
keepLogin: boolean keepLogin: boolean
message: string 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 { function extractProvisionErrorMessage(payload: unknown): string {
@@ -134,11 +150,8 @@ async function ensureLauncherTailscale(account: string, password: string): Promi
) { ) {
return return
} }
if ( if (isTailscaleMissingStatus(status)) {
status?.backendState === 'Unavailable' && throw new TailscaleInstallRequiredError()
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
) {
return
} }
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, { 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 [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null) const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
const [tailscaleInstalling, setTailscaleInstalling] = useState(false)
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : '' const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => { const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
@@ -282,7 +296,9 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
setPendingTailscaleConfirm({ setPendingTailscaleConfirm({
session, session,
email, email,
password,
keepLogin, keepLogin,
reason: tailscaleError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.' message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
}) })
return 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 ( return (
<> <>
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late"> <form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
@@ -394,33 +444,49 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
</form> </form>
{pendingTailscaleConfirm && createPortal( {pendingTailscaleConfirm && createPortal(
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]"> <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"> <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"> <p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다. {pendingTailscaleConfirm.reason === 'install-required'
? 'Autonomy Studio는 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치를 진행하려면 관리자 권한이 필요합니다.'
: '플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.'}
</p> </p>
</div> </div>
<div className="px-5 py-4"> <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"> <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> </div>
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4"> <div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={() => setPendingTailscaleConfirm(null)}
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"
>
취소
</button>
<button <button
type="button" type="button"
onClick={() => void continueWithoutTailscale()} onClick={() => void continueWithoutTailscale()}
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]" 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> </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()}
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]"
>
계속 진행
</button>
)}
</div> </div>
</section> </section>
</div>, </div>,