Add native media save dialog and bump cinny submodule.
All checks were successful
All checks were successful
Wire Electron/Tauri save-file IPC for desktop downloads and update the cinny submodule with username colors, release notes, and account profile layout fixes.
This commit is contained in:
2
cinny
2
cinny
Submodule cinny updated: 898d217451...e65a516350
@@ -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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { exec, execFile, execFileSync } = require('child_process');
|
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
|
// Read clipboard image
|
||||||
ipcMain.handle('read-clipboard-image', async () => {
|
ipcMain.handle('read-clipboard-image', async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
readImage: () => ipcRenderer.invoke('read-clipboard-image'),
|
readImage: () => ipcRenderer.invoke('read-clipboard-image'),
|
||||||
writeText: (text) => ipcRenderer.invoke('write-clipboard-text', text)
|
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
|
||||||
audio: {
|
audio: {
|
||||||
playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType),
|
playNotificationSound: (soundType = 'message') => ipcRenderer.invoke('play-notification-sound', soundType),
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||||
"dev:vite": "cross-env BROWSER=none sh -c 'cd cinny && npm start'",
|
"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 .",
|
"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": "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 -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder",
|
"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:linux": "npm run build -- --linux",
|
||||||
"build:win": "npm run build -- --win",
|
"build:win": "npm run build -- --win",
|
||||||
"postman:generate": "node scripts/generate-postman-collection.js",
|
"postman:generate": "node scripts/generate-postman-collection.js",
|
||||||
|
"generate:update-manifest": "node scripts/generate-update-manifest.mjs",
|
||||||
"playground": "npm --prefix cinny run playground"
|
"playground": "npm --prefix cinny run playground"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
114
scripts/generate-update-manifest.mjs
Normal file
114
scripts/generate-update-manifest.mjs
Normal file
@@ -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();
|
||||||
@@ -18,6 +18,17 @@
|
|||||||
"updater:allow-download",
|
"updater:allow-download",
|
||||||
"updater:allow-download-and-install",
|
"updater:allow-download-and-install",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
{
|
||||||
|
"identifier": "fs:scope",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$HOME/**" },
|
||||||
|
{ "path": "$DOWNLOAD/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$DOCUMENT/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
|
|||||||
Reference in New Issue
Block a user