Messaging: - Optimistic UI: message appears instantly in sender's view on send - Skip WS relay-back of own messages to avoid duplicates - Fix sent_at field name mismatch (sentAt → sent_at fallback) Admin portal: - /admin route: list all users, storage used vs quota, file count - Inline-edit per-user storage quota (MB) and admin flag - Stats header: total users, total storage, total files - is_admin + storage_quota_mb columns added to users table - Login response now includes isAdmin; sidebar shows Admin link for admins - requireAdmin middleware on all /admin/* routes Electron desktop app (desktop/): - BrowserWindow with native title bar, system tray, hide-to-tray on close - First-run setup dialog to configure self-hosted server URL - Persists server URL via electron-store - Tray menu: Open, Change server URL, Quit - IPC: showNotification for future native push notifications - electron-builder config for macOS (DMG), Windows (NSIS), Linux (AppImage) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
6.0 KiB
JavaScript
198 lines
6.0 KiB
JavaScript
'use strict';
|
|
|
|
const { app, BrowserWindow, Tray, Menu, ipcMain, Notification, nativeImage, shell } = require('electron');
|
|
const path = require('path');
|
|
|
|
// electron-store v10 uses ESM — load dynamically
|
|
let Store;
|
|
let store;
|
|
|
|
async function loadStore() {
|
|
const mod = await import('electron-store');
|
|
Store = mod.default;
|
|
store = new Store({
|
|
defaults: { serverUrl: '' },
|
|
});
|
|
}
|
|
|
|
let mainWindow = null;
|
|
let tray = null;
|
|
let setupWindow = null;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function getIconPath() {
|
|
if (process.platform === 'darwin') return path.join(__dirname, 'assets', 'icon.icns');
|
|
if (process.platform === 'win32') return path.join(__dirname, 'assets', 'icon.ico');
|
|
return path.join(__dirname, 'assets', 'icon.png');
|
|
}
|
|
|
|
function createTray() {
|
|
const trayImg = nativeImage.createFromPath(
|
|
path.join(__dirname, 'assets', 'tray.png')
|
|
);
|
|
tray = new Tray(trayImg.isEmpty() ? nativeImage.createEmpty() : trayImg);
|
|
tray.setToolTip('CloudLink');
|
|
|
|
const menu = Menu.buildFromTemplate([
|
|
{ label: 'Open CloudLink', click: () => { mainWindow?.show(); mainWindow?.focus(); } },
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Change server URL…',
|
|
click: () => openSetup(true),
|
|
},
|
|
{ type: 'separator' },
|
|
{ label: 'Quit', click: () => app.quit() },
|
|
]);
|
|
|
|
tray.setContextMenu(menu);
|
|
tray.on('click', () => { mainWindow?.show(); mainWindow?.focus(); });
|
|
}
|
|
|
|
function openSetup(fromTray = false) {
|
|
if (setupWindow) { setupWindow.focus(); return; }
|
|
|
|
setupWindow = new BrowserWindow({
|
|
width: 520,
|
|
height: 340,
|
|
resizable: false,
|
|
title: 'CloudLink — Setup',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
const current = store.get('serverUrl');
|
|
const html = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center; height: 100vh; gap: 24px; padding: 32px; }
|
|
h1 { font-size: 1.25rem; font-weight: 700; }
|
|
p { font-size: 0.875rem; color: #94a3b8; text-align: center; }
|
|
input { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #334155;
|
|
background: #1e293b; color: #e2e8f0; font-size: 0.9rem; outline: none; }
|
|
input:focus { border-color: #6366f1; }
|
|
button { padding: 10px 24px; background: #6366f1; color: white; border: none;
|
|
border-radius: 8px; font-size: 0.9rem; cursor: pointer; }
|
|
button:hover { background: #4f46e5; }
|
|
.row { display: flex; gap: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🔗 CloudLink</h1>
|
|
<p>Enter the URL of your CloudLink server.<br>
|
|
For local development use <code style="color:#818cf8">http://localhost:5173</code></p>
|
|
<input id="url" type="url" placeholder="https://cloudlink.yourserver.com"
|
|
value="${current}" />
|
|
<div class="row">
|
|
<button onclick="save()">Connect</button>
|
|
</div>
|
|
<script>
|
|
function save() {
|
|
const v = document.getElementById('url').value.trim().replace(/\\/$/, '');
|
|
if (!v) return;
|
|
window.cloudlink.setServerUrl(v);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
setupWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
|
setupWindow.on('closed', () => { setupWindow = null; });
|
|
}
|
|
|
|
function createMainWindow(serverUrl) {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1280,
|
|
height: 820,
|
|
minWidth: 900,
|
|
minHeight: 600,
|
|
title: 'CloudLink',
|
|
icon: getIconPath(),
|
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
// Allow the web app to use the Web Crypto API and WebSockets
|
|
webSecurity: true,
|
|
},
|
|
});
|
|
|
|
mainWindow.loadURL(serverUrl);
|
|
|
|
// Open external links in the default browser
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
shell.openExternal(url);
|
|
return { action: 'deny' };
|
|
});
|
|
|
|
// Hide to tray instead of quitting on close (macOS/Windows convention)
|
|
mainWindow.on('close', (e) => {
|
|
if (process.platform !== 'darwin' && !app.isQuitting) {
|
|
e.preventDefault();
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => { mainWindow = null; });
|
|
}
|
|
|
|
// ── IPC ───────────────────────────────────────────────────────────────────────
|
|
|
|
ipcMain.handle('get-server-url', () => store.get('serverUrl'));
|
|
|
|
ipcMain.handle('set-server-url', (_e, url) => {
|
|
store.set('serverUrl', url);
|
|
setupWindow?.close();
|
|
if (mainWindow) {
|
|
mainWindow.loadURL(url);
|
|
} else {
|
|
createMainWindow(url);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('show-notification', (_e, { title, body }) => {
|
|
if (Notification.isSupported()) {
|
|
new Notification({ title, body }).show();
|
|
}
|
|
});
|
|
|
|
// ── App lifecycle ─────────────────────────────────────────────────────────────
|
|
|
|
app.whenReady().then(async () => {
|
|
await loadStore();
|
|
createTray();
|
|
|
|
const serverUrl = store.get('serverUrl');
|
|
if (!serverUrl) {
|
|
openSetup();
|
|
} else {
|
|
createMainWindow(serverUrl);
|
|
}
|
|
|
|
app.on('activate', () => {
|
|
if (!mainWindow && store.get('serverUrl')) {
|
|
createMainWindow(store.get('serverUrl'));
|
|
} else {
|
|
mainWindow?.show();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on('before-quit', () => { app.isQuitting = true; });
|
|
|
|
// Keep running when all windows are closed (show via tray)
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
// don't quit — user can reopen from tray
|
|
}
|
|
});
|