mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(desktop): make attachment data-URL size limit configurable (#73221)
Hard 16 MB cap on readFileDataUrl blocked larger local attaches with no way to raise it. Settings -> Chat now has a free-form MB field. Main process owns the persisted value and clamps only absurd inputs.
This commit is contained in:
parent
b259668cac
commit
b5dc471152
13 changed files with 344 additions and 12 deletions
|
|
@ -7,6 +7,9 @@ import { pathToFileURL } from 'node:url'
|
|||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
|
|
@ -24,6 +27,14 @@ async function rejectsWithCode(promise, code: string) {
|
|||
})
|
||||
}
|
||||
|
||||
test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => {
|
||||
assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB)
|
||||
assert.equal(clampDataUrlReadMaxMb(0), 1)
|
||||
assert.equal(clampDataUrlReadMaxMb(256), 256)
|
||||
assert.equal(clampDataUrlReadMaxMb(99999), 4096)
|
||||
assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024)
|
||||
})
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,30 @@ import path from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
|
||||
// image preview, etc.). The whole file is base64-buffered in main, so this is
|
||||
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
|
||||
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
|
||||
// values can OOM / crash the app).
|
||||
const DATA_URL_READ_DEFAULT_MAX_MB = 16
|
||||
const DATA_URL_READ_MIN_MAX_MB = 1
|
||||
const DATA_URL_READ_MAX_MAX_MB = 4096
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
function clampDataUrlReadMaxMb(value) {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
|
||||
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
|
||||
}
|
||||
|
||||
function dataUrlReadMaxBytesFromMb(maxMb) {
|
||||
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
|
||||
}
|
||||
|
||||
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
||||
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
|
||||
|
|
@ -304,7 +325,11 @@ async function resolveReadableFileForIpc(
|
|||
}
|
||||
|
||||
export {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
DATA_URL_READ_MAX_MAX_MB,
|
||||
DATA_URL_READ_MIN_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,9 @@ import {
|
|||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret as encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
|
|
@ -960,7 +962,7 @@ app.setAboutPanelOptions({
|
|||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// whole file into memory and is hard-capped (default 16 MB, Settings → Chat),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
|
|
@ -10052,9 +10054,56 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
|||
return true
|
||||
})
|
||||
|
||||
// Data-URL file load cap (composer attach + local previews). Main owns the
|
||||
// persisted MB value so every IPC read honours Settings → Chat without the
|
||||
// renderer having to pass maxBytes on each call. Default is 16 MB; clamp
|
||||
// lives in hardening.ts.
|
||||
const DATA_URL_READ_MAX_CONFIG_PATH = path.join(app.getPath('userData'), 'data-url-read-max.json')
|
||||
|
||||
function readPersistedDataUrlReadMaxMb() {
|
||||
try {
|
||||
return clampDataUrlReadMaxMb(JSON.parse(fs.readFileSync(DATA_URL_READ_MAX_CONFIG_PATH, 'utf8')).maxMb)
|
||||
} catch {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
}
|
||||
|
||||
let dataUrlReadMaxMb = readPersistedDataUrlReadMaxMb()
|
||||
|
||||
function persistDataUrlReadMaxMb(maxMb) {
|
||||
const next = clampDataUrlReadMaxMb(maxMb)
|
||||
dataUrlReadMaxMb = next
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DATA_URL_READ_MAX_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_URL_READ_MAX_CONFIG_PATH, JSON.stringify({ maxMb: next }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[data-url-read-max] write failed: ${error.message}`)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:data-url-read-max:get', () => ({
|
||||
maxMb: dataUrlReadMaxMb,
|
||||
// Keep the default bytes constant visible for tests / diagnostics.
|
||||
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb)
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
|
||||
const next = persistDataUrlReadMaxMb(maxMb)
|
||||
|
||||
return {
|
||||
maxMb: next,
|
||||
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(next)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
|
||||
purpose: 'File preview'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
||||
dataUrlReadMax: {
|
||||
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
|
||||
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
|
||||
},
|
||||
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
|
|
|
|||
|
|
@ -3844,7 +3844,7 @@ describe('uploadComposerAttachment remote read failures', () => {
|
|||
|
||||
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
|
||||
// electron/hardening.ts rejects the readFileDataUrl IPC with this exact
|
||||
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
|
||||
// shape when a file exceeds the configured data-URL read cap.
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
|
|||
}
|
||||
|
||||
// The readFileDataUrl IPC base64-loads the whole file into memory and is
|
||||
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
|
||||
// hard-capped in the main process (default 16 MB; Settings → Chat), which
|
||||
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
|
||||
// remote mode every attachment's bytes go through that read, so a big file
|
||||
// surfaces that internal message verbatim in the failure toast. Translate it
|
||||
|
|
|
|||
|
|
@ -5,8 +5,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$dataUrlReadMaxMb,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
DATA_URL_READ_MAX_MAX_MB,
|
||||
DATA_URL_READ_MIN_MAX_MB,
|
||||
refreshDataUrlReadMaxMb,
|
||||
setDataUrlReadMaxMb
|
||||
} from '@/store/data-url-read-max'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
|
||||
|
|
@ -21,7 +32,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
|||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { QuickEntrySettings } from './quick-entry-settings'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
|
|
@ -305,9 +316,13 @@ export function ConfigSettings({
|
|||
<QuickEntrySettings />
|
||||
</>
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
|
||||
where image-attachment behavior already lives, so this sits above the
|
||||
schema fields for that section. */}
|
||||
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
|
||||
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
) : visibleFields.length === 0 ? null : (
|
||||
<div className="grid gap-1">
|
||||
{visibleFields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
|
|
@ -345,3 +360,73 @@ export function ConfigSettings({
|
|||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
|
||||
function AttachmentSizeSetting() {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const stored = useStore($dataUrlReadMaxMb)
|
||||
const [draft, setDraft] = useState(String(stored))
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDataUrlReadMaxMb()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(stored))
|
||||
}, [stored])
|
||||
|
||||
const commit = () => {
|
||||
// An empty draft means "reset to the default", not the 1 MB floor
|
||||
// (Number('') === 0 would otherwise clamp down to the floor).
|
||||
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
|
||||
|
||||
// Unchanged: snap the draft back to the stored value and skip the
|
||||
// pointless IPC write + haptic.
|
||||
if (applied === stored) {
|
||||
setDraft(String(stored))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void setDataUrlReadMaxMb(applied).then(next => {
|
||||
setDraft(String(next))
|
||||
|
||||
// On a bridge write failure the store keeps the old value; only
|
||||
// celebrate when the new cap actually landed.
|
||||
if (next === applied) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={c.attachmentSizeLabel}
|
||||
className="w-20"
|
||||
inputMode="numeric"
|
||||
max={DATA_URL_READ_MAX_MAX_MB}
|
||||
min={DATA_URL_READ_MIN_MAX_MB}
|
||||
onBlur={commit}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
value={draft}
|
||||
/>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{c.attachmentSizeUnit}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={c.attachmentSizeDesc}
|
||||
title={c.attachmentSizeTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
|
|
@ -114,6 +114,11 @@ declare global {
|
|||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
readFileDataUrl: (filePath: string) => Promise<string>
|
||||
/** Settings → Chat: max size for local files loaded as data URLs (attach/preview). */
|
||||
dataUrlReadMax?: {
|
||||
get: () => Promise<{ defaultMaxMb: number; maxBytes: number; maxMb: number }>
|
||||
set: (maxMb: number) => Promise<{ defaultMaxMb: number; maxBytes: number; maxMb: number }>
|
||||
}
|
||||
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
|
|
|
|||
|
|
@ -546,7 +546,12 @@ export const en: Translations = {
|
|||
imported: 'Config imported',
|
||||
invalidJson: 'Invalid config JSON',
|
||||
keepAwakeTitle: 'Keep computer awake',
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
|
||||
attachmentSizeTitle: 'Max attachment size',
|
||||
attachmentSizeDesc:
|
||||
'How big a local file Desktop will load for attach and previews, in MB. Default is 16. This is only a limit on this computer. Setting it very high loads the whole file into memory and can freeze or crash the app.',
|
||||
attachmentSizeUnit: 'MB',
|
||||
attachmentSizeLabel: 'Max attachment size in megabytes'
|
||||
},
|
||||
quickEntry: {
|
||||
enabledTitle: 'Quick Entry',
|
||||
|
|
|
|||
|
|
@ -454,6 +454,10 @@ export interface Translations {
|
|||
invalidJson: string
|
||||
keepAwakeTitle: string
|
||||
keepAwakeDesc: string
|
||||
attachmentSizeTitle: string
|
||||
attachmentSizeDesc: string
|
||||
attachmentSizeUnit: string
|
||||
attachmentSizeLabel: string
|
||||
}
|
||||
quickEntry: {
|
||||
enabledTitle: string
|
||||
|
|
|
|||
|
|
@ -756,7 +756,12 @@ export const zh: Translations = {
|
|||
imported: '配置已导入',
|
||||
invalidJson: '配置 JSON 无效',
|
||||
keepAwakeTitle: '保持电脑唤醒',
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
|
||||
attachmentSizeTitle: '附件大小上限',
|
||||
attachmentSizeDesc:
|
||||
'桌面端为附件和预览加载本地文件的大小上限(MB)。默认为 16。此限制仅作用于本机。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。',
|
||||
attachmentSizeUnit: 'MB',
|
||||
attachmentSizeLabel: '附件大小上限(MB)'
|
||||
},
|
||||
quickEntry: {
|
||||
enabledTitle: '快速输入',
|
||||
|
|
|
|||
63
apps/desktop/src/store/data-url-read-max.test.ts
Normal file
63
apps/desktop/src/store/data-url-read-max.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$dataUrlReadMaxMb,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
refreshDataUrlReadMaxMb,
|
||||
setDataUrlReadMaxMb
|
||||
} from './data-url-read-max'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
|
||||
const get = vi.fn(async () => ({ defaultMaxMb: 16, maxBytes: 16 * 1024 * 1024, maxMb: 16 }))
|
||||
|
||||
const set = vi.fn(async (maxMb: number) => ({
|
||||
defaultMaxMb: 16,
|
||||
maxBytes: maxMb * 1024 * 1024,
|
||||
maxMb
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
desktopWindow.hermesDesktop = { dataUrlReadMax: { get, set } } as unknown as Window['hermesDesktop']
|
||||
$dataUrlReadMaxMb.set(DATA_URL_READ_DEFAULT_MAX_MB)
|
||||
get.mockClear()
|
||||
set.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
})
|
||||
|
||||
describe('clampDataUrlReadMaxMb', () => {
|
||||
it('defaults invalid input and clamps only the safety range', () => {
|
||||
expect(clampDataUrlReadMaxMb(undefined)).toBe(16)
|
||||
expect(clampDataUrlReadMaxMb(0)).toBe(1)
|
||||
expect(clampDataUrlReadMaxMb(100)).toBe(100)
|
||||
expect(clampDataUrlReadMaxMb(99999)).toBe(4096)
|
||||
expect(clampDataUrlReadMaxMb(32.4)).toBe(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data-url-read-max store', () => {
|
||||
it('loads the main-process value into the atom', async () => {
|
||||
get.mockResolvedValueOnce({ defaultMaxMb: 16, maxBytes: 32 * 1024 * 1024, maxMb: 32 })
|
||||
|
||||
await expect(refreshDataUrlReadMaxMb()).resolves.toBe(32)
|
||||
expect($dataUrlReadMaxMb.get()).toBe(32)
|
||||
})
|
||||
|
||||
it('writes through the bridge and updates the atom', async () => {
|
||||
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(48)
|
||||
expect(set).toHaveBeenCalledWith(48)
|
||||
expect($dataUrlReadMaxMb.get()).toBe(48)
|
||||
})
|
||||
|
||||
it('keeps the last known-good value when the bridge write fails', async () => {
|
||||
set.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
await expect(setDataUrlReadMaxMb(48)).resolves.toBe(16)
|
||||
expect($dataUrlReadMaxMb.get()).toBe(16)
|
||||
})
|
||||
})
|
||||
76
apps/desktop/src/store/data-url-read-max.ts
Normal file
76
apps/desktop/src/store/data-url-read-max.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Max size for local files Desktop loads as data URLs (composer attach, image
|
||||
* previews, etc.). Main owns the real cap + its JSON under userData — this
|
||||
* atom only mirrors it for Settings → Chat. See electron/main.ts
|
||||
* (`hermes:data-url-read-max:*`) and the default/clamp in electron/hardening.ts.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
/** Ship default; must match DATA_URL_READ_DEFAULT_MAX_MB in hardening.ts. */
|
||||
export const DATA_URL_READ_DEFAULT_MAX_MB = 16
|
||||
export const DATA_URL_READ_MIN_MAX_MB = 1
|
||||
/** Typo / absurd-value guard only — large values can still OOM the app. */
|
||||
export const DATA_URL_READ_MAX_MAX_MB = 4096
|
||||
|
||||
export function clampDataUrlReadMaxMb(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
|
||||
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
|
||||
}
|
||||
|
||||
export const $dataUrlReadMaxMb = atom<number>(DATA_URL_READ_DEFAULT_MAX_MB)
|
||||
|
||||
export async function refreshDataUrlReadMaxMb(): Promise<number> {
|
||||
const api = window.hermesDesktop?.dataUrlReadMax
|
||||
|
||||
if (!api) {
|
||||
return $dataUrlReadMaxMb.get()
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.get()
|
||||
const maxMb = clampDataUrlReadMaxMb(result.maxMb)
|
||||
$dataUrlReadMaxMb.set(maxMb)
|
||||
|
||||
return maxMb
|
||||
} catch {
|
||||
return $dataUrlReadMaxMb.get()
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDataUrlReadMaxMb(maxMb: number): Promise<number> {
|
||||
const next = clampDataUrlReadMaxMb(maxMb)
|
||||
const api = window.hermesDesktop?.dataUrlReadMax
|
||||
|
||||
if (!api) {
|
||||
$dataUrlReadMaxMb.set(next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.set(next)
|
||||
const applied = clampDataUrlReadMaxMb(result.maxMb)
|
||||
$dataUrlReadMaxMb.set(applied)
|
||||
|
||||
return applied
|
||||
} catch (error) {
|
||||
// Leave the atom at the last known-good value and surface the failure —
|
||||
// an optimistic set here would show a cap that was never persisted and
|
||||
// silently reverts on restart.
|
||||
notifyError(error, 'Could not save the max attachment size')
|
||||
|
||||
return $dataUrlReadMaxMb.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.hermesDesktop?.dataUrlReadMax) {
|
||||
void refreshDataUrlReadMaxMb()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue