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)
+ })
+})