chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:41 +08:00
commit bcc7968ff6
9988 changed files with 1016778 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
View File
+25
View File
@@ -0,0 +1,25 @@
spec:
name: presenton
region: nyc
services:
- name: presenton
image:
registry_type: GHCR
registry: ghcr.io
repository: presenton/presenton
tag: latest
http_port: 80
instance_count: 1
instance_size_slug: apps-s-1vcpu-2gb
envs:
- key: MIGRATE_DATABASE_ON_STARTUP
value: "true"
scope: RUN_TIME
type: GENERAL
- key: START_OLLAMA
value: "false"
scope: RUN_TIME
type: GENERAL
alerts:
- rule: DEPLOYMENT_FAILED
- rule: DOMAIN_FAILED
+44
View File
@@ -0,0 +1,44 @@
.git
.gitignore
node_modules
**/node_modules
**/.next
**/.next-build
**/.venv
**/.pytest_cache
**/__pycache__
**/*.pyc
**/debug
**/fastembed_cache
**/chroma
.cache
app_data
presentation-export
servers/fastapi/tests
servers/fastapi/presenton_backend.egg-info
servers/fastapi/.python-version
servers/fastapi/placeholder
servers/nextjs/cypress
servers/nextjs/cypress.config.ts
servers/nextjs/README.md
servers/nextjs/tsconfig.tsbuildinfo
**/*.cy.ts
**/*.cy.tsx
# Keep only the LiteParse runner from the Electron tree.
electron/*
!electron/resources
electron/resources/*
!electron/resources/document-extraction
electron/resources/document-extraction/*
!electron/resources/document-extraction/liteparse_runner.mjs
servers/fastapi/tmp
servers/fastapi/debug
servers/fastapi/.venv
servers/nextjs/node_modules
servers/nextjs/.next
container.db
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [presenton] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+141
View File
@@ -0,0 +1,141 @@
name: Bug report
description: Report crashes, regressions, or incorrect behavior in Presenton.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug in **Presenton** 🚀
Please provide clear steps so we can reproduce and fix the issue quickly.
- type: dropdown
id: bug_type
attributes:
label: Bug type
description: Select the category that best matches the issue.
options:
- Regression (worked before, now fails)
- Crash (app exits or freezes)
- UI bug
- Generation bug (slides/content incorrect)
- Export bug (PPTX/PDF issues)
validations:
required: true
- type: textarea
id: summary
attributes:
label: Summary
description: One sentence describing the issue.
placeholder: Exporting a generated presentation as PPTX fails after slide generation.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Provide the shortest reproducible steps.
placeholder: |
1. Start Presenton Electron app
2. Generate presentation with 5 slides
3. Click "Export PPTX"
4. Error occurs
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should happen.
placeholder: The presentation should export successfully as a PPTX file.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead.
placeholder: Export fails and the app shows an error message.
validations:
required: true
- type: input
id: version
attributes:
label: Presenton version
description: Version of Presenton you are using.
placeholder: 0.6.2-beta
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
description: OS and version.
placeholder: macOS 14 / Ubuntu 24.04 / Windows 11
validations:
required: true
- type: dropdown
id: run_method
attributes:
label: How are you running Presenton?
options:
- Electron Desktop App
- Docker
- Local Development
- API Only
validations:
required: true
- type: dropdown
id: llm_provider
attributes:
label: LLM provider
description: AI model provider used for generation.
options:
- OpenAI
- Google Gemini
- Anthropic
- Ollama
- Custom OpenAI-compatible API
- Not relevant
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: Include logs, console output, or screenshots.
render: shell
- type: textarea
id: impact
attributes:
label: Impact
description: |
Explain how this bug affects usage.
Include:
- Who is affected
- Severity
- Frequency
- Consequence
placeholder: |
Affected: Users generating AI presentations
Severity: High
Frequency: Always
Consequence: Presentations cannot be exported.
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Any extra context that might help debug the issue.
placeholder: Logs from Electron console, template used, or related issues.
@@ -0,0 +1,88 @@
name: Feature request
description: Suggest a new capability or improvement for Presenton.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement to **Presenton** 🚀
Please describe the feature clearly so we can understand the problem and evaluate the idea.
- type: textarea
id: summary
attributes:
label: Summary
description: One-line description of the feature request.
placeholder: Add support for exporting presentations as Google Slides.
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem are you trying to solve? Why is the current behavior insufficient?
placeholder: |
Currently Presenton exports presentations only as PPTX or PDF.
Our team collaborates primarily in Google Slides, so we must manually convert files.
validations:
required: true
- type: textarea
id: proposed_solution
attributes:
label: Proposed solution
description: Describe the feature you'd like to see and how it should work.
placeholder: |
Add a new export option for Google Slides.
When generating a presentation, users could select:
- PPTX
- PDF
- Google Slides
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Have you considered other solutions or workarounds?
placeholder: |
Currently we export PPTX and manually upload to Google Slides, which adds extra steps.
- type: textarea
id: use_case
attributes:
label: Use case
description: Describe how this feature would be used in real workflows.
placeholder: |
Teams generating AI presentations for meetings could directly open them in Google Slides
for collaborative editing.
- type: textarea
id: impact
attributes:
label: Impact
description: |
Explain the importance of this feature.
Include:
- Who would benefit from it
- Severity of the current limitation
- Frequency of the issue
- Workflow impact
placeholder: |
Affected users: Teams using Presenton for collaborative presentations
Severity: Medium
Frequency: Frequent
Impact: Saves time and removes manual conversion steps.
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Any extra context, screenshots, references, or examples.
placeholder: Links to similar features in Gamma, Beautiful AI, or other presentation tools.
+63
View File
@@ -0,0 +1,63 @@
# GitHub Actions workflows
## Test All Applications (`test-all.yml`)
The test workflow runs on pushes and pull requests to `main`, and can also be
started manually. It enforces the checks that exist in the current repository:
- repository tooling: template-converter tests and bundled export verification;
- FastAPI: every pytest test using the Python version and locked dependencies
declared in `servers/fastapi`;
- Next.js: all Node.js unit tests, ESLint, a production build, and Cypress
component tests.
No test step is allowed to fail silently.
## Run the CI checks locally
Install Node.js 20+, npm, Python 3.11, and `uv`, then run:
```bash
./test-local.sh
```
The script installs locked dependencies and runs the same commands as the
GitHub Actions workflow.
## Run one test group
### FastAPI
```bash
cd servers/fastapi
uv sync --locked --dev
mkdir -p /tmp/presenton-tests/app-data /tmp/presenton-tests/temp
APP_DATA_DIRECTORY=/tmp/presenton-tests/app-data \
TEMP_DIRECTORY=/tmp/presenton-tests/temp \
DATABASE_URL=sqlite+aiosqlite:////tmp/presenton-tests/test.db \
DISABLE_ANONYMOUS_TRACKING=true \
DISABLE_IMAGE_GENERATION=true \
uv run --locked python -m pytest --verbose --tb=short
```
### Next.js
```bash
cd servers/nextjs
npm ci
npm test
npm run lint
NEXT_PUBLIC_FAST_API=http://localhost:8000 \
NEXT_PUBLIC_URL=http://localhost:3000 \
npm run build
npx cypress run --component --browser electron
```
### Repository tooling
```bash
npm ci
npm test
npm run sync:presentation-export
npm run check:presentation-export
```
+77
View File
@@ -0,0 +1,77 @@
name: Docker Release
on:
workflow_dispatch:
release:
types: [published]
permissions:
contents: read
packages: write
concurrency:
group: docker-release-${{ github.event.release.tag_name || github.ref_name || github.run_id }}
cancel-in-progress: false
env:
IMAGE_NAME: ghcr.io/presenton/presenton
MOVING_TAG: dev
jobs:
build-and-push:
name: Build and push multi-architecture image
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare Docker tags
id: docker-tags
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
IMAGE_TAG="${{ github.event.release.tag_name }}"
else
IMAGE_TAG="${MOVING_TAG}"
fi
{
echo "tags<<EOF"
echo "${IMAGE_NAME}:${IMAGE_TAG}"
echo "EOF"
echo "verify_tag=${IMAGE_TAG}"
} >> "${GITHUB_OUTPUT}"
- name: Build and push multi-architecture image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker-tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify image
env:
VERIFY_TAG: ${{ steps.docker-tags.outputs.verify_tag }}
run: |
docker buildx imagetools inspect "${IMAGE_NAME}:${VERIFY_TAG}" | tee image-inspect.txt
grep -q "linux/amd64" image-inspect.txt
grep -q "linux/arm64" image-inspect.txt
+53
View File
@@ -0,0 +1,53 @@
name: Upload Release to R2
on:
release:
types: [published]
jobs:
upload:
runs-on: ubuntu-latest
environment: sync_r2
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir assets
gh release download "${{ github.event.release.tag_name }}" --dir assets --pattern '*.deb' --pattern '*.dmg' --pattern '*.exe'
echo "Downloaded files:"
ls -lh assets/
- name: Extract version from filename
run: |
FILE=$(ls assets/ | head -n 1)
VERSION=$(echo "$FILE" | sed -E 's/^Presenton-(.+)\.(deb|dmg|exe)$/\1/')
echo "Extracted version: $VERSION"
echo "VERSION=$VERSION" >> $GITHUB_ENV
# 4. Install rclone
- name: Install rclone
run: |
curl https://rclone.org/install.sh | sudo bash
# 5. Configure R2
- name: Configure R2
run: |
mkdir -p ~/.config/rclone
cat <<EOF > ~/.config/rclone/rclone.conf
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.R2_ACCESS_KEY }}
secret_access_key = ${{ secrets.R2_SECRET_KEY }}
endpoint = https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
# 6. Upload to R2 (FINAL STEP)
- name: Upload files to R2
run: |
rclone copy assets r2:presenton-desktop/${{ env.VERSION }} --progress --transfers=8
+111
View File
@@ -0,0 +1,111 @@
name: Test All Applications
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
test-repository-tools:
name: Test repository tooling
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install dependencies
run: npm ci
- name: Run repository tests
run: npm test
- name: Install presentation export runtime
run: npm run sync:presentation-export
- name: Verify presentation export runtime
run: npm run check:presentation-export
test-fastapi:
name: Test FastAPI
runs-on: ubuntu-latest
defaults:
run:
working-directory: servers/fastapi
env:
APP_DATA_DIRECTORY: /tmp/presenton-app-data
TEMP_DIRECTORY: /tmp/presenton-temp
DATABASE_URL: sqlite+aiosqlite:////tmp/presenton-test.db
DISABLE_ANONYMOUS_TRACKING: "true"
DISABLE_IMAGE_GENERATION: "true"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: servers/fastapi/uv.lock
- name: Install locked dependencies
run: uv sync --locked --dev
- name: Create test directories
run: mkdir -p "$APP_DATA_DIRECTORY" "$TEMP_DIRECTORY"
- name: Run all pytest tests
run: uv run --locked python -m pytest --verbose --tb=short
test-nextjs:
name: Test Next.js
runs-on: ubuntu-latest
defaults:
run:
working-directory: servers/nextjs
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: servers/nextjs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run Node.js tests
run: npm test
- name: Run ESLint
run: npm run lint
- name: Build application
env:
NEXT_PUBLIC_FAST_API: http://localhost:8000
NEXT_PUBLIC_URL: http://localhost:3000
run: npm run build
- name: Run Cypress component tests
run: npx cypress run --component --browser electron
+34
View File
@@ -0,0 +1,34 @@
.env
.env.*
*.env
.venv
__pycache__
.pytest_cache
.next
node_modules
out
user_data
app_data
tmp
debug
.fastembed_cache
generated_models
nltk
chroma
container.db
.next-build
.cursor
.agents
skills-lock.json
.codex/
eng.traineddata
# presenton-export runtime (downloaded via scripts/sync-presentation-export.cjs or Docker build)
presentation-export/
.cache/presentation-export/
servers/fastapi/build/
servers/fastapi/dist/
servers/fastapi/fastembed_cache/
servers/fastapi/*.egg-info/
.codex/
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+153
View File
@@ -0,0 +1,153 @@
# Presenton Code of Conduct
## Our Community
The **Presenton** community is made up of contributors, developers, designers, researchers, and users from all around the world. Together we are building an open-source platform for **AI-powered presentation generation**.
We welcome contributions and participation from people of all backgrounds and experience levels. Whether you're fixing a typo, contributing code, reporting a bug, or proposing a new feature — you are a valued part of the community.
To ensure a positive and inclusive environment for everyone, we ask all community members to follow this Code of Conduct.
---
## Where This Applies
This Code of Conduct applies to all spaces managed by the **Presenton project**, including but not limited to:
- GitHub repositories (issues, pull requests, discussions)
- Official documentation
- Discord community
- Social media interactions
- Events, meetups, or presentations related to Presenton
Violations of this code outside these spaces may also affect a persons ability to participate in the Presenton community.
---
## Our Values
### 🤝 Be Friendly and Patient
Remember that many people contribute their time voluntarily. Be patient with newcomers and respectful of different experience levels.
---
### 🌍 Be Welcoming and Inclusive
We are committed to creating a community that welcomes people of all backgrounds and identities.
This includes, but is not limited to:
- Race
- Ethnicity
- Culture
- National origin
- Gender identity and expression
- Sexual orientation
- Age
- Disability
- Religion
- Education level
- Socioeconomic background
Everyone should feel safe and respected when participating in the Presenton community.
---
### 🧠 Be Considerate
Your work and decisions may impact others who rely on Presenton. Think about how your contributions affect:
- users
- contributors
- maintainers
- the broader open-source ecosystem
Remember that we are a global community, and English may not be everyone's first language.
---
### Respect Others
Disagreements are natural in any collaborative project, especially when discussing technical decisions.
However:
- Do not engage in personal attacks
- Avoid aggressive or dismissive language
- Focus on ideas, not individuals
A respectful environment allows everyone to contribute effectively.
---
### 💬 Choose Words Carefully
We strive to maintain a professional and supportive community.
The following behaviors are **not acceptable**:
- Violent threats or abusive language
- Discriminatory jokes or remarks
- Harassment or intimidation
- Posting sexually explicit or violent material
- Sharing or threatening to share personal information ("doxing")
- Personal insults or derogatory language
- Unwanted sexual attention
- Encouraging or advocating harmful behavior
- Repeated harassment after being asked to stop
If someone asks you to stop a behavior, please respect their request.
---
### 🤔 When We Disagree
Disagreements are part of building great software.
When they occur:
- Try to understand the other perspective
- Assume good intentions
- Focus on solving problems constructively
Remember: **diverse perspectives make open-source projects stronger**.
---
## Reporting Violations
If you believe someone is violating this Code of Conduct, please report it privately.
📧 **Email:** [suraj@presenton.ai](mailto:suraj@presenton.ai)
All reports will be handled confidentially and respectfully by the maintainers.
---
## Enforcement
Project maintainers are responsible for clarifying and enforcing this Code of Conduct. They may take appropriate action in response to unacceptable behavior, including:
- Warning the offender
- Removing content
- Temporarily restricting participation
- Banning users from community spaces
Our goal is to maintain a safe and productive environment for everyone.
---
## Acknowledgements
This Code of Conduct is inspired by guidelines from open-source communities and the **Speak Up! project**, adapted for the Presenton community.
---
## Questions?
If you have questions about this Code of Conduct, feel free to contact us:
📧 **suraj@presenton.ai**
Thank you for helping make **Presenton a welcoming and collaborative open-source project.**
+185
View File
@@ -0,0 +1,185 @@
# Contributing to Presenton
Welcome! 🚀
Thanks for helping improve **Presenton — the open-source AI presentation generator.**
## Quick Links
- **GitHub:** https://github.com/presenton/presenton
- **Docs:** https://docs.presenton.ai
- **Website:** https://presenton.ai
- **Discord:** https://discord.gg/9ZsKKxudNE
- **X:** https://x.com/presentonai
---
# Current Contribution Scope
The Electron application contains:
- Desktop application
- FastAPI backend
- Next.js frontend
- Local runtime integrations
Contributions outside `electron/` may not be accepted at this time.
---
# How to Contribute
### Bugs
Open an issue and include:
- Steps to reproduce
- Expected vs actual behavior
- Logs or screenshots
### Features
Start a **GitHub Issue** or **Discussion** explaining:
- The problem
- Proposed solution
### Code Contributions
1. Fork the repository
2. Create a branch
3. Implement your changes
4. Open a Pull Request
Example branch names:
```
feature/add-template-support
fix/export-pptx-error
docs/update-readme
```
---
# Development Setup (Electron)
### Prerequisites
- Node.js (LTS)
- npm
- Python
- `uv` (Python package manager)
# Setup Environment
From the `electron` directory:
```
cd electron
npm run setup:env
```
This installs:
- Node dependencies
- FastAPI dependencies
- Next.js dependencies
---
# Run the Electron App (Development)
```
npm run dev
```
This will:
- compile TypeScript
- start the Electron app
- run the backend and UI locally
---
# Build the Electron App
To build all components:
```
npm run build:all
```
---
# Before Opening a PR
### CLA and PR age policy
Pull requests must have the Contributor License Agreement (CLA) signed before
they can be accepted. Pull requests that remain open for more than 30 days
without a signed CLA may be discarded and closed. Please complete the CLA
prompt as soon as you open a pull request and update the pull request after
signing so maintainers can verify it.
Please ensure:
- Changes are **inside `electron/`**
- Code runs locally on development as well as build environment both
- PRs are **small and focused**
- You explain **what and why**
For UI changes, include screenshots.
---
# AI-Assisted Contributions
PRs created with **AI tools (ChatGPT, Claude, Codex, etc.) are welcome.**
Please mention:
- that the PR is **AI-assisted**
- the level of testing performed
- confirmation that you reviewed the generated code
---
# Good First Issues
Look for issues labeled:
```
good first issue
help wanted
```
---
# Community
Questions or discussions:
💬 Discord
https://discord.gg/9ZsKKxudNE
---
# Code of Conduct
Please follow our community guidelines:
```
CODE_OF_CONDUCT.md
```
---
Thanks for helping make **Presenton better for everyone.**
```
+161
View File
@@ -0,0 +1,161 @@
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim-trixie AS fastapi-builder
WORKDIR /app/servers/fastapi
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
RUN python -m venv --without-pip /opt/venv \
&& pip install --no-cache-dir uv
COPY servers/fastapi/pyproject.toml servers/fastapi/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
&& uv pip install --python /opt/venv/bin/python -r /tmp/requirements.txt
COPY servers/fastapi /app/servers/fastapi
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --python /opt/venv/bin/python --no-deps .
# mem0/spaCy BM25 lemmatization loads en_core_web_sm at runtime; spaCy tries pip to
# download it otherwise. Runtime image has no pip in PATH (--without-pip venv).
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --python /opt/venv/bin/python \
"https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl"
ENV HF_HOME=/root/.cache/huggingface \
PRESENTON_FASTEMBED_ICON_CACHE_DIR=/root/.cache/presenton/fastembed-icons
# Warm FastEmbed caches into the image (not a BuildKit cache mount, or HF weights would be missing).
RUN /opt/venv/bin/python scripts/warm_fastembed_cache.py
FROM node:20-bookworm-slim AS nextjs-builder
WORKDIR /app/servers/nextjs
ENV NEXT_TELEMETRY_DISABLED=1
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY servers/nextjs /app/servers/nextjs
RUN npm run build \
&& rm -rf .next-build/cache
FROM node:20-bookworm-slim AS assets-builder
WORKDIR /app
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates unzip \
&& rm -rf /var/lib/apt/lists/*
COPY package.json /app/
RUN mkdir -p /app/document-extraction-liteparse \
&& npm --prefix /app/document-extraction-liteparse init -y \
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
# Bundled export still loads @img/sharp-* native addons from node_modules (not inlined).
RUN rm -rf /app/presentation-export \
&& EXPORT_RUNTIME_ARCH="${TARGETARCH}" node /app/scripts/sync-presentation-export.cjs --force \
&& find /app/presentation-export/py -maxdepth 1 -type f -name "convert-linux-*" -exec chmod +x {} \; \
&& cd /app/presentation-export \
&& npm init -y \
&& npm install "sharp@^0.34.5" --include=optional --omit=dev --no-fund --no-audit --no-package-lock
FROM python:3.11-slim-trixie AS runtime
WORKDIR /app
ARG INSTALL_TESSERACT=true
ARG TARGETARCH
ARG CHROMIUM_VERSION=149.0.7827.196-1~deb13u1
ARG CHROMIUM_SNAPSHOT=20260625T180000Z
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
ENV APP_DATA_DIRECTORY=/app_data \
TEMP_DIRECTORY=/tmp/presenton \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
EXPORT_RUNTIME_DIR=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-current \
PRESENTON_APP_ROOT=/app \
HF_HOME=/root/.cache/huggingface \
PRESENTON_FASTEMBED_ICON_CACHE_DIR=/root/.cache/presenton/fastembed-icons \
PATH="/opt/venv/bin:${PATH}" \
NODE_ENV=production \
START_OLLAMA=false \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN set -eux; \
printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot; \
printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/%s trixie-security main\n' "$CHROMIUM_SNAPSHOT" > /etc/apt/sources.list.d/chromium-snapshot.list; \
packages="ca-certificates curl nginx fontconfig imagemagick zstd \
fonts-liberation fonts-noto-core xdg-utils \
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libexpat1 libgbm1 \
libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 libpango-1.0-0 \
libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
libxkbcommon0 libxrandr2 libxshmfence1 libxss1 libxtst6"; \
if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \
apt-get update; \
apt-get install -y --no-install-recommends --allow-downgrades \
$packages \
chromium="${CHROMIUM_VERSION}" \
chromium-common="${CHROMIUM_VERSION}" \
chromium-driver="${CHROMIUM_VERSION}"; \
apt-mark hold chromium chromium-common chromium-driver; \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -; \
apt-get install -y --no-install-recommends nodejs; \
rm -rf /var/lib/apt/lists/*
# Remove any non-Noto fonts that may have been installed as dependencies.
RUN find /usr/share/fonts -type f ! -iname 'Noto*' -delete \
&& find /usr/share/fonts -type d -empty -delete \
&& fc-cache -fsv
RUN mkdir -p /app/scripts /app/servers/fastapi /app/servers/nextjs
RUN mkdir -p /app_data/exports /app_data/images /app_data/uploads /app_data/fonts /app_data/templates /app_data/pptx-to-html /app_data/pptx-to-json \
&& chmod -R a+rX /app_data
COPY --from=fastapi-builder /opt/venv /opt/venv
COPY --from=fastapi-builder /app/servers/fastapi /app/servers/fastapi
COPY --from=fastapi-builder /root/.cache/huggingface /root/.cache/huggingface
COPY --from=fastapi-builder /root/.cache/presenton/fastembed-icons /root/.cache/presenton/fastembed-icons
COPY templates /app/templates
COPY --from=assets-builder /app/package.json /app/package.json
COPY --from=assets-builder /app/document-extraction-liteparse /app/document-extraction-liteparse
COPY --from=assets-builder /app/presentation-export /app/presentation-export
COPY --from=assets-builder /app/scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
RUN set -eux; \
if [ -z "${TARGETARCH:-}" ]; then TARGETARCH="$(dpkg --print-architecture)"; fi; \
case "$TARGETARCH" in \
amd64) export_arch="x64" ;; \
arm64) export_arch="arm64" ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" && exit 1 ;; \
esac; \
test -f "/app/presentation-export/py/convert-linux-${export_arch}"; \
ln -sf "/app/presentation-export/py/convert-linux-${export_arch}" /app/presentation-export/py/convert-linux-current; \
chmod +x "/app/presentation-export/py/convert-linux-${export_arch}"; \
ls -lah /app/presentation-export/py
COPY --from=nextjs-builder /app/servers/nextjs/.next-build/standalone/ /app/servers/nextjs/
COPY --from=nextjs-builder /app/servers/nextjs/public /app/servers/nextjs/public
COPY --from=nextjs-builder /app/servers/nextjs/.next-build/static /app/servers/nextjs/.next-build/static
COPY start.js LICENSE NOTICE ./
COPY scripts/presenton-terminal-banner.mjs /app/scripts/presenton-terminal-banner.mjs
COPY scripts/user-config-env.mjs /app/scripts/user-config-env.mjs
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["node", "/app/start.js"]
+103
View File
@@ -0,0 +1,103 @@
# syntax=docker/dockerfile:1.4
FROM python:3.11-slim-trixie
WORKDIR /app
ARG TARGETARCH
ARG CHROMIUM_VERSION=149.0.7827.196-1~deb13u1
ARG CHROMIUM_SNAPSHOT=20260625T180000Z
# LiteParse (Node + @llamaindex/liteparse) for document extraction; OCR via Tesseract.
ENV APP_DATA_DIRECTORY=/app_data \
TEMP_DIRECTORY=/tmp/presenton \
UV_SYSTEM_PYTHON=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PATH="/root/.local/bin:${PATH}" \
EXPORT_PACKAGE_ROOT=/app/presentation-export \
EXPORT_RUNTIME_DIR=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-current \
PRESENTON_APP_ROOT=/app \
HF_HOME=/root/.cache/huggingface \
PRESENTON_FASTEMBED_ICON_CACHE_DIR=/root/.cache/presenton/fastembed-icons \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
&& printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/%s trixie-security main\n' "$CHROMIUM_SNAPSHOT" > /etc/apt/sources.list.d/chromium-snapshot.list \
&& apt-get update && apt-get install -y --no-install-recommends --allow-downgrades \
ca-certificates curl unzip \
nginx fontconfig imagemagick zstd \
fonts-liberation fonts-noto-core xdg-utils \
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libexpat1 libgbm1 \
libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 libpango-1.0-0 \
libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
libxkbcommon0 libxrandr2 libxshmfence1 libxss1 libxtst6 \
tesseract-ocr tesseract-ocr-eng \
chromium="${CHROMIUM_VERSION}" \
chromium-common="${CHROMIUM_VERSION}" \
chromium-driver="${CHROMIUM_VERSION}" \
&& apt-mark hold chromium chromium-common chromium-driver \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& rm -rf /var/lib/apt/lists/*
# Remove any non-Noto fonts that may have been installed as dependencies.
RUN find /usr/share/fonts -type f ! -iname 'Noto*' -delete \
&& find /usr/share/fonts -type d -empty -delete \
&& fc-cache -fsv
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json /app/
RUN npm --prefix /app install --omit=dev
RUN mkdir -p /app/document-extraction-liteparse \
&& npm --prefix /app/document-extraction-liteparse init -y \
&& npm --prefix /app/document-extraction-liteparse install @llamaindex/liteparse@1.4.0 --omit=dev
COPY electron/resources/document-extraction/liteparse_runner.mjs /app/document-extraction-liteparse/liteparse_runner.mjs
COPY scripts/sync-presentation-export.cjs /app/scripts/sync-presentation-export.cjs
COPY scripts/presenton-terminal-banner.mjs /app/scripts/presenton-terminal-banner.mjs
COPY scripts/user-config-env.mjs /app/scripts/user-config-env.mjs
RUN rm -rf /app/presentation-export \
&& EXPORT_RUNTIME_ARCH="${TARGETARCH}" node /app/scripts/sync-presentation-export.cjs --force \
&& find /app/presentation-export/py -maxdepth 1 -type f -name "convert-linux-*" -exec chmod +x {} \; \
&& set -eux; \
if [ -z "${TARGETARCH:-}" ]; then TARGETARCH="$(dpkg --print-architecture)"; fi; \
case "$TARGETARCH" in \
amd64) export_arch="x64" ;; \
arm64) export_arch="arm64" ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" && exit 1 ;; \
esac; \
test -f "/app/presentation-export/py/convert-linux-${export_arch}"; \
ln -sf "/app/presentation-export/py/convert-linux-${export_arch}" /app/presentation-export/py/convert-linux-current; \
chmod +x "/app/presentation-export/py/convert-linux-${export_arch}"; \
ls -lah /app/presentation-export/py
# Bind mount `.:/app` hides any .venv under servers/fastapi at runtime — install deps into
# system site-packages (same interpreter `start.js` uses as `python`).
COPY servers/fastapi /app/servers/fastapi
COPY templates /app/templates
WORKDIR /app/servers/fastapi
RUN --mount=type=cache,target=/root/.cache/uv \
uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
&& uv pip install --system -r /tmp/requirements.txt \
&& uv pip install --system --no-deps .
# Mem0 BM25 lemmatization requires a spaCy language model at runtime.
# Installing it in the image avoids per-run downloads and startup self-disable.
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system \
"https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl"
# FastEmbed + Hugging Face Hub weights for icon search and Mem0 (no cache-only mount here:
# those files must remain in the image layer).
WORKDIR /app/servers/fastapi
RUN python scripts/warm_fastembed_cache.py
WORKDIR /app
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["node", "/app/start.js", "--dev"]
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 presenton
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+26825
View File
File diff suppressed because it is too large Load Diff
+701
View File
@@ -0,0 +1,701 @@
<p align="center">
<img src="./readme_assets/images/logo.png" alt="Presenton" />
</p>
<p align="center">
<a href="https://presenton.ai/download"><strong>Quickstart</strong></a> &middot;
<a href="https://docs.presenton.ai/"><strong>Docs</strong></a> &middot;
<a href="https://www.youtube.com/@presentonai"><strong>Youtube</strong></a> &middot;
<a href="https://discord.gg/9ZsKKxudNE"><strong>Discord</strong></a>
</p>
<p align="center">
<a href="https://github.com/presenton/presenton/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue?style=flat" alt="Apache2.0" /></a>
<a href="https://github.com/presenton/presenton"><img src="https://img.shields.io/github/stars/presenton/presenton?style=flat" alt="Stars" /></a>
<a href="https://presenton.ai/"><img src="https://img.shields.io/badge/Platform-Docker%20%7C%20Windows%20%7C%20macOS%20%7C%20Linux-lightgrey?style=flat" alt="Platform" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18582?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-18582" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/18582" alt="presenton%2Fpresenton | Trendshift" width="250" height="55" /></a>
</p>
# Open-Source AI Presentation Generator and API (Gamma, Canva, Beautiful AI, Decktopus, Presentations AI Alternative)
Discover what Presenton can do from AI-powered presentation generation to editing, exporting, and flexible model providers.
[▶ Watch Presenton in Action](https://github.com/user-attachments/assets/93e541dc-8487-4dcf-a9a0-95ad5ca94453)
### ✨ Why Presenton
No SaaS lock-in · No forced subscriptions · Full control over models and data
What makes Presenton different?
- Use Fully **self-hosted** in Web through [Docker Package](https://docs.presenton.ai/v3/get-started/quickstart)
- Or Download [Desktop App](https://presenton.ai/download) (Mac, Windows & Linux)
- Works with Ollama, LM Studio, OpenAI, Gemini, Vertex AI, Azure OpenAI, Amazon Bedrock, Fireworks, Together AI, Anthropic, or any other OpenAI compatible providers
- Comes with AI Presentation Generation API
- Fully open-source (Apache 2.0)
- Works with your own design/templates
- **Fully editable PPTX export**
> [!TIP]
> **Star us!** A ⭐ shows your support and encourages us to keep building! 😇
<p align="center">
<img src="./readme_assets/images/banner_bg.gif" alt="Presenton" />
</p>
#
### 🎛 Features
<p align="center">
<img src="./readme_assets/images/features.png" alt="Presenton Features" />
</p>
<p align="center">
<img src="./readme_assets/images/chatgpt-2-1.png" alt="Create stunning presentations with your existing ChatGPT subscription — secure and private, instant access, no API keys" />
</p>
#
### 💻 Presenton Desktop
Create AI-powered presentations using your own model provider (BYOK) or run everything locally on your own machine for full control and data privacy.
<p align="center">
<a href="https://presenton.ai/download">
<img src="./readme_assets/images/banner.png" alt="Cloud deployment" />
</a>
</p>
**Available Platforms**
<table>
<tr>
<th align="left">Platform</th>
<th align="left">Architecture</th>
<th align="left">Package</th>
<th align="left">Download</th>
</tr>
<tr>
<td><b>macOS</b></td>
<td>Apple Silicon / Intel</td>
<td><code>.dmg</code></td>
<td><a href="https://presenton.ai/download">Download ↗</a></td>
</tr>
<tr>
<td><b>Windows</b></td>
<td>x64</td>
<td><code>.exe</code></td>
<td><a href="https://presenton.ai/download">Download ↗</a></td>
</tr>
<tr>
<td><b>Linux</b></td>
<td>x64</td>
<td> <code>.deb</code></td>
<td><a href="https://presenton.ai/download">Download ↗</a></td>
</tr>
</table>
**Deploy to Cloud Providers**
<div style="display:flex; gap:12px; align-items:center;">
<a href="https://railway.com/deploy/presenton-ai-presentations">
<img
src="https://railway.com/button.svg"
alt="Deploy on Railway"
style="height:38px;"
/>
</a>
<a href="https://cloud.digitalocean.com/apps/new?repo=https://github.com/presenton/presenton/tree/main">
<img
src="https://www.deploytodo.com/do-btn-blue.svg"
alt="Deploy to DigitalOcean"
style="height:36px;"
/>
</a>
</div>
#
Presenton gives you complete control over your AI presentation workflow. Choose your models, customize your experience, and keep your data private.
- Custom Templates & Themes — Create unlimited presentation designs with HTML and Tailwind CSS
- AI Template Generation — Create presentation templates from existing Powerpoint documents.
- Flexible Generation — Build presentations from prompts or uploaded documents
- Export Ready — Save as PowerPoint (PPTX) and PDF with professional formatting
- Built-In MCP Server — Generate presentations over Model Context Protocol
- Bring Your Own Key — Use your own API keys for OpenAI, Google Gemini, Vertex AI, Azure OpenAI, Anthropic Claude, or any compatible provider. Only pay for what you use, no hidden fees or subscriptions.
- Ollama Integration — Run open-source models locally with full privacy
- OpenAI API Compatible — Connect to any OpenAI-compatible endpoint with your own models
- Multi-Provider Support — Mix and match text and image generation providers
- Versatile Image Generation — Choose from DALL-E 3, Gemini Flash, Pexels, or Pixabay
- Rich Media Support — Icons, charts, and custom graphics for professional presentations
- Runs Locally — All processing happens on your device, no cloud dependencies
- API Deployment — Host as your own API service for your team
- Fully Open-Source — Apache 2.0 licensed, inspect, modify, and contribute
- Docker Ready — One-command deployment with GPU support for local models
- Electron Desktop App — Run Presenton as a native desktop application on Windows, macOS, and Linux (no browser required)
- Sign in with ChatGPT — Use your free or paid ChatGPT account to sign in and start creating presentations instantly — no separate API key required
#
### ☁️ Presenton Cloud
Run Presenton directly in your browser — no installation, no setup required. Start creating presentations instantly from anywhere.
<p align="center">
<a href="https://presenton.ai">
<img src="./readme_assets/images/cloud-banner.png" alt="Presenton Cloud" />
</a>
</p>
#
### ⚡ Running Presenton
<p>
You can run Presenton in two ways:
<strong>Docker</strong> for a one-command setup without installing a local dev
stack, or the <strong>Electron desktop app</strong> for a native app
experience (ideal for development or offline use).
</p>
**Option 1: Electron (Desktop App)**
<p>
Run Presenton as a native desktop application. LLM and image provider
(API keys, etc.) can be configured in the app. The same environment variables
used for Docker apply when running the bundled backend.
</p>
<p>
<strong>Prerequisites:</strong> Node.js (LTS), npm, Python 3.11, and
<a href="https://docs.astral.sh/uv/">uv</a>
(for the shared FastAPI backend in <code>servers/fastapi</code>).
</p>
- Setup (First Time)
<pre><code class="language-bash">cd electron
npm run setup:env</code></pre>
This installs Node dependencies, runs <code>uv sync</code> in the FastAPI
server, and installs Next.js dependencies.
- Run in Development
<pre><code class="language-bash">npm run dev</code></pre>
<p>
This compiles TypeScript and starts Electron. The backend and UI run locally
inside the desktop window.
</p>
- Build Distributable (Optional)
To create installers for Windows, macOS, or Linux:
<pre><code class="language-bash">npm run build:all
npm run dist</code></pre>
<p>
Output files are written to <code>electron/dist</code>
(or as configured in your <code>electron-builder</code> settings).
</p>
<p>
For a public macOS DMG outside the Mac App Store, use
<code>APPLE_KEYCHAIN_PROFILE="presenton-notary" npm run build:all:mac:signed</code>
from <code>electron/</code> after the one-time Developer ID and notarization
setup in <code>docs/macos/dev/direct-distribution.md</code>.
</p>
**Option 2: Docker**
- Start Presenton
Linux/MacOS (Bash/Zsh Shell):
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
Windows (PowerShell):
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -v "${PWD}\app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Open Presenton
<p>
Open <a href="http://localhost:5001">http://localhost:5001</a> in the browser
of your choice to use Presenton.
</p>
<blockquote>
<p>
<strong>Note:</strong> You can replace <code>5001</code> with any other port
number of your choice to run Presenton on a different port number. If you use
Docker Compose, set <code>PRESENTON_HTTP_HOST_PORT</code>, for example
<code>PRESENTON_HTTP_HOST_PORT=8080 docker compose up production</code>.
</p>
</blockquote>
#
### ⚙️ Deployment Configurations
The lists below match the environment variables forwarded in this repositorys **`docker-compose.yml`** (`production`, `production-gpu`, `development`, and `development-gpu`). Put values in a `.env` file next to the compose file, or export them before `docker compose up`. The Electron app backend can read the same names when run outside Docker.
Other optional variables exist in code (for example advanced Mem0 paths, LiteParse runners, or `FAST_API_INTERNAL_URL` when Next.js and FastAPI are not same-origin); they are **not** wired in `docker-compose.yml`. Supported names are discoverable from `servers/fastapi/utils/get_env.py` and the Next.js server utilities under `servers/nextjs/`.
#### LLM and API keys
- **CAN_CHANGE_KEYS**=[true/false]: Set to **false** if you want to keep API keys hidden and make them unmodifiable.
- **LLM**=[openai/deepseek/google/vertex/azure/bedrock/openrouter/fireworks/together/cerebras/anthropic/litellm/lmstudio/ollama/custom/codex]: Select the text **LLM**.
- **OPENAI_API_KEY**: Required if **LLM** is **openai**.
- **OPENAI_MODEL**: Required if **LLM** is **openai** (default: `gpt-4.1`).
- **DEEPSEEK_API_KEY**: Required if **LLM** is **deepseek**.
- **DEEPSEEK_MODEL**: Required if **LLM** is **deepseek** (default: `deepseek-chat`).
- **DEEPSEEK_BASE_URL**: Optional if **LLM** is **deepseek** (default: `https://api.deepseek.com`).
- **GOOGLE_API_KEY**: Required if **LLM** is **google**.
- **GOOGLE_MODEL**: Required if **LLM** is **google** (default: `models/gemini-2.0-flash`).
- **VERTEX_MODEL**: Required if **LLM** is **vertex** (default: `gemini-2.5-flash`).
- **VERTEX_API_KEY**: Optional auth path for **LLM=vertex** (Vertex Express).
- **VERTEX_PROJECT** / **VERTEX_LOCATION**: Optional auth path for **LLM=vertex** when using GCP project credentials (do not combine with `VERTEX_API_KEY`).
- **VERTEX_BASE_URL**: Optional Vertex gateway/base URL override.
- **AZURE_OPENAI_MODEL**: Required if **LLM** is **azure** (deployment/model name).
- **AZURE_OPENAI_API_KEY**: Required if **LLM** is **azure**.
- **AZURE_OPENAI_API_VERSION**: Required if **LLM** is **azure** (for example `2024-10-21`).
- **AZURE_OPENAI_ENDPOINT** / **AZURE_OPENAI_BASE_URL**: At least one is required if **LLM** is **azure**.
- **AZURE_OPENAI_DEPLOYMENT**: Optional deployment override for **LLM** is **azure**.
- **BEDROCK_REGION**: Optional if **LLM** is **bedrock** (default: `us-east-1`).
- **BEDROCK_MODEL**: Required if **LLM** is **bedrock**. Use a standard model ID (example: `us.anthropic.claude-3-5-haiku-20241022-v1:0`) or a full **inference profile ARN** for newer models (example: Claude Sonnet 4.6). Passed through to Bedrock Converse as `modelId`. See **[Amazon Bedrock guide](docs/amazon-bedrock.md)**.
- **BEDROCK_API_KEY**: Optional if **LLM** is **bedrock** (API key auth; alternative to AWS keys).
- **BEDROCK_AWS_ACCESS_KEY_ID** / **BEDROCK_AWS_SECRET_ACCESS_KEY**: Required together if **LLM** is **bedrock** and `BEDROCK_API_KEY` is not set.
- **BEDROCK_AWS_SESSION_TOKEN**: Optional session token for **LLM** is **bedrock**.
- **BEDROCK_PROFILE_NAME**: Optional AWS profile name for **LLM** is **bedrock**.
- **OPENROUTER_API_KEY**: Required if **LLM** is **openrouter**.
- **OPENROUTER_MODEL**: Required if **LLM** is **openrouter** (default: `openai/gpt-4o`).
- **OPENROUTER_BASE_URL**: Optional if **LLM** is **openrouter** (default: `https://openrouter.ai/api/v1`).
- **FIREWORKS_API_KEY**: Required if **LLM** is **fireworks**.
- **FIREWORKS_MODEL**: Required if **LLM** is **fireworks** (example: `accounts/fireworks/models/llama-v3p1-8b-instruct`).
- **FIREWORKS_BASE_URL**: Optional if **LLM** is **fireworks** (default: `https://api.fireworks.ai/inference/v1`).
- **TOGETHER_API_KEY**: Required if **LLM** is **together**.
- **TOGETHER_MODEL**: Required if **LLM** is **together** (example: `openai/gpt-oss-20b`).
- **TOGETHER_BASE_URL**: Optional if **LLM** is **together** (default: `https://api.together.ai/v1`).
- **CEREBRAS_API_KEY**: Required if **LLM** is **cerebras**.
- **CEREBRAS_MODEL**: Required if **LLM** is **cerebras** (default: `llama-3.3-70b`).
- **CEREBRAS_BASE_URL**: Optional if **LLM** is **cerebras** (default: `https://api.cerebras.ai/v1`).
- **ANTHROPIC_API_KEY**: Required if **LLM** is **anthropic**.
- **ANTHROPIC_MODEL**: Required if **LLM** is **anthropic** (default: `claude-3-5-sonnet-20241022`).
- **CODEX_MODEL**: Required if **LLM** is **codex** (Codex OAuth flow; compose maps host port **1455** for the callback).
- **CUSTOM_LLM_URL**: OpenAI-compatible base URL if **LLM** is **custom**.
- **CUSTOM_LLM_API_KEY**: API key if **LLM** is **custom**.
- **CUSTOM_MODEL**: Model id if **LLM** is **custom**.
- **LITELLM_BASE_URL**: LiteLLM proxy or gateway base URL if **LLM** is **litellm**.
- **LITELLM_API_KEY**: Optional API key if **LLM** is **litellm**.
- **LITELLM_MODEL**: Required if **LLM** is **litellm** (default: `gpt-4.1`).
- **LMSTUDIO_BASE_URL**: Optional LM Studio base URL if **LLM** is **lmstudio** (default: `http://localhost:1234/v1`; `/v1` is auto-appended when omitted).
- **LMSTUDIO_API_KEY**: Optional API key if **LLM** is **lmstudio**.
- **LMSTUDIO_MODEL**: Required if **LLM** is **lmstudio** (example: `openai/gpt-oss-20b`).
- **DISABLE_THINKING**=[true/false]: If **true**, disables “thinking” for providers that support it (including DeepSeek).
- **WEB_GROUNDING**=[true/false]: If **true**, enables web search by default.
- **WEB_SEARCH_PROVIDER**=[auto/native/searxng/tavily/exa]: Selects the web search mode. `auto` uses native search for OpenAI, Google, and Anthropic, and otherwise leaves web search off unless you choose an external provider.
<!-- Brave and Serper search providers are hidden until they are tested. -->
<!-- - **WEB_SEARCH_PROVIDER** also supports `brave` and `serper`. -->
- **WEB_SEARCH_MAX_RESULTS**: Maximum external search results to add to model context (default `5`, maximum `10`).
- **SEARXNG_BASE_URL**: Base URL for a self-hosted SearXNG instance.
- **TAVILY_API_KEY**, **EXA_API_KEY**: Credentials for optional hosted search APIs.
<!-- - **BRAVE_SEARCH_API_KEY**, **SERPER_API_KEY**: Credentials for hidden, untested hosted search APIs. -->
- **EXTENDED_REASONING**=[true/false]: Enables extended reasoning where supported by the configured stack.
#### Ollama
Use when **LLM** is **ollama**:
- **OLLAMA_URL**: Base URL of the Ollama HTTP API (e.g. `http://host.docker.internal:11434` from Docker).
- **OLLAMA_MODEL**: Model name in Ollama (e.g. `llama3.2:3b`).
- **START_OLLAMA**=[true/false]: Container entrypoint (`start.js`): optional install + `ollama serve`. Default **false** (`development` / `production` compose).
#### Presentation memory (Mem0 OSS)
Mem0 uses local Qdrant + SQLite (OSS); memory is scoped per presentation.
By default the Docker runtime now points Mem0 at a local Ollama-compatible LLM endpoint, so it no longer needs an OpenAI key just to initialize. If you want to use OpenAI instead, set `MEM0_LLM_BASE_URL`/`MEM0_LLM_API_KEY` to your OpenAI-compatible endpoint and key.
Docker images install the default spaCy model (`en_core_web_sm`) during build so Mem0 can start without extra setup on each run.
| Variable | Purpose |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **MEM0_ENABLED** | **true**/false (compose default **true**). |
| **MEM0_LLM_MODEL** | Mem0 LLM model name (compose default **`llama3.1:latest`** or `OLLAMA_MODEL`). |
| **MEM0_LLM_API_KEY** | Mem0 LLM API key placeholder for OpenAI-compatible clients (compose default **`ollama`**). |
| **MEM0_LLM_BASE_URL** | Mem0 LLM base URL (compose default **`OLLAMA_URL`** or `http://host.docker.internal:11434`). |
| **MEM0_DIR** | Root directory (compose default **`/app_data/mem0`**). |
| **MEM0_EMBEDDER_PROVIDER** | Embedder backend (compose default **`fastembed`**). |
| **MEM0_EMBEDDER_MODEL** | Model id (compose default **`BAAI/bge-small-en-v1.5`**). |
| **MEM0_EMBEDDING_DIMS** | Vector size (compose default **384**). |
| **MEM0_SPACY_MODEL** | Optional spaCy model override (default **`en_core_web_sm`**). |
| **MEM0_REQUIRE_SPACY_MODEL** | Keep as **true** (default). Set to false only if you intentionally want Mem0 to run without spaCy lemmatization. |
#### Document parsing (LiteParse)
| Variable | Purpose |
| ------------------------- | ----------------------------------------- |
| **LITEPARSE_DPI** | OCR render DPI (compose default **120**). |
| **LITEPARSE_NUM_WORKERS** | Worker count (compose default **1**). |
#### Database
- **DATABASE_URL**: SQLAlchemy URL; if unset, the app falls back to SQLite under app data.
- **MIGRATE_DATABASE_ON_STARTUP**: Compose sets **`true`** for all services so migrations run on startup.
#### Image generation
These variables match `docker-compose.yml`. **`IMAGE_PROVIDER`** selects the backend (`pexels`, `pixabay`, `gemini_flash`, `nanobanana_pro`, `dall-e-3`, `gpt-image-1.5`, `comfyui`, `open_webui`). Use **OPENAI_API_KEY** for OpenAI image modes and **GOOGLE_API_KEY** for Gemini image modes (same keys as the LLM section).
- **DISABLE_IMAGE_GENERATION**=[true/false]: Disable slide image generation.
- **IMAGE_PROVIDER**: Provider id (see enum above).
- **PEXELS_API_KEY**: Pexels stock images.
- **PIXABAY_API_KEY**: Pixabay stock images.
- **DALL_E_3_QUALITY**=[standard/hd]: Optional for **dall-e-3** (default `standard`).
- **GPT_IMAGE_1_5_QUALITY**=[low/medium/high]: Optional for **gpt-image-1.5** (default `medium`).
- **COMFYUI_URL** / **COMFYUI_WORKFLOW**: Self-hosted ComfyUI workflow JSON.
- **OPEN_WEBUI_IMAGE_URL** / **OPEN_WEBUI_IMAGE_API_KEY**: Open WebUIcompatible image endpoint.
- **OPENAI_COMPAT_IMAGE_BASE_URL** / **OPENAI_COMPAT_IMAGE_API_KEY** / **OPENAI_COMPAT_IMAGE_MODEL**: Required if using **openai_compatible** to send image requests to any OpenAI-compatible `/v1/images/*` endpoint (LiteLLM, Azure, vLLM Gateways, etc.).
#### Telemetry
- **DISABLE_ANONYMOUS_TRACKING**=[true/false]: Set to **true** to disable anonymous telemetry.
#### Authentication (web login)
Presenton uses a **single admin account** per instance. Credentials live in `app_data` (hashed; see `userConfig.json`). Pass these with `-e` or via `.env` for compose:
- **AUTH_USERNAME** / **AUTH_PASSWORD** — Preseed the admin login on first boot (password at least 6 characters). Ignored if a user already exists unless **AUTH_OVERRIDE_FROM_ENV** is set.
- **AUTH_OVERRIDE_FROM_ENV**=[true/false] — If **true**, replace stored credentials from the env vars on every FastAPI startup and rotate the session signing secret (invalidates existing sessions). Remove after a one-off rotation.
- **RESET_AUTH**=[true/false] — If **true**, clear stored credentials on startup. Use for a **single** boot to recover access, then unset.
**Examples**
```bash
docker run -it --name presenton -p 5001:80 -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest
```
```bash
docker run -it --name presenton -p 5001:80 -e AUTH_USERNAME=admin -e AUTH_PASSWORD=changeme123 -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest
```
```bash
docker run -it --name presenton -p 5001:80 -e AUTH_USERNAME=admin -e AUTH_PASSWORD=changeme123 -v "${PWD}\app_data:/app_data" ghcr.io/presenton/presenton:latest
```
```bash
docker stop presenton && docker rm presenton && docker run -it --name presenton -p 5001:80 -e AUTH_USERNAME=admin -e AUTH_PASSWORD=newcred456 -e AUTH_OVERRIDE_FROM_ENV=true -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest
```
```bash
docker stop presenton && docker rm presenton && docker run -it --name presenton -p 5001:80 -e RESET_AUTH=true -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest
```
```bash
docker stop presenton && docker rm presenton && docker run -it --name presenton -p 5001:80 -e AUTH_USERNAME=admin -e AUTH_PASSWORD=changeme123 -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest
```
**Manual reset:** stop the container, edit `./app_data/userConfig.json`, delete `AUTH_USERNAME`, `AUTH_PASSWORD_HASH`, and `AUTH_SECRET_KEY`, save, and start again.
Sign out from the app: **Settings → Other → Sign out**.
#### MCP authentication
When auth is configured (`AUTH_USERNAME` / `AUTH_PASSWORD`), the MCP endpoint at `/mcp` now requires authentication as well.
1. Log in once to get a bearer token:
```bash
curl -s -X POST http://localhost:5001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"changeme123"}'
```
The response includes:
- `access_token` (session token)
- `token_type` (`bearer`)
2. Configure your MCP client to send that token on every request:
```json
{
"mcpServers": {
"presenton": {
"url": "http://localhost:5001/mcp",
"headers": {
"Authorization": "Bearer <access_token>"
}
}
}
}
```
Notes:
- If you rotate credentials with `AUTH_OVERRIDE_FROM_ENV=true`, previously issued session tokens are invalidated.
- MCP is not available in the Electron desktop app (`PRESENTON_ELECTRON=true`). Electron runs with `DISABLE_AUTH=true` by default, and the MCP server is disabled there to avoid auth conflicts.
> Note: LLM and image variables above are forwarded from **`docker-compose.yml`** when set in `.env`.
<br>
<br>
**Docker Run Examples by Provider**
Same variables as compose; use `-e` instead of `.env` when running `docker run` directly.
- Using OpenAI
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="openai" -e OPENAI_API_KEY="******" -e IMAGE_PROVIDER="dall-e-3" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Google
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="google" -e GOOGLE_API_KEY="******" -e IMAGE_PROVIDER="gemini_flash" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Vertex AI (API key mode)
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="vertex" -e VERTEX_API_KEY="******" -e VERTEX_MODEL="gemini-2.5-flash" -e IMAGE_PROVIDER="gemini_flash" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Azure OpenAI
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="azure" -e AZURE_OPENAI_API_KEY="******" -e AZURE_OPENAI_MODEL="gpt-4.1" -e AZURE_OPENAI_API_VERSION="2024-10-21" -e AZURE_OPENAI_ENDPOINT="https://YOUR-RESOURCE.openai.azure.com" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Amazon Bedrock (on-demand model ID) — see **[docs/amazon-bedrock.md](docs/amazon-bedrock.md)** for inference profiles, IAM, and troubleshooting.
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="bedrock" -e BEDROCK_REGION="us-east-1" -e BEDROCK_AWS_ACCESS_KEY_ID="******" -e BEDROCK_AWS_SECRET_ACCESS_KEY="******" -e BEDROCK_MODEL="us.anthropic.claude-3-5-haiku-20241022-v1:0" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Amazon Bedrock (inference profile ARN, e.g. Claude Sonnet 4.6)
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="bedrock" -e BEDROCK_REGION="us-east-1" -e BEDROCK_AWS_ACCESS_KEY_ID="******" -e BEDROCK_AWS_SECRET_ACCESS_KEY="******" -e BEDROCK_MODEL="arn:aws:bedrock:us-east-1:YOUR_ACCOUNT_ID:inference-profile/us.anthropic.claude-sonnet-4-6" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Fireworks
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="fireworks" -e FIREWORKS_API_KEY="******" -e FIREWORKS_MODEL="accounts/fireworks/models/llama-v3p1-8b-instruct" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Together AI
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="together" -e TOGETHER_API_KEY="******" -e TOGETHER_MODEL="openai/gpt-oss-20b" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Ollama
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="ollama" -e OLLAMA_MODEL="llama3.2:3b" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="*******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using Anthropic
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="anthropic" -e ANTHROPIC_API_KEY="******" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using LM Studio (local)
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e LLM="lmstudio" -e LMSTUDIO_BASE_URL="http://host.docker.internal:1234" -e LMSTUDIO_MODEL="openai/gpt-oss-20b" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using OpenAI Compatible LLM API
<pre><code class="language-bash">docker run -it -p 5001:80 -e CAN_CHANGE_KEYS="false" -e LLM="custom" -e CUSTOM_LLM_URL="http://*****" -e CUSTOM_LLM_API_KEY="*****" -e CUSTOM_MODEL="llama3.2:3b" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="********" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Running Presenton with GPU Support
To use GPU acceleration with Ollama models, you need to install and configure the NVIDIA Container Toolkit. This allows Docker containers to access your NVIDIA GPU.
Once the NVIDIA Container Toolkit is installed and configured, you can run Presenton with GPU support by adding the `--gpus=all` flag:
<pre><code class="language-bash">docker run -it --name presenton --gpus=all -p 5001:80 -e LLM="ollama" -e OLLAMA_MODEL="llama3.2:3b" -e IMAGE_PROVIDER="pexels" -e PEXELS_API_KEY="*******" -e CAN_CHANGE_KEYS="false" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
- Using an OpenAI-Compatible Image Provider
This routes all slide image requests through your OpenAI-compatible gateway (LiteLLM, Azure, vLLM, etc.) while keeping the text LLM configuration independent:
<pre><code class="language-bash">docker run -it --name presenton -p 5001:80 -e IMAGE_PROVIDER="openai_compatible" -e OPENAI_COMPAT_IMAGE_BASE_URL="https://proxy.example.com/v1" -e OPENAI_COMPAT_IMAGE_API_KEY="******" -e OPENAI_COMPAT_IMAGE_MODEL="gpt-image-1" -v "./app_data:/app_data" ghcr.io/presenton/presenton:latest</code></pre>
#
### ✨ Generate Presentation via API
**Generate Presentation**
<p>
<strong>Endpoint:</strong> <code>/api/v1/ppt/presentation/generate</code><br>
<strong>Method:</strong> <code>POST</code><br>
<strong>Content-Type:</strong> <code>application/json</code>
</p>
<p>
<strong>Authentication (HTTP Basic):</strong><br>
All <code>/api/v1/</code> routes except <code>/api/v1/auth/*</code> require authentication. Send your Presenton admin username and password (same as the web UI, or <strong>AUTH_USERNAME</strong> / <strong>AUTH_PASSWORD</strong> when preseeding Docker). With <code>curl</code>, put them right after <code>-u</code> as <code>-u USERNAME:PASSWORD</code> — that is HTTP Basic auth and sets <code>Authorization: Basic …</code> for you. Replace the sample <code>username:password</code> below with your real credentials.
</p>
**Request Body**
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>content</code></td>
<td>string</td>
<td>Yes</td>
<td>Main content used to generate the presentation.</td>
</tr>
<tr>
<td><code>slides_markdown</code></td>
<td>string[] | null</td>
<td>No</td>
<td>Provide custom slide markdown instead of auto-generation.</td>
</tr>
<tr>
<td><code>instructions</code></td>
<td>string | null</td>
<td>No</td>
<td>Additional generation instructions.</td>
</tr>
<tr>
<td><code>tone</code></td>
<td>string</td>
<td>No</td>
<td>
Text tone (default: <code>"default"</code>).
Options: <code>default</code>, <code>casual</code>, <code>professional</code>,
<code>funny</code>, <code>educational</code>, <code>sales_pitch</code>
</td>
</tr>
<tr>
<td><code>verbosity</code></td>
<td>string</td>
<td>No</td>
<td>
Content density (default: <code>"standard"</code>).
Options: <code>concise</code>, <code>standard</code>, <code>text-heavy</code>
</td>
</tr>
<tr>
<td><code>web_search</code></td>
<td>boolean</td>
<td>No</td>
<td>Enable web search grounding (default: <code>false</code>).</td>
</tr>
<tr>
<td><code>n_slides</code></td>
<td>integer</td>
<td>No</td>
<td>Number of slides to generate (default: <code>8</code>).</td>
</tr>
<tr>
<td><code>language</code></td>
<td>string</td>
<td>No</td>
<td>Presentation language (default: <code>"English"</code>).</td>
</tr>
<tr>
<td><code>template</code></td>
<td>string</td>
<td>No</td>
<td>Template name (default: <code>"general"</code>).</td>
</tr>
<tr>
<td><code>include_table_of_contents</code></td>
<td>boolean</td>
<td>No</td>
<td>Include table of contents slide (default: <code>false</code>).</td>
</tr>
<tr>
<td><code>include_title_slide</code></td>
<td>boolean</td>
<td>No</td>
<td>Include title slide (default: <code>true</code>).</td>
</tr>
<tr>
<td><code>files</code></td>
<td>string[] | null</td>
<td>No</td>
<td>
Files to use in generation.
Upload first via <code>/api/v1/ppt/files/upload</code>.
</td>
</tr>
<tr>
<td><code>export_as</code></td>
<td>string</td>
<td>No</td>
<td>
Export format (default: <code>"pptx"</code>).
Options: <code>pptx</code>, <code>pdf</code>
</td>
</tr>
</tbody>
</table>
**Response**
<pre><code class="language-json">{
"presentation_id": "string",
"path": "string",
"edit_path": "string"
}</code></pre>
**Example (curl + HTTP Basic auth with <code>-u</code>)**
<pre><code class="language-bash">curl -u username:password \
-X POST http://localhost:5001/api/v1/ppt/presentation/generate \
-H "Content-Type: application/json" \
-d '{
"content": "Introduction to Machine Learning",
"n_slides": 5,
"language": "English",
"template": "general",
"export_as": "pptx"
}'</code></pre>
**Example Response**
<pre><code class="language-json">{
"presentation_id": "d3000f96-096c-4768-b67b-e99aed029b57",
"path": "/app_data/d3000f96-096c-4768-b67b-e99aed029b57/Introduction_to_Machine_Learning.pptx",
"edit_path": "/presentation?id=d3000f96-096c-4768-b67b-e99aed029b57"
}</code></pre>
<blockquote>
<strong>Note:</strong>
Prepend your servers root URL to <code>path</code> and
<code>edit_path</code> to construct valid links.
</blockquote>
**Documentation & Tutorials**
<ul>
<li>
<a href="https://docs.presenton.ai/v3/get-started/quickstart">
Deploy Presenton
</a>
</li>
<li>
<a href="https://docs.presenton.ai/v3/get-started/api-introduction">
Full API Documentation
</a>
</li>
<li>
<a href="https://docs.presenton.ai/v3/guide/using-presenton-api">
Generate Presentations via API in 5 Minutes
</a>
</li>
<li>
<a href="https://docs.presenton.ai/tutorial/generate-presentation-from-csv">
Create Presentations from CSV using AI
</a>
</li>
<li>
<a href="https://docs.presenton.ai/tutorial/create-data-reports-using-ai">
Create Data Reports Using AI
</a>
</li>
</ul>
#
### 🚀 Roadmap
Track the public roadmap on GitHub Projects: [https://github.com/orgs/presenton/projects/2](https://github.com/orgs/presenton/projects/2)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`presenton/presenton`
- 原始仓库:https://github.com/presenton/presenton
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+17
View File
@@ -0,0 +1,17 @@
# Vision
Presenton exists to make local AI visual asset generation simple, powerful, and private.
AI should not only produce unstructured text blobs. It should generate structured, beautiful visual documents with layout, hierarchy, themes, diagrams, and design systems built in from the start.
Visual asset generation must not require surrendering data to external platforms. It must run locally. It must work inside private networks. It must remain usable in controlled and air gapped environments.
Users should be free to choose their models. Local models. Open models. Proprietary providers. Hybrid setups. Text models and image models working together. Systems such as Ollama or any compatible runtime. No lock in.
Templates and Designs are first class citizens. Users should be able to create them, modify them, package them, share them internally, and distribute them publicly. Themes and Asset packs can be configured manually.
Presenton is an open source document engine, not a closed design platform. It is infrastructure for AI native visual workflows. Portable. Extensible. Model agnostic. Private by default.
Our ambition is to become the open foundation for local, private, AI powered visual document generation.
Beautiful visual assets. Generated locally. Owned by the user.
+476
View File
@@ -0,0 +1,476 @@
services:
production:
platform: ${PRESENTON_DOCKER_PLATFORM:-linux/amd64}
# image: ghcr.io/presenton/presenton:latest
build:
context: .
dockerfile: Dockerfile
ports:
# Set PRESENTON_HTTP_HOST_PORT to change the host port.
- "${PRESENTON_HTTP_HOST_PORT:-5001}:80"
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
- "1455:1455"
volumes:
- ./app_data:/app_data
- ./servers/fastapi/templates:/app/servers/fastapi/templates:ro
- ./servers/fastapi/utils/internal_http.py:/app/servers/fastapi/utils/internal_http.py:ro
environment:
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
- PRESENTON_HOST_HTTP_PORT=${PRESENTON_HOST_HTTP_PORT:-5001}
- MIGRATE_DATABASE_ON_STARTUP=true
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS:-}
- LLM=${LLM:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-}
- DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_MODEL=${GOOGLE_MODEL:-}
- VERTEX_API_KEY=${VERTEX_API_KEY:-}
- VERTEX_MODEL=${VERTEX_MODEL:-}
- VERTEX_PROJECT=${VERTEX_PROJECT:-}
- VERTEX_LOCATION=${VERTEX_LOCATION:-}
- VERTEX_BASE_URL=${VERTEX_BASE_URL:-}
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
- AZURE_OPENAI_MODEL=${AZURE_OPENAI_MODEL:-}
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
- AZURE_OPENAI_BASE_URL=${AZURE_OPENAI_BASE_URL:-}
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
- BEDROCK_REGION=${BEDROCK_REGION:-}
- BEDROCK_API_KEY=${BEDROCK_API_KEY:-}
- BEDROCK_AWS_ACCESS_KEY_ID=${BEDROCK_AWS_ACCESS_KEY_ID:-}
- BEDROCK_AWS_SECRET_ACCESS_KEY=${BEDROCK_AWS_SECRET_ACCESS_KEY:-}
- BEDROCK_AWS_SESSION_TOKEN=${BEDROCK_AWS_SESSION_TOKEN:-}
- BEDROCK_PROFILE_NAME=${BEDROCK_PROFILE_NAME:-}
- BEDROCK_MODEL=${BEDROCK_MODEL:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-}
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-}
- FIREWORKS_API_KEY=${FIREWORKS_API_KEY:-}
- FIREWORKS_MODEL=${FIREWORKS_MODEL:-}
- FIREWORKS_BASE_URL=${FIREWORKS_BASE_URL:-}
- TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
- TOGETHER_MODEL=${TOGETHER_MODEL:-}
- TOGETHER_BASE_URL=${TOGETHER_BASE_URL:-}
- CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-}
- CEREBRAS_MODEL=${CEREBRAS_MODEL:-}
- CEREBRAS_BASE_URL=${CEREBRAS_BASE_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-}
- OLLAMA_URL=${OLLAMA_URL:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-}
- START_OLLAMA=${START_OLLAMA:-false}
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL:-}
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY:-}
- CUSTOM_MODEL=${CUSTOM_MODEL:-}
- LITELLM_BASE_URL=${LITELLM_BASE_URL:-}
- LITELLM_API_KEY=${LITELLM_API_KEY:-}
- LITELLM_MODEL=${LITELLM_MODEL:-}
- LMSTUDIO_BASE_URL=${LMSTUDIO_BASE_URL:-}
- LMSTUDIO_API_KEY=${LMSTUDIO_API_KEY:-}
- LMSTUDIO_MODEL=${LMSTUDIO_MODEL:-}
- CODEX_MODEL=${CODEX_MODEL:-}
- DISABLE_IMAGE_GENERATION=${DISABLE_IMAGE_GENERATION:-}
- IMAGE_PROVIDER=${IMAGE_PROVIDER:-}
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
- PIXABAY_API_KEY=${PIXABAY_API_KEY:-}
- DALL_E_3_QUALITY=${DALL_E_3_QUALITY:-}
- GPT_IMAGE_1_5_QUALITY=${GPT_IMAGE_1_5_QUALITY:-}
- EXTENDED_REASONING=${EXTENDED_REASONING:-}
- DISABLE_THINKING=${DISABLE_THINKING:-}
- WEB_GROUNDING=${WEB_GROUNDING:-}
- WEB_SEARCH_PROVIDER=${WEB_SEARCH_PROVIDER:-}
- WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-}
- SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- EXA_API_KEY=${EXA_API_KEY:-}
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
- DATABASE_URL=${DATABASE_URL:-}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING:-}
- COMFYUI_URL=${COMFYUI_URL:-}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW:-}
- MEM0_LLM_MODEL=${MEM0_LLM_MODEL:-${OLLAMA_MODEL:-llama3.1:latest}}
- MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-ollama}
- MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-${OLLAMA_URL:-http://host.docker.internal:11434}}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- MEM0_SPACY_MODEL=${MEM0_SPACY_MODEL:-en_core_web_sm}
- MEM0_REQUIRE_SPACY_MODEL=${MEM0_REQUIRE_SPACY_MODEL:-true}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL:-}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_BASE_URL=${OPENAI_COMPAT_IMAGE_BASE_URL:-}
- OPENAI_COMPAT_IMAGE_API_KEY=${OPENAI_COMPAT_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_MODEL=${OPENAI_COMPAT_IMAGE_MODEL:-}
- AUTH_USERNAME=${AUTH_USERNAME:-}
- AUTH_PASSWORD=${AUTH_PASSWORD:-}
- AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
- RESET_AUTH=${RESET_AUTH:-}
production-gpu:
platform: ${PRESENTON_DOCKER_PLATFORM:-linux/amd64}
# image: ghcr.io/presenton/presenton:latest
build:
context: .
dockerfile: Dockerfile
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
# Set PRESENTON_HTTP_HOST_PORT to change the host port.
- "${PRESENTON_HTTP_HOST_PORT:-5001}:80"
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
- "1455:1455"
volumes:
- ./app_data:/app_data
environment:
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
- PRESENTON_HOST_HTTP_PORT=${PRESENTON_HOST_HTTP_PORT:-5000}
- MIGRATE_DATABASE_ON_STARTUP=true
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS:-}
- LLM=${LLM:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-}
- DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_MODEL=${GOOGLE_MODEL:-}
- VERTEX_API_KEY=${VERTEX_API_KEY:-}
- VERTEX_MODEL=${VERTEX_MODEL:-}
- VERTEX_PROJECT=${VERTEX_PROJECT:-}
- VERTEX_LOCATION=${VERTEX_LOCATION:-}
- VERTEX_BASE_URL=${VERTEX_BASE_URL:-}
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
- AZURE_OPENAI_MODEL=${AZURE_OPENAI_MODEL:-}
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
- AZURE_OPENAI_BASE_URL=${AZURE_OPENAI_BASE_URL:-}
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
- BEDROCK_REGION=${BEDROCK_REGION:-}
- BEDROCK_API_KEY=${BEDROCK_API_KEY:-}
- BEDROCK_AWS_ACCESS_KEY_ID=${BEDROCK_AWS_ACCESS_KEY_ID:-}
- BEDROCK_AWS_SECRET_ACCESS_KEY=${BEDROCK_AWS_SECRET_ACCESS_KEY:-}
- BEDROCK_AWS_SESSION_TOKEN=${BEDROCK_AWS_SESSION_TOKEN:-}
- BEDROCK_PROFILE_NAME=${BEDROCK_PROFILE_NAME:-}
- BEDROCK_MODEL=${BEDROCK_MODEL:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-}
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-}
- FIREWORKS_API_KEY=${FIREWORKS_API_KEY:-}
- FIREWORKS_MODEL=${FIREWORKS_MODEL:-}
- FIREWORKS_BASE_URL=${FIREWORKS_BASE_URL:-}
- TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
- TOGETHER_MODEL=${TOGETHER_MODEL:-}
- TOGETHER_BASE_URL=${TOGETHER_BASE_URL:-}
- CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-}
- CEREBRAS_MODEL=${CEREBRAS_MODEL:-}
- CEREBRAS_BASE_URL=${CEREBRAS_BASE_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-}
- OLLAMA_URL=${OLLAMA_URL:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-}
- START_OLLAMA=${START_OLLAMA:-false}
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL:-}
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY:-}
- CUSTOM_MODEL=${CUSTOM_MODEL:-}
- LITELLM_BASE_URL=${LITELLM_BASE_URL:-}
- LITELLM_API_KEY=${LITELLM_API_KEY:-}
- LITELLM_MODEL=${LITELLM_MODEL:-}
- LMSTUDIO_BASE_URL=${LMSTUDIO_BASE_URL:-}
- LMSTUDIO_API_KEY=${LMSTUDIO_API_KEY:-}
- LMSTUDIO_MODEL=${LMSTUDIO_MODEL:-}
- CODEX_MODEL=${CODEX_MODEL:-}
- DISABLE_IMAGE_GENERATION=${DISABLE_IMAGE_GENERATION:-}
- IMAGE_PROVIDER=${IMAGE_PROVIDER:-}
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
- PIXABAY_API_KEY=${PIXABAY_API_KEY:-}
- DALL_E_3_QUALITY=${DALL_E_3_QUALITY:-}
- GPT_IMAGE_1_5_QUALITY=${GPT_IMAGE_1_5_QUALITY:-}
- EXTENDED_REASONING=${EXTENDED_REASONING:-}
- DISABLE_THINKING=${DISABLE_THINKING:-}
- WEB_GROUNDING=${WEB_GROUNDING:-}
- WEB_SEARCH_PROVIDER=${WEB_SEARCH_PROVIDER:-}
- WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-}
- SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- EXA_API_KEY=${EXA_API_KEY:-}
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
- DATABASE_URL=${DATABASE_URL:-}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING:-}
- COMFYUI_URL=${COMFYUI_URL:-}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW:-}
- MEM0_LLM_MODEL=${MEM0_LLM_MODEL:-${OLLAMA_MODEL:-llama3.1:latest}}
- MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-ollama}
- MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-${OLLAMA_URL:-http://host.docker.internal:11434}}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- MEM0_SPACY_MODEL=${MEM0_SPACY_MODEL:-en_core_web_sm}
- MEM0_REQUIRE_SPACY_MODEL=${MEM0_REQUIRE_SPACY_MODEL:-true}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL:-}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_BASE_URL=${OPENAI_COMPAT_IMAGE_BASE_URL:-}
- OPENAI_COMPAT_IMAGE_API_KEY=${OPENAI_COMPAT_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_MODEL=${OPENAI_COMPAT_IMAGE_MODEL:-}
- AUTH_USERNAME=${AUTH_USERNAME:-}
- AUTH_PASSWORD=${AUTH_PASSWORD:-}
- AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
- RESET_AUTH=${RESET_AUTH:-}
development:
platform: ${PRESENTON_DOCKER_PLATFORM:-linux/amd64}
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "${PRESENTON_HTTP_HOST_PORT:-5001}:80"
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
- "1455:1455"
volumes:
- .:/app
- presenton_root_node_modules:/app/node_modules
- presenton_document_extraction_liteparse:/app/document-extraction-liteparse
- ./app_data:/app_data
environment:
# Bundled PPTX/PDF export (Puppeteer); matches CI and production image.
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
- PRESENTON_HOST_HTTP_PORT=${PRESENTON_HOST_HTTP_PORT:-5001}
# Ollama is not baked into the image; set START_OLLAMA=true for runtime install, or use OLLAMA_URL.
- START_OLLAMA=${START_OLLAMA:-false}
- MIGRATE_DATABASE_ON_STARTUP=true
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS:-}
- LLM=${LLM:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-}
- DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_MODEL=${GOOGLE_MODEL:-}
- VERTEX_API_KEY=${VERTEX_API_KEY:-}
- VERTEX_MODEL=${VERTEX_MODEL:-}
- VERTEX_PROJECT=${VERTEX_PROJECT:-}
- VERTEX_LOCATION=${VERTEX_LOCATION:-}
- VERTEX_BASE_URL=${VERTEX_BASE_URL:-}
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
- AZURE_OPENAI_MODEL=${AZURE_OPENAI_MODEL:-}
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
- AZURE_OPENAI_BASE_URL=${AZURE_OPENAI_BASE_URL:-}
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
- BEDROCK_REGION=${BEDROCK_REGION:-}
- BEDROCK_API_KEY=${BEDROCK_API_KEY:-}
- BEDROCK_AWS_ACCESS_KEY_ID=${BEDROCK_AWS_ACCESS_KEY_ID:-}
- BEDROCK_AWS_SECRET_ACCESS_KEY=${BEDROCK_AWS_SECRET_ACCESS_KEY:-}
- BEDROCK_AWS_SESSION_TOKEN=${BEDROCK_AWS_SESSION_TOKEN:-}
- BEDROCK_PROFILE_NAME=${BEDROCK_PROFILE_NAME:-}
- BEDROCK_MODEL=${BEDROCK_MODEL:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-}
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-}
- FIREWORKS_API_KEY=${FIREWORKS_API_KEY:-}
- FIREWORKS_MODEL=${FIREWORKS_MODEL:-}
- FIREWORKS_BASE_URL=${FIREWORKS_BASE_URL:-}
- TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
- TOGETHER_MODEL=${TOGETHER_MODEL:-}
- TOGETHER_BASE_URL=${TOGETHER_BASE_URL:-}
- CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-}
- CEREBRAS_MODEL=${CEREBRAS_MODEL:-}
- CEREBRAS_BASE_URL=${CEREBRAS_BASE_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-}
- OLLAMA_URL=${OLLAMA_URL:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-}
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL:-}
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY:-}
- CUSTOM_MODEL=${CUSTOM_MODEL:-}
- LITELLM_BASE_URL=${LITELLM_BASE_URL:-}
- LITELLM_API_KEY=${LITELLM_API_KEY:-}
- LITELLM_MODEL=${LITELLM_MODEL:-}
- LMSTUDIO_BASE_URL=${LMSTUDIO_BASE_URL:-}
- LMSTUDIO_API_KEY=${LMSTUDIO_API_KEY:-}
- LMSTUDIO_MODEL=${LMSTUDIO_MODEL:-}
- CODEX_MODEL=${CODEX_MODEL:-}
- DISABLE_IMAGE_GENERATION=${DISABLE_IMAGE_GENERATION:-}
- IMAGE_PROVIDER=${IMAGE_PROVIDER:-}
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
- PIXABAY_API_KEY=${PIXABAY_API_KEY:-}
- DALL_E_3_QUALITY=${DALL_E_3_QUALITY:-}
- GPT_IMAGE_1_5_QUALITY=${GPT_IMAGE_1_5_QUALITY:-}
- EXTENDED_REASONING=${EXTENDED_REASONING:-}
- DISABLE_THINKING=${DISABLE_THINKING:-}
- WEB_GROUNDING=${WEB_GROUNDING:-}
- WEB_SEARCH_PROVIDER=${WEB_SEARCH_PROVIDER:-}
- WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-}
- SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- EXA_API_KEY=${EXA_API_KEY:-}
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
- DATABASE_URL=${DATABASE_URL:-}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING:-}
- COMFYUI_URL=${COMFYUI_URL:-}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW:-}
- MEM0_LLM_MODEL=${MEM0_LLM_MODEL:-${OLLAMA_MODEL:-llama3.1:latest}}
- MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-ollama}
- MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-${OLLAMA_URL:-http://host.docker.internal:11434}}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- MEM0_SPACY_MODEL=${MEM0_SPACY_MODEL:-en_core_web_sm}
- MEM0_REQUIRE_SPACY_MODEL=${MEM0_REQUIRE_SPACY_MODEL:-true}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL:-}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_BASE_URL=${OPENAI_COMPAT_IMAGE_BASE_URL:-}
- OPENAI_COMPAT_IMAGE_API_KEY=${OPENAI_COMPAT_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_MODEL=${OPENAI_COMPAT_IMAGE_MODEL:-}
- AUTH_USERNAME=${AUTH_USERNAME:-}
- AUTH_PASSWORD=${AUTH_PASSWORD:-}
- AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
- RESET_AUTH=${RESET_AUTH:-}
development-gpu:
platform: ${PRESENTON_DOCKER_PLATFORM:-linux/amd64}
build:
context: .
dockerfile: Dockerfile.dev
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "${PRESENTON_HTTP_HOST_PORT:-5001}:80"
# Required for Codex OAuth callback (OpenAI redirects browser directly to localhost:1455)
- "1455:1455"
volumes:
- .:/app
- presenton_root_node_modules:/app/node_modules
- presenton_document_extraction_liteparse:/app/document-extraction-liteparse
- ./app_data:/app_data
environment:
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
- PRESENTON_HOST_HTTP_PORT=${PRESENTON_HOST_HTTP_PORT:-5000}
- START_OLLAMA=${START_OLLAMA:-false}
- MIGRATE_DATABASE_ON_STARTUP=true
- CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS:-}
- LLM=${LLM:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
- DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-}
- DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_MODEL=${GOOGLE_MODEL:-}
- VERTEX_API_KEY=${VERTEX_API_KEY:-}
- VERTEX_MODEL=${VERTEX_MODEL:-}
- VERTEX_PROJECT=${VERTEX_PROJECT:-}
- VERTEX_LOCATION=${VERTEX_LOCATION:-}
- VERTEX_BASE_URL=${VERTEX_BASE_URL:-}
- AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-}
- AZURE_OPENAI_MODEL=${AZURE_OPENAI_MODEL:-}
- AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-}
- AZURE_OPENAI_BASE_URL=${AZURE_OPENAI_BASE_URL:-}
- AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION:-}
- AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-}
- BEDROCK_REGION=${BEDROCK_REGION:-}
- BEDROCK_API_KEY=${BEDROCK_API_KEY:-}
- BEDROCK_AWS_ACCESS_KEY_ID=${BEDROCK_AWS_ACCESS_KEY_ID:-}
- BEDROCK_AWS_SECRET_ACCESS_KEY=${BEDROCK_AWS_SECRET_ACCESS_KEY:-}
- BEDROCK_AWS_SESSION_TOKEN=${BEDROCK_AWS_SESSION_TOKEN:-}
- BEDROCK_PROFILE_NAME=${BEDROCK_PROFILE_NAME:-}
- BEDROCK_MODEL=${BEDROCK_MODEL:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-}
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-}
- FIREWORKS_API_KEY=${FIREWORKS_API_KEY:-}
- FIREWORKS_MODEL=${FIREWORKS_MODEL:-}
- FIREWORKS_BASE_URL=${FIREWORKS_BASE_URL:-}
- TOGETHER_API_KEY=${TOGETHER_API_KEY:-}
- TOGETHER_MODEL=${TOGETHER_MODEL:-}
- TOGETHER_BASE_URL=${TOGETHER_BASE_URL:-}
- CEREBRAS_API_KEY=${CEREBRAS_API_KEY:-}
- CEREBRAS_MODEL=${CEREBRAS_MODEL:-}
- CEREBRAS_BASE_URL=${CEREBRAS_BASE_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-}
- OLLAMA_URL=${OLLAMA_URL:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-}
- CUSTOM_LLM_URL=${CUSTOM_LLM_URL:-}
- CUSTOM_LLM_API_KEY=${CUSTOM_LLM_API_KEY:-}
- CUSTOM_MODEL=${CUSTOM_MODEL:-}
- LITELLM_BASE_URL=${LITELLM_BASE_URL:-}
- LITELLM_API_KEY=${LITELLM_API_KEY:-}
- LITELLM_MODEL=${LITELLM_MODEL:-}
- LMSTUDIO_BASE_URL=${LMSTUDIO_BASE_URL:-}
- LMSTUDIO_API_KEY=${LMSTUDIO_API_KEY:-}
- LMSTUDIO_MODEL=${LMSTUDIO_MODEL:-}
- CODEX_MODEL=${CODEX_MODEL:-}
- DISABLE_IMAGE_GENERATION=${DISABLE_IMAGE_GENERATION:-}
- IMAGE_PROVIDER=${IMAGE_PROVIDER:-}
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
- PIXABAY_API_KEY=${PIXABAY_API_KEY:-}
- DALL_E_3_QUALITY=${DALL_E_3_QUALITY:-}
- GPT_IMAGE_1_5_QUALITY=${GPT_IMAGE_1_5_QUALITY:-}
- EXTENDED_REASONING=${EXTENDED_REASONING:-}
- DISABLE_THINKING=${DISABLE_THINKING:-}
- WEB_GROUNDING=${WEB_GROUNDING:-}
- WEB_SEARCH_PROVIDER=${WEB_SEARCH_PROVIDER:-}
- WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-}
- SEARXNG_BASE_URL=${SEARXNG_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- EXA_API_KEY=${EXA_API_KEY:-}
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-}
- DATABASE_URL=${DATABASE_URL:-}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING:-}
- COMFYUI_URL=${COMFYUI_URL:-}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW:-}
- MEM0_LLM_MODEL=${MEM0_LLM_MODEL:-${OLLAMA_MODEL:-llama3.1:latest}}
- MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-ollama}
- MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-${OLLAMA_URL:-http://host.docker.internal:11434}}
- MEM0_ENABLED=${MEM0_ENABLED:-true}
- MEM0_DIR=${MEM0_DIR:-/app_data/mem0}
- MEM0_EMBEDDER_PROVIDER=${MEM0_EMBEDDER_PROVIDER:-fastembed}
- MEM0_EMBEDDER_MODEL=${MEM0_EMBEDDER_MODEL:-BAAI/bge-small-en-v1.5}
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- MEM0_SPACY_MODEL=${MEM0_SPACY_MODEL:-en_core_web_sm}
- MEM0_REQUIRE_SPACY_MODEL=${MEM0_REQUIRE_SPACY_MODEL:-true}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL:-}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_BASE_URL=${OPENAI_COMPAT_IMAGE_BASE_URL:-}
- OPENAI_COMPAT_IMAGE_API_KEY=${OPENAI_COMPAT_IMAGE_API_KEY:-}
- OPENAI_COMPAT_IMAGE_MODEL=${OPENAI_COMPAT_IMAGE_MODEL:-}
- AUTH_USERNAME=${AUTH_USERNAME:-}
- AUTH_PASSWORD=${AUTH_PASSWORD:-}
- AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
- RESET_AUTH=${RESET_AUTH:-}
volumes:
presenton_root_node_modules:
presenton_document_extraction_liteparse:
+192
View File
@@ -0,0 +1,192 @@
# Amazon Bedrock provider
Presenton can use **Amazon Bedrock** as the text LLM provider for presentation generation, editing, and chat. Configure Bedrock in the app UI (**Settings → Text provider**) or with environment variables / `userConfig.json` (Docker and desktop).
The backend sends your configured **Model** value to the Bedrock **Converse** API as `modelId`. That means you can use:
- Standard on-demand **model IDs** (for example `us.anthropic.claude-3-5-haiku-20241022-v1:0`)
- **Inference profile ARNs** (required for some newer models such as Claude Sonnet 4.6)
Region and credentials must match the model or profile you choose.
---
## Required configuration
| Field (UI) | Environment variable | Description |
| --- | --- | --- |
| **Provider** | `LLM=bedrock` | Select **Amazon Bedrock**. |
| **Region** | `BEDROCK_REGION` | AWS region for Bedrock (default: `us-east-1`). Must match where the model or inference profile is available. |
| **Model** | `BEDROCK_MODEL` | Model ID or inference profile ARN (see below). |
| **AWS Access Key ID** | `BEDROCK_AWS_ACCESS_KEY_ID` | IAM access key with Bedrock invoke permissions. |
| **AWS Secret Access Key** | `BEDROCK_AWS_SECRET_ACCESS_KEY` | Secret for the access key above. |
### Authentication
Use **one** of these patterns:
1. **AWS access key pair** (most common): Access Key ID + Secret Access Key (required fields above).
2. **Bedrock API key** (optional UI field): `BEDROCK_API_KEY` — if set, you do not need the access key pair.
Do not mix Bedrock API key auth with explicit AWS access key fields; the client accepts one method only.
---
## Optional (advanced) fields
Under **Advanced settings** in the Bedrock form (or via env):
| Field (UI) | Environment variable | When to use |
| --- | --- | --- |
| **AWS Session Token** | `BEDROCK_AWS_SESSION_TOKEN` | Temporary credentials (STS, assumed role, SSO session). |
| **AWS Profile Name** | `BEDROCK_PROFILE_NAME` | Use a named profile from `~/.aws/credentials` instead of inline keys (desktop/local dev). |
These are optional. Most Docker and server deployments only need region, model, and access key + secret.
---
## Model ID vs inference profile ARN
Many Bedrock models support **on-demand** invocation with a model ID like:
```text
us.anthropic.claude-3-5-haiku-20241022-v1:0
```
Some **newer** models (for example **Claude Sonnet 4.6**) do **not** support on-demand throughput with a plain model ID. For those you must use an **inference profile** ARN in the **Model** field.
In the AWS console: **Bedrock → Inference profiles** (or cross-region inference profiles), copy the full ARN, and paste it into Presentons **Model** field unchanged.
Presenton passes that string directly to Converse as `modelId`, so both formats work as long as AWS accepts them for your account and region.
---
## Common error: on-demand throughput not supported
You may see an error similar to:
```text
Invocation of model ID anthropic.claude-sonnet-4-6 with on-demand throughput isn't supported.
A model ID is currently required in model invocation requests for on-demand throughput.
A foundation model ARN or inference profile ARN is currently required in model invocation requests.
```
**What it means**
- The model ID you entered is valid in Bedrock, but that model **only** supports invocation through a **foundation model ARN** or **inference profile ARN**, not classic on-demand model IDs.
- This often applies to newer Anthropic models and **cross-region inference** setups.
**What to do**
1. Open **Amazon Bedrock** in the same **region** as `BEDROCK_REGION`.
2. Find the **inference profile** for the model (for example `us.anthropic.claude-sonnet-4-6`).
3. Copy the full **inference profile ARN**.
4. Paste the ARN into Presentons **Model** field (not the short model ID).
5. Ensure IAM allows `bedrock:InvokeModel` / Converse on that profile in that region.
---
## Example: working configuration (Claude Sonnet 4.6)
Use an inference profile ARN in **Model**, with region **us-east-1**:
| Setting | Value |
| --- | --- |
| Provider | `bedrock` |
| Region | `us-east-1` |
| Model | `arn:aws:bedrock:us-east-1:471112542209:inference-profile/us.anthropic.claude-sonnet-4-6` |
| AWS Access Key ID | Your IAM user or role access key |
| AWS Secret Access Key | Matching secret |
Replace `471112542209` with **your** AWS account ID. The ARN must come from **your** Bedrock console for the profile you enabled.
### Docker
```bash
docker run -it --name presenton -p 5001:80 \
-e LLM="bedrock" \
-e BEDROCK_REGION="us-east-1" \
-e BEDROCK_AWS_ACCESS_KEY_ID="YOUR_ACCESS_KEY_ID" \
-e BEDROCK_AWS_SECRET_ACCESS_KEY="YOUR_SECRET_ACCESS_KEY" \
-e BEDROCK_MODEL="arn:aws:bedrock:us-east-1:471112542209:inference-profile/us.anthropic.claude-sonnet-4-6" \
-e IMAGE_PROVIDER="pexels" \
-e PEXELS_API_KEY="YOUR_PEXELS_KEY" \
-e CAN_CHANGE_KEYS="false" \
-v "./app_data:/app_data" \
ghcr.io/presenton/presenton:latest
```
### On-demand model example (Haiku)
For models that support on-demand IDs:
```bash
-e BEDROCK_MODEL="us.anthropic.claude-3-5-haiku-20241022-v1:0"
```
---
## IAM permissions
The IAM principal (user or role) behind your keys needs permission to call Bedrock in the configured region, for example:
- `bedrock:InvokeModel`
- `bedrock:InvokeModelWithResponseStream`
Scope policies to the model IDs or inference profile ARNs you use. If you use inference profiles, include the profile ARN (or a wildcard your org allows) in the resource list.
Enable model access in the Bedrock console (**Model access**) for the foundation models tied to your profile.
---
## Troubleshooting
### Invalid model identifier
- **Symptom:** `ValidationException`, unknown model, or model not found.
- **Checks:**
- **Model** matches exactly what Bedrock shows (ID or full ARN, no extra spaces).
- For Sonnet 4.6class models, use an **inference profile ARN**, not only `anthropic.claude-…` without the profile path.
- Model access is **enabled** for your account in that region.
### Missing Bedrock permissions
- **Symptom:** `AccessDeniedException`, not authorized to perform `bedrock:InvokeModel`.
- **Checks:**
- IAM policy allows invoke on the model or inference profile.
- Keys belong to the intended account; no typo in access key or secret.
- If using `BEDROCK_PROFILE_NAME`, the profiles role has Bedrock permissions.
### Region mismatch
- **Symptom:** Model not found, or profile ARN rejected.
- **Checks:**
- `BEDROCK_REGION` is the region where the model/profile was created (for example `us-east-1` in the ARN path must match `BEDROCK_REGION`).
- Cross-region inference profiles still use a home region in the ARN; configure Presentons region to match AWS guidance for that profile.
### Unsupported on-demand invocation
- **Symptom:** Error text mentions *on-demand throughput isnt supported* or requires *inference profile ARN*.
- **Fix:** Switch **Model** from a plain model ID to the **inference profile ARN** (see [Common error](#common-error-on-demand-throughput-not-supported) above).
### Auth configuration errors at startup
- **Symptom:** “Bedrock auth is incomplete” or “Provide either api_key or AWS credentials”.
- **Fix:** Set `BEDROCK_API_KEY`, **or** both `BEDROCK_AWS_ACCESS_KEY_ID` and `BEDROCK_AWS_SECRET_ACCESS_KEY`. Do not leave all three empty.
---
## Environment variable reference
| Variable | Required | Notes |
| --- | --- | --- |
| `LLM` | Yes | Must be `bedrock`. |
| `BEDROCK_REGION` | Recommended | Default `us-east-1` if unset. |
| `BEDROCK_MODEL` | Yes | Model ID or inference profile ARN. |
| `BEDROCK_AWS_ACCESS_KEY_ID` | If not using API key | Pair with secret. |
| `BEDROCK_AWS_SECRET_ACCESS_KEY` | If not using API key | Pair with access key ID. |
| `BEDROCK_API_KEY` | Optional | Alternative to access key pair. |
| `BEDROCK_AWS_SESSION_TOKEN` | Optional | Temporary credentials. |
| `BEDROCK_PROFILE_NAME` | Optional | Named AWS profile. |
See also the [Deployment configurations](../README.md#-deployment-configurations) section in the main README.
+110
View File
@@ -0,0 +1,110 @@
# Electron Dependency Strategy
This is the working strategy for reducing first-run dependency installers in
the Electron app while keeping Presenton Apache-2.0.
## Recommendation
- Bundle Chrome for Testing with the Electron package (including Microsoft APPX)
for export rendering.
- Bundle ImageMagick under `resources/imagemagick/` for each platform build; the
packaged app validates that bundle during `afterPack`.
- Bundle the presentation export runtime for PPTX-to-HTML conversion and use
Chromium to render custom template previews.
- Extract modern OOXML/OpenDocument text directly without an office engine.
## Licensing Notes
This is engineering guidance, not legal advice.
ImageMagick is practical to bundle. The official license permits personal,
internal, and commercial use, and its terms are close to Apache-2.0. Keep the
ImageMagick license and notices in the distributed app.
Source: https://imagemagick.org/license/
Chromium/Chrome for Testing can be bundled, but the notices matter. Puppeteer
now targets Chrome for Testing for supported automation, and Chromium source is
BSD-style plus third-party licenses. Keep generated browser credits/notices with
the shipped runtime.
Sources:
- https://pptr.dev/supported-browsers
- https://chromium.googlesource.com/chromium/src/+/main/LICENSE
- https://www.chromium.org/chromium-os/licensing/
## Runtime Layout
Bundled Chromium:
```text
electron/resources/chromium/
presenton-runtime.json
chrome/<platform-build-id>/...
```
Populate it with:
```bash
cd electron
npm run prepare:export-chromium
```
Set `SKIP_BUNDLED_CHROMIUM=1` to keep the old first-run download behavior.
Bundled ImageMagick:
```text
electron/resources/imagemagick/<platform>-<arch>/
presenton-runtime.json
...
```
Examples:
```text
electron/resources/imagemagick/win32-x64/magick.exe
electron/resources/imagemagick/darwin-arm64/bin/magick
electron/resources/imagemagick/linux-x64/bin/magick
```
Populate it with:
```bash
cd electron
npm run prepare:imagemagick
```
Platform behavior:
- Windows downloads and validates the official portable `.7z` runtime.
- Linux downloads and validates the official AppImage, then writes a `bin/magick`
wrapper with `APPIMAGE_EXTRACT_AND_RUN=1` so it works without a host FUSE setup.
- macOS vendors a build-host ImageMagick prefix (`magick` on PATH, or
`IMAGEMAGICK_VENDOR_DIR`) and rewrites non-system dylib references into the
packaged runtime with `otool` and `install_name_tool`.
The app checks the manifest-backed bundle before PATH, Homebrew, MacPorts, or
other system installs.
## Current Behavior
- FastAPI receives `IMAGEMAGICK_BINARY`, `MAGICK_HOME`, and
`MAGICK_CONFIGURE_PATH` when the bundled or system ImageMagick runtime is
detected at startup.
- PPTX previews use the bundled PPTX-to-HTML converter and Chromium renderer.
- Modern OOXML/OpenDocument text extraction uses the bundled Python parser.
- Export Chromium and ImageMagick resolution check manifest-backed bundled app
runtimes before user or system locations.
## APPX / Store builds
Before `npm run build:electron`:
1. Run `npm run prepare:export-chromium` so Chromium is under `resources/chromium/`.
2. Run `npm run prepare:imagemagick` so ImageMagick is under
`resources/imagemagick/<platform>-<arch>/`.
3. The bundled export runtime and Chromium handle Template Studio previews.
Microsoft Store (MSIX/APPX) packages install under `Program Files\WindowsApps`.
Bundled Chrome cannot be launched in place from that folder; on first export the app
copies the browser tree to `%LOCALAPPDATA%\…\Cache\msix-export-chromium\` (same pattern
as the MSIX export runtime for Sharp). The portable EXE install does not need this copy.
@@ -0,0 +1,54 @@
# Custom Template Import Getting Started
This document explains the current PPTX import behavior from the custom
template page.
## What This Flow Does
The `Select a PPTX file` action on:
```txt
/custom-template
```
uses the original Template Studio UX. The slide-editor import backend is always
used.
The flow is:
1. Select a PPTX file.
2. Click `Check Fonts`.
3. Review available and missing fonts in the inline font management step.
4. Upload any missing fonts you want to preserve.
5. Continue to generate the old `Slide Preview` screen.
6. Click `Generate Template` to create the reusable template.
Template Studio still uses the same old UX: inline font management, slide
preview, progress, and generated preview cards. Only the Generate backend
changes. It sends the preview data to the Templates V2 API:
```txt
POST /api/v2/templates
```
That v2 request creates and saves the template, while the page stays on the
old `/custom-template` preview experience. The generated preview cards render
the raw Templates V2 `layouts.layouts[*]` JSON directly; they do not stage or
open a slide-editor deck.
Template Studio does not open the `Prepare fonts` dialog, create a slide-editor
deck, stage anything in IndexedDB, or redirect to `/slide-editor`.
## Verify The Flow
1. Start the app normally.
2. Open `/custom-template`.
3. Click `Select a PPTX file`.
4. Choose a `.pptx` file under 100 MB.
5. Confirm the selected file appears in the upload card.
6. Click `Check Fonts`.
7. Confirm the inline font management step appears.
8. Continue to preview.
9. Click `Generate Template`.
10. Confirm the page stays on `/custom-template`.
11. Confirm Generate creates the template with `POST /api/v2/templates`.
+56
View File
@@ -0,0 +1,56 @@
# macOS Development & Distribution
Guides for building and signing the Presenton Electron app on macOS.
| Guide | Description |
|-------|-------------|
| [Direct distribution](./direct-distribution.md) | Developer ID signing, notarization, DMG verification, and release commands for distribution outside the Mac App Store |
| [Mac App Store setup](./mac-app-store-setup.md) | Certificates, provisioning profiles, MAS build commands, and submission notes |
## Quick reference
All commands below run from the `electron/` directory on a Mac.
**First-time setup**
```bash
cd electron
npm run setup:env
```
**Run locally (development)**
```bash
npm run dev
```
**Build a DMG (local distribution, not App Store)**
```bash
npm run build:all
```
Output is written to `electron/dist/`. The default macOS target is a DMG built via `electron/build.js`.
For public releases outside the Mac App Store, use the signed and notarized direct distribution flow:
```bash
export APPLE_KEYCHAIN_PROFILE="presenton-notary"
npm run build:all:mac:signed
```
That assumes the release Mac already has a **Developer ID Application** certificate and a stored `notarytool` profile named `presenton-notary`. See [Direct distribution](./direct-distribution.md) for the exact one-time setup and verification commands.
**Build for the Mac App Store**
See [Mac App Store setup](./mac-app-store-setup.md) for certificates, provisioning profiles, and signing. Summary:
```bash
npm run build:all:mas-dev # development / TestFlight-style testing
npm run build:all:mas # distribution / App Store submission
```
## Related docs
- [Electron dependency strategy](../../electron-dependency-strategy.md) — bundled Chromium, ImageMagick, and export runtime
- [Project README — Electron section](../../../README.md) — prerequisites and high-level build steps
+300
View File
@@ -0,0 +1,300 @@
# macOS Direct Distribution
Use this guide to ship Presenton as a signed and notarized macOS app outside the Mac App Store. This is the correct path for a downloadable DMG from GitHub Releases or presenton.ai.
This is not a Mac App Store build. Do not use MAS provisioning profiles, App Store Connect upload, or App Review for this flow.
If you are building from a Mac that is already registered in Apple Developer, that is fine, but the registration is not what makes the public DMG trusted. Registered devices are for development provisioning and MAS-style testing. Public direct distribution requires a Developer ID Application certificate and Apple notarization.
## What This Produces
The signed release build creates:
```text
electron/dist/
Presenton-<version>.dmg
```
Users should be able to open the DMG and launch Presenton without macOS warning that the app is from an unidentified developer.
## How This Differs From MAS
| Area | Direct distribution | Mac App Store |
|------|---------------------|---------------|
| Certificate | Developer ID Application | Apple Distribution / 3rd Party Mac Developer |
| Provisioning profile | Not used | Required |
| App Sandbox | Not required by this build | Required |
| Notarization | Required | App Store processing handles distribution review |
| Output | DMG for download | PKG for App Store Connect |
The MAS guide in the referenced gist is still useful for the Apple Developer account and signing concepts, but this repo's direct distribution flow deliberately stops before MAS provisioning and App Store submission.
## Repository Configuration
Direct macOS distribution is configured in `electron/build.js`:
- `mac.hardenedRuntime` is enabled for non-MAS macOS builds.
- `mac.entitlements` uses `electron/build/entitlements.mac.plist`.
- `mac.entitlementsInherit` uses `electron/build/entitlements.mac.inherit.plist`.
- `mac.notarize` is enabled unless `PRESENTON_SKIP_NOTARIZATION=1`.
- `dmg.sign` is disabled because the app bundle is signed and notarized; signing the DMG itself is not required.
Use the release scripts in `electron/package.json`:
```bash
npm run build:all:mac:signed
npm run build:electron:mac:signed
npm run dist:mac:signed
```
The `:mac:signed` scripts set `PRESENTON_REQUIRE_MAC_SIGNING=1`, so they fail before packaging if a Developer ID certificate or notarization credentials are missing.
## Exact Setup For A Release Mac
Run this once on the Mac that will build releases.
### 1. Install Apple Command Line Tools
```bash
xcode-select --install
xcrun notarytool --version
```
`notarytool` must be available. If it is missing, install or update Xcode.
### 2. Install The Developer ID Certificate
In Xcode:
1. Open **Xcode** -> **Settings** -> **Accounts**.
2. Select the Apple Developer team.
3. Click **Manage Certificates**.
4. Click **+**.
5. Select **Developer ID Application**.
Do not choose **Apple Development** for public distribution. Do not choose **Apple Distribution** unless you are building for the Mac App Store. For a downloadable DMG, the certificate must be **Developer ID Application**.
Confirm the certificate is visible to `codesign`:
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
Expected shape:
```text
Developer ID Application: Your Company Name (TEAMID)
```
Most release Macs only have one Developer ID Application certificate, so you usually do not need to export a signing identity. If multiple Developer ID certificates are installed, set the exact identity before building:
```bash
export PRESENTON_MAC_SIGN_IDENTITY="Developer ID Application: Your Company Name (TEAMID)"
```
### 3. Store Notarization Credentials Once
Create an app-specific password for the Apple ID, then store notarization credentials in the local Keychain:
```bash
xcrun notarytool store-credentials "presenton-notary" \
--apple-id "apple-id@example.com" \
--team-id "TEAMID" \
--password "app-specific-password"
```
After that, the only notarization environment variable needed for normal local release builds is:
```bash
export APPLE_KEYCHAIN_PROFILE="presenton-notary"
```
To avoid exporting it manually every shell session, add it to your shell profile:
```bash
echo 'export APPLE_KEYCHAIN_PROFILE="presenton-notary"' >> ~/.zshrc
source ~/.zshrc
```
If you store the profile in a non-default keychain, also set:
```bash
export APPLE_KEYCHAIN="/path/to/keychain"
```
Do not commit notarization credentials, app-specific passwords, `.p8` keys, or real certificates to the repo.
## Build A Signed DMG
After the one-time setup, a normal release build is:
```bash
cd electron
npm run build:all:mac:signed
```
If `APPLE_KEYCHAIN_PROFILE` is not in your shell profile, run it inline:
```bash
cd electron
APPLE_KEYCHAIN_PROFILE="presenton-notary" npm run build:all:mac:signed
```
For the very first build on a fresh checkout, run setup first:
```bash
cd electron
npm run setup:env
npm run build:all:mac:signed
```
If the app resources are already built and you only need to re-run Electron packaging:
```bash
cd electron
npm run dist:mac:signed
```
The signed DMG is written to `electron/dist/`.
## What You Do Not Need
For public direct distribution, you do not need:
- A registered test device.
- A `.provisionprofile` file.
- `PRESENTON_MAS_DEV_IDENTITY`.
- `PRESENTON_MAS_DISTRIBUTION_IDENTITY`.
- `PRESENTON_APP_STORE_VERSION`.
- App Store Connect upload.
- App Review approval.
Those are MAS or development-provisioning concerns. The public DMG path is Developer ID signing plus notarization.
## Other Credential Options
The local Keychain profile above is the recommended flow for a human-operated release Mac. CI can use App Store Connect API keys instead:
```bash
export APPLE_API_KEY="/secure/path/AuthKey_XXXXXXXXXX.p8"
export APPLE_API_KEY_ID="XXXXXXXXXX"
export APPLE_API_ISSUER="00000000-0000-0000-0000-000000000000"
npm run build:all:mac:signed
```
You can also pass Apple ID credentials directly, but this is less convenient than a stored Keychain profile:
```bash
export APPLE_ID="apple-id@example.com"
export APPLE_APP_SPECIFIC_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="TEAMID"
npm run build:all:mac:signed
```
## Verify The Release
Run these checks before publishing the DMG.
### 1. Check Code Signature
Replace the app path if the architecture-specific output folder differs.
```bash
codesign --verify --deep --strict --verbose=2 "dist/mac/Presenton.app"
codesign -dv --verbose=4 "dist/mac/Presenton.app" 2>&1 | grep -E "Authority|TeamIdentifier|Runtime"
```
Expected:
- `Authority=Developer ID Application: ...`
- `TeamIdentifier=S6W5C54KL6`
- Hardened Runtime is present.
### 2. Check Notarization Stapling
```bash
xcrun stapler validate "dist/mac/Presenton.app"
```
Expected:
```text
The validate action worked!
```
### 3. Check Gatekeeper
```bash
spctl --assess --type execute --verbose=4 "dist/mac/Presenton.app"
spctl --assess --type open --verbose=4 "dist/Presenton-0.8.8-beta.dmg"
```
Expected shape:
```text
accepted
source=Notarized Developer ID
```
### 4. Test On Another Mac
Download the DMG on a Mac that did not build it, mount it, drag Presenton to `/Applications`, and launch it normally. This catches quarantine and Gatekeeper behavior that local build machines can hide.
## Troubleshooting
**The signed release build says the Developer ID identity is missing**
Install a Developer ID Application certificate in Keychain Access, or set:
```bash
export PRESENTON_MAC_SIGN_IDENTITY="Developer ID Application: Your Company Name (TEAMID)"
```
**The build says notarization credentials are missing**
Set one complete credential group:
```bash
export APPLE_KEYCHAIN_PROFILE="presenton-notary"
```
or:
```bash
export APPLE_ID="apple-id@example.com"
export APPLE_APP_SPECIFIC_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="TEAMID"
```
or:
```bash
export APPLE_API_KEY="/secure/path/AuthKey_XXXXXXXXXX.p8"
export APPLE_API_KEY_ID="XXXXXXXXXX"
export APPLE_API_ISSUER="00000000-0000-0000-0000-000000000000"
```
**macOS still says the app is damaged or cannot be opened**
Run the verification commands above. If stapling fails, rebuild with valid notarization credentials and do not publish the DMG until `spctl` reports `source=Notarized Developer ID`.
**You need a local unsigned build**
Use the generic build script instead of the signed release script:
```bash
npm run build:all
```
For release artifacts, always use:
```bash
npm run build:all:mac:signed
```
## References
- Steve Crickmore's Electron MAS release gist: https://gist.github.com/steve981cr/def310670dfd9ed1439bf31cc734f941
- Electron signing and notarization docs: https://www.electronjs.org/docs/latest/tutorial/code-signing
- electron-builder notarization docs: https://www.electron.build/code-signing-mac.html#notarize
- Apple notarization docs: https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution
+201
View File
@@ -0,0 +1,201 @@
# Mac App Store Setup
Presentons Electron app is configured for Mac App Store (MAS) distribution with App Sandbox. MAS builds must be produced on macOS because Apples signing tools are required.
This guide covers Apple Developer setup, local provisioning files, and the npm scripts in `electron/package.json` on `main`.
## Overview
| Target | Purpose | npm script |
|--------|---------|------------|
| `dmg` | Direct download outside the App Store | `npm run build:all:mac:signed` |
| `mas-dev` | Sandbox testing on registered Macs | `npm run build:all:mas-dev` |
| `mas` | App Store submission | `npm run build:all:mas` |
Build configuration lives in `electron/build.js`. Key identifiers:
- **Bundle ID:** `com.presenton.presenton`
- **Team ID:** `S6W5C54KL6` (set in `extendInfo.ElectronTeamID`)
## MAS files in the repo
These are checked in under `electron/build/`:
| File | Role |
|------|------|
| `entitlements.mas.plist` | Main app entitlements (sandbox, network, JIT, user-selected files) |
| `entitlements.mas.inherit.plist` | Inherited entitlements for helper processes and bundled binaries |
| `MacAppStore.provisionprofile.replace_me` | Placeholder — replace with your real App Store profile (see below) |
`build.js` validates provisioning profiles with `security cms -D` before building. Invalid or placeholder files will fail the build with a clear error.
## What you need from Apple
### 1. Apple Development certificate
Xcode → **Settings****Accounts** → your team → **Manage Certificates****+** → **Apple Development**.
Used for `mas-dev` builds and local sandbox testing.
### 2. Apple Distribution certificate
Same path as above, but choose **Apple Distribution**.
Used for `mas` (App Store submission) builds.
### 3. App ID (Bundle ID)
[Apple Developer Portal](https://developer.apple.com/account) → **Certificates, Identifiers & Profiles****Identifiers****App IDs**.
Create a macOS App ID that matches `com.presenton.presenton` (or update `APP_ID` in `electron/build.js` if you use a different ID).
### 4. Provisioning profiles
Create **two** macOS profiles and download each `.provisionprofile` file into `electron/build/`:
#### AppleDevelopment (MAS development)
- Portal → **Profiles****+**
- Type: **macOS App Development**
- App ID: your Presenton app ID
- Certificate: **Apple Development**
- Save as `electron/build/AppleDevelopment.provisionprofile`
`build.js` also accepts `AppleDev.provisionprofile` or `AppDev.provisionprofile` as fallbacks.
#### MacAppStore (MAS distribution)
- Portal → **Profiles****+**
- Type: **Mac App Store**
- App ID: your Presenton app ID
- Certificate: **Apple Distribution**
- Save as `electron/build/MacAppStore.provisionprofile`
Verify profiles decode correctly:
```bash
cd electron
security cms -D -i build/AppleDevelopment.provisionprofile
security cms -D -i build/MacAppStore.provisionprofile
```
Provisioning profiles expire (typically yearly). Regenerate and replace the local files when they do.
## Environment variables
| Variable | Used for | Description |
|----------|----------|-------------|
| `PRESENTON_MAC_TARGET` | Set by npm scripts | `mas-dev` or `mas` (do not set manually when using the scripts below) |
| `PRESENTON_MAS_DEV_IDENTITY` | `mas-dev` | Signing identity, e.g. `Apple Development: Your Name (TEAMID)` |
| `PRESENTON_MAS_DISTRIBUTION_IDENTITY` | `mas` | Distribution identity, e.g. `Apple Distribution: Your Org (TEAMID)` |
| `PRESENTON_MAS_IDENTITY` | `mas` | Alias for distribution identity |
| `CSC_NAME` | Either target | Fallback if the target-specific identity vars are unset |
| `PRESENTON_APP_STORE_VERSION` | `mas` | App Store **version** (`x.y.z`). Required when `package.json` version contains a suffix like `-beta` |
| `PRESENTON_APP_STORE_BUILD` | `mas` | App Store **build** number. Defaults to the short version if unset |
Example for a beta package version:
```bash
export PRESENTON_APP_STORE_VERSION=1.0.0
export PRESENTON_APP_STORE_BUILD=42
```
## Build commands
Run from `electron/` after `npm run setup:env` (first time only).
### Full build (recommended)
Rebuilds Next.js, FastAPI, export runtime, and packages the app:
```bash
# MAS development — runs on Macs in your dev provisioning profile
PRESENTON_MAS_DEV_IDENTITY="Apple Development: Your Name (TEAMID)" \
npm run build:all:mas-dev
# MAS distribution — for App Store Connect upload
PRESENTON_MAS_DISTRIBUTION_IDENTITY="Apple Distribution: Your Org (TEAMID)" \
PRESENTON_APP_STORE_VERSION=1.0.0 \
npm run build:all:mas
```
### Package only (resources already built)
If you already ran `build:all` and only need to re-run the Electron packager:
```bash
PRESENTON_MAS_DEV_IDENTITY="Apple Development: Your Name (TEAMID)" \
npm run dist:mac:mas-dev
PRESENTON_MAS_DISTRIBUTION_IDENTITY="Apple Distribution: Your Org (TEAMID)" \
PRESENTON_APP_STORE_VERSION=1.0.0 \
npm run dist:mac:mas
```
### Direct DMG (not App Store)
For a signed and notarized macOS disk image that does not go through App Store approval:
```bash
npm run build:all:mac:signed
```
See [Direct distribution](./direct-distribution.md) for the Developer ID certificate, notarization, and verification flow.
Artifacts land in `electron/dist/`.
## Output locations
Exact paths depend on architecture (`arm64` vs `x64`). Typical layouts:
```text
electron/dist/
mas-dev-arm64/ # unsigned or dev-signed .app (mas-dev)
mas-arm64/ # distribution-signed .app (mas)
Presenton-<version>.pkg # MAS installer for upload
Presenton-<version>.dmg # signed and notarized direct distribution DMG
```
Upload the `.pkg` from the `mas` build to [App Store Connect](https://appstoreconnect.apple.com).
## Icons
The default macOS icon is `electron/resources/ui/assets/images/presenton_short_filled.png`.
For App Store packaging you can provide a proper `.icns` at `electron/build/icon.icns` and update the `mac.icon` field in `electron/build.js` if you want a custom store icon.
Source PNGs for generating an icon set are in `electron/build/icon.iconset/`.
## Testing notes
- **MAS-signed apps** require the MAS Electron build (`mas` or `mas-dev` targets). A normal DMG build is not sandbox-compatible in the same way.
- **`mas-dev` builds** run only on Macs registered in your development provisioning profile.
- **`mas` (distribution) builds** usually do not launch locally; they are meant for App Store Connect processing and release.
- **`mas-dev` signing** uses `--timestamp=none` in `build.js` so local signing works without a network timestamp server.
## Troubleshooting
**“Missing MAS development/distribution provisioning profile”**
Place the correct `.provisionprofile` in `electron/build/` (see [Provisioning profiles](#4-provisioning-profiles)).
**“macOS could not decode” a profile**
Re-download the profile from the Developer Portal. Do not commit real profiles to git.
**“Cannot derive an App Store version from package version”**
Set `PRESENTON_APP_STORE_VERSION` to three integers, e.g. `1.0.0`.
**“MAS builds must be run on macOS”**
Run the build on a Mac with Xcode command-line tools installed (`xcode-select --install`).
**FastAPI or export binary not executable inside the .app**
`build.js` runs an `afterPack` hook that sets executable permissions on bundled binaries. If signing fails afterward, check that entitlements allow the sandboxed app to execute helpers in `Contents/Resources/app/resources/`.
## See also
- [macOS dev README](./README.md) — quick reference for all macOS build targets
- [Electron dependency strategy](../../electron-dependency-strategy.md) — what gets bundled in the app
+12
View File
@@ -0,0 +1,12 @@
## 2. Remove the runtime adapter path
From the Template V2 presentation flow, remove calls to:
- adaptTemplateV2LayoutToSlide
- normalizeTemplateV2Slide
- serializeTemplateV2LayoutFromSlide
- serializeTemplateV2ContentFromSlide
- applyGeneratedSlideContentToLayout
- old normalized slide layout resolver
Changes in editor, to adpot the slide.ui /components/elements.
@@ -0,0 +1,295 @@
# Template V2 direct component rendering
This document describes the Template V2 presentation runtime on branch
`dipesh/pre-159-move-new-editor-code-to-main-codebase-for-proper-integration`.
The governing rule is:
> Select a layout → write the complete layout to `slide.ui` → render `slide.ui`
> directly → edit `slide.ui` → persist `slide.ui`.
`slide.ui` is the only frontend source of truth for Template V2 layout and
content. The presentation renderer does not read Template V2 content from
`slide.content` and does not maintain a second canonical `Slide` copy.
## Active flow
```text
selected Template V2 layout
|
v
slide.ui = raw 1280 x 720 layout JSON
|
v
V1ContentRender
|
v
TemplateV2KonvaSlide
|
+-- render raw ui.components with react-konva
+-- edit an immutable, structurally shared raw UI tree
+-- dispatch updateSlideUi({ index, ui })
|
v
Redux presentationData.slides[index].ui
|
v
debounced presentation autosave
```
The presentation runtime no longer performs this conversion loop:
```text
raw slide.ui
-> canonical editor Slide
-> layout resolver / SlideSurface
-> reverse serialization
-> slide.ui plus hidden slide.content state
```
This removes redundant object construction, schema translation, Jotai editor
state, layout resolution, and reverse serialization from the render/edit path.
## Primary implementation files
| File | Responsibility |
| --- | --- |
| [`TemplateV2KonvaSlide.tsx`](../servers/nextjs/components/slide-editor/surface/TemplateV2KonvaSlide.tsx) | The preserved Dipesh component, now responsible for raw UI rendering, selection, transforms, existing element toolbars, inline editing, insertions, chart/image integration, history, and UI commits. |
| [`V1ContentRender.tsx`](../servers/nextjs/app/(presentation-generator)/components/V1ContentRender.tsx) | Routes Template V2 presentation slides directly to the raw renderer. |
| [`events.ts`](../servers/nextjs/components/slide-editor/events/events.ts) | Shared insert, chart, and active-surface event contracts. |
| [`presentationGeneration.ts`](../servers/nextjs/store/slices/presentationGeneration.ts) | Owns `updateSlideUi`, which updates only the selected slide's `ui`. |
| [`NewSlide.tsx`](../servers/nextjs/app/(presentation-generator)/presentation/components/NewSlide.tsx) | Writes a selected Template V2 layout directly into a new slide's `ui`. |
| [`blank-slide.ts`](../servers/nextjs/app/(presentation-generator)/_shared/blank-slide.ts) | Supplies an empty raw UI with `components: []`. |
| [`useAutoSave.tsx`](../servers/nextjs/app/(presentation-generator)/presentation/hooks/useAutoSave.tsx) | Persists the Redux presentation after its debounce. |
There are two `NewSlide.tsx` entry points in the presentation-generator tree.
Both now use the same `slide.ui` behavior.
## Raw UI contract
The renderer consumes the backend Template V2 shape without converting units:
```json
{
"id": "layout_id",
"background": "#FFFFFF",
"components": [
{
"id": "hero",
"position": { "x": 80, "y": 80 },
"size": { "width": 1120, "height": 560 },
"elements": [
{
"type": "text",
"name": "title",
"position": { "x": 0, "y": 0 },
"size": { "width": 900, "height": 140 },
"runs": [{ "text": "Title" }]
}
]
}
]
}
```
Geometry is interpreted in the native `1280 x 720` stage coordinate system:
- component positions are slide-relative;
- top-level element positions are component-relative;
- nested element positions are parent-relative;
- positions, sizes, font sizes, gaps, padding, strokes, and radii remain raw
UI values.
The loose raw boundary is intentional. Alias readers handle known snake-case
and camel-case variants without rewriting the entire tree.
## Rendering and editing
`TemplateV2KonvaSlide` creates one fixed-size Konva Stage and renders
each raw component as a clipped Group. Raw elements are traversed recursively
through `children`, `elements`, `child`, or repeated `item` structures.
Supported visuals include:
- text and text lists;
- images and SVG;
- rectangles, ellipses, lines, groups, containers, flex, and grid;
- tables;
- bar, line, area, and pie-style charts;
- infographic/progress primitives.
Interaction behavior:
- components can be selected, dragged, resized, and rotated;
- a selected element can be dragged, resized, and rotated;
- the first pointer interaction on an unselected child continues to select or
move its component; a selected child receives its own drag;
- layout-managed children receive `__presenton_manual_position` after a manual
transform so the layout engine does not immediately overwrite the edit;
- text, text-list, table, and SVG values use inline overlays;
- images use the upload API and save the returned source in the raw element;
- charts use the shared chart-panel event bridge;
- the existing `ElementToolbar` controls are retained as a selected-element
editor boundary; only that selected element is projected into editor units,
while rendering and persistence remain raw `slide.ui` operations;
- component-level design-variable controls are retained through the same
editor boundary and write their changes back into the raw component tree;
- undo and redo store raw UI snapshots, not canonical editor Slides.
## Backend-shape compatibility
The renderer reads and preserves the backend's current data shapes:
- TextList items may be arrays of text runs, direct text records, or legacy
strings.
- Table cells may contain `runs`, a direct `text`, and either `color` or `fill`.
Editing preserves the existing cell and first-run style metadata.
- Chart series read `values`, with `data` accepted as a compatibility alias.
This matters because a direct renderer must not silently depend on the canonical
editor adapter to normalize these values first.
## Commit and persistence behavior
Every edit ends at one mutation boundary:
```ts
updateSlideUi({
index: slideIndex,
ui: nextUi,
})
```
The reducer changes only:
```text
presentationData.slides[slideIndex].ui
```
It does not replace the whole slide and does not write an editor snapshot into
`slide.content`. The existing autosave observes the Redux presentation object
and persists the updated `ui` after its debounce.
New Template V2 slides are initialized as follows:
```text
layout selection -> newSlide.ui = selected raw layout
empty selection -> newSlide.ui = { background: "#FFFFFF", components: [] }
```
`content: {}` is currently retained on newly created slides for the shared API
shape, but it is not a Template V2 rendering or editing source.
## Adapter cleanup status
The active Template V2 presentation runtime has no calls to:
- `adaptTemplateV2LayoutToSlide`;
- `normalizeTemplateV2Slide`;
- `serializeTemplateV2LayoutFromSlide`;
- `serializeTemplateV2ContentFromSlide`;
- `applyGeneratedSlideContentToLayout`;
- the old normalized slide layout resolver.
`TemplateV2KonvaSlide` was preserved and changed in place. Its old whole-slide
adapter, Jotai deck hydration, `SlideSurface` route, reverse serializers, and
hidden `__template_v2_konva_slide__` content fallback were removed.
Some similarly named utilities remain for separate consumers:
- `TemplateV2LayoutPreview` still uses `adaptTemplateV2LayoutToSlide` to feed
the shared preview surface.
- The reusable slide editor still uses `normalizeTemplateV2Slide` for canonical
`Slide` model conversion where needed.
- The Blocks/palette boundary can still convert a selected library component
into an insertion payload. This conversion occurs only on insertion, not on
every render or edit.
Deleting these shared utilities globally would break template previews and the
standalone canonical editor without improving presentation-render performance.
They are not mounted in the direct presentation path.
## Backend generation boundary
The FastAPI generator currently creates semantic `slide.content` and then calls
`_apply_template_v2_content_to_ui()` during generation/streaming. The result is
a hydrated `slide.ui`, which is what the frontend renders and persists.
That server operation is generation-time work, not a browser render adapter.
The stricter final architecture should generate named element values directly
into a copied UI tree and keep `slide.content` empty for Template V2. Removing
the server hydrator before replacing the generator output would produce blank
template values, so it is intentionally outside this frontend runtime cleanup.
## Performance analysis
The direct path is structurally cheaper than the old presentation path because
it removes full-tree model conversion and reverse conversion from mounts and
edits. It also avoids initializing the standalone slide editor's state graph for
each presentation surface.
The editor hot path is optimized as follows:
- the incoming immutable UI becomes the initial draft by reference; no
full-tree startup clone is performed;
- edits use structural sharing and copy only the UI/component/element path that
changed;
- unchanged components, nested elements, and visual nodes are memoized;
- undo/redo stores immutable UI references in a bounded 50-entry history
instead of deep-cloning the complete UI for every edit;
- inline typing keeps its draft inside the textarea component, so keystrokes do
not rerender the Stage;
- repeated `item` rendering reuses the immutable source object rather than
cloning it per occurrence and render;
- view-only Layers disable Konva hit testing;
- global deck history runs at the debounced autosave boundary and stores
immutable structurally shared snapshots instead of cloning/stringifying the
complete deck on every editor commit;
- autosave change detection uses Redux object identity rather than serializing
the complete presentation merely to compare it.
Costs that remain include redraw of the affected Konva Layer,
selected-element toolbar projection, and request serialization performed by the
API client when autosave executes.
The next performance work should be evidence-driven:
1. Profile initial render, drag-end commit, inline-edit commit, and autosave on a
large real deck.
2. Split unusually large slides across carefully measured Konva Layers if canvas
redraw is still a bottleneck after React subtree memoization.
3. Move autosave comparison/persistence to slide-level patches if full-deck
JSON serialization is measurable.
4. Add browser performance tests before claiming a specific speedup.
The architecture eliminates known redundant work, but “blazing fast” still
requires measurements on representative decks.
## Verification checklist
For a Template V2 slide:
1. Confirm selecting a normal or blank layout immediately creates `slide.ui`.
2. Confirm `V1ContentRender` passes only `slide.ui` plus slide identity to the
direct renderer.
3. Render text, TextList, table, image, chart, flex, grid, and nested elements.
4. Drag, resize, and rotate both a component and a selected element.
5. Edit text/list/table values and verify their raw source shape is preserved.
6. Insert a palette element and a reusable block.
7. Replace an image and update a chart.
8. Undo/redo, reload, and confirm the saved values remain in `slide.ui`.
9. Confirm the presentation runtime does not create
`__template_v2_konva_slide__` in `slide.content`.
10. Confirm template previews and the standalone slide editor still work,
because their adapters were deliberately retained.
## Rules to preserve
1. Treat `slide.ui` as the only Template V2 frontend source of truth.
2. Do not add a renderer-side merge from `slide.content`.
3. Do not convert the whole UI to a canonical `Slide` for presentation display.
4. Preserve unknown raw fields while editing known fields.
5. Keep raw geometry in one coordinate system.
6. Keep preview/editor compatibility code outside the presentation runtime.
7. Persist through `updateSlideUi` and the existing autosave boundary.
8. Benchmark before adding caching complexity.
BIN
View File
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
.env
.env.*
*.env
.venv
__pycache__
.pytest_cache
.next
node_modules
out
user_data
app_data
tmp
debug
.fastembed_cache
.codex
generated_models
nltk
chroma
container.db
.next-build
.cursor
app_dist
resources/fastapi
resources/nextjs
dist
eng.traineddata
.cache/
.cache/export-runtime/
# presenton-export runtime (downloaded via npm run setup:export-runtime / scripts/sync-export-runtime.cjs)
resources/export/
# Bundled export Chromium (Chrome for Testing) — npm run prepare:export-chromium
# Includes .exe, .dll, .pak, .manifest, .hyb, .dat, and other browser binaries.
resources/chromium/
# Optional bundled ImageMagick binaries — see docs/electron-dependency-strategy.md
resources/imagemagick/
*.pkg
*.provisionprofile
*.cer
*.certSigningRequest
*.mobileprovision
*.p12
*.p8
*.toc
*.zip
*.pyc
# Agent skill artifacts
.agents/
skills-lock.json
+164
View File
@@ -0,0 +1,164 @@
import { ipcMain } from "electron";
import fs from "fs";
import path from "path";
import { getUserConfig } from "../utils";
import { nextjsDir } from "../utils/constants";
export function setupApiHandlers() {
// Handler for can-change-keys API
ipcMain.handle("api:can-change-keys", async () => {
const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false";
return { canChange: canChangeKeys };
});
// Handler for has-required-key API
ipcMain.handle("api:has-required-key", async () => {
let keyFromFile = "";
try {
const cfg = getUserConfig();
keyFromFile = cfg?.OPENAI_API_KEY || "";
} catch {
// Silent error handling
}
const keyFromEnv = process.env.OPENAI_API_KEY || "";
const hasKey = Boolean((keyFromFile || keyFromEnv).trim());
return { hasKey };
});
// Reads persisted user config so runtime toggles from the settings page
// are picked up immediately without requiring an app restart.
ipcMain.handle("api:telemetry-status", async () => {
const cfg = getUserConfig();
const fromConfig = cfg.DISABLE_ANONYMOUS_TRACKING;
const fromEnv = process.env.DISABLE_ANONYMOUS_TRACKING;
const raw = fromConfig ?? fromEnv ?? "";
const isDisabled = raw === "true" || raw === "True";
return { telemetryEnabled: !isDisabled };
});
// Handler for save-layout API
ipcMain.handle("api:save-layout", async (event, { layout_name, components }) => {
try {
if (!layout_name || !components || !Array.isArray(components)) {
throw new Error("Invalid request body. Expected layout_name and components array.");
}
// Define the layouts directory path
const layoutsDir = path.join(process.cwd(), "app_data", "layouts", layout_name);
// Create the directory if it doesn't exist
if (!fs.existsSync(layoutsDir)) {
fs.mkdirSync(layoutsDir, { recursive: true });
}
// Save each component as a separate file
const savedFiles = [];
for (const component of components) {
const { slide_number, component_code, component_name } = component;
if (!component_code || !component_name) {
console.warn(
`Skipping component for slide ${slide_number}: missing code or name`
);
continue;
}
const fileName = `${component_name}.tsx`;
const filePath = path.join(layoutsDir, fileName);
const cleanComponentCode = component_code
.replace(/```tsx/g, "")
.replace(/```/g, "");
fs.writeFileSync(filePath, cleanComponentCode, "utf8");
savedFiles.push({
slide_number,
component_name,
file_path: filePath,
file_name: fileName,
});
}
return {
success: true,
layout_name,
path: layoutsDir,
saved_files: savedFiles.length,
components: savedFiles,
};
} catch (error) {
console.error("Error saving layout:", error);
throw new Error("Failed to save layout components");
}
});
// Handler for templates API (static list)
ipcMain.handle("api:templates", async () => {
try {
const templatesPath = path.join(nextjsDir, "presentation-templates");
if (!fs.existsSync(templatesPath)) {
return [];
}
const items = fs.readdirSync(templatesPath, { withFileTypes: true });
const templateDirectories = items
.filter(item => item.isDirectory())
.map(dir => dir.name);
const allLayouts: Array<{ templateName: string; templateID: string; files: string[]; settings: any }> = [];
// Scan each template directory for layout files and settings
for (const templateName of templateDirectories) {
try {
const templatePath = path.join(templatesPath, templateName);
const templateFiles = fs.readdirSync(templatePath);
// Filter for .tsx files and exclude any non-layout files
const layoutFiles = templateFiles.filter(file =>
file.endsWith('.tsx') &&
!file.startsWith('.') &&
!file.includes('.test.') &&
!file.includes('.spec.') &&
file !== 'settings.json'
);
// Read settings.json if it exists
let settings: any = null;
const settingsPath = path.join(templatePath, 'settings.json');
try {
const settingsContent = fs.readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsContent);
} catch (settingsError) {
console.warn(`No settings.json found for template ${templateName} or invalid JSON`);
// Provide default settings if settings.json is missing or invalid
settings = {
description: `${templateName} presentation layouts`,
ordered: false,
default: false
};
}
if (layoutFiles.length > 0) {
allLayouts.push({
templateName: templateName,
templateID: templateName,
files: layoutFiles,
settings: settings
});
}
} catch (error) {
console.error(`Error reading template directory ${templateName}:`, error);
// Continue with other templates even if one fails
}
}
return allLayouts;
} catch (error) {
console.error("Error reading templates:", error);
return [];
}
});
}
+583
View File
@@ -0,0 +1,583 @@
import { ipcMain } from "electron";
import {
getAppDataDir,
getCacheDir,
getDownloadsDir,
getTempDir,
resourceBaseDir,
} from "../utils/constants";
import fs from "fs";
import path from "path";
import { showFileDownloadedDialog } from "../utils/dialog";
import { v4 as uuidv4 } from 'uuid';
import { ChildProcess, spawn } from "child_process";
import { safeError, safeLog } from "../utils/safe-console";
import { addMainBreadcrumb } from "../sentry/main";
import { BoundedTextBuffer, memorySnapshotMb } from "../utils/memory";
import { destroyChildProcessStdio, terminateChildProcess } from "../utils/lifecycle";
import { killProcess } from "../utils";
import {
isExportChromiumAvailable,
removeBrokenExportChromiumCaches,
resolveLaunchableExportChromiumPath,
} from "../utils/export-chromium";
import { resolveExportSpawnTarget } from "../utils/export-msix-runtime";
type BinaryFormat = "elf" | "mach-o" | "pe" | "unknown";
type RuntimeCandidate = {
command: string;
label: string;
useElectronRunAsNode?: boolean;
};
const activeExportProcesses = new Set<ChildProcess>();
function showFileDownloadedDialogInBackground(filePath: string): void {
setImmediate(() => {
void showFileDownloadedDialog(filePath).catch((error) => {
safeError("[Export] Failed to show downloaded-file dialog:", error);
});
});
}
export async function stopActiveExportProcesses(): Promise<void> {
const processes = Array.from(activeExportProcesses);
activeExportProcesses.clear();
await Promise.all(
processes.map((process) =>
terminateChildProcess(process, "Export", killProcess).catch((error) => {
safeError("[Export] Failed to stop active export process:", error);
}),
),
);
}
export function setupExportHandlers() {
ipcMain.handle("file-downloaded", async (_, filePath: string): Promise<IPCStatus> => {
const fileName = path.basename(filePath);
const destinationPath = path.join(getDownloadsDir(), fileName);
await fs.promises.rename(filePath, destinationPath);
showFileDownloadedDialogInBackground(destinationPath);
return { success: true };
});
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf") => {
let exportTempDir: string | undefined;
try {
await removeBrokenExportChromiumCaches();
if (!isExportChromiumAvailable()) {
return {
success: false,
message:
"Export requires Chromium. Restart Presenton and complete the Chromium setup step when prompted.",
};
}
addMainBreadcrumb("export", "electron.ipc_export.start", {
id,
title,
exportAs,
memory: memorySnapshotMb(),
});
const params = new URLSearchParams({ id });
if (process.env.NEXT_PUBLIC_FAST_API) {
params.set("fastapiUrl", process.env.NEXT_PUBLIC_FAST_API);
}
const pptUrl = `${process.env.NEXT_PUBLIC_URL}/pdf-maker?${params.toString()}`;
const exportTask = {
type: "export",
url: pptUrl,
format: exportAs,
title: title,
fastapiUrl: process.env.NEXT_PUBLIC_FAST_API,
};
const randomUuid = uuidv4();
const tempDir = getTempDir();
const appDataDir = getAppDataDir();
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 packagedExportRoot = path.join(resourceBaseDir, "resources", "export");
const packagedExportScriptPath = path.join(packagedExportRoot, "index.js");
const { scriptPath: exportScriptPath, converterPath: pythonModulePath } =
await resolveExportSpawnTarget(
packagedExportRoot,
packagedExportScriptPath,
(exportRoot) => resolveConverterPath(exportRoot)
);
safeLog("[Export] Spawning export task with config:", {
exportAs,
id,
title,
pptUrl,
exportTaskPath,
exportScriptPath,
pythonModulePath,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
});
const chromiumExecutablePath = await resolveLaunchableExportChromiumPath();
if (!chromiumExecutablePath) {
return {
success: false,
message:
"Export could not prepare Chromium for this Microsoft Store install. Restart Presenton and try again.",
};
}
const puppeteerTempDir = path.join(tempDir, "puppeteer");
const puppeteerCacheDir = path.join(getCacheDir(), "puppeteer");
await Promise.all([
fs.promises.mkdir(puppeteerTempDir, { recursive: true }),
fs.promises.mkdir(puppeteerCacheDir, { recursive: true }),
]);
const baseExportEnv = {
...process.env,
TEMP_DIRECTORY: tempDir,
APP_DATA_DIRECTORY: appDataDir,
NODE_ENV: "development",
BUILT_PYTHON_MODULE_PATH: pythonModulePath,
PUPPETEER_EXECUTABLE_PATH: chromiumExecutablePath,
PUPPETEER_CACHE_DIR: puppeteerCacheDir,
PUPPETEER_TMP_DIR: puppeteerTempDir,
};
const responsePath = exportTaskPath.replace(".json", ".response.json");
const responseRaw = await runExportTaskAndReadResponse(
exportScriptPath,
exportTaskPath,
responsePath,
baseExportEnv
);
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(getDownloadsDir(), path.basename(exportFilePath));
await moveFile(exportFilePath, destinationPath);
showFileDownloadedDialogInBackground(destinationPath);
addMainBreadcrumb("export", "electron.ipc_export.finish", {
id,
exportAs,
success: true,
memory: memorySnapshotMb(),
});
return { success: true, message: "Export completed." };
} catch (error: any) {
safeError("[Export] Error exporting presentation:", error);
addMainBreadcrumb("export", "electron.ipc_export.error", {
id,
exportAs,
message: error?.message,
memory: memorySnapshotMb(),
});
return { success: false, message: error?.message ?? "Export failed." };
} finally {
if (exportTempDir) {
await fs.promises.rm(exportTempDir, { recursive: true, force: true }).catch(() => {});
}
}
});
}
function getExportRuntimeCandidates(): RuntimeCandidate[] {
const candidates: RuntimeCandidate[] = [];
const push = (command: string | undefined, label: string, useElectronRunAsNode?: boolean) => {
if (!command) return;
const trimmed = command.trim();
if (!trimmed) return;
candidates.push({ command: trimmed, label, useElectronRunAsNode });
};
// Explicit overrides first.
push(process.env.EXPORT_NODE_BINARY, "EXPORT_NODE_BINARY");
push(process.env.NODE_BINARY, "NODE_BINARY");
// Match the older stable export approach: `spawn("node", ...)` first.
push("node", "node");
// Additional system node entries.
if (process.platform === "win32") {
push("node.exe", "node.exe");
push("node.cmd", "node.cmd");
} else {
push("/usr/bin/node", "/usr/bin/node");
push("/usr/local/bin/node", "/usr/local/bin/node");
}
// Fallback: Electron runtime in Node mode.
push(process.execPath, "process.execPath (electron-as-node)", true);
const deduped: RuntimeCandidate[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
const key = `${candidate.command}|${candidate.useElectronRunAsNode ? "electron-node" : "plain"}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(candidate);
}
return deduped;
}
function isRetryableRuntimeError(error: any): boolean {
const code = String(error?.code || "");
// Try another runtime when binary is missing or cannot be spawned.
return ["ENOENT", "EACCES", "EPERM", "EAGAIN"].includes(code);
}
async function runExportTaskWithRuntimeFallback(
exportScriptPath: string,
exportTaskPath: string,
baseEnv: NodeJS.ProcessEnv
): Promise<void> {
const runtimeCandidates = getExportRuntimeCandidates();
const failures: string[] = [];
for (const runtime of runtimeCandidates) {
let repairedBrowserCache = false;
while (true) {
safeLog(`[Export] Trying runtime: ${runtime.label} -> ${runtime.command}`);
try {
await runExportTaskOnce(
runtime,
exportScriptPath,
exportTaskPath,
baseEnv
);
return;
} catch (error: any) {
if (!repairedBrowserCache && (await repairBrokenPuppeteerCache(error))) {
repairedBrowserCache = true;
safeLog(`[Export] Retrying runtime ${runtime.label} after repairing Puppeteer cache.`);
continue;
}
const details = [
`${runtime.label}: ${error?.message || "Unknown error"}`,
error?.code ? `code=${error.code}` : "",
]
.filter(Boolean)
.join(" ");
failures.push(details);
safeError(`[Export] Runtime failed (${runtime.label})`, error);
if (!isRetryableRuntimeError(error)) {
throw error;
}
break;
}
}
}
throw new Error(
`Export failed to start with all runtimes.\n${failures.map((f) => `- ${f}`).join("\n")}`
);
}
async function runExportTaskAndReadResponse(
exportScriptPath: string,
exportTaskPath: string,
responsePath: string,
baseEnv: NodeJS.ProcessEnv
): Promise<string> {
const maxAttempts = 2;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await runExportTaskWithRuntimeFallback(exportScriptPath, exportTaskPath, baseEnv);
try {
return await fs.promises.readFile(responsePath, "utf8");
} catch (error: any) {
if (error?.code === "ENOENT" && attempt < maxAttempts) {
safeError(
`[Export] Response file missing after successful export process. Retrying (${attempt}/${maxAttempts}).`,
error
);
lastError = error;
continue;
}
throw error;
}
}
throw lastError instanceof Error
? lastError
: new Error("Export task completed without creating a response file.");
}
function extractMissingChromeFolderFromError(error: unknown): string | null {
const message = String((error as any)?.message ?? "");
const match = message.match(/browser folder \(([^)]+)\) exists but the executable \(([^)]+)\) is missing/i);
if (!match) return null;
const browserFolder = path.normalize(match[1]);
const missingExecutablePath = path.normalize(match[2]);
if (!browserFolder || !missingExecutablePath.startsWith(browserFolder)) return null;
const normalized = browserFolder.toLowerCase().replace(/\\/g, "/");
if (!normalized.includes("/.cache/puppeteer/chrome/")) return null;
return browserFolder;
}
async function repairBrokenPuppeteerCache(error: unknown): Promise<boolean> {
const browserFolder = extractMissingChromeFolderFromError(error);
if (!browserFolder) return false;
try {
await fs.promises.rm(browserFolder, { recursive: true, force: true });
safeLog(`[Export] Removed broken Puppeteer browser cache: ${browserFolder}`);
return true;
} catch (rmError) {
safeError(`[Export] Failed to remove broken Puppeteer cache at ${browserFolder}`, rmError);
return false;
}
}
async function runExportTaskOnce(
runtime: RuntimeCandidate,
exportScriptPath: string,
exportTaskPath: string,
baseEnv: NodeJS.ProcessEnv
): Promise<void> {
const runtimeEnv = {
...baseEnv,
...(runtime.useElectronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
};
const exportTaskProcess = spawn(runtime.command, [exportScriptPath, exportTaskPath], {
stdio: ["ignore", "pipe", "pipe"],
cwd: path.dirname(exportScriptPath),
windowsHide: process.platform === "win32",
env: runtimeEnv,
});
activeExportProcesses.add(exportTaskProcess);
safeLog("[Export] Child process started:", {
runtime: runtime.label,
pid: exportTaskProcess.pid,
memory: memorySnapshotMb(),
});
addMainBreadcrumb("export", "electron.export_child.start", {
runtime: runtime.label,
pid: exportTaskProcess.pid,
memory: memorySnapshotMb(),
});
const stdoutTail = new BoundedTextBuffer();
const stderrTail = new BoundedTextBuffer();
const onStdoutData = (data: Buffer) => {
const text = data.toString();
stdoutTail.append(text);
safeLog(`[Export] ${text}`);
};
const onStderrData = (data: Buffer) => {
const text = data.toString();
stderrTail.append(text);
safeError(`[Export] ${text}`);
};
exportTaskProcess.stdout?.on("data", onStdoutData);
exportTaskProcess.stderr?.on("data", onStderrData);
let settled = false;
let resolvePromise: (() => void) | null = null;
let rejectPromise: ((error: Error) => void) | null = null;
const cleanup = () => {
exportTaskProcess.stdout?.removeListener("data", onStdoutData);
exportTaskProcess.stderr?.removeListener("data", onStderrData);
exportTaskProcess.removeListener("error", onError);
exportTaskProcess.removeListener("close", onClose);
activeExportProcesses.delete(exportTaskProcess);
};
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
cleanup();
callback();
};
const onError = (error: Error) => {
finish(() => rejectPromise?.(error));
};
const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
finish(() => {
safeLog("[Export] Child process exited:", {
runtime: runtime.label,
pid: exportTaskProcess.pid,
code,
signal,
memory: memorySnapshotMb(),
});
addMainBreadcrumb("export", "electron.export_child.exit", {
runtime: runtime.label,
pid: exportTaskProcess.pid,
code,
signal,
memory: memorySnapshotMb(),
});
if (code === 0) {
resolvePromise?.();
} else {
const stderrText = stderrTail.toString() || "(no stderr)";
const stdoutText = stdoutTail.toString();
const detail =
stderrText !== "(no stderr)"
? stderrText
: stdoutText
? `stdout: ${stdoutText}`
: "";
const error: NodeJS.ErrnoException = new Error(
`Export process exited with code ${code ?? "unknown"}${signal ? ` signal ${signal}` : ""}${detail ? `. ${detail}` : ""}`
);
error.code = `EXIT_${code ?? "UNKNOWN"}`;
rejectPromise?.(error);
}
});
};
try {
await new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
exportTaskProcess.once("error", onError);
exportTaskProcess.once("close", onClose);
});
} finally {
cleanup();
destroyChildProcessStdio(exportTaskProcess);
}
}
async function resolveConverterPath(exportRoot: string): Promise<string> {
const pyDir = path.join(exportRoot, "py");
const extension = process.platform === "win32" ? ".exe" : "";
const converterCandidates = [
path.join(pyDir, `convert-${process.platform}-${process.arch}${extension}`),
path.join(pyDir, `convert-${process.platform}${extension}`),
...(process.platform === "win32"
? [path.join(pyDir, "convert.exe"), path.join(pyDir, "convert")]
: [path.join(pyDir, "convert")]),
];
const converterPath = await findFirstExistingPath(converterCandidates);
if (!converterPath) {
throw new Error(
[
"No converter binary found for export.",
"Expected one of:",
...converterCandidates.map((candidate) => ` - ${candidate}`),
].join("\n")
);
}
const format = await detectBinaryFormat(converterPath);
if (!isBinaryFormatCompatible(format)) {
throw new Error(
[
`Converter binary is not valid for ${process.platform}/${process.arch}.`,
`Selected converter: ${converterPath}`,
`Detected format: ${format}`,
"Please bundle a platform-correct converter binary (for example convert-darwin-arm64 or convert-darwin-x64).",
].join("\n")
);
}
return converterPath;
}
async function findFirstExistingPath(paths: string[]): Promise<string | null> {
for (const candidate of paths) {
try {
await fs.promises.access(candidate, fs.constants.F_OK);
return candidate;
} catch {
continue;
}
}
return null;
}
async function detectBinaryFormat(binaryPath: string): Promise<BinaryFormat> {
const fd = await fs.promises.open(binaryPath, "r");
try {
const header = Buffer.alloc(4);
await fd.read(header, 0, 4, 0);
if (header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) {
return "elf";
}
if (header[0] === 0x4d && header[1] === 0x5a) {
return "pe";
}
const magic = header.readUInt32BE(0);
if (
magic === 0xfeedface ||
magic === 0xcefaedfe ||
magic === 0xfeedfacf ||
magic === 0xcffaedfe ||
magic === 0xcafebabe ||
magic === 0xbebafeca
) {
return "mach-o";
}
return "unknown";
} finally {
await fd.close();
}
}
function isBinaryFormatCompatible(format: BinaryFormat): boolean {
if (process.platform === "darwin") return format === "mach-o";
if (process.platform === "linux") return format === "elf";
if (process.platform === "win32") return format === "pe";
return true;
}
function resolveExportedFilePath(responseData: any): string | null {
if (responseData?.path && typeof responseData.path === "string") {
return path.isAbsolute(responseData.path)
? responseData.path
: path.join(getAppDataDir(), 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);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import { settingsStore } from '../services/settings-store';
const FOOTER_KEY = 'footer';
export function setupFooterHandlers() {
ipcMain.handle('get-footer', async () => {
try {
const properties = settingsStore.get(FOOTER_KEY);
return { properties };
} catch (error) {
console.error('Error retrieving footer properties:', error);
throw error;
}
});
ipcMain.handle('set-footer', async (_, properties: any) => {
try {
if (!properties) {
throw new Error('Properties are required');
}
settingsStore.set(FOOTER_KEY, properties);
return { success: true };
} catch (error) {
console.error('Error saving footer properties:', error);
throw error;
}
});
}
+21
View File
@@ -0,0 +1,21 @@
import { setupExportHandlers } from "./export_handlers";
import { setupUserConfigHandlers } from "./user_config_handlers";
import { setupSlideMetadataHandlers } from "./slide_metadata";
import { setupReadFile } from "./read_file";
import { setupFooterHandlers } from "./footer_handlers";
import { setupThemeHandlers } from "./theme_handlers";
import { setupUploadImage } from "./upload_image";
import { setupLogHandler } from "./log_handler";
import { setupApiHandlers } from "./api_handlers";
export function setupIpcHandlers() {
setupExportHandlers();
setupUserConfigHandlers();
setupSlideMetadataHandlers();
setupReadFile();
setupFooterHandlers();
setupThemeHandlers();
setupUploadImage();
setupLogHandler();
setupApiHandlers();
}
+50
View File
@@ -0,0 +1,50 @@
import { ipcMain } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { getLogsDir } from '../utils/constants';
export function setupLogHandler() {
// Ensure logs directory exists
const logsDir = getLogsDir();
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logFilePath = path.join(logsDir, 'nextjs.log');
// Handle log writing through IPC - non-blocking
ipcMain.handle('write-nextjs-log', (_, logData: string) => {
try {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${logData}\n`;
// Use non-blocking write
fs.appendFile(logFilePath, logEntry, (err) => {
if (err) {
console.error('Error writing to log file:', err);
}
});
return { success: true };
} catch (error) {
console.error('Error in log handler:', error);
return { success: false, error: (error as Error).message };
}
});
// Handle log clearing
ipcMain.handle('clear-nextjs-logs', () => {
try {
// Create a new empty file, effectively clearing the old one
fs.writeFile(logFilePath, '', (err) => {
if (err) {
console.error('Error clearing log file:', err);
}
});
return { success: true };
} catch (error) {
console.error('Error in clear logs handler:', error);
return { success: false, error: (error as Error).message };
}
});
}
+14
View File
@@ -0,0 +1,14 @@
import { ipcMain } from "electron";
import { readReadableLocalFile } from "../utils/readable-file-access";
export function setupReadFile() {
ipcMain.handle("read-file", async (_, filePath: unknown) => {
try {
const content = readReadableLocalFile(filePath);
return { content };
} catch (error) {
console.error("Error reading file:", error);
throw error;
}
});
}
+378
View File
@@ -0,0 +1,378 @@
import { BrowserWindow, ipcMain } from "electron";
import fs from 'fs';
import path from 'path';
import { getTempDir } from "../utils/constants";
import { hasLiveWebContents, safeCloseWindow } from "../utils/lifecycle";
interface Position {
left: number;
top: number;
width: number;
height: number;
}
interface FontStyles {
name: string;
size: number;
bold: boolean;
weight: number;
color: string;
}
interface TextElement {
position: Position;
paragraphs: {
alignment: number;
text: string;
font: FontStyles;
}[];
}
interface PictureElement {
position: Position;
picture: {
is_network: boolean;
path: string;
};
shape: string | null;
object_fit: {
fit: string | null;
focus: number[];
};
overlay: string | null;
border_radius: number[];
}
interface BoxElement {
position: Position;
type: number;
fill: {
color: string;
};
border_radius: number;
stroke: {
color: string;
thickness: number;
};
shadow: {
radius: number;
color: string;
offset: number;
opacity: number;
angle: number;
};
}
interface LineElement {
position: Position;
lineType: number;
thickness: string;
color: string;
}
interface GraphElement {
position: Position;
picture: {
is_network: boolean;
path: string;
};
border_radius: number[];
}
type SlideElement = TextElement | PictureElement | BoxElement | LineElement | GraphElement;
export function setupSlideMetadataHandlers() {
ipcMain.handle("get-slide-metadata", async (_, url: string, theme: string, customColors?: any) => {
let win: BrowserWindow | null = null;
const getLiveWebContents = () => {
if (!hasLiveWebContents(win)) {
throw new Error("Slide metadata window was closed before capture completed.");
}
return win.webContents;
};
const executeInMetadataWindow = async <T>(script: string): Promise<T> => {
try {
return (await getLiveWebContents().executeJavaScript(script)) as T;
} catch (error) {
if (!hasLiveWebContents(win)) {
throw new Error("Slide metadata window was closed before capture completed.");
}
throw error;
}
};
try {
win = new BrowserWindow({
width: 1920,
height: 1080,
webPreferences: {
webSecurity: false,
preload: path.join(__dirname, '../preloads/index.js'),
},
show: false,
});
win.once("closed", () => {
win = null;
});
await win.loadURL(url, { userAgent: 'electron' });
await executeInMetadataWindow(`
new Promise((resolve) => {
const check = () => {
const el = document.querySelector('[data-element-type="slide-container"]');
if (el) return resolve(true);
setTimeout(check, 200);
};
check();
});
`);
const metadata = await executeInMetadataWindow<any[]>(
`
(() => {
const rgbToHex = (color) => {
if (!color || color === "transparent" || color === "none") return "000000";
if (color.startsWith("#")) return color.replace("#", "");
const matches = color.match(/\\d+/g);
if (!matches) return "000000";
const [r, g, b] = matches.map(x => parseInt(x));
return [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("");
};
const slidesMetadata = [];
const slideContainers = document.querySelectorAll('[data-element-type="slide-container"]');
slideContainers.forEach((container) => {
const containerEl = container;
containerEl.style.width = "1280px";
containerEl.style.height = "720px";
containerEl.style.transform = "none";
const containerRect = containerEl.getBoundingClientRect();
const slideIndex = parseInt(containerEl.getAttribute("data-slide-index") || "0");
const backgroundColor = rgbToHex(window.getComputedStyle(containerEl).backgroundColor);
const elements = [];
const slideElements = containerEl.querySelectorAll('[data-slide-element]:not([data-element-type="slide-container"])');
slideElements.forEach((element) => {
const el = element;
const elementRect = el.getBoundingClientRect();
const computedStyle = window.getComputedStyle(el);
const position = {
left: Math.round(elementRect.left - containerRect.left),
top: Math.round(elementRect.top - containerRect.top),
width: Math.round(elementRect.width),
height: Math.round(elementRect.height),
};
const elementType = el.getAttribute("data-element-type");
if (!elementType) return;
switch (elementType) {
case "text":
elements.push({
position,
paragraphs: [{
alignment: el.getAttribute("data-is-align") === 'true' ? 2 : 1,
text: el.getAttribute("data-text-content") || el.textContent || "",
font: {
name: computedStyle.fontFamily.split('_')[2] || 'Inter',
size: parseInt(computedStyle.fontSize),
bold: parseInt(computedStyle.fontWeight) >= 500,
weight: parseInt(computedStyle.fontWeight),
color: rgbToHex(computedStyle.color),
},
}],
});
break;
case "picture":
const imgEl = el.tagName.toLowerCase() === "img" ? el : el.querySelector("img");
if (imgEl) {
elements.push({
position,
picture: {
is_network: imgEl.src.startsWith("http"),
path: imgEl.src || imgEl.getAttribute("data-image-path") || "",
},
shape: imgEl.getAttribute('data-image-type'),
object_fit: {
fit: imgEl.getAttribute('data-object-fit'),
focus: [
parseFloat(imgEl.getAttribute('data-focial-point-x') || '0'),
parseFloat(imgEl.getAttribute('data-focial-point-y') || '0'),
],
},
overlay: el.getAttribute("data-is-icon") ? "ffffff" : null,
border_radius: Array(4).fill(parseInt(computedStyle.borderRadius) || 0),
});
}
break;
case "graph":
elements.push({
position,
picture: {
is_network: true,
path: \`__GRAPH_PLACEHOLDER__\${el.getAttribute("data-element-id")}\`,
},
border_radius: [0, 0, 0, 0],
});
break;
case "slide-box":
case "filledbox":
const boxShadow = computedStyle.boxShadow;
let shadowRadius = 0;
let shadowColor = "000000";
let shadowOffsetX = 0;
let shadowOffsetY = 0;
let shadowOpacity = 0;
if (boxShadow && boxShadow !== "none") {
const boxShadowRegex =
/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+),?\\s*([\\d.]+)?\\)?\\s+(-?\\d+)px\\s+(-?\\d+)px\\s+(-?\\d+)px/;
const match = boxShadow.match(boxShadowRegex);
if (match) {
const r = match[1];
const g = match[2];
const b = match[3];
const rgbStr = "rgb(" + r + ", " + g + ", " + b + ")";
shadowColor = rgbToHex(rgbStr);
shadowOpacity = match[4] ? parseFloat(match[4]) : 1;
shadowOffsetX = parseInt(match[5]);
shadowOffsetY = parseInt(match[6]);
shadowRadius = parseInt(match[7]);
}
}
elements.push({
position,
type:
computedStyle.borderRadius === "9999px" ||
computedStyle.borderRadius === "50%"
? 9
: 5,
fill: {
color: rgbToHex(computedStyle.backgroundColor),
},
border_radius: parseInt(computedStyle.borderRadius) || 0,
stroke: {
color: rgbToHex(computedStyle.borderColor),
thickness: parseInt(computedStyle.borderWidth) || 0,
},
shadow: {
radius: shadowRadius,
color: shadowColor,
offset: Math.sqrt(
shadowOffsetX * shadowOffsetX +
shadowOffsetY * shadowOffsetY
),
opacity: shadowOpacity,
angle: Math.round(
(Math.atan2(shadowOffsetY, shadowOffsetX) * 180) / Math.PI
),
},
});
break;
case "line":
elements.push({
position,
lineType: 1,
thickness: computedStyle.borderWidth || computedStyle.height,
color: rgbToHex(
computedStyle.borderColor || computedStyle.backgroundColor
),
});
break;
}
});
slidesMetadata.push({ slideIndex, backgroundColor, elements });
});
return slidesMetadata;
})();
`
);
// ✅ Handle Graphs: capture each graph element as an image
const graphIds: string[] = await executeInMetadataWindow(`
(() => {
return Array.from(document.querySelectorAll('[data-element-type="graph"]'))
.map(el => el.getAttribute("data-element-id"))
.filter(Boolean);
})();
`);
for (const id of graphIds) {
try {
// Scroll into view first
await executeInMetadataWindow(`
document.querySelector('[data-element-id="${id}"]').scrollIntoView({ behavior: 'instant', block: 'center' });
`);
// Wait a bit for any animations/rendering to complete
await new Promise((r) => setTimeout(r, 2000));
const bounds: Electron.Rectangle | null = await executeInMetadataWindow(`
(() => {
const el = document.querySelector('[data-element-id="${id}"]');
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
})();
`);
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
continue;
}
const image = await getLiveWebContents().capturePage(bounds);
const buffer = image.toJPEG(100);
if (buffer.length === 0) {
console.error("Empty buffer! Graph not captured.");
continue;
}
const filePath = path.join(getTempDir(), `chart-${id}-${Date.now()}.jpeg`);
fs.writeFileSync(filePath, buffer);
// Update metadata
metadata.forEach((slide: any) => {
slide.elements.forEach((element: any) => {
if ("picture" in element && element.picture.path === `__GRAPH_PLACEHOLDER__${id}`) {
element.picture.path = filePath;
}
});
});
} catch (err) {
console.error(`Failed to capture or save chart-${id}:`, err);
}
}
return metadata;
} catch (error) {
console.error("Error during page preparation:", error);
throw error;
} finally {
// if (browser) await browser.close();
safeCloseWindow(win);
}
});
}
+32
View File
@@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import { settingsStore } from '../services/settings-store';
const THEME_KEY = 'theme';
export function setupThemeHandlers() {
ipcMain.handle('get-theme', async () => {
try {
const theme = settingsStore.get(THEME_KEY);
return { theme };
} catch (error) {
console.error('Error retrieving theme:', error);
throw error;
}
});
ipcMain.handle('set-theme', async (_, themeData: any) => {
try {
if (!themeData) {
throw new Error('Theme data is required');
}
settingsStore.set(THEME_KEY, themeData);
return { success: true };
} catch (error) {
console.error('Error saving theme:', error);
throw error;
}
});
}
+28
View File
@@ -0,0 +1,28 @@
import { ipcMain } from "electron";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { getUserDataDir } from "../utils/constants";
export function setupUploadImage() {
ipcMain.handle("upload-image", async (_, file: Buffer) => {
try {
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(getUserDataDir(), "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
// Generate unique filename
const filename = `${crypto.randomBytes(16).toString('hex')}.png`;
const filePath = path.join(uploadsDir, filename);
// Write file to disk
await fs.writeFileSync(filePath, file);
// Return the path with file:// protocol for Electron
return `file://${filePath}`;
} catch (error) {
console.error("Error saving image:", error);
throw error;
}
});
}
+16
View File
@@ -0,0 +1,16 @@
import { ipcMain } from "electron";
import { getUserConfig, setUserConfig } from "../utils";
export function setupUserConfigHandlers() {
ipcMain.handle("get-user-config", async (_, __) => {
return getUserConfig();
});
ipcMain.handle("set-user-config", async (_, userConfig: UserConfig) => {
setUserConfig(userConfig);
});
ipcMain.handle("get-can-change-keys", async (_, __) => {
return process.env.CAN_CHANGE_KEYS !== "false";
});
}
+630
View File
@@ -0,0 +1,630 @@
require("dotenv").config();
import { app, BrowserWindow, globalShortcut } from "electron";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { findUnusedPorts, setupEnv, setUserConfig } from "./utils";
import { startFastApiServer, startNextJsServer } from "./utils/servers";
import {
baseDir,
ensureDirectoriesExist,
fastapiDir,
getAppDataDir,
getCacheDir,
getTempDir,
getUserConfigPath,
initializeAppPaths,
isDev,
localhost,
nextjsDir,
resourceBaseDir,
} from "./utils/constants";
import { setupIpcHandlers } from "./ipc";
import { stopActiveExportProcesses } from "./ipc/export_handlers";
import { getLiteParseRunnerPath } from "./utils/liteparse-check";
import {
buildPathWithImageMagick,
resolveImageMagickRuntime,
type ImageMagickRuntime,
} from "./utils/imagemagick-runtime";
import { startUpdateChecker, stopUpdateChecker } from "./utils/update-checker";
import {
addMainBreadcrumb,
captureMainException,
initMainSentry,
setMainSentryRuntimeContext,
} from "./sentry/main";
import { installSafeConsole, safeError, safeLog, safeStderrWrite, safeWarn } from "./utils/safe-console";
import { memorySnapshotMb } from "./utils/memory";
import {
isSupportedExternalUrl,
openExternalUrl,
showOpenTargetErrorDialog,
} from "./utils/open-target";
import {
finishChromiumCacheRecovery,
prepareChromiumCacheRecovery,
type ChromiumCacheRecoveryStatus,
} from "./utils/chromium-cache-recovery";
import { resolveLaunchableExportChromiumPath } from "./utils/export-chromium";
installSafeConsole();
// Local and ad-hoc signed macOS builds otherwise prompt for Keychain access when
// Chromium initializes encrypted session storage.
if (process.platform === "darwin") {
app.commandLine.appendSwitch("use-mock-keychain");
}
// Linux Chromium requires chrome-sandbox to be root-owned mode 4755; unpacked
// dist/linux-unpacked builds usually lack that. Disable sandbox only when invalid.
if (process.platform === "linux") {
try {
const sandboxPath = path.join(path.dirname(process.execPath), "chrome-sandbox");
if (fs.existsSync(sandboxPath)) {
const st = fs.statSync(sandboxPath);
const hasSetuid = (st.mode & 0o4777) === 0o4755;
const rootOwned = st.uid === 0;
if (!(hasSetuid && rootOwned)) {
app.commandLine.appendSwitch("no-sandbox");
}
} else {
app.commandLine.appendSwitch("no-sandbox");
}
} catch {
app.commandLine.appendSwitch("no-sandbox");
}
// Fall back to /tmp instead of shared memory to avoid Chromium crashes
// on systems where /dev/shm is unavailable/misconfigured.
app.commandLine.appendSwitch("disable-dev-shm-usage");
}
var win: BrowserWindow | undefined;
type ManagedServerProcess = Awaited<ReturnType<typeof startFastApiServer>>;
var fastApiServer: ManagedServerProcess | undefined;
var nextjsServer: ManagedServerProcess | undefined;
let isStopping = false;
function getLiveMainWindow(): BrowserWindow | undefined {
if (!win || win.isDestroyed()) {
return undefined;
}
return win;
}
type ProcessGoneDetails = {
reason?: string;
type?: string;
exitCode?: number;
serviceName?: string;
name?: string;
};
function profileHash(userDataDir: string): string {
return crypto.createHash("sha256").update(userDataDir).digest("hex").slice(0, 16);
}
function updateSentryRuntimeContext(cacheRecovery: ChromiumCacheRecoveryStatus): void {
setMainSentryRuntimeContext({
profileHash: profileHash(electronAppPaths.userDataDir),
cacheRecoveryStatus: cacheRecovery.status,
cacheRecoveryMode: cacheRecovery.mode,
});
}
function recordProcessGone(kind: "child" | "renderer", details: ProcessGoneDetails): void {
const reason = details.reason ?? "unknown";
const data = {
kind,
reason,
type: details.type,
exitCode: details.exitCode,
serviceName: details.serviceName,
name: details.name,
};
addMainBreadcrumb("process", `electron.${kind}_process_gone`, data);
if (isStopping || reason === "clean-exit") {
return;
}
captureMainException(new Error(`Electron ${kind} process gone: ${reason}`), data);
}
function resolveExportConverterPath(appRoot: string): string | undefined {
const pyDir = path.join(appRoot, "resources", "export", "py");
const candidates = [
path.join(pyDir, `convert-${process.platform}-${process.arch}`),
path.join(pyDir, `convert-${process.platform}-${process.arch}.exe`),
path.join(pyDir, `convert-${process.platform}`),
path.join(pyDir, `convert-${process.platform}.exe`),
path.join(pyDir, "convert"),
path.join(pyDir, "convert.exe"),
];
return candidates.find((candidate) => fs.existsSync(candidate));
}
function isDisableAuthEnabledValue(value?: string): boolean {
const raw = value?.trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
}
function resolveElectronDisableAuth(): string {
const raw = (
process.env.ELECTRON_DISABLE_AUTH ?? process.env.DISABLE_AUTH
)?.trim().toLowerCase();
if (!raw) {
return "true";
}
if (["0", "false", "no", "off"].includes(raw)) {
return "false";
}
if (isDisableAuthEnabledValue(raw)) {
return "true";
}
return "true";
}
function buildImageMagickEnv(runtime: ImageMagickRuntime | null): Partial<FastApiEnv> {
if (!runtime) {
return {};
}
const pathKey = process.platform === "win32" && process.env.Path !== undefined
? "Path"
: "PATH";
return {
IMAGEMAGICK_BINARY: runtime.binaryPath,
MAGICK_HOME: runtime.homeDir,
MAGICK_CONFIGURE_PATH: runtime.homeDir,
[pathKey]: buildPathWithImageMagick(runtime),
};
}
app.commandLine.appendSwitch('gtk-version', '3');
// Work around Chromium/Electron GPU compositor issues that can cause
// startup white screens on some Linux/driver combinations.
app.disableHardwareAcceleration();
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
}
const electronAppPaths = initializeAppPaths();
const chromiumCacheRecovery = prepareChromiumCacheRecovery(
electronAppPaths.cacheDir,
electronAppPaths.userDataDir,
);
safeLog("[Presenton] Electron paths initialized:", electronAppPaths);
initMainSentry();
updateSentryRuntimeContext(chromiumCacheRecovery);
app.on("child-process-gone", (_event, details) => {
recordProcessGone("child", details);
});
addMainBreadcrumb("memory", "electron.main.startup", memorySnapshotMb());
safeLog("[Presenton] Startup memory:", {
memory: memorySnapshotMb(),
});
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 1280,
height: 720,
show: false, // Reveal once the launch screen has painted to avoid a blank flash.
backgroundColor: "#f3f5ff",
icon: path.join(resourceBaseDir, "resources/ui/assets/images/presenton_short_filled.png"),
webPreferences: {
webSecurity: false,
// Ensure a known preload path and explicit isolation settings so
// the `contextBridge` API is exposed reliably to renderer pages.
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
preload: (() => {
const p = path.join(__dirname, 'preloads/index.js');
try {
if (!fs.existsSync(p)) {
safeWarn(`[Presenton] Preload not found at ${p}`);
}
} catch (e) {
safeWarn('[Presenton] Failed to stat preload path', e);
}
return p;
})(),
},
});
win = mainWindow;
mainWindow.on("closed", () => {
if (win === mainWindow) {
win = undefined;
}
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
recordProcessGone("renderer", details);
});
// Open external links (e.g. "Download update") in the system browser so the user
// sees download progress and can manage downloads normally.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!isSupportedExternalUrl(url)) {
safeWarn("[Presenton] Blocked unsupported window open URL.");
return { action: "deny" };
}
void openExternalUrl(url)
.then(async (result) => {
if (result.success) {
return;
}
safeWarn(`[Presenton] Failed to open external URL: ${result.message || "Unknown error"}`);
await showOpenTargetErrorDialog({
parent: mainWindow,
title: "Could Not Open Link",
message: "Presenton could not open this link in your browser.",
detail: `${result.message || "No application is registered to open this link."}\n\n${url}`,
});
})
.catch((error) => {
safeWarn("[Presenton] Failed to handle external URL open:", error);
});
return { action: "deny" };
});
mainWindow.once("ready-to-show", () => {
if (mainWindow.isDestroyed()) {
return;
}
mainWindow.show();
mainWindow.focus();
});
};
function focusMainWindow(): void {
const mainWindow = getLiveMainWindow();
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
}
if (gotSingleInstanceLock) {
app.on("second-instance", () => {
focusMainWindow();
});
}
async function startServers(fastApiPort: number, nextjsPort: number) {
try {
const appDataDir = getAppDataDir();
const tempDir = getTempDir();
const userConfigPath = getUserConfigPath();
const disableAuthForElectron = resolveElectronDisableAuth();
const imageMagickRuntime = resolveImageMagickRuntime();
const exportPackageRoot = path.join(resourceBaseDir, "resources", "export");
const exportConverterPath = resolveExportConverterPath(resourceBaseDir);
const exportChromiumPath = await resolveLaunchableExportChromiumPath();
const puppeteerCacheDir = path.join(getCacheDir(), "puppeteer");
const puppeteerTempDir = path.join(tempDir, "puppeteer");
await Promise.all([
fs.promises.mkdir(puppeteerCacheDir, { recursive: true }),
fs.promises.mkdir(puppeteerTempDir, { recursive: true }),
]);
if (exportChromiumPath) {
safeLog("[Presenton] Export Chromium runtime resolved:", exportChromiumPath);
} else {
safeWarn(
"[Presenton] Export Chromium runtime was not found; Template Studio slide previews will fail until Chromium is installed."
);
}
if (imageMagickRuntime) {
safeLog("[Presenton] ImageMagick runtime resolved:", {
source: imageMagickRuntime.source,
binaryPath: imageMagickRuntime.binaryPath,
homeDir: imageMagickRuntime.homeDir,
});
} else {
safeWarn("[Presenton] ImageMagick runtime was not found; LiteParse image conversion will fail until it is bundled or installed.");
}
const fastApi = await startFastApiServer(
fastapiDir,
fastApiPort,
{
DEBUG: isDev ? "True" : "False",
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL,
DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
BEDROCK_REGION: process.env.BEDROCK_REGION,
BEDROCK_API_KEY: process.env.BEDROCK_API_KEY,
BEDROCK_AWS_ACCESS_KEY_ID: process.env.BEDROCK_AWS_ACCESS_KEY_ID,
BEDROCK_AWS_SECRET_ACCESS_KEY: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY,
BEDROCK_AWS_SESSION_TOKEN: process.env.BEDROCK_AWS_SESSION_TOKEN,
BEDROCK_PROFILE_NAME: process.env.BEDROCK_PROFILE_NAME,
BEDROCK_MODEL: process.env.BEDROCK_MODEL,
FIREWORKS_API_KEY: process.env.FIREWORKS_API_KEY,
FIREWORKS_MODEL: process.env.FIREWORKS_MODEL,
FIREWORKS_BASE_URL: process.env.FIREWORKS_BASE_URL,
TOGETHER_API_KEY: process.env.TOGETHER_API_KEY,
TOGETHER_MODEL: process.env.TOGETHER_MODEL,
TOGETHER_BASE_URL: process.env.TOGETHER_BASE_URL,
LMSTUDIO_BASE_URL: process.env.LMSTUDIO_BASE_URL,
LMSTUDIO_API_KEY: process.env.LMSTUDIO_API_KEY,
LMSTUDIO_MODEL: process.env.LMSTUDIO_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
WEB_SEARCH_PROVIDER: process.env.WEB_SEARCH_PROVIDER,
WEB_SEARCH_MAX_RESULTS: process.env.WEB_SEARCH_MAX_RESULTS,
SEARXNG_BASE_URL: process.env.SEARXNG_BASE_URL,
TAVILY_API_KEY: process.env.TAVILY_API_KEY,
EXA_API_KEY: process.env.EXA_API_KEY,
BRAVE_SEARCH_API_KEY: process.env.BRAVE_SEARCH_API_KEY,
SERPER_API_KEY: process.env.SERPER_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
APP_DATA_DIRECTORY: appDataDir,
TEMP_DIRECTORY: tempDir,
USER_CONFIG_PATH: userConfigPath,
MIGRATE_DATABASE_ON_STARTUP: "True",
DISABLE_AUTH: disableAuthForElectron,
PRESENTON_ELECTRON: "true",
...buildImageMagickEnv(imageMagickRuntime),
LITEPARSE_RUNNER_PATH: getLiteParseRunnerPath(),
// Use Electron's embedded runtime for LiteParse so parsing does not
// depend on a system-wide Node installation.
LITEPARSE_NODE_BINARY: process.execPath,
ELECTRON_RUN_AS_NODE: "1",
EXPORT_PACKAGE_ROOT: exportPackageRoot,
EXPORT_RUNTIME_DIR: exportPackageRoot,
PUPPETEER_CACHE_DIR: puppeteerCacheDir,
PUPPETEER_TMP_DIR: puppeteerTempDir,
...(exportChromiumPath && {
PUPPETEER_EXECUTABLE_PATH: exportChromiumPath,
}),
...(exportConverterPath && {
BUILT_PYTHON_MODULE_PATH: exportConverterPath,
}),
},
isDev,
);
fastApiServer = fastApi;
await fastApi.ready;
const nextjs = await startNextJsServer(
nextjsDir,
nextjsPort,
{
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API,
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY,
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL,
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,
DISABLE_AUTH: disableAuthForElectron,
EXPORT_PACKAGE_ROOT: exportPackageRoot,
PRESENTON_APP_ROOT: resourceBaseDir,
...(exportConverterPath && {
BUILT_PYTHON_MODULE_PATH: exportConverterPath,
}),
},
isDev,
);
nextjsServer = nextjs;
await nextjs.ready;
} catch (error) {
safeError("Server startup error:", error);
}
}
async function stopServers() {
const fastApi = fastApiServer;
const nextjs = nextjsServer;
fastApiServer = undefined;
nextjsServer = undefined;
await Promise.all([
fastApi
? fastApi.stop().catch((error) => safeError("Failed to stop FastAPI:", error))
: Promise.resolve(),
nextjs
? nextjs.stop().catch((error) => safeError("Failed to stop NextJS:", error))
: Promise.resolve(),
]);
}
async function forceQuitApp(exitCode = 0) {
if (isStopping) return;
isStopping = true;
globalShortcut.unregisterAll();
stopUpdateChecker();
try {
await stopActiveExportProcesses();
await stopServers();
} finally {
app.exit(exitCode);
}
}
if (gotSingleInstanceLock) {
app.whenReady().then(async () => {
const disableAuthForElectron = resolveElectronDisableAuth();
process.env.DISABLE_AUTH = disableAuthForElectron;
process.env.ELECTRON_DISABLE_AUTH = disableAuthForElectron;
process.env.PRESENTON_ELECTRON = "true";
// Ensure all required directories exist before starting
ensureDirectoriesExist();
await finishChromiumCacheRecovery(
electronAppPaths.userDataDir,
chromiumCacheRecovery,
);
updateSentryRuntimeContext(chromiumCacheRecovery);
// Create main window and show the launch page while local servers boot.
createWindow();
const initialWindow = getLiveMainWindow();
if (initialWindow && !initialWindow.webContents.isDestroyed()) {
void initialWindow
.loadFile(path.join(resourceBaseDir, "resources/ui/homepage/index.html"))
.catch((error) => {
if (!initialWindow.isDestroyed()) {
safeWarn("[Presenton] Failed to load startup page", error);
}
});
}
// Ensure the launch screen stays visible and focused during the server boot.
const launchWindow = getLiveMainWindow();
launchWindow?.show();
launchWindow?.focus();
try {
setUserConfig({
CAN_CHANGE_KEYS: process.env.CAN_CHANGE_KEYS,
LLM: process.env.LLM,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL,
DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GOOGLE_MODEL: process.env.GOOGLE_MODEL,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
OLLAMA_URL: process.env.OLLAMA_URL,
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
CUSTOM_LLM_URL: process.env.CUSTOM_LLM_URL,
CUSTOM_LLM_API_KEY: process.env.CUSTOM_LLM_API_KEY,
CUSTOM_MODEL: process.env.CUSTOM_MODEL,
BEDROCK_REGION: process.env.BEDROCK_REGION,
BEDROCK_API_KEY: process.env.BEDROCK_API_KEY,
BEDROCK_AWS_ACCESS_KEY_ID: process.env.BEDROCK_AWS_ACCESS_KEY_ID,
BEDROCK_AWS_SECRET_ACCESS_KEY: process.env.BEDROCK_AWS_SECRET_ACCESS_KEY,
BEDROCK_AWS_SESSION_TOKEN: process.env.BEDROCK_AWS_SESSION_TOKEN,
BEDROCK_PROFILE_NAME: process.env.BEDROCK_PROFILE_NAME,
BEDROCK_MODEL: process.env.BEDROCK_MODEL,
FIREWORKS_API_KEY: process.env.FIREWORKS_API_KEY,
FIREWORKS_MODEL: process.env.FIREWORKS_MODEL,
FIREWORKS_BASE_URL: process.env.FIREWORKS_BASE_URL,
TOGETHER_API_KEY: process.env.TOGETHER_API_KEY,
TOGETHER_MODEL: process.env.TOGETHER_MODEL,
TOGETHER_BASE_URL: process.env.TOGETHER_BASE_URL,
LMSTUDIO_BASE_URL: process.env.LMSTUDIO_BASE_URL,
LMSTUDIO_API_KEY: process.env.LMSTUDIO_API_KEY,
LMSTUDIO_MODEL: process.env.LMSTUDIO_MODEL,
PEXELS_API_KEY: process.env.PEXELS_API_KEY,
PIXABAY_API_KEY: process.env.PIXABAY_API_KEY,
IMAGE_PROVIDER: process.env.IMAGE_PROVIDER,
DISABLE_IMAGE_GENERATION: process.env.DISABLE_IMAGE_GENERATION,
EXTENDED_REASONING: process.env.EXTENDED_REASONING,
TOOL_CALLS: process.env.TOOL_CALLS,
DISABLE_THINKING: process.env.DISABLE_THINKING,
WEB_GROUNDING: process.env.WEB_GROUNDING,
WEB_SEARCH_PROVIDER: process.env.WEB_SEARCH_PROVIDER,
WEB_SEARCH_MAX_RESULTS: process.env.WEB_SEARCH_MAX_RESULTS,
SEARXNG_BASE_URL: process.env.SEARXNG_BASE_URL,
TAVILY_API_KEY: process.env.TAVILY_API_KEY,
EXA_API_KEY: process.env.EXA_API_KEY,
BRAVE_SEARCH_API_KEY: process.env.BRAVE_SEARCH_API_KEY,
SERPER_API_KEY: process.env.SERPER_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ANONYMOUS_TRACKING: process.env.DISABLE_ANONYMOUS_TRACKING,
COMFYUI_URL: process.env.COMFYUI_URL,
COMFYUI_WORKFLOW: process.env.COMFYUI_WORKFLOW,
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
})
} catch (error) {
safeWarn("[Presenton] Failed to persist startup user config", error);
}
const [fastApiPort, nextjsPort] = await findUnusedPorts();
safeLog(`FastAPI port: ${fastApiPort}, NextJS port: ${nextjsPort}`);
//? Setup environment variables to be used in the preloads
setupEnv(fastApiPort, nextjsPort);
setupIpcHandlers();
await startServers(fastApiPort, nextjsPort);
if (isStopping) {
return;
}
const mainWindow = getLiveMainWindow();
if (!mainWindow || mainWindow.webContents.isDestroyed()) {
return;
}
try {
const appPath = isDisableAuthEnabledValue(process.env.DISABLE_AUTH)
? "/upload"
: "";
await mainWindow.loadURL(`${localhost}:${nextjsPort}${appPath}`);
} catch (error) {
if (mainWindow.isDestroyed()) {
return;
}
safeWarn("[Presenton] Failed to load application URL", error);
return;
}
// Begin polling the version server for available updates
const updateWindow = getLiveMainWindow();
if (updateWindow && !updateWindow.webContents.isDestroyed()) {
safeStderrWrite("[Presenton] Starting update checker...\n");
startUpdateChecker(updateWindow);
}
});
app.on("window-all-closed", async () => {
await forceQuitApp(0);
});
app.on("before-quit", async (event) => {
if (isStopping) return;
event.preventDefault();
await forceQuitApp(0);
});
app.on("will-quit", async (event) => {
if (isStopping) return;
event.preventDefault();
await forceQuitApp(0);
});
}
+34
View File
@@ -0,0 +1,34 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('env', {
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API || '',
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL || '',
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || '',
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH || '',
APP_VERSION: process.env.APP_VERSION || '',
DISABLE_AUTH: process.env.DISABLE_AUTH || '',
});
contextBridge.exposeInMainWorld('electron', {
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
exportPresentation: (id: string, title: string, format: "pptx" | "pdf") =>
ipcRenderer.invoke("export-presentation", id, title, format),
getUserConfig: () => ipcRenderer.invoke("get-user-config"),
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
readFile: (filePath: string) => ipcRenderer.invoke("read-file", filePath),
getSlideMetadata: (url: string, theme: string, customColors?: any, tempDirectory?: string) =>
ipcRenderer.invoke("get-slide-metadata", url, theme, customColors, tempDirectory),
getFooter: (userId: string) => ipcRenderer.invoke("get-footer", userId),
setFooter: (userId: string, properties: any) => ipcRenderer.invoke("set-footer", userId, properties),
getTheme: (userId: string) => ipcRenderer.invoke("get-theme", userId),
setTheme: (userId: string, themeData: any) => ipcRenderer.invoke("set-theme", userId, themeData),
uploadImage: (file: Buffer) => ipcRenderer.invoke("upload-image", file),
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
// API handlers
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
getTemplates: () => ipcRenderer.invoke("api:templates"),
});
+13
View File
@@ -0,0 +1,13 @@
// Preload script for PPTX export browser window
// This script runs before the page loads and injects environment variables
import './sentry';
// Expose environment variables to the window
(window as any).env = {
NEXT_PUBLIC_FAST_API: process.env.NEXT_PUBLIC_FAST_API || '',
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL || '',
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || '',
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH || '',
};
console.log('[PPTX Export Preload] Environment variables set:', (window as any).env);
+77
View File
@@ -0,0 +1,77 @@
import * as Sentry from '@sentry/electron/renderer';
let isSentryInitialized = false;
function parseBoolean(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined) {
return defaultValue;
}
return !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}
function parseSampleRate(value: string | undefined, defaultValue: number): number {
if (!value) {
return defaultValue;
}
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) {
return defaultValue;
}
return Math.max(0, Math.min(1, parsed));
}
export function initRendererSentry(): void {
if (isSentryInitialized) {
return;
}
const dsn = 'https://48b091ed88ae147c0957a46a823c1449@o4509882707410944.ingest.us.sentry.io/4511171070394368';
const isEnabled = parseBoolean(process.env.SENTRY_ENABLED, true);
if (!isEnabled) {
return;
}
const enableTracing = parseBoolean(process.env.SENTRY_ENABLE_TRACING, true);
const enableReplay = parseBoolean(process.env.SENTRY_ENABLE_REPLAY, false);
const enableFeedback = parseBoolean(process.env.SENTRY_ENABLE_FEEDBACK, false);
const integrations: any[] = [];
if (enableTracing) {
integrations.push(Sentry.browserTracingIntegration());
}
if (enableReplay) {
integrations.push(Sentry.replayIntegration());
}
if (enableFeedback) {
integrations.push(
Sentry.feedbackIntegration({
colorScheme: 'system',
}),
);
}
Sentry.init({
dsn,
enableLogs: parseBoolean(process.env.SENTRY_ENABLE_LOGS, true),
sendDefaultPii: parseBoolean(process.env.SENTRY_SEND_DEFAULT_PII, false),
tracesSampleRate: enableTracing
? parseSampleRate(process.env.SENTRY_TRACES_SAMPLE_RATE, 1.0)
: undefined,
replaysSessionSampleRate: enableReplay
? parseSampleRate(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE, 0.1)
: undefined,
replaysOnErrorSampleRate: enableReplay
? parseSampleRate(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, 1.0)
: undefined,
integrations,
});
isSentryInitialized = true;
Sentry.setTag('process.type', 'renderer');
}
initRendererSentry();
+169
View File
@@ -0,0 +1,169 @@
import { app } from "electron";
import * as Sentry from "@sentry/electron/main";
let isSentryInitialized = false;
type MainSentryRuntimeContext = {
profileHash?: string;
cacheRecoveryStatus?: string;
cacheRecoveryMode?: string;
};
function parseBoolean(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined) {
return defaultValue;
}
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
function parseSampleRate(value: string | undefined, defaultValue: number): number {
if (!value) {
return defaultValue;
}
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) {
return defaultValue;
}
return Math.max(0, Math.min(1, parsed));
}
function getEnvironment(): string {
if (process.env.SENTRY_ENVIRONMENT) {
return process.env.SENTRY_ENVIRONMENT;
}
if (process.env.NODE_ENV) {
return process.env.NODE_ENV;
}
return app.isPackaged ? "production" : "development";
}
function getRelease(): string {
if (process.env.SENTRY_RELEASE) {
return process.env.SENTRY_RELEASE;
}
return `presenton-electron@${app.getVersion()}`;
}
export function initMainSentry(): void {
if (isSentryInitialized) {
return;
}
const dsn = "https://48b091ed88ae147c0957a46a823c1449@o4509882707410944.ingest.us.sentry.io/4511171070394368";
const isEnabled = parseBoolean(process.env.SENTRY_ENABLED, true);
if (!isEnabled) {
return;
}
const tracesSampleRate = parseSampleRate(
process.env.SENTRY_TRACES_SAMPLE_RATE,
app.isPackaged ? 0.2 : 1.0,
);
try {
Sentry.init({
dsn,
enabled: true,
release: getRelease(),
environment: getEnvironment(),
debug: parseBoolean(process.env.SENTRY_DEBUG, false),
sendDefaultPii: parseBoolean(process.env.SENTRY_SEND_DEFAULT_PII, false),
enableLogs: parseBoolean(process.env.SENTRY_ENABLE_LOGS, true),
tracesSampleRate,
integrations: [Sentry.startupTracingIntegration()],
});
isSentryInitialized = true;
Sentry.setTag("process.type", "main");
Sentry.setTag("electron.version", process.versions.electron ?? "unknown");
Sentry.setTag("chromium.version", process.versions.chrome ?? "unknown");
Sentry.setTag("node.version", process.versions.node ?? "unknown");
Sentry.setTag("os.platform", process.platform);
Sentry.setTag("os.arch", process.arch);
Sentry.setContext("runtime", {
appVersion: app.getVersion(),
electronVersion: process.versions.electron ?? "unknown",
chromiumVersion: process.versions.chrome ?? "unknown",
nodeVersion: process.versions.node ?? "unknown",
platform: process.platform,
arch: process.arch,
packaged: app.isPackaged,
});
console.log("[Sentry] Initialized in Electron main process.");
} catch (error) {
console.error("[Sentry] Failed to initialize in Electron main process:", error);
}
}
export function setMainSentryRuntimeContext(context: MainSentryRuntimeContext): void {
if (!isSentryInitialized) {
return;
}
try {
if (context.profileHash) {
Sentry.setTag("profile.hash", context.profileHash);
}
if (context.cacheRecoveryStatus) {
Sentry.setTag("cache_recovery.status", context.cacheRecoveryStatus);
}
if (context.cacheRecoveryMode) {
Sentry.setTag("cache_recovery.mode", context.cacheRecoveryMode);
}
Sentry.setContext("presenton", {
profileHash: context.profileHash,
cacheRecoveryStatus: context.cacheRecoveryStatus,
cacheRecoveryMode: context.cacheRecoveryMode,
});
} catch {
/* ignore context failures */
}
}
export function captureMainException(
error: unknown,
context?: Record<string, unknown>,
): void {
if (!isSentryInitialized) {
return;
}
try {
Sentry.withScope((scope) => {
if (context) {
scope.setContext("presenton", context);
}
Sentry.captureException(error);
});
} catch {
/* ignore capture failures */
}
}
export function addMainBreadcrumb(
category: string,
message: string,
data?: Record<string, unknown>,
): void {
if (!isSentryInitialized) {
return;
}
try {
Sentry.addBreadcrumb({
category,
message,
level: "info",
data,
});
} catch {
/* ignore breadcrumb failures */
}
}
+84
View File
@@ -0,0 +1,84 @@
import path from 'path';
import fs from 'fs';
import { getUserDataDir } from '../utils/constants';
class SettingsStore {
private settingsPath: string | undefined;
private settings: { [key: string]: any };
private loaded = false;
constructor() {
this.settings = {};
}
private getSettingsPath(): string {
if (!this.settingsPath) {
this.settingsPath = path.join(getUserDataDir(), 'settings.json');
}
return this.settingsPath;
}
private loadSettings() {
if (this.loaded) {
return;
}
this.loaded = true;
try {
const settingsPath = this.getSettingsPath();
if (fs.existsSync(settingsPath)) {
const data = fs.readFileSync(settingsPath, 'utf-8');
this.settings = JSON.parse(data);
} else {
this.settings = {};
this.saveSettings();
}
} catch (error) {
console.error('Error loading settings:', error);
this.settings = {};
}
}
private saveSettings() {
try {
fs.writeFileSync(this.getSettingsPath(), JSON.stringify(this.settings, null, 2));
} catch (error) {
console.error('Error saving settings:', error);
throw error;
}
}
get(key: string, defaultValue: any = null): any {
this.loadSettings();
const value = this.settings[key];
return value || defaultValue;
}
set(key: string, value: any): void {
this.loadSettings();
this.settings[key] = value;
this.saveSettings();
}
// Helper method to check if settings exist
has(key: string): boolean {
this.loadSettings();
return key in this.settings;
}
// Helper method to delete a setting
delete(key: string): void {
this.loadSettings();
delete this.settings[key];
this.saveSettings();
}
}
// Export a singleton instance
export const settingsStore = new SettingsStore();
+173
View File
@@ -0,0 +1,173 @@
interface FastApiEnv {
DEBUG?: string,
CAN_CHANGE_KEYS?: string,
LLM?: string,
OPENAI_API_KEY?: string,
OPENAI_MODEL?: string,
DEEPSEEK_API_KEY?: string,
DEEPSEEK_MODEL?: string,
DEEPSEEK_BASE_URL?: string,
GOOGLE_API_KEY?: string,
GOOGLE_MODEL?: string,
ANTHROPIC_API_KEY?: string,
ANTHROPIC_MODEL?: string,
OLLAMA_URL?: string,
OLLAMA_MODEL?: string,
CUSTOM_LLM_URL?: string,
CUSTOM_LLM_API_KEY?: string,
CUSTOM_MODEL?: string,
BEDROCK_REGION?: string,
BEDROCK_API_KEY?: string,
BEDROCK_AWS_ACCESS_KEY_ID?: string,
BEDROCK_AWS_SECRET_ACCESS_KEY?: string,
BEDROCK_AWS_SESSION_TOKEN?: string,
BEDROCK_PROFILE_NAME?: string,
BEDROCK_MODEL?: string,
FIREWORKS_API_KEY?: string,
FIREWORKS_MODEL?: string,
FIREWORKS_BASE_URL?: string,
TOGETHER_API_KEY?: string,
TOGETHER_MODEL?: string,
TOGETHER_BASE_URL?: string,
LMSTUDIO_BASE_URL?: string,
LMSTUDIO_API_KEY?: string,
LMSTUDIO_MODEL?: string,
PEXELS_API_KEY?: string,
PIXABAY_API_KEY?: string,
IMAGE_PROVIDER?: string,
DISABLE_IMAGE_GENERATION?: string,
EXTENDED_REASONING?: string,
TOOL_CALLS?: string,
DISABLE_THINKING?: string,
WEB_GROUNDING?: string,
WEB_SEARCH_PROVIDER?: string,
WEB_SEARCH_MAX_RESULTS?: string,
SEARXNG_BASE_URL?: string,
TAVILY_API_KEY?: string,
EXA_API_KEY?: string,
BRAVE_SEARCH_API_KEY?: string,
SERPER_API_KEY?: string,
DATABASE_URL?: string,
DISABLE_ANONYMOUS_TRACKING?: string,
COMFYUI_URL?: string,
COMFYUI_WORKFLOW?: string,
DALL_E_3_QUALITY?: string,
GPT_IMAGE_1_5_QUALITY?: string,
APP_DATA_DIRECTORY?: string,
/** Same origin the Next.js app uses for FastAPI (assets + API). Passed through to Python. */
NEXT_PUBLIC_FAST_API?: string,
TEMP_DIRECTORY?: string,
USER_CONFIG_PATH?: string,
MIGRATE_DATABASE_ON_STARTUP?: string,
DISABLE_AUTH?: string,
PRESENTON_ELECTRON?: string,
/** Absolute path to the bundled/resolved ImageMagick magick executable. */
IMAGEMAGICK_BINARY?: string,
/** ImageMagick runtime root used by the bundled portable build. */
MAGICK_HOME?: string,
/** ImageMagick configuration directory used by the bundled portable build. */
MAGICK_CONFIGURE_PATH?: string,
/** POSIX-style path key used on macOS/Linux child processes. */
PATH?: string,
/** Windows path key, preserved to avoid duplicate PATH/Path env entries. */
Path?: string,
/** Absolute path to the bundled LiteParse runner script. */
LITEPARSE_RUNNER_PATH?: string,
/** Binary path used by LiteParseService to execute liteparse_runner.mjs. */
LITEPARSE_NODE_BINARY?: string,
/** Set to "1" when using the Electron binary as a Node runtime. */
ELECTRON_RUN_AS_NODE?: string,
/** Root directory of the bundled presentation export runtime package. */
EXPORT_PACKAGE_ROOT?: string,
/** Directory used by FastAPI export task runtime resolution. */
EXPORT_RUNTIME_DIR?: string,
/** Absolute path to the Chromium executable used by the export runtime. */
PUPPETEER_EXECUTABLE_PATH?: string,
/** Writable Puppeteer browser cache directory. */
PUPPETEER_CACHE_DIR?: string,
/** Writable Puppeteer temporary directory. */
PUPPETEER_TMP_DIR?: string,
/** Absolute path to bundled PyInstaller converter binary for export runtime. */
BUILT_PYTHON_MODULE_PATH?: string,
}
interface NextJsEnv {
NEXT_PUBLIC_FAST_API?: string,
/** Optional Docker-only: SSR/middleware reach FastAPI on a different base than the browser. */
FAST_API_INTERNAL_URL?: string,
TEMP_DIRECTORY?: string,
NEXT_PUBLIC_URL?: string,
NEXT_PUBLIC_USER_CONFIG_PATH?: string,
USER_CONFIG_PATH?: string,
APP_DATA_DIRECTORY?: string,
DISABLE_AUTH?: string,
EXPORT_PACKAGE_ROOT?: string,
PRESENTON_APP_ROOT?: string,
BUILT_PYTHON_MODULE_PATH?: string,
}
interface UserConfig {
CAN_CHANGE_KEYS?: string,
LLM?: string,
OPENAI_API_KEY?: string,
OPENAI_MODEL?: string,
DEEPSEEK_API_KEY?: string,
DEEPSEEK_MODEL?: string,
DEEPSEEK_BASE_URL?: string,
GOOGLE_API_KEY?: string,
GOOGLE_MODEL?: string,
ANTHROPIC_API_KEY?: string,
ANTHROPIC_MODEL?: string,
OLLAMA_URL?: string,
OLLAMA_MODEL?: string,
CUSTOM_LLM_URL?: string,
CUSTOM_LLM_API_KEY?: string,
CUSTOM_MODEL?: string,
BEDROCK_REGION?: string,
BEDROCK_API_KEY?: string,
BEDROCK_AWS_ACCESS_KEY_ID?: string,
BEDROCK_AWS_SECRET_ACCESS_KEY?: string,
BEDROCK_AWS_SESSION_TOKEN?: string,
BEDROCK_PROFILE_NAME?: string,
BEDROCK_MODEL?: string,
FIREWORKS_API_KEY?: string,
FIREWORKS_MODEL?: string,
FIREWORKS_BASE_URL?: string,
TOGETHER_API_KEY?: string,
TOGETHER_MODEL?: string,
TOGETHER_BASE_URL?: string,
LMSTUDIO_BASE_URL?: string,
LMSTUDIO_API_KEY?: string,
LMSTUDIO_MODEL?: string,
PEXELS_API_KEY?: string,
PIXABAY_API_KEY?: string,
IMAGE_PROVIDER?: string,
DISABLE_IMAGE_GENERATION?: string,
EXTENDED_REASONING?: string,
TOOL_CALLS?: string,
DISABLE_THINKING?: string,
WEB_GROUNDING?: string,
WEB_SEARCH_PROVIDER?: string,
WEB_SEARCH_MAX_RESULTS?: string,
SEARXNG_BASE_URL?: string,
TAVILY_API_KEY?: string,
EXA_API_KEY?: string,
BRAVE_SEARCH_API_KEY?: string,
SERPER_API_KEY?: string,
DATABASE_URL?: string,
DISABLE_ANONYMOUS_TRACKING?: string,
COMFYUI_URL?: string,
COMFYUI_WORKFLOW?: string,
DALL_E_3_QUALITY?: string,
GPT_IMAGE_1_5_QUALITY?: string,
CODEX_MODEL?: string,
CODEX_ACCESS_TOKEN?: string,
CODEX_REFRESH_TOKEN?: string,
CODEX_TOKEN_EXPIRES?: string,
CODEX_ACCOUNT_ID?: string,
}
interface IPCStatus {
success: boolean,
message?: string,
}
@@ -0,0 +1,235 @@
import { app, session } from "electron";
import fs from "fs";
import path from "path";
import { addMainBreadcrumb, captureMainException } from "../sentry/main";
import { safeError, safeLog, safeWarn } from "./safe-console";
export type ChromiumCacheRecoveryStatus = {
mode: "auto" | "force" | "off";
status: "pending-session-clear" | "completed" | "failed" | "skipped";
recoveryKey: string;
appVersion: string;
electronVersion: string;
quarantined: string[];
removedStaleDirectories: number;
errors: string[];
reason?: string;
};
const RECOVERY_KEY = "shared-dictionary-cache-v1";
const SENTINEL_FILE = "chromium-cache-recovery.json";
function getRecoveryMode(): ChromiumCacheRecoveryStatus["mode"] {
const raw = process.env.PRESENTON_CHROMIUM_CACHE_RECOVERY?.trim().toLowerCase();
if (["0", "false", "no", "off"].includes(raw ?? "")) {
return "off";
}
if (raw === "force") {
return "force";
}
return "auto";
}
function getSentinelPath(userDataDir: string): string {
return path.join(userDataDir, SENTINEL_FILE);
}
function readSentinel(userDataDir: string): Record<string, unknown> | undefined {
try {
const raw = fs.readFileSync(getSentinelPath(userDataDir), "utf8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return undefined;
}
}
function writeSentinel(userDataDir: string, status: ChromiumCacheRecoveryStatus): void {
const payload = {
recoveryKey: status.recoveryKey,
appVersion: status.appVersion,
electronVersion: status.electronVersion,
completedAt: new Date().toISOString(),
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
};
fs.writeFileSync(getSentinelPath(userDataDir), `${JSON.stringify(payload, null, 2)}\n`);
}
function shouldRunRecovery(
mode: ChromiumCacheRecoveryStatus["mode"],
userDataDir: string,
appVersion: string,
electronVersion: string,
): boolean {
if (mode === "force") {
return true;
}
if (mode === "off") {
return false;
}
const sentinel = readSentinel(userDataDir);
return !(
sentinel?.recoveryKey === RECOVERY_KEY &&
sentinel?.appVersion === appVersion &&
sentinel?.electronVersion === electronVersion
);
}
function uniqueQuarantinePath(quarantineRoot: string, label: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
let candidate = path.join(quarantineRoot, `${label}-${timestamp}`);
let suffix = 1;
while (fs.existsSync(candidate)) {
candidate = path.join(quarantineRoot, `${label}-${timestamp}-${suffix}`);
suffix += 1;
}
return candidate;
}
function quarantineIfPresent(
source: string,
quarantineRoot: string,
label: string,
status: ChromiumCacheRecoveryStatus,
): void {
if (!fs.existsSync(source)) {
return;
}
try {
fs.mkdirSync(quarantineRoot, { recursive: true });
fs.renameSync(source, uniqueQuarantinePath(quarantineRoot, label));
status.quarantined.push(label);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`${label}: ${message}`);
}
}
function removeOldDirectories(dir: string, status: ChromiumCacheRecoveryStatus): void {
if (!fs.existsSync(dir)) {
return;
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith("old_")) {
fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true });
status.removedStaleDirectories += 1;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`old-cache-cleanup: ${message}`);
}
}
export function prepareChromiumCacheRecovery(
cacheDir: string,
userDataDir: string,
): ChromiumCacheRecoveryStatus {
const mode = getRecoveryMode();
const appVersion = app.getVersion();
const electronVersion = process.versions.electron ?? "unknown";
const status: ChromiumCacheRecoveryStatus = {
mode,
status: "pending-session-clear",
recoveryKey: RECOVERY_KEY,
appVersion,
electronVersion,
quarantined: [],
removedStaleDirectories: 0,
errors: [],
};
if (mode === "off") {
status.status = "skipped";
status.reason = "disabled-by-env";
return status;
}
if (!shouldRunRecovery(mode, userDataDir, appVersion, electronVersion)) {
status.status = "skipped";
status.reason = "already-completed";
return status;
}
removeOldDirectories(cacheDir, status);
removeOldDirectories(path.join(userDataDir, "GPUCache"), status);
const quarantineRoot = path.join(userDataDir, "Recovered Chromium Cache");
quarantineIfPresent(
path.join(userDataDir, "Shared Dictionary"),
quarantineRoot,
"Shared Dictionary",
status,
);
if (status.errors.length > 0) {
status.status = "failed";
safeWarn("[Presenton] Chromium cache recovery finished with errors:", status);
return status;
}
safeLog("[Presenton] Chromium cache recovery prepared:", {
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
});
return status;
}
export async function finishChromiumCacheRecovery(
userDataDir: string,
status: ChromiumCacheRecoveryStatus,
): Promise<ChromiumCacheRecoveryStatus> {
if (status.status === "skipped") {
addMainBreadcrumb("cache-recovery", "electron.chromium_cache_recovery.skipped", {
reason: status.reason,
mode: status.mode,
});
return status;
}
try {
const defaultSession = session.defaultSession;
const skippedSessionClears: string[] = [];
if (typeof defaultSession.clearSharedDictionaryCache === "function") {
await defaultSession.clearSharedDictionaryCache();
} else {
skippedSessionClears.push("shared-dictionary-cache");
}
if (typeof defaultSession.clearCodeCaches === "function") {
await defaultSession.clearCodeCaches({ urls: [] });
} else {
skippedSessionClears.push("code-cache");
}
await defaultSession.clearCache();
writeSentinel(userDataDir, status);
status.status = "completed";
addMainBreadcrumb("cache-recovery", "electron.chromium_cache_recovery.completed", {
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
skippedSessionClears,
});
} catch (error) {
status.status = "failed";
const message = error instanceof Error ? error.message : String(error);
status.errors.push(`session-clear: ${message}`);
safeError("[Presenton] Chromium cache recovery failed:", error);
captureMainException(error, {
area: "chromium-cache-recovery",
mode: status.mode,
quarantined: status.quarantined,
removedStaleDirectories: status.removedStaleDirectories,
});
}
return status;
}
+337
View File
@@ -0,0 +1,337 @@
import { app } from "electron"
import path from "path"
import fs from "fs"
import os from "os"
export const localhost = "http://127.0.0.1"
export const isDev = !app.isPackaged;
export const baseDir = app.getAppPath();
export const resourceBaseDir =
isDev || !baseDir.endsWith(".asar") ? baseDir : `${baseDir}.unpacked`;
export const fastapiDir = isDev
? path.resolve(baseDir, "..", "servers", "fastapi")
: path.join(resourceBaseDir, "resources/fastapi");
export const nextjsDir = isDev
? path.resolve(baseDir, "..", "servers", "nextjs")
: path.join(resourceBaseDir, "resources/nextjs");
const appDirectoryName = "Presenton Open Source";
export type ElectronAppPaths = {
userDataDir: string;
appDataDir: string;
tempDir: string;
logsDir: string;
userConfigPath: string;
cacheDir: string;
crashDumpsDir: string;
sessionDataDir: string;
};
let appPaths: ElectronAppPaths | undefined;
let downloadsDir: string | undefined;
function unique(paths: Array<string | undefined>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const candidate of paths) {
if (!candidate) continue;
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
result.push(resolved);
}
return result;
}
function absoluteEnvPath(name: string): string | undefined {
const value = process.env[name]?.trim();
if (!value || !path.isAbsolute(value)) {
return undefined;
}
return value;
}
function getHomeDir(): string | undefined {
const envHome =
absoluteEnvPath("HOME") ||
absoluteEnvPath("USERPROFILE") ||
(() => {
const drive = process.env.HOMEDRIVE?.trim();
const homePath = process.env.HOMEPATH?.trim();
if (!drive || !homePath) return undefined;
const candidate = `${drive}${homePath}`;
return path.isAbsolute(candidate) ? candidate : undefined;
})();
if (envHome) {
return envHome;
}
try {
const home = os.homedir();
return home && path.isAbsolute(home) ? home : undefined;
} catch {
return undefined;
}
}
function electronPathCandidate(name: "temp" | "downloads" | "userData"): string | undefined {
try {
const candidate = app.getPath(name);
return candidate && path.isAbsolute(candidate) ? candidate : undefined;
} catch {
return undefined;
}
}
function canUseDirectory(dir: string): boolean {
try {
fs.mkdirSync(dir, { recursive: true });
fs.accessSync(dir, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function firstWritableDirectory(candidates: string[], label: string): string {
for (const candidate of candidates) {
if (canUseDirectory(candidate)) {
return candidate;
}
}
throw new Error(
`Unable to create a writable ${label} directory. Tried: ${candidates.join(", ")}`
);
}
function getTempRoot(): string {
const home = getHomeDir();
return firstWritableDirectory(
unique([
absoluteEnvPath("TMPDIR"),
absoluteEnvPath("TEMP"),
absoluteEnvPath("TMP"),
electronPathCandidate("temp"),
(() => {
try {
const tmp = os.tmpdir();
return tmp && path.isAbsolute(tmp) ? tmp : undefined;
} catch {
return undefined;
}
})(),
process.platform === "win32" ? absoluteEnvPath("LOCALAPPDATA") : undefined,
home ? path.join(home, ".cache") : undefined,
process.platform === "win32" ? undefined : "/tmp",
]),
"temporary root"
);
}
function getAppDataBaseDir(tempRoot: string): string {
const home = getHomeDir();
const fallback = path.join(tempRoot, "presenton-app-data");
if (process.platform === "win32") {
return firstWritableDirectory(
unique([
absoluteEnvPath("APPDATA"),
absoluteEnvPath("LOCALAPPDATA"),
home ? path.join(home, "AppData", "Roaming") : undefined,
fallback,
]),
"app data"
);
}
if (process.platform === "darwin") {
return firstWritableDirectory(
unique([
home ? path.join(home, "Library", "Application Support") : undefined,
fallback,
]),
"app data"
);
}
return firstWritableDirectory(
unique([
absoluteEnvPath("XDG_CONFIG_HOME"),
home ? path.join(home, ".config") : undefined,
fallback,
]),
"app data"
);
}
function resolveLinuxDownloadsDir(home: string | undefined): string | undefined {
if (!home) {
return undefined;
}
const userDirsPath = path.join(home, ".config", "user-dirs.dirs");
try {
const userDirs = fs.readFileSync(userDirsPath, "utf8");
const match = userDirs.match(/^XDG_DOWNLOAD_DIR=(["']?)(.+)\1$/m);
const rawValue = match?.[2]?.trim();
if (!rawValue) {
return undefined;
}
const expanded = rawValue.replace("$HOME", home);
return path.isAbsolute(expanded) ? expanded : path.join(home, expanded);
} catch {
return undefined;
}
}
function getDownloadsDirCandidate(userDataDir: string): string {
const home = getHomeDir();
const fallback = path.join(userDataDir, "exports");
if (process.platform === "linux") {
return firstWritableDirectory(
unique([
electronPathCandidate("downloads"),
resolveLinuxDownloadsDir(home),
home ? path.join(home, "Downloads") : undefined,
fallback,
]),
"downloads"
);
}
return firstWritableDirectory(
unique([
electronPathCandidate("downloads"),
home ? path.join(home, "Downloads") : undefined,
fallback,
]),
"downloads"
);
}
function setElectronPath(name: string, dir: string): void {
try {
app.setPath(name, dir);
} catch (error) {
console.warn(`[Presenton] Failed to set Electron path ${name}=${dir}`, error);
}
}
function appendDiskCacheSwitch(cacheDir: string): void {
try {
app.commandLine.appendSwitch("disk-cache-dir", cacheDir);
} catch (error) {
console.warn("[Presenton] Failed to configure Chromium disk cache path", error);
}
}
export function initializeAppPaths(): ElectronAppPaths {
if (appPaths) {
return appPaths;
}
const tempRoot = getTempRoot();
const appDataBaseDir = getAppDataBaseDir(tempRoot);
const userDataDir = firstWritableDirectory(
unique([
electronPathCandidate("userData"),
path.join(appDataBaseDir, appDirectoryName),
path.join(tempRoot, "presenton-user-data"),
]),
"user data"
);
const appDataDir = isDev
? firstWritableDirectory(
unique([path.join(baseDir, "app_data"), path.join(userDataDir, "app_data")]),
"application data"
)
: userDataDir;
const tempDir = firstWritableDirectory(
unique([path.join(tempRoot, "presenton"), path.join(userDataDir, "temp")]),
"temporary"
);
const logsDir = firstWritableDirectory(
unique([path.join(userDataDir, "logs"), path.join(tempDir, "logs")]),
"logs"
);
const cacheDir = firstWritableDirectory(
unique([path.join(userDataDir, "Cache"), path.join(tempDir, "Cache")]),
"cache"
);
const crashDumpsDir = firstWritableDirectory(
unique([path.join(userDataDir, "Crashpad"), path.join(tempDir, "Crashpad")]),
"crash dumps"
);
const sessionDataDir = userDataDir;
appPaths = {
userDataDir,
appDataDir,
tempDir,
logsDir,
userConfigPath: path.join(userDataDir, "userConfig.json"),
cacheDir,
crashDumpsDir,
sessionDataDir,
};
setElectronPath("userData", userDataDir);
setElectronPath("sessionData", sessionDataDir);
setElectronPath("temp", tempDir);
setElectronPath("crashDumps", crashDumpsDir);
setElectronPath("cache", cacheDir);
appendDiskCacheSwitch(cacheDir);
return appPaths;
}
export function ensureDirectoriesExist() {
initializeAppPaths();
}
function getInitializedPaths(): ElectronAppPaths {
return initializeAppPaths();
}
export function getUserDataDir(): string {
return getInitializedPaths().userDataDir;
}
export function getAppDataDir(): string {
return getInitializedPaths().appDataDir;
}
export function getTempDir(): string {
return getInitializedPaths().tempDir;
}
export function getLogsDir(): string {
return getInitializedPaths().logsDir;
}
export function getUserConfigPath(): string {
return getInitializedPaths().userConfigPath;
}
export function getDownloadsDir(): string {
if (!downloadsDir) {
downloadsDir = getDownloadsDirCandidate(getUserDataDir());
}
return downloadsDir;
}
export function getCacheDir(): string {
return getInitializedPaths().cacheDir;
}
export function getCrashDumpsDir(): string {
return getInitializedPaths().crashDumpsDir;
}
+43
View File
@@ -0,0 +1,43 @@
import { dialog } from "electron";
import path from "path";
import { openLocalPath, showOpenTargetErrorDialog } from "./open-target";
import { safeError } from "./safe-console";
export async function showFileDownloadedDialog(filePath: string): Promise<boolean> {
try {
const { response } = await dialog.showMessageBox({
type: "question",
buttons: ["Open File", "Open Folder", "Cancel"],
defaultId: 0,
title: "File Downloaded",
message: "What would you like to do?",
});
let targetPath: string | undefined;
let targetLabel: "file" | "folder" | undefined;
if (response === 0) {
targetPath = filePath;
targetLabel = "file";
} else if (response === 1) {
targetPath = path.dirname(filePath);
targetLabel = "folder";
}
if (targetPath && targetLabel) {
const result = await openLocalPath(targetPath);
if (!result.success) {
await showOpenTargetErrorDialog({
title: `Could Not Open ${targetLabel === "file" ? "File" : "Folder"}`,
message: `The exported file was saved, but Presenton could not open the ${targetLabel}.`,
detail: `${result.message || "No application is registered to open this item."}\n\nSaved location:\n${filePath}`,
});
}
}
return true;
} catch (error) {
safeError("Error handling downloaded file:", error);
return false;
}
}
+471
View File
@@ -0,0 +1,471 @@
import fs from "fs";
import os from "os";
import path from "path";
import { spawn } from "child_process";
import {
Browser,
Cache,
computeExecutablePath,
detectBrowserPlatform,
install,
} from "@puppeteer/browsers";
import { getCacheDir, resourceBaseDir } from "./constants";
import { isWindowsStoreInstall } from "./export-msix-runtime";
import { safeError, safeLog } from "./safe-console";
/** Must match the Chrome revision expected by the bundled presentation-export runtime. */
const EXPORT_CHROME_BUILD_ID =
process.env.EXPORT_CHROME_BUILD_ID?.trim() || "146.0.7680.76";
const BUNDLED_CHROMIUM_MANIFEST = "presenton-runtime.json";
type BundledChromiumManifest = {
browser?: string;
buildId?: string;
platform?: string;
nodePlatform?: string;
arch?: string;
executable?: string;
};
export type ChromiumInstallProgress = {
phase: "downloading" | "installing" | "done" | "error";
percent?: number;
message?: string;
};
function resolvePuppeteerCacheRoot(): string {
const configured = process.env.PUPPETEER_CACHE_DIR?.trim();
if (configured) {
return path.resolve(configured);
}
return path.join(os.homedir(), ".cache", "puppeteer");
}
export function getBundledExportChromiumCacheRoot(): string {
return path.join(resourceBaseDir, "resources", "chromium");
}
function readBundledChromiumManifest(): BundledChromiumManifest | null {
const manifestPath = path.join(getBundledExportChromiumCacheRoot(), BUNDLED_CHROMIUM_MANIFEST);
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as BundledChromiumManifest;
if (manifest.browser && manifest.browser !== Browser.CHROME) {
return null;
}
if (manifest.buildId && manifest.buildId !== EXPORT_CHROME_BUILD_ID) {
return null;
}
if (manifest.nodePlatform && manifest.nodePlatform !== process.platform) {
return null;
}
if (manifest.arch && manifest.arch !== process.arch) {
return null;
}
if (!manifest.executable) {
return null;
}
return manifest;
} catch {
return null;
}
}
function resolveManifestBundledExportChromiumPath(): string | null {
const manifest = readBundledChromiumManifest();
if (!manifest?.executable) {
return null;
}
const executablePath = path.join(getBundledExportChromiumCacheRoot(), manifest.executable);
return isMaterializedChromiumComplete(executablePath) ? executablePath : null;
}
function resolveExportChromeInstallOptions(cacheDir = resolvePuppeteerCacheRoot()):
| { browser: Browser.CHROME; buildId: string; cacheDir: string; platform: NonNullable<ReturnType<typeof detectBrowserPlatform>> }
| null {
const platform = detectBrowserPlatform();
if (!platform) {
return null;
}
return {
browser: Browser.CHROME,
buildId: EXPORT_CHROME_BUILD_ID,
cacheDir,
platform,
};
}
/** PreChrome-for-Testing cache layouts still present on some machines. */
function getLegacyExecutableRelativePaths(): string[] {
if (process.platform === "win32") {
return [
path.join("chrome-win64", "chrome.exe"),
path.join("chrome-win32", "chrome.exe"),
];
}
if (process.platform === "darwin") {
return [
path.join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
path.join("chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
path.join("chrome-mac-x64", "Chromium.app", "Contents", "MacOS", "Chromium"),
];
}
return [path.join("chrome-linux64", "chrome")];
}
function resolveLegacyInstalledExportChromiumPath(): string | null {
const chromeBaseDir = path.join(resolvePuppeteerCacheRoot(), "chrome");
let revisionDirs: string[] = [];
try {
revisionDirs = fs
.readdirSync(chromeBaseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(chromeBaseDir, entry.name));
} catch {
return null;
}
const legacyRelativePaths = getLegacyExecutableRelativePaths();
for (const revisionDir of revisionDirs) {
for (const relativePath of legacyRelativePaths) {
const executablePath = path.join(revisionDir, relativePath);
if (fs.existsSync(executablePath)) {
return executablePath;
}
}
}
return null;
}
export function resolveInstalledExportChromiumPath(): string | null {
const manifestBundledPath = resolveManifestBundledExportChromiumPath();
if (manifestBundledPath) {
return manifestBundledPath;
}
const bundledOptions = resolveExportChromeInstallOptions(getBundledExportChromiumCacheRoot());
if (bundledOptions) {
const bundledExpectedPath = computeExecutablePath(bundledOptions);
if (fs.existsSync(bundledExpectedPath)) {
return bundledExpectedPath;
}
const bundledCache = new Cache(bundledOptions.cacheDir);
for (const installed of bundledCache.getInstalledBrowsers()) {
if (installed.browser !== Browser.CHROME || installed.buildId !== bundledOptions.buildId) {
continue;
}
if (fs.existsSync(installed.executablePath)) {
return installed.executablePath;
}
}
}
const options = resolveExportChromeInstallOptions();
if (options) {
const expectedPath = computeExecutablePath(options);
if (fs.existsSync(expectedPath)) {
return expectedPath;
}
const cache = new Cache(options.cacheDir);
for (const installed of cache.getInstalledBrowsers()) {
if (installed.browser !== Browser.CHROME || installed.buildId !== options.buildId) {
continue;
}
if (fs.existsSync(installed.executablePath)) {
return installed.executablePath;
}
}
}
return resolveLegacyInstalledExportChromiumPath();
}
export function isExportChromiumAvailable(): boolean {
return Boolean(resolveInstalledExportChromiumPath());
}
function isPathUnderWindowsApps(filePath: string): boolean {
return /\\windowsapps\\/i.test(filePath);
}
function getMsixChromiumCacheRoot(): string {
return path.join(getCacheDir(), "msix-export-chromium", EXPORT_CHROME_BUILD_ID);
}
/**
* MSIX/APPX installs keep the app under Program Files\\WindowsApps. Chrome cannot
* reliably launch from that read-only package, so copy the browser folder to user cache.
*/
async function materializeBundledChromiumForMsix(bundledExePath: string): Promise<string> {
const browserDir = path.dirname(bundledExePath);
const revisionDir = path.dirname(browserDir);
const revisionName = path.basename(revisionDir);
const cacheRoot = getMsixChromiumCacheRoot();
const destRevisionDir = path.join(cacheRoot, "chrome", revisionName);
const destExe = path.join(destRevisionDir, path.basename(browserDir), path.basename(bundledExePath));
const stampPath = path.join(cacheRoot, ".source-revision-dir");
const sourceStamp = `${revisionDir}\n${await getDirectoryMtimeFingerprint(revisionDir)}`;
if (isMaterializedChromiumComplete(destExe)) {
try {
if ((await fs.promises.readFile(stampPath, "utf8")).trim() === sourceStamp.trim()) {
return destExe;
}
} catch {
// Stale cache; recopy below.
}
}
safeLog(
"[Chromium] Copying bundled Chrome for Microsoft Store install:",
destRevisionDir
);
await fs.promises.rm(cacheRoot, { recursive: true, force: true });
await fs.promises.mkdir(path.dirname(destRevisionDir), { recursive: true });
await fs.promises.cp(revisionDir, destRevisionDir, { recursive: true });
await fs.promises.writeFile(stampPath, sourceStamp, "utf8");
if (!isMaterializedChromiumComplete(destExe)) {
throw new Error(`Chrome executable missing after MSIX materialization: ${destExe}`);
}
return destExe;
}
function isMaterializedChromiumComplete(executablePath: string): boolean {
if (!fs.existsSync(executablePath)) {
return false;
}
if (process.platform !== "win32") {
return true;
}
const chromeDir = path.dirname(executablePath);
return ["chrome.dll", "icudtl.dat"].every((fileName) =>
fs.existsSync(path.join(chromeDir, fileName))
);
}
async function getDirectoryMtimeFingerprint(directory: string): Promise<string> {
let newestMtime = 0;
let fileCount = 0;
const visit = async (current: string) => {
const entries = await fs.promises.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
await visit(fullPath);
continue;
}
const stat = await fs.promises.stat(fullPath);
newestMtime = Math.max(newestMtime, stat.mtimeMs);
fileCount += 1;
}
};
await visit(directory);
return `${fileCount}:${newestMtime}`;
}
function verifyChromiumCanStart(executablePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const probe = spawn(
executablePath,
[
"--headless=new",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-sandbox",
"--no-first-run",
"--disable-extensions",
"about:blank",
],
{
stdio: "ignore",
windowsHide: process.platform === "win32",
}
);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
probe.kill();
resolve();
}, 3000);
probe.once("error", (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
});
probe.once("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
code === 0
? resolve()
: reject(new Error(`Chrome probe exited with code ${code ?? "unknown"}`));
});
});
}
/**
* Resolves a Chrome binary path that can actually be spawned (writable on MSIX/APPX).
*/
export async function resolveLaunchableExportChromiumPath(): Promise<string | null> {
const installed = resolveInstalledExportChromiumPath();
if (!installed) {
return null;
}
const mustMaterialize =
isWindowsStoreInstall() || isPathUnderWindowsApps(installed);
if (!mustMaterialize) {
return installed;
}
try {
const materializedPath = await materializeBundledChromiumForMsix(installed);
await verifyChromiumCanStart(materializedPath);
return materializedPath;
} catch (error) {
safeError("[Chromium] Failed to prepare Chrome for Microsoft Store export", error);
return null;
}
}
export async function removeBrokenExportChromiumCaches(): Promise<number> {
const cacheDir = resolvePuppeteerCacheRoot();
const cache = new Cache(cacheDir);
let removedCount = 0;
for (const installed of cache.getInstalledBrowsers()) {
if (installed.browser !== Browser.CHROME) {
continue;
}
if (fs.existsSync(installed.executablePath)) {
continue;
}
try {
await fs.promises.rm(installed.path, { recursive: true, force: true });
removedCount += 1;
safeLog(`[Chromium] Removed broken cache: ${installed.path}`);
} catch {
// Best effort cleanup only.
}
}
const chromeBaseDir = path.join(cacheDir, "chrome");
const legacyRelativePaths = getLegacyExecutableRelativePaths();
let revisionDirs: string[] = [];
try {
revisionDirs = fs
.readdirSync(chromeBaseDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(chromeBaseDir, entry.name));
} catch {
return removedCount;
}
for (const revisionDir of revisionDirs) {
const hasLegacyExecutable = legacyRelativePaths.some((relativePath) =>
fs.existsSync(path.join(revisionDir, relativePath))
);
if (hasLegacyExecutable) {
continue;
}
const basename = path.basename(revisionDir);
if (basename.includes("-")) {
continue;
}
try {
await fs.promises.rm(revisionDir, { recursive: true, force: true });
removedCount += 1;
safeLog(`[Chromium] Removed broken cache: ${revisionDir}`);
} catch {
// Best effort cleanup only.
}
}
return removedCount;
}
function formatMegabytes(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export async function installExportChromium(
onProgress?: (progress: ChromiumInstallProgress) => void
): Promise<void> {
const removed = await removeBrokenExportChromiumCaches();
if (removed > 0) {
onProgress?.({
phase: "installing",
message: `Removed ${removed} incomplete Chromium download${removed === 1 ? "" : "s"}.`,
});
}
if (isExportChromiumAvailable()) {
onProgress?.({ phase: "done", percent: 100, message: "Chromium is already installed." });
return;
}
const options = resolveExportChromeInstallOptions();
if (!options) {
throw new Error(`Unsupported platform for Chromium export runtime: ${process.platform}-${process.arch}`);
}
await fs.promises.mkdir(options.cacheDir, { recursive: true });
onProgress?.({
phase: "downloading",
percent: 0,
message: `Downloading Chromium ${options.buildId}`,
});
let lastLoggedPercent = -1;
await install({
...options,
downloadProgressCallback(downloadedBytes, totalBytes) {
if (totalBytes <= 0) {
return;
}
const percent = Math.min(99, Math.floor((downloadedBytes / totalBytes) * 100));
if (percent === lastLoggedPercent) {
return;
}
lastLoggedPercent = percent;
onProgress?.({
phase: "downloading",
percent,
message: `${formatMegabytes(downloadedBytes)} / ${formatMegabytes(totalBytes)}`,
});
},
});
if (!isExportChromiumAvailable()) {
const expectedPath = computeExecutablePath(options);
throw new Error(
`Chromium download finished but chrome executable was not found at ${expectedPath}. Check your network connection and try again.`
);
}
onProgress?.({
phase: "done",
percent: 100,
message: `Chromium ready (${resolveInstalledExportChromiumPath()})`,
});
}
export async function ensureExportChromiumReady(): Promise<boolean> {
await removeBrokenExportChromiumCaches();
if (isExportChromiumAvailable()) {
return true;
}
await installExportChromium();
return isExportChromiumAvailable();
}
+252
View File
@@ -0,0 +1,252 @@
import fs from "fs";
import path from "path";
import { createHash } from "crypto";
import { baseDir, getCacheDir, resourceBaseDir } from "./constants";
import { safeLog } from "./safe-console";
const CACHE_LAYOUT_VERSION = "2";
type ExportSpawnTarget = {
scriptPath: string;
converterPath: string;
};
/** MSIX installs live under Program Files\\WindowsApps and block dlopen on packaged .node files. */
export function isWindowsStoreInstall(): boolean {
if (process.platform !== "win32") {
return false;
}
const markers = [baseDir, process.execPath, path.dirname(process.execPath)];
return markers.some((candidate) => /\\windowsapps\\/i.test(candidate));
}
function getExportRuntimeVersion(): string {
try {
const packageJsonPath = path.join(baseDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
exportVersion?: string;
version?: string;
};
return pkg.exportVersion?.trim() || pkg.version?.trim() || "unknown";
} catch {
return "unknown";
}
}
function getMsixExportCacheRoot(exportRuntimeVersion: string): string {
return path.join(
getCacheDir(),
"msix-export-runtime",
CACHE_LAYOUT_VERSION,
exportRuntimeVersion
);
}
function sharpPackagesForPlatform(): string[] {
const arch = process.arch;
if (process.platform === "win32") {
if (arch === "arm64") {
return [
"sharp",
"@img/sharp-win32-arm64",
"@img/colour",
"detect-libc",
"semver",
];
}
return [
"sharp",
"@img/sharp-win32-x64",
"@img/colour",
"detect-libc",
"semver",
];
}
if (process.platform === "darwin") {
return arch === "arm64"
? ["sharp", "@img/sharp-darwin-arm64", "@img/colour", "detect-libc", "semver"]
: ["sharp", "@img/sharp-darwin-x64", "@img/colour", "detect-libc", "semver"];
}
return ["sharp", "@img/sharp-linux-x64", "@img/colour", "detect-libc", "semver"];
}
function resolvePackageSourceDir(sourceModulesRoot: string, packageName: string): string {
if (packageName.startsWith("@")) {
const [scope, name] = packageName.split("/");
return path.join(sourceModulesRoot, scope, name);
}
return path.join(sourceModulesRoot, packageName);
}
function resolvePackageDestDir(destModulesRoot: string, packageName: string): string {
return resolvePackageSourceDir(destModulesRoot, packageName);
}
function assertSourcePackagesAvailable(sourceModulesRoot: string): void {
const missingPackages = sharpPackagesForPlatform().filter((packageName) => {
const sourceDir = resolvePackageSourceDir(sourceModulesRoot, packageName);
return !fs.existsSync(path.join(sourceDir, "package.json"));
});
if (missingPackages.length > 0) {
throw new Error(
[
"Export dependencies are missing from the unpacked app bundle.",
`Missing: ${missingPackages.join(", ")}`,
`Expected under: ${sourceModulesRoot}`,
"Rebuild the APPX after unpacking sharp dependencies from app.asar.",
].join(" ")
);
}
}
async function fileFingerprint(filePath: string): Promise<string> {
const stat = await fs.promises.stat(filePath);
return `${stat.size}:${stat.mtimeMs}`;
}
async function buildSourceFingerprint(exportRoot: string, sourceModulesRoot: string): Promise<string> {
const hash = createHash("sha256");
const indexPath = path.join(exportRoot, "index.js");
hash.update(await fileFingerprint(indexPath));
for (const packageName of sharpPackagesForPlatform()) {
const packagePath = resolvePackageSourceDir(sourceModulesRoot, packageName);
hash.update(packageName);
hash.update(await fileFingerprint(path.join(packagePath, "package.json")));
if (packageName.startsWith("@img/sharp-")) {
const libDir = path.join(packagePath, "lib");
const entries = await fs.promises.readdir(libDir);
const nodeBinary = entries.find((entry) => entry.endsWith(".node"));
if (nodeBinary) {
hash.update(await fileFingerprint(path.join(libDir, nodeBinary)));
}
}
}
const pyDir = path.join(exportRoot, "py");
try {
const pyEntries = await fs.promises.readdir(pyDir);
for (const entry of pyEntries) {
if (!/^convert/i.test(entry)) {
continue;
}
hash.update(await fileFingerprint(path.join(pyDir, entry)));
}
} catch {
// Converter binaries are optional for fingerprinting.
}
return hash.digest("hex");
}
async function readCacheFingerprint(cacheRoot: string): Promise<string | null> {
const stampPath = path.join(cacheRoot, ".source-fingerprint");
try {
return (await fs.promises.readFile(stampPath, "utf8")).trim() || null;
} catch {
return null;
}
}
async function copyPath(source: string, destination: string): Promise<void> {
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
await fs.promises.cp(source, destination, { recursive: true, force: true });
}
async function materializeMsixExportRuntime(
exportRoot: string,
sourceModulesRoot: string,
cacheRoot: string
): Promise<void> {
safeLog("[Export] Preparing MSIX export runtime in user cache:", cacheRoot);
await fs.promises.rm(cacheRoot, { recursive: true, force: true });
await fs.promises.mkdir(cacheRoot, { recursive: true });
await copyPath(path.join(exportRoot, "index.js"), path.join(cacheRoot, "index.js"));
const pyDir = path.join(exportRoot, "py");
if (fs.existsSync(pyDir)) {
await copyPath(pyDir, path.join(cacheRoot, "py"));
}
const destModulesRoot = path.join(cacheRoot, "node_modules");
for (const packageName of sharpPackagesForPlatform()) {
const sourceDir = resolvePackageSourceDir(sourceModulesRoot, packageName);
const destDir = resolvePackageDestDir(destModulesRoot, packageName);
if (!fs.existsSync(sourceDir)) {
throw new Error(`Export dependency missing from app bundle: ${packageName} (${sourceDir})`);
}
await copyPath(sourceDir, destDir);
}
const fingerprint = await buildSourceFingerprint(exportRoot, sourceModulesRoot);
await fs.promises.writeFile(path.join(cacheRoot, ".source-fingerprint"), fingerprint, "utf8");
safeLog("[Export] MSIX export runtime ready.");
}
async function resolveConverterPathFromExportRoot(exportRoot: string): Promise<string> {
const pyDir = path.join(exportRoot, "py");
const extension = process.platform === "win32" ? ".exe" : "";
const converterCandidates = [
path.join(pyDir, `convert-${process.platform}-${process.arch}${extension}`),
path.join(pyDir, `convert-${process.platform}${extension}`),
...(process.platform === "win32"
? [path.join(pyDir, "convert.exe"), path.join(pyDir, "convert")]
: [path.join(pyDir, "convert")]),
];
for (const candidate of converterCandidates) {
try {
await fs.promises.access(candidate, fs.constants.F_OK);
return candidate;
} catch {
continue;
}
}
throw new Error(
[
"No converter binary found for export.",
"Expected one of:",
...converterCandidates.map((candidate) => ` - ${candidate}`),
].join("\n")
);
}
/**
* For Microsoft Store (MSIX) installs, copy export JS + sharp native addons to a writable
* directory so Node can dlopen them. Returns the original paths elsewhere.
*/
export async function resolveExportSpawnTarget(
packagedExportRoot: string,
packagedScriptPath: string,
resolvePackagedConverterPath: (exportRoot: string) => Promise<string>
): Promise<ExportSpawnTarget> {
if (!isWindowsStoreInstall()) {
return {
scriptPath: packagedScriptPath,
converterPath: await resolvePackagedConverterPath(packagedExportRoot),
};
}
const exportRuntimeVersion = getExportRuntimeVersion();
const cacheRoot = getMsixExportCacheRoot(exportRuntimeVersion);
const cachedScriptPath = path.join(cacheRoot, "index.js");
const sourceModulesRoot = path.join(resourceBaseDir, "node_modules");
assertSourcePackagesAvailable(sourceModulesRoot);
const expectedFingerprint = await buildSourceFingerprint(packagedExportRoot, sourceModulesRoot);
const cachedFingerprint = await readCacheFingerprint(cacheRoot);
const cacheIsCurrent =
cachedFingerprint === expectedFingerprint && fs.existsSync(cachedScriptPath);
if (!cacheIsCurrent) {
await materializeMsixExportRuntime(packagedExportRoot, sourceModulesRoot, cacheRoot);
}
return {
scriptPath: cachedScriptPath,
converterPath: await resolveConverterPathFromExportRoot(cacheRoot),
};
}
+208
View File
@@ -0,0 +1,208 @@
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
import { resourceBaseDir } from "./constants";
export type ImageMagickRuntime = {
binaryPath: string;
binDir: string;
homeDir: string;
version: string;
source: "bundled" | "system";
};
type ImageMagickRuntimeManifest = {
version?: string;
platform?: string;
arch?: string;
binary?: string;
kind?: string;
};
const RUNTIME_MANIFEST_NAME = "presenton-runtime.json";
function runVersion(command: string, homeDir = path.dirname(command)): string | null {
const result = spawnSync(command, ["-version"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
timeout: 15000,
env: {
...process.env,
MAGICK_HOME: homeDir,
MAGICK_CONFIGURE_PATH: homeDir,
MAGICK_TEMPORARY_PATH: process.env.TEMP || process.env.TMPDIR || homeDir,
MAGICK_OCL_DEVICE: "OFF",
APPIMAGE_EXTRACT_AND_RUN: "1",
},
windowsHide: true,
});
if (result.status !== 0) {
return null;
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
return output.toLowerCase().includes("imagemagick") ? output : null;
}
function resolveCommandPath(command: string): string | null {
if (path.isAbsolute(command)) {
return command;
}
if (process.platform === "win32") {
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-Command", `(Get-Command '${command}' -ErrorAction Stop).Source`],
{
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
windowsHide: true,
},
);
const lines = (result.stdout ?? "").trim().split(/\r?\n/).filter(Boolean);
const resolved = lines.length ? lines[lines.length - 1] : undefined;
return result.status === 0 && resolved ? resolved : null;
}
const result = spawnSync("which", [command], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
const resolved = (result.stdout ?? "").trim().split(/\r?\n/).filter(Boolean)[0];
return result.status === 0 && resolved ? resolved : null;
}
function uniqueExistingDirs(paths: string[]): string[] {
const seen = new Set<string>();
const dirs: string[] = [];
for (const candidate of paths) {
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
dirs.push(candidate);
}
}
return dirs;
}
export function getBundledImageMagickRoot(): string {
return path.join(resourceBaseDir, "resources", "imagemagick");
}
export function getBundledImageMagickDir(): string {
return path.join(getBundledImageMagickRoot(), `${process.platform}-${process.arch}`);
}
function readBundledRuntimeManifest(): ImageMagickRuntimeManifest | null {
const manifestPath = path.join(getBundledImageMagickDir(), RUNTIME_MANIFEST_NAME);
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as ImageMagickRuntimeManifest;
if (
manifest.platform &&
manifest.platform !== process.platform
) {
return null;
}
if (manifest.arch && manifest.arch !== process.arch) {
return null;
}
return manifest;
} catch {
return null;
}
}
function bundledBinaryCandidates(): string[] {
const runtimeDir = getBundledImageMagickDir();
const executable = process.platform === "win32" ? "magick.exe" : "magick";
const manifest = readBundledRuntimeManifest();
const manifestBinary = manifest?.binary
? [path.join(runtimeDir, manifest.binary)]
: [];
return [
...manifestBinary,
path.join(runtimeDir, executable),
path.join(runtimeDir, "bin", executable),
path.join(getBundledImageMagickRoot(), executable),
path.join(getBundledImageMagickRoot(), "bin", executable),
];
}
function systemBinaryCandidates(): string[] {
if (process.platform === "win32") {
return ["magick"];
}
return [
"magick",
"convert",
"/opt/homebrew/bin/magick",
"/usr/local/bin/magick",
"/opt/local/bin/magick",
"/usr/bin/magick",
"/usr/local/bin/convert",
"/usr/bin/convert",
];
}
function runtimeFromBinary(
binaryPath: string,
source: ImageMagickRuntime["source"],
): ImageMagickRuntime | null {
const resolvedBinaryPath = resolveCommandPath(binaryPath) ?? binaryPath;
const binDir = path.dirname(resolvedBinaryPath);
const manifest = source === "bundled" ? readBundledRuntimeManifest() : null;
const manifestRuntimeDir = getBundledImageMagickDir();
const homeDir = source === "bundled" && manifest?.binary
? manifestRuntimeDir
: path.basename(binDir).toLowerCase() === "bin"
? path.dirname(binDir)
: binDir;
const version = runVersion(resolvedBinaryPath, homeDir);
if (!version) {
return null;
}
return {
binaryPath: resolvedBinaryPath,
binDir,
homeDir,
version,
source,
};
}
export function resolveImageMagickRuntime(): ImageMagickRuntime | null {
for (const candidate of bundledBinaryCandidates()) {
if (fs.existsSync(candidate)) {
const runtime = runtimeFromBinary(candidate, "bundled");
if (runtime) {
return runtime;
}
}
}
for (const candidate of systemBinaryCandidates()) {
const runtime = runtimeFromBinary(candidate, "system");
if (runtime) {
return runtime;
}
}
return null;
}
export function buildPathWithImageMagick(runtime: ImageMagickRuntime): string {
const currentPath = process.env.Path ?? process.env.PATH ?? "";
return uniqueExistingDirs([runtime.binDir, currentPath])
.concat(
currentPath
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.filter((entry) => path.resolve(entry) !== path.resolve(runtime.binDir)),
)
.join(path.delimiter);
}
+93
View File
@@ -0,0 +1,93 @@
import net from 'net'
import treeKill from 'tree-kill'
import { getTempDir, getUserConfigPath, localhost } from './constants'
import { readUserConfigFile, updateUserConfigFile } from './user-config-store'
export function setUserConfig(userConfig: UserConfig) {
const userConfigPath = getUserConfigPath()
updateUserConfigFile<UserConfig>(userConfigPath, (existingConfig) => {
const definedIncomingEntries = Object.entries(userConfig).filter(([, value]) => value !== undefined)
return {
...existingConfig,
...Object.fromEntries(definedIncomingEntries),
CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN,
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,
CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID,
}
})
}
export function getUserConfig(): UserConfig {
const userConfigPath = getUserConfigPath()
return readUserConfigFile<UserConfig>(userConfigPath)
}
export function setupEnv(fastApiPort: number, nextjsPort: number) {
const { app } = require('electron');
process.env.APP_VERSION = app.getVersion();
process.env.SENTRY_RELEASE = process.env.SENTRY_RELEASE || `presenton-electron@${process.env.APP_VERSION}`;
process.env.SENTRY_ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || (app.isPackaged ? 'production' : 'development');
const tempDir = getTempDir();
const userConfigPath = getUserConfigPath();
process.env.NEXT_PUBLIC_FAST_API = `${localhost}:${fastApiPort}`;
process.env.TEMP_DIRECTORY = tempDir;
process.env.NEXT_PUBLIC_USER_CONFIG_PATH = userConfigPath;
process.env.NEXT_PUBLIC_URL = `${localhost}:${nextjsPort}`;
// Set environment variables for NextJS API routes
process.env.USER_CONFIG_PATH = userConfigPath;
// Read CAN_CHANGE_KEYS from existing env or default to true
if (process.env.CAN_CHANGE_KEYS === undefined) {
process.env.CAN_CHANGE_KEYS = "true";
}
}
export function killProcess(pid: number, signal: NodeJS.Signals = "SIGTERM") {
return new Promise((resolve, reject) => {
treeKill(pid, signal, (err: any) => {
if (err) {
console.error(`Error killing process ${pid}:`, err)
reject(err)
} else {
console.log(`Process ${pid} killed (${signal})`)
resolve(true)
}
})
})
}
export async function findUnusedPorts(startPort: number = 40000, count: number = 2): Promise<number[]> {
const ports: number[] = [];
console.log(`Finding ${count} unused ports starting from ${startPort}`);
const isPortAvailable = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port);
});
};
let currentPort = startPort;
while (ports.length < count) {
if (await isPortAvailable(currentPort)) {
ports.push(currentPort);
}
currentPort++;
}
return ports;
}
export function sanitizeFilename(filename: string): string {
return filename.replace(/[\\/:*?"<>|]/g, '_');
}
+147
View File
@@ -0,0 +1,147 @@
import type { ChildProcess } from "child_process";
import type { BrowserWindow, WebContents } from "electron";
type DestroyableStream = {
destroyed?: boolean;
destroy?: (error?: Error) => void;
end?: () => void;
};
export function hasLiveWebContents(win: BrowserWindow | null | undefined): win is BrowserWindow {
return Boolean(win && !win.isDestroyed() && !win.webContents.isDestroyed());
}
export function safeSendToWebContents(
wc: WebContents | null | undefined,
channel: string,
payload: unknown,
): boolean {
try {
if (!wc || wc.isDestroyed()) {
return false;
}
wc.send(channel, payload);
return true;
} catch {
return false;
}
}
export function safeCloseWindow(win: BrowserWindow | null | undefined, destroy = false): void {
try {
if (!win || win.isDestroyed()) {
return;
}
if (destroy) {
win.destroy();
} else {
win.close();
}
} catch {
/* Window/native handle may already be tearing down. */
}
}
export function isChildProcessAlive(child: ChildProcess | null | undefined): child is ChildProcess {
return Boolean(
child &&
child.pid &&
child.exitCode === null &&
child.signalCode === null &&
!child.killed,
);
}
export function waitForChildClose(
child: ChildProcess,
timeoutMs = 5_000,
): Promise<boolean> {
if (!isChildProcessAlive(child)) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let settled = false;
const finish = (closed: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
child.removeListener("close", onClose);
child.removeListener("error", onError);
resolve(closed);
};
const onClose = () => finish(true);
const onError = () => finish(true);
const timer = setTimeout(() => finish(false), timeoutMs);
child.once("close", onClose);
child.once("error", onError);
});
}
export function destroyChildProcessStdio(child: ChildProcess): void {
const streams: Array<DestroyableStream | null | undefined> = [
child.stdin,
child.stdout,
child.stderr,
];
for (const stream of streams) {
try {
if (!stream || stream.destroyed) {
continue;
}
if (typeof stream.end === "function") {
stream.end();
}
if (typeof stream.destroy === "function") {
stream.destroy();
}
} catch {
/* Ignore stream teardown races during app shutdown. */
}
}
}
export async function terminateChildProcess(
child: ChildProcess,
name: string,
killProcessTree: (pid: number, signal: NodeJS.Signals) => Promise<unknown>,
options: {
gracefulSignal?: NodeJS.Signals;
forceSignal?: NodeJS.Signals;
gracefulTimeoutMs?: number;
} = {},
): Promise<void> {
const gracefulSignal = options.gracefulSignal ?? "SIGTERM";
const forceSignal = options.forceSignal ?? "SIGKILL";
const gracefulTimeoutMs = options.gracefulTimeoutMs ?? 5_000;
if (!isChildProcessAlive(child) || !child.pid) {
destroyChildProcessStdio(child);
return;
}
try {
await killProcessTree(child.pid, gracefulSignal);
} catch {
/* Process may have already exited between the alive check and kill request. */
}
const closed = await waitForChildClose(child, gracefulTimeoutMs);
if (!closed && isChildProcessAlive(child) && child.pid) {
try {
await killProcessTree(child.pid, forceSignal);
} catch {
/* Best-effort forced shutdown. */
}
await waitForChildClose(child, 2_000);
}
destroyChildProcessStdio(child);
}
+6
View File
@@ -0,0 +1,6 @@
import path from "path";
import { resourceBaseDir } from "./constants";
export function getLiteParseRunnerPath(): string {
return path.join(resourceBaseDir, "resources", "document-extraction", "liteparse_runner.mjs");
}
+38
View File
@@ -0,0 +1,38 @@
export function memorySnapshotMb(): Record<string, number> {
const usage = process.memoryUsage();
return {
rss_mb: Math.round(usage.rss / 1024 / 1024),
heap_used_mb: Math.round(usage.heapUsed / 1024 / 1024),
heap_total_mb: Math.round(usage.heapTotal / 1024 / 1024),
external_mb: Math.round(usage.external / 1024 / 1024),
array_buffers_mb: Math.round(usage.arrayBuffers / 1024 / 1024),
};
}
export class BoundedTextBuffer {
private text = "";
private truncatedChars = 0;
constructor(private readonly limit = 8192) {}
append(value: Buffer | string): void {
const next = Buffer.isBuffer(value) ? value.toString("utf8") : value;
if (this.limit <= 0) {
this.truncatedChars += next.length;
return;
}
let combined = this.text + next;
if (combined.length > this.limit) {
const overflow = combined.length - this.limit;
this.truncatedChars += overflow;
combined = combined.slice(overflow);
}
this.text = combined;
}
toString(): string {
const body = this.text.trim();
if (!this.truncatedChars) return body;
return `... [truncated ${this.truncatedChars} chars]\n${body}`.trim();
}
}
+116
View File
@@ -0,0 +1,116 @@
import { dialog, shell, type BrowserWindow } from "electron";
import fs from "fs";
import path from "path";
import { safeWarn } from "./safe-console";
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error;
}
return fallback;
}
export function isSupportedExternalUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ALLOWED_EXTERNAL_PROTOCOLS.has(parsedUrl.protocol);
} catch {
return false;
}
}
export async function openExternalUrl(url: string): Promise<IPCStatus> {
if (!isSupportedExternalUrl(url)) {
return {
success: false,
message: "Only http and https links can be opened.",
};
}
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return {
success: false,
message: getErrorMessage(error, "The operating system could not open this link."),
};
}
}
export async function openLocalPath(targetPath: string): Promise<IPCStatus> {
const normalizedPath = path.normalize(targetPath);
if (!normalizedPath || !path.isAbsolute(normalizedPath)) {
return {
success: false,
message: "The file path is invalid.",
};
}
try {
await fs.promises.access(normalizedPath, fs.constants.F_OK);
} catch {
return {
success: false,
message: "The file or folder no longer exists.",
};
}
try {
const openError = await shell.openPath(normalizedPath);
if (openError) {
return {
success: false,
message: openError,
};
}
return { success: true };
} catch (error) {
return {
success: false,
message: getErrorMessage(error, "The operating system could not open this file or folder."),
};
}
}
export async function showOpenTargetErrorDialog({
parent,
title,
message,
detail,
}: {
parent?: BrowserWindow;
title: string;
message: string;
detail?: string;
}): Promise<void> {
try {
const options = {
type: "error" as const,
buttons: ["OK"],
defaultId: 0,
noLink: true,
title,
message,
detail,
};
if (parent && !parent.isDestroyed()) {
await dialog.showMessageBox(parent, options);
return;
}
await dialog.showMessageBox(options);
} catch (error) {
safeWarn("[Presenton] Failed to show open target error dialog:", error);
}
}
@@ -0,0 +1,93 @@
import fs from "fs";
import path from "path";
import { getAppDataDir, getTempDir } from "./constants";
export class LocalFileAccessError extends Error {
constructor(
message: string,
public readonly code: "INVALID_PATH" | "NOT_FOUND" | "ACCESS_DENIED",
) {
super(message);
this.name = "LocalFileAccessError";
}
}
function resolveBaseDir(baseDir: string): string {
const resolvedBaseDir = path.resolve(baseDir);
try {
return fs.realpathSync(resolvedBaseDir);
} catch {
return resolvedBaseDir;
}
}
function allowedReadableFileBaseDirs(): string[] {
return [getAppDataDir(), getTempDir()].map(resolveBaseDir);
}
function assertPathAllowed(candidatePath: string, baseDirs: string[]): void {
for (const baseDir of baseDirs) {
if (candidatePath === baseDir || candidatePath.startsWith(`${baseDir}${path.sep}`)) {
return;
}
}
throw new LocalFileAccessError(
"Access denied: File path not allowed",
"ACCESS_DENIED",
);
}
export function resolveReadableLocalFile(filePath: unknown): string {
if (typeof filePath !== "string" || filePath.trim().length === 0) {
throw new LocalFileAccessError("Invalid file path", "INVALID_PATH");
}
const requestedPath = path.resolve(filePath);
const allowedBaseDirs = allowedReadableFileBaseDirs();
assertPathAllowed(requestedPath, allowedBaseDirs);
let resolvedPath: string;
try {
resolvedPath = fs.realpathSync(requestedPath);
} catch {
throw new LocalFileAccessError("File not found", "NOT_FOUND");
}
assertPathAllowed(resolvedPath, allowedBaseDirs);
return resolvedPath;
}
export function readReadableLocalFile(filePath: unknown): string {
if (typeof filePath !== "string" || filePath.trim().length === 0) {
throw new LocalFileAccessError("Invalid file path", "INVALID_PATH");
}
const requestedPath = path.resolve(filePath);
const [appDataDir, tempDir] = allowedReadableFileBaseDirs();
if (!(requestedPath.startsWith(appDataDir) || requestedPath.startsWith(tempDir))) {
throw new LocalFileAccessError(
"Access denied: File path not allowed",
"ACCESS_DENIED",
);
}
let resolvedPath: string;
try {
resolvedPath = fs.realpathSync(requestedPath);
} catch {
throw new LocalFileAccessError("File not found", "NOT_FOUND");
}
if (!(resolvedPath.startsWith(appDataDir) || resolvedPath.startsWith(tempDir))) {
throw new LocalFileAccessError(
"Access denied: File path not allowed",
"ACCESS_DENIED",
);
}
assertPathAllowed(resolvedPath, [appDataDir, tempDir]);
return fs.readFileSync(resolvedPath, "utf-8");
}
+100
View File
@@ -0,0 +1,100 @@
import { format } from "util";
type WritableStream = NodeJS.WriteStream & {
closed?: boolean;
destroyed?: boolean;
writable?: boolean;
writableEnded?: boolean;
writableFinished?: boolean;
};
let installed = false;
const originalConsoleError = console.error.bind(console);
export function isIgnorablePipeError(error: unknown): boolean {
const err = error as NodeJS.ErrnoException | undefined;
const code = typeof err?.code === "string" ? err.code : "";
const message = error instanceof Error ? error.message : String(error ?? "");
return (
code === "EPIPE" ||
code === "ERR_STREAM_DESTROYED" ||
code === "ERR_STREAM_WRITE_AFTER_END" ||
/broken pipe/i.test(message) ||
/write after end/i.test(message) ||
/stream has been destroyed/i.test(message)
);
}
function isWritable(stream: WritableStream): boolean {
return (
stream.writable !== false &&
!stream.closed &&
!stream.destroyed &&
!stream.writableEnded &&
!stream.writableFinished
);
}
function reportUnexpectedStreamError(error: unknown): void {
if (isIgnorablePipeError(error)) {
return;
}
try {
originalConsoleError("[SafeConsole] stdout/stderr stream error:", error);
} catch {
/* If the console itself is unavailable, there is nowhere useful to report this. */
}
}
function writeToStream(stream: WritableStream, text: string): boolean {
if (!isWritable(stream)) {
return false;
}
try {
return stream.write(text, (error?: Error | null) => {
if (error) {
reportUnexpectedStreamError(error);
}
});
} catch (error) {
reportUnexpectedStreamError(error);
return false;
}
}
export function safeStdoutWrite(text: string): boolean {
return writeToStream(process.stdout, text);
}
export function safeStderrWrite(text: string): boolean {
return writeToStream(process.stderr, text);
}
export function safeLog(...args: unknown[]): void {
safeStdoutWrite(`${format(...args)}\n`);
}
export function safeWarn(...args: unknown[]): void {
safeStderrWrite(`${format(...args)}\n`);
}
export function safeError(...args: unknown[]): void {
safeStderrWrite(`${format(...args)}\n`);
}
export function installSafeConsole(): void {
if (installed) {
return;
}
installed = true;
process.stdout.on("error", reportUnexpectedStreamError);
process.stderr.on("error", reportUnexpectedStreamError);
console.log = safeLog;
console.warn = safeWarn;
console.error = safeError;
}
+393
View File
@@ -0,0 +1,393 @@
import { ChildProcess, spawn } from "child_process";
import { getLogsDir, localhost } from "./constants";
import http from "http";
import fs from "fs";
import path from "path";
import { safeError, safeLog as safeConsoleLog } from "./safe-console";
import { memorySnapshotMb } from "./memory";
import { destroyChildProcessStdio, terminateChildProcess } from "./lifecycle";
import { killProcess } from "./index";
type ManagedServerProcess = {
process: ChildProcess;
ready: Promise<void>;
stop: () => Promise<void>;
};
function resolveNextJsStandaloneServer(directory: string): {
serverScript: string;
cwd: string;
} {
const directScript = path.join(directory, "server.js");
if (fs.existsSync(directScript)) {
return { serverScript: directScript, cwd: directory };
}
const nestedScript = path.join(directory, "servers", "nextjs", "server.js");
if (fs.existsSync(nestedScript)) {
return { serverScript: nestedScript, cwd: path.dirname(nestedScript) };
}
throw new Error(`Next.js standalone server not found under: ${directory}`);
}
/** Next.js 16+ standalone runs from servers/nextjs/; static/public must sit next to server.js. */
function ensureNestedStandaloneAssets(bundleRoot: string, serverCwd: string): void {
if (path.resolve(bundleRoot) === path.resolve(serverCwd)) {
return;
}
const copyIfMissing = (source: string, destination: string) => {
if (!fs.existsSync(source) || fs.existsSync(destination)) {
return;
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.cpSync(source, destination, { recursive: true });
safeConsoleLog(`[Presenton] Linked Next.js assets: ${path.basename(source)} -> ${destination}`);
};
copyIfMissing(
path.join(bundleRoot, ".next-build", "static"),
path.join(serverCwd, ".next-build", "static"),
);
copyIfMissing(
path.join(bundleRoot, "public"),
path.join(serverCwd, "public"),
);
}
function createManagedServerProcess(params: {
name: string;
process: ChildProcess;
readyUrl: string;
cleanupListeners: () => void;
markStopping?: (stopping: boolean) => void;
}): ManagedServerProcess {
const abortController = new AbortController();
let stopPromise: Promise<void> | null = null;
const stop = async () => {
if (stopPromise) {
return stopPromise;
}
params.markStopping?.(true);
abortController.abort();
params.cleanupListeners();
stopPromise = terminateChildProcess(
params.process,
params.name,
killProcess,
).finally(() => {
params.cleanupListeners();
destroyChildProcessStdio(params.process);
});
return stopPromise;
};
return {
process: params.process,
ready: waitForServer(params.readyUrl, 120000, abortController.signal),
stop,
};
}
export async function startFastApiServer(
directory: string,
port: number,
env: FastApiEnv,
isDev: boolean,
) {
// Start FastAPI server
let command: string;
let args: string[];
if (isDev) {
command = "uv";
args = ["run", "python", "server.py", "--port", port.toString(), "--reload", "true"];
} else {
const binary = process.platform === "win32" ? "fastapi.exe" : "fastapi";
command = path.join(directory, binary);
args = ["--port", port.toString()];
}
const safeFileLog = (data: Buffer | string, logPath: string) => {
try {
fs.appendFileSync(logPath, data);
} catch {
/* ignore if logs dir not writable */
}
};
const logsDir = getLogsDir();
const fastapiLogPath = path.join(logsDir, "fastapi-server.log");
const fastApiProcess = spawn(
command,
args,
{
cwd: directory,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...env },
windowsHide: process.platform === "win32" && !isDev,
}
);
const onFastApiStdoutData = (data: any) => {
safeFileLog(data, fastapiLogPath);
safeConsoleLog(`FastAPI: ${data}`);
};
const onFastApiStderrData = (data: any) => {
safeFileLog(data, fastapiLogPath);
safeError(`FastAPI: ${data}`);
};
const onFastApiError = (err: Error) => {
safeFileLog(`Spawn error: ${err.message}\n`, fastapiLogPath);
};
fastApiProcess.stdout.on("data", onFastApiStdoutData);
fastApiProcess.stderr.on("data", onFastApiStderrData);
fastApiProcess.on("error", onFastApiError);
safeConsoleLog("[Presenton] FastAPI process spawned:", {
pid: fastApiProcess.pid,
memory: memorySnapshotMb(),
});
const cleanupListeners = () => {
fastApiProcess.stdout?.removeListener("data", onFastApiStdoutData);
fastApiProcess.stderr?.removeListener("data", onFastApiStderrData);
fastApiProcess.removeListener("error", onFastApiError);
};
return createManagedServerProcess({
name: "FastAPI",
process: fastApiProcess,
readyUrl: `${localhost}:${port}/docs`,
cleanupListeners,
});
}
export async function startNextJsServer(
directory: string,
port: number,
env: NextJsEnv,
isDev: boolean,
) {
let nextjsProcess: ChildProcess;
let stopping = false;
if (isDev) {
// Windows: npm is npm.cmd; spawn() needs a shell or ENOENT.
nextjsProcess = spawn(
process.platform === "win32" ? "npm.cmd" : "npm",
["run", "dev", "--", "-p", port.toString()],
{
cwd: directory,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...env },
shell: process.platform === "win32",
}
);
const nextjsLogPath = path.join(getLogsDir(), "nextjs-server.log");
const safeNextLog = (d: Buffer | string) => {
try {
fs.appendFileSync(nextjsLogPath, d);
} catch {
/* ignore */
}
};
const onStdoutData = (data: any) => {
safeNextLog(data);
safeConsoleLog(`NextJS: ${data}`);
};
const onStderrData = (data: any) => {
safeNextLog(data);
safeError(`NextJS: ${data}`);
};
const onError = (err: Error) => {
safeNextLog(`Spawn error: ${err.message}\n`);
safeError(`NextJS spawn error: ${err.message}`);
};
const onExit = (code: number | null, signal: string | null) => {
if (stopping) {
return;
}
safeError(`NextJS process exited unexpectedly: code=${code}, signal=${signal}`);
};
nextjsProcess.stdout?.on("data", onStdoutData);
nextjsProcess.stderr?.on("data", onStderrData);
nextjsProcess.on("error", onError);
nextjsProcess.on("exit", onExit);
const cleanupListeners = () => {
nextjsProcess.stdout?.removeListener("data", onStdoutData);
nextjsProcess.stderr?.removeListener("data", onStderrData);
nextjsProcess.removeListener("error", onError);
nextjsProcess.removeListener("exit", onExit);
};
return createManagedServerProcess({
name: "NextJS",
process: nextjsProcess,
readyUrl: `${localhost}:${port}`,
cleanupListeners,
markStopping: (value) => {
stopping = value;
},
});
} else {
const { serverScript, cwd } = resolveNextJsStandaloneServer(directory);
ensureNestedStandaloneAssets(directory, cwd);
nextjsProcess = spawn(
process.execPath,
[serverScript],
{
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...env,
ELECTRON_RUN_AS_NODE: "1",
HOSTNAME: "127.0.0.1",
PORT: port.toString(),
},
windowsHide: process.platform === "win32",
}
);
const nextjsLogPath = path.join(getLogsDir(), "nextjs-server.log");
const safeNextLog = (d: Buffer | string) => {
try {
fs.appendFileSync(nextjsLogPath, d);
} catch {
/* ignore */
}
};
const onStdoutData = (data: any) => {
safeNextLog(data);
safeConsoleLog(`NextJS: ${data}`);
};
const onStderrData = (data: any) => {
safeNextLog(data);
safeError(`NextJS: ${data}`);
};
const onError = (err: Error) => {
safeNextLog(`Spawn error: ${err.message}\n`);
safeError(`NextJS spawn error: ${err.message}`);
};
const onExit = (code: number | null, signal: string | null) => {
if (stopping) {
return;
}
safeError(`NextJS process exited unexpectedly: code=${code}, signal=${signal}`);
};
nextjsProcess.stdout?.on("data", onStdoutData);
nextjsProcess.stderr?.on("data", onStderrData);
nextjsProcess.on("error", onError);
nextjsProcess.on("exit", onExit);
const cleanupListeners = () => {
nextjsProcess.stdout?.removeListener("data", onStdoutData);
nextjsProcess.stderr?.removeListener("data", onStderrData);
nextjsProcess.removeListener("error", onError);
nextjsProcess.removeListener("exit", onExit);
};
return createManagedServerProcess({
name: "NextJS",
process: nextjsProcess,
readyUrl: `${localhost}:${port}`,
cleanupListeners,
markStopping: (value) => {
stopping = value;
},
});
}
}
function abortableDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(new Error("Server wait aborted"));
}
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
signal?.removeEventListener("abort", onAbort);
};
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
cleanup();
callback();
};
const onAbort = () => {
finish(() => reject(new Error("Server wait aborted")));
};
const timer = setTimeout(() => finish(resolve), delayMs);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function waitForServer(url: string, timeout = 120000, signal?: AbortSignal): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (signal?.aborted) {
throw new Error("Server wait aborted");
}
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
const req = http.get(url, (res) => {
cleanup();
res.resume();
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) {
finish(resolve);
} else {
finish(() => reject(new Error(`Unexpected status code: ${res.statusCode}`)));
}
});
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
cleanup();
callback();
};
const onError = (error: Error) => finish(() => reject(error));
const onAbort = () => {
req.on("error", () => {});
req.destroy();
finish(() => reject(new Error("Server wait aborted")));
};
const onTimeout = () => {
req.on("error", () => {});
req.destroy();
finish(() => reject(new Error('Request timed out')));
};
const cleanup = () => {
req.removeListener("error", onError);
signal?.removeEventListener("abort", onAbort);
};
req.on('error', onError);
req.setTimeout(5000, onTimeout);
signal?.addEventListener("abort", onAbort, { once: true });
});
return;
} catch (error) {
if (signal?.aborted) {
throw error;
}
await abortableDelay(1000, signal);
}
}
throw new Error(`Server did not start within ${timeout}ms`);
}
+390
View File
@@ -0,0 +1,390 @@
import { net } from "electron";
import { app, BrowserWindow } from "electron";
import { isDev } from "./constants";
import { safeStderrWrite } from "./safe-console";
/**
* Version check URL — GitHub raw version.json (no API required).
* Override with UPDATE_SERVER_URL for local testing.
*/
const VERSION_JSON_URL =
process.env.UPDATE_SERVER_URL ||
"https://raw.githubusercontent.com/presenton/presenton/refs/heads/main/electron/version.json";
const CURRENT_VERSION = app.getVersion();
const WEBSITE_DOWNLOAD_URL = "https://presenton.ai/download";
/** Maximum number of fetch attempts (polls). */
const MAX_ATTEMPTS = 3;
/** Wait 2 minutes after load before first poll (10s in dev for testing). */
const INITIAL_DELAY_MS = isDev ? 10 * 1_000 : 2 * 60 * 1_000;
/** 1 minute between poll attempts (5s in dev for testing). */
const POLL_INTERVAL_MS = isDev ? 5 * 1_000 : 1 * 60 * 1_000;
/** Short delay before injecting banner to allow React/Next.js to mount. */
const INJECT_DELAY_MS = isDev ? 500 : 1_000;
function log(msg: string): void {
const line = `[UpdateChecker] ${msg}\n`;
safeStderrWrite(line);
}
interface VersionResponse {
version: string;
message?: string;
downloads?: {
linux: string;
mac: string;
windows: string;
};
}
/**
* Simple semver comparison that strips pre-release labels for numeric comparison.
* Returns true if `remote` is strictly newer than `current`.
*/
function isNewerVersion(current: string, remote: string): boolean {
const toNumbers = (v: string) =>
v
.replace(/[^0-9.]/g, "")
.split(".")
.map(Number);
const curr = toNumbers(current);
const rem = toNumbers(remote);
const len = Math.max(curr.length, rem.length);
for (let i = 0; i < len; i++) {
const c = curr[i] ?? 0;
const r = rem[i] ?? 0;
if (r > c) return true;
if (r < c) return false;
}
return false;
}
async function fetchVersionInfo(): Promise<VersionResponse | null> {
try {
log(`Fetching ${VERSION_JSON_URL}...`);
const response = await net.fetch(VERSION_JSON_URL, {
method: "GET",
headers: { "User-Agent": `Presenton/${CURRENT_VERSION}` },
});
if (!response.ok) {
log(`Fetch failed: HTTP ${response.status}`);
return null;
}
const data = (await response.json()) as VersionResponse;
log(`Fetched version: ${data.version}`);
return data;
} catch (err) {
log(`Fetch error: ${err}`);
return null;
}
}
/** Pending update to re-inject on navigation (production: React/Next.js may replace DOM). */
let pendingUpdate: { version: string; downloadUrl: string; message?: string } | null = null;
type UpdateTimer = ReturnType<typeof setTimeout>;
const scheduledTimers = new Set<UpdateTimer>();
const delayCancels = new Set<() => void>();
let updateCheckerStopped = true;
let cleanupUpdateCheckerListeners: (() => void) | null = null;
function hasLiveWebContents(win: BrowserWindow): boolean {
return !win.isDestroyed() && !win.webContents.isDestroyed();
}
function scheduleUpdateTimer(callback: () => void, delayMs: number): void {
if (updateCheckerStopped) return;
const timer = setTimeout(() => {
scheduledTimers.delete(timer);
if (!updateCheckerStopped) {
callback();
}
}, delayMs);
scheduledTimers.add(timer);
}
function waitForUpdateDelay(delayMs: number): Promise<boolean> {
if (updateCheckerStopped) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
let cancel = () => {};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
scheduledTimers.delete(timer);
delayCancels.delete(cancel);
resolve(!updateCheckerStopped);
}, delayMs);
cancel = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
scheduledTimers.delete(timer);
delayCancels.delete(cancel);
resolve(false);
};
scheduledTimers.add(timer);
delayCancels.add(cancel);
});
}
function clearScheduledUpdateWork(): void {
for (const cancel of Array.from(delayCancels)) {
cancel();
}
for (const timer of Array.from(scheduledTimers)) {
clearTimeout(timer);
scheduledTimers.delete(timer);
}
if (cleanupUpdateCheckerListeners) {
cleanupUpdateCheckerListeners();
cleanupUpdateCheckerListeners = null;
}
}
/**
* Schedules banner injection after INJECT_DELAY_MS so React/Next.js can mount first.
* In production (.deb), the DOM may not be ready when did-finish-load fires.
*/
function scheduleBannerInjection(
win: BrowserWindow,
version: string,
downloadUrl: string,
message?: string
): void {
pendingUpdate = { version, downloadUrl, message };
scheduleUpdateTimer(() => {
if (!hasLiveWebContents(win) || !pendingUpdate) return;
log(`Injecting banner now`);
injectUpdateBanner(win, pendingUpdate.version, pendingUpdate.downloadUrl, pendingUpdate.message);
}, INJECT_DELAY_MS);
}
/** Escape HTML to prevent XSS; preserve newlines for display. */
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\n/g, "<br>");
}
/**
* Injects an update banner at the bottom, aligned with the app UI.
* Includes a "View details" overlay for changelog/message.
*/
function injectUpdateBanner(
win: BrowserWindow,
latest: string,
downloadUrl: string,
message?: string
): void {
if (!hasLiveWebContents(win)) {
return;
}
const hasMessage = Boolean(message && message.trim());
const safeMessage = hasMessage ? escapeHtml(message!.trim()) : "";
const safeMessageJson = JSON.stringify(safeMessage);
const viewDetailsBtnHtml = hasMessage
? '<button id="__presenton_view_details_btn__" style="color:#64748b;background:none;border:none;cursor:pointer;font-size:12px;padding:4px 8px;text-decoration:underline;text-underline-offset:2px;">View details</button>'
: "";
const script = /* js */ `
(function () {
if (document.getElementById('__presenton_update_banner__')) return;
const msgHtml = ${safeMessageJson};
const banner = document.createElement('div');
banner.id = '__presenton_update_banner__';
banner.style.cssText = [
'position:fixed',
'bottom:16px',
'left:50%',
'transform:translateX(-50%)',
'max-width:min(560px,calc(100vw - 32px))',
'width:100%',
'background:rgba(255,255,255,0.95)',
'backdrop-filter:blur(12px)',
'-webkit-backdrop-filter:blur(12px)',
'color:#191919',
'display:flex',
'align-items:center',
'justify-content:space-between',
'padding:12px 16px',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'font-size:13px',
'z-index:2147483646',
'border:1px solid rgba(148,163,184,0.3)',
'border-radius:12px',
'box-shadow:0 4px 24px rgba(0,0,0,0.08)',
'gap:12px',
].join(';');
banner.innerHTML = \`
<span style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
<span style="font-size:18px;">✨</span>
<span>
Presenton&nbsp;<strong style="color:#5141e5">${latest}</strong>&nbsp;is available
&mdash;&nbsp;you have&nbsp;<strong>${CURRENT_VERSION}</strong>
</span>
</span>
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;">
${viewDetailsBtnHtml}
<a href="${downloadUrl}" target="_blank" style="color:#fff;text-decoration:none;background:#5141e5;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:500;white-space:nowrap;">Download update</a>
<button onclick="document.getElementById('__presenton_update_banner__').remove();var o=document.getElementById('__presenton_update_overlay__');if(o)o.remove();" title="Dismiss" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:20px;line-height:1;padding:0 2px;">&times;</button>
</div>
\`;
document.body.appendChild(banner);
if (msgHtml) {
const overlay = document.createElement('div');
overlay.id = '__presenton_update_overlay__';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:none;align-items:center;justify-content:center;z-index:2147483647;padding:24px;';
overlay.onclick = function(e) { if (e.target === overlay) overlay.style.display = 'none'; };
overlay.innerHTML = \`
<div style="background:#fff;border-radius:16px;max-width:420px;width:100%;max-height:80vh;overflow:auto;box-shadow:0 24px 48px rgba(0,0,0,0.15);padding:24px;" onclick="event.stopPropagation()">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;font-size:18px;font-weight:600;color:#191919;">What's new in ${latest}</h3>
<button onclick="document.getElementById('__presenton_update_overlay__').style.display='none'" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:24px;line-height:1;padding:0;">&times;</button>
</div>
<div style="color:#475569;font-size:14px;line-height:1.6;" id="__presenton_overlay_content__"></div>
</div>
\`;
document.body.appendChild(overlay);
document.getElementById('__presenton_overlay_content__').innerHTML = msgHtml;
document.getElementById('__presenton_view_details_btn__').onclick = function() {
document.getElementById('__presenton_update_overlay__').style.display = 'flex';
};
}
})();
`;
win.webContents.executeJavaScript(script).catch((err) => {
log(`Banner injection failed: ${err}`);
});
}
/**
* Polls for version info up to MAX_ATTEMPTS times with 1 min between attempts.
* Stops as soon as a successful response is received or all attempts are exhausted.
*/
async function checkForUpdatesWithRetry(win: BrowserWindow): Promise<void> {
log(`Starting check (current: ${CURRENT_VERSION})`);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (updateCheckerStopped || !hasLiveWebContents(win)) {
log("Window destroyed, aborting");
return;
}
log(`Attempt ${attempt}/${MAX_ATTEMPTS}`);
const data = await fetchVersionInfo();
if (updateCheckerStopped || !hasLiveWebContents(win)) {
log("Window destroyed, aborting");
return;
}
if (data) {
const newer = isNewerVersion(CURRENT_VERSION, data.version);
log(`Remote ${data.version} vs current ${CURRENT_VERSION} -> newer? ${newer}`);
if (newer) {
const downloadUrl = WEBSITE_DOWNLOAD_URL;
log(`Injecting banner for ${data.version} (after ${INJECT_DELAY_MS}ms delay)`);
scheduleBannerInjection(win, data.version, downloadUrl, data.message);
} else {
log("No update needed, skipping banner");
}
return;
}
// Wait 1 minute before the next poll (skip delay after the last attempt)
if (attempt < MAX_ATTEMPTS) {
log(`Next poll in ${POLL_INTERVAL_MS / 1_000}s...`);
const shouldContinue = await waitForUpdateDelay(POLL_INTERVAL_MS);
if (!shouldContinue) return;
}
}
log("All attempts failed, no update info");
}
/**
* Starts the update checker.
* Waits 2 minutes after load, then polls 3 times with 1 min interval.
* Re-injects banner on every navigation (handles Next.js client routing).
*/
export function startUpdateChecker(win: BrowserWindow): void {
stopUpdateChecker();
updateCheckerStopped = false;
if (!hasLiveWebContents(win)) {
updateCheckerStopped = true;
return;
}
log("Registered, waiting for did-finish-load");
let hasRunCheck = false;
const onLoad = () => {
if (updateCheckerStopped || !hasLiveWebContents(win)) return;
if (pendingUpdate) {
log("did-finish-load (navigation), re-injecting banner");
scheduleBannerInjection(win, pendingUpdate.version, pendingUpdate.downloadUrl, pendingUpdate.message);
} else if (!hasRunCheck) {
hasRunCheck = true;
log(`did-finish-load fired, first poll in ${INITIAL_DELAY_MS / 1_000}s`);
scheduleUpdateTimer(() => {
if (!hasLiveWebContents(win)) return;
void checkForUpdatesWithRetry(win).catch((err) => {
log(`Update check failed: ${err}`);
});
}, INITIAL_DELAY_MS);
}
};
const onClosed = () => {
stopUpdateChecker();
};
win.once("closed", onClosed);
cleanupUpdateCheckerListeners = () => {
if (hasLiveWebContents(win)) {
win.webContents.removeListener("did-finish-load", onLoad);
}
try {
win.removeListener("closed", onClosed);
} catch {
// BrowserWindow may already be torn down when cleanup runs from "closed".
}
};
if (!win.webContents.isLoading()) {
log(`Page already loaded, first poll in ${INITIAL_DELAY_MS / 1_000}s`);
hasRunCheck = true;
scheduleUpdateTimer(() => {
if (!hasLiveWebContents(win)) return;
void checkForUpdatesWithRetry(win).catch((err) => {
log(`Update check failed: ${err}`);
});
}, INITIAL_DELAY_MS);
}
win.webContents.on("did-finish-load", onLoad);
}
export function stopUpdateChecker(): void {
updateCheckerStopped = true;
pendingUpdate = null;
clearScheduledUpdateWork();
}
+232
View File
@@ -0,0 +1,232 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
type ConfigSnapshot<T extends object> = {
config: T;
primaryValid: boolean;
};
const LOCK_TIMEOUT_MS = 5000;
const LOCK_STALE_MS = 30000;
const RETRY_DELAY_MS = 50;
const MAX_IO_ATTEMPTS = 6;
function sleepSync(ms: number): void {
const end = Date.now() + ms;
while (Date.now() < end) {
// Keep lock acquisition synchronous without requiring newer lib typings.
}
}
function isRetryableFsError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
}
function retrySync<T>(label: string, operation: () => T): T {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_IO_ATTEMPTS; attempt += 1) {
try {
return operation();
} catch (error) {
lastError = error;
if (!isRetryableFsError(error) || attempt === MAX_IO_ATTEMPTS - 1) {
break;
}
sleepSync(RETRY_DELAY_MS * (attempt + 1));
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`Failed to ${label}: ${message}`);
}
function backupPath(configPath: string): string {
return `${configPath}.bak`;
}
function lockPath(configPath: string): string {
return `${configPath}.lock`;
}
function ensureParentDirectory(configPath: string): void {
retrySync("create user config directory", () => {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
});
}
function readJsonIfValid<T extends object>(filePath: string): T | undefined {
try {
const content = retrySync(`read ${path.basename(filePath)}`, () =>
fs.readFileSync(filePath, "utf8")
);
const trimmed = content.trim();
if (!trimmed) {
return {} as T;
}
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as T)
: undefined;
} catch {
return undefined;
}
}
function readSnapshot<T extends object>(configPath: string): ConfigSnapshot<T> {
const primary = readJsonIfValid<T>(configPath);
if (primary) {
return { config: primary, primaryValid: true };
}
const backup = readJsonIfValid<T>(backupPath(configPath));
return { config: backup ?? ({} as T), primaryValid: false };
}
function removeStaleLock(lockFilePath: string): void {
try {
const stat = fs.statSync(lockFilePath);
if (Date.now() - stat.mtimeMs >= LOCK_STALE_MS) {
retrySync("remove stale user config lock", () => {
fs.unlinkSync(lockFilePath);
});
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
throw error;
}
}
}
function acquireLock(configPath: string): () => void {
ensureParentDirectory(configPath);
const lockFilePath = lockPath(configPath);
const startedAt = Date.now();
while (true) {
try {
const fd = fs.openSync(lockFilePath, "wx");
try {
fs.writeSync(
fd,
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })
);
} finally {
fs.closeSync(fd);
}
return () => {
try {
retrySync("release user config lock", () => {
fs.unlinkSync(lockFilePath);
});
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
console.warn("[Presenton] Failed to release user config lock", error);
}
}
};
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "EEXIST" && !isRetryableFsError(error)) {
throw error;
}
removeStaleLock(lockFilePath);
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
throw new Error(`Timed out waiting for user config lock: ${lockFilePath}`);
}
sleepSync(RETRY_DELAY_MS);
}
}
}
function writeFileDurably(filePath: string, content: string): void {
const fd = retrySync(`open ${path.basename(filePath)} for writing`, () =>
fs.openSync(filePath, "w")
);
try {
retrySync(`write ${path.basename(filePath)}`, () => {
fs.writeFileSync(fd, content, "utf8");
});
retrySync(`sync ${path.basename(filePath)}`, () => {
fs.fsyncSync(fd);
});
} finally {
fs.closeSync(fd);
}
}
function copyBackupIfPossible(configPath: string, primaryValid: boolean): void {
const configBackupPath = backupPath(configPath);
try {
if (primaryValid && fs.existsSync(configPath)) {
retrySync("write user config backup", () => {
fs.copyFileSync(configPath, configBackupPath);
});
} else if (!fs.existsSync(configBackupPath) && fs.existsSync(configPath)) {
retrySync("initialize user config backup", () => {
fs.copyFileSync(configPath, configBackupPath);
});
}
} catch (error) {
console.warn("[Presenton] Failed to update user config backup", error);
}
}
function writeAtomicJson<T extends object>(
configPath: string,
config: T,
primaryValid: boolean
): void {
ensureParentDirectory(configPath);
copyBackupIfPossible(configPath, primaryValid);
const tempPath = `${configPath}.${process.pid}.${Date.now()}.${crypto
.randomBytes(6)
.toString("hex")}.tmp`;
writeFileDurably(tempPath, JSON.stringify(config));
try {
retrySync("replace user config", () => {
fs.renameSync(tempPath, configPath);
});
copyBackupIfPossible(configPath, false);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch {
/* Best-effort cleanup. */
}
throw error;
}
}
export function readUserConfigFile<T extends object>(configPath: string): T {
try {
ensureParentDirectory(configPath);
return readSnapshot<T>(configPath).config;
} catch {
return {} as T;
}
}
export function updateUserConfigFile<T extends object>(
configPath: string,
update: (existingConfig: T) => T
): T {
const releaseLock = acquireLock(configPath);
try {
const snapshot = readSnapshot<T>(configPath);
const nextConfig = update({ ...snapshot.config });
writeAtomicJson(configPath, nextConfig, snapshot.primaryValid);
return nextConfig;
} finally {
releaseLock();
}
}
+712
View File
@@ -0,0 +1,712 @@
const builder = require("electron-builder")
const { execFileSync } = require("child_process")
const fs = require("fs")
const path = require("path")
const packageMetadata = require("./package.json")
const {
normalizeBundledMacChromiumForPackaging,
} = require("./scripts/prepare-export-chromium.cjs")
const APP_ID = "com.presenton.presenton"
const TEAM_ID = "S6W5C54KL6"
const macTarget = process.env.PRESENTON_MAC_TARGET
const isMasBuild = macTarget === "mas" || macTarget === "mas-dev"
const isDirectMacBuild = !isMasBuild && (process.platform === "darwin" || !!macTarget)
const requireDirectMacSigning = process.env.PRESENTON_REQUIRE_MAC_SIGNING === "1"
const masDevProvisioningProfile = resolveProvisioningProfileForTarget({
target: "mas-dev",
label: "MAS development",
candidates: [
"build/AppleDevelopment.provisionprofile",
"build/AppleDev.provisionprofile",
"build/AppDev.provisionprofile",
],
})
const masProvisioningProfile = resolveProvisioningProfileForTarget({
target: "mas",
label: "Mac App Store distribution",
candidates: [
"build/MacAppStore.provisionprofile",
"build/AppDistri.provisionprofile",
],
})
const masDevIdentity =
process.env.PRESENTON_MAS_DEV_IDENTITY || process.env.CSC_NAME || ""
const masSigningIdentities = resolveMasSigningIdentitiesForTarget()
const masIdentityQualifier = masSigningIdentities.qualifier
const masAppSigningIdentity = masSigningIdentities.appIdentity
const masInstallerSigningIdentity = masSigningIdentities.installerIdentity
const appStoreBundleShortVersion =
macTarget === "mas" ? getAppStoreBundleShortVersion() : undefined
const appStoreBundleVersion =
macTarget === "mas"
? getAppStoreBundleVersion(appStoreBundleShortVersion)
: undefined
const macDistributionIdentity =
process.env.PRESENTON_MAC_SIGN_IDENTITY ||
process.env.CSC_NAME ||
undefined
const shouldNotarizeDirectMacBuild =
isDirectMacBuild && process.env.PRESENTON_SKIP_NOTARIZATION !== "1"
const masSigningExtraArgs =
process.env.PRESENTON_CODESIGN_TIMESTAMP === "1" ? [] : ["--timestamp=none"]
function getAppStoreBundleShortVersion() {
const configuredVersion = process.env.PRESENTON_APP_STORE_VERSION
if (configuredVersion) {
validateAppStoreVersion(configuredVersion, "PRESENTON_APP_STORE_VERSION")
return configuredVersion
}
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(packageMetadata.version)
if (!match) {
throw new Error(
`Cannot derive an App Store version from package version "${packageMetadata.version}". Set PRESENTON_APP_STORE_VERSION to three period-separated integers, for example 1.0.0.`
)
}
return `${match[1]}.${match[2]}.${match[3]}`
}
function getAppStoreBundleVersion(bundleShortVersion) {
const configuredBuild = process.env.PRESENTON_APP_STORE_BUILD
if (configuredBuild) {
validateAppStoreBuild(configuredBuild, "PRESENTON_APP_STORE_BUILD")
return configuredBuild
}
return bundleShortVersion
}
function validateAppStoreVersion(value, source) {
if (!/^\d+\.\d+\.\d+$/.test(value)) {
throw new Error(
`${source} must be three period-separated integers, for example 1.0.0. Received: ${value}`
)
}
}
function validateAppStoreBuild(value, source) {
if (!/^\d+(\.\d+){0,2}$/.test(value)) {
throw new Error(
`${source} must be one to three period-separated integers, for example 42 or 1.0.1. Received: ${value}`
)
}
}
function resolveProvisioningProfileForTarget({ target, label, candidates }) {
try {
return resolveProvisioningProfile({ target, label, candidates })
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
function resolveProvisioningProfile({ target, label, candidates }) {
if (macTarget !== target) {
return undefined
}
if (process.platform !== "darwin") {
throw new Error(
`${label} builds must be run on macOS because Apple signing tools are required.`
)
}
const undecodableProfiles = []
for (const candidate of candidates) {
const candidatePath = path.join(__dirname, candidate)
if (!fs.existsSync(candidatePath)) {
continue
}
if (canDecodeProvisioningProfile(candidatePath)) {
return candidate
}
undecodableProfiles.push(candidate)
}
if (undecodableProfiles.length > 0) {
throw new Error(
`Found ${label} provisioning profile, but macOS could not decode it: ${undecodableProfiles.join(", ")}. Re-download the matching Apple provisioning profile and replace the local file.`
)
}
throw new Error(
`Missing ${label} provisioning profile. Expected: ${candidates.join(", ")}`
)
}
function canDecodeProvisioningProfile(profilePath) {
if (commandSucceeds("security", ["cms", "-D", "-i", profilePath])) {
return true
}
if (
commandSucceeds("openssl", [
"cms",
"-inform",
"DER",
"-verify",
"-noverify",
"-in",
profilePath,
"-out",
"/dev/null",
])
) {
return true
}
return false
}
function commandSucceeds(command, args) {
try {
execFileSync(command, args, { stdio: "ignore" })
return true
} catch {
return false
}
}
function resolveMasSigningIdentitiesForTarget() {
if (macTarget !== "mas") {
return {
qualifier: "",
appIdentity: "",
installerIdentity: "",
}
}
try {
return resolveMasSigningIdentities()
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
function resolveMasSigningIdentities() {
const identities = getAppleSigningIdentities()
const explicitIdentity =
process.env.PRESENTON_MAS_DISTRIBUTION_IDENTITY ||
process.env.PRESENTON_MAS_IDENTITY
const qualifier = explicitIdentity
? validateMasIdentityQualifier(
explicitIdentity,
"PRESENTON_MAS_DISTRIBUTION_IDENTITY/PRESENTON_MAS_IDENTITY",
identities
)
: process.env.CSC_NAME
? validateMasIdentityQualifier(
String(process.env.CSC_NAME),
"CSC_NAME",
identities
)
: discoverMasIdentityQualifier(identities)
const appIdentity = identities.find((identity) =>
isMasAppIdentityName(identity.name) && identity.name.includes(qualifier)
)
const installerIdentity = identities.find((identity) =>
isMasInstallerIdentityName(identity.name) && identity.name.includes(qualifier)
)
if (!appIdentity || !installerIdentity) {
throw buildMissingMasIdentityError(qualifier, identities, {
appIdentityFound: !!appIdentity,
installerIdentityFound: !!installerIdentity,
})
}
return {
qualifier,
appIdentity: appIdentity.name,
installerIdentity: installerIdentity.name,
}
}
function validateMasIdentityQualifier(value, source, identities) {
const identity = value.trim()
if (!identity) {
throw new Error(`${source} is set but empty.`)
}
const matchedIdentity = identities.find(
(candidate) => candidate.hash === identity || candidate.name === identity
)
const identityName = matchedIdentity?.name || identity
if (isDevelopmentIdentityName(identityName)) {
throw new Error(
`${source} points to a development identity (${identityName}). MAS distribution builds require Apple Distribution or 3rd Party Mac Developer Application.`
)
}
if (isMasAppIdentityName(identityName) || isMasInstallerIdentityName(identityName)) {
return getMasIdentityQualifier(identityName)
}
if (/^[A-Fa-f0-9]{40}$/.test(identity) && !matchedIdentity) {
throw new Error(
`${source} is a certificate hash that was not found in the keychain, so the matching MAS installer certificate cannot be discovered. Use the certificate name or team qualifier instead.`
)
}
return identity
}
function discoverMasIdentityQualifier(identities) {
const appIdentity = identities.find((identity) => isMasAppIdentityName(identity.name))
if (appIdentity) {
return getMasIdentityQualifier(appIdentity.name)
}
throw buildMissingMasIdentityError("", identities, {
appIdentityFound: false,
installerIdentityFound: identities.some((identity) =>
isMasInstallerIdentityName(identity.name)
),
})
}
function getAppleSigningIdentities() {
const outputs = []
for (const args of [
["find-identity", "-v"],
["find-identity", "-v", "-p", "codesigning"],
]) {
try {
outputs.push(execFileSync("security", args, { encoding: "utf8" }))
} catch {
continue
}
}
const seen = new Set()
return outputs
.join("\n")
.split(/\r?\n/)
.map((line) => {
const match = line.match(/^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"([^"]+)"/)
return match ? { hash: match[1], name: match[2] } : undefined
})
.filter(Boolean)
.filter((identity) => {
if (seen.has(identity.name)) {
return false
}
seen.add(identity.name)
return true
})
}
function isMasAppIdentityName(name) {
return /^(Apple Distribution|3rd Party Mac Developer Application):/.test(name)
}
function isMasInstallerIdentityName(name) {
return /^3rd Party Mac Developer Installer:/.test(name)
}
function isDevelopmentIdentityName(name) {
return /^(Apple Development|Mac Developer):/.test(name)
}
function isDeveloperIdApplicationIdentityName(name) {
return /^Developer ID Application:/.test(name)
}
function getMasIdentityQualifier(identityName) {
return identityName.replace(
/^(Apple Distribution|3rd Party Mac Developer Application|3rd Party Mac Developer Installer):\s*/,
""
)
}
function buildMissingMasIdentityError(qualifier, identities, status) {
const availableIdentities = identities.length
? identities.map((identity) => ` - ${identity.name}`).join("\n")
: " (no valid Apple signing identities found)"
const expectedQualifier = qualifier ? ` matching "${qualifier}"` : ""
return new Error(
[
"Missing MAS signing identity.",
`MAS distribution builds require both an app certificate and an installer certificate${expectedQualifier}:`,
" - Apple Distribution or 3rd Party Mac Developer Application",
" - 3rd Party Mac Developer Installer",
"",
`App certificate found: ${status.appIdentityFound ? "yes" : "no"}`,
`Installer certificate found: ${status.installerIdentityFound ? "yes" : "no"}`,
"",
"Create/download the missing certificate from Apple Developer Certificates,",
"install it in Keychain Access, then rerun the build.",
"",
"Available Apple signing identities:",
availableIdentities,
].join("\n")
)
}
function assertCodesignCanUseIdentity(identity) {
if (!identity || macTarget !== "mas" || process.platform !== "darwin") {
return
}
const tempDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "presenton-codesign-"))
const tempFile = path.join(tempDir, "preflight")
try {
fs.writeFileSync(tempFile, "Presenton MAS signing preflight\n")
execFileSync(
"codesign",
["--force", "--sign", identity, "--timestamp=none", tempFile],
{ stdio: "ignore", timeout: 30000 }
)
} catch (error) {
const timedOut = error && error.signal === "SIGTERM"
throw new Error(
[
"Could not complete MAS signing preflight with the selected distribution identity.",
timedOut
? "codesign timed out, usually because macOS is waiting for Keychain/private-key access."
: "codesign failed before electron-builder started signing the app.",
"Unlock the login keychain and allow codesign access to the Apple Distribution private key, then rerun the build.",
`Identity: ${identity}`,
].join("\n")
)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
function assertDirectMacReleaseReadiness() {
if (!isDirectMacBuild || !requireDirectMacSigning) {
return
}
if (process.platform !== "darwin") {
throw new Error(
"Signed macOS distribution builds must be run on macOS because Apple codesign and notarytool are required."
)
}
const identity = resolveDeveloperIdApplicationIdentity()
assertDirectMacNotarizationCredentials()
assertCodesignCanUseDirectIdentity(identity)
}
function resolveDeveloperIdApplicationIdentity() {
const identities = getAppleSigningIdentities()
const configuredIdentity = macDistributionIdentity
if (configuredIdentity) {
const matchedIdentity = identities.find(
(candidate) =>
candidate.hash === configuredIdentity || candidate.name === configuredIdentity
)
const identityName = matchedIdentity?.name || configuredIdentity
if (!matchedIdentity) {
throw new Error(
[
"The configured macOS signing identity was not found in the keychain.",
"Direct distribution requires a Developer ID Application certificate.",
`Configured identity: ${configuredIdentity}`,
"",
buildAvailableDeveloperIdIdentityList(identities),
].join("\n")
)
}
if (!isDeveloperIdApplicationIdentityName(identityName)) {
throw new Error(
[
"The configured macOS signing identity is not valid for direct distribution.",
"Use a Developer ID Application certificate, not Apple Development, Apple Distribution, or a Mac App Store certificate.",
`Configured identity: ${identityName}`,
].join("\n")
)
}
return identityName
}
const discoveredIdentity = identities.find((identity) =>
isDeveloperIdApplicationIdentityName(identity.name)
)
if (!discoveredIdentity) {
throw new Error(
[
"Missing Developer ID Application signing identity.",
"Install the certificate in Keychain Access or set PRESENTON_MAC_SIGN_IDENTITY to its exact name.",
"",
buildAvailableDeveloperIdIdentityList(identities),
].join("\n")
)
}
return discoveredIdentity.name
}
function buildAvailableDeveloperIdIdentityList(identities) {
const available = identities.length
? identities.map((identity) => ` - ${identity.name}`).join("\n")
: " (no valid Apple signing identities found)"
return ["Available Apple signing identities:", available].join("\n")
}
function assertDirectMacNotarizationCredentials() {
if (!shouldNotarizeDirectMacBuild) {
throw new Error(
"PRESENTON_SKIP_NOTARIZATION=1 is not allowed when PRESENTON_REQUIRE_MAC_SIGNING=1."
)
}
const hasAppleIdCredentials =
!!process.env.APPLE_ID &&
!!process.env.APPLE_APP_SPECIFIC_PASSWORD &&
!!process.env.APPLE_TEAM_ID
const hasApiKeyCredentials =
!!process.env.APPLE_API_KEY &&
!!process.env.APPLE_API_KEY_ID &&
!!process.env.APPLE_API_ISSUER
const hasKeychainProfile = !!process.env.APPLE_KEYCHAIN_PROFILE
if (hasAppleIdCredentials || hasApiKeyCredentials || hasKeychainProfile) {
return
}
throw new Error(
[
"Missing Apple notarization credentials.",
"Set one of these credential groups before running the signed macOS release build:",
" - APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID",
" - APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER",
" - APPLE_KEYCHAIN_PROFILE, optionally APPLE_KEYCHAIN",
].join("\n")
)
}
function assertCodesignCanUseDirectIdentity(identity) {
const tempDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "presenton-codesign-"))
const tempFile = path.join(tempDir, "preflight")
try {
fs.writeFileSync(tempFile, "Presenton direct macOS signing preflight\n")
execFileSync(
"codesign",
["--force", "--sign", identity, "--options", "runtime", tempFile],
{ stdio: "ignore", timeout: 30000 }
)
} catch (error) {
const timedOut = error && error.signal === "SIGTERM"
throw new Error(
[
"Could not complete direct macOS signing preflight with the selected Developer ID identity.",
timedOut
? "codesign timed out, usually because macOS is waiting for Keychain/private-key access."
: "codesign failed before electron-builder started signing the app.",
"Unlock the login keychain and allow codesign access to the Developer ID private key, then rerun the build.",
`Identity: ${identity}`,
].join("\n")
)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
// AfterPack hook: set executable permissions and repair packaged runtime bundles on macOS.
const afterPack = async (context) => {
if (context.electronPlatformName === "darwin") {
const appPath = context.appOutDir
const appBundleName = `${context.packager.appInfo.productFilename}.app`
const resourcesRoot = resolvePackagedResourcesRoot(
appPath,
appBundleName
)
const fastapiPath = path.join(resourcesRoot, "fastapi", "fastapi")
const exportPyDir = path.join(resourcesRoot, "export", "py")
const converterCandidates = [
`convert-${process.platform}-${process.arch}`,
`convert-${process.platform}`,
"convert",
]
console.log("Setting executable permissions for FastAPI binary...")
console.log("FastAPI path:", fastapiPath)
if (fs.existsSync(fastapiPath)) {
fs.chmodSync(fastapiPath, 0o755)
console.log("✓ Execute permissions set for FastAPI")
} else {
console.warn("⚠ FastAPI binary not found at:", fastapiPath)
}
console.log("Setting executable permissions for export converter binary...")
let converterFound = false
for (const candidate of converterCandidates) {
const candidatePath = path.join(exportPyDir, candidate)
if (fs.existsSync(candidatePath)) {
fs.chmodSync(candidatePath, 0o755)
console.log("✓ Execute permissions set for converter:", candidatePath)
converterFound = true
}
}
if (!converterFound) {
console.warn("⚠ No converter binary found in:", exportPyDir)
}
const fastapiDir = path.join(resourcesRoot, "fastapi")
if (fs.existsSync(fastapiDir)) {
console.log("FastAPI directory contents:", fs.readdirSync(fastapiDir))
}
if (fs.existsSync(exportPyDir)) {
console.log("Export py directory contents:", fs.readdirSync(exportPyDir))
}
normalizeBundledMacChromiumForPackaging(resourcesRoot)
}
}
function resolvePackagedResourcesRoot(appPath, appBundleName) {
const contentsResourcesRoot = path.join(appPath, appBundleName, "Contents", "Resources")
const candidates = [
path.join(contentsResourcesRoot, "app.asar.unpacked", "resources"),
path.join(contentsResourcesRoot, "app", "resources"),
]
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]
}
const config = {
appId: APP_ID,
productName: "Presenton",
asar: true,
asarUnpack: [
"resources/**",
// LiteParse runs from FastAPI via Electron-as-Node and needs real package dirs.
"node_modules/**",
],
copyright: "Copyright © 2026 Presenton",
directories: {
output: "dist",
buildResources: "build",
},
files: [
"resources",
"app_dist",
"node_modules",
"NOTICE"
],
afterPack,
mac: {
artifactName: "Presenton-${version}.${ext}",
target: [macTarget || "dmg"],
category: "public.app-category.productivity",
hardenedRuntime: !isMasBuild,
gatekeeperAssess: false,
entitlements: isMasBuild ? undefined : "build/entitlements.mac.plist",
entitlementsInherit: isMasBuild ? undefined : "build/entitlements.mac.inherit.plist",
identity:
macTarget === "mas" || macTarget === "mas-dev"
? null
: macDistributionIdentity,
notarize: isMasBuild || !shouldNotarizeDirectMacBuild ? false : true,
icon: "build/icon.icns",
bundleShortVersion: appStoreBundleShortVersion,
bundleVersion: appStoreBundleVersion,
extendInfo: {
ElectronTeamID: TEAM_ID,
},
},
masDev: {
type: "development",
identity: masDevIdentity || undefined,
provisioningProfile: masDevProvisioningProfile,
entitlements: "build/entitlements.mas.plist",
entitlementsInherit: "build/entitlements.mas.inherit.plist",
// osx-sign always adds --timestamp; this later flag keeps local MAS dev signing offline-tolerant.
additionalArguments: ["--timestamp=none"],
},
mas: {
type: "distribution",
identity: masIdentityQualifier || undefined,
provisioningProfile: masProvisioningProfile,
entitlements: "build/entitlements.mas.plist",
entitlementsInherit: "build/entitlements.mas.inherit.plist",
// Avoid codesign hanging on Apple's timestamp service during local MAS packaging.
additionalArguments: masSigningExtraArgs,
},
linux: {
artifactName: "Presenton-${version}.${ext}",
target: ["AppImage", "deb"],
icon: "build/icons",
},
deb: {
afterInstall: "build/after-install.tpl",
},
win: {
target: ["nsis", "appx"],
icon: "build/icon.ico",
artifactName: "Presenton-${version}.${ext}",
executableName: "Presenton",
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
allowElevation: true,
installerIcon: "build/icon.ico",
uninstallerIcon: "build/icon.ico",
installerHeaderIcon: "build/icon.ico",
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: "Presenton",
uninstallDisplayName: "Presenton",
},
dmg: {
sign: false,
size: "2300m",
},
appx: {
identityName: "PresentonAI.Presenton",
publisher: "CN=8A2C57B5-F1C6-473A-93EE-2E9B72134341",
displayName: "Presenton",
publisherDisplayName: "Presenton Inc.",
applicationId: "PresentonAI.Presenton",
},
}
const effectiveMacTarget = macTarget || "dmg"
const targets =
process.platform === "darwin"
? builder.Platform.MAC.createTarget([effectiveMacTarget])
: undefined
if (macTarget === "mas" && process.env.PRESENTON_SKIP_CODESIGN_PREFLIGHT !== "1") {
assertCodesignCanUseIdentity(masAppSigningIdentity)
}
assertDirectMacReleaseReadiness()
if (isDirectMacBuild && process.platform === "darwin") {
console.log("[macOS direct] Distribution settings:", {
target: effectiveMacTarget,
identity: macDistributionIdentity || "auto",
hardenedRuntime: true,
notarize: shouldNotarizeDirectMacBuild,
signingRequired: requireDirectMacSigning,
})
}
if (macTarget === "mas") {
console.log("[MAS] Signing preflight:", {
identityQualifier: masIdentityQualifier || "auto",
appIdentity: masAppSigningIdentity || "none",
installerIdentity: masInstallerSigningIdentity || "none",
provisioningProfile: masProvisioningProfile || "none",
additionalArguments: masSigningExtraArgs,
})
}
builder.build({ targets, config })
@@ -0,0 +1,20 @@
Replace this marker with the real MAS distribution provisioning profile.
Required final file name:
MacAppStore.provisionprofile
Where it should live:
electron/build/MacAppStore.provisionprofile
How to create it:
1. Go to Apple Developer > Certificates, Identifiers & Profiles > Profiles.
2. Create a Mac App Store Connect distribution provisioning profile.
3. Select the explicit App ID: com.presenton.presenton.
4. Select the Apple Distribution certificate installed on the Mac.
5. Download the profile.
6. Rename or copy it to MacAppStore.provisionprofile in this folder.
Do not commit the real .provisionprofile file. It is ignored by git.
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
if type update-alternatives 2>/dev/null >&1; then
# Remove previous link if it doesn't use update-alternatives
if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then
rm -f '/usr/bin/${executable}'
fi
update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
else
ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
fi
# Check if user namespaces are supported by the kernel and working with a quick test:
if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then
# Use SUID chrome-sandbox only on systems without user namespaces:
chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
else
chmod 0755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
fi
if hash update-mime-database 2>/dev/null; then
update-mime-database /usr/share/mime || true
fi
if hash update-desktop-database 2>/dev/null; then
update-desktop-database /usr/share/applications || true
fi
# Install apparmor profile. (Ubuntu 24+)
# First check if the version of AppArmor running on the device supports our profile.
# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0.
#
# Those apparmor_parser flags are akin to performing a dry run of loading a profile.
# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles
#
# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility.
# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile
if apparmor_status --enabled > /dev/null 2>&1; then
APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile'
APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}'
if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then
cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET"
# Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment.
# Use cases are for example environments where images for clients are maintained.
# There, AppArmor might correctly be installed, but live updating makes no sense.
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
# Extra flags taken from dh_apparmor:
# > By using '-W -T' we ensure that any abstraction updates are also pulled in.
# https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport
apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET"
fi
else
echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile"
fi
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>S6W5C54KL6.com.presenton.presenton</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Some files were not shown because too many files have changed in this diff Show More