diff --git a/package.json b/package.json index f286cc2..cae28a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autonomy-studio-launcher", - "version": "1.0.14", + "version": "1.0.15", "description": "Autonomy Studio v2 Frontend", "main": "./out/main/index.js", "author": { @@ -27,6 +27,14 @@ "appId": "com.autonomystudio.launcher", "productName": "Autonomy Studio Launcher", "artifactName": "autonomy-studio-launcher-${version}.${ext}", + "protocols": [ + { + "name": "Autonomy Studio Launcher", + "schemes": [ + "autonomy-studio" + ] + } + ], "directories": { "output": "dist" }, diff --git a/src/main/index.ts b/src/main/index.ts index fac8dce..a6c1054 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,7 @@ 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 { spawn, exec, execFile, ChildProcess } from 'child_process' import { electronApp, optimizer, is } from '@electron-toolkit/utils' protocol.registerSchemesAsPrivileged([ @@ -11,6 +11,7 @@ protocol.registerSchemesAsPrivileged([ ]) let splash: BrowserWindow | null = null +let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null type CommandResult = { ok: boolean @@ -57,6 +58,77 @@ function getLauncherDir(): string { return dirname(process.execPath) } +function dispatchGoogleOAuthPayload(payload: { loginToken?: string; error?: string }): void { + const windows = BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()) + if (windows.length === 0) { + pendingGoogleOAuthPayload = payload + return + } + + pendingGoogleOAuthPayload = null + for (const win of windows) { + win.webContents.send('google-oauth-callback', payload) + } +} + +function handleProtocolUrl(rawUrl: string): void { + let url: URL + try { + url = new URL(rawUrl) + } catch { + return + } + + if (url.protocol !== 'autonomy-studio:' || url.hostname !== 'auth' || url.pathname !== '/google/callback') { + return + } + + dispatchGoogleOAuthPayload({ + loginToken: url.searchParams.get('loginToken') ?? undefined, + error: url.searchParams.get('error') ?? undefined, + }) +} + +function escapeDesktopExecValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function registerLinuxProtocolHandler(): void { + if (process.platform !== 'linux') return + + try { + const desktopFileName = is.dev + ? 'autonomy-studio-launcher-dev.desktop' + : 'autonomy-studio-launcher.desktop' + const applicationsDir = join(app.getPath('home'), '.local', 'share', 'applications') + const desktopFilePath = join(applicationsDir, desktopFileName) + const iconPath = is.dev + ? join(process.cwd(), 'resources/icon.png') + : join(dirname(process.execPath), 'resources/icon.png') + const execArgs = process.defaultApp && process.argv[1] + ? `"${escapeDesktopExecValue(process.execPath)}" "${escapeDesktopExecValue(resolve(process.argv[1]))}" %u` + : `"${escapeDesktopExecValue(process.execPath)}" %u` + + mkdirSync(applicationsDir, { recursive: true }) + writeFileSync(desktopFilePath, [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Autonomy Studio Launcher', + `Exec=${execArgs}`, + `Icon=${iconPath}`, + 'Terminal=false', + 'NoDisplay=true', + 'MimeType=x-scheme-handler/autonomy-studio;', + '' + ].join('\n'), 'utf8') + + execFile('xdg-mime', ['default', desktopFileName, 'x-scheme-handler/autonomy-studio'], () => undefined) + execFile('update-desktop-database', [applicationsDir], () => undefined) + } catch (error) { + console.warn('Failed to register autonomy-studio protocol handler', error) + } +} + function getUserAppsDir(): string { return join(app.getPath('home'), '.local', 'share', 'autonomy-studio', 'apps') } @@ -711,6 +783,9 @@ function createWindow(): void { splash?.close() splash = null mainWindow.show() + if (pendingGoogleOAuthPayload) { + dispatchGoogleOAuthPayload(pendingGoogleOAuthPayload) + } }, 1500) }) @@ -853,6 +928,7 @@ function createWindow(): void { ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus()) 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-ssh-key-ensure', () => ensureLauncherSshKey()) ipcMain.handle( @@ -1058,32 +1134,64 @@ function createWindow(): void { } } -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() +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + app.exit(0) +} else { + if (process.defaultApp && process.argv.length >= 2) { + app.setAsDefaultProtocolClient('autonomy-studio', process.execPath, [resolve(process.argv[1])]) + } else { + app.setAsDefaultProtocolClient('autonomy-studio') } -}) + + app.on('second-instance', (_event, argv) => { + const protocolUrl = argv.find((arg) => arg.startsWith('autonomy-studio://')) + if (protocolUrl) handleProtocolUrl(protocolUrl) + + const window = BrowserWindow.getAllWindows()[0] + if (window) { + if (window.isMinimized()) window.restore() + window.focus() + } + }) + + app.on('open-url', (event, url) => { + event.preventDefault() + handleProtocolUrl(url) + }) + + for (const arg of process.argv) { + if (arg.startsWith('autonomy-studio://')) handleProtocolUrl(arg) + } + + app.whenReady().then(() => { + electronApp.setAppUserModelId('com.autonomystudio.launcher') + registerLinuxProtocolHandler() + + 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() + } + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f5526f..414ed35 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,6 +20,14 @@ const launcherTailscale = { const launcherSshKey = { ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'), } +const launcherAuth = { + startGoogleLogin: (url: string) => ipcRenderer.invoke('start-google-login', url), + onGoogleOAuthCallback: (callback: (payload: { loginToken?: string; error?: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { loginToken?: string; error?: string }) => callback(payload) + ipcRenderer.on('google-oauth-callback', listener) + return () => ipcRenderer.removeListener('google-oauth-callback', listener) + }, +} if (process.contextIsolated) { try { @@ -27,6 +35,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta) contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale) contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey) + contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) } catch (error) { console.error(error) } @@ -39,4 +48,6 @@ if (process.contextIsolated) { window.launcherTailscale = launcherTailscale // @ts-ignore window.launcherSshKey = launcherSshKey + // @ts-ignore + window.launcherAuth = launcherAuth } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 293756d..f2f480b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,19 +31,39 @@ function App(): React.JSX.Element { } }, []) - const handleLogout = useCallback((): void => { - const accessToken = authSession?.accessToken + const requestLogout = useCallback((accessToken?: string): void => { void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true, headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }).catch(() => undefined) + }, []) + + const handleLogout = useCallback((): void => { + requestLogout(authSession?.accessToken) window.launcherTailscale?.logout().catch(() => undefined) window.localStorage.removeItem(AUTH_SESSION_KEY) window.sessionStorage.removeItem(AUTH_SESSION_KEY) setAuthSession(null) - }, [authSession?.accessToken]) + }, [authSession?.accessToken, requestLogout]) + + + useEffect(() => { + if (!authSession?.accessToken) return + + const accessToken = authSession.accessToken + const handleSessionClose = (): void => { + requestLogout(accessToken) + } + + window.addEventListener('pagehide', handleSessionClose) + window.addEventListener('beforeunload', handleSessionClose) + return () => { + window.removeEventListener('pagehide', handleSessionClose) + window.removeEventListener('beforeunload', handleSessionClose) + } + }, [authSession?.accessToken, requestLogout]) useEffect(() => { if (!authSession?.accessToken) return diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e63e81e..2e5bd24 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -52,6 +52,12 @@ declare global { privateKeyPath: string }> } + launcherAuth?: { + startGoogleLogin: (url: string) => Promise + onGoogleOAuthCallback: ( + callback: (payload: { loginToken?: string; error?: string }) => void + ) => () => void + } } } diff --git a/src/renderer/src/pages/LoginForm.tsx b/src/renderer/src/pages/LoginForm.tsx index 5777445..7cb38a9 100644 --- a/src/renderer/src/pages/LoginForm.tsx +++ b/src/renderer/src/pages/LoginForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons' @@ -41,6 +41,7 @@ export interface AuthSession { } const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') +const AUTH_API_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100').replace(/\/+$/, '') const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const MIN_LOGIN_DURATION_MS = 2000 const AUTH_SESSION_KEY = 'atnmy.auth.session' @@ -202,6 +203,58 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: onLogin(session) } + useEffect(() => { + const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => { + setPendingTailscaleConfirm(null) + setError('') + + if (payload.error || !payload.loginToken) { + setLoading(false) + onLoadingChange(false) + setError('Google 로그인에 실패했습니다.') + return + } + + setLoading(true) + onLoadingChange(true) + const startedAt = Date.now() + + try { + const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/oauth/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ loginToken: payload.loginToken }), + }) + if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.') + + const session = await response.json() as AuthSession + await completeLogin(session, session.user.email, keepLogin, startedAt) + } catch (oauthError) { + setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.') + } finally { + setLoading(false) + onLoadingChange(false) + } + }) + + return () => unsubscribe?.() + }, [completeLogin, keepLogin, onLoadingChange]) + + const handleGoogleLogin = async (): Promise => { + setLoading(true) + setError('') + onLoadingChange(true) + + try { + await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`) + } catch { + setLoading(false) + onLoadingChange(false) + setError('Google 로그인 창을 열 수 없습니다.') + } + } + const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() if (emailError) return @@ -327,7 +380,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
-