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 +