Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ if (!fs.existsSync(COMMANDS_DIR)) {
fs.mkdirSync(COMMANDS_DIR)
}

function getSafeNotePath(id: string): string {
const fullPath = path.resolve(NOTES_DIR, id)
const relative = path.relative(NOTES_DIR, fullPath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Access denied: Invalid path')
}
return fullPath
}

function writeCommandFile(name: string, content: string) {
const filePath = path.join(COMMANDS_DIR, name)
if (!fs.existsSync(filePath)) {
Expand Down Expand Up @@ -305,9 +314,24 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
},
})

// Prevent new window creation
win.webContents.setWindowOpenHandler(() => {
return { action: 'deny' }
})

// Prevent navigation to external sites inside the app
win.webContents.on('will-navigate', (event, url) => {
const isLocalhost = url.startsWith('http://localhost:') || url.startsWith('http://127.0.0.1:')
const isFile = url.startsWith('file://')
if (!isLocalhost && !isFile) {
event.preventDefault()
}
})

win.on('close', saveWindowState)

win.on('ready-to-show', () => {
Expand Down Expand Up @@ -439,26 +463,36 @@ app.whenReady().then(() => {
// Wait for settings to load before registering new note shortcut to get custom one
// Registration is handled via IPC from the renderer on startup

let currentNewNoteShortcut = ''
let currentToggleShortcut = ''
let isShortcutsPaused = false

const registerNewNoteShortcut = (combo: string) => {
currentNewNoteShortcut = combo
try {
if (globalShortcut.isRegistered(combo)) {
globalShortcut.unregister(combo)
}
globalShortcut.register(combo, () => {
if (win) {
bringToActiveSpace(win)
win.webContents.send('trigger-new-note')
}
})
if (!isShortcutsPaused && combo) {
globalShortcut.register(combo, () => {
if (win) {
bringToActiveSpace(win)
win.webContents.send('trigger-new-note')
}
})
}
} catch (e) {}
}

const registerToggleShortcut = (combo: string) => {
currentToggleShortcut = combo
try {
if (globalShortcut.isRegistered(combo)) {
globalShortcut.unregister(combo)
}
globalShortcut.register(combo, toggleWindow)
if (!isShortcutsPaused && combo) {
globalShortcut.register(combo, toggleWindow)
}
} catch (e) {}
}

Expand All @@ -475,6 +509,17 @@ app.whenReady().then(() => {
} catch (e) {}
})

ipcMain.on('pause-shortcuts', () => {
isShortcutsPaused = true
globalShortcut.unregisterAll()
})

ipcMain.on('resume-shortcuts', () => {
isShortcutsPaused = false
if (currentNewNoteShortcut) registerNewNoteShortcut(currentNewNoteShortcut)
if (currentToggleShortcut) registerToggleShortcut(currentToggleShortcut)
})

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
Expand Down Expand Up @@ -586,7 +631,7 @@ ipcMain.handle('get-notes', () => {
})

ipcMain.handle('save-note', (event, { id, content }) => {
const filePath = path.join(NOTES_DIR, id)
const filePath = getSafeNotePath(id)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
return true
Expand All @@ -596,7 +641,7 @@ ipcMain.handle('delete-note', (event, id) => {
if (id.startsWith('commands/')) {
return false
}
const filePath = path.join(NOTES_DIR, id)
const filePath = getSafeNotePath(id)
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
cleanEmptyFoldersRecursively(path.dirname(filePath))
Expand All @@ -605,8 +650,8 @@ ipcMain.handle('delete-note', (event, id) => {
})

ipcMain.handle('rename-note', (event, { oldId, newId }) => {
const oldPath = path.join(NOTES_DIR, oldId)
const newPath = path.join(NOTES_DIR, newId)
const oldPath = getSafeNotePath(oldId)
const newPath = getSafeNotePath(newId)
if (fs.existsSync(oldPath)) {
fs.mkdirSync(path.dirname(newPath), { recursive: true })
fs.renameSync(oldPath, newPath)
Expand Down Expand Up @@ -644,6 +689,18 @@ ipcMain.on('open-settings', () => {
},
})

settingsWin.webContents.setWindowOpenHandler(() => {
return { action: 'deny' }
})

settingsWin.webContents.on('will-navigate', (event, url) => {
const isLocalhost = url.startsWith('http://localhost:') || url.startsWith('http://127.0.0.1:')
const isFile = url.startsWith('file://')
if (!isLocalhost && !isFile) {
event.preventDefault()
}
})

if (process.env.VITE_DEV_SERVER_URL) {
settingsWin.loadURL(process.env.VITE_DEV_SERVER_URL + '#/settings')
} else {
Expand Down Expand Up @@ -733,7 +790,7 @@ ipcMain.on('set-launch-startup', (_, value: boolean) => {
})

ipcMain.handle('read-note', async (_, id) => {
return fs.readFileSync(path.join(NOTES_DIR, id), 'utf-8')
return fs.readFileSync(getSafeNotePath(id), 'utf-8')
})

ipcMain.handle('export-note', async (_, filename: string, content: string) => {
Expand All @@ -757,7 +814,9 @@ ipcMain.handle('export-note', async (_, filename: string, content: string) => {
})

ipcMain.on('open-external', (_, url) => {
shell.openExternal(url)
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
shell.openExternal(url)
}
})

ipcMain.on('open-file', (_, filePath) => {
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('power:resume', handler)
return () => ipcRenderer.removeListener('power:resume', handler)
},
pauseShortcuts: () => ipcRenderer.send('pause-shortcuts'),
resumeShortcuts: () => ipcRenderer.send('resume-shortcuts'),
})
157 changes: 144 additions & 13 deletions src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function Settings() {
})

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && !e.defaultPrevented) {
window.electronAPI.closeWindow()
}
}
Expand Down Expand Up @@ -198,21 +198,11 @@ export default function Settings() {
<h3>Global Shortcuts</h3>
<div className="setting-group">
<label>Toggle App Visibility</label>
<input
type="text"
value={globalShortcutToggle}
onChange={(e) => setGlobalShortcutToggle(e.target.value)}
placeholder="e.g. CommandOrControl+Shift+C"
/>
<ShortcutInput value={globalShortcutToggle} onChange={setGlobalShortcutToggle} />
</div>
<div className="setting-group">
<label>New Note (Global)</label>
<input
type="text"
value={globalShortcutNewNote}
onChange={(e) => setGlobalShortcutNewNote(e.target.value)}
placeholder="e.g. CommandOrControl+Shift+N"
/>
<ShortcutInput value={globalShortcutNewNote} onChange={setGlobalShortcutNewNote} />
</div>
</section>

Expand Down Expand Up @@ -338,3 +328,144 @@ export default function Settings() {
</div>
)
}

function ShortcutInput({ value, onChange }: { value: string; onChange: (val: string) => void }) {
const [recording, setRecording] = useState(false)

useEffect(() => {
if (recording) {
if (window.electronAPI.pauseShortcuts) window.electronAPI.pauseShortcuts()
} else {
if (window.electronAPI.resumeShortcuts) window.electronAPI.resumeShortcuts()
}
}, [recording])

const renderShortcutDisplay = (shortcut: string) => {
if (!shortcut) return <span>Click to record</span>
const parts = shortcut.split('+')
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{parts.map((part, index) => {
let display = part
switch (part) {
case 'CommandOrControl':
case 'Command':
display = '⌘'
break
case 'Control':
display = '⌃'
break
case 'Shift':
display = '⇧'
break
case 'Alt':
case 'Option':
display = '⌥'
break
case 'Up':
display = '↑'
break
case 'Down':
display = '↓'
break
case 'Left':
display = '←'
break
case 'Right':
display = '→'
break
case 'Space':
display = '␣'
break
}
return (
<span key={index} style={{ display: 'flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '24px',
height: '24px',
padding: '0 6px',
background: 'rgba(128,128,128,0.2)',
borderRadius: '6px',
boxShadow: '0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1)',
fontSize: '13px',
fontWeight: 500,
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
{display}
</span>
{index < parts.length - 1 && (
<span style={{ margin: '0 4px', opacity: 0.5, fontSize: '14px' }}>+</span>
)}
</span>
)
})}
</div>
)
}

const handleKeyDown = (e: React.KeyboardEvent) => {
if (!recording) return
e.preventDefault()

if (e.key === 'Escape') {
setRecording(false)
return
}

if (e.key === 'Backspace' || e.key === 'Delete') {
onChange('')
setRecording(false)
return
}

const modifiers = []
if (e.metaKey || e.ctrlKey) modifiers.push('CommandOrControl')
if (e.altKey) modifiers.push('Alt')
if (e.shiftKey) modifiers.push('Shift')

// Don't record if only a modifier is pressed
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
return
}

let key = e.key.toUpperCase()
if (key === ' ') key = 'Space'
// Map arrows and other special keys
if (key === 'ARROWUP') key = 'Up'
if (key === 'ARROWDOWN') key = 'Down'
if (key === 'ARROWLEFT') key = 'Left'
if (key === 'ARROWRIGHT') key = 'Right'

const shortcut = [...modifiers, key].join('+')
onChange(shortcut)
setRecording(false)
}

return (
<button
className="shortcut-input-btn"
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
style={{
padding: '8px 12px',
background: recording ? 'rgba(138, 180, 248, 0.2)' : 'rgba(128,128,128,0.1)',
border: recording ? '1px solid #8ab4f8' : '1px solid rgba(128,128,128,0.2)',
borderRadius: '6px',
cursor: 'pointer',
minWidth: '220px',
textAlign: 'left',
color: 'inherit',
fontFamily: 'inherit',
fontSize: '13px',
}}
>
{recording ? 'Recording... (Press Esc to cancel)' : renderShortcutDisplay(value)}
</button>
)
}
2 changes: 2 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface ElectronAPI {
safeStorageDecrypt: (val: string) => Promise<string>
onPowerSuspend: (callback: () => void) => () => void
onPowerResume: (callback: () => void) => () => void
pauseShortcuts: () => void
resumeShortcuts: () => void
}

declare global {
Expand Down
Loading