diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..5fcb8dc --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,47 @@ +# Korean and English UI + +Use the language selector at the top of the studio settings screen (local or +remote workspace). Select a language and press Save to apply it. Unsaved choices +do not change the UI or stored preference. Korean is the default. The choice is +stored under `autonomy-studio.lms.language` in local storage and applies on the +next launch. Switching language updates React subscribers without remounting the +workspace, terminal, editor, or visualization iframe. + +## Scope + +- App navigation, curriculum UI, studio controls, settings, dialogs and help. +- Known application status/error messages and NAV2/SLAM viewer controls. +- Course titles, learning content, materials, module names, source code, terminal + output and server-authored notifications remain in their original language. +- Unrecognized backend/SSH/Docker errors are preserved verbatim for diagnosis. +- Embedded text in guide images and external pages is not translated. +- This does not change ROS, Docker, SSH, the OS locale, or module parameters. + +## Adding Text + +Use `t('Korean source text')` from `src/renderer/src/i18n` at the display boundary +and add the English value to `en.json`. Call `useLocaleRender()` in components +which render translations so an open screen reacts to language changes. + +Keep identifiers and state values unchanged. Do not translate stored form values, +parameter strings, module identifiers, or values used in conditions. Use whole +sentences with named interpolation placeholders instead of joining translated +fragments. Korean falls back to the source key; unknown messages retain the source. + +The standalone SLAM viewer uses `public/slam-viewer/locale.js` and explicitly +marked text/attributes. It follows the same saved preference through storage +events, without reconnecting the bridge or resetting the scene. + +## Verification + +```sh +npm run test:i18n +npx tsc --noEmit -p tsconfig.web.json +npm run build +``` + +The tests cover language persistence, storage failure, cross-window updates, +interpolation, source fallback and literal translation-key coverage. Browser +checks should additionally cover both languages, narrow studio panels, preserving +unsaved edits, and language changes with the viewer open. `npm run build` does not +package an AppImage. diff --git a/package-lock.json b/package-lock.json index 7c1cd04..b98a3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.0.23", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.0.23", + "version": "1.2.1", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", @@ -16,9 +16,11 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", + "i18next": "^26.4.2", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "react-i18next": "^17.0.15", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", @@ -289,6 +291,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5269,6 +5280,15 @@ "dev": true, "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5336,6 +5356,34 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -7933,6 +7981,33 @@ "react": "^19.2.6" } }, + "node_modules/react-i18next": { + "version": "17.0.15", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz", + "integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -8923,7 +8998,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9102,6 +9177,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 2fc240f..78d7421 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "email": "hooladhoon@gmail.com" }, "scripts": { + "test:i18n": "node --test scripts/test-i18n.cjs", + "test:theme": "node --test scripts/test-studio-theme.cjs", "dev": "electron-vite dev", "build": "electron-vite build", "build:linux": "npm run build && electron-builder --linux" @@ -55,9 +57,11 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", + "i18next": "^26.4.2", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "react-i18next": "^17.0.15", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", diff --git a/scripts/test-i18n.cjs b/scripts/test-i18n.cjs new file mode 100644 index 0000000..01d655a --- /dev/null +++ b/scripts/test-i18n.cjs @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { test } = require('node:test') +const ts = require('typescript') +const { createInstance } = require('i18next') + +const root = path.resolve(__dirname, '../src/renderer/src') +const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8')) + +test('all execution guide page titles have English translations', () => { + const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8') + const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1]) + assert.equal(titles.length, 4) + for (const title of titles) assert.ok(dictionary[title], title) +}) + +test('visualization progress uses exact runtime translation keys', () => { + const { api } = setup('en') + assert.equal(api.t('맵 환경 준비 중'), 'Preparing map environment') + assert.equal(api.t('시작 중'), 'Starting') + assert.equal(api.t('종료 중'), 'Stopping') + const panel = fs.readFileSync(path.join(root, 'features/workspace/WorkspaceVisualizationPanel.tsx'), 'utf8') + assert.ok(panel.includes('busy ? t(statusMessage)')) +}) + +test('viewer translates before 3D initialization and accepts language only from its parent', () => { + const events = new Map() + const sent = [] + const parent = { postMessage: message => sent.push(message) } + const label = { dataset: { i18n: '응답을 기다리는 중입니다.' }, textContent: '' } + const document = { + documentElement: {}, + querySelectorAll: selector => selector === '[data-i18n]' ? [label] : [] + } + const source = fs.readFileSync(path.join(root, '../public/slam-viewer/locale.js'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, allowJs: true + } }).outputText + vm.runInNewContext(code, { + exports: {}, document, + localStorage: { getItem: () => 'ko' }, + window: { parent, addEventListener: (type, listener) => events.set(type, listener) } + }) + assert.equal(sent[0].type, 'lms:language:ready') + events.get('message')({ source: {}, data: { type: 'lms:language', language: 'en' } }) + assert.equal(label.textContent, '응답을 기다리는 중입니다.') + events.get('message')({ source: parent, data: { type: 'lms:language', language: 'en' } }) + assert.equal(label.textContent, 'Waiting for a response.') + events.get('message')({ source: parent, data: { type: 'lms:language', language: 'ko' } }) + assert.equal(label.textContent, '응답을 기다리는 중입니다.') + const html = fs.readFileSync(path.join(root, '../public/slam-viewer/index.html'), 'utf8') + assert.ok(html.includes('')) +}) + +function setup(savedLanguage, storageBlocked = false) { + const instance = createInstance() + const storage = new Map([['autonomy-studio.lms.language', savedLanguage]]) + const events = new Map() + const document = { documentElement: { lang: '' } } + const exports = {} + const source = fs.readFileSync(path.join(root, 'i18n/index.ts'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { + exports, document, + localStorage: { + getItem(key) { if (storageBlocked) throw Error('blocked'); return storage.get(key) }, + setItem(key, value) { if (storageBlocked) throw Error('blocked'); storage.set(key, value) } + }, + window: { addEventListener: (name, listener) => events.set(name, listener) }, + require(name) { + if (name === 'i18next') return instance + if (name === './en.json') return dictionary + return require(name) + } + }) + return { api: exports, storage, document, events } +} + +test('Korean defaults, English persists, interpolation and fallback preserve user content', () => { + const { api, storage, document } = setup('invalid') + assert.equal(api.t('설정'), '설정') + assert.equal(document.documentElement.lang, 'ko') + api.setLanguage('en') + assert.equal(api.t('설정'), 'Settings') + assert.equal(api.t('{{week}}주차', { week: 3 }), 'Week 3') + assert.equal(api.t('강사가 작성한 강좌 제목'), '강사가 작성한 강좌 제목') + assert.equal(api.t('sim_mode:=false'), 'sim_mode:=false') + assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'en') + assert.equal(document.documentElement.lang, 'en') + api.setLanguage('ko') + assert.equal(api.t('{{week}}주차', { week: 3 }), '3주차') +}) + +test('restores preference and synchronizes other renderer windows', () => { + const { api, document, events } = setup('en') + assert.equal(api.t('설정'), 'Settings') + events.get('storage')({ key: api.LANGUAGE_STORAGE_KEY, newValue: 'ko' }) + assert.equal(document.documentElement.lang, 'ko') +}) + +test('blocked storage does not prevent switching language', () => { + const { api } = setup(undefined, true) + api.setLanguage('en') + assert.equal(api.t('설정'), 'Settings') +}) + +test('language selector edits a draft without applying or persisting it', () => { + const { api, storage, document } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'i18n/LanguageSelect.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => name === './index' ? api : require(name) }) + let draft = 'ko' + const element = exports.default({ dark: false, value: draft, onChange: value => { draft = value } }) + const select = element.props.children.find(child => child.type === 'select') + select.props.onChange({ target: { value: 'en' } }) + assert.equal(draft, 'en') + assert.equal(document.documentElement.lang, 'ko') + assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'ko') +}) + +test('built-in map labels follow language without changing map data or saved names', () => { + const { api } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'features/workspace/WorldMapSelectorModal.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => { + if (name === '../../i18n') return api + if (name === './studioTheme') return { useStudioTheme: () => 'dark' } + if (name.endsWith('.css')) return {} + return require(name) + } }) + const map = exports.NAV2_MAP_OPTIONS[0] + assert.equal(exports.getMapDisplayName(map), '기본 월드') + api.setLanguage('en') + assert.equal(exports.getMapDisplayName(map), 'Default world') + assert.equal(exports.getMapDisplayName({ ...map, saved: true }), '기본 월드') + assert.equal(map.name, '기본 월드') + assert.equal(map.worldFile, 'turtlebot3_world.world') + assert.equal(api.t('새 2D 맵'), 'New 2D map') + assert.equal(api.t('새 3D 맵'), 'New 3D map') +}) + +test('translations preserve every interpolation placeholder', () => { + const placeholders = value => [...value.matchAll(/{{\s*([^}]+)\s*}}/g)].map(match => match[1]).sort() + for (const [key, value] of Object.entries(dictionary)) { + assert.ok(value.trim(), `Empty translation: ${key}`) + assert.deepEqual(placeholders(value), placeholders(key), key) + } +}) + +test('learning statistics translate API labels but preserve numeric values', () => { + const { api } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'features/curriculum/LearningStatsCard.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => name === '../../i18n' + ? { ...api, useLocaleRender() {} } : require(name) }) + const stats = [ + { key: 'weeks', label: '완료한 주차', value: '0/3', iconKey: 'check' }, + { key: 'time', label: '학습 시간', value: '02:08', iconKey: 'clock' }, + { key: 'commits', label: '커밋 수', value: '0', iconKey: 'trending' }, + { key: 'score', label: '평균 점수', value: '0', iconKey: 'gauge' } + ] + const { createElement } = require('react') + const { renderToStaticMarkup } = require('react-dom/server') + const render = () => renderToStaticMarkup(createElement(exports.default, { stats })) + assert.ok(render().includes('완료한 주차')) + api.setLanguage('en') + const html = render() + for (const text of ['Completed weeks', 'Learning time', 'Commits', 'Average score', '0/3', '02:08']) { + assert.ok(html.includes(text), text) + } + for (const item of stats) assert.ok(!html.includes(item.label), item.label) + api.setLanguage('ko') + assert.ok(render().includes('평균 점수')) +}) + +test('all literal translation calls have an English entry', () => { + function check(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) { check(file); continue } + if (!/\.tsx?$/.test(file)) continue + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + function visit(node) { + if (ts.isCallExpression(node) && node.expression.getText(source) === 't' && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) { + assert.ok(Object.hasOwn(dictionary, node.arguments[0].text), `${file}: ${node.arguments[0].text}`) + } + ts.forEachChild(node, visit) + } + visit(source) + } + } + check(root) +}) diff --git a/scripts/test-remote-shell.cjs b/scripts/test-remote-shell.cjs new file mode 100644 index 0000000..47ba4ec --- /dev/null +++ b/scripts/test-remote-shell.cjs @@ -0,0 +1,85 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { readFileSync, mkdtempSync, writeFileSync, rmSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { spawn } = require('node:child_process') +const vm = require('node:vm') +const ts = require('typescript') + +const transpile = (source) => ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText +const helpers = { exports: {} } +vm.runInNewContext(transpile(readFileSync(join(__dirname, '../src/main/remoteShell.ts'), 'utf8')), helpers) +const { remoteShellCommand, remoteShellEnvironment } = helpers.exports +const source = readFileSync(join(__dirname, '../src/main/index.ts'), 'utf8') +const start = source.indexOf('async function runCommandOutput(') +const end = source.indexOf('\nasync function runCommand(', start) +const context = { spawn, process, setTimeout, clearTimeout, createModuleProgressParser: () => () => {} } +vm.runInNewContext(transpile(source.slice(start, end)), context) +const run = context.runCommandOutput + +test('SSH data output excludes locale warnings even when command succeeds', async () => { + const result = await run('/bin/bash', ['--noprofile', '--norc', '-c', 'printf %s "/tmp/module with spaces"'], undefined, + { LC_ALL: 'missing_TEST.UTF-8' }, 5000, 'stdout') + assert.equal(result, '/tmp/module with spaces') +}) + +test('failed commands retain stderr diagnostics and stdout', async () => { + await assert.rejects(run('/bin/sh', ['-c', 'printf context; printf "permission denied" >&2; exit 7'], undefined, undefined, 5000, 'stdout'), + error => error.message.includes('context') && error.message.includes('permission denied')) +}) + +test('non-SSH combined output stays compatible and explicit log merging works', async () => { + const command = 'printf output; printf diagnostic >&2' + const combined = await run('/bin/sh', ['-c', command]) + assert.match(combined, /output/) + assert.match(combined, /diagnostic/) + const logs = await run('/bin/sh', ['-c', `{ ${command}; } 2>&1`], undefined, undefined, 5000, 'stdout') + assert.match(logs, /diagnostic/) +}) + +test('SSH environment neutralizes locale forwarding without losing other settings', () => { + const original = { LANG: 'ko_KR.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'bad', LANGUAGE: 'ko:en', PATH: '/bin', TERM: 'xterm-256color' } + const env = remoteShellEnvironment(original) + assert.equal(env.LANG, 'C') + assert.equal(env.LC_ALL, 'C') + assert.equal(env.LC_CTYPE, undefined) + assert.equal(env.LANGUAGE, undefined) + assert.equal(env.PATH, '/bin') + assert.equal(env.TERM, 'xterm-256color') + assert.equal(original.LC_CTYPE, 'bad') +}) + +test('remote wrapper preserves multiline commands, Unicode, quotes and exit status', async () => { + const command = `cat <<'END' +/tmp/한글 module's folder +END` + const result = await run('/bin/sh', ['-c', remoteShellCommand(command)], undefined, remoteShellEnvironment(process.env), 5000, 'stdout') + assert.equal(result, "/tmp/한글 module's folder\n") + await assert.rejects(run('/bin/sh', ['-c', remoteShellCommand('echo remote-failure >&2; exit 9')], undefined, undefined, 5000, 'stdout'), /remote-failure/) +}) + +test('locale selection prefers C UTF-8, supports other UTF-8 locales and falls back to C', async () => { + const dir = mkdtempSync(join(tmpdir(), 'lms-locale-test-')) + try { + for (const [locales, expected] of [['en_GB.utf8 C.utf8', 'C.utf8'], ['C en_GB.utf8', 'en_GB.utf8'], ['C POSIX', 'C']]) { + writeFileSync(join(dir, 'locale'), `#!/bin/sh\nprintf '%s\\n' ${locales}\n`, { mode: 0o755 }) + const result = await run('/bin/sh', ['-c', remoteShellCommand('printf "%s|%s" "$LANG" "$LC_ALL"')], undefined, + { PATH: `${dir}:${process.env.PATH}`, LC_ALL: 'C' }, 5000, 'stdout') + assert.equal(result, `${expected}|${expected}`) + } + } finally { rmSync(dir, { recursive: true, force: true }) } +}) + +test('queued SSH, stats probes and remote PTY use normalization; container logs keep stderr', () => { + const ssh = source.slice(source.indexOf('async function runSshOutput('), source.indexOf('async function readRemoteDockerStatsOutput(')) + assert.equal((ssh.match(/remoteShellCommand\(command\)/g) || []).length, 2) + assert.equal((ssh.match(/'stdout'/g) || []).length, 2) + const terminal = source.slice(source.indexOf("ipcMain.handle('remote-workspace:terminal:start'"), source.indexOf("ipcMain.handle('terminal-resize'")) + assert.match(terminal, /remoteShellCommand\(mode === 'container'/) + assert.match(terminal, /remoteShellEnvironment\(terminalEnvironment/) + assert.match(source, /target \? remoteShellCommand\(init\)/) + assert.match(source, /docker logs --timestamps --tail 300.*2>&1/) +}) diff --git a/scripts/test-studio-theme.cjs b/scripts/test-studio-theme.cjs new file mode 100644 index 0000000..7845746 --- /dev/null +++ b/scripts/test-studio-theme.cjs @@ -0,0 +1,48 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const root = path.resolve(__dirname, '../src/renderer/src/features/workspace') + +function setup(saved, blocked = false) { + const storage = new Map([['autonomy-studio.lms.studio-theme', saved]]) + const events = new Map() + const exports = {} + const code = ts.transpileModule(fs.readFileSync(path.join(root, 'studioTheme.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } + }).outputText + vm.runInNewContext(code, { exports, + require: () => ({ useSyncExternalStore: (_subscribe, snapshot) => snapshot() }), + localStorage: { + getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) }, + setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) } + }, + window: { addEventListener: (type, listener) => events.set(type, listener) } + }) + return { api: exports, storage, events } +} + +test('theme defaults dark, persists light, and restores the preference', () => { + const { api, storage } = setup('invalid') + assert.equal(api.useStudioTheme(), 'dark') + api.setStudioTheme('light') + assert.equal(api.useStudioTheme(), 'light') + assert.equal(storage.get(api.STUDIO_THEME_KEY), 'light') + assert.equal(setup('light').api.useStudioTheme(), 'light') +}) +test('theme remains usable when storage is blocked', () => { + const { api } = setup(null, true) + api.setStudioTheme('light') + assert.equal(api.useStudioTheme(), 'light') +}) +test('theme synchronizes across windows without writing runtime settings', () => { + const { api, storage, events } = setup('dark') + storage.set(api.STUDIO_THEME_KEY, 'light') + events.get('storage')({ key: api.STUDIO_THEME_KEY }) + assert.equal(api.useStudioTheme(), 'light') + storage.clear() + events.get('storage')({ key: null }) + assert.equal(api.useStudioTheme(), 'dark') +}) diff --git a/src/main/index.ts b/src/main/index.ts index 6d16a5c..683203e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { checkDockerStatus } from './dockerStatus' import { cameraRuntimeArtifacts } from './cameraRuntime' import { remoteModuleFilesScript } from './remoteModuleFiles' import { resolveContainerTerminal } from './containerTerminal' +import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress' @@ -1723,10 +1724,11 @@ function handleModuleOperation(channel: string, handler: Parameters { +async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise { const readProgress = createModuleProgressParser() return new Promise((resolvePromise, rejectPromise) => { let output = '' + let stdout = '' let terminated = false const child = spawn(command, args, { cwd, @@ -1756,6 +1758,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e child.stdout.setEncoding('utf8') child.stdout.on('data', (data: string) => { + stdout += data.toString() output += data.toString() readProgress(data.toString()) }) @@ -1769,7 +1772,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e child.on('close', (code) => { clearTimeout(timeout) if (terminated) return - if (code === 0) resolvePromise(output) + if (code === 0) resolvePromise(outputMode === 'stdout' ? stdout : output) else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`)) }) }) @@ -2135,11 +2138,12 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, - command + remoteShellCommand(command) ], undefined, - undefined, - timeoutMs + remoteShellEnvironment(process.env), + timeoutMs, + 'stdout' )) } @@ -2167,11 +2171,12 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, - command, + remoteShellCommand(command), ], undefined, - undefined, + remoteShellEnvironment(process.env), timeoutMs, + 'stdout', ) } @@ -5704,7 +5709,7 @@ async function readRemoteWorkspaceModuleLogs( if (!existsOutput.trim()) return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } try { - const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)}`, 10000) + const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)} 2>&1`, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } @@ -5980,7 +5985,8 @@ async function containerTerminalCommand(moduleRoot: string, moduleName: string | const container = resolveContainerTerminal(output, moduleRoot) const shell = buildWorkspaceShellCommand(moduleName, container.cwd, cols, rows, false) const init = 'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi; if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi; exec ' + shell - return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} /bin/bash --noprofile --norc -c ${quoteShellSingle(init)}` + const containerShell = target ? remoteShellCommand(init) : `/bin/bash --noprofile --norc -c ${quoteShellSingle(init)}` + return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} ${containerShell}` } function registerTerminalHandlers(): void { @@ -6079,14 +6085,15 @@ function registerTerminalHandlers(): void { '-i', target.identityFile, `${target.user}@${target.host}`, - mode === 'container' + remoteShellCommand(mode === 'container' ? await containerTerminalCommand(resolved.moduleRoot, moduleName, safeCols, safeRows, target) - : buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) + : buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)) ], { name: 'xterm-256color', cols: safeCols, rows: safeRows, - env: terminalEnvironment(safeCols, safeRows) + env: Object.fromEntries(Object.entries(remoteShellEnvironment(terminalEnvironment(safeCols, safeRows))) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string')) }) attachTerminalProcess(event.sender, sessionId, terminalKey, proc) diff --git a/src/main/remoteShell.ts b/src/main/remoteShell.ts new file mode 100644 index 0000000..4bd72c2 --- /dev/null +++ b/src/main/remoteShell.ts @@ -0,0 +1,28 @@ +function quote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +// SSH may forward LANG/LC_* through the user's SendEnv configuration. +// Undefined entries also remove inherited keys when merged by the command runner. +export function remoteShellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result = { ...env } + for (const key of Object.keys(result)) { + if (key === 'LANGUAGE' || key.startsWith('LC_')) result[key] = undefined + } + return { ...result, LANG: 'C', LC_ALL: 'C' } +} + +export function remoteShellCommand(command: string): string { + const script = [ + 'selected_locale=C', + // Probe under C so an inherited, unavailable locale cannot break the probe. + 'available_locales=$(locale -a 2>/dev/null) || available_locales=', + 'for candidate in $available_locales; do', + ' case "$candidate" in C.utf8|C.UTF-8) selected_locale=$candidate; break ;;', + ' *utf8|*UTF-8|*utf-8) if [ "$selected_locale" = C ]; then selected_locale=$candidate; fi ;;', + ' esac', + 'done', + `exec env LANG="$selected_locale" LC_ALL="$selected_locale" LANGUAGE=C /bin/bash --noprofile --norc -c ${quote(command)}`, + ].join('\n') + return `env LANG=C LC_ALL=C LANGUAGE=C /bin/sh -c ${quote(script)}` +} diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 13313cc..b0c9d81 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -377,50 +377,50 @@ - Wall + Wall - SAVE + SAVE - Path - Sensor - Pose - Goal - + Path + Sensor + Pose + Goal + Default Top Follow - ▾ + ▾ - ▾ - + ▾ + - i 전진 · , 후진 · j/l 회전 · k 정지 + i 전진 · , 후진 · j/l 회전 · k 정지 - + ▾ - - 응답을 기다리는 중입니다. + 응답을 기다리는 중입니다. - 맵을 저장하고 있습니다. + 맵을 저장하고 있습니다. +