diff --git a/cinny b/cinny index 898d217..e65a516 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 898d2174519222f7f009b01734d32f2239bca004 +Subproject commit e65a516350c37299f31fc3270ed8f9e4c8ff99f4 diff --git a/electron/main.js b/electron/main.js index bfaa374..2701fc3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); const { exec, execFile, execFileSync } = require('child_process'); @@ -1275,6 +1275,83 @@ ipcMain.handle('open-external-url', async (event, url) => { } }); +/** + * Save a media blob from the renderer via a native Save dialog. + * Payload: { filename, mimeType?, data: Uint8Array | ArrayBuffer | number[] } + */ +ipcMain.handle('media:save-file', async (event, payload = {}) => { + try { + const filename = typeof payload.filename === 'string' && payload.filename.trim() + ? path.basename(payload.filename.trim()) || 'download' + : 'download'; + const mimeType = typeof payload.mimeType === 'string' ? payload.mimeType : ''; + const raw = payload.data; + if (!raw) { + return { success: false, error: 'Missing file data' }; + } + + const buffer = Buffer.from( + raw instanceof ArrayBuffer + ? new Uint8Array(raw) + : ArrayBuffer.isView(raw) + ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) + : raw + ); + + const ext = path.extname(filename).replace(/^\./, '').toLowerCase(); + const filters = (() => { + if (mimeType.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) { + return [ + { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mimeType.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) { + return [ + { name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mimeType.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) { + return [ + { name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mimeType === 'application/pdf' || ext === 'pdf') { + return [ + { name: 'PDF', extensions: ['pdf'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (ext) { + return [ + { name: ext.toUpperCase(), extensions: [ext] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + return [{ name: 'All Files', extensions: ['*'] }]; + })(); + + const win = BrowserWindow.fromWebContents(event.sender) || mainWindow; + const result = await dialog.showSaveDialog(win || undefined, { + title: 'Save file', + defaultPath: filename, + filters, + }); + + if (result.canceled || !result.filePath) { + return { success: true, canceled: true }; + } + + await fs.promises.writeFile(result.filePath, buffer); + return { success: true, path: result.filePath }; + } catch (error) { + console.error('[media:save-file] failed:', error); + return { success: false, error: error.message || String(error) }; + } +}); + // Read clipboard image ipcMain.handle('read-clipboard-image', async () => { try { diff --git a/electron/preload.js b/electron/preload.js index fb11105..cc7fc64 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -104,6 +104,12 @@ contextBridge.exposeInMainWorld('electron', { readImage: () => ipcRenderer.invoke('read-clipboard-image'), writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text) }, + + // Media save (native Save dialog for images / videos / files) + media: { + saveFile: ({ filename, mimeType, data }) => + ipcRenderer.invoke('media:save-file', { filename, mimeType, data }), + }, // Audio audio: { playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType), diff --git a/package.json b/package.json index 178e79a..ae4d8c6 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,12 @@ "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"", "dev:vite": "cross-env BROWSER=none sh -c 'cd cinny && npm start'", "dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .", - "build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always", - "build:local": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder", + "build": "node scripts/generate-update-manifest.mjs && node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always", + "build:local": "node scripts/generate-update-manifest.mjs && node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder", "build:linux": "npm run build -- --linux", "build:win": "npm run build -- --win", "postman:generate": "node scripts/generate-postman-collection.js", + "generate:update-manifest": "node scripts/generate-update-manifest.mjs", "playground": "npm --prefix cinny run playground" }, "keywords": [], diff --git a/scripts/generate-update-manifest.mjs b/scripts/generate-update-manifest.mjs new file mode 100644 index 0000000..3f5073a --- /dev/null +++ b/scripts/generate-update-manifest.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Regenerates cinny/public/update/manifest.json from markdown files. + * + * - currentupdate.md is always the "current" entry + * - Other *.md files matching X.Y.Z.md become "older" (sorted newest first) + * - title, date, version come from YAML frontmatter when present + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const UPDATE_DIR = path.join(ROOT, 'cinny', 'public', 'update'); +const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json'); +const CURRENT_FILE = 'currentupdate.md'; +const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +function parseFrontmatter(source) { + const match = FRONTMATTER_RE.exec(source.trimStart()); + if (!match) return {}; + + const frontmatter = {}; + match[1].split('\n').forEach((line) => { + const colon = line.indexOf(':'); + if (colon <= 0) return; + const key = line.slice(0, colon).trim(); + const raw = line.slice(colon + 1).trim(); + frontmatter[key] = raw.replace(/^['"]|['"]$/g, ''); + }); + return frontmatter; +} + +function compareVersions(a, b) { + const pa = a.split('.').map((part) => Number.parseInt(part, 10)); + const pb = b.split('.').map((part) => Number.parseInt(part, 10)); + const len = Math.max(pa.length, pb.length); + + for (let i = 0; i < len; i += 1) { + const na = pa[i] ?? 0; + const nb = pb[i] ?? 0; + if (na !== nb) return nb - na; + } + return 0; +} + +function readMarkdownMeta(fileName) { + const filePath = path.join(UPDATE_DIR, fileName); + const source = fs.readFileSync(filePath, 'utf8'); + const frontmatter = parseFrontmatter(source); + const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1]; + + return { + file: fileName, + version: frontmatter.version ?? versionFromName, + title: frontmatter.title, + date: frontmatter.date, + }; +} + +function buildManifest() { + if (!fs.existsSync(UPDATE_DIR)) { + throw new Error(`Update directory not found: ${UPDATE_DIR}`); + } + + const currentPath = path.join(UPDATE_DIR, CURRENT_FILE); + if (!fs.existsSync(currentPath)) { + throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`); + } + + const older = fs + .readdirSync(UPDATE_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name)) + .map((entry) => readMarkdownMeta(entry.name)) + .filter((entry) => entry.version) + .sort((a, b) => compareVersions(a.version, b.version)); + + return { + current: CURRENT_FILE, + older: older.map(({ file, version, title, date }) => { + const item = { file }; + if (version) item.version = version; + if (title) item.title = title; + if (date) item.date = date; + return item; + }), + }; +} + +function main() { + const manifest = buildManifest(); + const json = `${JSON.stringify(manifest, null, 2)}\n`; + + let previous = null; + if (fs.existsSync(MANIFEST_PATH)) { + previous = fs.readFileSync(MANIFEST_PATH, 'utf8'); + } + + if (previous === json) { + console.log('[update-manifest] manifest.json is already up to date'); + return; + } + + fs.writeFileSync(MANIFEST_PATH, json, 'utf8'); + console.log( + `[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))` + ); +} + +main(); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 626660c..00d9eac 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,17 @@ "updater:allow-download", "updater:allow-download-and-install", "dialog:default", + "fs:default", + "fs:allow-write-file", + { + "identifier": "fs:scope", + "allow": [ + { "path": "$HOME/**" }, + { "path": "$DOWNLOAD/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT/**" } + ] + }, "process:allow-restart", { "identifier": "http:default",