diff --git a/package.json b/package.json index d35907f..f8eb00e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.4", + "version": "1.2.5", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/scripts/test-camera-lan.cjs b/scripts/test-camera-lan.cjs new file mode 100644 index 0000000..6f098cf --- /dev/null +++ b/scripts/test-camera-lan.cjs @@ -0,0 +1,81 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const ts = require('typescript') + +const source = ts.createSourceFile('index.ts', fs.readFileSync(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true) +const names = ['buildFastDdsPeerConfig', 'parseCameraLanAddresses', 'cameraLanEnabled', 'rosNetworkEnabled', 'prepareRemoteRosNetworkConfig'] +const code = source.statements.filter(node => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map(node => node.getText(source)).join('\n') +function load(overrides = {}) { + const dependencies = { + parseRosDomainId: value => Number(value ?? 0), + readLocalTailscaleIpv4: async () => '100.64.0.10', + readRemoteTailscaleIpv4: async () => '100.64.0.20', + runSshOutput: async () => JSON.stringify([{ addr_info: [{ family: 'inet', scope: 'global', local: '192.168.1.20' }] }]), + runSsh: async () => {}, + quoteShellSingle: value => "'" + value.replaceAll("'", "'\\''") + "'", + expandRemotePathVar: () => [], + encodeBase64: value => Buffer.from(value).toString('base64'), + ...overrides, + } + return new Function(...Object.keys(dependencies), ts.transpile(code, { target: ts.ScriptTarget.ES2022 }) + `;return {${names.join(',')}}`)(...Object.values(dependencies)) +} + +test('LAN discovery adds physical addresses and multicast while keeping Tailscale peers', () => { + const { buildFastDdsPeerConfig } = load() + const xml = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77', ['192.168.1.20']) + const whitelist = xml.match(/(.*?)<\/interfaceWhiteList>/s)[1] + for (const address of ['127.0.0.1', '100.64.0.20', '192.168.1.20']) assert.ok(whitelist.includes(address)) + assert.ok(xml.includes('
100.64.0.10
')) + assert.ok(xml.includes('
239.255.0.1
26650')) + assert.ok(xml.includes('')) + const original = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77') + assert.ok(!original.includes('192.168.1.20')) + assert.ok(!original.includes('239.255.0.1')) +}) + +test('only the Yahboom camera opts in', () => { + const { cameraLanEnabled } = load() + assert.equal(cameraLanEnabled('[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws'), true) + for (const role of ['vehicle', 'agent', 'control', 'slam', 'planning']) { + assert.equal(cameraLanEnabled(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`), false) + } + assert.equal(cameraLanEnabled('[lms-nav2-custom]camera_v1.0.0_ws'), false) +}) + +test('physical interface responses are parsed, validated and deduplicated', () => { + const { parseCameraLanAddresses } = load() + const output = JSON.stringify([{ addr_info: [ + { family: 'inet', scope: 'global', local: '192.168.1.20' }, + { family: 'inet', scope: 'global', local: '999.1.1.1' }, + { family: 'inet6', scope: 'global', local: 'fe80::1' }, + ] }]) + assert.deepEqual(parseCameraLanAddresses(output + '\n' + output), ['192.168.1.20']) +}) + +test('camera config uses its own path and LAN works without ROS platform linking', async () => { + let script = '' + const api = load({ + readLocalTailscaleIpv4: async () => { throw new Error('Must not require local Tailscale') }, + readRemoteTailscaleIpv4: async () => { throw new Error('Must not require remote Tailscale') }, + runSsh: async (_target, command) => { script = command }, + }) + assert.equal(await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77'), null) + const result = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77', true) + assert.ok(result.endsWith('-camera-lan.xml')) + const encoded = script.match(/printf %s '([^']+)'/)[1] + const xml = Buffer.from(encoded, 'base64').toString() + assert.ok(xml.includes('192.168.1.20')) + assert.ok(xml.includes('239.255.0.1')) +}) + +test('linked camera retains both networks and does not overwrite restricted config', async () => { + const api = load() + const restricted = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77') + const camera = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true) + assert.notEqual(camera, restricted) + assert.ok(camera.includes('100-64-0-10')) + const noLan = load({ runSshOutput: async () => '' }) + await assert.rejects(noLan.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true), /IPv4/) +}) diff --git a/scripts/test-custom-module-access.cjs b/scripts/test-custom-module-access.cjs new file mode 100644 index 0000000..a7aeec0 --- /dev/null +++ b/scripts/test-custom-module-access.cjs @@ -0,0 +1,30 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const root = path.resolve(__dirname, '../src') +const app = fs.readFileSync(path.join(root, 'renderer/src/App.tsx'), 'utf8') +const view = fs.readFileSync(path.join(root, 'renderer/src/features/workspace/ModuleListView.tsx'), 'utf8') + +test('remote creation and Git import are enabled while raw folder import remains local', () => { + assert.match(app, /allowCustomModuleCreation=\{true\}/) + assert.match(app, /allowCustomModuleImport=\{true\}/) + assert.match(app, /remoteGitImport=\{remoteWorkspace \? \{ workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule \} : undefined\}/) + assert.match(view, /remoteGitImport \? { + assert.match(app, /loadDockerImages=\{\(\) => remoteWorkspace\s*\? window\.remoteWorkspace\.modules\.listDockerImages\(getRemoteWorkspaceTarget\(\)\)/) + const dialog = view.slice(view.indexOf('function ModuleAddDialog('), view.indexOf('function ModuleImportDialog(')) + assert.match(dialog, /void loadDockerImages\(\)/) + assert.doesNotMatch(dialog, /window\.localWorkspace/) + const preload = fs.readFileSync(path.join(root, 'preload/index.ts'), 'utf8') + const main = fs.readFileSync(path.join(root, 'main/index.ts'), 'utf8') + assert.match(preload, /invoke\('remote-workspace:modules:docker-images', target\)/) + assert.match(main, /handle\('remote-workspace:modules:docker-images'/) +}) diff --git a/scripts/test-git-module-import.cjs b/scripts/test-git-module-import.cjs new file mode 100644 index 0000000..6fd755e --- /dev/null +++ b/scripts/test-git-module-import.cjs @@ -0,0 +1,109 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const path = require('node:path') +const os = require('node:os') +const vm = require('node:vm') +const ts = require('typescript') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') +const exec = promisify(execFile) +const root = path.resolve(__dirname, '..') + +async function loadHelpers() { + const exports = {} + const code = ts.transpileModule(await fs.readFile(path.join(root, 'src/main/gitModuleImport.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText + vm.runInNewContext(code, { exports, require, URL }) + return exports +} + +test('repository addresses strip credentials and reject local/helper protocols', async () => { + const api = await loadHelpers() + assert.equal(api.repositoryAddress('https://user:secret@example.com/team/module.git?token=secret'), 'https://example.com/team/module.git') + assert.equal(api.repositoryAddress('git@example.com:team/module.git'), 'ssh://git@example.com/team/module.git') + for (const url of ['file:///tmp/repo', 'ext::command', '/tmp/repo']) assert.throws(() => api.repositoryAddress(url)) + assert.equal(api.remoteBranches('a'.repeat(40) + '\trefs/heads/main\ninvalid')[0].name, 'main') +}) + +test('local module discovery ignores malformed folders and rejects symlink escapes', async () => { + const api = await loadHelpers() + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-list-')) + try { + const workspace = path.join(dir, 'workspace') + const folder = '[lms-nav2-custom]demo_v1.0.0_ws' + await fs.mkdir(path.join(workspace, folder), { recursive: true }) + await fs.writeFile(path.join(workspace, folder, '.autonomy-studio-module.json'), JSON.stringify({ sourceType: 'CUSTOM', name: 'Demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py' })) + assert.equal((await api.listLocalGitModules(workspace))[0].name, 'Demo') + await fs.symlink(dir, path.join(workspace, '[lms-nav2-custom]escape_v1.0.0_ws')) + await assert.rejects(api.localGitModulePath(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'), /outside/) + await assert.rejects(api.localGitModulePath(workspace, '../escape'), /Invalid/) + assert.equal((await api.listLocalGitModules(path.join(dir, 'missing'))).length, 0) + } finally { await fs.rm(dir, { recursive: true, force: true }) } +}) + +test('Git clone imports only the selected commit, refuses overwrite and cleans failed staging', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-import-')) + const repo = path.join(dir, 'source') + const workspace = path.join(dir, 'remote') + const folderName = '[lms-nav2-custom]demo_v1.0.0_ws' + const git = (...args) => exec('git', args, { cwd: repo }) + try { + await fs.mkdir(path.join(repo, 'src/demo/launch'), { recursive: true }) + await fs.writeFile(path.join(repo, 'src/demo/package.xml'), 'demo') + await fs.writeFile(path.join(repo, 'src/demo/launch/demo.launch.py'), '# launch') + for (const file of ['build.sh', 'run.sh']) await fs.writeFile(path.join(repo, file), '#!/bin/bash\nexit 0\n') + await git('init', '-b', 'main') + await git('add', '.') + await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'initial') + const commit = (await git('rev-parse', 'HEAD')).stdout.trim() + await fs.writeFile(path.join(repo, 'uncommitted.txt'), 'must not transfer') + let dirty = false + let pushed = true + let requestedCommit = commit + const index = await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8') + const fn = index.slice(index.indexOf('async function importRemoteGitModule('), index.indexOf('async function transferLocalWorkspaceModuleToRemote(')) + const quote = value => "'" + value.replaceAll("'", "'\\''") + "'" + const context = { + URL, Buffer, + normalizeRemoteTarget: target => target, + validateCustomModuleScaffold: input => input, + inspectLocalGitModule: async () => ({ repository: 'https://example.com/module.git', dirty, pushed, branches: [{ name: 'main', commit: requestedCommit }] }), + remoteRepositoryUrl: () => 'file://' + repo, + withGitCredential: value => value, + customModuleConfigContent: input => JSON.stringify(input), + encodeBase64: value => Buffer.from(value).toString('base64'), + quoteShellSingle: quote, + expandRemotePathVar: () => [], + runSshOutput: async (_target, script) => (await exec('bash', ['-c', script], { timeout: 15000 })).stdout, + } + vm.runInNewContext(ts.transpileModule(fn, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context) + const input = { sourceFolder: folderName, folderName, branch: 'main', commit, name: 'Remote demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py', dockerImage: 'vehicle:arm64' } + const run = () => context.importRemoteGitModule({ workspaceRoot: workspace }, repo, input) + dirty = true + await assert.rejects(run(), /커밋/) + dirty = false + pushed = false + await assert.rejects(run(), /푸시/) + pushed = true + assert.equal((await run()).ok, true) + const target = path.join(workspace, folderName) + assert.equal((await exec('git', ['rev-parse', 'HEAD'], { cwd: target })).stdout.trim(), commit) + await assert.rejects(fs.stat(path.join(target, 'uncommitted.txt')), { code: 'ENOENT' }) + assert.equal(JSON.parse(await fs.readFile(path.join(target, '.autonomy-studio-module.json'), 'utf8')).dockerImage, 'vehicle:arm64') + await fs.writeFile(path.join(target, 'keep.txt'), 'keep') + await assert.rejects(run(), /복제에 실패/) + assert.equal(await fs.readFile(path.join(target, 'keep.txt'), 'utf8'), 'keep') + await fs.rm(target, { recursive: true }) + requestedCommit = 'a'.repeat(40) + input.commit = requestedCommit + await assert.rejects(run(), /복제에 실패/) + assert.equal((await fs.readdir(workspace)).length, 0) + await fs.mkdir(path.join(repo, 'install')) + await fs.writeFile(path.join(repo, 'install/binary'), 'not portable') + await git('add', 'install') + await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'bad artifacts') + input.commit = requestedCommit = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects(run(), /복제에 실패/) + assert.equal((await fs.readdir(workspace)).length, 0) + } finally { await fs.rm(dir, { recursive: true, force: true }) } +}) diff --git a/scripts/test-ros-connections.cjs b/scripts/test-ros-connections.cjs index 2e3efa9..7a5dbd4 100644 --- a/scripts/test-ros-connections.cjs +++ b/scripts/test-ros-connections.cjs @@ -45,3 +45,43 @@ test('keeps discovered topics without endpoints and deduplicates node entries', test('module selection and guessed topic mappings are removed from the view', () => { assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/) }) + +test('active toggle filters only topics, preserves totals and hides filtered details', () => { + const graph = { topics: [ + { name: '/active', types: [], publishers: [endpoint('/pub')], subscribers: [endpoint('/sub')] }, + { name: '/waiting', types: [], publishers: [endpoint('/pub')], subscribers: [] }, + ] } + function render(activeOnly) { + const states = [graph, false, activeOnly, '/waiting', true, 320] + let index = 0 + const api = {} + const jsx = (type, props, key) => ({ type, props, key }) + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } + }).outputText, { exports: api, require: (name) => { + if (name === 'react') return { useState: () => [states[index++], () => {}], useMemo: fn => fn(), useEffect: () => {} } + if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx } + if (name === '../../i18n') return { t: value => value, useLocaleRender: () => {} } + return {} + } }) + const tree = api.default({ dark: false, modules: [{ name: 'Running module', state: 'running' }], remoteWorkspace: false, rosDomainId: '77' }) + const nodes = [] + function visit(node) { + if (Array.isArray(node)) return node.forEach(visit) + if (!node || typeof node !== 'object') return + nodes.push(node) + visit(node.props?.children) + } + visit(tree) + return nodes + } + for (const activeOnly of [false, true]) { + const nodes = render(activeOnly) + assert.equal(nodes.filter(node => node.type === 'button' && node.key?.startsWith('/')).length, activeOnly ? 1 : 2) + const toggle = nodes.find(node => node.props?.title === '활성 연결만 보기') + assert.equal(toggle.props['aria-pressed'], activeOnly) + assert.equal(toggle.props.children.at(-1), 1) + assert.equal(nodes.find(node => node.type?.name === 'RuntimeLane').props.modules.length, 1) + assert.equal(nodes.some(node => node.type?.name === 'TopicDetailDrawer'), !activeOnly) + } +}) diff --git a/src/main/gitModuleImport.ts b/src/main/gitModuleImport.ts new file mode 100644 index 0000000..4f27855 --- /dev/null +++ b/src/main/gitModuleImport.ts @@ -0,0 +1,48 @@ +import { readdir, readFile, realpath } from 'fs/promises' +import { resolve, sep } from 'path' +import type { LocalGitModule } from '../shared/gitModuleImport' + +export async function localGitModulePath(root: string, folder: string): Promise { + if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder') + const base = await realpath(root) + const target = await realpath(resolve(base, folder)) + if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace') + return target +} + +export async function listLocalGitModules(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return [] + throw error + }) + const modules: LocalGitModule[] = [] + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue + try { + const target = await localGitModulePath(root, entry.name) + const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8')) + if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue + modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name }) + } catch { /* Ignore incomplete module folders. */ } + } + return modules.sort((a, b) => a.name.localeCompare(b.name)) +} + +export function repositoryAddress(value: string): string { + // Do not allow local paths, Git remote helpers, or credentials in the UI. + const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value + const url = new URL(normalized) + if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol') + url.password = '' + if (url.protocol !== 'ssh:') url.username = '' + url.search = '' + url.hash = '' + return url.toString() +} + +export function remoteBranches(output: string): Array<{ name: string; commit: string }> { + return output.split('\n').flatMap(line => { + const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/) + return match ? [{ name: match[2], commit: match[1] }] : [] + }).sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/src/main/index.ts b/src/main/index.ts index 1293ec5..22c0086 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,8 @@ import { cameraRuntimeArtifacts } from './cameraRuntime' import { remoteModuleFilesScript } from './remoteModuleFiles' import { resolveContainerTerminal } from './containerTerminal' import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' +import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport' +import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress' @@ -1273,6 +1275,82 @@ async function createRemoteCustomWorkspaceModule( return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) } } +async function inspectLocalGitModule(workspaceRoot: string, folder: string): Promise { + const workspace = expandUserPath(workspaceRoot) + if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + const root = await localGitModulePath(workspace, folder) + const git = (args: string[]) => runCommandOutput('git', args, root, { ...process.env, GIT_TERMINAL_PROMPT: '0' }, 15_000, 'stdout') + const top = (await git(['rev-parse', '--show-toplevel'])).trim() + if (resolve(top) !== root) throw new Error('모듈 자체의 Git 저장소가 필요합니다.') + const remote = await git(['remote', 'get-url', 'student']).catch(() => git(['remote', 'get-url', 'origin'])) + const repository = repositoryAddress(remote.trim()) + const currentBranch = (await git(['branch', '--show-current'])).trim() + const dirty = Boolean((await git(['status', '--porcelain'])).trim()) + const head = (await git(['rev-parse', 'HEAD'])).trim() + let branches: GitModuleSource['branches'] + try { + branches = remoteBranches(await git(['ls-remote', '--heads', withGitCredential(repository)])) + } catch { throw new Error('Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.') } + return { repository, currentBranch, branches, dirty, pushed: branches.some(branch => branch.name === currentBranch && branch.commit === head) } +} + +async function importRemoteGitModule(targetInput: RemoteWorkspaceTarget, workspaceRoot: string, input: GitModuleImportInput & { folderName: string }): Promise<{ ok: boolean; workspacePath: string }> { + const target = normalizeRemoteTarget(targetInput) + const module = validateCustomModuleScaffold(input) + const source = await inspectLocalGitModule(workspaceRoot, input.sourceFolder) + if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.') + const branch = source.branches.find(branch => branch.name === input.branch && branch.commit === input.commit) + if (!branch) throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.') + const repository = remoteRepositoryUrl(source.repository) + const authenticated = withGitCredential(repository) + const url = new URL(authenticated) + const authorization = ['http:', 'https:'].includes(url.protocol) && url.username + ? `Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' + const config = encodeBase64(customModuleConfigContent(module)) + // Stage the clone and publish only after validation. Never overwrite an existing workspace. + const script = [ + 'set -e', + `ROOT=${quoteShellSingle(target.workspaceRoot)}`, + ...expandRemotePathVar('ROOT'), + 'mkdir -p "$ROOT"', + `TARGET="$ROOT/"${quoteShellSingle(module.folderName)}`, + 'if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then echo "Module folder already exists" >&2; exit 1; fi', + 'STAGING=$(mktemp -d "$ROOT/.git-import-XXXXXXXX")', + 'trap \'rm -rf -- "$STAGING"\' EXIT', + `AUTH=$(printf %s ${quoteShellSingle(encodeBase64(authorization))} | base64 -d)`, + 'export GIT_TERMINAL_PROMPT=0', + `if ! GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="$AUTH" git clone --single-branch --branch ${quoteShellSingle(branch.name)} -- ${quoteShellSingle(repository)} "$STAGING/module" 2>"$STAGING/clone-error"; then echo "Git clone failed: check repository access from the platform" >&2; exit 1; fi`, + 'unset AUTH', + `test "$(git -C "$STAGING/module" rev-parse HEAD)" = ${quoteShellSingle(branch.commit)} || { echo "Branch changed; refresh and retry" >&2; exit 1; }`, + `python3 - "$STAGING/module" ${quoteShellSingle(config)} <<'PY'`, + 'import base64, json, os, subprocess, sys', + 'root = sys.argv[1]', + 'config = json.loads(base64.b64decode(sys.argv[2]))', + 'paths = subprocess.check_output(["git", "-C", root, "ls-tree", "-r", "--name-only", "HEAD"]).decode().splitlines()', + 'if any(set(path.split("/")) & {"build", "install", "log", "__pycache__"} for path in paths): raise SystemExit("Repository contains build artifacts; remove them before importing")', + 'for path in paths:', + ' if os.path.islink(os.path.join(root, path)): raise SystemExit("Repository symlinks are not supported for module import")', + 'package = config["packageName"]', + 'candidates = [root, os.path.join(root, "src", package)]', + 'if not any(os.path.isfile(os.path.join(p, "package.xml")) and os.path.isfile(os.path.join(p, "launch", config["launchFile"])) for p in candidates): raise SystemExit("Package or launch file is missing in the selected branch")', + 'for script in ("build.sh", "run.sh"):', + ' if not os.path.isfile(os.path.join(root, script)): raise SystemExit("Module build.sh/run.sh is missing")', + ' os.chmod(os.path.join(root, script), 0o755)', + 'with open(os.path.join(root, ".autonomy-studio-module.json"), "w") as fp: json.dump(config, fp, indent=2)', + 'PY', + 'mv -T -n "$STAGING/module" "$TARGET"', + 'test ! -d "$STAGING/module" || { echo "Module folder already exists" >&2; exit 1; }', + 'printf "%s" "$TARGET"', + ].join('\n') + try { + const workspacePath = (await runSshOutput(target, script, 120_000)).trim() + return { ok: true, workspacePath } + } catch { + // SSH failures can include the command (and its temporary authorization header). + throw new Error('Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.') + } +} + async function transferLocalWorkspaceModuleToRemote( targetInput: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | null | undefined, @@ -1774,9 +1852,11 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N await runCommandOutput(command, args, cwd, env, timeoutMs) } -async function listLocalDockerImages(): Promise<{ images: string[]; message?: string }> { +async function listWorkspaceDockerImages(target?: RemoteWorkspaceTarget): Promise<{ images: string[]; message?: string }> { try { - const output = await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000) + const output = target + ? await runSshOutput(target, "docker image ls --format '{{json .}}'", 10_000) + : await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000) const images = Array.from(new Set(output.split('\n').flatMap((line) => { if (!line.trim()) return [] try { @@ -1794,7 +1874,7 @@ async function listLocalDockerImages(): Promise<{ images: string[]; message?: st } catch (error) { return { images: [], - message: error instanceof Error ? error.message : '로컬 Docker 이미지를 불러오지 못했습니다.' + message: error instanceof Error ? error.message : 'Docker 이미지를 불러오지 못했습니다.' } } } @@ -4337,7 +4417,7 @@ function parseRosDomainId(input?: string | null): number { return Number.isInteger(value) && value >= 0 && value <= 232 ? value : 0 } -function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null): string { +function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null, lanAddresses: string[] = []): string { const domainId = parseRosDomainId(rosDomainId) const discoveryBasePort = 7400 + (250 * domainId) // Fast DDS can bind the default discovery port itself (d0), while other @@ -4347,7 +4427,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo discoveryBasePort, ...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId)) ] - const localAddresses = Array.from(new Set(['127.0.0.1', localAddress])) + const localAddresses = Array.from(new Set(['127.0.0.1', localAddress, ...lanAddresses])) const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress])) return [ '', @@ -4371,6 +4451,11 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo ' autonomy_studio_udp', ' ', ' ', + ...(lanAddresses.length ? [ + ' ', + `
239.255.0.1
${discoveryBasePort}
`, + '
', + ] : []), ' ', ...localAddresses.flatMap((address) => [ ' ', @@ -4382,6 +4467,9 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo ]), ' ', ' ', + ...(lanAddresses.length ? [ + `
239.255.0.1
${discoveryBasePort}
`, + ] : []), ...peerAddresses.flatMap((address) => peers.flatMap((port) => [ ' ', ' ', @@ -4467,25 +4555,51 @@ async function prepareLocalRosNetworkConfig( return configPath } +function parseCameraLanAddresses(output: string): string[] { + const interfaces = output.split('\n').filter((line) => line.trim()).flatMap((line) => JSON.parse(line)) + return Array.from(new Set(interfaces.flatMap((item) => + (item.addr_info ?? []) + .filter((address: { family?: string; scope?: string; local?: string }) => + address.family === 'inet' && address.scope === 'global' + && typeof address.local === 'string' + && /^(?:\d{1,3}\.){3}\d{1,3}$/.test(address.local) + && address.local.split('.').every((part) => Number(part) <= 255)) + .map((address: { local: string }) => address.local), + ))) +} + +function cameraLanEnabled(folderName: string): boolean { + return /^\[lms-nav2-yahboom-camera\]yahboom_camera_v[^/]+_ws$/.test(folderName) +} + async function prepareRemoteRosNetworkConfig( target: RemoteWorkspaceTarget, runtimeRoot: string, network?: RosNetworkConfig | null, rosDomainId?: string | null, + allowCameraLan = false, ): Promise { - if (!rosNetworkEnabled(network)) return null + if (!rosNetworkEnabled(network) && !allowCameraLan) return null const [peerAddress, localAddress] = await Promise.all([ - readLocalTailscaleIpv4(), - readRemoteTailscaleIpv4(target), + rosNetworkEnabled(network) ? readLocalTailscaleIpv4() : Promise.resolve('127.0.0.1'), + rosNetworkEnabled(network) ? readRemoteTailscaleIpv4(target) : Promise.resolve('127.0.0.1'), ]) - const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml` + const lanAddresses = allowCameraLan ? parseCameraLanAddresses(await runSshOutput(target, [ + 'set -e', + 'for interface in /sys/class/net/*; do', + ' [ -e "$interface/device" ] || continue', + ' ip -j -4 address show dev "${interface##*/}" up scope global', + 'done', + ].join('\n'), 15_000)) : [] + if (allowCameraLan && !lanAddresses.length) throw new Error('카메라 내부망 통신에 사용할 활성 Ethernet/Wi-Fi IPv4 주소가 없습니다.') + const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}${allowCameraLan ? '-camera-lan' : ''}.xml` await runSsh(target, [ 'set -e', `CONFIG_PATH=${quoteShellSingle(configPath)}`, ...expandRemotePathVar('CONFIG_PATH'), 'mkdir -p "$(dirname \"$CONFIG_PATH\")"', - `printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId)))} | base64 -d > "$CONFIG_PATH"`, + `printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId, lanAddresses)))} | base64 -d > "$CONFIG_PATH"`, ].join('\n'), 15_000) return configPath } @@ -5337,7 +5451,8 @@ async function startRemoteWorkspaceModule( const runtimeRoot = nav2Module ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) - const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) + const allowCameraLan = cameraLanEnabled(targetFolderName) + const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId, allowCameraLan) await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test') ? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage) : yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) @@ -5390,7 +5505,7 @@ async function startRemoteWorkspaceModule( ...expandRemotePathVar('ROOT'), `PARAMS=${quoteShellSingle(remoteRosParams)}`, `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, - `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`, + `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) || allowCameraLan ? '0' : '1'}`, ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), 'read -r -a EXTRA_ARGS <<< "$PARAMS"', 'export FASTDDS_CONFIG_PATH', @@ -6342,7 +6457,15 @@ function registerLocalWorkspaceHandlers(): void { closeSplashAndShowMain() }) ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) - ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages()) + ipcMain.handle('local-workspace:modules:docker-images', () => listWorkspaceDockerImages()) + ipcMain.handle('local-workspace:modules:git-sources', (_event, root: string) => { + const workspace = expandUserPath(root) + if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + return listLocalGitModules(workspace) + }) + ipcMain.handle('local-workspace:modules:git-source', (_event, root: string, folder: string) => inspectLocalGitModule(root, folder)) + ipcMain.handle('remote-workspace:modules:import-git', (_event, target: RemoteWorkspaceTarget, root: string, input: GitModuleImportInput & { folderName: string }) => importRemoteGitModule(target, root, input)) + ipcMain.handle('remote-workspace:modules:docker-images', (_event, target: RemoteWorkspaceTarget) => listWorkspaceDockerImages(target)) ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus()) ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/')) ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph()) diff --git a/src/preload/index.ts b/src/preload/index.ts index 2e47394..b9fd6d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -218,6 +218,8 @@ contextBridge.exposeInMainWorld('localWorkspace', { modules: { list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'), + listGitSources: (root: string) => ipcRenderer.invoke('local-workspace:modules:git-sources', root), + inspectGitSource: (root: string, folder: string) => ipcRenderer.invoke('local-workspace:modules:git-source', root, folder), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input), selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'), @@ -307,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { modules: { list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), + listDockerImages: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:modules:docker-images', target), + importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => ipcRenderer.invoke('remote-workspace:modules:import-git', target, root, input), createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input), transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName), start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), diff --git a/src/renderer/public/assets/splash-logo.png b/src/renderer/public/assets/splash-logo.png new file mode 100644 index 0000000..60d3b20 Binary files /dev/null and b/src/renderer/public/assets/splash-logo.png differ diff --git a/src/renderer/public/assets/splash.before-aspect-fix.png b/src/renderer/public/assets/splash.before-aspect-fix.png new file mode 100644 index 0000000..3a40108 Binary files /dev/null and b/src/renderer/public/assets/splash.before-aspect-fix.png differ diff --git a/src/renderer/public/assets/splash.png b/src/renderer/public/assets/splash.png index 3a40108..bdba798 100644 Binary files a/src/renderer/public/assets/splash.png and b/src/renderer/public/assets/splash.png differ diff --git a/src/renderer/public/splash.html b/src/renderer/public/splash.html index 234795c..df90c23 100644 --- a/src/renderer/public/splash.html +++ b/src/renderer/public/splash.html @@ -9,7 +9,7 @@ width: 100%; height: 100%; overflow: hidden; - background: #07111f; + background: #ffffff; font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } @@ -17,7 +17,7 @@ position: relative; width: 100vw; height: 100vh; - background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%); + background: #ffffff; -webkit-app-region: drag; } @@ -31,22 +31,53 @@ -webkit-user-drag: none; } - .fallback-title { + .brand-logo { position: absolute; - top: 45%; + top: 116px; + left: calc(50% - 3px); + z-index: 1; + width: 80px; + height: 70px; + transform: translateX(-50%); + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + } + + .brand-copy { + position: absolute; + top: 187px; left: 50%; - transform: translate(-50%, -50%); - color: #4d5d76; - text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28); - font-size: 28px; - font-weight: 800; + z-index: 1; + width: 290px; + transform: translateX(-50%); + text-align: center; + } + + .brand-title { + color: #252d39; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 32px; + font-weight: 400; + line-height: 1; letter-spacing: 6px; + text-indent: 6px; + } + + .brand-subtitle { + margin-top: 9px; + color: #7a8495; + font-size: 12px; + font-weight: 400; + line-height: 1.2; + letter-spacing: 0; } .loading { position: absolute; right: 26px; bottom: 20px; + z-index: 2; color: #64738a; text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24); font-size: 12px; @@ -58,6 +89,7 @@ position: absolute; bottom: 20px; left: 20px; + z-index: 2; color: #7a8495; text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2); font-size: 11px; @@ -68,15 +100,17 @@
-
LMS
- + + +
+
LMS
+
Learning Management System
+
학습 환경 준비 중...