diff --git a/src/main/index.ts b/src/main/index.ts index b7164a7..6be4c4e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ -import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron' +import { app, BrowserWindow, globalShortcut, net, nativeImage, protocol, shell } from 'electron' import { existsSync } from 'fs' import { join } from 'path' +import { pathToFileURL } from 'url' import { isTrustedRendererUrl, registerIpc } from './ipc' import { killAllPtys, killPtysByOwner } from './pty' import { applyStartupSystemSettings, loadSettings } from './settingsStore' @@ -45,6 +46,14 @@ if (!app.isPackaged) { app.setPath('userData', join(app.getPath('appData'), 'OpenTerminal-dev')) } +// Custom background image (设置 → 主题): the renderer page cannot load file: +// subresources directly (Chromium blocks them from non-file origins), so it +// references them through this scheme instead. The handler serves exactly one +// file — the configured background image — nothing else. +protocol.registerSchemesAsPrivileged([ + { scheme: 'otimg', privileges: { secure: true, supportFetchAPI: false, corsEnabled: false } } +]) + // Single instance: a second launch just surfaces the existing window (pulls // it out of the tray if hidden there) instead of starting another process. // The refused instance skips the whole startup path: `app.quit()` only asks @@ -57,6 +66,19 @@ if (!gotSingleInstanceLock) { app.on('second-instance', () => showOrCreate()) app.whenReady().then(() => { + // Serve the configured background image (path lives in settings; anything + // else — including a path that is no longer configured — is refused, so the + // protocol cannot be used to read arbitrary files). + protocol.handle('otimg', (request) => { + const url = new URL(request.url) + const requested = decodeURIComponent(url.pathname.replace(/^\//, '')) + const allowed = loadSettings().terminal.backgroundImage + if (allowed === '' || requested !== allowed || !existsSync(requested)) { + return new Response('', { status: 403 }) + } + return net.fetch(pathToFileURL(requested).toString()) + }) + registerIpc() registerUpdateIpc() // Before the window exists: a renderer-triggered check must not run against diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 0ff44c1..f6145ff 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -138,11 +138,15 @@ function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] { : null const colors: ThemeColors = { ...DEFAULT_DARK.colors } const target = colors as unknown as Record + const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i for (const key of THEME_COLOR_KEYS) { const color = candidate?.[key] - if (typeof color === 'string' && color !== '') target[key] = color - else if (color !== undefined) { - warnings.push(`customThemes[${index}].colors.${key} repaired → built-in default`) + // Only well-formed hex colours survive: xterm would render anything else + // as black, and the theme editor's native colour input needs #rrggbb. + if (typeof color === 'string' && HEX_COLOR.test(color.trim())) { + target[key] = color.trim() + } else if (color !== undefined) { + warnings.push(`customThemes[${index}].colors.${key}: not a hex color — repaired`) } } themes.push({ diff --git a/src/renderer/index.html b/src/renderer/index.html index a012544..92cad32 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ OpenTerminal diff --git a/src/renderer/src/settings/ThemeSettingsTab.tsx b/src/renderer/src/settings/ThemeSettingsTab.tsx index 2825ac6..67fa385 100644 --- a/src/renderer/src/settings/ThemeSettingsTab.tsx +++ b/src/renderer/src/settings/ThemeSettingsTab.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { Button, ColorPicker, Popconfirm } from 'antd' +import { Button, ColorPicker, Popconfirm, Slider } from 'antd' import { t } from '@shared/i18n' import type { TerminalSettings } from '@shared/settings' import { DEFAULT_SETTINGS } from '@shared/settings' @@ -46,8 +46,45 @@ export function ThemeSettingsTab({ await setCustomThemes(next) } + const pickImage = async (): Promise => { + const files = await window.api.pickFiles() + const image = files.find((f) => /\.(png|jpe?g|bmp|gif|webp|avif)$/i.test(f)) + if (image) await updateTerminal({ backgroundImage: image }) + } + return (
+
+
+
{t('settings.theme.backgroundImage')}
+
{t('settings.theme.backgroundImageDesc')}
+
+
+ + {settings.terminal.backgroundImage !== '' && ( + + )} +
+
+ {settings.terminal.backgroundImage !== '' && ( +
+
+
{t('settings.theme.imageOpacity')}
+
+ void updateTerminal({ backgroundImageOpacity: v })} + /> +
+ )}
{t('settings.theme.accent')}
diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index a95b557..58e7f1f 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -582,3 +582,11 @@ .shortcut-input.is-recording .ant-input { border-color: var(--tab-accent, #3fb950); } + +/* theme editor: duplicate-name notice under the name field */ +.theme-editor-hint { + margin: -4px 0 10px; + padding-left: 2px; + font-size: 12px; + color: #d29922; +} diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index b5a442d..1031c9f 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -67,8 +67,20 @@ const SEARCH_DECORATIONS: NonNullable ['terminal-view', className].filter(Boolean).join(' '), [className]) + const terminalClassName = useMemo( + () => + ['terminal-view', settings.terminal.backgroundImage !== '' && 'has-bg-image', className] + .filter(Boolean) + .join(' '), + [className, settings.terminal.backgroundImage] + ) /** * [M5] Compute and store the floating popup position so it hugs the text @@ -1277,6 +1296,15 @@ export const TerminalView: ForwardRefExoticComponent )}
+ {settings.terminal.backgroundImage !== '' && ( +
+ )} {dead && (
{t('terminal.dead.message', { code: exitCode })}
diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css index 9202cbe..8534875 100644 --- a/src/renderer/src/terminal/terminal.css +++ b/src/renderer/src/terminal/terminal.css @@ -15,11 +15,17 @@ /* xterm.css defaults .xterm-viewport to #000; when the pane height isn't a multiple of the cell height the leftover bottom strip shows that black. - Pin it to the (theme-derived) chrome background so the gap is invisible. */ -.terminal-view .xterm .xterm-viewport { + Pin it to the (theme-derived) chrome background so the gap is invisible. + With a custom background image the viewport must stay transparent — this + opaque layer would cover the .term-bg-image behind the xterm surface. */ +.terminal-view:not(.has-bg-image) .xterm .xterm-viewport { background-color: var(--chrome-bg); } +.terminal-view.has-bg-image .xterm .xterm-viewport { + background-color: transparent; +} + /* Slim slate scrollbar for the xterm viewport — the native Windows one renders light track + arrow buttons that look broken on the dark surface. The thumb sits inset inside a wider grab lane (transparent border + padding-box clip) @@ -353,6 +359,24 @@ padding: 0; } +/* ---- custom background image (设置 → 主题) ---- + Sits behind the xterm screen; while an image is set the theme background is + transparent (allowTransparency), so this layer is what shows through. */ +.term-bg-image { + position: absolute; + inset: 0; + z-index: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + pointer-events: none; +} + +.terminal-view-dock > .xterm { + position: relative; + z-index: 1; +} + /* ---- dead process overlay ---- */ .term-dead-mask { position: absolute; diff --git a/src/renderer/src/theme/editor/ThemeEditor.tsx b/src/renderer/src/theme/editor/ThemeEditor.tsx index 40c43fd..79fde67 100644 --- a/src/renderer/src/theme/editor/ThemeEditor.tsx +++ b/src/renderer/src/theme/editor/ThemeEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { Input, Modal } from 'antd' import { t } from '@shared/i18n' import type { ThemeColors, TerminalTheme } from '@shared/theme' -import { DEFAULT_DARK, getThemeById } from '@shared/theme' +import { BUILTIN_THEMES, DEFAULT_DARK, getThemeById } from '@shared/theme' import { useSettingsStore } from '../../settings/store' export interface ThemeEditorProps { @@ -49,6 +49,17 @@ const ANSI_KEYS: FieldSpec[] = [ { key: 'brightWhite', label: 'Bright White' } ] +/** The native colour input only accepts #rrggbb: clamp every seeded value so a + * hand-edited or migrated theme can't render the picker as solid black. */ +function normalizeThemeColors(colors: ThemeColors, fallback: ThemeColors): ThemeColors { + const out = { ...fallback } + for (const key of Object.keys(fallback) as (keyof ThemeColors)[]) { + const value = colors[key] ?? '' + out[key] = /^#[0-9a-f]{6}$/i.test(value) ? value : (fallback[key] ?? '') + } + return out +} + export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React.JSX.Element { const settings = useSettingsStore((s) => s.settings) const updateTerminal = useSettingsStore((s) => s.updateTerminal) @@ -64,6 +75,11 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React const [name, setName] = useState('') const [colors, setColors] = useState(DEFAULT_DARK.colors) const [saving, setSaving] = useState(false) + /** another theme (builtin or custom) already uses this name (allowed, but the user should know) */ + const duplicateName = + name.trim() !== '' && + (BUILTIN_THEMES.some((th) => th.name === name.trim()) || + settings.customThemes.some((th) => th.id !== themeId && th.name === name.trim())) // (re)initialize the form each time the modal opens useEffect(() => { @@ -75,12 +91,12 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React return } setName(editingTheme.name) - setColors({ ...editingTheme.colors }) + setColors(normalizeThemeColors(editingTheme.colors, DEFAULT_DARK.colors)) } else { /** create mode: seed a fresh copy based on the currently active theme */ const active = getThemeById(settings.terminal.themeId, settings.customThemes) setName(active.name + t('settings.themeEditor.copySuffix')) - setColors({ ...active.colors }) + setColors(normalizeThemeColors(active.colors, DEFAULT_DARK.colors)) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) @@ -141,6 +157,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React maxLength={60} />
+ {duplicateName &&
{t('settings.themeEditor.duplicateName')}
}
{headerKeys().map((spec) => ( diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index d10f80d..c23af9a 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -139,12 +139,18 @@ const settings: Record = { 'settings.highlight.builtin.url': 'Web links', 'settings.theme.accent': 'Tab accent color', 'settings.theme.accentDesc': 'Accent color for the active tab border, the sidebar indicator, and SSH badges', + 'settings.theme.backgroundImage': 'Background image', + 'settings.theme.backgroundImageDesc': 'Show an image behind the terminal; remove it to go back to a solid background', + 'settings.theme.chooseImage': 'Choose image', + 'settings.theme.clearImage': 'Remove', + 'settings.theme.imageOpacity': 'Image opacity', 'settings.theme.customBadge': 'Custom', 'settings.theme.deleteTitle': 'Delete custom theme', 'settings.theme.create': 'New theme', 'settings.theme.editTitle': 'Edit theme', 'settings.themeEditor.name': 'Name', 'settings.themeEditor.namePlaceholder': 'Theme name', + 'settings.themeEditor.duplicateName': 'Same name as another theme (saving is still allowed)', 'settings.themeEditor.copySuffix': ' (copy)', 'settings.about.currentVersion': 'Current version', 'settings.about.statusIdle': 'Click to check for updates', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 879ed64..850958e 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -136,12 +136,18 @@ const settings: Record = { 'settings.highlight.builtin.url': 'ウェブリンク', 'settings.theme.accent': 'タブのアクセントカラー', 'settings.theme.accentDesc': 'アクティブなタブの枠線、サイドバーのインジケーター、SSH バッジのアクセントカラー', + 'settings.theme.backgroundImage': '背景画像', + 'settings.theme.backgroundImageDesc': 'ターミナルの背後に画像を表示します。削除すると無地の背景に戻ります', + 'settings.theme.chooseImage': '画像を選択', + 'settings.theme.clearImage': '削除', + 'settings.theme.imageOpacity': '画像の不透明度', 'settings.theme.customBadge': 'カスタム', 'settings.theme.deleteTitle': 'カスタムテーマを削除', 'settings.theme.create': '新しいテーマ', 'settings.theme.editTitle': 'テーマを編集', 'settings.themeEditor.name': '名前', 'settings.themeEditor.namePlaceholder': 'テーマ名', + 'settings.themeEditor.duplicateName': '他のテーマと同名です(保存は可能)', 'settings.themeEditor.copySuffix': '(コピー)', 'settings.about.currentVersion': '現在のバージョン', 'settings.about.statusIdle': 'クリックして更新を確認', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 69cb63e..40598be 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -133,12 +133,18 @@ const settings: Record = { 'settings.highlight.builtin.url': '网址链接', 'settings.theme.accent': '标签强调色', 'settings.theme.accentDesc': '活动标签边框、侧栏指示条与 SSH 徽章的强调颜色', + 'settings.theme.backgroundImage': '背景图片', + 'settings.theme.backgroundImageDesc': '为终端设置一张背景图片;移除后恢复纯色背景', + 'settings.theme.chooseImage': '选择图片', + 'settings.theme.clearImage': '移除图片', + 'settings.theme.imageOpacity': '图片不透明度', 'settings.theme.customBadge': '自定义', 'settings.theme.deleteTitle': '删除自定义主题', 'settings.theme.create': '新建主题', 'settings.theme.editTitle': '编辑主题', 'settings.themeEditor.name': '名称', 'settings.themeEditor.namePlaceholder': '主题名称', + 'settings.themeEditor.duplicateName': '与其他主题重名,仍可保存', 'settings.themeEditor.copySuffix': '(副本)', 'settings.about.currentVersion': '当前版本', 'settings.about.statusIdle': '点击检查更新', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index b9e33e4..9c6daa9 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -133,12 +133,18 @@ const settings: Record = { 'settings.highlight.builtin.url': '網址連結', 'settings.theme.accent': '標籤強調色', 'settings.theme.accentDesc': '作用中標籤的邊框、側邊欄指示條與 SSH 徽章的強調顏色', + 'settings.theme.backgroundImage': '背景圖片', + 'settings.theme.backgroundImageDesc': '為終端設定一張背景圖片;移除後恢復純色背景', + 'settings.theme.chooseImage': '選擇圖片', + 'settings.theme.clearImage': '移除圖片', + 'settings.theme.imageOpacity': '圖片不透明度', 'settings.theme.customBadge': '自訂', 'settings.theme.deleteTitle': '刪除自訂主題', 'settings.theme.create': '新增主題', 'settings.theme.editTitle': '編輯主題', 'settings.themeEditor.name': '名稱', 'settings.themeEditor.namePlaceholder': '主題名稱', + 'settings.themeEditor.duplicateName': '與其他主題同名,仍可儲存', 'settings.themeEditor.copySuffix': '(副本)', 'settings.about.currentVersion': '目前版本', 'settings.about.statusIdle': '點擊檢查更新', diff --git a/src/shared/settings.ts b/src/shared/settings.ts index e3a7ec1..05410cc 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -49,6 +49,10 @@ export interface TerminalSettings { showOpenCwdButton: boolean /** accent color for the active tab outline, rail indicator and SSH badges */ tabAccentColor: string + /** absolute path to an image rendered behind the terminal; '' = none */ + backgroundImage: string + /** background image layer opacity, 10..100 (%) */ + backgroundImageOpacity: number } export interface SystemSettings { @@ -132,7 +136,9 @@ export const DEFAULT_SETTINGS: AppSettings = { showRecButton: false, showOpenLogsButton: false, showOpenCwdButton: true, - tabAccentColor: '#3fb950' + tabAccentColor: '#3fb950', + backgroundImage: '', + backgroundImageOpacity: 60 }, customThemes: [], highlightRules: DEFAULT_HIGHLIGHT_RULES,