Files
cloudlink/desktop/main.js
T
nmemmertandClaude Sonnet 4.6 9b57dd9b94 feat: real-time messaging, Electron desktop app, admin drive portal
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>
2026-08-28 10:31:41 -04:00

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
}
});