chore: import upstream snapshot with attribution
@@ -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
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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 person’s 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.**
|
||||
@@ -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.**
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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> ·
|
||||
<a href="https://docs.presenton.ai/"><strong>Docs</strong></a> ·
|
||||
<a href="https://www.youtube.com/@presentonai"><strong>Youtube</strong></a> ·
|
||||
<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&utm_medium=badge&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 repository’s **`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 WebUI–compatible 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 server’s 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)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`presenton/presenton`
|
||||
- 原始仓库:https://github.com/presenton/presenton
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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 Presenton’s **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 Presenton’s **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.6–class 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 profile’s 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 Presenton’s region to match AWS guidance for that profile.
|
||||
|
||||
### Unsupported on-demand invocation
|
||||
|
||||
- **Symptom:** Error text mentions *on-demand throughput isn’t 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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,201 @@
|
||||
# Mac App Store Setup
|
||||
|
||||
Presenton’s Electron app is configured for Mac App Store (MAS) distribution with App Sandbox. MAS builds must be produced on macOS because Apple’s 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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pre–Chrome-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();
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '_');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.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 <strong style="color:#5141e5">${latest}</strong> is available
|
||||
— you have <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;">×</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;">×</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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 830 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 44 KiB |