Merge branch 'develop' into release
This commit is contained in:
@@ -8,9 +8,13 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||
build: { rollupOptions: { input: resolve('src/main/index.ts') } },
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||
build: { rollupOptions: { input: resolve('src/preload/index.ts') } },
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
@@ -19,6 +23,7 @@ export default defineConfig({
|
||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "autonomy-studio-launcher",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.4",
|
||||
"description": "Autonomy Studio v2 Frontend",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
@@ -14,13 +14,13 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"start": "electron-vite preview --config electron.vite.config.ts",
|
||||
"dev": "electron-vite dev --config electron.vite.config.ts",
|
||||
"build": "npm run typecheck && electron-vite build --config electron.vite.config.ts",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:mac": "electron-vite build --config electron.vite.config.ts && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const ts = require('typescript')
|
||||
const source = readFileSync(join(__dirname, '../src/main/appImageFuse.ts'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const api = {}
|
||||
new Function('exports', compiled)(api)
|
||||
const cache = 'libfuse.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libfuse.so.2'
|
||||
|
||||
function fixture(options = {}) {
|
||||
let installed = Boolean(options.installed)
|
||||
const calls = []
|
||||
const prepare = api.createFusePreparation({
|
||||
platform: options.platform ?? 'linux', arch: 'x64',
|
||||
readOsRelease: () => `ID=ubuntu\nVERSION_ID="${options.version ?? '24.04'}"\nPRETTY_NAME="Ubuntu"`,
|
||||
confirm: async () => { calls.push('confirm'); return options.accept !== false },
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === '/sbin/ldconfig') return { stdout: installed ? cache : '', stderr: '' }
|
||||
if (command === '/usr/bin/apt-cache') return { stdout: options.noCandidate ? 'Candidate: (none)' : 'Candidate: 2.9.9', stderr: '' }
|
||||
if (command === 'pkexec') {
|
||||
if (options.fail) throw new Error('authentication cancelled')
|
||||
installed = options.stillMissing !== true
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected command ${command}`)
|
||||
},
|
||||
})
|
||||
return { prepare, calls }
|
||||
}
|
||||
|
||||
test('FUSE 3 and a foreign architecture do not satisfy FUSE 2', () => {
|
||||
assert.equal(api.hasFuseLibrary(cache, 'x64'), true)
|
||||
assert.equal(api.hasFuseLibrary(cache, 'arm64'), false)
|
||||
assert.equal(api.hasFuseLibrary('libfuse3.so.3 (libc6,x86-64)', 'x64'), false)
|
||||
})
|
||||
test('existing library and non-Linux skip installation', async () => {
|
||||
for (const options of [{ installed: true }, { platform: 'darwin' }]) {
|
||||
const f = fixture(options)
|
||||
await f.prepare()
|
||||
assert.equal(f.calls.includes('confirm'), false)
|
||||
}
|
||||
})
|
||||
test('Ubuntu versions choose the appropriate package and recheck installation', async () => {
|
||||
for (const [version, pkg] of [['22.04', 'libfuse2'], ['24.04', 'libfuse2t64'], ['26.04', 'libfuse2t64']]) {
|
||||
const f = fixture({ version })
|
||||
await f.prepare()
|
||||
assert.deepEqual(f.calls.find((call) => call[0] === 'pkexec'), ['pkexec', '/usr/bin/apt-get', 'install', '-y', '--no-remove', pkg])
|
||||
assert.equal(f.calls.filter((call) => call[0] === '/sbin/ldconfig').length, 2)
|
||||
}
|
||||
})
|
||||
test('cancel does not install and can be retried', async () => {
|
||||
const f = fixture({ accept: false })
|
||||
await assert.rejects(f.prepare(), /취소/)
|
||||
await assert.rejects(f.prepare(), /취소/)
|
||||
assert.equal(f.calls.some((call) => call[0] === 'pkexec'), false)
|
||||
})
|
||||
test('missing candidate, failed authentication and failed postcheck reject', async () => {
|
||||
for (const options of [{ noCandidate: true }, { fail: true }, { stillMissing: true }]) {
|
||||
await assert.rejects(fixture(options).prepare())
|
||||
}
|
||||
})
|
||||
test('concurrent preparations share one prompt and installation', async () => {
|
||||
const f = fixture()
|
||||
await Promise.all([f.prepare(), f.prepare()])
|
||||
assert.equal(f.calls.filter((call) => call === 'confirm').length, 1)
|
||||
assert.equal(f.calls.filter((call) => call[0] === 'pkexec').length, 1)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
export type FusePackage = 'libfuse2' | 'libfuse2t64'
|
||||
|
||||
export function parseOsRelease(content: string): Record<string, string> {
|
||||
return Object.fromEntries(content.split('\n').flatMap((line) => {
|
||||
const match = line.match(/^([A-Z_]+)=(.*)$/)
|
||||
return match ? [[match[1], match[2].replace(/^(["'])(.*)\1$/, '$2')]] : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function fusePackageCandidates(os: Record<string, string>): FusePackage[] {
|
||||
const modern = (os.ID === 'ubuntu' && Number.parseFloat(os.VERSION_ID) >= 24.04)
|
||||
|| (os.ID === 'debian' && Number.parseFloat(os.VERSION_ID) >= 13)
|
||||
return modern ? ['libfuse2t64', 'libfuse2'] : ['libfuse2', 'libfuse2t64']
|
||||
}
|
||||
|
||||
export function hasFuseLibrary(cache: string, arch: string): boolean {
|
||||
const architecture = arch === 'x64' ? 'x86-64' : arch === 'arm64' ? 'AArch64' : null
|
||||
return cache.split('\n').some((line) => /^\s*libfuse\.so\.2\s/.test(line)
|
||||
&& (!architecture || line.includes(architecture)))
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
platform: string
|
||||
arch: string
|
||||
readOsRelease: () => string
|
||||
run: (command: string, args: string[], timeout?: number) => Promise<{ stdout: string; stderr: string }>
|
||||
confirm: (details: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function createFusePreparation(deps: Dependencies): () => Promise<void> {
|
||||
let pending: Promise<void> | null = null
|
||||
const ready = async (): Promise<boolean> => {
|
||||
const result = await deps.run('/sbin/ldconfig', ['-p'], 10000)
|
||||
return hasFuseLibrary(result.stdout, deps.arch)
|
||||
}
|
||||
const prepare = async (): Promise<void> => {
|
||||
if (deps.platform !== 'linux' || await ready()) return
|
||||
const os = parseOsRelease(deps.readOsRelease())
|
||||
if (![os.ID, ...(os.ID_LIKE ?? '').split(/\s+/)].some((id) => id === 'ubuntu' || id === 'debian')) {
|
||||
throw new Error('LMS 실행에 libfuse.so.2가 필요합니다. 이 운영체제에서는 자동 설치를 지원하지 않습니다. 배포판의 FUSE 2 라이브러리를 설치한 뒤 다시 실행하세요.')
|
||||
}
|
||||
let selected: FusePackage | undefined
|
||||
for (const candidate of fusePackageCandidates(os)) {
|
||||
const result = await deps.run('/usr/bin/apt-cache', ['policy', candidate], 10000)
|
||||
if (/Candidate:\s*(?!\(none\))\S+/.test(result.stdout)) {
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!selected) throw new Error('FUSE 2 설치 패키지를 찾지 못했습니다. 시스템 패키지 목록과 Ubuntu 저장소 설정을 확인한 뒤 다시 시도하세요.')
|
||||
if (!await deps.confirm(`${os.PRETTY_NAME ?? os.ID}\n\nLMS 실행에 필요한 ${selected} 라이브러리가 없습니다. 설치를 누르면 관리자 인증 후 설치합니다.`)) {
|
||||
throw new Error('필수 구성요소 설치를 취소했습니다. 다음 설치 또는 실행 시 다시 진행할 수 있습니다.')
|
||||
}
|
||||
try {
|
||||
await deps.run('pkexec', ['/usr/bin/apt-get', 'install', '-y', '--no-remove', selected], 600000)
|
||||
} catch (error) {
|
||||
throw new Error(`필수 구성요소 설치가 완료되지 않았습니다. 관리자 인증 취소, 네트워크 또는 패키지 관리자 잠금 상태를 확인하세요.\n${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
if (!await ready()) throw new Error('설치 후에도 libfuse.so.2를 확인하지 못했습니다. 시스템 패키지 상태를 확인한 뒤 다시 시도하세요.')
|
||||
}
|
||||
return () => {
|
||||
if (!pending) pending = prepare().finally(() => { pending = null })
|
||||
return pending
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -6,6 +6,7 @@ import { get as httpsGet } from 'https'
|
||||
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { LmsWebBridge } from './lms-web-bridge'
|
||||
import { createFusePreparation } from './appImageFuse'
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||
@@ -14,6 +15,35 @@ protocol.registerSchemesAsPrivileged([
|
||||
let splash: BrowserWindow | null = null
|
||||
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
||||
const lmsWebBridge = new LmsWebBridge()
|
||||
const prepareLmsFuse = createFusePreparation({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
readOsRelease: () => readFileSync('/etc/os-release', 'utf8'),
|
||||
run: (command, args, timeout) => runCommand(command, args, timeout, { LC_ALL: 'C', LANG: 'C' }),
|
||||
confirm: async (detail) => {
|
||||
const options = {
|
||||
type: 'info' as const,
|
||||
title: 'LMS 실행 환경 준비',
|
||||
message: '필수 구성요소를 설치하시겠습니까?',
|
||||
detail,
|
||||
buttons: ['설치', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
}
|
||||
const parent = BrowserWindow.getFocusedWindow()
|
||||
const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options)
|
||||
return result.response === 0
|
||||
},
|
||||
})
|
||||
|
||||
async function prepareAppRuntime(payload: { appName: string; execPath?: string; devAppPath?: string }): Promise<void> {
|
||||
if (payload.appName.trim().toLowerCase() !== 'lms') return
|
||||
const devPath = resolveDevAppPath(payload.devAppPath) ?? getDefaultDevAppPath(payload.appName)
|
||||
if (is.dev && devPath && isRunnableDevAppPath(devPath)) return
|
||||
const path = resolveExecPath(payload.execPath, payload.appName)
|
||||
if (path?.toLowerCase().endsWith('.appimage')) await prepareLmsFuse()
|
||||
}
|
||||
|
||||
type CommandResult = {
|
||||
ok: boolean
|
||||
@@ -952,6 +982,10 @@ function createWindow(): void {
|
||||
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
||||
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||
|
||||
if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) {
|
||||
await prepareLmsFuse()
|
||||
}
|
||||
|
||||
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||
event.sender.send('app-download-progress', {
|
||||
appName: payload.appName,
|
||||
@@ -1094,7 +1128,12 @@ function createWindow(): void {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.on('launch-app', (_, payload: {
|
||||
ipcMain.handle('prepare-app-runtime', async (_event, payload: { appName: string; execPath?: string; devAppPath?: string }) => {
|
||||
await prepareAppRuntime(payload)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.on('launch-app', async (_, payload: {
|
||||
appName: string
|
||||
url?: string
|
||||
width?: number
|
||||
@@ -1104,6 +1143,13 @@ function createWindow(): void {
|
||||
handoffCode?: string
|
||||
audienceApp?: string
|
||||
}) => {
|
||||
try {
|
||||
await prepareAppRuntime(payload)
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('app-window-closed', payload.appName)
|
||||
dialog.showErrorBox('LMS 실행 환경 준비 실패', error instanceof Error ? error.message : String(error))
|
||||
return
|
||||
}
|
||||
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
||||
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||
const authLaunchArgs = handoffCode
|
||||
|
||||
@@ -355,6 +355,7 @@ body,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
min-width: 78px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -273,6 +273,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
return
|
||||
}
|
||||
|
||||
const hasAccountIdentity = Boolean(
|
||||
status?.loginName?.trim() || status?.userName?.trim(),
|
||||
)
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
@@ -280,7 +283,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
isSameTailscaleAccount(status.userName, userEmail)
|
||||
),
|
||||
)
|
||||
if (status?.connected && isSameAccount) {
|
||||
if (status?.connected && (!hasAccountIdentity || isSameAccount)) {
|
||||
setTailscaleIssue(null)
|
||||
return
|
||||
}
|
||||
@@ -315,11 +318,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
return
|
||||
}
|
||||
|
||||
const shouldResetSession = Boolean(
|
||||
status &&
|
||||
status.backendState !== 'Unavailable' &&
|
||||
hasAccountIdentity &&
|
||||
!isSameAccount,
|
||||
)
|
||||
const result = await launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||
shouldResetSession,
|
||||
)
|
||||
if (!result.ok) {
|
||||
setTailscaleIssue({
|
||||
@@ -778,6 +787,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
|
||||
let handoffCode: string | undefined
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('prepare-app-runtime', {
|
||||
appName: activeApp,
|
||||
execPath: appConfig.execPath,
|
||||
devAppPath: appConfig.devAppPath,
|
||||
})
|
||||
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||
if (handoffAudienceApp) {
|
||||
const [handoffToken] = await Promise.all([
|
||||
@@ -803,6 +817,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
})
|
||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.')
|
||||
} finally {
|
||||
setLaunchingApp(null)
|
||||
}
|
||||
@@ -989,6 +1005,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
||||
) : canLaunch ? (
|
||||
activeAppLaunching ? (
|
||||
<button className="launch-pending-btn" disabled>
|
||||
<svg aria-hidden="true" className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
||||
<path d="M12 2a10 10 0 1 0 10 10" />
|
||||
</svg>
|
||||
실행중
|
||||
</button>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user