Compare commits
7 Commits
5b3c983af9
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| af5ee0299d | |||
| 9bbad61fc5 | |||
| 3bf433a129 | |||
| 9e7f4b02c7 | |||
| 4ca0575b32 | |||
| 5099ca74e0 | |||
| 3b6e2787c7 |
+19
@@ -5,3 +5,22 @@ out/
|
|||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Direct `tsc -p` output. Electron Vite owns application compilation.
|
||||||
|
/electron.vite.config.js
|
||||||
|
/electron.vite.config.d.ts
|
||||||
|
/src/main/*.js
|
||||||
|
/src/main/*.d.ts
|
||||||
|
/src/main/**/*.js
|
||||||
|
/src/main/**/*.d.ts
|
||||||
|
/src/preload/*.js
|
||||||
|
/src/preload/*.d.ts
|
||||||
|
/src/preload/**/*.js
|
||||||
|
/src/preload/**/*.d.ts
|
||||||
|
/src/renderer/src/*.js
|
||||||
|
/src/renderer/src/*.jsx
|
||||||
|
/src/renderer/src/*.d.ts
|
||||||
|
/src/renderer/src/**/*.js
|
||||||
|
/src/renderer/src/**/*.jsx
|
||||||
|
/src/renderer/src/**/*.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||||
|
build: { rollupOptions: { input: resolve('src/main/index.ts') } },
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||||
|
build: { rollupOptions: { input: resolve('src/preload/index.ts') } },
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
@@ -19,6 +23,7 @@ export default defineConfig({
|
|||||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.1.0",
|
"version": "1.1.5",
|
||||||
"description": "Autonomy Studio v2 Frontend",
|
"description": "Autonomy Studio v2 Frontend",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview --config electron.vite.config.ts",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev --config electron.vite.config.ts",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build --config electron.vite.config.ts",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"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:linux": "npm run build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"build": {
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-14
@@ -6,6 +6,7 @@ import { get as httpsGet } from 'https'
|
|||||||
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import { LmsWebBridge } from './lms-web-bridge'
|
import { LmsWebBridge } from './lms-web-bridge'
|
||||||
|
import { createFusePreparation } from './appImageFuse'
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||||
@@ -14,6 +15,35 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
let splash: BrowserWindow | null = null
|
let splash: BrowserWindow | null = null
|
||||||
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
||||||
const lmsWebBridge = new LmsWebBridge()
|
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 = {
|
type CommandResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -320,12 +350,17 @@ function downloadFile(
|
|||||||
|
|
||||||
const total = Number(response.headers['content-length'] ?? 0)
|
const total = Number(response.headers['content-length'] ?? 0)
|
||||||
let received = 0
|
let received = 0
|
||||||
|
let lastProgress = -1
|
||||||
const file = createWriteStream(tmpPath)
|
const file = createWriteStream(tmpPath)
|
||||||
|
|
||||||
response.on('data', (chunk: Buffer) => {
|
response.on('data', (chunk: Buffer) => {
|
||||||
received += chunk.length
|
received += chunk.length
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
onProgress(Math.min(99, Math.round((received / total) * 100)))
|
const progress = Math.min(99, Math.round((received / total) * 100))
|
||||||
|
if (progress !== lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
onProgress(progress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -512,6 +547,38 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLauncherOsUser(): string {
|
||||||
|
const user = (process.env.USER ?? process.env.LOGNAME ?? '').trim()
|
||||||
|
return /^[a-z_][a-z0-9_-]*\$?$/i.test(user) ? user : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTailscaleOperator(): Promise<CommandResult> {
|
||||||
|
const operator = getLauncherOsUser()
|
||||||
|
if (!operator) {
|
||||||
|
return { ok: false, message: '현재 OS 사용자를 확인하지 못했습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ['set', `--operator=${operator}`]
|
||||||
|
try {
|
||||||
|
await runCommand('tailscale', args, 15000)
|
||||||
|
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
||||||
|
} catch (error) {
|
||||||
|
if (!isTailscalePermissionError(error)) {
|
||||||
|
return { ok: false, message: commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패') }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runCommand('pkexec', ['/usr/bin/tailscale', ...args], 30000)
|
||||||
|
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
||||||
|
} catch (pkexecError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function installTailscale(): Promise<CommandResult> {
|
async function installTailscale(): Promise<CommandResult> {
|
||||||
const installScript = [
|
const installScript = [
|
||||||
'set -e',
|
'set -e',
|
||||||
@@ -590,13 +657,6 @@ async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<vo
|
|||||||
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
|
||||||
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
|
|
||||||
if (!loggedOut) {
|
|
||||||
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
||||||
const attempts: Array<[string, string[]]> = [
|
const attempts: Array<[string, string[]]> = [
|
||||||
['tailscale', ['status', '--json']],
|
['tailscale', ['status', '--json']],
|
||||||
@@ -716,16 +776,24 @@ async function loginTailscale(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isTailscalePermissionError(error)) {
|
if (isTailscalePermissionError(error)) {
|
||||||
try {
|
try {
|
||||||
if (forceReset) {
|
|
||||||
await resetTailscaleSessionWithPkexec(commandEnv)
|
|
||||||
}
|
|
||||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
||||||
await waitForTailscaleConnected()
|
await waitForTailscaleConnected()
|
||||||
return { ok: true, message: 'Tailscale 연결 완료' }
|
|
||||||
|
// Persist unprivileged access after the one privileged recovery login.
|
||||||
|
const operator = await ensureTailscaleOperator()
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: operator.ok
|
||||||
|
? 'Tailscale 연결 완료'
|
||||||
|
: 'Tailscale 연결 완료. 다음 연결을 위해 제어 권한을 다시 확인하세요.',
|
||||||
|
}
|
||||||
} catch (pkexecError) {
|
} catch (pkexecError) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
|
message: commandErrorMessage(
|
||||||
|
pkexecError,
|
||||||
|
commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다. 다시 연결해 주세요.'),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,6 +987,10 @@ function createWindow(): void {
|
|||||||
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
||||||
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||||
|
|
||||||
|
if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) {
|
||||||
|
await prepareLmsFuse()
|
||||||
|
}
|
||||||
|
|
||||||
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||||
event.sender.send('app-download-progress', {
|
event.sender.send('app-download-progress', {
|
||||||
appName: payload.appName,
|
appName: payload.appName,
|
||||||
@@ -1027,6 +1099,7 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||||
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
|
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
|
||||||
|
ipcMain.handle('launcher-tailscale-ensure-operator', () => ensureTailscaleOperator())
|
||||||
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'launcher-tailscale-login',
|
'launcher-tailscale-login',
|
||||||
@@ -1060,7 +1133,12 @@ function createWindow(): void {
|
|||||||
return { ok: true }
|
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
|
appName: string
|
||||||
url?: string
|
url?: string
|
||||||
width?: number
|
width?: number
|
||||||
@@ -1070,6 +1148,13 @@ function createWindow(): void {
|
|||||||
handoffCode?: string
|
handoffCode?: string
|
||||||
audienceApp?: 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 { 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 authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||||
const authLaunchArgs = handoffCode
|
const authLaunchArgs = handoffCode
|
||||||
|
|||||||
@@ -176,6 +176,28 @@ function containerName(folderName: string): string | null {
|
|||||||
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null
|
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNav2ModuleFolder(folderName: string): boolean {
|
||||||
|
return /^\[lms-nav2-[^\]]+\].+_v[^/]+_ws$/.test(folderName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalNav2RuntimeImage(module: CourseModule, body: Record<string, unknown>, rosParams: string): string {
|
||||||
|
const configured = module.dockerImage?.trim() ?? ''
|
||||||
|
if (configured && /^[A-Za-z0-9._/:@-]+$/.test(configured)) return configured
|
||||||
|
|
||||||
|
const gazeboModule = /^\[lms-nav2-gazebo-/i.test(module.folderName)
|
||||||
|
|| (/lms-nav2-mkmini/i.test(module.folderName) && /(?:^|\s)simulator:=gazebo(?:\s|$)/.test(rosParams))
|
||||||
|
const runtimeImages = Array.isArray(body.runtimeImages) ? body.runtimeImages : []
|
||||||
|
const matched = runtimeImages.find((value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||||
|
const image = (value as RuntimeImageArtifact).image?.trim() ?? ''
|
||||||
|
return gazeboModule
|
||||||
|
? /nav2.*gazebo|gazebo.*nav2/i.test(image)
|
||||||
|
: /nav2/i.test(image) && !/gazebo|ros-bridge/i.test(image)
|
||||||
|
}) as RuntimeImageArtifact | undefined
|
||||||
|
|
||||||
|
return matched?.image?.trim() || (gazeboModule ? 'future-nav2-gazebo:humble' : 'future-nav2:humble')
|
||||||
|
}
|
||||||
|
|
||||||
function nav2LaunchSpec(folderName: string): { packageName: string; launchFile: string } | null {
|
function nav2LaunchSpec(folderName: string): { packageName: string; launchFile: string } | null {
|
||||||
const module = folderName.match(/^\[lms-nav2-(?:gazebo-)?([a-z]+)\]/)?.[1]
|
const module = folderName.match(/^\[lms-nav2-(?:gazebo-)?([a-z]+)\]/)?.[1]
|
||||||
const specs: Record<string, { packageName: string; launchFile: string }> = {
|
const specs: Record<string, { packageName: string; launchFile: string }> = {
|
||||||
@@ -930,7 +952,6 @@ export class LmsWebBridge {
|
|||||||
const module = this.moduleFromBody(body)
|
const module = this.moduleFromBody(body)
|
||||||
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
||||||
const target = resolve(root, module.folderName)
|
const target = resolve(root, module.folderName)
|
||||||
const runtimeConfigRoot = resolve(homedir(), 'autonomy-studio', 'runtime', 'config')
|
|
||||||
if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||||
const container = containerName(module.folderName)
|
const container = containerName(module.folderName)
|
||||||
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
||||||
@@ -940,6 +961,23 @@ export class LmsWebBridge {
|
|||||||
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
||||||
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
||||||
const gpuEnabled = body.gpuEnabled === true
|
const gpuEnabled = body.gpuEnabled === true
|
||||||
|
if (isNav2ModuleFolder(module.folderName)) {
|
||||||
|
const runtimeScript = resolve(homedir(), 'autonomy-studio', 'runtime', 'nav2', 'docker', 'docker_run_nav2.sh')
|
||||||
|
if (!existsSync(runtimeScript)) throw new Error(`NAV2 런타임 스크립트를 찾을 수 없습니다: ${runtimeScript}`)
|
||||||
|
await command('bash', [
|
||||||
|
runtimeScript,
|
||||||
|
container,
|
||||||
|
root,
|
||||||
|
module.folderName,
|
||||||
|
resolveLocalNav2RuntimeImage(module, body, rosParams),
|
||||||
|
rosDomainId,
|
||||||
|
gpuEnabled ? 'true' : 'false',
|
||||||
|
...rosParams.split(/\s+/).filter(Boolean),
|
||||||
|
], undefined, 45_000)
|
||||||
|
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfigRoot = resolve(homedir(), 'autonomy-studio', 'runtime', 'config')
|
||||||
await mkdir(runtimeConfigRoot, { recursive: true })
|
await mkdir(runtimeConfigRoot, { recursive: true })
|
||||||
const args = ['run', '-d', '--name', container, '--network', 'host', '-v', `${target}:/autoware:rw`, '-v', `${runtimeConfigRoot}:/autoware/config:rw`, '-v', '/etc/localtime:/etc/localtime:ro', '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'ROS_LOCALHOST_ONLY=1']
|
const args = ['run', '-d', '--name', container, '--network', 'host', '-v', `${target}:/autoware:rw`, '-v', `${runtimeConfigRoot}:/autoware/config:rw`, '-v', '/etc/localtime:/etc/localtime:ro', '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'ROS_LOCALHOST_ONLY=1']
|
||||||
if (process.env.DISPLAY) args.push('-e', `DISPLAY=${process.env.DISPLAY}`, '-v', '/tmp/.X11-unix:/tmp/.X11-unix')
|
if (process.env.DISPLAY) args.push('-e', `DISPLAY=${process.env.DISPLAY}`, '-v', '/tmp/.X11-unix:/tmp/.X11-unix')
|
||||||
@@ -957,6 +995,19 @@ export class LmsWebBridge {
|
|||||||
const container = containerName(module.folderName)
|
const container = containerName(module.folderName)
|
||||||
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
||||||
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
||||||
|
if (isNav2ModuleFolder(module.folderName)) {
|
||||||
|
const runtimeScript = resolve(homedir(), 'autonomy-studio', 'runtime', 'nav2', 'docker', 'docker_build_nav2.sh')
|
||||||
|
if (!existsSync(runtimeScript)) throw new Error(`NAV2 빌드 스크립트를 찾을 수 없습니다: ${runtimeScript}`)
|
||||||
|
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
||||||
|
await command('bash', [
|
||||||
|
runtimeScript,
|
||||||
|
root,
|
||||||
|
module.folderName,
|
||||||
|
resolveLocalNav2RuntimeImage(module, body, rosParams),
|
||||||
|
rosDomainId,
|
||||||
|
], undefined, 900_000)
|
||||||
|
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||||
|
}
|
||||||
await command('docker', [
|
await command('docker', [
|
||||||
'run', '--rm', '--name', `${container}-build`, '--volume', `${target}:/autoware:rw`, '-u', 'root', '--net=host',
|
'run', '--rm', '--name', `${container}-build`, '--volume', `${target}:/autoware:rw`, '-u', 'root', '--net=host',
|
||||||
'-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', AUTOWARE_IMAGE,
|
'-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', AUTOWARE_IMAGE,
|
||||||
@@ -1689,7 +1740,7 @@ done`
|
|||||||
const buildOnly = body.buildOnly === true
|
const buildOnly = body.buildOnly === true
|
||||||
const configuredImage = typeof body.dockerImage === 'string' ? body.dockerImage.trim() : module.dockerImage?.trim()
|
const configuredImage = typeof body.dockerImage === 'string' ? body.dockerImage.trim() : module.dockerImage?.trim()
|
||||||
const nav2Launch = nav2LaunchSpec(module.folderName)
|
const nav2Launch = nav2LaunchSpec(module.folderName)
|
||||||
const runtimeImage = configuredImage || (nav2Launch ? 'nav2-dev:humble' : AUTOWARE_IMAGE)
|
const runtimeImage = configuredImage || (nav2Launch ? 'future-nav2-gazebo:humble' : AUTOWARE_IMAGE)
|
||||||
if (!/^[A-Za-z0-9._/:@-]+$/.test(runtimeImage)) throw new Error('모듈 런타임 이미지가 올바르지 않습니다.')
|
if (!/^[A-Za-z0-9._/:@-]+$/.test(runtimeImage)) throw new Error('모듈 런타임 이미지가 올바르지 않습니다.')
|
||||||
const nav2RunCommand = nav2Launch
|
const nav2RunCommand = nav2Launch
|
||||||
? `set -e; source /opt/ros/humble/setup.bash; for setup in /modules/*/install/setup.bash; do [ -f "${'$'}setup" ] && source "${'$'}setup"; done; exec ros2 launch ${nav2Launch.packageName} ${nav2Launch.launchFile} ${'$'}1`
|
? `set -e; source /opt/ros/humble/setup.bash; for setup in /modules/*/install/setup.bash; do [ -f "${'$'}setup" ] && source "${'$'}setup"; done; exec ros2 launch ${nav2Launch.packageName} ${nav2Launch.launchFile} ${'$'}1`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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'),
|
install: () => ipcRenderer.invoke('launcher-tailscale-install'),
|
||||||
|
ensureOperator: () => ipcRenderer.invoke('launcher-tailscale-ensure-operator'),
|
||||||
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'),
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ function App(): React.JSX.Element {
|
|||||||
blockLauncherSessionRefresh()
|
blockLauncherSessionRefresh()
|
||||||
window.launcherSession?.endUserSession().catch(() => undefined)
|
window.launcherSession?.endUserSession().catch(() => undefined)
|
||||||
requestLogout(authSession?.accessToken)
|
requestLogout(authSession?.accessToken)
|
||||||
window.launcherTailscale?.logout().catch(() => undefined)
|
|
||||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
setAuthSession(null)
|
setAuthSession(null)
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ body,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|||||||
Vendored
+4
@@ -40,6 +40,10 @@ declare global {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
message: string
|
message: string
|
||||||
}>
|
}>
|
||||||
|
ensureOperator: () => 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
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const LMS_WEB_BASE_URL = `${(
|
|||||||
).replace(/\/+$/, '')}/`
|
).replace(/\/+$/, '')}/`
|
||||||
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||||
|
const TAILSCALE_DEVICE_ID_STORAGE_KEY = 'atnmy.launcher.tailscale-device-id'
|
||||||
const IS_DEV = import.meta.env.DEV
|
const IS_DEV = import.meta.env.DEV
|
||||||
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
@@ -102,6 +103,21 @@ function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount:
|
|||||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTailscaleDeviceId(): string {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(TAILSCALE_DEVICE_ID_STORAGE_KEY)?.trim()
|
||||||
|
if (stored && /^[a-z0-9-]{6,24}$/i.test(stored)) return stored
|
||||||
|
|
||||||
|
const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID().replace(/-/g, '').slice(0, 10)
|
||||||
|
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
window.localStorage.setItem(TAILSCALE_DEVICE_ID_STORAGE_KEY, next)
|
||||||
|
return next
|
||||||
|
} catch {
|
||||||
|
return 'launcher-client'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
status?.backendState === 'Unavailable' &&
|
status?.backendState === 'Unavailable' &&
|
||||||
@@ -257,6 +273,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAccountIdentity = Boolean(
|
||||||
|
status?.loginName?.trim() || status?.userName?.trim(),
|
||||||
|
)
|
||||||
const isSameAccount = Boolean(
|
const isSameAccount = Boolean(
|
||||||
status &&
|
status &&
|
||||||
(
|
(
|
||||||
@@ -264,7 +283,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
isSameTailscaleAccount(status.userName, userEmail)
|
isSameTailscaleAccount(status.userName, userEmail)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (status?.connected && isSameAccount) {
|
if (status?.connected && (!hasAccountIdentity || isSameAccount)) {
|
||||||
setTailscaleIssue(null)
|
setTailscaleIssue(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,7 +295,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
hostname: window.navigator.userAgent.includes('Electron')
|
||||||
|
? `launcher-client-${getTailscaleDeviceId()}`
|
||||||
|
: `client-${getTailscaleDeviceId()}`,
|
||||||
usageType: 'launcher-client',
|
usageType: 'launcher-client',
|
||||||
publicSshKey: sshKey?.publicKey,
|
publicSshKey: sshKey?.publicKey,
|
||||||
}),
|
}),
|
||||||
@@ -297,11 +318,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldResetSession = Boolean(
|
||||||
|
status &&
|
||||||
|
status.backendState !== 'Unavailable' &&
|
||||||
|
hasAccountIdentity &&
|
||||||
|
!isSameAccount,
|
||||||
|
)
|
||||||
const result = await launcherTailscale.login(
|
const result = await launcherTailscale.login(
|
||||||
provision.tailscaleAuthKey,
|
provision.tailscaleAuthKey,
|
||||||
provision.tailscaleLoginServer ?? '',
|
provision.tailscaleLoginServer ?? '',
|
||||||
provision.tailscaleHostname,
|
provision.tailscaleHostname,
|
||||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
shouldResetSession,
|
||||||
)
|
)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setTailscaleIssue({
|
setTailscaleIssue({
|
||||||
@@ -760,6 +787,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
|
|
||||||
let handoffCode: string | undefined
|
let handoffCode: string | undefined
|
||||||
try {
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('prepare-app-runtime', {
|
||||||
|
appName: activeApp,
|
||||||
|
execPath: appConfig.execPath,
|
||||||
|
devAppPath: appConfig.devAppPath,
|
||||||
|
})
|
||||||
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||||
if (handoffAudienceApp) {
|
if (handoffAudienceApp) {
|
||||||
const [handoffToken] = await Promise.all([
|
const [handoffToken] = await Promise.all([
|
||||||
@@ -785,6 +817,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
})
|
})
|
||||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||||
setRunningApps((prev) => new Set([...prev, activeApp]))
|
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.')
|
||||||
} finally {
|
} finally {
|
||||||
setLaunchingApp(null)
|
setLaunchingApp(null)
|
||||||
}
|
}
|
||||||
@@ -971,6 +1005,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
) : canLaunch ? (
|
) : canLaunch ? (
|
||||||
activeAppLaunching ? (
|
activeAppLaunching ? (
|
||||||
<button className="launch-pending-btn" disabled>
|
<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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": false,
|
||||||
|
"incremental": false,
|
||||||
|
"noEmit": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"types": ["electron-vite/node"]
|
"types": ["electron-vite/node"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
"include": ["src/renderer/src/**/*"],
|
"include": ["src/renderer/src/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": false,
|
||||||
|
"incremental": false,
|
||||||
|
"noEmit": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user