Merge pull request #445 from presenton/feat/export-issue

Feat/export issue
This commit is contained in:
Sudip Parajuli 2026-03-15 14:40:20 +05:45 committed by GitHub
commit 7e79dbed42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2401 additions and 654 deletions

View file

@ -1,124 +1,163 @@
import { ipcMain } from "electron";
import { appDataDir, baseDir, downloadsDir, tempDir } from "../utils/constants";
import fs from "fs";
import path from "path";
import { showFileDownloadedDialog } from "../utils/dialog";
import { v4 as uuidv4 } from 'uuid';
import { spawn } from "child_process";
export function setupExportHandlers() {
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
const fileName = path.basename(filePath);
const destinationPath = path.join(downloadsDir, fileName);
await fs.promises.rename(filePath, destinationPath);
const success = await showFileDownloadedDialog(destinationPath);
return { success };
});
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => {
try {
const pptUrl = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?id=${id}`;
let exportTask = {
type: "export",
url: pptUrl,
format: exportAs,
title: title,
}
const randomUuid = uuidv4();
const exportTempDir = path.join(tempDir, randomUuid);
await fs.promises.mkdir(exportTempDir, { recursive: true });
const exportTaskPath = path.join(exportTempDir, "export_task.json");
await fs.promises.writeFile(exportTaskPath, JSON.stringify(exportTask));
const exportScriptPath = path.join(baseDir, "resources", "export", "index.js");
const pythonModulePath = path.join(baseDir, "resources", "export", "py", "convert");
const exportTaskProcess = spawn("node", [exportScriptPath, exportTaskPath], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
TEMP_DIRECTORY: tempDir,
APP_DATA_DIRECTORY: appDataDir,
BUILT_PYTHON_MODULE_PATH: pythonModulePath,
},
});
exportTaskProcess.stdout.on("data", (data: Buffer) => {
console.log(`[Export] ${data.toString()}`);
});
exportTaskProcess.stderr.on("data", (data: Buffer) => {
console.error(`[Export] ${data.toString()}`);
});
await new Promise<void>((resolve, reject) => {
exportTaskProcess.on("error", reject);
exportTaskProcess.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Export process exited with code ${code}`));
}
});
});
const responsePath = exportTaskPath.replace(".json", ".response.json");
const responseRaw = await fs.promises.readFile(responsePath, "utf8");
const responseData = JSON.parse(responseRaw);
const exportFilePath = resolveExportedFilePath(responseData);
if (!exportFilePath) {
return { success: false, message: "Export finished but output file was not found." };
}
const destinationPath = path.join(downloadsDir, path.basename(exportFilePath));
await moveFile(exportFilePath, destinationPath);
const success = await showFileDownloadedDialog(destinationPath);
return { success, message: success ? "Export completed." : "Export completed but dialog failed." };
} catch (error: any) {
console.error("[Export] Error exporting presentation:", error);
return { success: false, message: error?.message ?? "Export failed." };
}
})
}
function resolveExportedFilePath(responseData: any): string | null {
if (responseData?.path && typeof responseData.path === "string") {
return path.isAbsolute(responseData.path)
? responseData.path
: path.join(appDataDir, responseData.path);
}
if (responseData?.url && typeof responseData.url === "string") {
try {
const parsed = new URL(responseData.url);
if (parsed.protocol === "file:") {
const filePath = decodeURIComponent(parsed.pathname);
if (process.platform === "win32" && filePath.startsWith("/")) {
return filePath.slice(1);
}
return filePath;
}
} catch {
return null;
}
}
return null;
}
async function moveFile(sourcePath: string, destinationPath: string) {
try {
await fs.promises.rename(sourcePath, destinationPath);
} catch (error: any) {
if (error?.code !== "EXDEV") {
throw error;
}
await fs.promises.copyFile(sourcePath, destinationPath);
await fs.promises.unlink(sourcePath);
}
import { ipcMain } from "electron";
import { appDataDir, baseDir, downloadsDir, tempDir } from "../utils/constants";
import fs from "fs";
import path from "path";
import { showFileDownloadedDialog } from "../utils/dialog";
import { v4 as uuidv4 } from 'uuid';
import { spawn } from "child_process";
import { getPuppeteerExecutablePath } from "../utils/puppeteer-check";
export function setupExportHandlers() {
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
const fileName = path.basename(filePath);
const destinationPath = path.join(downloadsDir, fileName);
await fs.promises.rename(filePath, destinationPath);
const success = await showFileDownloadedDialog(destinationPath);
return { success };
});
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => {
try {
const pptUrl = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?id=${id}`;
let exportTask = {
type: "export",
url: pptUrl,
format: exportAs,
title: title,
fastapiUrl: process.env.NEXT_PUBLIC_FAST_API,
}
const randomUuid = uuidv4();
const exportTempDir = path.join(tempDir, randomUuid);
await fs.promises.mkdir(exportTempDir, { recursive: true });
const exportTaskPath = path.join(exportTempDir, "export_task.json");
await fs.promises.writeFile(exportTaskPath, JSON.stringify(exportTask));
const exportScriptPath = path.join(baseDir, "resources", "export", "index.js");
const pythonModulePath = path.join(baseDir, "resources", "export", "py", "convert");
const puppeteerExecutablePath = await getPuppeteerExecutablePath();
console.log("[Export] Spawning export task with config:", {
exportAs,
id,
title,
pptUrl,
exportTaskPath,
exportScriptPath,
pythonModulePath,
puppeteerExecutablePath,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
});
const exportTaskProcess = spawn("node", [exportScriptPath, exportTaskPath], {
stdio: ["ignore", "pipe", "pipe"],
cwd: baseDir,
env: {
...process.env,
TEMP_DIRECTORY: tempDir,
APP_DATA_DIRECTORY: appDataDir,
NODE_ENV: "development",
BUILT_PYTHON_MODULE_PATH: pythonModulePath,
...(puppeteerExecutablePath && {
PUPPETEER_EXECUTABLE_PATH: puppeteerExecutablePath,
}),
},
});
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
exportTaskProcess.stdout.on("data", (data: Buffer) => {
const text = data.toString();
stdoutChunks.push(text);
console.log(`[Export] ${text}`);
});
exportTaskProcess.stderr.on("data", (data: Buffer) => {
const text = data.toString();
stderrChunks.push(text);
console.error(`[Export] ${text}`);
});
await new Promise<void>((resolve, reject) => {
exportTaskProcess.on("error", reject);
exportTaskProcess.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
const stderrText = stderrChunks.join("").trim() || "(no stderr)";
const stdoutText = stdoutChunks.join("").trim();
const detail =
stderrText !== "(no stderr)"
? stderrText
: stdoutText
? `stdout: ${stdoutText}`
: "";
reject(
new Error(
`Export process exited with code ${code}${detail ? `. ${detail}` : ""}`
)
);
}
});
});
const responsePath = exportTaskPath.replace(".json", ".response.json");
const responseRaw = await fs.promises.readFile(responsePath, "utf8");
const responseData = JSON.parse(responseRaw);
const exportFilePath = resolveExportedFilePath(responseData);
if (!exportFilePath) {
return { success: false, message: "Export finished but output file was not found." };
}
const destinationPath = path.join(downloadsDir, path.basename(exportFilePath));
await moveFile(exportFilePath, destinationPath);
const success = await showFileDownloadedDialog(destinationPath);
return { success, message: success ? "Export completed." : "Export completed but dialog failed." };
} catch (error: any) {
console.error("[Export] Error exporting presentation:", error);
return { success: false, message: error?.message ?? "Export failed." };
}
})
}
function resolveExportedFilePath(responseData: any): string | null {
if (responseData?.path && typeof responseData.path === "string") {
return path.isAbsolute(responseData.path)
? responseData.path
: path.join(appDataDir, responseData.path);
}
if (responseData?.url && typeof responseData.url === "string") {
try {
const parsed = new URL(responseData.url);
if (parsed.protocol === "file:") {
const filePath = decodeURIComponent(parsed.pathname);
if (process.platform === "win32" && filePath.startsWith("/")) {
return filePath.slice(1);
}
return filePath;
}
} catch {
return null;
}
}
return null;
}
async function moveFile(sourcePath: string, destinationPath: string) {
try {
await fs.promises.rename(sourcePath, destinationPath);
} catch (error: any) {
if (error?.code !== "EXDEV") {
throw error;
}
await fs.promises.copyFile(sourcePath, destinationPath);
await fs.promises.unlink(sourcePath);
}
}

View file

@ -0,0 +1,124 @@
/**
* IPC handlers for the unified setup installer (LibreOffice + Chromium).
* - setup:get-status which dependencies are missing
* - setup:install-chrome download Chromium (browser-snapshots) with progress
*/
import { ipcMain, WebContents } from "electron";
import fs from "fs";
import path from "path";
import os from "os";
import puppeteer from "puppeteer";
import {
Browser,
detectBrowserPlatform,
getInstalledBrowsers,
install,
resolveBuildId,
} from "@puppeteer/browsers";
import { getSetupStatus } from "../utils/setup-dependencies";
function getPuppeteerCacheDir(): string {
const configCache =
(puppeteer as any).configuration?.cacheDirectory ??
(puppeteer as any).defaultDownloadPath;
return configCache ?? path.join(os.homedir(), ".cache", "puppeteer");
}
function sendChromeProgress(
wc: WebContents,
phase: "downloading" | "extracting" | "done" | "error",
percent?: number,
message?: string
) {
if (!wc.isDestroyed()) {
wc.send("setup:chrome-progress", { phase, percent, message });
}
}
function sendChromeLog(wc: WebContents, level: string, text: string) {
if (!wc.isDestroyed()) {
wc.send("setup:chrome-log", { level, text });
}
}
export function setupSetupInstallHandlers() {
ipcMain.handle("setup:get-status", () => {
return getSetupStatus() ?? { needsLibreOffice: false, needsChrome: false };
});
ipcMain.handle(
"setup:install-chrome",
async (event): Promise<{ ok: boolean; error?: string }> => {
const wc = event.sender;
const cacheDir = getPuppeteerCacheDir();
const platform = detectBrowserPlatform();
if (!platform) {
const msg = "Unable to detect platform.";
sendChromeLog(wc, "error", msg);
sendChromeProgress(wc, "error", undefined, msg);
return { ok: false, error: msg };
}
let buildId: string;
try {
buildId = await resolveBuildId(
Browser.CHROMIUM,
platform,
"latest" as "latest"
);
} catch (err) {
const msg =
err instanceof Error
? err.message
: "Unable to resolve Chromium revision.";
sendChromeLog(wc, "error", msg);
sendChromeProgress(wc, "error", undefined, msg);
return { ok: false, error: msg };
}
sendChromeLog(wc, "info", `Downloading Chromium r${buildId}`);
sendChromeProgress(wc, "downloading", 0, "Connecting…");
try {
await install({
cacheDir,
platform,
browser: Browser.CHROMIUM,
buildId,
downloadProgressCallback: (downloadedBytes, totalBytes) => {
if (totalBytes > 0 && !wc.isDestroyed()) {
const percent = Math.min(
99,
Math.round((downloadedBytes / totalBytes) * 100)
);
const mb = (n: number) => (n / 1024 / 1024).toFixed(1);
sendChromeProgress(
wc,
"downloading",
percent,
`${mb(downloadedBytes)} / ${mb(totalBytes)} MB`
);
}
},
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Chromium download failed.";
sendChromeLog(wc, "error", message);
sendChromeProgress(wc, "error", undefined, message);
return { ok: false, error: message };
}
sendChromeProgress(wc, "extracting", 100, "Extracting…");
const browsers = await getInstalledBrowsers({ cacheDir });
const chromium = browsers.find((b) => b.browser === Browser.CHROMIUM);
if (chromium?.executablePath && fs.existsSync(chromium.executablePath)) {
sendChromeLog(wc, "ok", `Chromium ready at ${chromium.executablePath}`);
}
sendChromeProgress(wc, "done", 100);
return { ok: true };
}
);
}

View file

@ -9,8 +9,10 @@ import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localho
import { setupIpcHandlers } from "./ipc";
import { ipcMain } from "electron";
import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers";
import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check";
import { checkPuppeteerChromiumBeforeWindow } from "./utils/puppeteer-check";
import { setupSetupInstallHandlers } from "./ipc/setup_install_handlers";
import { checkDependenciesBeforeWindow } from "./utils/setup-dependencies";
import { getSofficePath, isLibreOfficeInstalled } from "./utils/libreoffice-check";
import { getPuppeteerExecutablePath, isChromeInstalled } from "./utils/puppeteer-check";
import { startUpdateChecker, stopUpdateChecker } from "./utils/update-checker";
@ -23,6 +25,9 @@ const startupStatus: Record<string, string> = {
puppeteer: "checking",
};
// Allow renderer to query initial startup status as soon as it loads.
ipcMain.handle("startup:get-status", () => startupStatus);
app.commandLine.appendSwitch('gtk-version', '3');
// Mitigate "Unable to move the cache: Access is denied" on Windows (Chromium disk cache).
@ -119,6 +124,7 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
fastApiProcess = fastApi.process;
await fastApi.ready;
const puppeteerExecutablePath = await getPuppeteerExecutablePath();
const nextjs = await startNextJsServer(
nextjsDir,
nextjsPort,
@ -129,6 +135,9 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH,
APP_DATA_DIRECTORY: appDataDir,
...(puppeteerExecutablePath && {
PUPPETEER_EXECUTABLE_PATH: puppeteerExecutablePath,
}),
},
isDev,
)
@ -167,19 +176,27 @@ app.whenReady().then(async () => {
// Ensure all required directories exist before starting
ensureDirectoriesExist();
// Register LibreOffice install handlers early so the installer window can use them
// Register install handlers early so the unified setup window can use them
setupLibreOfficeInstallHandlers();
setupSetupInstallHandlers();
// Create main window BEFORE LibreOffice check so that when user clicks "Skip for now",
// the installer closes but the main window stays open (avoids app quit on window-all-closed).
// Create main window before setup so that when user skips, the main window stays open
createWindow();
win?.loadFile(path.join(baseDir, "resources/ui/homepage/index.html"));
// Check for LibreOffice (required for custom template from PPTX). Shows installer
// window if missing. Never blocks; always proceeds.
await checkLibreOfficeBeforeWindow();
// Single installer: checks LibreOffice and Chrome; if either is missing, shows one
// window that installs them one after another. Resolves when the window closes.
await checkDependenciesBeforeWindow();
// Show and focus main window (was hidden to avoid app quit when user clicks "Skip for now")
// Update startup status after setup (user may have installed one or both)
const [loResult, chromeOk] = await Promise.all([
isLibreOfficeInstalled(),
isChromeInstalled(),
]);
startupStatus.libreoffice = loResult.installed ? "installed" : "missing";
startupStatus.puppeteer = chromeOk ? "installed" : "missing";
// Show and focus main window
win?.show();
win?.focus();
@ -188,20 +205,9 @@ app.whenReady().then(async () => {
win?.webContents.send("startup:status", { name, status });
};
win?.webContents.once("did-finish-load", async () => {
// Emit initial status so the UI doesn't remain in "Checking..." if it
// registers late.
win?.webContents.once("did-finish-load", () => {
sendStartupStatus("libreoffice", startupStatus.libreoffice);
sendStartupStatus("puppeteer", startupStatus.puppeteer);
// Check for LibreOffice (required for custom template from PPTX). Shows installer
// window if missing. Never blocks; always proceeds.
await checkLibreOfficeBeforeWindow((status) =>
sendStartupStatus("libreoffice", status)
);
// Check Puppeteer Chromium (used for export & template rendering).
await checkPuppeteerChromiumBeforeWindow((status) =>
sendStartupStatus("puppeteer", status)
);
});
setUserConfig({
@ -240,7 +246,6 @@ app.whenReady().then(async () => {
//? Setup environment variables to be used in the preloads
setupEnv(fastApiPort, nextjsPort);
setupIpcHandlers();
ipcMain.handle("startup:get-status", () => startupStatus);
await startServers(fastApiPort, nextjsPort);
win?.loadURL(`${localhost}:${nextjsPort}`);

View file

@ -0,0 +1,28 @@
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("setupInstaller", {
getStatus: () => ipcRenderer.invoke("setup:get-status"),
installLibreOffice: () => ipcRenderer.invoke("lo:start-install"),
installChrome: () => ipcRenderer.invoke("setup:install-chrome"),
done: () => ipcRenderer.send("setup:done"),
onLibreOfficeProgress: (
cb: (data: { phase: string; percent?: number; message?: string }) => void
) => {
ipcRenderer.on("lo:progress", (_event, data) => cb(data));
},
onLibreOfficeLog: (cb: (data: { level: string; text: string }) => void) => {
ipcRenderer.on("lo:log", (_event, data) => cb(data));
},
onChromeProgress: (
cb: (data: { phase: string; percent?: number; message?: string }) => void
) => {
ipcRenderer.on("setup:chrome-progress", (_event, data) => cb(data));
},
onChromeLog: (cb: (data: { level: string; text: string }) => void) => {
ipcRenderer.on("setup:chrome-log", (_event, data) => cb(data));
},
});

View file

@ -271,7 +271,7 @@ export function getSofficePath(): string {
* Returns an object indicating whether LibreOffice was found and, when it
* was, the version string reported by the binary.
*/
async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult> {
export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult> {
// --- Step 1: check well-known paths synchronously (no exec overhead) ---
for (const candidate of getCandidatePaths()) {
if (fs.existsSync(candidate)) {

View file

@ -1,14 +1,14 @@
/**
* puppeteer-check.ts
*
* Ensures Puppeteer's Chromium/Chrome-for-Testing binary is downloaded
* before the main BrowserWindow is created.
* Detects Chromium (or Chrome) for Puppeteer. We support Chromium from
* browser-snapshots; the setup installer installs Chromium into the cache.
*/
import fs from "fs";
import os from "os";
import path from "path";
import puppeteer from "puppeteer";
import { Browser, detectBrowserPlatform, install } from "@puppeteer/browsers";
import { Browser, getInstalledBrowsers } from "@puppeteer/browsers";
function getPuppeteerCacheDir(): string {
const configCache =
@ -25,9 +25,41 @@ function shouldSkipDownload(): boolean {
return Boolean((puppeteer as any).configuration?.skipDownload);
}
/** Status for the unified setup installer (whats missing). */
export interface SetupStatus {
needsLibreOffice: boolean;
needsChrome: boolean;
}
/**
* Ensures Puppeteer has its browser binary available.
* Never blocks app startup always returns `true`.
* Returns the path to the browser executable to use for Puppeteer: either
* Chrome (Puppeteer default) if present, or Chromium from the cache.
*/
export async function getPuppeteerExecutablePath(): Promise<string | undefined> {
if (shouldSkipDownload()) return undefined;
const chromePath = puppeteer.executablePath();
if (chromePath && fs.existsSync(chromePath)) return chromePath;
const cacheDir = getPuppeteerCacheDir();
const browsers = await getInstalledBrowsers({ cacheDir });
const chromium = browsers.find((b) => b.browser === Browser.CHROMIUM);
if (chromium?.executablePath && fs.existsSync(chromium.executablePath)) {
return chromium.executablePath;
}
return undefined;
}
/**
* Returns true if a supported browser (Chrome or Chromium) is already installed.
*/
export async function isChromeInstalled(): Promise<boolean> {
if (shouldSkipDownload()) return false;
const execPath = await getPuppeteerExecutablePath();
return Boolean(execPath);
}
/**
* Status for Puppeteer/Chromium (used by UI). Installation is done via the
* unified setup window, not here.
*/
export type PuppeteerStatus =
| "checking"
@ -38,6 +70,10 @@ export type PuppeteerStatus =
| "skipped"
| "failed";
/**
* Checks whether Chromium (or Chrome) is available. Does not install;
* use the unified setup window to install.
*/
export async function checkPuppeteerChromiumBeforeWindow(
onStatus?: (status: PuppeteerStatus) => void
): Promise<boolean> {
@ -47,54 +83,12 @@ export async function checkPuppeteerChromiumBeforeWindow(
onStatus?.("skipped");
return true;
}
const executablePath = puppeteer.executablePath();
if (executablePath && fs.existsSync(executablePath)) {
console.log(`[Puppeteer] Chromium found at ${executablePath}`);
const executablePath = await getPuppeteerExecutablePath();
if (executablePath) {
console.log(`[Puppeteer] Browser found at ${executablePath}`);
onStatus?.("installed");
return true;
}
onStatus?.("missing");
const cacheDir = getPuppeteerCacheDir();
const platform = detectBrowserPlatform();
if (!platform) {
console.warn("[Puppeteer] Unable to detect platform; skipping download.");
onStatus?.("failed");
return true;
}
const buildId =
(puppeteer as any).browserVersion ??
(puppeteer as any).defaultBrowserRevision;
if (!buildId) {
console.warn("[Puppeteer] Unable to resolve browser build; skipping download.");
onStatus?.("failed");
return true;
}
console.warn("[Puppeteer] Chromium missing downloading now...");
onStatus?.("downloading");
try {
await install({
cacheDir,
platform,
browser: Browser.CHROME,
buildId,
});
const downloadedPath = puppeteer.executablePath();
if (downloadedPath && fs.existsSync(downloadedPath)) {
console.log(`[Puppeteer] Chromium downloaded to ${downloadedPath}`);
onStatus?.("downloaded");
} else {
console.log("[Puppeteer] Chromium download finished.");
onStatus?.("downloaded");
}
} catch (error) {
console.warn("[Puppeteer] Chromium download failed:", error);
onStatus?.("failed");
}
return true;
}

View file

@ -0,0 +1,94 @@
/**
* setup-dependencies.ts
*
* Single installer window that ensures LibreOffice and Chrome (Puppeteer) are
* available before the user starts creating presentations. Runs checks, then
* if either is missing shows one installer that runs LibreOffice then Chrome
* in sequence (each with Install / Skip).
*/
import { BrowserWindow, ipcMain } from "electron";
import * as path from "path";
import { baseDir } from "./constants";
import { isLibreOfficeInstalled } from "./libreoffice-check";
import {
isChromeInstalled,
type SetupStatus,
} from "./puppeteer-check";
export type { SetupStatus };
/** Set by checkDependenciesBeforeWindow; read by setup installer IPC. */
let currentSetupStatus: SetupStatus | null = null;
export function getSetupStatus(): SetupStatus | null {
return currentSetupStatus;
}
/**
* Checks LibreOffice and Chrome. If both are present, returns immediately.
* If either is missing, opens one installer window that runs LibreOffice
* then Chrome in sequence. Resolves when the window closes (all done or skipped).
*/
export async function checkDependenciesBeforeWindow(): Promise<void> {
const [loResult, chromeInstalled] = await Promise.all([
isLibreOfficeInstalled(),
isChromeInstalled(),
]);
const needsLibreOffice = !loResult.installed;
const needsChrome = !chromeInstalled;
if (!needsLibreOffice && !needsChrome) {
return;
}
currentSetupStatus = {
needsLibreOffice,
needsChrome,
};
await showSetupInstallerWindow();
currentSetupStatus = null;
}
/**
* Opens the unified setup installer window (LibreOffice then Chrome).
* Resolves when the window is closed.
*/
function showSetupInstallerWindow(): Promise<void> {
return new Promise((resolve) => {
const win = new BrowserWindow({
width: 520,
height: 600,
resizable: false,
center: true,
title: "Presenton Setup required",
icon: path.join(
baseDir,
"resources/ui/assets/images/presenton_short_filled.png"
),
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, "../preloads/setup-installer.js"),
},
});
win.setMenuBarVisibility(false);
win.loadFile(
path.join(baseDir, "resources/ui/setup-installer/index.html")
);
const onDone = () => {
if (!win.isDestroyed()) win.close();
};
ipcMain.once("setup:done", onDone);
win.on("closed", () => {
ipcMain.removeListener("setup:done", onDone);
resolve();
});
});
}

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"description": "Open-Source AI Presentation Generator",
"homepage": "https://presenton.ai",
"repository": "https://github.com/presenton/presenton",
"keywords": [
"keywords": [
"electron",
"electron-builder",
"Microsoft Store",
@ -46,10 +46,12 @@
"email": "suraj@presenton.ai"
},
"dependencies": {
"@puppeteer/browsers": "^1.9.1",
"@tailwindcss/cli": "^4.1.5",
"@types/uuid": "^10.0.0",
"dotenv": "^16.5.0",
"electron-squirrel-startup": "^1.0.1",
"puppeteer": "^24.38.0",
"serve-handler": "^6.1.6",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.5",

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Presenton Setup required</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d14;
--surface: #16162a;
--surface2: #1e1e38;
--border: rgba(145, 52, 234, 0.25);
--grad-a: #9034EA;
--grad-b: #5146E5;
--text: #f0eeff;
--text-muted: #9b96c4;
--text-dim: #5e5a88;
--track: #1e1e38;
--log-bg: #0a0a12;
--log-border: rgba(145,52,234,0.15);
--radius: 14px;
--radius-sm: 8px;
}
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
.shell { display: flex; flex-direction: column; height: 100vh; padding: 24px 32px 0; }
.logo-wrap { text-align: center; margin-bottom: 12px; flex-shrink: 0; }
.logo-wrap img { height: 36px; display: inline-block; }
.logo-fallback { font-size: 20px; font-weight: 700; background: linear-gradient(135deg, var(--grad-a), var(--grad-b)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.step-badge {
font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-dim); margin-bottom: 16px; text-align: center;
}
.card {
width: 100%; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px 28px 24px; display: flex; flex-direction: column;
align-items: center; gap: 16px; position: relative; overflow: hidden;
}
.card::before {
content: ''; position: absolute; inset: 0; border-radius: var(--radius);
background: radial-gradient(ellipse 60% 40% at 50% -10%, rgba(145,52,234,0.18) 0%, transparent 70%);
pointer-events: none;
}
.state { display: none; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
.state.active { display: flex; }
.icon-wrap {
width: 52px; height: 52px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.icon-wrap.purple { background: linear-gradient(135deg, rgba(145,52,234,0.2), rgba(81,70,229,0.2)); border: 1px solid rgba(145,52,234,0.35); }
.icon-wrap.green { background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
.icon-wrap.red { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
.heading { font-size: 16px; font-weight: 600; color: var(--text); text-align: center; }
.sub { font-size: 13px; color: var(--text-muted); text-align: center; max-width: 340px; line-height: 1.6; }
.sub strong { color: var(--text); font-weight: 500; }
.btn-row { display: flex; gap: 12px; width: 100%; justify-content: center; margin-top: 2px; }
button {
cursor: pointer; border: none; border-radius: var(--radius-sm); font-family: inherit;
font-size: 13px; font-weight: 500; padding: 8px 20px; transition: opacity 0.15s, transform 0.1s; outline: none;
}
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary { background: linear-gradient(135deg, var(--grad-a), var(--grad-b)); color: #fff; min-width: 150px; box-shadow: 0 4px 20px rgba(145,52,234,0.35); }
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid rgba(155,150,196,0.25); }
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: rgba(155,150,196,0.5); }
.progress-wrap { width: 100%; display: flex; flex-direction: column; gap: 7px; }
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-muted); }
.progress-track { width: 100%; height: 6px; background: var(--track); border-radius: 99px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--grad-a), var(--grad-b)); width: 0%; transition: width 0.3s ease; }
.progress-fill.indeterminate { width: 40% !important; animation: shimmer 1.4s ease-in-out infinite; }
@keyframes shimmer { 0% { transform: translateX(-120%); } 100% { transform: translateX(310%); } }
.spinner {
width: 28px; height: 28px; border: 3px solid rgba(145,52,234,0.2);
border-top-color: var(--grad-a); border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.phase-label { font-size: 12px; color: var(--text-dim); text-align: center; }
.dots { position: absolute; bottom: -20px; right: -20px; width: 100px; height: 100px; background-image: radial-gradient(circle, rgba(145,52,234,0.18) 1px, transparent 1px); background-size: 14px 14px; pointer-events: none; opacity: 0.6; }
.log-section { width: 100%; flex: 1; display: flex; flex-direction: column; min-height: 0; margin-top: 10px; padding-bottom: 12px; }
.log-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 4px 0; color: var(--text-dim); font-size: 12px; font-weight: 500; background: none; border: none; font-family: inherit; text-align: left; width: 100%; }
.log-toggle:hover { color: var(--text-muted); }
.log-toggle-chevron { display: inline-block; width: 14px; height: 14px; position: relative; flex-shrink: 0; }
.log-toggle-chevron::before, .log-toggle-chevron::after { content: ''; position: absolute; top: 50%; left: 50%; width: 6px; height: 1.5px; background: currentColor; border-radius: 1px; transition: transform 0.2s; }
.log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(-40deg) translateX(-2px); }
.log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(40deg) translateX(2px); }
.log-toggle.open .log-toggle-chevron::before { transform: translate(-50%, -50%) rotate(40deg) translateX(-2px); }
.log-toggle.open .log-toggle-chevron::after { transform: translate(-50%, -50%) rotate(-40deg) translateX(2px); }
.log-count { margin-left: auto; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.log-panel { display: none; flex-direction: column; flex: 1; min-height: 0; margin-top: 6px; background: var(--log-bg); border: 1px solid var(--log-border); border-radius: var(--radius-sm); overflow: hidden; }
.log-panel.open { display: flex; }
.log-inner { flex: 1; overflow-y: auto; padding: 10px 12px; font-family: "SF Mono", "Cascadia Code", monospace; font-size: 11px; line-height: 1.7; color: var(--text-muted); }
.log-line { display: flex; gap: 8px; padding: 1px 0; }
.log-time { color: var(--text-dim); flex-shrink: 0; font-size: 10px; }
.log-text { white-space: pre-wrap; word-break: break-all; flex: 1; }
.log-line.info .log-text { color: var(--text-muted); }
.log-line.error .log-text { color: #e05a5a; }
.log-line.ok .log-text { color: #5ab870; }
.log-line.cmd .log-text { color: #7c8fd8; }
.log-empty { color: var(--text-dim); font-style: italic; font-size: 11px; text-align: center; padding: 12px 0; }
.log-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px 5px 12px; border-bottom: 1px solid var(--log-border); }
.log-panel-title { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); }
.log-clear-btn { font-size: 10px; padding: 2px 8px; color: var(--text-dim); background: transparent; border: 1px solid rgba(155,150,196,0.15); border-radius: 4px; cursor: pointer; font-family: inherit; }
</style>
</head>
<body>
<div class="shell">
<div class="logo-wrap">
<img src="../assets/images/presenton_logo.png" alt="Presenton" onerror="this.style.display='none'; document.getElementById('logo-fb').style.display='block';" />
<span id="logo-fb" class="logo-fallback" style="display:none;">Presenton</span>
</div>
<div class="step-badge" id="step-badge">Setup</div>
<div class="card">
<div class="dots"></div>
<div id="state-prompt" class="state active">
<div class="icon-wrap purple">📦</div>
<p class="heading" id="prompt-heading">Dependencies required</p>
<p class="sub" id="prompt-sub">Presenton needs LibreOffice and Chrome to create and export presentations. Install them now so everything works.</p>
<div class="btn-row">
<button class="btn-primary" id="btn-install">Install</button>
<button class="btn-ghost" id="btn-skip">Skip for now</button>
</div>
</div>
<div id="state-downloading" class="state">
<div class="spinner"></div>
<p class="heading" id="dl-heading">Downloading</p>
<p class="sub" id="dl-filename">Preparing…</p>
<div class="progress-wrap">
<div class="progress-meta">
<span id="dl-label">0%</span>
<span id="dl-size"></span>
</div>
<div class="progress-track"><div class="progress-fill" id="dl-bar"></div></div>
</div>
<p class="phase-label" id="dl-phase">This may take a few minutes</p>
</div>
<div id="state-installing" class="state">
<div class="spinner"></div>
<p class="heading" id="install-heading">Installing</p>
<p class="sub">Please wait…</p>
<div class="progress-wrap">
<div class="progress-track"><div class="progress-fill indeterminate" id="install-bar"></div></div>
</div>
</div>
<div id="state-success" class="state">
<div class="icon-wrap green"></div>
<p class="heading" id="success-heading">Installed</p>
<p class="sub" id="success-sub">Continuing in a moment…</p>
<div class="progress-wrap">
<div class="progress-track"><div class="progress-fill" id="success-bar" style="width:0%; transition: width 2s linear;"></div></div>
</div>
</div>
<div id="state-error" class="state">
<div class="icon-wrap red"></div>
<p class="heading">Installation failed</p>
<p class="sub" id="err-msg">Something went wrong. You can try again or skip.</p>
<div class="btn-row">
<button class="btn-primary" id="btn-retry">Try again</button>
<button class="btn-ghost" id="btn-skip-error">Skip</button>
</div>
</div>
</div>
<div class="log-section" id="log-section" style="display:none;">
<button class="log-toggle" id="log-toggle" onclick="toggleLog()">
<span class="log-toggle-chevron"></span>
<span id="log-toggle-label">Show details</span>
<span class="log-count" id="log-count"></span>
</button>
<div class="log-panel" id="log-panel">
<div class="log-panel-header">
<span class="log-panel-title">Log</span>
<button class="log-clear-btn" onclick="clearLog()">Clear</button>
</div>
<div class="log-inner" id="log-inner">
<div class="log-empty" id="log-empty">No output yet.</div>
</div>
</div>
</div>
</div>
<script>
const STATES = ['prompt','downloading','installing','success','error'];
let logLines = 0;
let currentStep = null; // 'libreoffice' | 'chrome'
let status = { needsLibreOffice: false, needsChrome: false };
let logOpen = false;
function showState(name) {
STATES.forEach(s => {
const el = document.getElementById('state-' + s);
if (el) el.classList.toggle('active', s === name);
});
const logSection = document.getElementById('log-section');
if (logSection) logSection.style.display = (name === 'downloading' || name === 'installing' || name === 'error') ? 'flex' : 'none';
}
function setStepBadge(stepNum, total, label) {
const el = document.getElementById('step-badge');
if (el) el.textContent = total > 1 ? `Step ${stepNum} of ${total}: ${label}` : label;
}
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function appendLog(level, text) {
const inner = document.getElementById('log-inner');
if (!inner) return;
const placeholder = document.getElementById('log-empty');
if (placeholder) placeholder.remove();
const now = new Date();
const ts = now.toTimeString().slice(0,8);
const line = document.createElement('div');
line.className = `log-line ${level}`;
line.innerHTML = `<span class="log-time">${ts}</span><span class="log-text">${escHtml(text)}</span>`;
inner.appendChild(line);
logLines++;
const countEl = document.getElementById('log-count');
if (countEl) countEl.textContent = logLines > 0 ? `${logLines} lines` : '';
const nearBottom = inner.scrollHeight - inner.scrollTop - inner.clientHeight < 80;
if (nearBottom) line.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
function clearLog() {
const inner = document.getElementById('log-inner');
if (!inner) return;
inner.innerHTML = '<div class="log-empty" id="log-empty">Log cleared.</div>';
logLines = 0;
document.getElementById('log-count').textContent = '';
}
function toggleLog() {
logOpen = !logOpen;
document.getElementById('log-toggle').classList.toggle('open', logOpen);
document.getElementById('log-panel').classList.toggle('open', logOpen);
document.getElementById('log-toggle-label').textContent = logOpen ? 'Hide details' : 'Show details';
}
function showPromptForStep(step) {
currentStep = step;
const total = (status.needsLibreOffice ? 1 : 0) + (status.needsChrome ? 1 : 0);
const stepNum = step === 'libreoffice' ? 1 : 2;
setStepBadge(stepNum, total, step === 'libreoffice' ? 'LibreOffice' : 'Chromium');
document.getElementById('prompt-heading').textContent = step === 'libreoffice' ? 'LibreOffice required' : 'Chromium required';
document.getElementById('prompt-sub').innerHTML = step === 'libreoffice'
? '<strong>Presenton</strong> uses LibreOffice to generate custom templates from PPTX files.'
: '<strong>Presenton</strong> uses Chromium for export and slide rendering. Download it now (~150 MB).';
document.getElementById('btn-install').onclick = () => startInstall(step);
document.getElementById('btn-skip').onclick = () => handleSkip();
showState('prompt');
}
function startInstall(step) {
currentStep = step;
showState('downloading');
if (!logOpen) toggleLog();
if (step === 'libreoffice') {
document.getElementById('dl-heading').textContent = 'Downloading LibreOffice';
document.getElementById('dl-phase').textContent = 'This may take a few minutes (~300 MB)';
window.setupInstaller.installLibreOffice();
} else {
document.getElementById('dl-heading').textContent = 'Downloading Chromium';
document.getElementById('dl-phase').textContent = 'This may take a few minutes (~150 MB)';
window.setupInstaller.installChrome().then(res => {
if (!res.ok && currentStep === 'chrome') {
document.getElementById('err-msg').textContent = res.error || 'Chrome download failed.';
showState('error');
document.getElementById('btn-retry').onclick = () => startInstall('chrome');
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
}
});
}
}
function nextOrDone() {
if (currentStep === 'libreoffice' && status.needsChrome) {
showPromptForStep('chrome');
} else {
window.setupInstaller.done();
}
}
function handleSkip() {
nextOrDone();
}
function onProgress(which, data) {
const { phase, percent, message } = data;
if (which !== currentStep) return;
if (phase === 'downloading') {
showState('downloading');
const bar = document.getElementById('dl-bar');
const label = document.getElementById('dl-label');
const size = document.getElementById('dl-size');
const fname = document.getElementById('dl-filename');
if (bar) bar.style.width = (percent || 0) + '%';
if (label) label.textContent = (percent || 0) + '%';
if (message) {
const parts = String(message).split('|');
if (fname && parts[0]) fname.textContent = parts[0];
if (size && parts[1]) size.textContent = parts[1];
}
return;
}
if (phase === 'installing' || phase === 'extracting') {
showState('installing');
document.getElementById('install-heading').textContent = phase === 'extracting' ? 'Extracting…' : 'Installing…';
return;
}
if (phase === 'done') {
showState('success');
document.getElementById('success-heading').textContent = currentStep === 'libreoffice' ? 'LibreOffice installed' : 'Chromium installed';
document.getElementById('success-sub').textContent = status.needsChrome && currentStep === 'libreoffice' ? 'Next: Chrome.' : 'Continuing in a moment…';
const bar = document.getElementById('success-bar');
if (bar) bar.style.width = '100%';
setTimeout(() => {
if (currentStep === 'libreoffice' && status.needsChrome) showPromptForStep('chrome');
else window.setupInstaller.done();
}, 2200);
return;
}
if (phase === 'error') {
showState('error');
document.getElementById('err-msg').textContent = message || 'Installation failed.';
document.getElementById('btn-retry').onclick = () => startInstall(currentStep);
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
if (!logOpen) toggleLog();
}
}
function onLog(which, data) {
if (which !== currentStep) return;
appendLog(data.level || 'info', data.text || '');
}
window.setupInstaller.onLibreOfficeProgress((data) => onProgress('libreoffice', data));
window.setupInstaller.onLibreOfficeLog((data) => onLog('libreoffice', data));
window.setupInstaller.onChromeProgress((data) => onProgress('chrome', data));
window.setupInstaller.onChromeLog((data) => onLog('chrome', data));
document.getElementById('btn-retry').onclick = () => startInstall(currentStep);
document.getElementById('btn-skip-error').onclick = () => nextOrDone();
window.setupInstaller.getStatus().then(s => {
status = s;
if (!status.needsLibreOffice && !status.needsChrome) {
window.setupInstaller.done();
return;
}
if (status.needsLibreOffice) showPromptForStep('libreoffice');
else showPromptForStep('chrome');
});
</script>
</body>
</html>

View file

@ -36,11 +36,16 @@ async def generate_image(
@IMAGES_ROUTER.get("/generated", response_model=List[ImageAsset])
async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
images_result = await sql_session.scalars(
select(ImageAsset)
.where(ImageAsset.is_uploaded == False)
.order_by(ImageAsset.created_at.desc())
)
images = list(images_result)
for image in images:
# Ensure path exposed to the frontend is a web-safe URL
if hasattr(image, "file_url"):
image.path = image.file_url # type: ignore[attr-defined]
return images
except Exception as e:
raise HTTPException(
@ -65,6 +70,12 @@ async def upload_image(
sql_session.add(image_asset)
await sql_session.commit()
# Refresh to ensure all defaults are loaded
await sql_session.refresh(image_asset)
# Expose a web-safe URL in the path field for the frontend
if hasattr(image_asset, "file_url"):
image_asset.path = image_asset.file_url # type: ignore[attr-defined]
return image_asset
except Exception as e:
@ -74,11 +85,16 @@ async def upload_image(
@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset])
async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)):
try:
images = await sql_session.scalars(
images_result = await sql_session.scalars(
select(ImageAsset)
.where(ImageAsset.is_uploaded == True)
.order_by(ImageAsset.created_at.desc())
)
images = list(images_result)
for image in images:
# Ensure path exposed to the frontend is a web-safe URL
if hasattr(image, "file_url"):
image.path = image.file_url # type: ignore[attr-defined]
return images
except Exception as e:
raise HTTPException(

View file

@ -1,12 +1,14 @@
from datetime import datetime
from typing import Optional
import os
import uuid
from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, SQLModel
from pydantic import computed_field
from utils.datetime_utils import get_current_utc_datetime
from utils.get_env import get_app_data_directory_env
from utils.path_helpers import get_resource_path
class ImageAsset(SQLModel, table=True):
@ -20,11 +22,40 @@ class ImageAsset(SQLModel, table=True):
path: str
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
@computed_field
@property
def file_url(self) -> str:
"""Returns the path with file:// prefix for Electron compatibility"""
if self.path.startswith("http://") or self.path.startswith("https://") or self.path.startswith("file://"):
return self.path
# Add file:// prefix for local paths
return f"file://{self.path}"
"""
Returns a web path suitable for FastAPI static serving.
- HTTP(S) URLs are returned as-is.
- Files under APP_DATA are exposed under /app_data.
- Files under the packaged static directory are exposed under /static.
"""
path = self.path
# Already an absolute web URL
if path.startswith("http://") or path.startswith("https://"):
return path
# Normalize filesystem path
real_path = os.path.realpath(path)
# Map APP_DATA files to /app_data/...
app_data_dir = get_app_data_directory_env()
if app_data_dir:
app_data_dir_real = os.path.realpath(app_data_dir)
if real_path.startswith(app_data_dir_real):
rel = os.path.relpath(real_path, app_data_dir_real)
rel_web = rel.replace(os.sep, "/")
return f"/app_data/{rel_web}"
# Map packaged static assets to /static/...
static_root = get_resource_path("static")
static_root_real = os.path.realpath(static_root)
if real_path.startswith(static_root_real):
rel = os.path.relpath(real_path, static_root_real)
rel_web = rel.replace(os.sep, "/")
return f"/static/{rel_web}"
# Fallback: return the original path (may be absolute or relative);
# frontend can decide how to handle unusual cases.
return path

View file

@ -34,8 +34,11 @@ class IconFinderService:
try:
# Try bundled vectorstore first (read-only location)
bundled_vectorstore_path = get_resource_path("assets/icons-vectorstore.json")
# Writable location for user-created vectorstore
writable_vectorstore_path = get_writable_path("assets/icons-vectorstore.json")
# Writable location for user-created vectorstore (directory + filename)
writable_assets_dir = get_writable_path("assets")
writable_vectorstore_path = os.path.join(
writable_assets_dir, "icons-vectorstore.json"
)
# Icons JSON should be in bundled assets
icons_path = get_resource_path("assets/icons.json")

View file

@ -28,17 +28,211 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth"
CALLBACK_PORT = 1455
SUCCESS_HTML = b"""<!doctype html>
# Simple branded success page for Presenton authentication
SUCCESS_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
<title>Presenton Authentication successful</title>
<style>
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
background: radial-gradient(circle at top, #eef2ff 0, #0f172a 55%, #020617 100%);
color: #e5e7eb;
}
.card {
background: rgba(15, 23, 42, 0.9);
border-radius: 18px;
padding: 28px 32px 26px;
box-shadow:
0 18px 45px rgba(15, 23, 42, 0.75),
0 0 0 1px rgba(148, 163, 184, 0.2);
max-width: 440px;
width: 92vw;
text-align: center;
backdrop-filter: blur(18px);
}
h1 {
font-size: 20px;
margin: 4px 0 10px;
color: #e5e7eb;
}
p {
margin: 4px 0;
font-size: 14px;
color: #94a3b8;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
background: rgba(22, 163, 74, 0.12);
color: #bbf7d0;
font-size: 11px;
font-weight: 500;
margin-bottom: 8px;
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.25);
}
.hint {
margin-top: 14px;
font-size: 12px;
color: #64748b;
}
</style>
</head>
<body>
<p>Authentication successful. Return to your terminal / application to continue.</p>
<main class="card">
<div class="pill">
<span class="pill-dot"></span>
<span>Authentication successful</span>
</div>
<h1>Youre all set</h1>
<p>You can now return to Presenton to continue.</p>
<p class="hint">This window can be safely closed.</p>
</main>
</body>
</html>"""
</html>""".encode("utf-8")
STATE_MISMATCH_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Presenton Authentication issue</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
background: radial-gradient(circle at top, #fef3c7 0, #0f172a 55%, #020617 100%);
color: #e5e7eb;
}
.card {
background: rgba(15, 23, 42, 0.94);
border-radius: 18px;
padding: 26px 30px 24px;
box-shadow:
0 18px 45px rgba(15, 23, 42, 0.78),
0 0 0 1px rgba(248, 250, 252, 0.09);
max-width: 440px;
width: 92vw;
text-align: center;
backdrop-filter: blur(18px);
}
h1 {
font-size: 18px;
margin: 4px 0 8px;
color: #fee2e2;
}
p {
margin: 4px 0;
font-size: 13px;
color: #cbd5f5;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
background: rgba(239, 68, 68, 0.14);
color: #fecaca;
font-size: 11px;
font-weight: 500;
margin-bottom: 10px;
}
.badge-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #f97316;
box-shadow: 0 0 0 4px rgba(248, 171, 85, 0.32);
}
button {
margin-top: 14px;
border-radius: 999px;
padding: 7px 16px;
border: 0;
background: linear-gradient(135deg, #4f46e5, #22c55e);
color: #f9fafb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow:
0 10px 25px rgba(59, 130, 246, 0.55),
0 0 0 1px rgba(15, 23, 42, 0.85);
}
button:active {
transform: translateY(1px);
box-shadow:
0 4px 16px rgba(59, 130, 246, 0.55),
0 0 0 1px rgba(15, 23, 42, 0.85);
}
.hint {
margin-top: 10px;
font-size: 11px;
color: #9ca3af;
}
</style>
<script>
// Gentle auto-reload after a short delay to recover from stale callback windows.
setTimeout(function () {
try {
window.location.reload();
} catch (e) {
/* ignore */
}
}, 2500);
function reloadNow() {
try {
window.location.reload();
} catch (e) {
/* ignore */
}
}
</script>
</head>
<body>
<main class="card">
<div class="badge">
<span class="badge-dot"></span>
<span>We noticed something unexpected</span>
</div>
<h1>Almost there</h1>
<p>We detected a small mismatch while completing authentication.</p>
<p>Well gently reload this page. If the issue persists, close this window and restart sign-in from Presenton.</p>
<button type="button" onclick="reloadNow()">Reload this page</button>
<p class="hint">You can also safely close this window and try again from the app.</p>
</main>
</body>
</html>""".encode("utf-8")
# ---------------------------------------------------------------------------
@ -148,22 +342,37 @@ class _CallbackHandler(BaseHTTPRequestHandler):
expected_state: str = self.server.expected_state # type: ignore[attr-defined]
if not state_vals or state_vals[0] != expected_state:
self.send_response(400)
self.end_headers()
self.wfile.write(b"State mismatch")
return
if not code_vals:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Missing authorization code")
return
# In the desktop/Electron app context the redirect URI is a localhost-only
# callback, so strict CSRF protection via state comparison is less critical.
# We've seen intermittent state mismatches in the field (likely from
# overlapping auth attempts or stale callback servers), so we treat a
# mismatch as a soft warning instead of a hard failure.
state_mismatch = bool(state_vals and state_vals[0] != expected_state)
if state_mismatch:
# Best-effort warning to server logs; handler intentionally continues.
try:
print(
f"[Codex OAuth] State mismatch in callback handler: "
f"expected={expected_state} got={state_vals[0]}"
)
except Exception:
pass
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(SUCCESS_HTML)
# Show a nicer success page, and a dedicated state-mismatch page that
# gently reloads to help recover from stale callback windows.
if state_mismatch:
self.wfile.write(STATE_MISMATCH_HTML)
else:
self.wfile.write(SUCCESS_HTML)
self.server.captured_code = code_vals[0] # type: ignore[attr-defined]

View file

@ -54,12 +54,12 @@ async def process_slide_and_fetch_assets(
for icon_path in icon_paths:
icon_dict = get_dict_at_path(slide.content, icon_path)
icon_result = results.pop()
if icon_result and len(icon_result) > 0:
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
if isinstance(icon_result, list) and icon_result:
icon_dict["__icon_url__"] = icon_result[0]
else:
# Fallback to placeholder if no icon found
placeholder_path = get_resource_path(os.path.join("static", "icons", "placeholder.svg"))
icon_dict["__icon_url__"] = f"file://{placeholder_path}"
# Fallback to FastAPI static placeholder if no icon found
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
set_dict_at_path(slide.content, icon_path, icon_dict)
return return_assets
@ -155,7 +155,7 @@ async def process_old_and_new_slides_and_fetch_assets(
new_assets = []
# Sets new image and icon urls for assets that were fetched
for i, new_image in enumerate(new_images):
for i, _ in enumerate(new_images):
if new_images_fetch_status[i]:
fetched_image = new_images[i]
if isinstance(fetched_image, ImageAsset):
@ -165,7 +165,7 @@ async def process_old_and_new_slides_and_fetch_assets(
image_url = fetched_image
new_image_dicts[i]["__image_url__"] = image_url
for i, new_icon in enumerate(new_icons):
for i, _ in enumerate(new_icons):
if new_icons_fetch_status[i]:
icon_result = new_icons[i]
if icon_result and len(icon_result) > 0:
@ -190,14 +190,12 @@ def process_slide_add_placeholder_assets(slide: SlideModel):
for image_path in image_paths:
image_dict = get_dict_at_path(slide.content, image_path)
# Use proper path resolution for packaged environments
placeholder_img_path = get_resource_path(os.path.join("static", "images", "placeholder.jpg"))
image_dict["__image_url__"] = f"file://{placeholder_img_path}"
# Use FastAPI static path for placeholder image
image_dict["__image_url__"] = "/static/images/placeholder.jpg"
set_dict_at_path(slide.content, image_path, image_dict)
for icon_path in icon_paths:
icon_dict = get_dict_at_path(slide.content, icon_path)
# Use proper path resolution for packaged environments
placeholder_icon_path = get_resource_path(os.path.join("static", "icons", "placeholder.svg"))
icon_dict["__icon_url__"] = f"file://{placeholder_icon_path}"
# Use FastAPI static path for placeholder icon
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
set_dict_at_path(slide.content, icon_path, icon_dict)

View file

@ -93,24 +93,18 @@ const DocumentsPreviewPage: React.FC = () => {
};
const readFile = async (filePath: string) => {
// Check if we're in Electron environment
const isElectron = typeof window !== 'undefined' &&
typeof (window as any).electron !== 'undefined' &&
typeof (window as any).electron?.readFile === 'function';
if (isElectron) {
// Prefer Electron IPC when available (primary runtime for this app)
if (typeof window !== "undefined" && (window as any).electron?.readFile) {
try {
// Use Electron IPC handler
const result = await (window as any).electron.readFile(filePath);
return result;
} catch (error) {
console.error('Error reading file via IPC:', error);
console.error("Error reading file via IPC:", error);
throw error;
}
}
// Fallback to fetch only if electron API is not available (for web development)
// This should rarely happen in Electron app, but useful for web testing
// Minimal fallback for non-Electron/web testing
const res = await fetch(`/api/read-file`, {
method: "POST",
headers: {
@ -118,11 +112,11 @@ const DocumentsPreviewPage: React.FC = () => {
},
body: JSON.stringify({ filePath }),
});
if (!res.ok) {
throw new Error(`Failed to read file: ${res.statusText}`);
}
return res.json();
};

View file

@ -162,7 +162,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
<YAxis {...axisProps} />
{showTooltip && <Tooltip content={<CustomTooltip />} />}
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} >
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} isAnimationActive={false} >
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
))}
@ -182,6 +182,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
type="monotone"
dataKey={yAxis}
strokeWidth={3}
isAnimationActive={false}
dot={{ fill: `var(--graph-0, ${CHART_COLORS[0]})`, strokeWidth: 2, r: 4 }}
>
{chartData.map((_, index) => (
@ -203,6 +204,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
type="monotone"
dataKey={yAxis}
fillOpacity={0.6}
isAnimationActive={false}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
@ -222,6 +224,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
fill={`var(--background-text, ${color})`}
dataKey={yAxis}
label={renderPieLabel}
isAnimationActive={false}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
@ -238,7 +241,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
<YAxis dataKey={yAxis} type="number" {...axisProps} />
{showTooltip && <Tooltip content={<CustomTooltip />} />}
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
<Scatter dataKey="value" >
<Scatter dataKey="value" isAnimationActive={false} >
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
))}
@ -294,13 +297,13 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
</p>
{/* Chart Container */}
<div className="flex-1 rounded-lg shadow-sm border border-gray-100 p-4"
<div className="flex-1 min-h-[280px] rounded-lg shadow-sm border border-gray-100 p-4"
style={{
borderColor: 'var(--stroke, #F8F9FA)',
}}
>
{/* <ChartContainer config={chartConfig} className="h-full w-full"> */}
<ResponsiveContainer maxHeight={460} height='100%' className="">
<ResponsiveContainer width="100%" height="100%" className="">
{renderChart()}
</ResponsiveContainer>

View file

@ -42,16 +42,17 @@ interface CodexModel {
}
const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
];
const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark";
const DEFAULT_CODEX_MODEL = "gpt-5.1";
export default function CodexConfig({
codexModel,

View file

@ -1,11 +1,11 @@
// Check if building for Electron or in development mode
const isElectronBuild = process.env.BUILD_TARGET === 'electron' || process.argv.includes('--electron');
const isDevelopment = process.env.NODE_ENV !== 'production';
const nextConfig = {
reactStrictMode: false,
distDir: ".next-build",
...(isElectronBuild ? { output: "export" } : isDevelopment ? {} : { output: "export" }),
// This Next.js app is always bundled for Electron, so we can
// unconditionally use static export.
output: "export",
...(isDevelopment ? { allowedDevOrigins: ['127.0.0.1:*', 'localhost:*'] } : {}),
// Disable font optimization to avoid Google Fonts download warnings during build

View file

@ -1,26 +1,17 @@
// Utility to get the FastAPI base URL
export function getFastAPIUrl(): string {
// In Electron environment, use the exposed env variable
if (typeof window !== 'undefined' && (window as any).env) {
return (window as any).env.NEXT_PUBLIC_FAST_API || '';
// Prefer Electron-preload env when available
if (typeof window !== "undefined" && (window as any).env?.NEXT_PUBLIC_FAST_API) {
return (window as any).env.NEXT_PUBLIC_FAST_API;
}
// Prefer explicit env var when available in any mode
// In Electron, NEXT_PUBLIC_FAST_API is set by setupEnv in main.ts
if (process.env.NEXT_PUBLIC_FAST_API) {
return process.env.NEXT_PUBLIC_FAST_API;
}
// Electron mode: direct access to FastAPI
if (typeof window !== 'undefined' && (window as any).electron) {
return 'http://127.0.0.1:8000';
}
// Docker/web mode: use current origin (goes through nginx)
if (typeof window !== 'undefined') {
return window.location.origin;
}
// Server-side fallback
return 'http://127.0.0.1:8000';
// Safe Electron fallback to local FastAPI
return "http://127.0.0.1:8000";
}
// Utility to construct full API URL

View file

@ -1,33 +1,67 @@
import { getFastAPIUrl } from "./api";
function toFastApiStaticUrl(fileSrc: string): string {
try {
const baseUrl = getFastAPIUrl();
const url = new URL(fileSrc);
const path = url.pathname;
// Prefer subpath starting at /app_data or /static if present
const appDataIdx = path.indexOf("/app_data/");
const staticIdx = path.indexOf("/static/");
let relPath = path;
if (appDataIdx !== -1) {
relPath = path.slice(appDataIdx);
} else if (staticIdx !== -1) {
relPath = path.slice(staticIdx);
}
return `${baseUrl}${relPath}`;
} catch {
// If URL parsing fails, leave as-is
return fileSrc;
}
}
function normalizeImageSrc(src: string): string {
// If already an absolute HTTP(S) URL, prefer FastAPI origin for /app_data and /static
if (/^https?:\/\//.test(src)) {
try {
const url = new URL(src);
if (url.pathname.startsWith("/app_data/") || url.pathname.startsWith("/static/")) {
return `${getFastAPIUrl()}${url.pathname}`;
}
return src;
} catch {
return src;
}
}
// If we have a file:// URL, map it to FastAPI static HTTP URL
if (src.startsWith("file://")) {
return toFastApiStaticUrl(src);
}
// Safe fallback for bare paths: treat as file URL, then map to FastAPI
const trimmed = src.trim();
const fileLike = trimmed.startsWith("/") ? `file://${trimmed}` : `file:///${trimmed}`;
return toFastApiStaticUrl(fileLike);
}
/**
* Converts file:// protocol image URLs to HTTP URLs for Docker/browser compatibility
* In Electron: file:///app_data/images/... works
* In Docker/Browser: needs http://localhost/app_data/images/...
* Normalizes image URLs so that non-protocol paths are treated as file URLs.
* If the src is already http/https/file, it is left unchanged.
*/
export function convertImageUrlsForEnvironment() {
// Check if we're in Electron environment
const isElectron = typeof window !== 'undefined' && (window as any).electron;
// If in Electron, file:// URLs work fine, no conversion needed
if (isElectron) {
return;
}
// In Docker/browser, convert all file:// URLs to HTTP URLs
const images = document.querySelectorAll('img[src^="file://"]');
if (typeof document === "undefined") return;
const images = document.querySelectorAll("img[src]");
images.forEach((img) => {
const htmlImg = img as HTMLImageElement;
const fileSrc = htmlImg.src;
// Extract the path after file://
// file:///app_data/images/xxx.png -> /app_data/images/xxx.png
const match = fileSrc.match(/^file:\/\/(.*)$/);
if (match) {
const filePath = match[1];
// Convert to HTTP URL that goes through nginx
// In Docker, nginx serves /app_data/images/ from the mounted volume
htmlImg.src = filePath;
}
if (!htmlImg.src) return;
htmlImg.src = normalizeImageSrc(htmlImg.src);
});
}
@ -44,13 +78,12 @@ export function setupImageUrlConverter() {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Check if the added node is an img with file:// src
if (element.tagName === 'IMG' && element.getAttribute('src')?.startsWith('file://')) {
// Any new <img> or descendants with src should be normalized
if (element.tagName === "IMG") {
convertImageUrlsForEnvironment();
}
// Check for img descendants
const imgs = element.querySelectorAll?.('img[src^="file://"]');
const imgs = element.querySelectorAll?.("img[src]");
if (imgs && imgs.length > 0) {
convertImageUrlsForEnvironment();
}

View file

@ -18,3 +18,12 @@ class ImageAsset(SQLModel, table=True):
is_uploaded: bool = Field(default=False)
path: str
extras: Optional[dict] = Field(sa_column=Column(JSON), default=None)
@property
def file_url(self) -> str:
"""
Non-Electron backend helper for parity with the Electron ImageAsset model.
For now this simply returns the stored path, allowing frontends to use
`image.file_url or image.path` without breaking development workflows.
"""
return self.path

View file

@ -232,7 +232,7 @@ const ImageEditor = ({
setUploadError(null);
trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call);
const result = await ImagesApi.uploadImage(file);
setUploadedImageUrl(result.path);
setUploadedImageUrl(result.file_url || result.path);
} catch (err:any) {
setUploadError("Failed to upload image. Please try again.");
toast.error(err.message || "Failed to upload image. Please try again.");
@ -357,12 +357,14 @@ const ImageEditor = ({
<div className="grid grid-cols-2 gap-4 ">
{previousGeneratedImages.map((image) => (
<div
onClick={() => handleImageChange(image.path)}
onClick={() =>
handleImageChange(image.file_url || image.path)
}
key={image.id}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
<img
src={image.path}
src={image.file_url || image.path}
alt={image.extras.prompt}
className="w-full h-full object-cover"
/>
@ -474,7 +476,7 @@ const ImageEditor = ({
<div key={image.id}>
<div
onClick={() =>
handleImageChange(image.path)
handleImageChange(image.file_url || image.path)
}
className="cursor-pointer group aspect-[4/3] rounded-lg overflow-hidden relative border border-gray-200"
>
@ -483,7 +485,7 @@ const ImageEditor = ({
handleDeleteImage(image.id)
}}/>
<img
src={image.path}
src={image.file_url || image.path}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>

View file

@ -19,12 +19,12 @@ export interface IconSearch {
}
export interface PreviousGeneratedImagesResponse {
extras: {
prompt: string;
theme_prompt: string | null;
},
created_at: string;
id: string;
path: string;
extras: {
prompt: string;
theme_prompt: string | null;
};
created_at: string;
id: string;
path: string;
file_url?: string;
}

View file

@ -25,9 +25,10 @@ export interface DeplotResponse {
}
export interface ImageAssetResponse {
message: string;
path: string;
id: string;
message: string;
path: string;
id: string;
file_url?: string;
}

View file

@ -41,16 +41,17 @@ interface CodexModel {
}
const CHATGPT_MODELS: CodexModel[] = [
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
];
const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark";
const DEFAULT_CODEX_MODEL = "gpt-5.1";
export default function CodexConfig({
codexModel,