chore: uptick
This commit is contained in:
@@ -70,6 +70,30 @@ const APP_ICON_PATHS = [
|
||||
path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png')
|
||||
]
|
||||
|
||||
const MEDIA_MIME_TYPES = {
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.bmp': 'image/bmp',
|
||||
'.flac': 'audio/flac',
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.opus': 'audio/ogg; codecs=opus',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
|
||||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
@@ -80,6 +104,7 @@ let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
@@ -462,13 +487,8 @@ function fetchJson(url, token, options = {}) {
|
||||
|
||||
function mimeTypeForPath(filePath) {
|
||||
const ext = path.extname(filePath || '').toLowerCase()
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.bmp') return 'image/bmp'
|
||||
return 'application/octet-stream'
|
||||
|
||||
return MEDIA_MIME_TYPES[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function extensionForMimeType(mimeType) {
|
||||
@@ -552,6 +572,162 @@ async function saveImageFromUrl(rawUrl) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function writeComposerImage(buffer, ext = '.png') {
|
||||
const rawExt = String(ext || '.png')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}`
|
||||
const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png'
|
||||
const dir = path.join(app.getPath('userData'), 'composer-images')
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '')
|
||||
const random = crypto.randomBytes(3).toString('hex')
|
||||
const filePath = path.join(dir, `composer_${stamp}_${random}${safeExt}`)
|
||||
await fs.promises.writeFile(filePath, buffer)
|
||||
return filePath
|
||||
}
|
||||
|
||||
function previewLabelForUrl(url) {
|
||||
return `${url.host}${url.pathname === '/' ? '' : url.pathname}`
|
||||
}
|
||||
|
||||
function expandUserPath(filePath) {
|
||||
const value = String(filePath || '').trim()
|
||||
|
||||
if (value === '~') {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) {
|
||||
return path.join(app.getPath('home'), value.slice(2))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function previewFileTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||||
let resolved = filePath
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
resolved = path.join(resolved, 'index.html')
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
label: path.basename(resolved),
|
||||
source: raw,
|
||||
url: pathToFileURL(resolved).toString()
|
||||
}
|
||||
}
|
||||
|
||||
function previewUrlTarget(rawTarget) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const url = new URL(raw)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.hostname === '0.0.0.0') {
|
||||
url.hostname = '127.0.0.1'
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'url',
|
||||
label: previewLabelForUrl(url),
|
||||
source: raw,
|
||||
url: url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return previewUrlTarget(raw)
|
||||
}
|
||||
|
||||
return previewFileTarget(raw, baseDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function previewFilePathFromUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) {
|
||||
throw new Error('Preview file is not a readable HTML file')
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
function sendPreviewFileChanged(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const { webContents } = mainWindow
|
||||
if (!webContents || webContents.isDestroyed()) return
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = previewFilePathFromUrl(rawUrl)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
let timer = null
|
||||
const watcher = fs.watch(filePath, () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||||
}, 120)
|
||||
})
|
||||
|
||||
previewWatchers.set(id, {
|
||||
close: () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
|
||||
return { id, path: filePath }
|
||||
}
|
||||
|
||||
function stopPreviewFileWatch(id) {
|
||||
const watcher = previewWatchers.get(id)
|
||||
|
||||
if (!watcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
previewWatchers.delete(id)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function closePreviewWatchers() {
|
||||
for (const id of previewWatchers.keys()) {
|
||||
stopPreviewFileWatch(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
@@ -843,6 +1019,7 @@ function createWindow() {
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
nodeIntegration: false,
|
||||
devTools: Boolean(DEV_SERVER)
|
||||
}
|
||||
@@ -922,6 +1099,31 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
const data = payload?.data
|
||||
if (!data) throw new Error('saveImageBuffer: missing data')
|
||||
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
return writeComposerImage(buffer, payload?.ext || '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (!image || image.isEmpty()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||
normalizePreviewTarget(String(target || ''), baseDir ? String(baseDir) : '')
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -936,6 +1138,8 @@ app.whenReady().then(() => {
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
closePreviewWatchers()
|
||||
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
@@ -9,7 +9,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
|
||||
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
|
||||
getPathForFile: file => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
||||
},
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
|
||||
Reference in New Issue
Block a user