feat(desktop): reconcile live tool events, polish thread chrome, harden boot
- chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results).
This commit is contained in:
@@ -3,11 +3,7 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
bundledRuntimeImportCheck,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
||||
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
|
||||
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
||||
const fallback =
|
||||
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
||||
const parsed = Number(timeoutMs)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed)
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value, safeStorageApi) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
let encryptionAvailable = false
|
||||
|
||||
try {
|
||||
encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
|
||||
} catch {
|
||||
encryptionAvailable = false
|
||||
}
|
||||
|
||||
if (!encryptionAvailable) {
|
||||
throw new Error(
|
||||
'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
encoding: 'safeStorage',
|
||||
value: safeStorageApi.encryptString(raw).toString('base64')
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
|
||||
throw new Error(
|
||||
`Failed to encrypt the remote gateway token for secure storage${detail}. ` +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function sensitiveFileBlockReason(filePath) {
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
const basename = path.basename(normalized)
|
||||
const ext = path.extname(basename)
|
||||
|
||||
if (!basename) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.includes('/.ssh/')) {
|
||||
return 'SSH key/config files are blocked.'
|
||||
}
|
||||
|
||||
if (normalized.includes('/.gnupg/')) {
|
||||
return 'GPG key material is blocked.'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('/.aws/credentials')) {
|
||||
return 'AWS credential files are blocked.'
|
||||
}
|
||||
|
||||
if (basename === '.env') {
|
||||
return '.env files are blocked because they commonly contain secrets.'
|
||||
}
|
||||
|
||||
if (basename.startsWith('.env.')) {
|
||||
const suffix = basename.slice('.env.'.length)
|
||||
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
||||
return `${basename} is blocked because it appears to contain environment secrets.`
|
||||
}
|
||||
}
|
||||
|
||||
if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
|
||||
return 'SSH private key files are blocked.'
|
||||
}
|
||||
|
||||
if (SENSITIVE_EXTENSIONS.has(ext)) {
|
||||
return `${ext} key/certificate files are blocked.`
|
||||
}
|
||||
|
||||
if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
|
||||
return `${basename} is blocked because it may include auth credentials.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs('2750'), 2750)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret requires available secure storage', () => {
|
||||
assert.equal(
|
||||
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
|
||||
null
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
|
||||
/Secure token storage is unavailable/
|
||||
)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
|
||||
const secret = encryptDesktopSecret('token-123', {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
|
||||
})
|
||||
|
||||
assert.deepEqual(secret, {
|
||||
encoding: 'safeStorage',
|
||||
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
|
||||
})
|
||||
})
|
||||
|
||||
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
|
||||
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
|
||||
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
@@ -21,6 +21,14 @@ const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { spawn } = require('node:child_process')
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
if (USER_DATA_OVERRIDE) {
|
||||
@@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
// 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
|
||||
// 2. Development source — when running `npm run dev` from a checkout, the
|
||||
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
|
||||
// installed `hermes` on PATH so local Python edits are actually exercised.
|
||||
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
|
||||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
// 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
|
||||
// pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
|
||||
// (used by test:desktop:fresh to force the factory-image bootstrap path).
|
||||
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
|
||||
@@ -876,15 +893,6 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Development source — when running `npm run dev` from a checkout, the
|
||||
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the
|
||||
// desktop uses the dev's local edits, not whatever's under HERMES_HOME.
|
||||
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
|
||||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
// 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
|
||||
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
|
||||
// On packaged installs this is populated from FACTORY_HERMES_ROOT during
|
||||
@@ -1155,6 +1163,7 @@ function fetchJson(url, token, options = {}) {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
@@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) {
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
if (options.timeoutMs) {
|
||||
req.setTimeout(options.timeoutMs, () => {
|
||||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
|
||||
})
|
||||
}
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||||
})
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
@@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) {
|
||||
const pending = fetchHtmlTitleWithCurl(url)
|
||||
.catch(() => '')
|
||||
.then(value => usableTitle((value || '').slice(0, 240)))
|
||||
.then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)))
|
||||
.then(
|
||||
async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
|
||||
)
|
||||
.then(clean => {
|
||||
cacheTitle(key, clean)
|
||||
titleInflight.delete(key)
|
||||
@@ -2022,24 +2031,7 @@ function tokenPreview(value) {
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
encoding: 'safeStorage',
|
||||
value: safeStorage.encryptString(raw).toString('base64')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plaintext for platforms where Electron cannot encrypt.
|
||||
}
|
||||
|
||||
return { encoding: 'plain', value: raw }
|
||||
return encryptDesktopSecretStrict(value, safeStorage)
|
||||
}
|
||||
|
||||
function decryptDesktopSecret(secret) {
|
||||
@@ -2108,14 +2100,19 @@ function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig())
|
||||
}
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
|
||||
token: incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingToken
|
||||
}
|
||||
|
||||
if (mode === 'remote') {
|
||||
@@ -2181,7 +2178,7 @@ function resolveRemoteBackend() {
|
||||
}
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input)
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const remote =
|
||||
config.mode === 'remote'
|
||||
? {
|
||||
@@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
const connection = await startHermes()
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
|
||||
method: request.method,
|
||||
body: request.body
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
timeoutMs
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const input = String(filePath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
const data = await fs.promises.readFile(resolved)
|
||||
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
const data = await fs.promises.readFile(resolvedPath)
|
||||
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
const input = String(filePath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const handle = await fs.promises.open(resolved, 'r')
|
||||
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
purpose: 'Text preview'
|
||||
})
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const handle = await fs.promises.open(resolvedPath, 'r')
|
||||
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
try {
|
||||
@@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
|
||||
byteSize: stat.size,
|
||||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||||
mimeType: mimeTypeForPath(resolved),
|
||||
path: resolved,
|
||||
mimeType: mimeTypeForPath(resolvedPath),
|
||||
path: resolvedPath,
|
||||
text: buffer.subarray(0, bytesRead).toString('utf8'),
|
||||
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user