diff --git a/app/containers/aboutPage/index.js b/app/containers/aboutPage/index.js index 2c7c7a04..8845f27f 100644 --- a/app/containers/aboutPage/index.js +++ b/app/containers/aboutPage/index.js @@ -1,6 +1,6 @@ import { bindActionCreators } from 'redux' import { connect } from 'react-redux' -import { Image } from 'react-bootstrap' +import { Button, Image } from 'react-bootstrap' import electronBridge from '../../utilities/electronBridge' import Modal from '../compatModal' import { updateAboutModalStatus } from '../../actions' @@ -11,15 +11,273 @@ import ContributorInfo from '../../../.all-contributorsrc' import logoDarkImage from './logo-dark.webp' import logoLightImage from './logo-light.webp' import React, { Component } from 'react' -import { isDarkTheme } from '../../utilities/themeManager' -import { t } from '../../utilities/i18n' +import ThemeManager, { isDarkTheme } from '../../utilities/themeManager' +import { getSupportedLocales, t } from '../../utilities/i18n' +import { preferenceRequiresRestart } from '../../utilities/config/preferences' import './index.scss' const conf = electronBridge.config const { configFilePath, logFilePath } = electronBridge.globals.getPaths() +const themeManager = new ThemeManager() + +const preferenceGroups = [ + { + title: 'Appearance', + fields: [ + { + key: 'theme', + label: 'Theme', + type: 'select', + options: [ + ['light', 'Light'], ['dark', 'Dark'], ['one-dark', 'One Dark'], ['atom-one-dark', 'Atom One Dark'], + ['github-light', 'GitHub Light'], ['github-dark', 'GitHub Dark'], + ['catppuccin-latte', 'Catppuccin Latte'], ['catppuccin-mocha', 'Catppuccin Mocha'], + ['solarized-light', 'Solarized Light'], ['solarized-dark', 'Solarized Dark'], + ['dracula', 'Dracula'], ['material-theme', 'Material'], ['ayu', 'Ayu'] + ] + }, + { key: 'i18n:locale', label: 'Language', type: 'select', options: getSupportedLocales().map(locale => [locale.code, locale.name]) } + ] + }, + { + title: 'Behavior', + fields: [ + { key: 'autoUpdate', label: 'Automatically check for updates', type: 'checkbox' }, + { key: 'startAtLogin', label: 'Start Lepton at login', type: 'checkbox' }, + { key: 'window:closeToTray', label: 'Close window to tray', type: 'checkbox' }, + { key: 'window:minimizeToTray', label: 'Minimize window to tray', type: 'checkbox' } + ] + }, + { + title: 'Profile', + fields: [ + { key: 'avatar:type', label: 'Avatar', type: 'select', options: [['github', 'GitHub'], ['boring', 'Generated']] }, + { key: 'avatar:boringAvatarVariant', label: 'Generated avatar style', type: 'select', options: [['beam', 'Beam'], ['marble', 'Marble'], ['pixel', 'Pixel'], ['sunset', 'Sunset'], ['ring', 'Ring'], ['bauhaus', 'Bauhaus']] }, + { key: 'userPanel:hideProfilePhoto', label: 'Hide profile photo', type: 'checkbox' } + ] + }, + { + title: 'Snippets and Tags', + fields: [ + { key: 'snippet:sorting', label: 'Sort by', type: 'select', options: [['updated_at', 'Updated'], ['created_at', 'Created'], ['description', 'Description']] }, + { key: 'snippet:sortingReverse', label: 'Reverse sort order', type: 'checkbox' }, + { key: 'snippet:expanded', label: 'Expand snippets by default', type: 'checkbox' }, + { key: 'snippet:newSnippetPrivate', label: 'Create secret snippets by default', type: 'checkbox' }, + { key: 'snippet:downloadAll', label: 'Download all snippets for content search', type: 'checkbox' }, + { key: 'tag:showInSnippetList', label: 'Show tags in the snippet list', type: 'checkbox' }, + { key: 'tag:colored', label: 'Use colored tag badges', type: 'checkbox' } + ] + }, + { + title: 'Editor', + fields: [ + { key: 'editor:tabSize', label: 'Tab size', type: 'select', numeric: true, options: [[2, '2'], [4, '4'], [8, '8']] }, + { key: 'editor:validateFilename', label: 'Validate filenames', type: 'checkbox' } + ] + }, + { + title: 'Notifications', + fields: [ + { key: 'notifications:success', label: 'Show success notifications', type: 'checkbox' }, + { key: 'notifications:failure', label: 'Show failure notifications', type: 'checkbox' } + ] + }, + { + title: 'Network', + fields: [ + { key: 'proxy:enable', label: 'Use proxy', type: 'checkbox' }, + { key: 'proxy:address', label: 'Proxy address', type: 'text', placeholder: 'socks://localhost:1080' } + ] + }, + { + title: 'GitHub Enterprise', + fields: [ + { key: 'enterprise:enable', label: 'Enable GitHub Enterprise', type: 'checkbox' }, + { key: 'enterprise:host', label: 'Host', type: 'text', placeholder: 'github.example.com' }, + { key: 'enterprise:token', label: 'Personal access token', type: 'password' }, + { key: 'enterprise:avatarUrl', label: 'Avatar URL', type: 'text' } + ] + }, + { + title: 'Advanced', + fields: [ + { key: 'zoom:percent', label: 'Default zoom', type: 'number', numeric: true, min: 50, max: 300 }, + { key: 'logger:level', label: 'Log level', type: 'select', options: [['info', 'Info'], ['debug', 'Debug'], ['warn', 'Warning'], ['error', 'Error']] }, + { key: 'security:cachedAccessTokenStorage', label: 'Cached token storage', type: 'select', options: [['auto', 'Automatic'], ['encrypted', 'Encrypted'], ['file', 'File']] } + ] + }, + { + title: 'Keyboard Shortcuts', + fields: [ + { key: 'shortcuts:keyShortcutForSearch', label: 'Search', type: 'text' }, + { key: 'shortcuts:keyNewGist', label: 'New snippet', type: 'text' }, + { key: 'shortcuts:keyEditGist', label: 'Edit snippet', type: 'text' }, + { key: 'shortcuts:keyDeleteGist', label: 'Delete snippet', type: 'text' }, + { key: 'shortcuts:keySubmitGist', label: 'Save snippet', type: 'text' }, + { key: 'shortcuts:keyImmersiveMode', label: 'Immersive mode', type: 'text' }, + { key: 'shortcuts:keyAboutPage', label: 'Settings', type: 'text' }, + { key: 'shortcuts:keyDashboard', label: 'Dashboard', type: 'text' }, + { key: 'shortcuts:keySyncGists', label: 'Sync snippets', type: 'text' } + ] + } +] class AboutPage extends Component { + constructor (props) { + super(props) + const values = this.loadPreferences() + this.state = { + activeTab: 'preferences', + error: '', + initialValues: values, + restartNotice: '', + saveNotice: '', + saving: false, + values + } + } + + componentDidUpdate (previousProps) { + if (previousProps.aboutModalStatus !== 'ON' && this.props.aboutModalStatus === 'ON') { + const values = this.loadPreferences() + this.setState({ + activeTab: 'preferences', + error: '', + initialValues: values, + restartNotice: '', + saveNotice: '', + saving: false, + values + }) + } + } + + loadPreferences () { + return preferenceGroups.reduce((values, group) => { + group.fields.forEach(field => { values[field.key] = conf.get(field.key) }) + return values + }, {}) + } + + updatePreference (field, event) { + const value = field.type === 'checkbox' + ? event.target.checked + : field.numeric ? Number(event.target.value) : event.target.value + + this.setState(state => ({ + error: '', + restartNotice: '', + saveNotice: '', + values: Object.assign({}, state.values, { [field.key]: value }) + })) + } + + getChangedPreferenceKeys () { + return Object.keys(this.state.values).filter(key => this.state.values[key] !== this.state.initialValues[key]) + } + + applyPreferences (closeAfterSave) { + const changedKeys = this.getChangedPreferenceKeys() + if (changedKeys.length === 0) { + if (closeAfterSave) this.handleCloseButtonClicked() + return + } + + this.setState({ error: '', restartNotice: '', saveNotice: '', saving: true }) + Promise.all(changedKeys.map(key => electronBridge.config.set(key, this.state.values[key]))) + .then(persistedValues => { + if (persistedValues.some(value => value === undefined)) throw new Error('Preference was rejected') + + if (changedKeys.includes('theme')) themeManager.setTheme(this.state.values.theme) + const restartFields = preferenceGroups + .flatMap(group => group.fields) + .filter(field => changedKeys.includes(field.key) && preferenceRequiresRestart(field.key)) + const restartRequired = restartFields.length > 0 + const restartNotice = restartRequired + ? `Saved. Restart Lepton to apply: ${restartFields.map(field => field.label).join(', ')}.` + : '' + const saveNotice = restartRequired ? '' : 'Changes applied.' + const values = Object.assign({}, this.state.values) + this.setState({ initialValues: values, restartNotice, saveNotice, saving: false }) + + if (closeAfterSave && restartRequired) { + electronBridge.dialog.showMessage({ + title: 'Restart required', + message: restartNotice + }).catch(() => {}) + } + if (closeAfterSave) this.handleCloseButtonClicked() + }) + .catch(() => { + this.setState({ + error: 'Could not save preferences to .leptonrc.', + saveNotice: '', + saving: false + }) + }) + } + + cancelPreferences () { + this.setState(state => ({ + error: '', + restartNotice: '', + saveNotice: '', + values: Object.assign({}, state.initialValues) + })) + this.handleCloseButtonClicked() + } + + renderPreferenceField (field) { + const value = this.state.values[field.key] + if (field.type === 'checkbox') { + return ( + + ) + } + + if (field.type === 'select') { + return ( + + ) + } + + return ( + + ) + } + + renderPreferencesSection () { + return ( +
+ { preferenceGroups.map(group => ( +
+

{ group.title }

+ { group.fields.map(this.renderPreferenceField.bind(this)) } +
+ )) } +
Save or Apply writes these changes to .leptonrc.
+
+ ) + } + openFileInEditor (filePath) { if (filePath === configFilePath) { electronBridge.files.ensureConfigFile(defaultConfig) @@ -100,8 +358,14 @@ class AboutPage extends Component { renderSettingModalBody () { return ( -
- { this.renderAboutSection() } +
+ +
+ { this.state.activeTab === 'preferences' ? this.renderPreferencesSection() : this.renderAboutSection() } +
) } @@ -111,19 +375,50 @@ class AboutPage extends Component { updateAboutModalStatus('OFF') } + renderFooter () { + if (this.state.activeTab === 'about') { + return + } + + const disabled = this.state.saving + const hasChanges = this.getChangedPreferenceKeys().length > 0 + const applied = !hasChanges && !disabled && Boolean(this.state.saveNotice || this.state.restartNotice) + return ( +
+
+ { this.state.saving ? 'Applying changes…' : null } + { !this.state.saving && this.state.error ? { this.state.error } : null } + { !this.state.saving && this.state.restartNotice ? { this.state.restartNotice } : null } + { !this.state.saving && this.state.saveNotice ? this.state.saveNotice : null } +
+
+ { applied + ? + :
+ + + +
} +
+
+ ) + } + render () { return ( - { t('about.title') } + Settings { this.renderSettingModalBody() } + + { this.renderFooter() } + ) } diff --git a/app/containers/aboutPage/index.scss b/app/containers/aboutPage/index.scss index fdb8fbbd..8c43e008 100644 --- a/app/containers/aboutPage/index.scss +++ b/app/containers/aboutPage/index.scss @@ -6,19 +6,181 @@ color: var(--text-secondary); } - .section-base { - height: 80vh; + .about-section { + display: flex; + flex-direction: column; + min-height: 100%; + color: var(--text-secondary); } - .general-section { - @extend .section-base; + .modal-dialog { + width: min(780px, calc(100vw - 32px)); } - .about-section { - @extend .section-base; + .modal-content { display: flex; + max-height: calc(100vh - 40px); flex-direction: column; + } + + .modal-body { + min-height: 0; + padding: 0; + overflow: hidden; + } + + .settings-layout { + display: flex; + height: min(68vh, 540px); + min-height: 0; + } + + .settings-tabs { + width: 145px; + flex: 0 0 145px; + padding: 16px 9px; + border-right: 1px solid var(--border-color); + background: var(--bg-secondary); + } + + .settings-tabs button { + width: 100%; + padding: 7px 9px; + border: 0; + background: transparent; + color: var(--text-secondary); + text-align: left; + } + + .settings-tabs button:hover, + .settings-tabs button.active { + color: var(--text-primary); + } + + .settings-tabs button.active { + font-weight: bold; + } + + .settings-content { + min-width: 0; + min-height: 0; + flex: 1; + padding: 16px 20px; + overflow-x: hidden; + overflow-y: scroll; + scrollbar-color: color-mix(in srgb, var(--text-secondary) 48%, transparent) var(--bg-secondary); + scrollbar-gutter: stable; + scrollbar-width: thin; + } + + .settings-content::-webkit-scrollbar { + width: 10px; + } + + .settings-content::-webkit-scrollbar-track { + background: var(--bg-secondary); + } + + .settings-content::-webkit-scrollbar-thumb { + border: 2px solid var(--bg-secondary); + border-radius: 999px; + background: color-mix(in srgb, var(--text-secondary) 48%, transparent); + } + + .preferences-section { + color: var(--text-secondary); + } + + .preference-group { + margin-bottom: 12px; + } + + .preference-group h4 { + margin: 0 0 4px; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); color: var(--text-secondary); + font-size: 12px; + font-weight: bold; + letter-spacing: .08em; + text-transform: uppercase; + } + + .preference-row, + .preference-checkbox { + display: flex; + min-height: 29px; + margin: 0; + align-items: center; + font-weight: normal; + } + + .preference-row { + justify-content: space-between; + } + + .preference-row select, + .preference-row > input { + width: 280px; + height: 26px; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + } + + .preference-row select { + padding-right: 34px; + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), + linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); + background-position: + calc(100% - 17px) 50%, + calc(100% - 12px) 50%; + background-repeat: no-repeat; + background-size: 5px 5px, 5px 5px; + } + + .preference-checkbox input { + margin: 0 9px 0 0; + } + + .preference-save-note { + padding-top: 10px; + border-top: 1px solid var(--border-color); + font-size: 12px; + } + + .preference-error { + color: var(--accent-warning); + } + + .preference-restart-notice { + color: var(--accent-warning); + } + + .preferences-footer { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + } + + .preference-status { + padding-right: 14px; + color: var(--text-secondary); + font-size: 12px; + text-align: left; + } + + .preference-actions { + flex: none; + } + + .modal-footer .btn + .btn { + margin-left: 7px; } .logo-section { @@ -34,7 +196,7 @@ } .contributor-section { - overflow: auto; + overflow: visible; min-height: 150px; border-radius: 5px; padding: 0 20px; @@ -43,8 +205,8 @@ } .license-section { - overflow: auto; - flex: auto; + overflow: visible; + flex: none; margin-bottom: 20px; border-radius: 5px; padding: 0 20px; @@ -91,10 +253,6 @@ cursor: pointer; } - .modal-sm { - width: 375px; - } - .logo-sub { margin: 0 5px; } diff --git a/app/utilities/config/preferences.js b/app/utilities/config/preferences.js new file mode 100644 index 00000000..c9926e16 --- /dev/null +++ b/app/utilities/config/preferences.js @@ -0,0 +1,118 @@ +const { getSupportedLocales } = require('../i18n') + +const THEMES = [ + 'light', + 'dark', + 'one-dark', + 'atom-one-dark', + 'github-light', + 'github-dark', + 'catppuccin-latte', + 'catppuccin-mocha', + 'solarized-light', + 'solarized-dark', + 'dracula', + 'material-theme', + 'ayu' +] + +const booleanKeys = new Set([ + 'autoUpdate', + 'startAtLogin', + 'window:closeToTray', + 'window:minimizeToTray', + 'userPanel:hideProfilePhoto', + 'proxy:enable', + 'snippet:sortingReverse', + 'snippet:expanded', + 'snippet:newSnippetPrivate', + 'snippet:downloadAll', + 'tag:showInSnippetList', + 'tag:colored', + 'editor:validateFilename', + 'enterprise:enable', + 'notifications:success', + 'notifications:failure' +]) + +const shortcutKeys = [ + 'keyShortcutForSearch', 'keyNewGist', 'keyEditGist', 'keyDeleteGist', + 'keySubmitGist', 'keyImmersiveMode', 'keyAboutPage', 'keyDashboard', 'keySyncGists' +].map(key => `shortcuts:${key}`) + +const stringKeys = new Set([ + 'proxy:address', + 'enterprise:host', + 'enterprise:token', + 'enterprise:avatarUrl', + ...shortcutKeys +]) + +const APPLY_MODES = { + IMMEDIATE: 'immediate', + NEXT_USE: 'next-use', + RESTART: 'restart' +} + +function metadataForKeys (keys, applyMode) { + return keys.reduce((metadata, key) => { + metadata[key] = { applyMode } + return metadata + }, {}) +} + +const preferenceMetadata = { + ...metadataForKeys([ + 'theme', 'autoUpdate', 'startAtLogin', 'zoom:percent', 'logger:level', ...shortcutKeys + ], APPLY_MODES.IMMEDIATE), + ...metadataForKeys([ + 'window:closeToTray', 'window:minimizeToTray', 'security:cachedAccessTokenStorage', + 'snippet:sorting', 'snippet:sortingReverse', 'snippet:downloadAll', + 'tag:showInSnippetList', 'tag:colored', 'editor:validateFilename', + 'notifications:success', 'notifications:failure' + ], APPLY_MODES.NEXT_USE), + ...metadataForKeys([ + 'i18n:locale', 'avatar:type', 'avatar:boringAvatarVariant', 'userPanel:hideProfilePhoto', + 'proxy:enable', 'proxy:address', 'snippet:expanded', 'snippet:newSnippetPrivate', + 'editor:tabSize', 'enterprise:enable', 'enterprise:host', 'enterprise:token', + 'enterprise:avatarUrl' + ], APPLY_MODES.RESTART) +} + +const writableConfigKeys = new Set(Object.keys(preferenceMetadata)) + +function preferenceRequiresRestart (key) { + return Boolean(preferenceMetadata[key] && preferenceMetadata[key].applyMode === APPLY_MODES.RESTART) +} + +function isValidPreferenceValue (key, value) { + if (!writableConfigKeys.has(key)) return false + if (booleanKeys.has(key)) return typeof value === 'boolean' + if (stringKeys.has(key)) { + if (typeof value !== 'string' || value.length > 500) return false + return !key.startsWith('shortcuts:') || value.trim().length > 0 + } + if (key === 'theme') return THEMES.includes(value) + if (key === 'i18n:locale') { + return getSupportedLocales().some(locale => locale.code === value) + } + if (key === 'editor:tabSize') { + return Number.isInteger(value) && [2, 4, 8].includes(value) + } + if (key === 'zoom:percent') return Number.isInteger(value) && value >= 50 && value <= 300 + if (key === 'avatar:type') return ['github', 'boring'].includes(value) + if (key === 'avatar:boringAvatarVariant') return ['beam', 'marble', 'pixel', 'sunset', 'ring', 'bauhaus'].includes(value) + if (key === 'logger:level') return ['debug', 'info', 'warn', 'error'].includes(value) + if (key === 'security:cachedAccessTokenStorage') return ['auto', 'encrypted', 'file'].includes(value) + if (key === 'snippet:sorting') return ['updated_at', 'created_at', 'description'].includes(value) + return false +} + +module.exports = { + APPLY_MODES, + isValidPreferenceValue, + preferenceMetadata, + preferenceRequiresRestart, + THEMES, + writableConfigKeys +} diff --git a/app/utilities/electronBridge/index.js b/app/utilities/electronBridge/index.js index 5fda629d..9a49b654 100644 --- a/app/utilities/electronBridge/index.js +++ b/app/utilities/electronBridge/index.js @@ -34,6 +34,9 @@ function createUnavailableBridge () { getAccessToken: unavailableBridgeMethod, setAccessToken: unavailableBridgeMethod }, + dialog: { + showMessage: unavailableBridgeMethod + }, files: { ensureConfigFile: unavailableBridgeMethod }, diff --git a/build/icon/icon.png b/build/icon/icon.png index 11378583..04bfcc6f 100644 Binary files a/build/icon/icon.png and b/build/icon/icon.png differ diff --git a/docs/img/app-icon.png b/docs/img/app-icon.png index 11378583..04bfcc6f 100644 Binary files a/docs/img/app-icon.png and b/docs/img/app-icon.png differ diff --git a/docs/img/portfolio/find-in-page.png b/docs/img/portfolio/find-in-page.png index 418d3cdf..afc0e5df 100644 Binary files a/docs/img/portfolio/find-in-page.png and b/docs/img/portfolio/find-in-page.png differ diff --git a/docs/img/portfolio/preferences.png b/docs/img/portfolio/preferences.png new file mode 100644 index 00000000..65000c95 Binary files /dev/null and b/docs/img/portfolio/preferences.png differ diff --git a/docs/img/pr-669/draft-available.png b/docs/img/pr-669/draft-available.png index dbb2766f..cc4e63a7 100644 Binary files a/docs/img/pr-669/draft-available.png and b/docs/img/pr-669/draft-available.png differ diff --git a/docs/img/pr-669/draft-loaded.png b/docs/img/pr-669/draft-loaded.png index fc348ec2..33c86039 100644 Binary files a/docs/img/pr-669/draft-loaded.png and b/docs/img/pr-669/draft-loaded.png differ diff --git a/main.js b/main.js index e90b0b77..c3868246 100755 --- a/main.js +++ b/main.js @@ -40,6 +40,9 @@ const { clearGitHubAuthWindowStorageAndDestroy } = require('./app/utilities/auth/githubAuthWindow') const { applyDefaultZoomPercent } = require('./app/utilities/zoom') +const { + isValidPreferenceValue +} = require('./app/utilities/config/preferences') const logger = createMainLogger() const electronLocalStorage = createElectronLocalStorage({ @@ -80,8 +83,6 @@ let operationType = 0 const MACOS_TRAY_ICON_SIZE = 18 -const shortcuts = nconf.get('shortcuts') - function getConfigPath() { if (process && process.env && process.env.XDG_CONFIG_HOME) { return path.join(process.env.XDG_CONFIG_HOME, '.leptonrc') @@ -345,6 +346,7 @@ function handleNonMacWindowClose (e, win) { } function setUpApplicationMenu () { + const shortcuts = nconf.get('shortcuts') // Create the Application's main menu let { buildMainMenuTemplate } = require('./app/utilities/menu/mainMenu') let gistMenu = { @@ -414,8 +416,6 @@ function setUpApplicationMenu () { function setUpBridgeIpcHandlers () { const loggerMethods = new Set(['debug', 'error', 'info', 'warn']) const appPathNames = new Set(['appData', 'home', 'temp', 'userData']) - const writableConfigKeys = new Set(['i18n:locale']) - function isAllowedConfigKey (key) { if (typeof key !== 'string' || key.length === 0) return false const rootKey = key.split(':')[0] @@ -505,7 +505,7 @@ function setUpBridgeIpcHandlers () { }) ipcMain.handle('lepton:config:set', (event, key, value) => { - if (!isMainWindowSender(event) || !writableConfigKeys.has(key)) { + if (!isMainWindowSender(event) || !isValidPreferenceValue(key, value)) { logger.warn(`[bridge] Rejected config write for "${key}"`) return undefined } @@ -513,13 +513,31 @@ function setUpBridgeIpcHandlers () { const persistedValue = key === 'i18n:locale' ? configureI18n(value) : value nconf.set(key, persistedValue) writeConfigValue(key, persistedValue) - setUpApplicationMenu() - if (key !== 'i18n:locale' && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.reload() + if (key === 'autoUpdate') autoUpdater.autoDownload = persistedValue + if (key === 'startAtLogin') { + applyStartAtLoginSetting({ app, enabled: persistedValue, logger }) + } + if (key === 'logger:level') logger.level = persistedValue + if (key === 'zoom:percent' && mainWindow && !mainWindow.isDestroyed()) { + applyDefaultZoomPercent({ webContents: mainWindow.webContents, percent: persistedValue, logger }) } + setUpApplicationMenu() return persistedValue }) + ipcMain.handle('lepton:dialog:show-message', (event, options = {}) => { + if (!isMainWindowSender(event)) return undefined + const title = typeof options.title === 'string' ? options.title.slice(0, 100) : appInfo.name + const message = typeof options.message === 'string' ? options.message.slice(0, 500) : '' + if (!message) return undefined + return dialog.showMessageBox(mainWindow, { + type: 'info', + title, + message, + buttons: ['OK'] + }) + }) + ipcMain.on('lepton:account:get', (event) => { if (!isMainWindowSender(event)) { event.returnValue = {} diff --git a/preload.js b/preload.js index f1f6f0ad..95f9f03b 100644 --- a/preload.js +++ b/preload.js @@ -43,6 +43,9 @@ const leptonApi = { getAccessToken: () => ipcRenderer.sendSync('lepton:credentials:get-access-token'), setAccessToken: (token) => ipcRenderer.sendSync('lepton:credentials:set-access-token', token) }, + dialog: { + showMessage: (options) => ipcRenderer.invoke('lepton:dialog:show-message', options) + }, files: { ensureConfigFile: (defaults) => ipcRenderer.invoke('lepton:files:ensure-config', defaults) }, diff --git a/tests/smoke/electron-render-smoke.js b/tests/smoke/electron-render-smoke.js index 00919c6d..427c1b23 100644 --- a/tests/smoke/electron-render-smoke.js +++ b/tests/smoke/electron-render-smoke.js @@ -56,7 +56,8 @@ const RENDER_FIXTURES = [ { name: 'about', selector: '.about-modal .modal-title', - text: 'About' + text: 'Settings|Preferences|Cancel|Apply|Save', + forbiddenSelector: '.preferences-section h3' }, { name: 'dashboard', diff --git a/tests/utilities/preferences.test.js b/tests/utilities/preferences.test.js new file mode 100644 index 00000000..fca5726e --- /dev/null +++ b/tests/utilities/preferences.test.js @@ -0,0 +1,50 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + APPLY_MODES, + isValidPreferenceValue, + preferenceMetadata, + preferenceRequiresRestart, + writableConfigKeys +} = require('../../app/utilities/config/preferences') + +describe('preferences config boundary', () => { + it('allowlists the preference page settings', () => { + expect([...writableConfigKeys]).toEqual(expect.arrayContaining([ + 'theme', + 'i18n:locale', + 'snippet:downloadAll', + 'editor:tabSize', + 'proxy:address', + 'enterprise:token', + 'shortcuts:keyAboutPage' + ])) + }) + + it('defines application behavior for every writable preference', () => { + expect(Object.keys(preferenceMetadata).sort()).toEqual([...writableConfigKeys].sort()) + expect(preferenceMetadata.theme.applyMode).toBe(APPLY_MODES.IMMEDIATE) + expect(preferenceMetadata['snippet:downloadAll'].applyMode).toBe(APPLY_MODES.NEXT_USE) + expect(preferenceRequiresRestart('i18n:locale')).toBe(true) + expect(preferenceRequiresRestart('proxy:address')).toBe(true) + expect(preferenceRequiresRestart('notifications:success')).toBe(false) + expect(preferenceRequiresRestart('unknown:key')).toBe(false) + }) + + it('validates values before writing .leptonrc', () => { + expect(isValidPreferenceValue('theme', 'github-dark')).toBe(true) + expect(isValidPreferenceValue('i18n:locale', 'fr')).toBe(true) + expect(isValidPreferenceValue('editor:tabSize', 4)).toBe(true) + expect(isValidPreferenceValue('notifications:success', false)).toBe(true) + expect(isValidPreferenceValue('zoom:percent', 120)).toBe(true) + expect(isValidPreferenceValue('proxy:address', 'pac+https://example.com/proxy.pac')).toBe(true) + expect(isValidPreferenceValue('shortcuts:keyAboutPage', 'CommandOrControl+,')).toBe(true) + expect(isValidPreferenceValue('theme', 'unknown')).toBe(false) + expect(isValidPreferenceValue('editor:tabSize', 3)).toBe(false) + expect(isValidPreferenceValue('zoom:percent', 400)).toBe(false) + expect(isValidPreferenceValue('shortcuts:keyAboutPage', '')).toBe(false) + expect(isValidPreferenceValue('unknown:key', 'secret')).toBe(false) + }) +})