Add native media save dialog and bump cinny submodule.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Successful in 6m51s
Build / build-linux (push) Successful in 3m11s
Build / finalize-release (push) Successful in 5s

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:
2026-08-23 08:07:02 +10:00
parent 5e06256973
commit 2828ae425c
6 changed files with 213 additions and 4 deletions

2
cinny

Submodule cinny updated: 898d217451...e65a516350

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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": [],

View 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();

View File

@@ -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",