diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index f817357..f3b62e1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -3,16 +3,33 @@ on:
workflow_dispatch:
jobs:
+ bump-version:
+ if: github.repository == 'lukasbach/pensieve'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: 'paramsinghvc/gh-action-bump-version@master'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
publish:
- runs-on: windows-latest
- # strategy:
- # matrix:
- # os:
- # [
- # { name: 'windows', image: 'windows-latest' },
- # { name: 'macos', image: 'macos-latest' },
- # ]
- # runs-on: ${{ matrix.os.image }}
+ needs: bump-version
+ if: always() && (needs.bump-version.result == 'success' || needs.bump-version.result == 'skipped')
+ strategy:
+ matrix:
+ include:
+ - os: windows
+ image: windows-latest
+ arch: ""
+ - os: macos
+ image: macos-latest
+ arch: arm64
+ - os: macos
+ image: macos-15-intel
+ arch: x64
+ runs-on: ${{ matrix.image }}
permissions:
contents: write
deployments: write
@@ -24,10 +41,9 @@ jobs:
- run: yarn lint
- run: yarn run typecheck
- run: yarn run build:docs
- - run: yarn make
- - uses: 'paramsinghvc/gh-action-bump-version@master'
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - run: yarn make ${{ matrix.arch != '' && '--arch=' || '' }}${{ matrix.arch }}
- run: yarn publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 8140532..a8b68e1 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -6,7 +6,19 @@ on:
jobs:
test:
- runs-on: windows-latest
+ strategy:
+ matrix:
+ include:
+ - os: windows
+ image: windows-latest
+ arch: ""
+ - os: macos
+ image: macos-latest
+ arch: arm64
+ - os: macos
+ image: macos-15-intel
+ arch: x64
+ runs-on: ${{ matrix.image }}
permissions:
contents: read
id-token: write
@@ -19,4 +31,4 @@ jobs:
- run: yarn run lint
- run: yarn run typecheck
- run: yarn run build:docs
- - run: yarn run make
\ No newline at end of file
+ - run: yarn run make ${{ matrix.arch != '' && '--arch=' || '' }}${{ matrix.arch }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4953bc6..69db27d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,4 +114,5 @@ extra
dist-docs
docs/index.md
docs/images
-.vscode
\ No newline at end of file
+.vscode
+vector-store/vector-store.db
diff --git a/README.md b/README.md
index ca1cb50..2d56979 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,27 @@ If you prefer not to use Homebrew:
- **FFmpeg**: Download from [https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)
- **Whisper**: Download from [https://github.com/ggerganov/whisper.cpp](https://github.com/ggerganov/whisper.cpp)
+### Opening the App After Download
+
+After downloading Pensieve from the releases page, you may see a message that the app is "damaged" when trying to open it. This is due to macOS Gatekeeper's quarantine feature for apps downloaded from the internet.
+
+**Using the DMG Installer Helper (Recommended):**
+
+When you open the Pensieve DMG, you'll see a "Pensieve Installer.app" helper:
+1. Double-click "Pensieve Installer.app" (you may need to right-click it the first time and select "Open")
+2. The helper will automatically:
+ - Copy Pensieve.app to your Applications folder
+ - Remove the quarantine attribute
+ - Optionally open Pensieve for you
+3. You can then open Pensieve normally from Applications anytime
+
+**Alternative: Manual Method**
+
+If you prefer not to use the helper, after moving Pensieve to Applications, open Terminal and run:
+```bash
+xattr -d com.apple.quarantine /Applications/Pensieve.app
+```
+
### Troubleshooting
If Pensieve shows warning dialogs about missing dependencies:
diff --git a/forge.config.ts b/forge.config.ts
index 8550a7d..4f721d6 100644
--- a/forge.config.ts
+++ b/forge.config.ts
@@ -12,6 +12,10 @@ import fs from "fs-extra";
import path from "path";
import { Resvg } from "@resvg/resvg-js";
import pngToIco from "png-to-ico";
+import { exec } from "child_process";
+import { promisify } from "util";
+
+const execAsync = promisify(exec);
const createIcon = async (factor: number, base = 32) => {
const source = await fs.readFile(path.join(__dirname, "./icon.svg"), "utf-8");
@@ -30,26 +34,87 @@ const createIcon = async (factor: number, base = 32) => {
);
};
+const createIcns = async () => {
+ const extraDir = path.join(__dirname, "extra");
+ const iconsetDir = path.join(extraDir, "icon.iconset");
+ await fs.ensureDir(iconsetDir);
+
+ // macOS requires specific icon sizes in .iconset format
+ // Format: icon_{size}x{size}.png and icon_{size}x{size}@2x.png
+ const sizes = [
+ { size: 16, filename: "icon_16x16.png" },
+ { size: 32, filename: "icon_16x16@2x.png" }, // 32x32 for @2x
+ { size: 32, filename: "icon_32x32.png" },
+ { size: 64, filename: "icon_32x32@2x.png" }, // 64x64 for @2x
+ { size: 128, filename: "icon_128x128.png" },
+ { size: 256, filename: "icon_128x128@2x.png" }, // 256x256 for @2x
+ { size: 256, filename: "icon_256x256.png" },
+ { size: 512, filename: "icon_256x256@2x.png" }, // 512x512 for @2x
+ { size: 512, filename: "icon_512x512.png" },
+ { size: 1024, filename: "icon_512x512@2x.png" }, // 1024x1024 for @2x
+ ];
+
+ // Generate all required icon sizes
+ for (const { size, filename } of sizes) {
+ const source = await fs.readFile(
+ path.join(__dirname, "./icon.svg"),
+ "utf-8",
+ );
+ const resvg = new Resvg(source, {
+ background: "transparent",
+ fitTo: { mode: "width", value: size },
+ });
+ const png = resvg.render();
+ await fs.writeFile(path.join(iconsetDir, filename), png.asPng() as any);
+ }
+
+ // Convert .iconset to .icns using macOS iconutil
+ const icnsPath = path.join(extraDir, "icon.icns");
+ await execAsync(`iconutil -c icns "${iconsetDir}" -o "${icnsPath}"`);
+
+ // Clean up the .iconset directory
+ await fs.remove(iconsetDir);
+};
+
const config: ForgeConfig = {
packagerConfig: {
asar: {
unpack: "*.{node,dll,exe}",
},
extraResource: "./extra",
- icon: "./extra/icon@8x.ico",
+ // Electron packager will automatically use .icns for macOS, .ico for Windows
+ // We generate both formats in generateAssets hook
+ icon: "./extra/icon",
+ ignore: [
+ /^\/src/,
+ /^\/docs/,
+ /^\/images/,
+ /^\/\.github/,
+ /^\/\.idea/,
+ /^\/scripts/,
+ /^\/vector-store/,
+ /^\/\.git/,
+ /^\/\.gitignore/,
+ /^\/README\.md$/,
+ /^\/yarn\.lock$/,
+ /^\/\.yarnrc\.yml$/,
+ /^\/package-lock\.json$/,
+ ],
+ },
+ rebuildConfig: {
+ onlyModules: [],
},
- rebuildConfig: {},
makers: [
new MakerSquirrel({
loadingGif: path.join(__dirname, "splash.gif"),
- setupIcon: "./extra/icon@8x.ico",
+ setupIcon: "./extra/icon.ico",
}),
// new MakerAppX({}),
new MakerZIP({}, ["darwin"]),
new MakerRpm({}),
new MakerDeb({}),
new MakerDMG({
- icon: "./extra/icon@8x.ico",
+ icon: "./extra/icon.icns",
}),
],
plugins: [
@@ -93,8 +158,11 @@ const config: ForgeConfig = {
name: "@electron-forge/publisher-github",
config: {
repository: {
- owner: "lukasbach",
- name: "pensieve",
+ owner:
+ process.env.GITHUB_REPOSITORY_OWNER ||
+ process.env.GITHUB_REPOSITORY?.split("/")[0] ||
+ "lukasbach",
+ name: process.env.GITHUB_REPOSITORY?.split("/")[1] || "pensieve",
},
prerelease: false,
draft: true,
@@ -104,6 +172,38 @@ const config: ForgeConfig = {
],
hooks: {
+ packageAfterPrune: async (config, buildPath) => {
+ // Remove problematic symlinks in nested node_modules/.bin directories that break ASAR
+ // These symlinks point outside the package and cause ASAR packaging to fail.
+ // .bin directories are only needed for development (npm/yarn scripts), not at runtime.
+ const removeBinSymlinks = async (dir: string) => {
+ try {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ // Recursively check all directories
+ await removeBinSymlinks(fullPath);
+
+ // If this is a .bin directory, remove it entirely
+ if (entry.name === ".bin") {
+ await fs.remove(fullPath);
+ }
+ } else if (entry.isSymbolicLink()) {
+ // Remove individual symlinks
+ await fs.unlink(fullPath);
+ }
+ }
+ } catch (error) {
+ // Ignore errors - directory might not exist or be inaccessible
+ }
+ };
+
+ const nodeModulesPath = path.join(buildPath, "node_modules");
+ if (await fs.pathExists(nodeModulesPath)) {
+ await removeBinSymlinks(nodeModulesPath);
+ }
+ },
generateAssets: async (config, platform, arch) => {
const ffmpegBase = path.join(
__dirname,
@@ -157,14 +257,174 @@ const config: ForgeConfig = {
// Note: For macOS and Linux, FFmpeg and Whisper will use system installations
+ // Generate standard PNG icons (for runtime use)
await createIcon(1);
await createIcon(2);
await createIcon(3);
await createIcon(4);
await createIcon(8);
+
+ // Generate Windows ICO file (always generate for cross-platform builds)
await pngToIco(path.join(__dirname, "extra/icon@8x.png")).then((buf) =>
- fs.writeFileSync(path.join(__dirname, "extra/icon@8x.ico"), buf as any),
+ fs.writeFileSync(path.join(__dirname, "extra/icon.ico"), buf as any),
);
+
+ // Generate macOS ICNS file (only on macOS due to iconutil requirement)
+ if (platform === "darwin") {
+ await createIcns();
+ }
+ },
+ postMake: async (config, makeResults) => {
+ // Add DMG helper to automatically remove quarantine when app is copied
+ if (process.platform === "darwin") {
+ for (const result of makeResults) {
+ if (result.platform === "darwin") {
+ // Find the DMG file - artifacts can be an array of strings or objects
+ const dmgPath = result.artifacts.find((artifact: any) => {
+ const artifactPath =
+ typeof artifact === "string" ? artifact : artifact?.path;
+ return artifactPath?.endsWith(".dmg");
+ });
+
+ const dmgPathStr =
+ typeof dmgPath === "string" ? dmgPath : (dmgPath as any)?.path;
+
+ if (dmgPathStr) {
+ try {
+ const helperScript = path.join(
+ __dirname,
+ "scripts/dmg-helper.applescript",
+ );
+ const helperApp = path.join(
+ __dirname,
+ "scripts/Pensieve Installer.app",
+ );
+
+ console.log(`Looking for helper script at: ${helperScript}`);
+ console.log(`DMG path: ${dmgPathStr}`);
+
+ // Compile AppleScript to .app bundle
+ if (await fs.pathExists(helperScript)) {
+ console.log("Compiling AppleScript to app bundle...");
+ await execAsync(
+ `osacompile -o "${helperApp}" "${helperScript}"`,
+ );
+
+ // Convert DMG to read-write, mount it, add helper, then convert back
+ const tempDmgBase = dmgPathStr.replace(/\.dmg$/, ".rw");
+ const tempDmg = `${tempDmgBase}.dmg`;
+
+ // Remove temp file if it exists
+ if (await fs.pathExists(tempDmg)) {
+ await fs.remove(tempDmg);
+ }
+
+ console.log("Converting DMG to read-write format...");
+ await execAsync(
+ `hdiutil convert "${dmgPathStr}" -format UDRW -o "${tempDmgBase}" -quiet`,
+ );
+
+ // Mount the read-write DMG (hdiutil adds .dmg extension automatically)
+ console.log("Mounting DMG...");
+ const mountOutput = await execAsync(
+ `hdiutil attach "${tempDmg}" -nobrowse -plist`,
+ );
+
+ // Parse mount point from plist output or stdout
+ let mountPoint: string | undefined;
+ if (mountOutput.stdout.includes("mount-point")) {
+ // Parse plist XML - find mount-point key and get the following string value
+ const plistLines = mountOutput.stdout.split("\n");
+ for (let i = 0; i < plistLines.length; i++) {
+ if (plistLines[i].includes("mount-point")) {
+ // Next non-empty line should be the string value
+ for (let j = i + 1; j < plistLines.length; j++) {
+ const stringMatch = plistLines[j].match(
+ /(.*?)<\/string>/,
+ );
+ if (stringMatch) {
+ const [, mountPointValue] = stringMatch;
+ mountPoint = mountPointValue;
+ break;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // Fallback to regex on stdout/stderr if plist parsing didn't work
+ if (!mountPoint) {
+ const match = (
+ mountOutput.stdout + mountOutput.stderr
+ ).match(/\/Volumes\/[^\s\n<]+/);
+ mountPoint = match?.[0];
+ }
+
+ if (mountPoint) {
+ console.log(`DMG mounted at: ${mountPoint}`);
+ // Verify helper app exists before copying
+ if (!(await fs.pathExists(helperApp))) {
+ throw new Error(`Helper app not found at: ${helperApp}`);
+ }
+ const targetPath = path.join(
+ mountPoint,
+ "Pensieve Installer.app",
+ );
+ console.log(`Copying ${helperApp} to ${targetPath}`);
+ // Copy helper app to DMG
+ await fs.copy(helperApp, targetPath);
+ // Verify copy succeeded
+ if (!(await fs.pathExists(targetPath))) {
+ throw new Error(
+ `Failed to copy helper app to ${targetPath}`,
+ );
+ }
+ console.log("Copied installer helper to DMG");
+
+ // Unmount DMG
+ await execAsync(`hdiutil detach "${mountPoint}" -quiet`);
+ console.log("Unmounted DMG");
+
+ // Convert back to read-only and replace original
+ console.log("Converting DMG back to read-only...");
+ const outputBase = dmgPathStr.replace(/\.dmg$/, "");
+ // Remove original DMG if it exists
+ if (await fs.pathExists(dmgPathStr)) {
+ await fs.remove(dmgPathStr);
+ }
+ await execAsync(
+ `hdiutil convert "${tempDmg}" -format UDZO -o "${outputBase}" -quiet`,
+ );
+
+ // Clean up temp files
+ await fs.remove(tempDmg);
+ await fs.remove(helperApp);
+
+ console.log("Successfully added installer helper to DMG");
+ } else {
+ console.warn(
+ "Could not find mount point from hdiutil output",
+ );
+ }
+ } else {
+ console.warn(`Helper script not found at: ${helperScript}`);
+ }
+ } catch (error) {
+ console.error("Failed to add DMG helper:", error);
+ // Don't fail the build if this fails
+ }
+ } else if (result.platform === "darwin") {
+ // Only warn if this is a darwin result (skip other platforms)
+ console.warn(
+ "DMG file not found in artifacts:",
+ result.artifacts,
+ );
+ }
+ }
+ }
+ }
+ return makeResults;
},
},
};
diff --git a/scripts/dmg-helper.applescript b/scripts/dmg-helper.applescript
new file mode 100644
index 0000000..e0cb2be
--- /dev/null
+++ b/scripts/dmg-helper.applescript
@@ -0,0 +1,105 @@
+-- DMG Helper Script
+-- This script automatically copies Pensieve.app to Applications and removes quarantine
+
+on run
+ set appName to "Pensieve.app"
+ set appPath to (path to applications folder as string) & appName
+ set dmgVolume to ""
+
+ -- Find the DMG volume (where this script is running from)
+ try
+ tell application "Finder"
+ -- Get the disk where this script is located
+ set scriptPath to path to me as string
+ set dmgVolume to disk of (file scriptPath) as string
+ end tell
+ on error
+ -- Fallback: try to find Pensieve.app in common DMG locations
+ set dmgVolume to "/Volumes"
+ end try
+
+ -- Find Pensieve.app in the DMG
+ set sourceAppPath to ""
+ try
+ tell application "Finder"
+ -- Look for Pensieve.app in the DMG volume
+ if dmgVolume is not "" then
+ set sourceAppPath to (dmgVolume & appName) as string
+ if not (file sourceAppPath exists) then
+ -- Try looking in /Volumes
+ set volumesList to list disks
+ repeat with vol in volumesList
+ try
+ set testPath to (vol as string) & appName
+ if (file testPath exists) then
+ set sourceAppPath to testPath
+ exit repeat
+ end if
+ end try
+ end repeat
+ end if
+ end if
+ end tell
+ on error
+ set sourceAppPath to ""
+ end try
+
+ -- If we can't find the app, prompt user
+ if sourceAppPath is "" then
+ display dialog "Pensieve Installer" & return & return & "Could not find Pensieve.app in the DMG." & return & return & "Please ensure the DMG is open and try again." buttons {"OK"} default button "OK" with icon caution
+ return
+ end if
+
+ -- Check if app already exists in Applications
+ set appExists to false
+ try
+ set appExists to file appPath exists
+ on error
+ set appExists to false
+ end try
+
+ if appExists then
+ -- Ask if user wants to replace it
+ set replaceResult to display dialog "Pensieve Installer" & return & return & "Pensieve.app already exists in Applications." & return & return & "Do you want to replace it?" buttons {"Cancel", "Replace"} default button "Replace" with icon caution
+ if button returned of replaceResult is "Cancel" then
+ return
+ end if
+ end if
+
+ -- Show progress message
+ display dialog "Pensieve Installer" & return & return & "Installing Pensieve to Applications folder..." buttons {} default button "OK" with icon note giving up after 1
+
+ -- Copy the app to Applications
+ try
+ tell application "Finder"
+ -- Remove existing app if it exists
+ if appExists then
+ delete file appPath
+ end if
+ -- Copy the app
+ duplicate file sourceAppPath to folder (path to applications folder)
+ end tell
+
+ -- Wait a moment for copy to complete
+ delay 1
+
+ -- Remove quarantine attribute
+ try
+ do shell script "xattr -d com.apple.quarantine " & quoted form of POSIX path of appPath
+ on error
+ -- Try alternative method
+ do shell script "xattr -cr " & quoted form of POSIX path of appPath
+ end try
+
+ -- Show success message
+ display dialog "Installation Complete!" & return & return & "Pensieve has been successfully installed to Applications." & return & return & "You can now open Pensieve normally." buttons {"Open Pensieve", "OK"} default button "Open Pensieve" with icon note
+ if button returned of result is "Open Pensieve" then
+ tell application "Finder"
+ open file appPath
+ end tell
+ end if
+ on error errorMessage
+ display dialog "Installation Failed" & return & return & "An error occurred while installing Pensieve:" & return & return & errorMessage buttons {"OK"} default button "OK" with icon stop
+ end try
+end run
+