| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { app, BrowserWindow, Menu, dialog, shell } from 'electron'; |
| import { autoUpdater } from 'electron-updater'; |
| import { migrateLegacyDir } from './migrate-legacy'; |
| import * as path from 'path'; |
| import * as fs from 'fs'; |
| import * as http from 'http'; |
| import * as https from 'https'; |
| import * as crypto from 'crypto'; |
|
|
| const isDev = !app.isPackaged; |
| const isMac = process.platform === 'darwin'; |
| |
| |
| const isSelfTest = process.argv.includes('--self-test') || process.env.OSW_SELFTEST === '1'; |
| const RELEASES_URL = 'https://github.com/o-stahl/osw-studio/releases/latest'; |
| const RELEASES_API_URL = 'https://api.github.com/repos/o-stahl/osw-studio/releases/latest'; |
|
|
| let mainWindow: BrowserWindow | null = null; |
| let serverPort: number | null = null; |
|
|
| |
| |
| |
| |
|
|
| function logToFile(message: string): void { |
| try { |
| const logDir = path.join(app.getPath('userData'), 'logs'); |
| fs.mkdirSync(logDir, { recursive: true }); |
| fs.appendFileSync(path.join(logDir, 'main.log'), `[${new Date().toISOString()}] ${message}\n`); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
|
|
| async function startNextServer(host: string = 'localhost'): Promise<number> { |
| const { getPort } = await import('get-port-please'); |
| const port = await getPort({ portRange: [30011, 50000] }); |
|
|
| process.env.PORT = String(port); |
| process.env.HOSTNAME = host; |
| process.env.NEXT_PUBLIC_APP_URL = `http://${host}:${port}`; |
| process.env.OSW_DESKTOP = 'true'; |
|
|
| |
| |
| |
| |
| const appDir = app.getAppPath(); |
| const dataDir = path.join(app.getPath('userData'), 'data'); |
| const deploymentsDir = path.join(app.getPath('userData'), 'deployments'); |
|
|
| |
| |
| |
| |
| migrateLegacyDir(path.join(appDir, 'data'), dataDir, logToFile); |
| migrateLegacyDir(path.join(appDir, 'deployments'), deploymentsDir, logToFile); |
|
|
| fs.mkdirSync(dataDir, { recursive: true }); |
| process.env.DATA_DIR = dataDir; |
|
|
| fs.mkdirSync(deploymentsDir, { recursive: true }); |
| process.env.DEPLOYMENTS_DIR = deploymentsDir; |
|
|
| |
| const secretsFile = path.join(app.getPath('userData'), '.secrets.json'); |
| let secrets: { sessionSecret: string; encryptionKey: string }; |
| try { |
| secrets = JSON.parse(fs.readFileSync(secretsFile, 'utf-8')); |
| } catch { |
| secrets = { |
| sessionSecret: crypto.randomBytes(32).toString('hex'), |
| encryptionKey: crypto.randomBytes(32).toString('base64'), |
| }; |
| fs.writeFileSync(secretsFile, JSON.stringify(secrets), { mode: 0o600 }); |
| } |
| process.env.SESSION_SECRET = secrets.sessionSecret; |
| process.env.SECRETS_ENCRYPTION_KEY = secrets.encryptionKey; |
| |
|
|
| |
| |
| process.chdir(appDir); |
| require(path.join(appDir, 'server.js')); |
|
|
| return port; |
| } |
|
|
| async function waitForServer(url: string, maxRetries = 30): Promise<boolean> { |
| for (let i = 0; i < maxRetries; i++) { |
| const ok = await new Promise<boolean>((resolve) => { |
| const req = http.get(url, (res) => { res.resume(); resolve(true); }); |
| req.on('error', () => resolve(false)); |
| req.setTimeout(2000, () => { req.destroy(); resolve(false); }); |
| }); |
| if (ok) return true; |
| await new Promise(r => setTimeout(r, 1000)); |
| } |
| return false; |
| } |
|
|
| |
| |
| |
|
|
| function postJson(url: string): Promise<{ status: number; body: string }> { |
| return new Promise((resolve, reject) => { |
| const req = http.request(url, { method: 'POST' }, (res) => { |
| let data = ''; |
| res.on('data', (c) => { data += c; }); |
| res.on('end', () => resolve({ status: res.statusCode || 0, body: data })); |
| }); |
| req.on('error', reject); |
| req.setTimeout(15000, () => { req.destroy(new Error('request timed out')); }); |
| req.end(); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| async function runSelfTest(): Promise<void> { |
| console.log(`[self-test] OSW Studio Desktop v${app.getVersion()} on ${process.platform}/${process.arch}`); |
| console.log(`[self-test] electron=${process.versions.electron} node=${process.versions.node} abi(modules)=${process.versions.modules}`); |
| try { |
| |
| |
| const port = await startNextServer('127.0.0.1'); |
| const base = `http://127.0.0.1:${port}`; |
|
|
| const ready = await waitForServer(base); |
| if (!ready) { |
| console.error('[self-test] FAIL: server did not respond within the startup timeout'); |
| app.exit(1); |
| return; |
| } |
|
|
| const { status, body } = await postJson(`${base}/api/auth/desktop-init`); |
| let parsed: { workspaceId?: string; error?: string; detail?: string } = {}; |
| try { parsed = JSON.parse(body); } catch { } |
|
|
| if (status === 200 && parsed.workspaceId) { |
| console.log(`[self-test] PASS: workspace initialized (${parsed.workspaceId})`); |
| app.exit(0); |
| return; |
| } |
|
|
| console.error(`[self-test] FAIL: desktop-init returned HTTP ${status}`); |
| if (parsed.error) console.error(`[self-test] error: ${parsed.error}`); |
| if (parsed.detail) console.error(`[self-test] detail:\n${parsed.detail}`); |
| if (!parsed.error && body) console.error(`[self-test] body: ${body}`); |
| app.exit(1); |
| } catch (err) { |
| console.error(`[self-test] FAIL: ${(err as Error)?.stack || String(err)}`); |
| app.exit(1); |
| } |
| } |
|
|
| |
| |
| |
|
|
| function escapeHtml(text: string): string { |
| return text |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"'); |
| } |
|
|
| function diagnosticPage(rawTitle: string, rawDetail: string): string { |
| const title = escapeHtml(rawTitle); |
| const detail = escapeHtml(rawDetail); |
| const logPath = escapeHtml(path.join(app.getPath('userData'), 'logs', 'main.log')); |
| const html = `<!DOCTYPE html> |
| <html><head><meta charset="utf-8"><title>OSW Studio — Problem</title> |
| <style> |
| body { font-family: system-ui, sans-serif; background: #121212; color: #eaeaea; margin: 0; |
| display: flex; align-items: center; justify-content: center; min-height: 100vh; } |
| .box { max-width: 560px; padding: 2rem; } |
| h1 { font-size: 1.3rem; } p, li { line-height: 1.6; color: #bbb; } |
| code { background: #1e1e1e; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85em; } |
| a { color: #7aa2f7; } |
| </style></head> |
| <body><div class="box"> |
| <h1>${title}</h1> |
| <p>${detail}</p> |
| <p>OSW Studio Desktop v${app.getVersion()}</p> |
| <ul> |
| <li>Restarting the app usually resolves transient startup problems.</li> |
| <li>If this happened after an update, download the latest installer from the |
| <a href="${RELEASES_URL}" target="_blank">releases page</a> and reinstall — your projects and data are kept.</li> |
| <li>Log file for bug reports: <code>${logPath}</code></li> |
| </ul> |
| </div></body></html>`; |
| return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; |
| } |
|
|
| function showDiagnostic(title: string, detail: string): void { |
| logToFile(`${title}: ${detail}`); |
| if (mainWindow && !mainWindow.isDestroyed()) { |
| mainWindow.loadURL(diagnosticPage(title, detail)); |
| } |
| } |
|
|
| |
| |
| |
|
|
| interface UpdatePrefs { skippedVersion?: string } |
|
|
| function updatePrefsFile(): string { |
| return path.join(app.getPath('userData'), 'update-prefs.json'); |
| } |
|
|
| function readUpdatePrefs(): UpdatePrefs { |
| try { |
| return JSON.parse(fs.readFileSync(updatePrefsFile(), 'utf-8')); |
| } catch { |
| return {}; |
| } |
| } |
|
|
| function writeUpdatePrefs(prefs: UpdatePrefs): void { |
| try { |
| fs.writeFileSync(updatePrefsFile(), JSON.stringify(prefs)); |
| } catch { |
| |
| } |
| } |
|
|
| function isNewerVersion(candidate: string, current: string): boolean { |
| const a = candidate.split('.').map(n => parseInt(n, 10) || 0); |
| const b = current.split('.').map(n => parseInt(n, 10) || 0); |
| for (let i = 0; i < Math.max(a.length, b.length); i++) { |
| if ((a[i] || 0) > (b[i] || 0)) return true; |
| if ((a[i] || 0) < (b[i] || 0)) return false; |
| } |
| return false; |
| } |
|
|
| let updaterWired = false; |
| let updateDownloaded = false; |
| let downloadedVersion = ''; |
|
|
| async function promptRestartToUpdate(version: string): Promise<void> { |
| const { response } = await dialog.showMessageBox({ |
| type: 'info', |
| title: 'Update ready', |
| message: `OSW Studio v${version} has been downloaded.`, |
| detail: 'Restart now to apply the update, or keep working — it will NOT install until you choose to.', |
| buttons: ['Restart now', 'Later'], |
| defaultId: 0, |
| cancelId: 1, |
| }); |
| if (response === 0) { |
| autoUpdater.quitAndInstall(); |
| } |
| } |
|
|
| async function showUpdateError(err: unknown): Promise<void> { |
| const logPath = path.join(app.getPath('userData'), 'logs', 'main.log'); |
| const message = err instanceof Error ? err.message : String(err); |
| const { response } = await dialog.showMessageBox({ |
| type: 'error', |
| title: 'Update failed', |
| message: 'The update could not be downloaded.', |
| detail: `${message}\n\nYou can download the latest version manually from the releases page. Details are saved to the log:\n${logPath}`, |
| buttons: ['Open releases page', 'Close'], |
| defaultId: 0, |
| cancelId: 1, |
| }); |
| if (response === 0) shell.openExternal(RELEASES_URL); |
| } |
|
|
| function wireUpdater(): void { |
| if (updaterWired) return; |
| updaterWired = true; |
|
|
| autoUpdater.autoDownload = false; |
| autoUpdater.autoInstallOnAppQuit = false; |
|
|
| |
| |
| autoUpdater.on('error', (err) => { |
| logToFile(`Updater error: ${err?.message || err}`); |
| mainWindow?.setProgressBar(-1); |
| }); |
|
|
| autoUpdater.on('download-progress', (progress) => { |
| mainWindow?.setProgressBar((progress?.percent ?? 0) / 100); |
| }); |
|
|
| autoUpdater.on('update-downloaded', (info) => { |
| mainWindow?.setProgressBar(-1); |
| updateDownloaded = true; |
| downloadedVersion = info.version; |
| void promptRestartToUpdate(info.version); |
| }); |
| } |
|
|
| |
| |
| |
| |
| async function checkForUpdatesElectron(interactive: boolean): Promise<void> { |
| wireUpdater(); |
| |
| |
| if (updateDownloaded) { |
| if (interactive) await promptRestartToUpdate(downloadedVersion); |
| return; |
| } |
| try { |
| const result = await autoUpdater.checkForUpdates(); |
| const version = result?.updateInfo?.version; |
| if (!version || !isNewerVersion(version, app.getVersion())) { |
| if (interactive) { |
| dialog.showMessageBox({ type: 'info', message: 'You are on the latest version.', detail: `OSW Studio v${app.getVersion()}` }); |
| } |
| return; |
| } |
| if (!interactive && readUpdatePrefs().skippedVersion === version) return; |
|
|
| const { response } = await dialog.showMessageBox({ |
| type: 'info', |
| title: 'Update available', |
| message: `OSW Studio v${version} is available.`, |
| detail: `You are on v${app.getVersion()}. If you download, it downloads in the background (progress shows on the taskbar) and prompts you to restart when it is ready. It will not install until you choose to.`, |
| buttons: ['Download', 'Skip this version', 'Later'], |
| defaultId: 0, |
| cancelId: 2, |
| }); |
| if (response === 0) { |
| logToFile(`Downloading update v${version}`); |
| mainWindow?.setProgressBar(0); |
| autoUpdater.downloadUpdate().catch(err => { |
| mainWindow?.setProgressBar(-1); |
| logToFile(`Update download failed: ${err?.message || err}`); |
| void showUpdateError(err); |
| }); |
| } else if (response === 1) { |
| writeUpdatePrefs({ skippedVersion: version }); |
| } |
| } catch (err) { |
| logToFile(`Update check failed: ${(err as Error)?.message || err}`); |
| if (interactive) { |
| dialog.showMessageBox({ type: 'warning', message: 'Update check failed.', detail: 'See the log file for details, or check the releases page manually.' }); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| async function checkForUpdatesMac(interactive: boolean): Promise<void> { |
| try { |
| const body = await new Promise<string>((resolve, reject) => { |
| const req = https.get(RELEASES_API_URL, { headers: { 'User-Agent': 'osw-studio-desktop' } }, (res) => { |
| let data = ''; |
| res.on('data', (c) => { data += c; }); |
| res.on('end', () => resolve(data)); |
| }); |
| req.on('error', reject); |
| req.setTimeout(10000, () => { req.destroy(); reject(new Error('timeout')); }); |
| }); |
| const release = JSON.parse(body); |
| const version = String(release.tag_name || '').replace(/^desktop-v/, ''); |
| if (!version || !isNewerVersion(version, app.getVersion())) { |
| if (interactive) { |
| dialog.showMessageBox({ type: 'info', message: 'You are on the latest version.', detail: `OSW Studio v${app.getVersion()}` }); |
| } |
| return; |
| } |
| if (!interactive && readUpdatePrefs().skippedVersion === version) return; |
|
|
| const { response } = await dialog.showMessageBox({ |
| type: 'info', |
| title: 'Update available', |
| message: `OSW Studio v${version} is available.`, |
| detail: `You are on v${app.getVersion()}. Auto-update is not available on macOS (the app is unsigned) — download the new version from the releases page. Your projects and data are kept.`, |
| buttons: ['Open releases page', 'Skip this version', 'Later'], |
| defaultId: 0, |
| cancelId: 2, |
| }); |
| if (response === 0) { |
| shell.openExternal(RELEASES_URL); |
| } else if (response === 1) { |
| writeUpdatePrefs({ skippedVersion: version }); |
| } |
| } catch (err) { |
| logToFile(`Update check failed: ${(err as Error)?.message || err}`); |
| if (interactive) { |
| dialog.showMessageBox({ type: 'warning', message: 'Update check failed.', detail: 'Check the releases page manually.' }); |
| } |
| } |
| } |
|
|
| function checkForUpdates(interactive: boolean): void { |
| if (isDev) return; |
| if (isMac) { |
| void checkForUpdatesMac(interactive); |
| } else { |
| void checkForUpdatesElectron(interactive); |
| } |
| } |
|
|
| |
| |
| |
|
|
| function buildMenu(): void { |
| const template: Electron.MenuItemConstructorOptions[] = [ |
| ...(isMac ? [{ role: 'appMenu' as const }] : []), |
| { role: 'fileMenu' as const }, |
| { role: 'editMenu' as const }, |
| { role: 'viewMenu' as const }, |
| { role: 'windowMenu' as const }, |
| { |
| role: 'help' as const, |
| submenu: [ |
| { label: 'Check for Updates…', click: () => checkForUpdates(true) }, |
| { label: 'Open Releases Page', click: () => shell.openExternal(RELEASES_URL) }, |
| { type: 'separator' as const }, |
| { label: 'Report an Issue', click: () => shell.openExternal('https://github.com/o-stahl/osw-studio/issues') }, |
| { label: 'Open Logs Folder', click: () => { |
| const logDir = path.join(app.getPath('userData'), 'logs'); |
| try { fs.mkdirSync(logDir, { recursive: true }); } catch { } |
| shell.openPath(logDir); |
| } }, |
| ], |
| }, |
| ]; |
| Menu.setApplicationMenu(Menu.buildFromTemplate(template)); |
| } |
|
|
| |
| |
| |
|
|
| async function createWindow(port: number): Promise<void> { |
| mainWindow = new BrowserWindow({ |
| width: 1400, |
| height: 900, |
| minWidth: 800, |
| minHeight: 600, |
| title: 'OSW Studio', |
| webPreferences: { |
| nodeIntegration: false, |
| contextIsolation: true, |
| }, |
| }); |
|
|
| const serverUrl = `http://localhost:${port}`; |
|
|
| mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { |
| |
| if (errorCode === -3) return; |
| showDiagnostic('The app failed to load', `${errorDescription} (${errorCode}) while loading ${validatedURL}.`); |
| }); |
|
|
| |
| mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => { |
| if (url.startsWith('http://localhost')) return { action: 'allow' as const }; |
| shell.openExternal(url); |
| return { action: 'deny' as const }; |
| }); |
|
|
| mainWindow.on('closed', () => { |
| mainWindow = null; |
| }); |
|
|
| if (!isDev) { |
| const ready = await waitForServer(serverUrl); |
| if (!ready) { |
| showDiagnostic('The local server did not start', 'The bundled server did not respond within 30 seconds.'); |
| return; |
| } |
| } |
|
|
| mainWindow.loadURL(serverUrl); |
|
|
| if (isDev) { |
| mainWindow.webContents.openDevTools({ mode: 'detach' }); |
| } |
| } |
|
|
| |
| |
| |
|
|
| app.whenReady().then(async () => { |
| if (isSelfTest) { |
| |
| await runSelfTest(); |
| return; |
| } |
|
|
| logToFile(`OSW Studio Desktop v${app.getVersion()} starting on ${process.platform}/${process.arch}`); |
| buildMenu(); |
| try { |
| if (isDev) { |
| |
| serverPort = 3000; |
| } else { |
| serverPort = await startNextServer(); |
| } |
| await createWindow(serverPort); |
| checkForUpdates(false); |
| } catch (error) { |
| const message = (error as Error)?.message || String(error); |
| logToFile(`Failed to start: ${message}`); |
| if (!mainWindow) { |
| mainWindow = new BrowserWindow({ width: 900, height: 600, title: 'OSW Studio' }); |
| } |
| mainWindow.loadURL(diagnosticPage('OSW Studio failed to start', message)); |
| } |
| }); |
|
|
| app.on('window-all-closed', () => { |
| app.quit(); |
| }); |
|
|
| app.on('activate', () => { |
| if (mainWindow === null && serverPort) { |
| createWindow(serverPort); |
| } |
| }); |
|
|