Repository: pinokiocomputer/pinokio
Branch: main
Commit: f5e5073c1f19
Files: 33
Total size: 263.5 KB
Directory structure:
gitextract_4ttzgoi5/
├── .github/
│ └── workflows/
│ ├── build.yml
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── RELEASE.md
├── after-pack.js
├── build/
│ ├── entitlements.mac.inherit.plist
│ ├── entitlements.mac.plist
│ ├── icon.icns
│ ├── installer.nsh
│ └── sign.js
├── chmod.js
├── config.js
├── full.js
├── linux_build.sh
├── main.js
├── minimal.js
├── package.json
├── patch-linux-arm64-natives.js
├── popup-shell.js
├── popup-toolbar.html
├── preload.js
├── prompt.html
├── script/
│ ├── patch.command
│ ├── run-update-banner-test.js
│ └── zip.js
├── splash.html
├── temp/
│ ├── rebuild.js
│ └── yarn.js
├── update-banner.html
├── updater.js
└── wrap-linux-launcher.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/build.yml
================================================
name: Build/release
on:
push:
branches:
- main
#on: workflow_dispatch
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Get package.json version
id: get_version
shell: bash
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
- name: Create an empty release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Releasing version $PACKAGE_VERSION"
gh release create "v$PACKAGE_VERSION" --draft \
--title "v$PACKAGE_VERSION" \
--notes-file RELEASE.md
# --notes "Pinokio version $PACKAGE_VERSION."
windows-unsigned:
if: false
needs: "create-release"
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: 22
- name: Build/release Electron app
id: electron-builder
uses: samuelmeuli/action-electron-builder@v1.6.0
with:
github_token: ${{ secrets.github_token }}
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
#release: ${{ startsWith(github.ref, 'refs/tags/v') }}
release: true
#args: --win --dir # Build win-unpacked only
args: --win
windows:
# if: false
needs: "create-release"
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: 22
- name: Build/release Electron app
id: electron-builder
uses: samuelmeuli/action-electron-builder@v1.6.0
with:
github_token: ${{ secrets.github_token }}
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
#release: ${{ startsWith(github.ref, 'refs/tags/v') }}
#release: true
release: false
#args: --win --dir # Build win-unpacked only
args: --win
# - name: Check contents
# run: |
# dir dist-win32 \
# dir dist-win32\\win-unpacked
# shell: cmd
### sign start
- name: upload-unsigned-artifact
id: upload-unsigned-artifact
uses: actions/upload-artifact@v4
with:
#path: dist-win32
#path: dist-win32/win-unpacked/Pinokio.exe
path: dist-win32/Pinokio.exe
retention-days: 1
- id: Sign
if: ${{ runner.os == 'Windows' }}
uses: signpath/github-action-submit-signing-request@v1.1
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: 'd2da0df2-dc12-4516-8222-87178d5ebf3d'
project-slug: 'pinokio'
#signing-policy-slug: 'test-signing'
signing-policy-slug: 'release-signing'
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: './signed-windows'
parameters: |
version: ${{ toJSON(github.ref_name) }}
- name: Rebuild blockmap and latest.yml from signed installer
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
files=(signed-windows/*.exe)
if [[ ${#files[@]} -ne 1 ]]; then
echo "Expected exactly one signed exe, found ${#files[@]}" >&2
exit 1
fi
SIGNED_EXE="${files[0]}"
EXE_BASENAME=$(basename "$SIGNED_EXE")
VERSION=$(node -p "require('./package.json').version")
OUT_DIR=dist-win32
mkdir -p "$OUT_DIR"
# Use app-builder bundled with electron-builder to regenerate blockmap for the signed binary
APP_BUILDER=$(node -p "require('app-builder-bin').appBuilderPath")
"$APP_BUILDER" blockmap --input "$SIGNED_EXE" --output "$OUT_DIR/${EXE_BASENAME}.blockmap"
EXE="$SIGNED_EXE" BLOCKMAP="$OUT_DIR/${EXE_BASENAME}.blockmap" VERSION="$VERSION" OUT_DIR="$OUT_DIR" node - <<'EOF'
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const exe = process.env.EXE;
const blockmap = process.env.BLOCKMAP;
const version = process.env.VERSION;
const outDir = process.env.OUT_DIR;
const exeStats = fs.statSync(exe);
const blockmapStats = fs.statSync(blockmap);
const sha512 = crypto.createHash('sha512').update(fs.readFileSync(exe)).digest('base64');
const lines = [
`version: ${version}`,
`files:`,
` - url: ${path.basename(exe)}`,
` sha512: ${sha512}`,
` size: ${exeStats.size}`,
` blockMapSize: ${blockmapStats.size}`,
`path: ${path.basename(exe)}`,
`sha512: ${sha512}`,
`releaseDate: "${new Date().toISOString()}"`
];
fs.writeFileSync(path.join(outDir, 'latest.yml'), lines.join('\n'));
EOF
# # Replace the unsigned exe with the signed exe
# - name: Replace with signed exe
# run: |
# copy /Y ".\signed-windows\Pinokio.exe" ".\dist-win32\win-unpacked\Pinokio.exe"
# shell: cmd
### sign end
# # Build the final installer from the signed exe
# - name: Build final installer
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
## CSC_IDENTITY_AUTO_DISCOVERY: "false" # disable any auto code-sign discovery
## DISABLE_CODE_SIGNING: "true" # electron-builder respects this to skip signing
# run: |
# #yarn run electron-builder --win --prepackaged dist-win32/win-unpacked --publish never
# yarn run electron-builder --win --prepackaged dist-win32/win-unpacked --publish always
- name: Get package.json version
id: get_version
shell: bash
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
- name: Publish GitHub Release with gh
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Releasing version $PACKAGE_VERSION"
#gh release create "v$PACKAGE_VERSION" ./signed-windows/*.exe \
#gh release upload "v$PACKAGE_VERSION" ./dist-win32/*.exe .dist-win32/latest.yml ./dist-win32/*.exe.blockmap
gh release upload "v$PACKAGE_VERSION" ./signed-windows/*.exe ./dist-win32/latest.yml ./dist-win32/*.exe.blockmap --clobber
#gh release create "v$PACKAGE_VERSION" ./dist-win32/*.exe \
# --title "Release v$PACKAGE_VERSION" \
# --notes "Pinokio version $PACKAGE_VERSION."
mac:
# if: false
needs: "create-release"
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: 22
# - name: Prepare for app notarization
# if: startsWith(matrix.os, 'macos')
# # Import Apple API key for app notarization on macOS
# run: |
# mkdir -p ~/private_keys/
# echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
- name: Build/release Electron app
id: electron-builder
uses: samuelmeuli/action-electron-builder@v1.6.0
with:
# GitHub token, automatically provided to the action
# (No need to define this secret in the repo settings)
github_token: ${{ secrets.github_token }}
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
#release: ${{ startsWith(github.ref, 'refs/tags/v') }}
release: true
mac_certs: ${{ secrets.mac_certs }}
mac_certs_password: ${{ secrets.mac_certs_password }}
env:
# macOS notarization API key
#API_KEY_ID: ${{ secrets.api_key_id }}
#API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Show notarization-error.log
if: failure()
run: cat dist-darwin/**/notarization-error.log || echo "No notarization-error.log found"
linux:
# if: false
needs: "create-release"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: 22
- name: Install dependencies
shell: bash
run: |
if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci
else
npm install
fi
- name: Constrain Linux targets to matrix arch
shell: bash
env:
TARGET_ARCH: ${{ matrix.arch }}
run: |
node - <<'EOF'
const fs = require('fs');
const packagePath = './package.json';
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const targetArch = process.env.TARGET_ARCH;
if (!targetArch) {
throw new Error('TARGET_ARCH is not set');
}
const linux = packageJson.build && packageJson.build.linux;
if (!linux || !Array.isArray(linux.target)) {
throw new Error('build.linux.target is not configured as an array');
}
linux.target = linux.target.map((entry) => {
if (typeof entry === 'string') {
return { target: entry, arch: [targetArch] };
}
if (entry && typeof entry === 'object') {
return { ...entry, arch: [targetArch] };
}
return entry;
});
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
EOF
- name: Install ARM64 parcel watcher native
if: matrix.arch == 'arm64'
shell: bash
run: |
npm_config_os=linux \
npm_config_cpu=arm64 \
npm_config_libc=glibc \
npm_config_force=true \
npm install --no-save @parcel/watcher-linux-arm64-glibc@2.5.1
- name: Build/release Linux app
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.github_token }}
run: |
./node_modules/.bin/electron-builder install-app-deps --platform linux --arch ${{ matrix.arch }}
./node_modules/.bin/electron-builder --linux --${{ matrix.arch }} --publish never
- name: Validate ARM64 Linux packaged natives
if: matrix.arch == 'arm64'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
pick_file() {
for candidate in "$@"; do
if [ -f "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
deb_files=(dist-linux/*arm64*.deb dist-linux/*aarch64*.deb)
if [ ${#deb_files[@]} -eq 0 ]; then
echo "No ARM64 .deb artifacts found in dist-linux" >&2
ls -la dist-linux || true
exit 1
fi
for deb in "${deb_files[@]}"; do
echo "Validating $deb"
workdir="$(mktemp -d)"
dpkg-deb -x "$deb" "$workdir/root"
if ! grep -R --binary-files=without-match -q '/opt/Pinokio/pinokio-bin' "$workdir/root"; then
echo "Could not find /opt/Pinokio/pinokio-bin fallback in extracted package" >&2
exit 1
fi
pty_node="$(pick_file \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/@homebridge/node-pty-prebuilt-multiarch/build/Release/pty.node" \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/pinokiod/node_modules/@homebridge/node-pty-prebuilt-multiarch/build/Release/pty.node" \
)" || {
echo "Missing patched pty.node in ARM64 package" >&2
exit 1
}
watcher_node="$(pick_file \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/@parcel/watcher/build/Release/watcher.node" \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/pinokiod/node_modules/@parcel/watcher/build/Release/watcher.node" \
)" || {
echo "Missing patched watcher.node in ARM64 package" >&2
exit 1
}
watcher_platform_node="$(pick_file \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/@parcel/watcher-linux-arm64-glibc/watcher.node" \
"$workdir/root/opt/Pinokio/resources/app.asar.unpacked/node_modules/pinokiod/node_modules/@parcel/watcher-linux-arm64-glibc/watcher.node" \
)" || {
echo "Missing @parcel/watcher-linux-arm64-glibc payload in ARM64 package" >&2
exit 1
}
file "$pty_node"
file "$watcher_node"
file "$watcher_platform_node"
file "$pty_node" | grep -qi 'aarch64' || {
echo "pty.node is not ARM64 in $deb" >&2
exit 1
}
file "$watcher_node" | grep -qi 'aarch64' || {
echo "watcher.node is not ARM64 in $deb" >&2
exit 1
}
file "$watcher_platform_node" | grep -qi 'aarch64' || {
echo "watcher-linux-arm64-glibc payload is not ARM64 in $deb" >&2
exit 1
}
rm -rf "$workdir"
done
- name: Upload Linux release artifacts
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
tag="v${version}"
files=()
while IFS= read -r -d '' file; do
files+=("$file")
done < <(find dist-linux -maxdepth 1 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*linux*.yml' -o -name 'latest*.yml' \) \
-print0)
if [ "${#files[@]}" -eq 0 ]; then
echo "No Linux artifacts found in dist-linux" >&2
ls -la dist-linux || true
exit 1
fi
gh release upload "$tag" "${files[@]}" --clobber
================================================
FILE: .github/workflows/test.yml
================================================
name: Test
#on: push
on: workflow_dispatch
jobs:
print:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get package.json version
id: get_version
shell: bash
# run: echo "PACKAGE_VERSION=$(node -p 'require("./package.json").version')" >> $GITHUB_ENV
#run: echo 'PACKAGE_VERSION=$(node -p "require(\"./package.json\").version")' >> $GITHUB_ENV
# run: echo "PACKAGE_VERSION=$(node -p \"require('./package.json').version\")" >> $GITHUB_ENV
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV
- name: Print env
shell: bash
run: echo $PACKAGE_VERSION
================================================
FILE: .gitignore
================================================
node_modules
dist
cache
package-lock.json
.claude
================================================
FILE: LICENSE
================================================
Copyright 2023 Pinokio
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# Pinokio
Launch Anything.
# Script Policy
Pinokio is a 1-click launcher for any open-source project. Think of it as a terminal application with a user-friendly interface that can programmatically interact with scripts.
This means:
1. **Scripts can run anything:** Just like terminal apps can run shell scripts, Pinokio scripts can run any command, download files, and execute them. Essentially, Pinokio is a user-friendly terminal with a UI.
2. **How scripts can be run:** There are two ways to run scripts on Pinokio:
1. **Write your own:** Just like writing and executing shell scripts in the terminal, you can create your own scripts and run them locally.
2. **Install from the "Discover" page:** Vetted scripts are manually listed in the directory, tracked via Git, and frozen under the official GitHub organization. These are guaranteed to be secure and safe to install.
3. **Verified Scripts:** To be featured on the "Discover" page, scripts must go through the following strict process:
1. **Publisher Verification:** You must be personally verified to submit scripts for consideration. Contact the Pinokio admin (https://x.com/cocktailpeanut) to request verification.
2. **Github Organization Invitation:** Once verified, you'll be invited to the official Pinokio Factory GitHub organization as a contributor. Only members of this organization can publish scripts eligible for the "Discover" page. Abusing publishing privileges may result in removal from the organization.
3. **Repository Transfer and Freeze** To apply for a feature, you must transfer your script repository to the Pinokio Factory GitHub organization. Follow this guide: https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository
4. **Feature Application:** Once your repository is fully transferred and controlled by the organization, it is considered "frozen". You can then request to feature it on the "Discover" page by contacting the admin.
5. **Review:** The script will be thoroughly reviewed and tested by the Pinokio admin. If verified as safe, it will be featured on the "Discover" page.
6. **Troubleshooting:** If any issues arise after a script is featured, the Pinokio admin may:
- Delist the script from the "Discover" page
- Modify the script to resolve the issue. Since the script is under the Pinokio Factory organization, the admin has the rights to make necessary fixes.
# Security
## Scripts are isolated by design
By default all Pinokio scripts are stored run under an isolated location (at `~/pinokio/api`). Additionally, all binaries installed through the built-in package managers in Pinokio are installed within `~/pinokio/bin`. Basically, everything you do is stored inside `~/pinokio`. The risk factor is when a script intentionally tries to deviatte away from this.
The script verification process checks to make sure this doesn't happen.
Th Pinokio script syntax was designed to make this process simpler, both by human and machines.
## Scripts are open source
All scripts must be downloaded from public git repositories. The scripts are both human readable and machine readable (written in JSON syntax), so you can always check the source code before running it.
Here's an example install screen, with an alert letting you know the downloaded 3rd party script is about to be run, as well as the URL to the original script repository where it was downloaded from.

## Script Verification
Verified scripts are scripts that are explicitly reviewed and approved by the Pinokio admin. Because the scripts are designed to run isolated by default, and the syntax makes it easy to detect when a command intentionally tries to run things outside of the isolated environment, it is easy to detect any script that does things out of the ordinary. Here are some of the checks done by the Pinokio admin to make sure each script file is secure:
1. **Path check:** When we verify the scripts, we look at the scripts to see if all commands are run inside each app's path. The script syntax was designed to make this process easy (with the `path` attribute, which declares the folder path from which to run a command, and by default the execution path is each app's path)
2. **Venv check:** We also check to make sure every dependency installation is done within the context of each app using `venv`. This process is again made easy with the script syntax (with the `venv` attribute, which automatically activates a virtual environment and installs all dependencies there, inside each app's folder)
3. **3rd Party Package check:** We also check that any 3rd party packages installed through Pinokio to make sure that they are installed inside the pinokio isolated environment. The built-in package mangagers (Conda, Homebrew, Pip, and NPM) install everything inside the isolated pinokio home path (`~/pinokio`) by default. Since everything runs isolated by default, verifying this is simple (by checking that there are no explicit declaration of additional code that tries to go outside of the isolated environment)
Here's an example execution script that installs python dependencies:
```json
{
"method": "shell.run",
"params": {
"message": "uv pip install -r requirements.txt",
"path": "server",
"venv": "venv"
}
}
```
1. First of all, by default the entire thing is run isolated in the pinokio activated conda environment, and the execution path is the downloaded app's path (for example `~/pinokio/api/myapp`)
2. second, since the `path` is declared as `server`, the code will be run inside the `server` folder ofr the app (in this case `~/pinokio/api/myapp/server`)
3. Third, the `venv` attribute is included, so the python dependencies are also installed in an app-isolated manner. If the app is located at `~/pinokio/api/myapp`, the The depenencies will be stored at `~/pinokio/api/myapp/venv`
The script verification check makes sure that all these components are run locally within the constraints of each app.
Of course, there are also additional checks such as:
1. Checking the reputation of the repository and the developer of the original project
2. Trhing out the app personally
3. Making sure that the install and launch instructions actually follow the recommended instructions suggested in the original project's README.
No scripts are approved until rigorously tested.
================================================
FILE: RELEASE.md
================================================
# Pinokio Release
## Code Signing Policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
## Privacy Policy
This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
================================================
FILE: after-pack.js
================================================
module.exports = async (context) => {
const chmodHandler = require('./chmod')
const wrapLinuxLauncher = require('./wrap-linux-launcher')
const patchLinuxArm64Natives = require('./patch-linux-arm64-natives')
await chmodHandler(context)
await wrapLinuxLauncher(context)
await patchLinuxArm64Natives(context)
}
================================================
FILE: build/entitlements.mac.inherit.plist
================================================
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.cs.disable-library-validation
================================================
FILE: build/entitlements.mac.plist
================================================
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.cs.disable-library-validation
com.apple.security.device.audio-input
com.apple.security.device.camera
================================================
FILE: build/installer.nsh
================================================
# https://github.com/electron-userland/electron-builder/issues/6865#issuecomment-1871121350
!macro customInit
Delete "$INSTDIR\Uninstall*.exe"
!macroend
================================================
FILE: build/sign.js
================================================
module.exports = async function () {
// no-op: prevents electron-builder from calling signtool.exe
};
================================================
FILE: chmod.js
================================================
const exec = require('child_process').exec;
module.exports = async (context) => {
const paths = [
`${context.appOutDir}/resources/app.asar.unpacked/node_modules/go-get-folder-size/dist/go-get-folder-size_linux_386/go-get-folder-size`,
`${context.appOutDir}/resources/app.asar.unpacked/node_modules/go-get-folder-size/dist/go-get-folder-size_linux_amd64_v1/go-get-folder-size`,
`${context.appOutDir}/resources/app.asar.unpacked/node_modules/go-get-folder-size/dist/go-get-folder-size_linux_arm64/go-get-folder-size`,
]
for(let p of paths) {
await exec(`chmod +x "${p}"`);
}
}
================================================
FILE: config.js
================================================
const Store = require('electron-store');
const packagejson = require("./package.json")
const store = new Store();
module.exports = {
newsfeed: (gitRemote) => {
return `https://pinokiocomputer.github.io/home/item?uri=${gitRemote}&display=feed`
},
profile: (gitRemote) => {
return `https://pinokiocomputer.github.io/home/item?uri=${gitRemote}&display=profile`
},
site: "https://pinokio.co",
discover_dark: "https://beta.pinokio.co",
discover_light: "https://beta.pinokio.co",
portal: "https://beta.pinokio.co",
docs: "https://pinokio.co/docs",
install: "https://pinokiocomputer.github.io/program.pinokio.computer/#/?id=install",
agent: "electron",
version: packagejson.version,
store
}
================================================
FILE: full.js
================================================
const {app, screen, shell, BrowserWindow, BrowserView, ipcMain, dialog, clipboard, session, desktopCapturer, systemPreferences, Menu } = require('electron')
const windowStateKeeper = require('electron-window-state');
const fs = require('fs')
const path = require("path")
const Pinokiod = require("pinokiod")
const os = require('os')
const Updater = require('./updater')
const createPopupShellManager = require('./popup-shell')
const is_mac = process.platform.startsWith("darwin")
const platform = os.platform()
var mainWindow;
var root_url;
var wins = {}
var pinned = {}
var launched
var theme
var colors
var splashWindow
var splashIcon
var updateBannerPayload
var updateBannerDismissed = false
var updateInfo = null
var updateDownloadInFlight = false
const updateTestMode = (() => {
const value = process.env.PINOKIO_TEST_UPDATE_BANNER
if (!value) {
return false
}
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase())
})()
let updateTestInterval = null
let updateTestTimeout = null
const UPDATE_RELEASES_URL = 'https://github.com/peanutcocktail/pinokio/releases'
const setWindowTitleBarOverlay = (win, overlay) => {
if (!win || !win.setTitleBarOverlay) {
return
}
try {
win.setTitleBarOverlay(overlay)
} catch (e) {
// console.log("ERROR", e)
}
}
const applyTitleBarOverlayToAllWindows = () => {
if (!colors) {
return
}
const overlay = titleBarOverlay(colors)
const browserWindows = BrowserWindow.getAllWindows()
for (const win of browserWindows) {
setWindowTitleBarOverlay(win, overlay)
}
}
const updateThemeColors = (payload = {}) => {
console.log("updateThemeColors", payload)
const nextTheme = payload.theme
const nextColors = payload.colors
if (nextTheme) {
theme = nextTheme
}
if (nextColors) {
colors = nextColors
}
applyTitleBarOverlayToAllWindows()
}
const stripHtmlTags = (value) => {
if (!value) {
return ''
}
return String(value).replace(/<[^>]*>/g, '')
}
const buildReleaseNotesPreview = (notes) => {
if (!notes) {
return ''
}
let text = ''
if (Array.isArray(notes)) {
text = notes.map((note) => note && (note.note || note.releaseNotes || note.title || '')).join('\n')
} else if (typeof notes === 'string') {
text = notes
} else {
text = String(notes)
}
const cleaned = stripHtmlTags(text).replace(/\r/g, '')
const lines = cleaned.split('\n').map((line) => line.trim()).filter(Boolean)
if (!lines.length) {
return ''
}
const firstLine = lines[0]
if (firstLine.length > 140) {
return `${firstLine.slice(0, 137)}...`
}
return firstLine
}
const buildProgressLabel = (progress) => {
if (!progress || typeof progress.percent !== 'number') {
return ''
}
const percent = Math.round(progress.percent)
if (typeof progress.transferred === 'number' && typeof progress.total === 'number' && progress.total > 0) {
const transferred = (progress.transferred / 1024 / 1024).toFixed(1)
const total = (progress.total / 1024 / 1024).toFixed(1)
return `${percent}% (${transferred} MB of ${total} MB)`
}
return `${percent}%`
}
const buildUpdateBannerPayload = (state, info, extra = {}) => {
const resolved = info || {}
return {
state,
version: resolved.version || '',
notesPreview: buildReleaseNotesPreview(resolved.releaseNotes),
releaseUrl: UPDATE_RELEASES_URL,
...extra
}
}
const clearUpdateTestTimers = () => {
if (updateTestInterval) {
clearInterval(updateTestInterval)
updateTestInterval = null
}
if (updateTestTimeout) {
clearTimeout(updateTestTimeout)
updateTestTimeout = null
}
}
const showUpdateBannerTestAvailable = () => {
updateInfo = {
version: '99.9.9-test',
releaseNotes: 'Simulated update for banner testing.'
}
updateDownloadInFlight = false
updateBannerDismissed = false
showUpdateBanner(buildUpdateBannerPayload('available', updateInfo))
}
const startUpdateBannerTestDownload = () => {
if (!updateInfo) {
showUpdateBannerTestAvailable()
}
clearUpdateTestTimers()
updateDownloadInFlight = true
let progress = 0
const tick = () => {
progress = Math.min(100, progress + 6 + Math.random() * 12)
showUpdateBanner(buildUpdateBannerPayload('downloading', updateInfo, {
progressPercent: progress,
notesPreview: `${Math.round(progress)}%`
}))
if (progress >= 100) {
clearUpdateTestTimers()
updateDownloadInFlight = false
showUpdateBanner(buildUpdateBannerPayload('ready', updateInfo))
}
}
tick()
updateTestInterval = setInterval(tick, 320)
}
const simulateUpdateBannerRestart = () => {
clearUpdateTestTimers()
hideUpdateBanner()
updateTestTimeout = setTimeout(() => {
showUpdateBannerTestAvailable()
}, 800)
}
const dispatchUpdateBanner = (payload) => {
updateBannerPayload = payload
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
if (payload && payload.state === 'available' && updateBannerDismissed) {
return
}
if (mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
if (!mainWindow.webContents.isLoading()) {
mainWindow.webContents.send('pinokio:update-banner', payload)
}
}
}
const showUpdateBanner = (payload) => {
if (payload && payload.state === 'available' && updateBannerDismissed) {
updateBannerPayload = payload
return
}
dispatchUpdateBanner(payload || updateBannerPayload)
}
const hideUpdateBanner = () => {
dispatchUpdateBanner({ state: 'hidden' })
}
let PORT
//let PORT = 42000
//let PORT = (platform === 'linux' ? 42000 : 80)
let config = require('./config')
const filter = function (item) {
return item.browserName === 'Chrome';
};
const updater = new Updater()
const pinokiod = new Pinokiod(config)
const ENABLE_BROWSER_CONSOLE_LOG = process.env.PINOKIO_BROWSER_LOG === '1'
const browserConsoleState = new WeakMap()
const attachedConsoleListeners = new WeakSet()
const consoleLevelLabels = ['log', 'info', 'warn', 'error', 'debug']
let browserLogFilePath
let browserLogFileReady = false
let browserLogBuffer = []
let browserLogWritePromise = Promise.resolve()
let permissionHandlersInstalled = false
let injectorHandlersInstalled = false
const frameInjectorSyncState = new Map()
const frameInjectTargetRegistry = new Map()
const PINOKIO_INJECT_ISOLATED_WORLD_ID = 42000
const permissionPrompted = new Set()
const permissionPromptInFlight = new Set()
const safeParseUrl = (value, base) => {
if (!value) {
return null
}
try {
if (base) {
return new URL(value, base)
}
return new URL(value)
} catch (err) {
return null
}
}
const popupNavigationGuards = new Map()
const isRootShellUrl = (value) => {
const root = safeParseUrl(root_url)
const target = safeParseUrl(value, root ? root.href : undefined)
return Boolean(root && target && target.origin === root.origin && (target.pathname || '/') === '/')
}
const getHttpNavigationTarget = (value, base) => {
const target = safeParseUrl(value, base)
if (!target || (target.protocol !== 'http:' && target.protocol !== 'https:')) {
return null
}
return target
}
const openNonPinokioNavigationInPopup = ({ event, owner, url, frame } = {}) => {
const target = getHttpNavigationTarget(url, root_url || undefined)
if (!target || !owner || owner.isDestroyed?.() || owner.__pinokioPopupShell) {
return false
}
if (popupShellManager.isPinokioWindowUrl(target.href, root_url)) {
return false
}
if (event && typeof event.preventDefault === 'function') {
event.preventDefault()
}
const frameId = frame && (frame.frameToken || frame.frameTreeNodeId || frame.routingId)
if (frameId) {
const guardKey = `${owner.id}:${frameId}:${target.href}`
const now = Date.now()
const last = popupNavigationGuards.get(guardKey) || 0
popupNavigationGuards.set(guardKey, now)
setTimeout(() => {
if (popupNavigationGuards.get(guardKey) === now) {
popupNavigationGuards.delete(guardKey)
}
}, 1500)
if (now - last < 1500) {
return true
}
}
popupShellManager.openExternalWindow({ url: target.href })
return true
}
const installForceDestroyOnClose = (win) => {
if (!win || win.__pinokioCloseHandlerInstalled) {
return
}
win.__pinokioCloseHandlerInstalled = true
win.once('close', (event) => {
if (win.isDestroyed()) {
return
}
event.preventDefault()
win.destroy()
})
}
const popupShellManager = createPopupShellManager({
installForceDestroyOnClose
})
const installClosePopupOnDownload = (targetSession) => {
if (!targetSession || targetSession.__pinokioClosePopupOnDownloadInstalled) {
return
}
targetSession.__pinokioClosePopupOnDownloadInstalled = true
targetSession.on('will-download', (_event, _item, webContents) => {
if (!webContents || typeof webContents.getOwnerBrowserWindow !== 'function') {
return
}
let owner = null
try {
owner = webContents.getOwnerBrowserWindow()
} catch (_) {
owner = null
}
if (!owner || owner.isDestroyed?.() || !owner.__pinokioCloseOnFirstDownload) {
return
}
owner.__pinokioCloseOnFirstDownload = false
setTimeout(() => {
if (!owner.isDestroyed()) {
owner.close()
}
}, 0)
})
}
const resolveConsoleSourceUrl = (sourceId, pageUrl) => {
const page = safeParseUrl(pageUrl)
const source = safeParseUrl(sourceId, page ? page.href : undefined)
if (source && (source.protocol === 'http:' || source.protocol === 'https:' || source.protocol === 'file:')) {
return source.href
}
if (page) {
return page.href
}
return null
}
const shouldLogUrl = (url) => {
if (!ENABLE_BROWSER_CONSOLE_LOG) {
return false
}
if (!url) {
return false
}
const rootParsed = safeParseUrl(root_url)
const target = safeParseUrl(url, rootParsed ? rootParsed.origin : undefined)
if (!target) {
return false
}
if (rootParsed) {
if (target.origin !== rootParsed.origin) {
return false
}
const normalizedTargetPath = (target.pathname || '').replace(/\/+$/, '')
const normalizedRootPath = (rootParsed.pathname || '').replace(/\/+$/, '')
if (normalizedTargetPath === normalizedRootPath) {
return false
}
} else {
const normalizedTargetPath = (target.pathname || '').replace(/\/+$/, '')
if (!normalizedTargetPath) {
return false
}
}
return true
}
const getBrowserLogFile = () => {
if (!ENABLE_BROWSER_CONSOLE_LOG) {
return null
}
if (!browserLogFilePath) {
if (!pinokiod || !pinokiod.kernel || !pinokiod.kernel.homedir) {
return null
}
try {
browserLogFilePath = pinokiod.kernel.path('logs/browser.log')
} catch (err) {
console.error('[BROWSER LOG] Failed to resolve browser log file path', err)
return null
}
}
return browserLogFilePath
}
const ensureBrowserLogFile = () => {
if (!ENABLE_BROWSER_CONSOLE_LOG) {
return null
}
const filePath = getBrowserLogFile()
if (!filePath) {
return null
}
if (browserLogFileReady) {
return filePath
}
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
if (fs.existsSync(filePath)) {
try {
const existingContent = fs.readFileSync(filePath, 'utf8')
const existingLines = existingContent.split(/\r?\n/).filter((line) => line.length > 0)
const filteredLines = []
for (const line of existingLines) {
const parts = line.split('\t')
if (parts.length >= 2) {
const urlPart = parts[1]
if (!shouldLogUrl(urlPart)) {
continue
}
}
filteredLines.push(`${line}\n`)
if (filteredLines.length > 100) {
filteredLines.shift()
}
}
browserLogBuffer = filteredLines
fs.writeFileSync(filePath, browserLogBuffer.join(''))
} catch (err) {
console.error('[BROWSER LOG] Failed to prime existing browser log', err)
browserLogBuffer = []
}
}
browserLogFileReady = true
return filePath
} catch (err) {
console.error('[BROWSER LOG] Failed to prepare browser log file', err)
return null
}
}
const titleBarOverlay = (colors) => {
if (is_mac) {
return false
} else {
return colors
}
}
const getLogFileHint = () => {
try {
if (pinokiod && pinokiod.kernel && pinokiod.kernel.homedir) {
return path.resolve(pinokiod.kernel.homedir, "logs", "stdout.txt")
}
} catch (err) {
}
return path.resolve(os.homedir(), ".pinokio", "logs", "stdout.txt")
}
const getSplashIcon = () => {
if (splashIcon) {
return splashIcon
}
const candidates = [
path.join('assets', 'icon.png'),
path.join('assets', 'icon_small@2x.png'),
path.join('assets', 'icon_small.png'),
'icon2.png'
]
for (const relative of candidates) {
const absolute = path.join(__dirname, relative)
if (fs.existsSync(absolute)) {
splashIcon = relative.split(path.sep).join('/')
return splashIcon
}
}
splashIcon = path.join('assets', 'icon_small.png').split(path.sep).join('/')
return splashIcon
}
const ensureSplashWindow = () => {
if (splashWindow && !splashWindow.isDestroyed()) {
return splashWindow
}
splashWindow = new BrowserWindow({
width: 420,
height: 320,
frame: false,
resizable: false,
transparent: true,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
fullscreenable: false,
webPreferences: {
spellcheck: false,
backgroundThrottling: false
}
})
splashWindow.on('closed', () => {
splashWindow = null
})
return splashWindow
}
const updateSplashWindow = ({ state = 'loading', message, detail, logPath, icon } = {}) => {
const win = ensureSplashWindow()
const query = { state }
if (message) {
query.message = message
}
if (detail) {
const trimmed = detail.length > 800 ? `${detail.slice(0, 800)}…` : detail
query.detail = trimmed
}
if (logPath) {
query.log = logPath
}
if (icon) {
query.icon = icon
}
win.loadFile(path.join(__dirname, 'splash.html'), { query }).finally(() => {
if (!win.isDestroyed()) {
win.show()
}
})
}
const closeSplashWindow = () => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close()
}
}
const showStartupError = ({ message, detail, error } = {}) => {
const formatted = detail || formatStartupError(error)
updateSplashWindow({
state: 'error',
message: message || 'Pinokio could not start',
detail: formatted,
logPath: getLogFileHint(),
icon: getSplashIcon()
})
}
const formatStartupError = (error) => {
if (!error) {
return ''
}
if (error.stack) {
return `${error.message || 'Unknown error'}\n\n${error.stack}`
}
if (error.message) {
return error.message
}
if (typeof error === 'string') {
return error
}
try {
return JSON.stringify(error, null, 2)
} catch (err) {
return String(error)
}
}
const SESSION_COOKIE_TTL_DAYS = 90
const SESSION_COOKIE_TTL_SEC = SESSION_COOKIE_TTL_DAYS * 24 * 60 * 60
const SESSION_COOKIE_JAR_FILENAME = 'session-cookies.json'
let sessionCookieSavePromise = null
let isQuitting = false
const getSessionCookieJarPath = () => path.join(app.getPath('userData'), SESSION_COOKIE_JAR_FILENAME)
const buildCookieUrl = (cookie) => {
if (!cookie || !cookie.domain) {
return null
}
const host = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
if (!host) {
return null
}
const scheme = cookie.secure ? 'https://' : 'http://'
const cookiePath = cookie.path && cookie.path.startsWith('/') ? cookie.path : '/'
return `${scheme}${host}${cookiePath}`
}
const serializeSessionCookie = (cookie) => {
const url = buildCookieUrl(cookie)
if (!url || typeof cookie.name !== 'string') {
return null
}
const entry = {
url,
name: cookie.name,
value: typeof cookie.value === 'string' ? cookie.value : '',
path: cookie.path && cookie.path.startsWith('/') ? cookie.path : '/',
secure: !!cookie.secure,
httpOnly: !!cookie.httpOnly
}
if (cookie.hostOnly !== true && cookie.domain) {
entry.domain = cookie.domain
}
if (cookie.sameSite) {
entry.sameSite = cookie.sameSite
}
if (cookie.priority) {
entry.priority = cookie.priority
}
if (cookie.sameParty != null) {
entry.sameParty = cookie.sameParty
}
if (cookie.sourceScheme) {
entry.sourceScheme = cookie.sourceScheme
}
if (Number.isInteger(cookie.sourcePort)) {
entry.sourcePort = cookie.sourcePort
}
return entry
}
const persistSessionCookies = () => {
if (sessionCookieSavePromise) {
return sessionCookieSavePromise
}
sessionCookieSavePromise = (async () => {
try {
const cookies = await session.defaultSession.cookies.get({})
const sessionCookies = cookies.filter((cookie) => cookie && cookie.session)
const entries = sessionCookies.map(serializeSessionCookie).filter(Boolean)
const jarPath = getSessionCookieJarPath()
if (!entries.length) {
await fs.promises.unlink(jarPath).catch((err) => {
if (err && err.code !== 'ENOENT') {
console.warn('[Session Cookies] Failed to remove jar', err)
}
})
return
}
const payload = {
version: 1,
savedAt: Date.now(),
cookies: entries
}
await fs.promises.mkdir(path.dirname(jarPath), { recursive: true }).catch(() => {})
await fs.promises.writeFile(jarPath, JSON.stringify(payload), 'utf8')
} catch (err) {
console.warn('[Session Cookies] Failed to persist', err)
} finally {
sessionCookieSavePromise = null
}
})()
return sessionCookieSavePromise
}
const restoreSessionCookies = async () => {
const jarPath = getSessionCookieJarPath()
let raw
try {
raw = await fs.promises.readFile(jarPath, 'utf8')
} catch (err) {
if (err && err.code !== 'ENOENT') {
console.warn('[Session Cookies] Failed to read jar', err)
}
return
}
let data
try {
data = JSON.parse(raw)
} catch (err) {
console.warn('[Session Cookies] Failed to parse jar', err)
return
}
const entries = Array.isArray(data.cookies) ? data.cookies : []
if (!entries.length) {
return
}
const expirationDate = Math.floor(Date.now() / 1000) + SESSION_COOKIE_TTL_SEC
for (const entry of entries) {
if (!entry || !entry.url || !entry.name) {
continue
}
const details = {
url: entry.url,
name: entry.name,
value: typeof entry.value === 'string' ? entry.value : '',
path: entry.path || '/',
secure: !!entry.secure,
httpOnly: !!entry.httpOnly,
expirationDate
}
if (entry.domain) {
details.domain = entry.domain
}
if (entry.sameSite) {
details.sameSite = entry.sameSite
}
if (entry.priority) {
details.priority = entry.priority
}
if (entry.sameParty != null) {
details.sameParty = entry.sameParty
}
if (entry.sourceScheme) {
details.sourceScheme = entry.sourceScheme
}
if (Number.isInteger(entry.sourcePort)) {
details.sourcePort = entry.sourcePort
}
try {
await session.defaultSession.cookies.set(details)
} catch (err) {
console.warn('[Session Cookies] Failed to restore cookie', entry.name, err)
}
}
}
const clearPersistedSessionCookies = async () => {
const jarPath = getSessionCookieJarPath()
try {
await fs.promises.unlink(jarPath)
} catch (err) {
if (err && err.code !== 'ENOENT') {
console.warn('[Session Cookies] Failed to remove jar', err)
}
}
}
const clearSessionCaches = async () => {
try {
await session.defaultSession.clearCache()
} catch (err) {
console.warn('[Session Cache] Failed to clear http cache', err)
}
try {
await session.defaultSession.clearStorageData({
storages: ['serviceworkers', 'cachestorage']
})
} catch (err) {
console.warn('[Session Cache] Failed to clear service worker/cache storage', err)
}
}
function UpsertKeyValue(obj, keyToChange, value) {
const keyToChangeLower = keyToChange.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value;
// Done
return;
}
}
// Insert at end instead
obj[keyToChange] = value;
}
const clearBrowserConsoleState = (webContents) => {
if (browserConsoleState.has(webContents)) {
browserConsoleState.delete(webContents)
}
}
const updateBrowserConsoleTarget = (webContents, url) => {
if (!ENABLE_BROWSER_CONSOLE_LOG) {
return
}
if (!root_url) {
clearBrowserConsoleState(webContents)
return
}
let parsed
try {
parsed = new URL(url)
} catch (e) {
clearBrowserConsoleState(webContents)
return
}
if (parsed.origin !== root_url) {
clearBrowserConsoleState(webContents)
return
}
const existing = browserConsoleState.get(webContents)
if (existing && existing.url === parsed.href) {
return
}
browserConsoleState.set(webContents, { url: parsed.href })
}
const inspectorSessions = new Map()
let inspectorHandlersInstalled = false
const inspectorLogFile = path.join(os.tmpdir(), 'pinokio-inspector.log')
const inspectorMainLog = (label, payload) => {
try {
const serialized = payload === undefined ? '' : ' ' + JSON.stringify(payload)
const line = `[InspectorMain] ${label}${serialized}\n`
try {
fs.appendFileSync(inspectorLogFile, line)
} catch (_) {}
process.stdout.write(line)
} catch (_) {
try {
fs.appendFileSync(inspectorLogFile, `[InspectorMain] ${label}\n`)
} catch (_) {}
process.stdout.write(`[InspectorMain] ${label}\n`)
}
}
const normalizeInspectorUrl = (value) => {
if (!value) {
return null
}
try {
return new URL(value).href
} catch (_) {
return value
}
}
const urlsRoughlyMatch = (expected, candidate) => {
if (!expected) {
return true
}
if (!candidate) {
return false
}
if (candidate === expected) {
return true
}
return candidate.startsWith(expected) || expected.startsWith(candidate)
}
const flattenFrameTree = (frame, acc = [], depth = 0) => {
if (!frame) {
return acc
}
let frameName = null
try {
frameName = typeof frame.name === 'string' && frame.name.length ? frame.name : null
} catch (_) {
frameName = null
}
acc.push({ frame, depth, url: normalizeInspectorUrl(frame.url || ''), name: frameName })
const children = Array.isArray(frame.frames) ? frame.frames : []
for (const child of children) {
flattenFrameTree(child, acc, depth + 1)
}
return acc
}
const findDescendantByUrl = (frame, targetUrl) => {
if (!frame || !targetUrl) {
return null
}
const normalizedTarget = normalizeInspectorUrl(targetUrl)
if (!normalizedTarget) {
return null
}
const stack = [frame]
while (stack.length) {
const current = stack.pop()
try {
const currentUrl = normalizeInspectorUrl(current.url || '')
if (currentUrl && urlsRoughlyMatch(normalizedTarget, currentUrl)) {
return current
}
} catch (_) {}
const children = Array.isArray(current.frames) ? current.frames : []
for (const child of children) {
if (child) {
stack.push(child)
}
}
}
return null
}
const selectTargetFrame = (webContents, payload = {}) => {
if (!webContents || !webContents.mainFrame) {
inspectorMainLog('no-webcontents', {})
return null
}
const frames = flattenFrameTree(webContents.mainFrame, [])
if (!frames.length) {
inspectorMainLog('no-frames', { webContentsId: webContents.id })
return null
}
inspectorMainLog('incoming', {
frameUrl: payload.frameUrl || null,
frameName: payload.frameName || null,
frameNodeId: payload.frameNodeId || null,
frameCount: frames.length,
})
const canonicalUrl = normalizeInspectorUrl(payload.frameUrl)
const relativeOrdinal = typeof payload.candidateRelativeOrdinal === 'number' ? payload.candidateRelativeOrdinal : null
const globalOrdinal = typeof payload.frameIndex === 'number' ? payload.frameIndex : null
const canonicalFrameName = typeof payload.frameName === 'string' && payload.frameName.trim() ? payload.frameName.trim() : null
const canonicalFrameNodeId = typeof payload.frameNodeId === 'string' && payload.frameNodeId.trim() ? payload.frameNodeId.trim() : null
if (canonicalFrameName || canonicalFrameNodeId) {
inspectorMainLog('identifier-search', {
frameName: canonicalFrameName || null,
frameNodeId: canonicalFrameNodeId || null,
names: frames.map((entry) => entry.name || null).slice(0, 12),
})
let identifierMatch = null
if (canonicalFrameNodeId) {
identifierMatch = frames.find((entry) => entry && entry.name === canonicalFrameNodeId) || null
if (identifierMatch) {
const normalizedUrl = normalizeInspectorUrl(identifierMatch.url || '')
if (canonicalUrl && (!normalizedUrl || !urlsRoughlyMatch(canonicalUrl, normalizedUrl))) {
const descendant = findDescendantByUrl(identifierMatch.frame, canonicalUrl)
if (descendant) {
inspectorMainLog('identifier-match-node-descendant', {
index: frames.indexOf(identifierMatch),
name: identifierMatch.name || null,
url: identifierMatch.url || null,
descendantUrl: normalizeInspectorUrl(descendant.url || ''),
})
return descendant
}
}
inspectorMainLog('identifier-match-node', {
index: frames.indexOf(identifierMatch),
name: identifierMatch.name || null,
url: identifierMatch.url || null,
})
return identifierMatch.frame
}
}
if (canonicalFrameName) {
identifierMatch = frames.find((entry) => entry && entry.name === canonicalFrameName) || null
if (identifierMatch) {
const normalizedUrl = normalizeInspectorUrl(identifierMatch.url || '')
if (canonicalUrl && (!normalizedUrl || !urlsRoughlyMatch(canonicalUrl, normalizedUrl))) {
const descendant = findDescendantByUrl(identifierMatch.frame, canonicalUrl)
if (descendant) {
inspectorMainLog('identifier-match-name-descendant', {
index: frames.indexOf(identifierMatch),
name: identifierMatch.name || null,
url: identifierMatch.url || null,
descendantUrl: normalizeInspectorUrl(descendant.url || ''),
})
return descendant
}
}
inspectorMainLog('identifier-match-name', {
index: frames.indexOf(identifierMatch),
name: identifierMatch.name || null,
url: identifierMatch.url || null,
})
return identifierMatch.frame
}
}
inspectorMainLog('identifier-miss', {})
}
let matches = frames
if (canonicalUrl) {
matches = frames.filter(({ url }) => urlsRoughlyMatch(canonicalUrl, url))
}
if (matches.length) {
if (relativeOrdinal !== null) {
const filtered = matches.slice().sort((a, b) => a.depth - b.depth || frames.indexOf(a) - frames.indexOf(b))
const targetEntry = filtered[Math.min(Math.max(relativeOrdinal, 0), filtered.length - 1)]
if (targetEntry) {
inspectorMainLog('relative-ordinal-match', {
index: frames.indexOf(targetEntry),
name: targetEntry.name || null,
url: targetEntry.url || null,
})
return targetEntry.frame
}
}
const fallbackEntry = matches[0]
if (fallbackEntry) {
inspectorMainLog('fallback-match', {
index: frames.indexOf(fallbackEntry),
name: fallbackEntry.name || null,
url: fallbackEntry.url || null,
})
return fallbackEntry.frame
}
}
if (globalOrdinal !== null && frames[globalOrdinal]) {
inspectorMainLog('global-ordinal-match', {
index: globalOrdinal,
name: frames[globalOrdinal].name || null,
url: frames[globalOrdinal].url || null,
})
return frames[globalOrdinal].frame
}
inspectorMainLog('default-match', {
name: frames[0]?.name || null,
url: frames[0]?.url || null,
})
return frames[0]?.frame || null
}
const buildInspectorInjection = () => {
const source = function () {
try {
if (window.__PINOKIO_INSPECTOR__ && typeof window.__PINOKIO_INSPECTOR__.stop === 'function') {
window.__PINOKIO_INSPECTOR__.stop()
}
const overlay = document.createElement('div')
overlay.style.position = 'fixed'
overlay.style.pointerEvents = 'none'
overlay.style.border = '2px solid rgba(77,163,255,0.9)'
overlay.style.background = 'rgba(77,163,255,0.2)'
overlay.style.boxShadow = '0 0 0 1px rgba(23,52,92,0.45)'
overlay.style.zIndex = '2147483647'
overlay.style.display = 'none'
document.documentElement.appendChild(overlay)
let active = true
const post = (type, payload) => {
try {
window.parent.postMessage({ pinokioInspector: { type, frameUrl: window.location.href, ...payload } }, '*')
} catch (err) {
// ignore
}
}
const updateBox = (target) => {
if (!active || !target) {
overlay.style.display = 'none'
return
}
const rect = target.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) {
overlay.style.display = 'none'
return
}
overlay.style.display = 'block'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
}
const buildPathKeys = (node) => {
if (!node) {
return []
}
const keys = []
let current = node
let depth = 0
while (current && current.nodeType === Node.ELEMENT_NODE && depth < 8) {
const tag = current.tagName ? current.tagName.toLowerCase() : 'element'
let descriptor = tag
if (current.id) {
descriptor += `#${current.id}`
} else if (current.classList && current.classList.length) {
descriptor += `.${Array.from(current.classList).slice(0, 2).join('.')}`
}
keys.push(descriptor)
current = current.parentElement
depth += 1
}
return keys.reverse()
}
const handleMove = (event) => {
if (!active) {
return
}
const target = event.target
updateBox(target)
post('update', {
nodeName: target && target.tagName ? target.tagName.toLowerCase() : '',
pathKeys: buildPathKeys(target),
})
}
const preventClick = (event) => {
if (!active) {
return
}
event.preventDefault()
event.stopPropagation()
}
const handleClick = async (event) => {
if (!active) {
return
}
event.preventDefault()
event.stopPropagation()
const target = event.target
const html = target && target.outerHTML ? target.outerHTML : ''
let screenshot = null
// Hide the overlay before taking screenshot to avoid capturing it
if (overlay && overlay.style) {
overlay.style.display = 'none'
}
// Small delay to ensure overlay is hidden before screenshot
await new Promise(resolve => setTimeout(resolve, 50))
try {
// Use html2canvas-like approach to capture actual element rendering
const rect = target.getBoundingClientRect()
// Send element bounds for screenshot capture
const screenshotRequest = {
type: 'screenshot',
bounds: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height))
},
devicePixelRatio: window.devicePixelRatio || 1,
frameUrl: window.location.href,
__pinokioRelayStage: 0,
__pinokioRelayComplete: window === window.top
}
// Post screenshot request via postMessage to main page
try {
console.log('Attempting screenshot capture...')
console.log('electronAPI available in iframe:', !!window.electronAPI)
console.log('Screenshot request:', screenshotRequest)
// Send screenshot request to parent page via postMessage
const response = await new Promise((resolve, reject) => {
const messageId = 'screenshot_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
const handleResponse = (event) => {
if (event.data && event.data.pinokioScreenshotResponse && event.data.messageId === messageId) {
window.removeEventListener('message', handleResponse)
if (event.data.success) {
resolve(event.data.screenshot)
} else {
reject(new Error(event.data.error || 'Screenshot failed'))
}
}
}
window.addEventListener('message', handleResponse)
// Send request to parent page
window.parent.postMessage({
pinokioScreenshotRequest: screenshotRequest,
messageId: messageId
}, '*')
// Timeout after 3 seconds
setTimeout(() => {
window.removeEventListener('message', handleResponse)
reject(new Error('Screenshot timeout'))
}, 3000)
})
screenshot = response
console.log('Screenshot captured successfully via parent page')
} catch (screenshotError) {
console.error('Screenshot capture failed:', screenshotError)
screenshot = null
}
} catch (error) {
console.warn('Screenshot capture failed:', error)
screenshot = null
}
post('complete', {
outerHTML: html,
pathKeys: buildPathKeys(target),
screenshot: screenshot
})
stop()
}
const handleKey = (event) => {
if (!active) {
return
}
if (event.key === 'Escape') {
post('cancelled', {})
stop()
}
}
const stop = () => {
if (!active) {
return
}
active = false
document.removeEventListener('mousemove', handleMove, true)
document.removeEventListener('mouseover', handleMove, true)
document.removeEventListener('mousedown', preventClick, true)
document.removeEventListener('click', handleClick, true)
window.removeEventListener('keydown', handleKey, true)
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay)
}
window.__PINOKIO_INSPECTOR__ = null
}
document.addEventListener('mousemove', handleMove, true)
document.addEventListener('mouseover', handleMove, true)
document.addEventListener('mousedown', preventClick, true)
document.addEventListener('click', handleClick, true)
window.addEventListener('keydown', handleKey, true)
window.__PINOKIO_INSPECTOR__ = {
stop,
}
post('started', {})
} catch (error) {
try {
window.parent.postMessage({ pinokioInspector: { type: 'error', frameUrl: window.location.href, message: error && error.message ? error.message : String(error) } }, '*')
} catch (_) {}
}
}
return `(${source.toString()})();`
}
const buildScreenshotRelayInjection = () => {
const source = function () {
try {
if (window.__PINOKIO_SCREENSHOT_RELAY__) {
return
}
window.__PINOKIO_SCREENSHOT_RELAY__ = true
const pending = new Map()
const EXPIRATION_MS = 5000
const rememberSource = (messageId, sourceWindow) => {
if (!messageId || !sourceWindow) {
return
}
pending.set(messageId, sourceWindow)
setTimeout(() => {
pending.delete(messageId)
}, EXPIRATION_MS)
}
const safeStringify = (value) => {
try {
return JSON.stringify(value)
} catch (_) {
return '"[unserializable]"'
}
}
const log = (label, payload) => {
try {
console.log('[Pinokio Screenshot Relay] ' + label + ' ' + safeStringify(payload))
} catch (_) {
// ignore logging failures
}
}
log('relay-installed', { href: window.location.href })
window.addEventListener('message', (event) => {
const data = event && event.data
log('message-event', {
href: window.location.href,
hasData: Boolean(data),
messageId: data && data.messageId ? data.messageId : null,
hasRequest: Boolean(data && data.pinokioScreenshotRequest),
hasResponse: Boolean(data && data.pinokioScreenshotResponse)
})
if (!data) {
return
}
if (data.pinokioScreenshotRequest) {
if (!event.source || event.source === window) {
log('request-ignored-no-source', {
href: window.location.href,
messageId: data.messageId || null
})
return
}
rememberSource(data.messageId, event.source)
log('request-processing', {
href: window.location.href,
messageId: data.messageId || null,
originalBounds: data.pinokioScreenshotRequest && data.pinokioScreenshotRequest.bounds ? data.pinokioScreenshotRequest.bounds : null,
originalDevicePixelRatio: data.pinokioScreenshotRequest ? data.pinokioScreenshotRequest.devicePixelRatio : null
})
let offsetX = 0
let offsetY = 0
let matchedFrame = false
try {
for (let index = 0; index < window.frames.length; index += 1) {
const childWindow = window.frames[index]
if (childWindow === event.source) {
log('matching-window-frames', {
href: window.location.href,
messageId: data.messageId || null,
frameIndex: index
})
try {
const frameElement = childWindow.frameElement
if (frameElement) {
const rect = frameElement.getBoundingClientRect()
offsetX = rect ? rect.left || 0 : 0
offsetY = rect ? rect.top || 0 : 0
matchedFrame = true
log('matched-window-frames', {
href: window.location.href,
messageId: data.messageId || null,
frameIndex: index,
rect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null
})
break
}
} catch (error) {
log('frame-element-access-error', {
href: window.location.href,
messageId: data.messageId || null,
frameIndex: index,
error: error && error.message ? error.message : String(error)
})
}
}
}
if (!matchedFrame) {
const FRAME_SELECTOR = 'iframe, frame'
const frames = document.querySelectorAll ? document.querySelectorAll(FRAME_SELECTOR) : []
log('matching-query-selector', {
href: window.location.href,
messageId: data.messageId || null,
selector: FRAME_SELECTOR,
count: frames ? frames.length : 0
})
for (const frameEl of frames) {
if (!frameEl) {
continue
}
try {
if (frameEl.contentWindow === event.source) {
const rect = frameEl.getBoundingClientRect()
offsetX = rect ? rect.left || 0 : 0
offsetY = rect ? rect.top || 0 : 0
matchedFrame = true
log('matched-query-selector', {
href: window.location.href,
messageId: data.messageId || null,
selector: FRAME_SELECTOR,
rect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null
})
break
}
} catch (error) {
log('query-selector-access-error', {
href: window.location.href,
messageId: data.messageId || null,
selector: FRAME_SELECTOR,
error: error && error.message ? error.message : String(error)
})
}
}
}
} catch (error) {
log('frame-enumeration-error', {
href: window.location.href,
messageId: data.messageId || null,
error: error && error.message ? error.message : String(error)
})
}
if (!matchedFrame) {
log('frame-match-failed', {
href: window.location.href,
messageId: data.messageId || null,
offsetX,
offsetY
})
}
const request = data.pinokioScreenshotRequest || {}
const originalBounds = request.bounds || {}
const parentDpr = window.devicePixelRatio || 1
const currentDpr = request.devicePixelRatio && request.devicePixelRatio > 0 ? request.devicePixelRatio : 1
const nextStage = (typeof request.__pinokioRelayStage === 'number' ? request.__pinokioRelayStage : 0) + 1
request.__pinokioRelayStage = nextStage
request.__pinokioRelayComplete = window.parent === window
if (matchedFrame) {
const adjustedBounds = {
x: (originalBounds.x || 0) + offsetX,
y: (originalBounds.y || 0) + offsetY,
width: originalBounds.width || 0,
height: originalBounds.height || 0,
}
request.bounds = adjustedBounds
request.devicePixelRatio = Math.max(currentDpr, parentDpr)
request.__pinokioAdjusted = true
log('request-adjusted', {
href: window.location.href,
messageId: data.messageId || null,
offsetX,
offsetY,
parentDpr,
resultingBounds: adjustedBounds,
originalBounds,
resultingDevicePixelRatio: request.devicePixelRatio,
relayStage: request.__pinokioRelayStage,
relayComplete: request.__pinokioRelayComplete
})
} else {
log('request-forward-unadjusted', {
href: window.location.href,
messageId: data.messageId || null,
relayStage: request.__pinokioRelayStage,
relayComplete: request.__pinokioRelayComplete
})
}
data.pinokioScreenshotRequest = request
log('request-forward', {
href: window.location.href,
messageId: data.messageId || null,
matchedFrame,
hasParent: Boolean(window.parent && window.parent !== window)
})
if (window.parent && window.parent !== window) {
window.parent.postMessage(data, '*')
if (event && typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation()
}
return
}
const targetSource = event.source
const messageId = data.messageId
const captureRequest = data.pinokioScreenshotRequest
log('top-level-capture', {
href: window.location.href,
messageId,
relayStage: captureRequest.__pinokioRelayStage,
relayComplete: captureRequest.__pinokioRelayComplete,
adjustedFlag: captureRequest.__pinokioAdjusted,
bounds: captureRequest.bounds || null
})
const captureApi = window.electronAPI && typeof window.electronAPI.captureScreenshot === 'function'
? window.electronAPI.captureScreenshot
: null
if (!captureApi) {
log('top-level-capture-missing-api', { href: window.location.href })
return
}
Promise.resolve()
.then(() => captureApi(captureRequest))
.then((screenshot) => {
log('top-level-capture-success', { href: window.location.href, messageId })
try {
targetSource.postMessage({
pinokioScreenshotResponse: true,
messageId,
success: true,
screenshot
}, '*')
} catch (error) {
log('top-level-response-error', {
href: window.location.href,
messageId,
error: error && error.message ? error.message : String(error)
})
}
})
.catch((error) => {
log('top-level-capture-error', {
href: window.location.href,
messageId,
error: error && error.message ? error.message : String(error)
})
try {
targetSource.postMessage({
pinokioScreenshotResponse: true,
messageId,
success: false,
error: error && error.message ? error.message : String(error)
}, '*')
} catch (responseError) {
log('top-level-response-error', {
href: window.location.href,
messageId,
error: responseError && responseError.message ? responseError.message : String(responseError)
})
}
})
return
}
if (data.pinokioScreenshotResponse && data.messageId) {
log('response-processing', {
href: window.location.href,
messageId: data.messageId
})
const target = pending.get(data.messageId)
if (target && target !== event.source) {
pending.delete(data.messageId)
try {
log('response-forwarding-down', {
href: window.location.href,
messageId: data.messageId
})
target.postMessage(data, '*')
return
} catch (error) {
log('response-forwarding-error', {
href: window.location.href,
messageId: data.messageId,
error: error && error.message ? error.message : String(error)
})
}
}
log('response-forwarding-up', {
href: window.location.href,
messageId: data.messageId,
hasParent: Boolean(window.parent && window.parent !== window)
})
if (window.parent && window.parent !== window) {
window.parent.postMessage(data, '*')
}
}
}, true)
} catch (error) {
try {
console.warn('[Pinokio Screenshot Relay] relay-install-error ' + (error && error.message ? error.message : String(error)))
} catch (_) {
// ignore logging failures
}
}
}
return `(${source.toString()})();`
}
const installScreenshotRelays = async (frame) => {
if (!frame) {
return
}
const topFrame = frame.top || frame
const frames = flattenFrameTree(topFrame, [])
for (const entry of frames) {
const candidate = entry && entry.frame
if (!candidate || candidate.isDestroyed && candidate.isDestroyed()) {
continue
}
try {
await candidate.executeJavaScript(buildScreenshotRelayInjection(), true)
} catch (error) {
console.warn('Screenshot relay injection failed:', error && error.message ? error.message : error)
}
}
}
const startInspectorSession = async (webContents, payload = {}) => {
const existing = inspectorSessions.get(webContents.id)
if (existing) {
await stopInspectorSession(webContents)
}
const targetFrame = selectTargetFrame(webContents, payload)
if (!targetFrame) {
throw new Error('Unable to locate iframe to inspect.')
}
await installScreenshotRelays(targetFrame)
await targetFrame.executeJavaScript(buildInspectorInjection(), true)
const navigationHandler = () => {
const resultPromise = stopInspectorSession(webContents)
Promise.resolve(resultPromise).then((outcome) => {
if (!webContents.isDestroyed()) {
webContents.send('pinokio:inspector-cancelled', { frameUrl: (outcome && outcome.frameUrl) || targetFrame.url || payload.frameUrl || '' })
}
})
}
if (!webContents.isDestroyed()) {
webContents.on('did-navigate', navigationHandler)
webContents.on('did-navigate-in-page', navigationHandler)
}
inspectorSessions.set(webContents.id, {
frame: targetFrame,
navigationHandler,
})
return {
frameUrl: targetFrame.url || payload.frameUrl || '',
}
}
const stopInspectorSession = async (webContents) => {
const session = inspectorSessions.get(webContents.id)
if (!session) {
return { frameUrl: '' }
}
inspectorSessions.delete(webContents.id)
if (session.navigationHandler && !webContents.isDestroyed()) {
webContents.removeListener('did-navigate', session.navigationHandler)
webContents.removeListener('did-navigate-in-page', session.navigationHandler)
}
const frameUrl = session.frame && session.frame.url ? session.frame.url : ''
try {
await session.frame.executeJavaScript('window.__PINOKIO_INSPECTOR__ && window.__PINOKIO_INSPECTOR__.stop()', true)
} catch (_) {}
return { frameUrl }
}
const safeCaptureStringify = (value) => {
try {
return JSON.stringify(value)
} catch (_) {
return '"[unserializable]"'
}
}
const captureLog = (label, payload) => {
try {
console.log('[Pinokio Capture] ' + label + ' ' + safeCaptureStringify(payload))
} catch (_) {
console.log('[Pinokio Capture] ' + label)
}
}
const installInspectorHandlers = () => {
console.log('Installing inspector handlers...')
if (inspectorHandlersInstalled) {
console.log('Inspector handlers already installed, skipping')
return
}
inspectorHandlersInstalled = true
console.log('Installing pinokio:capture-screenshot handler')
ipcMain.handle('pinokio:start-inspector', async (event, payload = {}) => {
try {
const result = await startInspectorSession(event.sender, payload)
event.sender.send('pinokio:inspector-started', { frameUrl: result.frameUrl })
return { ok: true }
} catch (error) {
const message = error && error.message ? error.message : 'Unable to start inspect mode.'
event.sender.send('pinokio:inspector-error', { message })
throw new Error(message)
}
})
ipcMain.handle('pinokio:stop-inspector', async (event) => {
try {
const result = await stopInspectorSession(event.sender)
event.sender.send('pinokio:inspector-cancelled', { frameUrl: result.frameUrl || '' })
return { ok: true }
} catch (error) {
const message = error && error.message ? error.message : 'Unable to stop inspect mode.'
event.sender.send('pinokio:inspector-error', { message })
throw new Error(message)
}
})
ipcMain.handle('pinokio:capture-screenshot-debug', async (event, payload) => {
const { screenshotRequest } = payload
const emitDebug = (label, data) => {
captureLog(label, data)
try {
event.sender.send('pinokio:capture-debug-log', {
label,
payload: data
})
} catch (_) {
// ignore renderer emit errors
}
}
emitDebug('handler-invoked', {
senderId: event && event.sender ? event.sender.id : null,
hasRequest: Boolean(screenshotRequest),
bounds: screenshotRequest && screenshotRequest.bounds ? {
x: screenshotRequest.bounds.x,
y: screenshotRequest.bounds.y,
width: screenshotRequest.bounds.width,
height: screenshotRequest.bounds.height,
} : null,
devicePixelRatio: screenshotRequest ? screenshotRequest.devicePixelRatio : null,
adjustedFlag: Boolean(screenshotRequest && screenshotRequest.__pinokioAdjusted),
relayStage: screenshotRequest && typeof screenshotRequest.__pinokioRelayStage !== 'undefined' ? screenshotRequest.__pinokioRelayStage : null,
relayComplete: screenshotRequest && typeof screenshotRequest.__pinokioRelayComplete !== 'undefined' ? screenshotRequest.__pinokioRelayComplete : null,
frameOffset: screenshotRequest && screenshotRequest.frameOffset ? {
x: screenshotRequest.frameOffset.x,
y: screenshotRequest.frameOffset.y,
} : null
})
if (!screenshotRequest || !screenshotRequest.bounds) {
throw new Error('Invalid screenshot request')
}
// Get the inspector session to access the target frame
const session = inspectorSessions.get(event.sender.id)
if (!session || !session.frame) {
throw new Error('No inspector session or frame found')
}
try {
const bounds = screenshotRequest.bounds
const dpr = screenshotRequest.devicePixelRatio || 1
const alreadyAdjusted = Boolean(screenshotRequest.__pinokioAdjusted)
emitDebug('incoming-bounds', {
senderId: event && event.sender ? event.sender.id : null,
bounds,
devicePixelRatio: dpr,
alreadyAdjusted,
relayStage: screenshotRequest && typeof screenshotRequest.__pinokioRelayStage !== 'undefined' ? screenshotRequest.__pinokioRelayStage : null,
relayComplete: screenshotRequest && typeof screenshotRequest.__pinokioRelayComplete !== 'undefined' ? screenshotRequest.__pinokioRelayComplete : null
})
let framePosition = { x: 0, y: 0 }
if (!alreadyAdjusted) {
try {
framePosition = await session.frame.executeJavaScript(`
(function() {
let x = 0, y = 0;
let currentWindow = window;
while (currentWindow !== window.top) {
try {
const frameElement = currentWindow.frameElement;
if (frameElement) {
const rect = frameElement.getBoundingClientRect();
x += rect.left;
y += rect.top;
}
} catch (error) {
return { x, y, crossOriginBlocked: true };
}
currentWindow = currentWindow.parent;
}
return { x, y };
})();
`)
if (framePosition && framePosition.crossOriginBlocked) {
framePosition = { x: framePosition.x || 0, y: framePosition.y || 0 }
}
} catch (error) {
console.warn('Unable to determine frame offset via DOM script:', error)
framePosition = { x: 0, y: 0 }
emitDebug('frame-position-fallback', {
senderId: event && event.sender ? event.sender.id : null,
error: error && error.message ? error.message : String(error)
})
}
}
emitDebug('frame-position-computed', {
senderId: event && event.sender ? event.sender.id : null,
alreadyAdjusted,
framePosition,
bounds,
devicePixelRatio: dpr,
relayStage: screenshotRequest && typeof screenshotRequest.__pinokioRelayStage !== 'undefined' ? screenshotRequest.__pinokioRelayStage : null,
relayComplete: screenshotRequest && typeof screenshotRequest.__pinokioRelayComplete !== 'undefined' ? screenshotRequest.__pinokioRelayComplete : null
})
// Capture full page and crop to element bounds
const fullImage = await event.sender.capturePage()
const fullSize = fullImage.getSize()
emitDebug('capture-page-size', {
senderId: event && event.sender ? event.sender.id : null,
fullSize
})
// Calculate crop bounds with frame position and device pixel ratio
const cropBounds = {
x: Math.round((bounds.x + framePosition.x) * dpr),
y: Math.round((bounds.y + framePosition.y) * dpr),
width: Math.round(bounds.width * dpr),
height: Math.round(bounds.height * dpr)
}
// Validate crop bounds
cropBounds.x = Math.max(0, Math.min(cropBounds.x, fullSize.width - 1))
cropBounds.y = Math.max(0, Math.min(cropBounds.y, fullSize.height - 1))
cropBounds.width = Math.min(cropBounds.width, fullSize.width - cropBounds.x)
cropBounds.height = Math.min(cropBounds.height, fullSize.height - cropBounds.y)
emitDebug('crop-bounds', {
senderId: event && event.sender ? event.sender.id : null,
framePosition,
dpr,
validatedCropBounds: cropBounds,
fullSize
})
const croppedImage = fullImage.crop(cropBounds)
const buffer = croppedImage.toPNG()
emitDebug('capture-success', {
senderId: event && event.sender ? event.sender.id : null,
cropWidth: cropBounds.width,
cropHeight: cropBounds.height
})
return 'data:image/png;base64,' + buffer.toString('base64')
} catch (error) {
console.error('Screenshot capture failed:', error)
emitDebug('capture-error', {
senderId: event && event.sender ? event.sender.id : null,
error: error && error.message ? error.message : String(error)
})
throw error
}
})
}
const getFrameInjectorKey = (frame) => {
if (!frame) {
return ''
}
if (typeof frame.frameTreeNodeId === 'number') {
return `frame:${frame.frameTreeNodeId}`
}
const processId = typeof frame.processId === 'number' ? frame.processId : 'unknown'
const token = typeof frame.frameToken === 'string' && frame.frameToken
? frame.frameToken
: String(typeof frame.routingId === 'number' ? frame.routingId : 'unknown')
return `${processId}:${token}`
}
const getPinokioInjectWebContentsKey = (sender, frame = null) => {
if (sender && typeof sender.id === 'number') {
return `wc:${sender.id}`
}
if (frame && frame.hostWebContents && typeof frame.hostWebContents.id === 'number') {
return `wc:${frame.hostWebContents.id}`
}
return ''
}
const serializeForJavaScript = (value) => JSON.stringify(value)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
const normalizePinokioInjectDescriptor = (descriptor) => {
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
return null
}
const src = typeof descriptor.src === 'string' ? descriptor.src.trim() : ''
if (!src) {
return null
}
const match = Array.isArray(descriptor.match) && descriptor.match.length
? descriptor.match.filter((item) => typeof item === 'string' && item.trim())
: ['*']
const world = typeof descriptor.world === 'string' && descriptor.world.trim().toLowerCase() === 'isolated'
? 'isolated'
: 'main'
const whenValue = typeof descriptor.when === 'string' ? descriptor.when.trim().toLowerCase() : ''
const when = (whenValue === 'start' || whenValue === 'end') ? whenValue : 'idle'
const frameValue = typeof descriptor.frame === 'string' ? descriptor.frame.trim().toLowerCase() : ''
const frame = frameValue === 'all' ? 'all' : 'self'
return {
src,
match,
world,
when,
frame
}
}
const normalizePinokioInjectTargetRegistrations = (targets) => {
const values = Array.isArray(targets) ? targets : []
const normalized = []
for (const target of values) {
if (!target || typeof target !== 'object' || Array.isArray(target)) {
continue
}
const name = typeof target.name === 'string' ? target.name.trim() : ''
const src = normalizeInspectorUrl(typeof target.src === 'string' ? target.src.trim() : '')
if (!name && !src) {
continue
}
normalized.push({
name,
src,
inject: Array.isArray(target.inject)
? target.inject.map((entry) => normalizePinokioInjectDescriptor(entry)).filter(Boolean)
: []
})
}
return normalized
}
const findFramePath = (frame, target, trail = []) => {
if (!frame || !target) {
return null
}
const nextTrail = trail.concat(frame)
if (frame === target) {
return nextTrail
}
const children = Array.isArray(frame.frames) ? frame.frames : []
for (const child of children) {
const result = findFramePath(child, target, nextTrail)
if (result) {
return result
}
}
return null
}
const resolvePinokioRelativeMatchTarget = (href) => {
try {
const parsed = new URL(href)
return `${parsed.pathname}${parsed.search}${parsed.hash}` || '/'
} catch (_) {
return href || ''
}
}
const escapePinokioPattern = (value) => String(value || '').replace(/[|\\{}()[\]^$+?.]/g, '\\$&')
const pinokioPatternToExpression = (value) => {
const input = String(value || '')
let expression = ''
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
if (char === '*') {
while (input[index + 1] === '*') {
index += 1
}
expression += '.*'
continue
}
expression += escapePinokioPattern(char)
}
return `^${expression}$`
}
const matchesPinokioInjectPattern = (pattern, currentUrl) => {
if (typeof pattern !== 'string') {
return false
}
const normalizedPattern = pattern.trim()
if (!normalizedPattern) {
return false
}
const sourceValue = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(normalizedPattern)
? currentUrl
: resolvePinokioRelativeMatchTarget(currentUrl)
const expression = pinokioPatternToExpression(normalizedPattern)
try {
return new RegExp(expression).test(sourceValue)
} catch (_) {
return false
}
}
const matchPinokioInjectTargetToFrame = (targets, frame, hints = {}) => {
if (!Array.isArray(targets) || !targets.length) {
return null
}
const frameName = (frame && typeof frame.name === 'string' ? frame.name.trim() : '')
|| (typeof hints.frameName === 'string' ? hints.frameName.trim() : '')
const frameUrl = normalizeInspectorUrl((frame && frame.url) || '')
|| normalizeInspectorUrl(typeof hints.frameUrl === 'string' ? hints.frameUrl.trim() : '')
let matched = null
if (frameName) {
matched = targets.find((entry) => entry.name && entry.name === frameName && (!entry.src || urlsRoughlyMatch(entry.src, frameUrl)))
|| targets.find((entry) => entry.name && entry.name === frameName)
}
if (!matched && frameUrl) {
matched = targets.find((entry) => entry.src && urlsRoughlyMatch(entry.src, frameUrl)) || null
}
return matched
}
const resolvePinokioInjectTargetMatch = ({ registry, frame, currentUrl, targetHints, descendantDepth = 0 }) => {
if (!registry || !Array.isArray(registry.targets) || registry.targets.length === 0) {
return null
}
const target = matchPinokioInjectTargetToFrame(registry.targets, frame, targetHints)
if (!target) {
return null
}
const inject = target.inject.filter((descriptor) => {
if (descriptor && descriptor.frame !== 'all' && descendantDepth !== 0) {
return false
}
const matches = Array.isArray(descriptor.match) && descriptor.match.length
? descriptor.match
: ['*']
return matches.some((pattern) => matchesPinokioInjectPattern(pattern, currentUrl))
})
return {
target,
inject
}
}
const resolvePinokioInjectorsForFrame = (frame, payload = {}, sender = null) => {
if (!frame) {
return {
inject: [],
context: null
}
}
const requestedContext = payload && payload.context && typeof payload.context === 'object'
? payload.context
: {}
const currentUrl = typeof requestedContext.currentUrl === 'string' && requestedContext.currentUrl.trim()
? requestedContext.currentUrl.trim()
: (normalizeInspectorUrl(frame.url || '') || '')
let ownerFrame = frame.parent || null
let directChildFrame = frame
let descendantDepth = 0
const targetHints = {
frameName: typeof requestedContext.frameName === 'string' ? requestedContext.frameName.trim() : '',
frameUrl: currentUrl
}
while (ownerFrame) {
const ownerKey = getFrameInjectorKey(ownerFrame)
const registry = frameInjectTargetRegistry.get(ownerKey)
if (!registry || !Array.isArray(registry.targets) || registry.targets.length === 0) {
directChildFrame = ownerFrame
ownerFrame = ownerFrame.parent || null
descendantDepth += 1
continue
}
const match = resolvePinokioInjectTargetMatch({
registry,
frame: directChildFrame,
currentUrl,
targetHints,
descendantDepth
})
if (!match) {
directChildFrame = ownerFrame
ownerFrame = ownerFrame.parent || null
descendantDepth += 1
continue
}
return {
inject: match.inject,
context: {
frameUrl: normalizeInspectorUrl(ownerFrame.url || '') || '',
rootFrameUrl: normalizeInspectorUrl(directChildFrame.url || '') || '',
currentUrl,
pageUrl: normalizeInspectorUrl(frame.url || '') || currentUrl
}
}
}
const webContentsKey = getPinokioInjectWebContentsKey(sender, frame)
if (webContentsKey) {
const registries = Array.from(frameInjectTargetRegistry.entries())
.map(([ownerKey, registry]) => ({ ownerKey, registry }))
.filter(({ registry }) => registry && registry.webContentsKey === webContentsKey && Array.isArray(registry.targets) && registry.targets.length > 0)
.sort((left, right) => (right.registry.updatedAt || 0) - (left.registry.updatedAt || 0))
for (const entry of registries) {
const match = resolvePinokioInjectTargetMatch({
registry: entry.registry,
frame,
currentUrl,
targetHints,
descendantDepth: 0
})
if (!match) {
continue
}
return {
inject: match.inject,
context: {
frameUrl: entry.registry.pageUrl || '',
rootFrameUrl: normalizeInspectorUrl(frame.url || '') || currentUrl,
currentUrl,
pageUrl: entry.registry.pageUrl || currentUrl
}
}
}
}
return {
inject: [],
context: null
}
}
const PINOKIO_ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/
const resolvePinokioInjectSourceUrl = (value) => {
if (typeof value !== 'string') {
return ''
}
const trimmed = value.trim()
if (!trimmed) {
return ''
}
if (!PINOKIO_ABSOLUTE_URL_PATTERN.test(trimmed) && !trimmed.startsWith('/')) {
return ''
}
const baseUrl = root_url || 'http://localhost'
const parsed = safeParseUrl(trimmed, baseUrl)
if (!parsed) {
return ''
}
if (!['http:', 'https:', 'file:'].includes(parsed.protocol)) {
return ''
}
return parsed.href
}
const buildPinokioInjectRuntimeBootstrap = () => {
const source = function() {
const resolveTargetWindow = () => {
try {
if (window.parent && window.parent !== window) {
return window.parent
}
} catch (_) {
}
try {
if (window.top && window.top !== window) {
return window.top
}
} catch (_) {
}
return window
}
const ensureApi = () => {
if (!window.$pinokio || typeof window.$pinokio !== 'object') {
window.$pinokio = {}
}
if (typeof window.$pinokio.trigger !== 'function') {
window.$pinokio.trigger = function(eventName, payload = {}, context = {}) {
if (typeof eventName !== 'string' || !eventName.trim()) {
return { ok: false, handled: false, reason: 'invalid_event_name' }
}
const nextContext = (context && typeof context === 'object') ? { ...context } : {}
if (!nextContext.frameUrl) {
nextContext.frameUrl = window.location.href
}
resolveTargetWindow().postMessage({
e: 'pinokio:event',
event: eventName.trim(),
payload: (payload && typeof payload === 'object') ? payload : {},
context: nextContext
}, '*')
return { ok: true, handled: true, event: eventName.trim() }
}
}
window.$pinokio.inject = function(definition) {
return window.__PINOKIO_INJECT_RUNTIME__.register(definition)
}
return window.$pinokio
}
const buildMountContext = (descriptor, sourceContext) => {
const currentUrl = (window && window.location && window.location.href) ? window.location.href : ''
const baseContext = (sourceContext && typeof sourceContext === 'object') ? { ...sourceContext } : {}
if (!baseContext.frameUrl) {
baseContext.frameUrl = currentUrl
}
if (!baseContext.currentUrl) {
baseContext.currentUrl = currentUrl
}
if (!baseContext.rootFrameUrl) {
baseContext.rootFrameUrl = currentUrl
}
return {
...baseContext,
descriptor,
trigger(eventName, payload = {}, context = {}) {
const nextContext = (context && typeof context === 'object')
? { ...baseContext, ...context }
: { ...baseContext }
return window.$pinokio.trigger(eventName, payload, nextContext)
}
}
}
if (!window.__PINOKIO_INJECT_RUNTIME__) {
const state = {
current: null,
cleanups: new Map()
}
window.__PINOKIO_INJECT_RUNTIME__ = {
register(definition) {
const current = state.current
if (!current) {
throw new Error('window.$pinokio.inject() must be called while an injector is loading.')
}
if (!definition || typeof definition !== 'object' || typeof definition.mount !== 'function') {
throw new Error('Pinokio injectors must provide a mount(ctx) function.')
}
if (current.registered) {
throw new Error('Injector registered more than once during a single mount.')
}
const cleanup = definition.mount(buildMountContext(current.descriptor, current.context))
current.registered = true
if (typeof cleanup === 'function') {
state.cleanups.set(current.descriptor.runtimeId, cleanup)
} else {
state.cleanups.delete(current.descriptor.runtimeId)
}
return { ok: true, id: current.descriptor.runtimeId || '' }
},
run(descriptor, context, runSource) {
ensureApi()
state.current = {
descriptor: descriptor || {},
context: (context && typeof context === 'object') ? context : {},
registered: false
}
try {
if (typeof runSource === 'function') {
runSource()
}
if (!state.current.registered) {
throw new Error('Injector did not call window.$pinokio.inject(...).')
}
} finally {
state.current = null
}
},
unmountAll() {
for (const cleanup of state.cleanups.values()) {
if (typeof cleanup !== 'function') {
continue
}
try {
cleanup()
} catch (error) {
try {
console.warn('[pinokio][inject] cleanup failed', error && error.message ? error.message : String(error))
} catch (_) {
}
}
}
state.cleanups.clear()
}
}
}
ensureApi()
}
return `(${source.toString()})();`
}
const buildPinokioInjectUnmountScript = () => `(() => {
const runtime = window.__PINOKIO_INJECT_RUNTIME__
if (runtime && typeof runtime.unmountAll === 'function') {
runtime.unmountAll()
}
})();`
const buildPinokioInjectExecution = ({ descriptor, context, source }) => {
const bootstrap = buildPinokioInjectRuntimeBootstrap()
return `(() => {
${bootstrap}
window.__PINOKIO_INJECT_RUNTIME__.run(${serializeForJavaScript(descriptor)}, ${serializeForJavaScript(context || {})}, () => {
${source}
})
})();
//# sourceURL=${descriptor.src}`
}
const resetPinokioInjectorsInFrame = async (frame) => {
if (!frame || (typeof frame.isDestroyed === 'function' && frame.isDestroyed())) {
return
}
const code = buildPinokioInjectUnmountScript()
const tasks = []
if (typeof frame.executeJavaScript === 'function') {
tasks.push(frame.executeJavaScript(code, false))
}
if (typeof frame.executeJavaScriptInIsolatedWorld === 'function') {
tasks.push(frame.executeJavaScriptInIsolatedWorld(
PINOKIO_INJECT_ISOLATED_WORLD_ID,
[{ code }],
false
))
}
await Promise.allSettled(tasks)
}
const executePinokioInjectDescriptor = async (frame, descriptor, context) => {
if (!frame || (typeof frame.isDestroyed === 'function' && frame.isDestroyed())) {
throw new Error('Target frame is not available.')
}
const sourceDescriptor = descriptor
const sourceUrl = resolvePinokioInjectSourceUrl(descriptor.src)
if (!sourceUrl) {
throw new Error(`Invalid injector source URL: ${descriptor.src}`)
}
const response = await fetch(sourceUrl, { cache: 'no-store' })
if (!response || !response.ok) {
const status = response ? response.status : 'unknown'
throw new Error(`Unable to load injector source: ${status}`)
}
const source = await response.text()
const resolvedDescriptor = {
...sourceDescriptor,
src: sourceUrl
}
const code = buildPinokioInjectExecution({ descriptor: resolvedDescriptor, context, source })
if (descriptor.world === 'isolated') {
if (typeof frame.executeJavaScriptInIsolatedWorld !== 'function') {
throw new Error('Isolated-world frame injection is not supported by this Electron frame API.')
}
return frame.executeJavaScriptInIsolatedWorld(
PINOKIO_INJECT_ISOLATED_WORLD_ID,
[{ code, url: sourceUrl }],
false
)
}
return frame.executeJavaScript(code, false)
}
const installInjectorHandlers = () => {
if (injectorHandlersInstalled) {
return
}
injectorHandlersInstalled = true
const updatePinokioInjectTargets = (ownerFrame, sender, payload = {}) => {
if (!ownerFrame || (typeof ownerFrame.isDestroyed === 'function' && ownerFrame.isDestroyed())) {
return { ok: false, reason: 'missing_frame', targets: [] }
}
const ownerKey = getFrameInjectorKey(ownerFrame)
const webContentsKey = getPinokioInjectWebContentsKey(sender, ownerFrame)
const targets = normalizePinokioInjectTargetRegistrations(payload && payload.targets)
frameInjectTargetRegistry.set(ownerKey, {
targets,
pageUrl: payload && payload.pageUrl ? payload.pageUrl : '',
webContentsKey,
updatedAt: Date.now()
})
return { ok: true, targets }
}
ipcMain.on('pinokio:update-inject-targets', (event, payload = {}) => {
updatePinokioInjectTargets(event.senderFrame, event.sender, payload)
})
ipcMain.on('pinokio:update-inject-targets-sync', (event, payload = {}) => {
event.returnValue = updatePinokioInjectTargets(event.senderFrame, event.sender, payload)
})
ipcMain.handle('pinokio:resolve-injectors', async (event, payload = {}) => {
const frame = event.senderFrame
if (!frame || (typeof frame.isDestroyed === 'function' && frame.isDestroyed())) {
return { ok: false, reason: 'missing_frame', inject: [], context: null }
}
const resolved = resolvePinokioInjectorsForFrame(frame, payload, event.sender)
return {
ok: true,
inject: resolved.inject,
context: resolved.context
}
})
ipcMain.handle('pinokio:reset-injectors', async (event, payload = {}) => {
const frame = event.senderFrame
if (!frame || (typeof frame.isDestroyed === 'function' && frame.isDestroyed())) {
return { ok: false, reason: 'missing_frame' }
}
const frameKey = getFrameInjectorKey(frame)
const syncId = typeof payload.syncId === 'number' ? payload.syncId : 0
frameInjectorSyncState.set(frameKey, syncId)
await resetPinokioInjectorsInFrame(frame)
return { ok: true, syncId }
})
ipcMain.handle('pinokio:mount-injectors', async (event, payload = {}) => {
const frame = event.senderFrame
if (!frame || (typeof frame.isDestroyed === 'function' && frame.isDestroyed())) {
return { ok: false, reason: 'missing_frame', applied: [], failed: [] }
}
const frameKey = getFrameInjectorKey(frame)
const syncId = typeof payload.syncId === 'number' ? payload.syncId : 0
if (syncId && frameInjectorSyncState.get(frameKey) !== syncId) {
return { ok: true, skipped: true, reason: 'stale_sync', applied: [], failed: [], syncId }
}
const baseContext = payload && payload.context && typeof payload.context === 'object'
? { ...payload.context }
: {}
const injectList = Array.isArray(payload.inject) ? payload.inject : []
const applied = []
const failed = []
for (let index = 0; index < injectList.length; index += 1) {
if (syncId && frameInjectorSyncState.get(frameKey) !== syncId) {
return { ok: true, skipped: true, reason: 'stale_sync', applied, failed, syncId }
}
const normalizedDescriptor = normalizePinokioInjectDescriptor(injectList[index])
if (!normalizedDescriptor) {
continue
}
const descriptor = {
...normalizedDescriptor,
runtimeId: `${frameKey}:${syncId}:${index}:${normalizedDescriptor.src}`
}
try {
await executePinokioInjectDescriptor(frame, descriptor, baseContext)
applied.push({
src: descriptor.src,
world: descriptor.world,
runtimeId: descriptor.runtimeId
})
} catch (error) {
const message = error && error.message ? error.message : String(error)
failed.push({
src: descriptor.src,
world: descriptor.world,
error: message
})
console.warn('[pinokio][main] injector mount failed', {
src: descriptor.src,
world: descriptor.world,
error: message
})
}
}
return {
ok: failed.length === 0,
applied,
failed,
syncId
}
})
}
const normalizePermissionList = (value) => {
if (!value) return []
const list = Array.isArray(value) ? value : [value]
return list.map((item) => typeof item === 'string' ? item.trim() : '').filter(Boolean)
}
const permissionLabels = {
microphone: 'Microphone',
camera: 'Camera',
screen: 'Screen Recording',
screen_capture: 'Screen Recording'
}
const logPermission = (...args) => {
console.log('[PERMISSION]', ...args)
}
const permissionHints = {
darwin: {
microphone: 'System Settings → Privacy & Security → Microphone',
camera: 'System Settings → Privacy & Security → Camera',
screen: 'System Settings → Privacy & Security → Screen Recording',
screen_capture: 'System Settings → Privacy & Security → Screen Recording'
},
win32: {
microphone: 'Settings → Privacy & security → Microphone (allow desktop apps)',
camera: 'Settings → Privacy & security → Camera (allow desktop apps)',
screen: 'Settings → Privacy & security → Screen recording',
screen_capture: 'Settings → Privacy & security → Screen recording'
},
linux: {
microphone: 'Check your sound settings (PipeWire/PulseAudio) and app permissions.',
camera: 'Check your video device permissions in system settings.',
screen: 'Check your desktop portal or compositor screen capture permissions.',
screen_capture: 'Check your desktop portal or compositor screen capture permissions.'
}
}
const getMediaAccessStatusSafe = (mediaType) => {
if (!systemPreferences || typeof systemPreferences.getMediaAccessStatus !== 'function') {
return 'unsupported'
}
try {
return systemPreferences.getMediaAccessStatus(mediaType)
} catch (_) {
return 'unknown'
}
}
const requestMediaPermission = async (permission) => {
const platform = process.platform
if (permission === 'microphone' || permission === 'camera') {
const preStatus = getMediaAccessStatusSafe(permission)
const canAsk = platform === 'darwin' && systemPreferences && typeof systemPreferences.askForMediaAccess === 'function'
logPermission('requestMediaPermission', permission, { platform, preStatus, canAsk })
let granted = false
if (platform === 'darwin' && systemPreferences && typeof systemPreferences.askForMediaAccess === 'function') {
granted = await systemPreferences.askForMediaAccess(permission)
}
const status = getMediaAccessStatusSafe(permission)
if (status === 'granted') {
granted = true
}
logPermission('requestMediaPermission result', permission, { status, granted })
return { status, granted }
}
if (permission === 'screen' || permission === 'screen_capture') {
const status = getMediaAccessStatusSafe('screen')
logPermission('requestMediaPermission screen', permission, { status })
return { status, granted: status === 'granted' }
}
logPermission('requestMediaPermission unsupported', permission)
return { status: 'unsupported', granted: false }
}
const buildPermissionMessage = (platform, denied) => {
if (!denied.length) return ''
const items = denied.map((permission) => permissionLabels[permission] || permission)
const label = items.length === 1 ? items[0] : items.join(', ')
const hints = permissionHints[platform] || permissionHints.linux
const hint = denied.length === 1
? (hints[denied[0]] || '')
: ''
if (hint) {
return `Pinokio needs ${label} access. Enable it in ${hint}.`
}
return `Pinokio needs ${label} access. Please enable it in your OS privacy settings.`
}
const installPermissionHandlers = () => {
if (permissionHandlersInstalled) {
return
}
permissionHandlersInstalled = true
ipcMain.handle('pinokio:request-permissions', async (event, payload = {}) => {
const permissions = normalizePermissionList(payload.permissions)
if (permissions.length === 0) {
return { ok: true, permissions: [], results: {}, denied: [] }
}
const results = {}
const denied = []
for (const permission of permissions) {
const result = await requestMediaPermission(permission)
results[permission] = result
if (!result.granted) {
denied.push(permission)
}
}
return {
ok: denied.length === 0,
permissions,
denied,
results,
platform: process.platform,
message: denied.length ? buildPermissionMessage(process.platform, denied) : ''
}
})
}
const canRequestPermission = (permission) => {
if (process.platform !== 'darwin') {
return false
}
return permission === 'microphone' || permission === 'camera'
}
const promptForProjectPermissions = async (webContents, project, permissions) => {
if (!permissions.length) {
return
}
const promptKey = `${project}:${permissions.join(',')}`
if (permissionPromptInFlight.has(promptKey) || permissionPrompted.has(promptKey)) {
logPermission('prompt skipped (already prompted)', { project, permissions })
return
}
logPermission('prompt start', { project, permissions })
const pending = []
const blocked = []
const statusInfo = []
for (const permission of permissions) {
const statusTarget = (permission === 'screen' || permission === 'screen_capture') ? 'screen' : permission
const status = getMediaAccessStatusSafe(statusTarget)
if (status === 'granted') {
statusInfo.push({ permission, status, action: 'skip' })
continue
}
if (status === 'denied') {
blocked.push(permission)
statusInfo.push({ permission, status, action: 'blocked' })
} else if (canRequestPermission(permission)) {
pending.push(permission)
statusInfo.push({ permission, status, action: 'pending' })
} else {
blocked.push(permission)
statusInfo.push({ permission, status, action: 'blocked' })
}
}
logPermission('prompt status', statusInfo)
logPermission('prompt lists', { pending, blocked })
if (pending.length === 0 && blocked.length === 0) {
return
}
permissionPromptInFlight.add(promptKey)
try {
const owner = webContents && !webContents.isDestroyed()
? BrowserWindow.fromWebContents(webContents)
: null
const denied = blocked.slice()
if (pending.length > 0) {
const label = pending.map((permission) => permissionLabels[permission] || permission).join(', ')
const { response } = await dialog.showMessageBox(owner, {
type: 'info',
buttons: ['Allow', 'Not now'],
defaultId: 0,
cancelId: 1,
title: 'Permission required',
message: `Allow ${label} access?`,
detail: `This app requests ${label} access. Click "Allow" to show the OS permission prompt.`,
noLink: true
})
logPermission('prompt response', { project, permissions: pending, response })
if (response === 0) {
for (const permission of pending) {
const result = await requestMediaPermission(permission)
if (!result.granted) {
denied.push(permission)
}
}
}
}
if (denied.length > 0) {
logPermission('prompt denied', { project, denied })
const message = buildPermissionMessage(process.platform, denied)
if (message) {
await dialog.showMessageBox(owner, {
type: 'warning',
buttons: ['OK'],
defaultId: 0,
message,
noLink: true
})
}
}
} finally {
permissionPromptInFlight.delete(promptKey)
permissionPrompted.add(promptKey)
}
}
// Screenshot capture function for inspect mode
const captureScreenshotRegion = async (bounds) => {
try {
const { nativeImage } = require('electron')
// Get all displays to find the correct one
const displays = screen.getAllDisplays()
const primaryDisplay = screen.getPrimaryDisplay()
// Get desktop capturer sources with full resolution
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: {
width: primaryDisplay.bounds.width * primaryDisplay.scaleFactor,
height: primaryDisplay.bounds.height * primaryDisplay.scaleFactor
}
})
if (sources.length === 0) {
throw new Error('No screen sources available')
}
// Find the screen source that matches our primary display
let screenSource = sources[0] // fallback to first source
// Try to find the exact screen source by name or use the first one
for (const source of sources) {
if (source.name.includes('Entire Screen') || source.name.includes('Screen 1')) {
screenSource = source
break
}
}
// Get the full resolution screenshot from thumbnail
const thumbnailImage = screenSource.thumbnail
const fullScreenshotBuffer = thumbnailImage.toPNG()
const fullScreenshot = nativeImage.createFromBuffer(fullScreenshotBuffer)
// Calculate the actual pixel bounds accounting for device pixel ratio
const scaleFactor = primaryDisplay.scaleFactor
const actualBounds = {
x: Math.max(0, Math.round(bounds.x * scaleFactor)),
y: Math.max(0, Math.round(bounds.y * scaleFactor)),
width: Math.min(
Math.round(bounds.width * scaleFactor),
fullScreenshot.getSize().width - Math.round(bounds.x * scaleFactor)
),
height: Math.min(
Math.round(bounds.height * scaleFactor),
fullScreenshot.getSize().height - Math.round(bounds.y * scaleFactor)
)
}
// Ensure minimum size
actualBounds.width = Math.max(1, actualBounds.width)
actualBounds.height = Math.max(1, actualBounds.height)
// Crop the screenshot to the element bounds
const croppedImage = fullScreenshot.crop(actualBounds)
// Convert to PNG buffer and then to data URL
const croppedBuffer = croppedImage.toPNG()
const dataUrl = 'data:image/png;base64,' + croppedBuffer.toString('base64')
console.log(`Screenshot captured: ${actualBounds.width}x${actualBounds.height} at (${actualBounds.x},${actualBounds.y})`)
return dataUrl
} catch (error) {
console.warn('Screenshot capture failed:', error)
throw error
}
}
//function enable_cors(win) {
// win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
// details.requestHeaders['Origin'] = null;
// details.headers['Origin'] = null;
// callback({ requestHeaders: details.requestHeaders })
// });
//// win.webContents.session.webRequest.onBeforeSendHeaders(
//// (details, callback) => {
//// const { requestHeaders } = details;
//// UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']);
//// callback({ requestHeaders });
//// },
//// );
////
//// win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
//// const { responseHeaders } = details;
//// UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']);
//// UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']);
//// callback({
//// responseHeaders,
//// });
//// });
//}
const pushContextMenuSeparator = (template) => {
if (!template.length) {
return
}
if (template[template.length - 1].type === 'separator') {
return
}
template.push({ type: 'separator' })
}
const buildBrowserContextMenuTemplate = (webContents, params = {}) => {
const template = []
const linkURL = typeof params.linkURL === 'string' ? params.linkURL : ''
const srcURL = typeof params.srcURL === 'string' ? params.srcURL : ''
const selectionText = typeof params.selectionText === 'string' ? params.selectionText : ''
const hasSelection = selectionText.trim().length > 0
const editFlags = params.editFlags || {}
const isEditable = Boolean(params.isEditable)
const hasMediaSource = typeof params.mediaType === 'string' && params.mediaType !== 'none' && srcURL
const canSuggestSpelling = Array.isArray(params.dictionarySuggestions) && params.dictionarySuggestions.length > 0
const hasMisspelledWord = typeof params.misspelledWord === 'string' && params.misspelledWord.length > 0
const owner = webContents && !webContents.isDestroyed() ? webContents.getOwnerBrowserWindow() : null
const canGoBack = Boolean(webContents && webContents.canGoBack && webContents.canGoBack())
const canGoForward = Boolean(webContents && webContents.canGoForward && webContents.canGoForward())
if (linkURL) {
template.push({
label: 'Open Link in New Window',
click: () => {
try {
if (typeof loadNewWindow === 'function' && PORT) {
if (popupShellManager.isPinokioWindowUrl(linkURL, root_url)) {
loadNewWindow(linkURL, PORT)
} else {
popupShellManager.openExternalWindow({ url: linkURL })
}
return
}
} catch (error) {
}
shell.openExternal(linkURL).catch(() => {})
}
})
template.push({
label: 'Open Link in Browser',
click: () => {
shell.openExternal(linkURL).catch(() => {})
}
})
template.push({
label: 'Copy Link Address',
click: () => clipboard.writeText(linkURL)
})
pushContextMenuSeparator(template)
}
if (hasMediaSource) {
template.push({
label: 'Open Media in Browser',
click: () => {
shell.openExternal(srcURL).catch(() => {})
}
})
template.push({
label: 'Copy Media Address',
click: () => clipboard.writeText(srcURL)
})
pushContextMenuSeparator(template)
}
if (!isEditable) {
template.push({
label: 'Back',
enabled: canGoBack,
click: () => {
if (webContents && !webContents.isDestroyed() && webContents.canGoBack()) {
webContents.goBack()
}
}
})
template.push({
label: 'Forward',
enabled: canGoForward,
click: () => {
if (webContents && !webContents.isDestroyed() && webContents.canGoForward()) {
webContents.goForward()
}
}
})
template.push({
label: 'Reload',
click: () => {
if (webContents && !webContents.isDestroyed()) {
webContents.reload()
}
}
})
pushContextMenuSeparator(template)
}
if (isEditable) {
if (canSuggestSpelling && hasMisspelledWord) {
for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
template.push({
label: suggestion,
click: () => {
if (webContents && !webContents.isDestroyed()) {
webContents.replaceMisspelling(suggestion)
}
}
})
}
template.push({
label: 'Add to Dictionary',
click: () => {
try {
if (webContents && !webContents.isDestroyed() && webContents.session && typeof webContents.session.addWordToSpellCheckerDictionary === 'function') {
webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
}
} catch (error) {
}
}
})
pushContextMenuSeparator(template)
}
template.push({ role: 'undo', enabled: editFlags.canUndo !== false })
template.push({ role: 'redo', enabled: editFlags.canRedo !== false })
pushContextMenuSeparator(template)
template.push({ role: 'cut', enabled: editFlags.canCut !== false })
template.push({ role: 'copy', enabled: editFlags.canCopy !== false })
template.push({ role: 'paste', enabled: editFlags.canPaste !== false })
template.push({ role: 'delete', enabled: editFlags.canDelete !== false })
pushContextMenuSeparator(template)
template.push({ role: 'selectAll' })
} else {
if (hasSelection) {
template.push({ role: 'copy' })
}
template.push({ role: 'selectAll' })
}
pushContextMenuSeparator(template)
template.push({
label: 'Inspect Element',
click: () => {
if (!webContents || webContents.isDestroyed()) {
return
}
if (!webContents.isDevToolsOpened()) {
webContents.openDevTools({ mode: 'detach' })
}
const x = typeof params.x === 'number' ? params.x : null
const y = typeof params.y === 'number' ? params.y : null
if (x !== null && y !== null) {
webContents.inspectElement(x, y)
}
}
})
if (template.length && template[template.length - 1].type === 'separator') {
template.pop()
}
if (owner && owner.isDestroyed()) {
return []
}
return template
}
const attach = (event, webContents) => {
let wc = webContents
if (ENABLE_BROWSER_CONSOLE_LOG && !attachedConsoleListeners.has(webContents)) {
attachedConsoleListeners.add(webContents)
webContents.on('console-message', (event, level, message, line, sourceId) => {
if (!root_url) {
return
}
const state = browserConsoleState.get(webContents)
let pageUrl = state && state.url ? state.url : ''
if (!pageUrl) {
try {
pageUrl = webContents.getURL()
} catch (err) {
pageUrl = ''
}
}
if (!pageUrl || !pageUrl.startsWith(root_url)) {
return
}
const targetFile = ensureBrowserLogFile()
if (!targetFile) {
return
}
const logUrl = resolveConsoleSourceUrl(sourceId, pageUrl)
if (!logUrl || !shouldLogUrl(logUrl)) {
return
}
const timestamp = new Date().toISOString()
const levelLabel = consoleLevelLabels[level] || 'log'
let location = ''
if (sourceId) {
location = ` (${sourceId}${line ? `:${line}` : ''})`
} else if (line) {
location = ` (:${line})`
}
const entry = `[${timestamp}]\t${logUrl}\t[${levelLabel}] ${message}${location}\n`
browserLogBuffer.push(entry)
if (browserLogBuffer.length > 100) {
browserLogBuffer.shift()
}
browserLogWritePromise = browserLogWritePromise.then(() => fs.promises.writeFile(targetFile, browserLogBuffer.join(''))).catch((err) => {
console.error('[BROWSER LOG] Failed to persist console output', err)
})
})
webContents.once('destroyed', () => {
clearBrowserConsoleState(webContents)
})
}
// Enable screen capture permissions for all webContents
webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
callback(true)
//console.log(`[PERMISSION DEBUG] Permission requested: "${permission}" from webContents`)
//if (permission === 'media' || permission === 'display-capture' || permission === 'desktopCapture') {
// console.log(`[PERMISSION DEBUG] Granting permission: "${permission}"`)
// callback(true)
//} else {
// console.log(`[PERMISSION DEBUG] Denying permission: "${permission}"`)
// callback(false)
//}
})
webContents.session.setPermissionCheckHandler((webContents, permission) => {
return true
//console.log(`[PERMISSION DEBUG] Permission check for: "${permission}"`)
//return permission === 'media' || permission === 'display-capture' || permission === 'desktopCapture'
})
webContents.session.setDisplayMediaRequestHandler((request, callback) => {
console.log('[DISPLAY MEDIA DEBUG] Display media request received')
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
console.log('[DISPLAY MEDIA DEBUG] Available sources:', sources.length)
if (sources.length > 0) {
callback({ video: sources[0], audio: 'loopback' })
} else {
callback({})
}
}).catch(err => {
console.error('[DISPLAY MEDIA DEBUG] Error getting sources:', err)
callback({})
})
})
webContents.on('will-prevent-unload', (event) => {
event.preventDefault()
})
webContents.on('will-navigate', (event, url) => {
if (!webContents.opened) {
// The first time this view is being used, set the "opened" to true, and don't do anything
// The next time the view navigates, "the "opened" is already true, so trigger the URL open logic
// - if the new URL has the same host as the app's url, open in app
// - if it's a remote host, open in external browser
webContents.opened = true
} else {
// console.log("will-navigate", { event, url })
const owner = webContents.getOwnerBrowserWindow()
if (openNonPinokioNavigationInPopup({ event, owner, url })) {
return
}
const target = safeParseUrl(url, root_url || undefined)
if (target && !popupShellManager.isPinokioWindowUrl(target.href, root_url) && target.protocol !== 'http:' && target.protocol !== 'https:') {
event.preventDefault()
shell.openExternal(target.href)
}
}
})
webContents.on('will-frame-navigate', (event) => {
const owner = webContents.getOwnerBrowserWindow()
const frame = event && event.frame
const isDirectChildFrame = Boolean(
frame &&
webContents.mainFrame &&
frame.parent &&
!frame.parent.parent &&
frame.parent === webContents.mainFrame
)
if (!isDirectChildFrame) {
return
}
const currentUrl = (() => {
try {
return webContents.getURL()
} catch (_) {
return ''
}
})()
if (!isRootShellUrl(currentUrl)) {
return
}
openNonPinokioNavigationInPopup({
event,
owner,
url: event && event.url,
frame
})
})
// webContents.session.defaultSession.loadExtension('path/to/unpacked/extension').then(({ id }) => {
// })
webContents.session.webRequest.onHeadersReceived((details, callback) => {
// console.log("details", details)
// console.log("responseHeaders", JSON.stringify(details.responseHeaders, null, 2))
// 1. Remove X-Frame-Options
if (details.responseHeaders["X-Frame-Options"]) {
delete details.responseHeaders["X-Frame-Options"]
} else if (details.responseHeaders["x-frame-options"]) {
delete details.responseHeaders["x-frame-options"]
}
// 2. Remove Content-Security-Policy "frame-ancestors" attribute
let csp
let csp_type;
if (details.responseHeaders["Content-Security-Policy"]) {
csp = details.responseHeaders["Content-Security-Policy"]
csp_type = 0
} else if (details.responseHeaders['content-security-policy']) {
csp = details.responseHeaders["content-security-policy"]
csp_type = 1
}
if (details.responseHeaders["cross-origin-opener-policy-report-only"]) {
delete details.responseHeaders["cross-origin-opener-policy-report-only"]
} else if (details.responseHeaders["Cross-Origin-Opener-Policy-Report-Only"]) {
delete details.responseHeaders["Cross-Origin-Opener-Policy-Report-Only"]
}
if (csp) {
// console.log("CSP", csp)
// find /frame-ancestors ;$/
let new_csp = csp.map((c) => {
return c.replaceAll(/frame-ancestors[^;]+;?/gi, "")
})
// console.log("new_csp = ", new_csp)
const r = {
responseHeaders: details.responseHeaders
}
if (csp_type === 0) {
r.responseHeaders["Content-Security-Policy"] = new_csp
} else if (csp_type === 1) {
r.responseHeaders["content-security-policy"] = new_csp
}
// console.log("R", JSON.stringify(r, null, 2))
callback(r)
} else {
// console.log("RH", details.responseHeaders)
callback({
responseHeaders: details.responseHeaders
})
}
})
webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
let ua = details.requestHeaders['User-Agent']
// console.log("User Agent Before", ua)
if (ua) {
ua = ua.replace(/ pinokio\/[0-9.]+/i, '');
ua = ua.replace(/Electron\/.+ /i,'');
// console.log("User Agent After", ua)
details.requestHeaders['User-Agent'] = ua;
}
// console.log("REQ", details)
// console.log("HEADER BEFORE", details.requestHeaders)
// // Remove all sec-fetch-* headers
// for(let key in details.requestHeaders) {
// if (key.toLowerCase().startsWith("sec-")) {
// delete details.requestHeaders[key]
// }
// }
// console.log("HEADER AFTER", details.requestHeaders)
callback({ cancel: false, requestHeaders: details.requestHeaders });
});
// webContents.session.webRequest.onBeforeSendHeaders(
// (details, callback) => {
// const { requestHeaders } = details;
// UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']);
// callback({ requestHeaders });
// },
// );
//
// webContents.session.webRequest.onHeadersReceived((details, callback) => {
// const { responseHeaders } = details;
// UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']);
// UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']);
// callback({
// responseHeaders,
// });
// });
// webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
// //console.log("Before", { details })
// if (details.requestHeaders) details.requestHeaders['Origin'] = null;
// if (details.requestHeaders) details.requestHeaders['Referer'] = null;
// if (details.requestHeaders) details.requestHeaders['referer'] = null;
// if (details.headers) details.headers['Origin'] = null;
// if (details.headers) details.headers['Referer'] = null;
// if (details.headers) details.headers['referer'] = null;
//
// if (details.referrer) details.referrer = null
// //console.log("After", { details })
// callback({ requestHeaders: details.requestHeaders })
// });
// webContents.on("did-create-window", (parentWindow, details) => {
// const view = new BrowserView();
// parentWindow.setBrowserView(view);
// view.setBounds({ x: 0, y: 30, width: parentWindow.getContentBounds().width, height: parentWindow.getContentBounds().height - 30 });
// view.setAutoResize({ width: true, height: true });
// view.webContents.loadURL(details.url);
// })
webContents.on('did-navigate', (event, url) => {
let win = webContents.getOwnerBrowserWindow()
if (win && typeof win.setTitleBarOverlay === "function") {
const overlay = titleBarOverlay(colors)
setWindowTitleBarOverlay(win, overlay)
}
launched = true
updateBrowserConsoleTarget(webContents, url)
})
webContents.on('did-navigate-in-page', (event, url) => {
updateBrowserConsoleTarget(webContents, url)
})
webContents.on('context-menu', (event, params) => {
const template = buildBrowserContextMenuTemplate(webContents, params)
if (!template.length) {
return
}
const menu = Menu.buildFromTemplate(template)
const win = webContents.getOwnerBrowserWindow()
if (win && !win.isDestroyed()) {
menu.popup({ window: win })
return
}
menu.popup()
})
webContents.setWindowOpenHandler((config) => {
let url = config.url
let features = config.features || ""
let disposition = config.disposition || ""
let params = new URLSearchParams(features.split(",").join("&"))
let win = wc.getOwnerBrowserWindow()
let [width, height] = win.getSize()
let [x,y] = win.getPosition()
// if the origin is the same as the pinokio host,
// always open in new window
// if not, check the features
// if features exists and it's app or self, open in pinokio
// otherwise if it's file,
if (/(^|,)\s*pinokio\s*(,|$)/i.test(features)) {
const targetUrl = popupShellManager.resolveTargetUrl({
url,
openerWebContents: wc,
rootUrl: root_url
})
if (targetUrl) {
if (popupShellManager.isPinokioWindowUrl(targetUrl, root_url)) {
loadNewWindow(targetUrl, PORT)
} else {
popupShellManager.openExternalWindow({ url: targetUrl })
}
}
return { action: 'deny' };
}
if (features === "browser") {
shell.openExternal(url);
return { action: 'deny' };
} else if (disposition === "foreground-tab" || disposition === "background-tab") {
const targetUrl = popupShellManager.resolveTargetUrl({
url,
openerWebContents: wc,
rootUrl: root_url
})
if (targetUrl) {
popupShellManager.openExternalWindow({ url: targetUrl })
}
return { action: 'deny' };
} else if (popupShellManager.isPinokioWindowUrl(url, root_url)) {
return {
action: 'allow',
outlivesOpener: true,
overrideBrowserWindowOptions: {
width: (params.get("width") ? parseInt(params.get("width")) : width),
height: (params.get("height") ? parseInt(params.get("height")) : height),
x: x + 30,
y: y + 30,
parent: null,
titleBarStyle : "hidden",
titleBarOverlay : titleBarOverlay(colors),
webPreferences: {
session: session.defaultSession,
webSecurity: false,
spellcheck: false,
nativeWindowOpen: true,
contextIsolation: false,
nodeIntegrationInSubFrames: true,
preload: path.join(__dirname, 'preload.js')
},
}
}
} else {
if (features.startsWith("app") || features.startsWith("self")) {
return popupShellManager.createPopupResponse({ params, width, height, x, y })
}
if (features.startsWith("file")) {
let u = features.replace("file://", "")
shell.showItemInFolder(u)
return { action: 'deny' };
}
const targetUrl = popupShellManager.resolveTargetUrl({
url,
openerWebContents: wc,
rootUrl: root_url
})
if (targetUrl) {
popupShellManager.openExternalWindow({ url: targetUrl })
}
return { action: 'deny' };
}
// if (origin === root_url) {
// // if the origin is the same as pinokio, open in pinokio
// // otherwise open in external browser
// if (features) {
// if (features.startsWith("app") || features.startsWith("self")) {
// return {
// action: 'allow',
// outlivesOpener: true,
// overrideBrowserWindowOptions: {
// width: (params.get("width") ? parseInt(params.get("width")) : width),
// height: (params.get("height") ? parseInt(params.get("height")) : height),
// x: x + 30,
// y: y + 30,
//
// parent: null,
// titleBarStyle : "hidden",
// titleBarOverlay : titleBarOverlay("default"),
// }
// }
// } else if (features.startsWith("file")) {
// let u = features.replace("file://", "")
// shell.showItemInFolder(u)
// return { action: 'deny' };
// } else {
// return { action: 'deny' };
// }
// } else {
// if (features.startsWith("file")) {
// let u = features.replace("file://", "")
// shell.showItemInFolder(u)
// return { action: 'deny' };
// } else {
// shell.openExternal(url);
// return { action: 'deny' };
// }
// }
// } else {
// if (features.startsWith("file")) {
// let u = features.replace("file://", "")
// shell.showItemInFolder(u)
// return { action: 'deny' };
// } else {
// shell.openExternal(url);
// return { action: 'deny' };
// }
// }
});
}
const getWinState = (url, options) => {
let filename
try {
let pathname = new URL(url).pathname.slice(1)
filename = pathname.slice("/").join("-")
} catch {
filename = "index.json"
}
let state = windowStateKeeper({
file: filename,
...options
});
return state
}
const createWindow = (port) => {
let mainWindowState = windowStateKeeper({
// file: "index.json",
defaultWidth: 1000,
defaultHeight: 800
});
mainWindow = new BrowserWindow({
titleBarStyle : "hidden",
titleBarOverlay : titleBarOverlay(colors),
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 190,
webPreferences: {
session: session.defaultSession,
webSecurity: false,
spellcheck: false,
nativeWindowOpen: true,
contextIsolation: false,
nodeIntegrationInSubFrames: true,
enableRemoteModule: false,
experimentalFeatures: true,
preload: path.join(__dirname, 'preload.js')
},
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Debug media device availability
mainWindow.webContents.once('did-finish-load', () => {
console.log('[MEDIA DEBUG] Main window loaded, checking media devices availability...')
mainWindow.webContents.executeJavaScript(`
console.log('[MEDIA DEBUG] navigator.mediaDevices available:', !!navigator.mediaDevices);
console.log('[MEDIA DEBUG] getDisplayMedia available:', !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia));
console.log('[MEDIA DEBUG] getUserMedia available:', !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) {
console.log('[MEDIA DEBUG] Supported constraints:', navigator.mediaDevices.getSupportedConstraints());
}
`).catch(err => console.error('[MEDIA DEBUG] Error checking media devices:', err))
if (updateBannerPayload && !(updateBannerPayload.state === 'available' && updateBannerDismissed)) {
mainWindow.webContents.send('pinokio:update-banner', updateBannerPayload)
}
})
// Enable screen capture permissions
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
callback(true)
//console.log(`[PERMISSION DEBUG] MainWindow permission requested: "${permission}"`)
//if (permission === 'media' || permission === 'display-capture' || permission === 'desktopCapture') {
// console.log(`[PERMISSION DEBUG] MainWindow granting permission: "${permission}"`)
// callback(true)
//} else {
// console.log(`[PERMISSION DEBUG] MainWindow denying permission: "${permission}"`)
// callback(false)
//}
})
// enable_cors(mainWindow)
if("" + port === "80") {
root_url = `http://localhost`
} else {
root_url = `http://localhost:${port}`
}
mainWindow.loadURL(root_url)
// mainWindow.maximize();
mainWindowState.manage(mainWindow);
}
const loadNewWindow = (url, port) => {
let winState = windowStateKeeper({
// file: "index.json",
defaultWidth: 1000,
defaultHeight: 800
});
let win = new BrowserWindow({
titleBarStyle : "hidden",
titleBarOverlay : titleBarOverlay(colors),
x: winState.x,
y: winState.y,
width: winState.width,
height: winState.height,
minWidth: 190,
webPreferences: {
session: session.defaultSession,
webSecurity: false,
spellcheck: false,
nativeWindowOpen: true,
contextIsolation: false,
nodeIntegrationInSubFrames: true,
enableRemoteModule: false,
experimentalFeatures: true,
preload: path.join(__dirname, 'preload.js')
},
})
installForceDestroyOnClose(win)
// Enable screen capture permissions
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
callback(true)
//console.log(`[PERMISSION DEBUG] New window permission requested: "${permission}"`)
//if (permission === 'media' || permission === 'display-capture' || permission === 'desktopCapture') {
// console.log(`[PERMISSION DEBUG] New window granting permission: "${permission}"`)
// callback(true)
//} else {
// console.log(`[PERMISSION DEBUG] New window denying permission: "${permission}"`)
// callback(false)
//}
})
// enable_cors(win)
win.focus()
win.loadURL(url)
winState.manage(win)
}
popupShellManager.setPinokioHomeWindowOpener(() => {
if (root_url && PORT) {
loadNewWindow(root_url, PORT)
}
})
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('pinokio', process.execPath, [path.resolve(process.argv[1])])
}
} else {
app.setAsDefaultProtocolClient('pinokio')
}
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
// Prevent having error
event.preventDefault()
// and continue
callback(true)
})
app.on('second-instance', (event, argv) => {
if (!mainWindow || mainWindow.isDestroyed()) {
createWindow(PORT)
} else {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
const url = [...argv].reverse().find(arg => typeof arg === 'string' && arg.startsWith('pinokio:'))
if (!url) {
return
}
//let u = new URL(url).search
let u = url.replace(/pinokio:[\/]+/, "")
loadNewWindow(`${root_url}/pinokio/${u}`, PORT)
// if (BrowserWindow.getAllWindows().length === 0 || !mainWindow) createWindow(PORT)
// mainWindow.focus()
// mainWindow.loadURL(`${root_url}/pinokio/${u}`)
})
// Create mainWindow, load the rest of the app, etc...
// Enable desktop capture for getDisplayMedia support (must be before app ready)
app.commandLine.appendSwitch('disable-features', 'LazyImageLoading')
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
app.commandLine.appendSwitch('enable-features', 'GetDisplayMediaSet,GetDisplayMediaSetAutoSelectAllScreens');
app.whenReady().then(async () => {
console.log('App is ready, about to install inspector handlers...')
app.userAgentFallback = "Pinokio"
installInspectorHandlers()
installInjectorHandlers()
installPermissionHandlers()
installClosePopupOnDownload(session.defaultSession)
installClosePopupOnDownload(popupShellManager.getPopupBrowserSession())
ipcMain.on('pinokio:update-banner-action', (_event, payload = {}) => {
const action = payload && payload.action
if (!action) {
return
}
if (updateTestMode) {
if (action === 'update') {
startUpdateBannerTestDownload()
return
}
if (action === 'restart') {
simulateUpdateBannerRestart()
return
}
if (action === 'dismiss') {
updateBannerDismissed = true
hideUpdateBanner()
return
}
if (action === 'release-notes') {
const target = payload && payload.releaseUrl ? payload.releaseUrl : UPDATE_RELEASES_URL
shell.openExternal(target)
return
}
}
if (action === 'update') {
if (updateDownloadInFlight) {
return
}
updateDownloadInFlight = true
updateBannerDismissed = false
showUpdateBanner(buildUpdateBannerPayload('downloading', updateInfo, { progressPercent: 0 }))
updater.downloadUpdate().catch((err) => {
updateDownloadInFlight = false
const message = err && err.message ? err.message : 'Update failed'
showUpdateBanner(buildUpdateBannerPayload('error', updateInfo, { errorMessage: message }))
})
return
}
if (action === 'restart') {
updater.quitAndInstall()
return
}
if (action === 'dismiss') {
updateBannerDismissed = true
hideUpdateBanner()
return
}
if (action === 'release-notes') {
const target = payload && payload.releaseUrl ? payload.releaseUrl : UPDATE_RELEASES_URL
shell.openExternal(target)
}
})
// PROMPT
let promptResponse
ipcMain.on('prompt', function(eventRet, arg) {
promptResponse = null
const point = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(point)
const bounds = display.bounds
// const bounds = focused.getBounds()
let promptWindow = new BrowserWindow({
x: bounds.x + bounds.width/2 - 200,
y: bounds.y + bounds.height/2 - 60,
width: 400,
height: 120,
//width: 1000,
//height: 500,
show: false,
resizable: false,
// movable: false,
// alwaysOnTop: true,
frame: false,
webPreferences: {
session: session.defaultSession,
webSecurity: false,
spellcheck: false,
nativeWindowOpen: true,
contextIsolation: false,
nodeIntegrationInSubFrames: true,
preload: path.join(__dirname, 'preload.js')
},
})
arg.val = arg.val || ''
const promptHtml = `
`
// promptWindow.loadFile("prompt.html")
promptWindow.loadURL('data:text/html,' + encodeURIComponent(promptHtml))
promptWindow.show()
promptWindow.on('closed', function() {
console.log({ promptResponse })
debugger
eventRet.returnValue = promptResponse
promptWindow = null
})
})
ipcMain.on('prompt-response', function(event, arg) {
if (arg === ''){ arg = null }
console.log("prompt-response", { arg})
promptResponse = arg
})
updateSplashWindow({
state: 'loading',
message: 'Starting Pinokio…',
icon: getSplashIcon()
})
try {
await restoreSessionCookies()
await clearSessionCaches()
try {
const portInUse = await pinokiod.running(pinokiod.port)
if (portInUse) {
showStartupError({
message: 'Pinokio is already running',
detail: `Pinokio detected another instance listening on port ${pinokiod.port}. Please close the other instance before launching a new one.`
})
return
}
} catch (checkError) {
console.warn('Failed to verify pinokio port availability', checkError)
}
await pinokiod.start({
onquit: () => {
app.quit()
},
onrestart: () => {
persistSessionCookies().finally(() => {
app.relaunch()
app.exit()
})
},
onrefresh: (payload) => {
try {
updateThemeColors(payload || { theme: pinokiod.theme, colors: pinokiod.colors })
} catch (err) {
console.error('Failed to sync title bar theme', err)
}
},
browser: {
clearCache: async () => {
console.log('clear cache from all sessions')
// Clear default session
await session.defaultSession.clearStorageData()
// Clear all custom sessions from active windows
const windows = BrowserWindow.getAllWindows()
for (const window of windows) {
if (window.webContents && window.webContents.session) {
await window.webContents.session.clearStorageData()
}
}
await clearPersistedSessionCookies()
console.log("cleared all sessions")
},
requestPermissions: async (payload = {}) => {
try {
const project = typeof payload.name === 'string' ? payload.name.trim() : ''
const permissions = normalizePermissionList(payload.permissions)
logPermission('callback received', { project, permissions })
if (!project || permissions.length === 0) {
logPermission('callback skipped (missing project or permissions)', { project, permissions })
return { ok: true, skipped: true }
}
const owner = BrowserWindow.getFocusedWindow() || mainWindow || BrowserWindow.getAllWindows()[0] || null
const webContents = owner && owner.webContents ? owner.webContents : null
if (!webContents || webContents.isDestroyed()) {
logPermission('callback failed (no webContents)', { project, permissions })
return { ok: false, error: 'no-webcontents' }
}
await promptForProjectPermissions(webContents, project, permissions)
return { ok: true }
} catch (err) {
console.error('[PERMISSION] Failed to prompt via callback', err)
return { ok: false, error: err && err.message ? err.message : String(err) }
}
}
}
})
} catch (error) {
console.error('Failed to start pinokiod', error)
showStartupError({ error })
return
}
closeSplashWindow()
PORT = pinokiod.port
app.on('web-contents-created', attach)
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow(PORT)
})
app.on('before-quit', function(e) {
if (pinokiod.kernel.kill) {
if (isQuitting) {
return
}
e.preventDefault()
isQuitting = true
persistSessionCookies().finally(() => {
console.log('Cleaning up before quit', process.pid)
pinokiod.kernel.kill()
})
}
});
app.on('window-all-closed', function () {
console.log("window-all-closed")
if (process.platform !== 'darwin') {
// Reset all shells before quitting
pinokiod.kernel.shell.reset()
// wait 1 second before quitting the app
// otherwise the app.quit() fails because the subprocesses are running
setTimeout(() => {
console.log("app.quit()")
app.quit()
}, 1000)
}
})
app.on('browser-window-created', (event, win) => {
const parentWindow = (win && typeof win.getParentWindow === 'function') ? win.getParentWindow() : null
if (parentWindow && !parentWindow.isDestroyed()) installForceDestroyOnClose(win)
if (win.type !== "splash") {
if (win && typeof win.setTitleBarOverlay === 'function') {
const overlay = titleBarOverlay(colors)
setWindowTitleBarOverlay(win, overlay)
}
}
})
app.on('open-url', (event, url) => {
let u = url.replace(/pinokio:[\/]+/, "")
// let u = new URL(url).search
// console.log("u", u)
loadNewWindow(`${root_url}/pinokio/${u}`, PORT)
// if (BrowserWindow.getAllWindows().length === 0 || !mainWindow) createWindow(PORT)
// const topWindow = BrowserWindow.getFocusedWindow();
// console.log("top window", topWindow)
// //mainWindow.focus()
// //mainWindow.loadURL(`${root_url}/pinokio/${u}`)
// topWindow.focus()
// topWindow.loadURL(`${root_url}/pinokio/${u}`)
})
// app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')
let all = BrowserWindow.getAllWindows()
for(win of all) {
try {
if (win && typeof win.setTitleBarOverlay === 'function') {
const overlay = titleBarOverlay(colors)
setWindowTitleBarOverlay(win, overlay)
}
} catch (e) {
// console.log("E2", e)
}
}
createWindow(PORT)
if (updateTestMode) {
setTimeout(() => {
showUpdateBannerTestAvailable()
}, 400)
} else {
updater.setHandlers({
onUpdateAvailable: (info) => {
updateInfo = info
updateDownloadInFlight = false
updateBannerDismissed = false
showUpdateBanner(buildUpdateBannerPayload('available', info))
},
onUpdateNotAvailable: () => {
updateInfo = null
updateDownloadInFlight = false
hideUpdateBanner()
},
onDownloadProgress: (progress) => {
const payload = buildUpdateBannerPayload('downloading', updateInfo, {
progressPercent: progress && typeof progress.percent === 'number' ? progress.percent : 0,
notesPreview: buildProgressLabel(progress)
})
showUpdateBanner(payload)
},
onUpdateDownloaded: (info) => {
updateInfo = info
updateDownloadInFlight = false
showUpdateBanner(buildUpdateBannerPayload('ready', info))
},
onError: (err) => {
const wasDownloading = updateDownloadInFlight
updateDownloadInFlight = false
if (!wasDownloading) {
console.warn('Update check error:', err)
return
}
const message = err && err.message ? err.message : 'Update error'
showUpdateBanner(buildUpdateBannerPayload('error', updateInfo, { notesPreview: message }))
}
})
updater.run(mainWindow)
}
})
}
================================================
FILE: linux_build.sh
================================================
docker run --rm -ti \
-v "$PWD:/project" \
-w /project \
-e SNAPCRAFT_BUILD_ENVIRONMENT=host \
-e SNAP_DESTRUCTIVE_MODE=true \
electronuserland/builder \
bash -lc "rm -rf node_modules && npm install && npm run dist"
================================================
FILE: main.js
================================================
const { app } = require('electron')
const Pinokiod = require("pinokiod")
const config = require('./config')
const pinokiod = new Pinokiod(config)
if (process.platform === 'linux') {
console.log('[PINOKIO DEBUG] Linux startup')
console.log('[PINOKIO DEBUG] ELECTRON_OZONE_PLATFORM_HINT:', process.env.ELECTRON_OZONE_PLATFORM_HINT || '')
console.log('[PINOKIO DEBUG] ELECTRON_DISABLE_GPU:', process.env.ELECTRON_DISABLE_GPU || '')
console.log('[PINOKIO DEBUG] DISPLAY:', process.env.DISPLAY || '')
console.log('[PINOKIO DEBUG] WAYLAND_DISPLAY:', process.env.WAYLAND_DISPLAY || '')
console.log('[PINOKIO DEBUG] argv:', process.argv.join(' '))
app.disableHardwareAcceleration()
}
let mode = pinokiod.kernel.store.get("mode") || "full"
//iprocess.env.PINOKIO_MODE = process.env.PINOKIO_MODE || 'desktop';
if (mode === 'minimal' || mode === 'background') {
require('./minimal');
} else {
require('./full');
}
================================================
FILE: minimal.js
================================================
const { app, Tray, Menu, shell, nativeImage, BrowserWindow, session, Notification } = require('electron');
const path = require('path')
const os = require('os')
const fs = require('fs')
const Pinokiod = require("pinokiod")
const config = require('./config')
const Updater = require('./updater')
const pinokiod = new Pinokiod(config)
const updater = new Updater()
let tray
let hiddenWindow
let rootUrl
let splashWindow
let splashIcon
const getLogFileHint = () => {
try {
if (pinokiod && pinokiod.kernel && pinokiod.kernel.homedir) {
return path.resolve(pinokiod.kernel.homedir, "logs", "stdout.txt")
}
} catch (err) {
}
return path.resolve(os.homedir(), ".pinokio", "logs", "stdout.txt")
}
const ensureSplashWindow = () => {
if (splashWindow && !splashWindow.isDestroyed()) {
return splashWindow
}
splashWindow = new BrowserWindow({
width: 420,
height: 320,
frame: false,
resizable: false,
transparent: true,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
fullscreenable: false,
webPreferences: {
backgroundThrottling: false
}
})
splashWindow.on('closed', () => {
splashWindow = null
})
return splashWindow
}
const getSplashIcon = () => {
if (splashIcon) {
return splashIcon
}
const candidates = [
path.join('assets', 'icon.png'),
path.join('assets', 'icon_small@2x.png'),
path.join('assets', 'icon_small.png'),
'icon2.png'
]
for (const relative of candidates) {
const absolute = path.join(__dirname, relative)
if (fs.existsSync(absolute)) {
splashIcon = relative.split(path.sep).join('/')
return splashIcon
}
}
splashIcon = path.join('assets', 'icon_small.png').split(path.sep).join('/')
return splashIcon
}
const updateSplashWindow = ({ state = 'loading', message, detail, logPath, icon } = {}) => {
const win = ensureSplashWindow()
const query = { state }
if (message) {
query.message = message
}
if (detail) {
const trimmed = detail.length > 800 ? `${detail.slice(0, 800)}…` : detail
query.detail = trimmed
}
if (logPath) {
query.log = logPath
}
if (icon) {
query.icon = icon
}
win.loadFile(path.join(__dirname, 'splash.html'), { query }).finally(() => {
if (!win.isDestroyed()) {
win.show()
}
})
}
const closeSplashWindow = () => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close()
}
}
const showStartupError = ({ message, detail, error } = {}) => {
const formatted = detail || formatStartupError(error)
updateSplashWindow({
state: 'error',
message: message || 'Pinokio could not start',
detail: formatted,
logPath: getLogFileHint(),
icon: getSplashIcon()
})
}
const formatStartupError = (error) => {
if (!error) return ''
if (error.stack) {
return `${error.message || 'Unknown error'}\n\n${error.stack}`
}
if (error.message) return error.message
if (typeof error === 'string') return error
try {
return JSON.stringify(error, null, 2)
} catch (err) {
return String(error)
}
}
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
app.on('second-instance', () => {
if (rootUrl) {
shell.openExternal(rootUrl)
}
})
app.whenReady().then(async () => {
if (!gotTheLock) {
return
}
updateSplashWindow({
state: 'loading',
message: 'Starting Pinokio…',
icon: getSplashIcon()
})
try {
try {
const portInUse = await pinokiod.running(pinokiod.port)
if (portInUse) {
showStartupError({
message: 'Pinokio is already running',
detail: `An existing Pinokio instance is using port ${pinokiod.port}. Please close it before launching another.`
})
return
}
} catch (checkError) {
console.warn('Failed to verify pinokio port availability', checkError)
}
await pinokiod.start({
onquit: () => {
app.quit()
},
onrestart: () => {
app.relaunch();
app.exit()
},
browser: {
clearCache: async () => {
console.log('clear cache', session.defaultSession)
await session.defaultSession.clearStorageData()
console.log("cleared")
}
}
})
} catch (error) {
console.error('Failed to start pinokiod', error)
showStartupError({ error })
return
}
let quitting = false
app.on('before-quit', (e) => {
if (quitting) {
return
}
if (pinokiod && pinokiod.kernel && typeof pinokiod.kernel.kill === 'function') {
quitting = true
e.preventDefault()
try {
pinokiod.kernel.kill()
} catch (err) {
console.warn('Failed to terminate pinokiod on quit', err)
}
}
})
closeSplashWindow()
rootUrl = `http://localhost:${pinokiod.port}`
if (process.platform === 'darwin') app.dock.hide();
const assetsRoot = app.isPackaged ? process.resourcesPath : __dirname
const iconPath = path.resolve(assetsRoot, "assets/icon_small.png")
let icon = nativeImage.createFromPath(iconPath)
icon = icon.resize({
height: 24,
width: 24
});
console.log('Tray icon path:', iconPath, 'isEmpty:', icon.isEmpty()); // if true, image failed to load
tray = new Tray(icon)
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open in Browser', click: () => shell.openExternal(rootUrl) },
{ label: 'Restart', click: () => { app.relaunch(); app.exit(); } },
{ label: 'Quit', click: () => app.quit() }
]);
tray.setToolTip('Pinokio');
tray.setContextMenu(contextMenu);
const showNotification = (options = {}) => {
try {
new Notification({
title: 'Pinokio',
body: 'Running in background',
...options
}).show()
} catch (err) {
console.warn('Failed to show background notification', err)
}
}
const announceTray = () => {
const platformHandlers = {
darwin: () => {
try {
tray.setHighlightMode('always')
tray.setTitle('Pinokio running')
setTimeout(() => tray.setHighlightMode('selection'), 4000)
setTimeout(() => tray.popUpContextMenu(contextMenu), 150)
} catch (err) {
console.warn('Failed to signal tray/notification on macOS', err)
}
showNotification()
},
win32: () => {
try {
app.setAppUserModelId('Pinokio')
} catch (err) {
console.warn('Failed to set AppUserModelID', err)
}
showNotification({ icon: iconPath })
},
default: () => {
showNotification()
}
}
const handler = platformHandlers[process.platform] || platformHandlers.default
handler()
}
announceTray()
tray.on('click', () => {
tray.popUpContextMenu(contextMenu);
});
shell.openExternal(rootUrl);
hiddenWindow = new BrowserWindow({ show: false });
updater.run(hiddenWindow)
});
================================================
FILE: package.json
================================================
{
"name": "Pinokio",
"private": true,
"version": "7.0.0",
"homepage": "https://pinokio.co",
"description": "pinokio",
"main": "main.js",
"email": "cocktailpeanuts@proton.me",
"author": "https://twitter.com/cocktailpeanut",
"scripts": {
"start": "electron .",
"test:update-banner": "node script/run-update-banner-test.js",
"pack": "./node_modules/.bin/electron-builder --dir",
"eject": "hdiutil info | grep '/dev/disk' | awk '{print $1}' | xargs -I {} hdiutil detach {}",
"l": "docker run --rm -ti -v $PWD:/project -w /project -e SNAPCRAFT_BUILD_ENVIRONMENT=host -e SNAP_DESTRUCTIVE_MODE=true electronuserland/builder bash -lc 'rm -rf node_modules && npm install && npm run monkeypatch && ./node_modules/.bin/electron-builder install-app-deps && ./node_modules/.bin/electron-builder -l'",
"mw": "rm -rf node_modules && npm install && npm run monkeypatch && ./node_modules/.bin/electron-builder install-app-deps && ./node_modules/.bin/electron-builder -mw && npm run zip",
"build2": "npm run l && npm run mw",
"dist": "npm run monkeypatch && ./node_modules/.bin/electron-builder install-app-deps && export SNAPCRAFT_BUILD_ENVIRONMENT=host && export SNAP_DESTRUCTIVE_MODE='true' && ./node_modules/.bin/electron-builder -l && npm run zip",
"dist2": "npm run monkeypatch && export USE_SYSTEM_FPM=true && ./node_modules/.bin/electron-builder install-app-deps && export SNAPCRAFT_BUILD_ENVIRONMENT=host && export SNAP_DESTRUCTIVE_MODE='true' && ./node_modules/.bin/electron-builder -mwl && npm run zip",
"zip": "node script/zip",
"monkeypatch": "cp temp/yarn.js node_modules/app-builder-lib/out/util/yarn.js && cp temp/rebuild.js node_modules/@electron/rebuild/lib/src/rebuild.js",
"postinstall2": "npm run monkeypatch && ./node_modules/.bin/electron-builder install-app-deps",
"fix": "brew install fpm"
},
"build": {
"appId": "computer.pinokio",
"afterPack": "after-pack.js",
"afterSign": "electron-builder-notarize",
"directories": {
"output": "dist-${platform}"
},
"publish": [
{
"provider": "github",
"owner": "pinokiocomputer",
"repo": "pinokio"
}
],
"asarUnpack": [
"node_modules/go-get-folder-size/**/*",
"node_modules/7zip-bin/**/*",
"node_modules/sweetalert2/**/*",
"node_modules/@homebridge/**/*",
"node_modules/pinokiod/server/public/**/*",
"node_modules/pinokiod/server/scripts/**/*",
"node_modules/toasted-notifier/vendor/mac.noindex/**/*"
],
"nsis": {
"include": "build/installer.nsh"
},
"extraResources": [
"./script/**",
{
"from": "assets/icon_small.png",
"to": "assets/icon_small.png"
}
],
"protocols": [
{
"name": "pinokio",
"schemes": [
"pinokio"
]
}
],
"mac": {
"category": "utility",
"target": [
{
"target": "default",
"arch": [
"x64",
"arm64"
]
}
],
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "Pinokio needs microphone access for apps that use audio.",
"NSCameraUsageDescription": "Pinokio needs camera access for apps that use video."
}
},
"dmg": {
"backgroundColor": "#ffffff",
"icon": null,
"window": {
"width": 520,
"height": 320
},
"iconSize": 120,
"contents": [
{
"type": "file",
"x": 160,
"y": 170
},
{
"type": "link",
"name": "Applications",
"path": "/Applications",
"x": 360,
"y": 170
}
]
},
"linux": {
"maintainer": "Cocktail Peanut ",
"target": [
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
{
"target": "rpm",
"arch": [
"x64",
"arm64"
]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
}
]
},
"win": {
"artifactName": "Pinokio.${ext}",
"signtoolOptions": {
"sign": "./build/sign.js"
},
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
}
},
"license": "MIT",
"dependencies": {
"electron-progressbar": "^2.2.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"pinokiod": "^7.0.0"
},
"devDependencies": {
"@electron/rebuild": "3.2.10",
"electron": "39.2.3",
"electron-builder": "26.2.0",
"electron-builder-notarize": "^1.5.2"
}
}
================================================
FILE: patch-linux-arm64-natives.js
================================================
const fs = require('fs')
const path = require('path')
const ARCH_BY_ENUM = {
0: 'ia32',
1: 'x64',
2: 'armv7l',
3: 'arm64',
4: 'universal'
}
const ELF_MACHINE_AARCH64 = 183
const resolveArch = (context) => {
if (typeof context.arch === 'string') {
return context.arch.toLowerCase()
}
if (typeof context.arch === 'number') {
const mapped = ARCH_BY_ENUM[context.arch]
if (mapped) {
return mapped
}
}
if (typeof context.appOutDir === 'string' && /arm64/i.test(context.appOutDir)) {
return 'arm64'
}
return ''
}
const ensureAarch64Elf = (filePath, label) => {
const data = fs.readFileSync(filePath)
if (data.length < 20) {
throw new Error(`[linux-arm64-native-fix] ${label} is too small to be an ELF binary: ${filePath}`)
}
if (!(data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46)) {
throw new Error(`[linux-arm64-native-fix] ${label} is not an ELF binary: ${filePath}`)
}
const isLittleEndian = data[5] !== 2
const machine = isLittleEndian ? data.readUInt16LE(18) : data.readUInt16BE(18)
if (machine !== ELF_MACHINE_AARCH64) {
throw new Error(`[linux-arm64-native-fix] ${label} is not aarch64 (e_machine=${machine}): ${filePath}`)
}
}
const copyWithValidation = (source, destination, label) => {
if (!fs.existsSync(source)) {
throw new Error(`[linux-arm64-native-fix] Missing ${label} source file: ${source}`)
}
ensureAarch64Elf(source, `${label} source`)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(source, destination)
ensureAarch64Elf(destination, `${label} destination`)
console.log(`[linux-arm64-native-fix] Patched ${label}: ${destination}`)
}
const existingDirectories = (candidates) => candidates.filter((candidate) => fs.existsSync(candidate))
module.exports = async (context) => {
if (context.electronPlatformName !== 'linux') {
return
}
const arch = resolveArch(context)
if (arch !== 'arm64') {
return
}
const unpackedRoot = path.join(context.appOutDir, 'resources', 'app.asar.unpacked')
const ptyBases = existingDirectories([
path.join(unpackedRoot, 'node_modules', '@homebridge', 'node-pty-prebuilt-multiarch'),
path.join(unpackedRoot, 'node_modules', 'pinokiod', 'node_modules', '@homebridge', 'node-pty-prebuilt-multiarch')
])
if (ptyBases.length === 0) {
throw new Error('[linux-arm64-native-fix] Could not find @homebridge/node-pty-prebuilt-multiarch in app.asar.unpacked')
}
for (const ptyBase of ptyBases) {
const source = path.join(ptyBase, 'prebuilds', 'linux-arm64', 'node.abi131.node')
const destination = path.join(ptyBase, 'build', 'Release', 'pty.node')
copyWithValidation(source, destination, 'node-pty')
}
const watcherBases = existingDirectories([
path.join(unpackedRoot, 'node_modules', '@parcel', 'watcher'),
path.join(unpackedRoot, 'node_modules', 'pinokiod', 'node_modules', '@parcel', 'watcher')
])
if (watcherBases.length === 0) {
throw new Error('[linux-arm64-native-fix] Could not find @parcel/watcher in app.asar.unpacked')
}
for (const watcherBase of watcherBases) {
const source = path.join(path.dirname(watcherBase), 'watcher-linux-arm64-glibc', 'watcher.node')
const destination = path.join(watcherBase, 'build', 'Release', 'watcher.node')
copyWithValidation(source, destination, 'parcel-watcher')
}
}
================================================
FILE: popup-shell.js
================================================
const path = require('path')
const windowStateKeeper = require('electron-window-state')
const { BrowserWindow, WebContentsView, session } = require('electron')
const parseUrl = (value, base) => {
try {
return new URL(value, base)
} catch (_) {
return null
}
}
const isHttpUrl = (value) => {
return Boolean(value && (value.protocol === 'http:' || value.protocol === 'https:'))
}
const getFeatureDimension = (params, key, fallback) => {
const value = parseInt(params.get(key), 10)
return Number.isFinite(value) ? value : fallback
}
module.exports = ({
contentPreloadPath = path.join(__dirname, 'preload.js'),
toolbarHtmlPath = path.join(__dirname, 'popup-toolbar.html'),
toolbarHeight = 46,
installForceDestroyOnClose
} = {}) => {
let openPinokioHomeWindow = null
const popupBrowserPartition = 'persist:pinokio-popup-browser'
const buildBrowserLikeUserAgent = () => {
const chromeVersion = process.versions.chrome || '140.0.0.0'
if (process.platform === 'darwin') {
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
}
if (process.platform === 'win32') {
const arch = process.arch === 'arm64' ? 'ARM64' : 'x64'
return `Mozilla/5.0 (Windows NT 10.0; Win64; ${arch}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
}
const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
return `Mozilla/5.0 (X11; Linux ${arch}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
}
const browserLikeUserAgent = buildBrowserLikeUserAgent()
const getPopupBrowserSession = () => {
const popupSession = session.fromPartition(popupBrowserPartition)
if (!popupSession.__pinokioPopupBrowserConfigured) {
popupSession.__pinokioPopupBrowserConfigured = true
popupSession.setUserAgent(browserLikeUserAgent, 'en-US,en')
}
return popupSession
}
const buildAppPopupContentWebPreferences = (overrides = {}) => {
const next = (overrides && typeof overrides === 'object') ? { ...overrides } : {}
return {
...next,
session: session.defaultSession,
webSecurity: false,
spellcheck: false,
nativeWindowOpen: true,
contextIsolation: false,
nodeIntegrationInSubFrames: true,
enableRemoteModule: false,
experimentalFeatures: true,
preload: contentPreloadPath
}
}
const buildBrowserPopupContentWebPreferences = (overrides = {}) => {
const next = (overrides && typeof overrides === 'object') ? { ...overrides } : {}
delete next.session
delete next.preload
delete next.partition
delete next.nodeIntegration
delete next.nodeIntegrationInSubFrames
delete next.contextIsolation
delete next.experimentalFeatures
delete next.webSecurity
return {
...next,
partition: popupBrowserPartition,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
nativeWindowOpen: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
enableRemoteModule: false,
experimentalFeatures: false
}
}
const unwrapContainerTarget = (target, rootParsed) => {
let next = target
while (next && next.pathname === '/container') {
const innerUrl = next.searchParams.get('url')
if (!innerUrl) {
break
}
const unwrapped = parseUrl(innerUrl, rootParsed ? rootParsed.origin : undefined)
if (!isHttpUrl(unwrapped) || unwrapped.href === next.href) {
break
}
next = unwrapped
}
return next
}
const isPinokioWindowUrl = (value, rootUrl) => {
const rootParsed = parseUrl(rootUrl)
const target = unwrapContainerTarget(
parseUrl(value, rootParsed ? rootParsed.origin : undefined),
rootParsed
)
if (!rootParsed || !isHttpUrl(target)) {
return false
}
return target.origin === rootParsed.origin
}
const resolveTargetUrl = ({ url, openerWebContents, rootUrl } = {}) => {
const openerUrl = (() => {
try {
return openerWebContents && !openerWebContents.isDestroyed()
? openerWebContents.getURL()
: (rootUrl || '')
} catch (_) {
return rootUrl || ''
}
})()
const target = parseUrl(url, openerUrl || (rootUrl || undefined))
return isHttpUrl(target) ? target.href : ''
}
const buildRegularWindowOptions = ({ x, y, width, height, overlay } = {}) => {
const options = {
x,
y,
width: width || 1000,
height: height || 800,
minWidth: 190,
parent: null,
titleBarStyle: 'hidden',
webPreferences: buildAppPopupContentWebPreferences()
}
if (overlay) {
options.titleBarOverlay = overlay
}
return options
}
const createRegularWindow = ({ x, y, width, height, overlay } = {}) => {
const win = new BrowserWindow(buildRegularWindowOptions({ x, y, width, height, overlay }))
installForceDestroyOnClose(win)
return win
}
const layoutPopupShell = (shellState) => {
if (!shellState || !shellState.win || shellState.win.isDestroyed()) {
return
}
const bounds = shellState.win.getContentBounds()
const width = Math.max(bounds.width || 0, 0)
const height = Math.max(bounds.height || 0, 0)
shellState.toolbarView.setBounds({
x: 0,
y: 0,
width,
height: toolbarHeight
})
shellState.contentView.setBounds({
x: 0,
y: toolbarHeight,
width,
height: Math.max(height - toolbarHeight, 0)
})
}
const buildPopupShellState = (shellState) => {
const target = shellState && shellState.contentView ? shellState.contentView.webContents : null
let url = ''
let title = ''
try {
if (target && !target.isDestroyed()) {
url = target.getURL() || ''
title = target.getTitle() || ''
}
} catch (_) {
}
return {
url,
title: title || url || 'Pinokio',
canGoBack: Boolean(target && !target.isDestroyed() && target.canGoBack()),
canGoForward: Boolean(target && !target.isDestroyed() && target.canGoForward())
}
}
const sendPopupShellState = (shellState) => {
if (!shellState || !shellState.toolbarView || !shellState.contentView) {
return
}
const toolbarContents = shellState.toolbarView.webContents
if (!toolbarContents || toolbarContents.isDestroyed()) {
return
}
const state = buildPopupShellState(shellState)
toolbarContents.send('pinokio:popup-shell-state', state)
if (shellState.win && !shellState.win.isDestroyed()) {
shellState.win.setTitle(state.title)
}
}
const createPopupShellWindow = ({
x,
y,
width,
height,
adoptedWebContents = null,
contentWebPreferences = {},
browserLike = false,
initialUrl = ''
} = {}) => {
const win = new BrowserWindow({
frame: true,
x,
y,
width: width || 1000,
height: height || 800,
minWidth: 190,
backgroundColor: '#ffffff'
})
win.__pinokioPopupShell = true
win.__pinokioCloseOnFirstDownload = Boolean(browserLike && initialUrl)
installForceDestroyOnClose(win)
const toolbarView = new WebContentsView({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
spellcheck: false,
backgroundThrottling: false
}
})
const contentView = adoptedWebContents
? new WebContentsView({ webContents: adoptedWebContents })
: new WebContentsView({
webPreferences: browserLike
? buildBrowserPopupContentWebPreferences(contentWebPreferences)
: buildAppPopupContentWebPreferences(contentWebPreferences)
})
const shellState = {
win,
toolbarView,
contentView
}
win.contentView.addChildView(contentView)
win.contentView.addChildView(toolbarView)
layoutPopupShell(shellState)
const syncShellState = () => {
layoutPopupShell(shellState)
sendPopupShellState(shellState)
}
const focusContent = () => {
if (contentView.webContents && !contentView.webContents.isDestroyed()) {
contentView.webContents.focus()
}
}
toolbarView.webContents.on('did-finish-load', () => {
sendPopupShellState(shellState)
})
toolbarView.webContents.on('ipc-message', (_event, channel) => {
const target = contentView.webContents
if (!target || target.isDestroyed()) {
return
}
if (channel === 'pinokio:popup-shell-back') {
if (target.canGoBack()) {
target.goBack()
}
return
}
if (channel === 'pinokio:popup-shell-forward') {
if (target.canGoForward()) {
target.goForward()
}
return
}
if (channel === 'pinokio:popup-shell-refresh') {
target.reload()
return
}
if (channel === 'pinokio:popup-shell-open-home') {
if (typeof openPinokioHomeWindow === 'function') {
openPinokioHomeWindow()
}
}
})
if (browserLike && contentView.webContents && !contentView.webContents.isDestroyed()) {
getPopupBrowserSession()
contentView.webContents.setUserAgent(browserLikeUserAgent)
}
contentView.webContents.on('did-finish-load', () => {
if (shellState.win && !shellState.win.isDestroyed()) {
shellState.win.__pinokioCloseOnFirstDownload = false
}
syncShellState()
focusContent()
})
contentView.webContents.on('did-navigate', syncShellState)
contentView.webContents.on('did-navigate-in-page', syncShellState)
contentView.webContents.on('page-title-updated', (event) => {
event.preventDefault()
sendPopupShellState(shellState)
})
win.on('focus', focusContent)
win.on('resize', syncShellState)
toolbarView.webContents.loadFile(toolbarHtmlPath).catch((error) => {
console.error('[pinokio][popup-shell] failed to load toolbar', error)
})
if (initialUrl) {
contentView.webContents.loadURL(initialUrl).catch((error) => {
console.error('[pinokio][popup-shell] failed to load content url', { initialUrl, error })
})
}
return shellState
}
const allowPermissions = (targetSession) => {
if (!targetSession) {
return
}
targetSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(true)
})
}
const createPopupWindowState = () => {
if (typeof windowStateKeeper !== 'function') {
return {
x: undefined,
y: undefined,
width: 1000,
height: 800,
manage: () => {}
}
}
return windowStateKeeper({
// file: "index.json",
defaultWidth: 1000,
defaultHeight: 800
})
}
const createPopupResponse = ({ params, width, height, x, y } = {}) => {
return {
action: 'allow',
outlivesOpener: true,
overrideBrowserWindowOptions: {
webPreferences: buildBrowserPopupContentWebPreferences()
},
createWindow: (options = {}) => {
const shellState = createPopupShellWindow({
width: getFeatureDimension(params, 'width', width),
height: getFeatureDimension(params, 'height', height),
x: x + 30,
y: y + 30,
adoptedWebContents: options.webContents || null,
contentWebPreferences: options.webPreferences || {},
browserLike: true
})
return shellState.contentView.webContents
}
}
}
const openExternalWindow = ({ url, windowState } = {}) => {
const nextWindowState = windowState || createPopupWindowState()
const shellState = createPopupShellWindow({
x: nextWindowState.x,
y: nextWindowState.y,
width: nextWindowState.width,
height: nextWindowState.height,
browserLike: true,
initialUrl: url
})
const win = shellState.win
allowPermissions(shellState.contentView.webContents.session)
win.focus()
nextWindowState.manage(win)
return win
}
return {
createPopupResponse,
getPopupBrowserSession,
isPinokioWindowUrl,
resolveTargetUrl,
openExternalWindow,
setPinokioHomeWindowOpener: (nextOpener) => {
openPinokioHomeWindow = typeof nextOpener === 'function' ? nextOpener : null
}
}
}
================================================
FILE: popup-toolbar.html
================================================
Popup Toolbar
================================================
FILE: preload.js
================================================
// put this preload for main-window to give it prompt()
const { ipcRenderer, } = require('electron')
window.prompt = function(title, val){
return ipcRenderer.sendSync('prompt', {title, val})
}
try {
} catch (_) {
}
const sendPinokio = (action) => {
if (!action) {
return
}
try {
if (window.parent === window.top) {
window.parent.postMessage({ action }, "*")
}
} catch (_) {
}
}
// Only apply frame bridge hooks inside embedded pages.
let isEmbeddedFrame = false
let isDirectChildFrame = false
try {
isEmbeddedFrame = window.parent !== window
isDirectChildFrame = isEmbeddedFrame && window.parent === window.top
} catch (_) {
isEmbeddedFrame = false
isDirectChildFrame = false
}
let previousFrameUrl = isEmbeddedFrame ? document.location.href : ''
const publishFrameLocation = () => {
if (!isEmbeddedFrame) {
return
}
const currentUrl = document.location.href
if (currentUrl === previousFrameUrl) {
return
}
previousFrameUrl = currentUrl
if (isDirectChildFrame) {
sendPinokio({
type: 'location',
url: currentUrl
})
}
syncPinokioInjectors('location').catch(() => {})
}
if (isEmbeddedFrame) {
if (isDirectChildFrame) {
sendPinokio({
type: 'location',
url: previousFrameUrl
})
}
const originalPushState = history.pushState
history.pushState = function pushStateWithPinokioLocation(...args) {
const result = originalPushState.apply(this, args)
publishFrameLocation()
return result
}
const originalReplaceState = history.replaceState
history.replaceState = function replaceStateWithPinokioLocation(...args) {
const result = originalReplaceState.apply(this, args)
publishFrameLocation()
return result
}
window.addEventListener('popstate', publishFrameLocation)
window.addEventListener('hashchange', publishFrameLocation)
window.addEventListener('beforeunload', () => {
resetPinokioInjectors('unload').catch(() => {})
}, { once: true })
window.addEventListener('message', (event) => {
if (event.data) {
if (event.data.action === 'back') {
history.back()
} else if (event.data.action === 'forward') {
history.forward()
} else if (event.data.action === 'refresh') {
location.reload()
}
}
})
}
//document.addEventListener("DOMContentLoaded", (e) => {
// if (window.parent === window.top) {
// window.parent.postMessage({
// action: {
// type: "title",
// text: document.title
// }
// }, "*")
// }
//})
window.electronAPI = {
send: (type, msg) => {
ipcRenderer.send(type, msg)
},
sendSync: (type, msg) => ipcRenderer.sendSync(type, msg),
requestPermissions: (payload) => ipcRenderer.invoke('pinokio:request-permissions', payload || {}),
startInspector: (payload) => ipcRenderer.invoke('pinokio:start-inspector', payload || {}),
stopInspector: () => ipcRenderer.invoke('pinokio:stop-inspector'),
captureScreenshot: (screenshotRequest) => {
return ipcRenderer.invoke('pinokio:capture-screenshot-debug', { screenshotRequest })
}
}
const resolvePinokioTargetWindow = () => {
try {
if (window.parent && window.parent !== window) {
return window.parent
}
} catch (_) {
}
try {
if (window.top && window.top !== window) {
return window.top
}
} catch (_) {
}
return window
}
const postPinokioEvent = (eventName, payload = {}, context = {}) => {
const target = resolvePinokioTargetWindow()
const nextContext = (context && typeof context === 'object') ? { ...context } : {}
if (!nextContext.frameUrl) {
nextContext.frameUrl = window.location.href
}
if (!nextContext.workspace) {
const workspaceHint = resolvePinokioWorkspaceHint()
if (workspaceHint) {
nextContext.workspace = workspaceHint
}
}
target.postMessage({
e: 'pinokio:event',
event: eventName,
payload: (payload && typeof payload === 'object') ? payload : {},
context: nextContext
}, '*')
}
const ensurePinokioApi = () => {
const api = (window.$pinokio && typeof window.$pinokio === 'object')
? window.$pinokio
: {}
api.trigger = (eventName, payload = {}, context = {}) => {
if (typeof eventName !== 'string' || !eventName.trim()) {
return { ok: false, handled: false, reason: 'invalid_event_name' }
}
const normalizedEvent = eventName.trim()
postPinokioEvent(
normalizedEvent,
(payload && typeof payload === 'object') ? payload : {},
(context && typeof context === 'object') ? context : {}
)
return { ok: true, handled: true, event: normalizedEvent }
}
window.$pinokio = api
return api
}
ensurePinokioApi()
const extractWorkspaceFromPathname = (pathname) => {
if (typeof pathname !== 'string') {
return ''
}
const value = pathname.trim()
if (!value) {
return ''
}
const patterns = [
/^\/pinokio\/([^/?#]+)/i,
/^\/p\/([^/?#]+)/i,
/^\/api\/([^/?#]+)/i,
/^\/_api\/([^/?#]+)/i,
/^\/raw\/api\/([^/?#]+)/i,
/^\/asset\/api\/([^/?#]+)/i,
/^\/files\/api\/([^/?#]+)/i,
/^\/env\/api\/([^/?#]+)/i,
/^\/run\/api\/([^/?#]+)/i,
]
for (const pattern of patterns) {
const match = value.match(pattern)
if (!match || !match[1]) {
continue
}
try {
return decodeURIComponent(match[1]).trim()
} catch (_) {
return String(match[1] || '').trim()
}
}
return ''
}
const resolvePinokioWorkspaceHint = () => {
const candidates = []
try {
const ref = (typeof document !== 'undefined' && document.referrer) ? document.referrer : ''
if (ref) {
candidates.push(ref)
}
} catch (_) {
}
try {
candidates.push(window.location.href)
} catch (_) {
}
for (const candidate of candidates) {
if (typeof candidate !== 'string' || !candidate.trim()) {
continue
}
try {
const parsed = new URL(candidate, 'http://localhost')
const workspaceQueryHint = (parsed.searchParams.get('__pinokio_workspace') || parsed.searchParams.get('workspace') || '').trim()
if (workspaceQueryHint) {
return workspaceQueryHint
}
const workspace = extractWorkspaceFromPathname(parsed.pathname || '')
if (workspace) {
return workspace
}
} catch (_) {
const workspace = extractWorkspaceFromPathname(candidate)
if (workspace) {
return workspace
}
}
}
return ''
}
let pinokioInjectSyncId = 0
const buildPinokioContext = (reason = 'load', responseContext = {}) => {
const currentUrl = window.location.href
const referrerUrl = (typeof document !== 'undefined' && document.referrer) ? document.referrer : ''
const workspaceHint = resolvePinokioWorkspaceHint()
const frameName = typeof window.name === 'string' ? window.name.trim() : ''
const rootFrameUrl = responseContext && typeof responseContext.frameUrl === 'string' && responseContext.frameUrl.trim()
? responseContext.frameUrl.trim()
: currentUrl
return {
frameUrl: currentUrl,
rootFrameUrl,
currentUrl,
pageUrl: referrerUrl || rootFrameUrl,
referrerUrl,
frameName: frameName || undefined,
workspace: workspaceHint || undefined,
reason
}
}
const waitForPinokioDocumentEnd = () => {
if (document.readyState === 'loading') {
return new Promise((resolve) => {
window.addEventListener('DOMContentLoaded', resolve, { once: true })
})
}
return Promise.resolve()
}
const waitForPinokioDocumentIdle = async () => {
await waitForPinokioDocumentEnd()
await new Promise((resolve) => {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(() => resolve(), { timeout: 120 })
return
}
setTimeout(resolve, 32)
})
}
const requestPinokioInjectDescriptors = (reason = 'load') => {
if (!isEmbeddedFrame) {
return Promise.resolve(null)
}
const context = buildPinokioContext(reason)
return ipcRenderer.invoke('pinokio:resolve-injectors', {
reason,
context
}).then((result) => result && typeof result === 'object' ? result : null).catch(() => null)
}
const resetPinokioInjectors = async (reason = 'sync', syncId = pinokioInjectSyncId) => {
if (!isEmbeddedFrame) {
return
}
try {
await ipcRenderer.invoke('pinokio:reset-injectors', {
syncId,
reason,
context: buildPinokioContext(reason)
})
} catch (error) {
try {
console.warn('[pinokio][preload] injector reset failed', {
reason,
error: error && error.message ? error.message : String(error)
})
} catch (_) {
}
}
}
const mountPinokioInjectGroup = async (syncId, descriptors, responseContext, reason) => {
if (!descriptors.length || syncId !== pinokioInjectSyncId) {
return
}
try {
await ipcRenderer.invoke('pinokio:mount-injectors', {
syncId,
reason,
inject: descriptors,
context: buildPinokioContext(reason, responseContext)
})
} catch (error) {
try {
console.warn('[pinokio][preload] injector mount failed', {
reason,
descriptors: descriptors.map((item) => item && item.src).filter(Boolean),
error: error && error.message ? error.message : String(error)
})
} catch (_) {
}
}
}
async function syncPinokioInjectors(reason = 'load') {
if (!isEmbeddedFrame) {
return
}
const syncId = ++pinokioInjectSyncId
const response = await requestPinokioInjectDescriptors(reason)
if (syncId !== pinokioInjectSyncId) {
return
}
const descriptors = Array.isArray(response && response.inject) ? response.inject : []
const groups = {
start: [],
end: [],
idle: []
}
for (const descriptor of descriptors) {
const when = descriptor && (descriptor.when === 'start' || descriptor.when === 'end')
? descriptor.when
: 'idle'
groups[when].push(descriptor)
}
await resetPinokioInjectors(reason, syncId)
if (syncId !== pinokioInjectSyncId) {
return
}
await mountPinokioInjectGroup(syncId, groups.start, response && response.context, reason)
await waitForPinokioDocumentEnd()
await mountPinokioInjectGroup(syncId, groups.end, response && response.context, reason)
await waitForPinokioDocumentIdle()
await mountPinokioInjectGroup(syncId, groups.idle, response && response.context, reason)
}
if (isEmbeddedFrame) {
syncPinokioInjectors('load').catch(() => {})
}
;(function initUpdateBanner() {
if (typeof document === 'undefined') {
return
}
if (window !== window.top) {
return
}
const BANNER_HEIGHT = 72
const state = {
payload: null,
banner: null,
style: null,
layoutActive: false,
layoutTick: null,
ready: false
}
const ensureStyle = () => {
if (state.style) {
return
}
const style = document.createElement('style')
style.id = 'pinokio-update-banner-style'
style.textContent = `
body.pinokio-update-banner-active[data-pinokio-update-layout-root="1"] #layout-root {
height: calc(100% - var(--layout-dragger-height, 0px) - var(--pinokio-update-banner-height, 0px));
}
body.pinokio-update-banner-active:not([data-pinokio-update-layout-root="1"]) {
padding-bottom: var(--pinokio-update-banner-height, 0px) !important;
}
#pinokio-update-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--pinokio-update-banner-height, 72px);
display: none;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px 12px;
box-sizing: border-box;
background: linear-gradient(90deg, rgba(24, 24, 30, 0.94), rgba(30, 30, 38, 0.98));
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 -12px 26px rgba(0, 0, 0, 0.35);
z-index: 2147483646;
color: #f5f5f7;
font-family: "SF Pro Text", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
border-radius: 0;
}
#pinokio-update-banner .pinokio-update-left {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
#pinokio-update-banner .pinokio-update-title {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.1px;
}
#pinokio-update-banner .pinokio-update-title.danger {
color: #ff7b72;
}
#pinokio-update-banner .pinokio-update-details {
font-size: 12px;
color: rgba(255, 255, 255, 0.68);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 520px;
}
#pinokio-update-banner .pinokio-update-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#pinokio-update-banner button {
appearance: none;
border: 1px solid transparent;
border-radius: 0;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
#pinokio-update-banner button:disabled {
opacity: 0.6;
cursor: default;
}
#pinokio-update-banner .pinokio-update-primary {
color: #1b1b1f;
background: #f6c251;
}
#pinokio-update-banner .pinokio-update-primary:hover:not(:disabled) {
background: #ffcc66;
transform: translateY(-1px);
}
#pinokio-update-banner .pinokio-update-ghost {
color: #f5f5f7;
background: transparent;
border-color: rgba(255, 255, 255, 0.18);
}
#pinokio-update-banner .pinokio-update-ghost:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.35);
}
#pinokio-update-banner .pinokio-update-progress {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.15);
}
#pinokio-update-banner .pinokio-update-progress-bar {
height: 100%;
width: 0%;
background: #f6c251;
transition: width 150ms ease;
}
#pinokio-update-banner .pinokio-update-hidden {
display: none !important;
}
`
const target = document.head || document.documentElement
target.appendChild(style)
state.style = style
}
const ensureBanner = () => {
if (state.banner) {
return state.banner
}
if (!document.body) {
return null
}
ensureStyle()
const container = document.createElement('div')
container.id = 'pinokio-update-banner'
container.innerHTML = `
`
container.addEventListener('click', (event) => {
const button = event.target.closest('button')
if (!button) {
return
}
const action = button.getAttribute('data-action')
if (!action) {
return
}
if (action === 'release-notes' && state.payload && state.payload.releaseUrl) {
ipcRenderer.send('pinokio:update-banner-action', { action, releaseUrl: state.payload.releaseUrl })
return
}
ipcRenderer.send('pinokio:update-banner-action', { action })
})
document.body.appendChild(container)
state.banner = container
return container
}
const notifyLayoutResize = () => {
if (state.layoutTick) {
cancelAnimationFrame(state.layoutTick)
}
state.layoutTick = requestAnimationFrame(() => {
state.layoutTick = null
try {
window.dispatchEvent(new CustomEvent('pinokio:viewport-change', {
detail: { height: window.innerHeight }
}))
} catch (_) {
window.dispatchEvent(new Event('resize'))
}
})
}
const applyLayoutOffset = (active) => {
if (!document.body) {
return
}
const hasLayoutRoot = Boolean(document.getElementById('layout-root'))
if (hasLayoutRoot) {
document.body.setAttribute('data-pinokio-update-layout-root', '1')
} else {
document.body.removeAttribute('data-pinokio-update-layout-root')
}
document.documentElement.style.setProperty('--pinokio-update-banner-height', `${BANNER_HEIGHT}px`)
document.body.classList.toggle('pinokio-update-banner-active', Boolean(active))
if (state.layoutActive !== Boolean(active)) {
state.layoutActive = Boolean(active)
notifyLayoutResize()
}
}
const setHidden = (node, hidden) => {
if (!node) return
node.classList.toggle('pinokio-update-hidden', Boolean(hidden))
}
const render = (payload) => {
state.payload = payload
if (!payload || payload.state === 'hidden') {
if (state.banner) {
state.banner.style.display = 'none'
}
applyLayoutOffset(false)
return
}
const banner = ensureBanner()
if (!banner) {
return
}
banner.style.display = 'flex'
applyLayoutOffset(true)
const title = banner.querySelector('.pinokio-update-title')
const details = banner.querySelector('.pinokio-update-details')
const updateNow = banner.querySelector('[data-action="update"]')
const restartNow = banner.querySelector('[data-action="restart"]')
const releaseNotes = banner.querySelector('[data-action="release-notes"]')
const progress = banner.querySelector('.pinokio-update-progress')
const progressBar = banner.querySelector('.pinokio-update-progress-bar')
const stateKey = payload.state || 'available'
const version = payload.version ? `Version ${payload.version}` : ''
const notes = payload.notesPreview || ''
const detail = [version, notes].filter(Boolean).join(' - ')
let titleText = 'Update available'
if (stateKey === 'downloading') titleText = 'Downloading update'
if (stateKey === 'ready') titleText = 'Update ready'
if (stateKey === 'error') titleText = 'Update failed'
if (title) {
title.textContent = titleText
if (stateKey === 'error') {
title.classList.add('danger')
} else {
title.classList.remove('danger')
}
}
if (details) {
details.textContent = detail
}
if (updateNow) {
updateNow.textContent = stateKey === 'error' ? 'Retry' : 'Update now'
updateNow.disabled = stateKey === 'downloading'
}
setHidden(updateNow, stateKey === 'ready')
setHidden(restartNow, stateKey !== 'ready')
setHidden(progress, stateKey !== 'downloading')
setHidden(releaseNotes, !payload.releaseUrl)
if (progressBar) {
if (stateKey === 'downloading' && typeof payload.progressPercent === 'number') {
const percent = Math.max(0, Math.min(100, payload.progressPercent))
progressBar.style.width = `${percent}%`
} else {
progressBar.style.width = '0%'
}
}
}
ipcRenderer.on('pinokio:update-banner', (_event, payload) => {
render(payload)
})
const ready = () => {
state.ready = true
if (state.payload) {
render(state.payload)
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready, { once: true })
} else {
ready()
}
})()
;(function initInspector() {
if (typeof document === 'undefined') {
return
}
const log = (message) => {
try {
console.log(`[Inspector] ${message}`)
} catch (_) {
// ignore
}
}
const state = {
active: false,
button: null,
lastFrameOrdinal: null,
lastRelativeOrdinal: null,
lastUrl: null,
lastDomPath: null,
displayUrl: null,
overlay: null,
instructionsVisible: false,
closing: false,
}
const normalizeUrl = (value) => {
if (!value) {
return null
}
try {
return new URL(value, window.location.href).toString()
} catch (err) {
return value
}
}
const findIframeCandidates = () => {
const list = Array.from(document.querySelectorAll('iframe')).map((iframe, index) => {
const style = window.getComputedStyle ? window.getComputedStyle(iframe) : null
const rect = iframe.getBoundingClientRect()
const area = rect ? Math.max(rect.width, 0) * Math.max(rect.height, 0) : 0
const visible = Boolean(
rect &&
rect.width > 2 &&
rect.height > 2 &&
!iframe.classList.contains('hidden') &&
!iframe.hasAttribute('hidden') &&
(!style || (style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0))
)
return {
element: iframe,
index,
rect,
area,
visible,
src: normalizeUrl(iframe.getAttribute('src') || iframe.src || ''),
}
})
return list
}
const selectVisibleIframe = () => {
const candidates = findIframeCandidates()
if (!candidates.length) {
log('no iframe candidates discovered')
return null
}
candidates.slice(0, 3).forEach((candidate) => {
const rect = candidate.rect || { width: 0, height: 0 }
log(`candidate[${candidate.index}] src=${candidate.src || ''} visible=${candidate.visible ? 'yes' : 'no'} size=${Math.round(rect.width)}x${Math.round(rect.height)}`)
})
const visible = candidates.filter((candidate) => candidate.visible)
const ranked = (visible.length ? visible : candidates).slice().sort((a, b) => {
if (b.area === a.area) {
return (b.rect ? b.rect.width : 0) - (a.rect ? a.rect.width : 0)
}
return b.area - a.area
})
const chosen = ranked[0]
if (!chosen) {
log('no suitable iframe found after ranking')
return null
}
log(`selected iframe src=${chosen.src || ''} visible=${chosen.visible ? 'yes' : 'no'} size=${Math.round(chosen.rect?.width || 0)}x${Math.round(chosen.rect?.height || 0)}`)
const siblingsWithSameUrl = candidates.filter((candidate) => candidate.src === chosen.src)
const relativeOrdinal = siblingsWithSameUrl.indexOf(chosen)
return {
element: chosen.element,
index: chosen.index,
relativeOrdinal: relativeOrdinal >= 0 ? relativeOrdinal : null,
url: chosen.src,
}
}
const ensureOverlay = () => {
if (state.overlay && state.overlay.container && document.body.contains(state.overlay.container)) {
return state.overlay
}
// Remove stale overlays left over from previous script executions
const orphaned = Array.from(document.querySelectorAll('.pinokio-inspector-overlay'))
for (const node of orphaned) {
if (!state.overlay || node !== state.overlay.container) {
node.remove()
}
}
const container = document.createElement('div')
container.className = 'pinokio-inspector-overlay'
Object.assign(container.style, {
position: 'fixed',
right: '16px',
bottom: '16px',
maxWidth: 'min(420px, 92vw)',
maxHeight: '70vh',
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(9, 12, 20, 0.92)',
color: '#fefefe',
boxShadow: '0 20px 42px rgba(0,0,0,0.45)',
display: 'none',
flexDirection: 'column',
gap: '8px',
zIndex: '2147483646',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontSize: '12px',
})
const header = document.createElement('div')
Object.assign(header.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
})
const title = document.createElement('strong')
title.textContent = 'Inspect Mode'
Object.assign(title.style, {
fontSize: '12px',
letterSpacing: '0.06em',
textTransform: 'uppercase',
})
const closeButton = document.createElement('button')
closeButton.type = 'button'
closeButton.textContent = '×'
closeButton.dataset.role = 'close'
Object.assign(closeButton.style, {
background: 'transparent',
border: 'none',
color: '#fefefe',
fontSize: '18px',
lineHeight: '1',
cursor: 'pointer',
})
header.append(title, closeButton)
const status = document.createElement('div')
status.dataset.role = 'status'
status.style.color = '#ccd5ff'
const urlRow = document.createElement('div')
urlRow.dataset.role = 'url'
Object.assign(urlRow.style, {
color: '#9aa7c2',
fontSize: '11px',
wordBreak: 'break-all',
})
const htmlSection = document.createElement('div')
htmlSection.dataset.role = 'html-container'
Object.assign(htmlSection.style, {
display: 'none',
margin: '8px 0',
padding: '8px',
borderRadius: '8px',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
})
const htmlHeader = document.createElement('div')
Object.assign(htmlHeader.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '6px',
})
const htmlLabel = document.createElement('div')
htmlLabel.textContent = 'Element Snippet'
Object.assign(htmlLabel.style, {
fontSize: '11px',
color: '#9aa7c2',
textTransform: 'uppercase',
letterSpacing: '0.06em',
})
const buttonBaseStyle = {
display: 'none',
background: 'rgba(77,163,255,0.2)',
border: '1px solid rgba(77,163,255,0.4)',
borderRadius: '6px',
padding: '4px 12px',
fontSize: '11px',
cursor: 'pointer',
color: '#ccd5ff',
fontWeight: '600',
}
const copyButton = document.createElement('button')
copyButton.dataset.role = 'copy'
copyButton.type = 'button'
copyButton.textContent = 'Copy snippet'
Object.assign(copyButton.style, buttonBaseStyle)
htmlHeader.append(htmlLabel, copyButton)
const htmlBlock = document.createElement('textarea')
htmlBlock.dataset.role = 'html'
Object.assign(htmlBlock.style, {
margin: '0',
padding: '10px',
maxHeight: '28vh',
overflow: 'auto',
borderRadius: '8px',
background: 'rgba(255,255,255,0.08)',
display: 'none',
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace",
fontSize: '11px',
border: '1px solid rgba(255,255,255,0.18)',
color: '#fefefe',
resize: 'vertical',
minHeight: '140px',
width: '100%',
boxSizing: 'border-box',
})
htmlBlock.spellcheck = false
htmlSection.append(htmlHeader, htmlBlock)
const screenshotBlock = document.createElement('div')
screenshotBlock.dataset.role = 'screenshot-container'
Object.assign(screenshotBlock.style, {
margin: '8px 0',
padding: '8px',
borderRadius: '8px',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.12)',
display: 'none',
textAlign: 'center',
})
const screenshotHeader = document.createElement('div')
Object.assign(screenshotHeader.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '8px',
})
const screenshotImg = document.createElement('img')
screenshotImg.dataset.role = 'screenshot'
Object.assign(screenshotImg.style, {
maxWidth: '100%',
maxHeight: '200px',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
})
const screenshotLabel = document.createElement('div')
screenshotLabel.textContent = 'Element Screenshot'
Object.assign(screenshotLabel.style, {
fontSize: '11px',
color: '#9aa7c2',
textTransform: 'uppercase',
letterSpacing: '0.06em',
})
const copyScreenshotButton = document.createElement('button')
copyScreenshotButton.dataset.role = 'copy-screenshot'
copyScreenshotButton.type = 'button'
copyScreenshotButton.textContent = 'Copy screenshot'
Object.assign(copyScreenshotButton.style, buttonBaseStyle)
screenshotHeader.append(screenshotLabel, copyScreenshotButton)
screenshotBlock.append(screenshotHeader, screenshotImg)
container.append(header, status, urlRow, htmlSection, screenshotBlock)
document.body.appendChild(container)
const overlay = {
container,
status,
urlRow,
htmlSection,
htmlBlock,
screenshotBlock,
screenshotImg,
copyButton,
copyScreenshotButton,
closeButton,
}
closeButton.addEventListener('click', () => {
stopInspector()
})
copyButton.addEventListener('click', async () => {
const text = overlay.htmlBlock.value || ''
if (!text) {
return
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
overlay.copyButton.textContent = 'Copied'
setTimeout(() => {
overlay.copyButton.textContent = 'Copy snippet'
}, 1500)
} catch (err) {
overlay.copyButton.textContent = 'Copy failed'
setTimeout(() => {
overlay.copyButton.textContent = 'Copy snippet'
}, 1500)
}
})
copyScreenshotButton.addEventListener('click', async () => {
const img = overlay.screenshotImg
if (!img.src) {
return
}
try {
// Convert data URL to blob
const response = await fetch(img.src)
const blob = await response.blob()
if (navigator.clipboard && navigator.clipboard.write) {
const clipboardItem = new ClipboardItem({ 'image/png': blob })
await navigator.clipboard.write([clipboardItem])
} else {
throw new Error('Clipboard API not available')
}
overlay.copyScreenshotButton.textContent = 'Copied'
setTimeout(() => {
overlay.copyScreenshotButton.textContent = 'Copy screenshot'
}, 1500)
} catch (err) {
console.warn('Screenshot copy failed:', err)
overlay.copyScreenshotButton.textContent = 'Copy failed'
setTimeout(() => {
overlay.copyScreenshotButton.textContent = 'Copy screenshot'
}, 1500)
}
})
state.overlay = overlay
return overlay
}
const showOverlay = (message, frameUrl, html, screenshot) => {
const overlay = ensureOverlay()
if (overlay.container.parentNode) {
overlay.container.parentNode.appendChild(overlay.container)
}
for (const node of document.querySelectorAll('.pinokio-inspector-overlay')) {
if (node !== overlay.container) {
node.style.display = 'none'
}
}
overlay.container.style.display = 'flex'
overlay.status.textContent = message || ''
overlay.urlRow.textContent = frameUrl ? `Page: ${frameUrl}` : ''
state.displayUrl = frameUrl || null
// Handle HTML content
if (html) {
const pageUrl = frameUrl || state.displayUrl || state.lastUrl || ''
const domPath = state.lastDomPath || ''
const lines = []
if (pageUrl) {
lines.push(`Page: ${pageUrl}`)
}
if (domPath) {
lines.push(`DOM: ${domPath}`)
}
lines.push(`HTML: ${html}`)
overlay.htmlSection.style.display = 'block'
overlay.htmlBlock.style.display = 'block'
overlay.htmlBlock.value = lines.join('\n')
overlay.copyButton.style.display = 'inline-flex'
overlay.copyButton.textContent = 'Copy snippet'
} else {
overlay.htmlSection.style.display = 'none'
overlay.htmlBlock.style.display = 'none'
overlay.htmlBlock.value = ''
overlay.copyButton.style.display = 'none'
}
// Handle screenshot content
if (screenshot) {
overlay.screenshotImg.src = screenshot
overlay.screenshotBlock.style.display = 'block'
overlay.copyScreenshotButton.style.display = 'inline-flex'
overlay.copyScreenshotButton.textContent = 'Copy screenshot'
} else {
overlay.screenshotImg.src = ''
overlay.screenshotBlock.style.display = 'none'
overlay.copyScreenshotButton.style.display = 'none'
}
}
const hideOverlay = () => {
const overlay = state.overlay
if (overlay && overlay.container) {
overlay.container.style.display = 'none'
overlay.status.textContent = ''
overlay.urlRow.textContent = ''
overlay.htmlBlock.value = ''
overlay.htmlSection.style.display = 'none'
overlay.htmlBlock.style.display = 'none'
overlay.copyButton.style.display = 'none'
overlay.screenshotImg.src = ''
overlay.screenshotBlock.style.display = 'none'
overlay.copyScreenshotButton.style.display = 'none'
}
state.instructionsVisible = false
state.closing = false
state.displayUrl = null
}
const startInspector = async (button) => {
if (state.active) {
log('inspector already active')
return
}
const target = selectVisibleIframe()
if (!target) {
showOverlay('No visible iframe found to inspect.', '', null)
log('startInspector aborted: no target iframe')
return
}
hideOverlay()
state.active = true
state.button = button || null
state.lastFrameOrdinal = target.index
state.lastRelativeOrdinal = target.relativeOrdinal
state.lastUrl = target.url
state.lastDomPath = null
if (state.button) {
state.button.classList.add('inspector-active')
state.button.setAttribute('aria-pressed', 'true')
}
showOverlay('Inspect mode enabled – hover items and click to capture.', target.url || '', null)
try {
await window.electronAPI.startInspector({
frameIndex: target.index,
frameUrl: target.url,
candidateOrdinal: target.index,
candidateRelativeOrdinal: target.relativeOrdinal,
})
log('startInspector IPC resolved')
} catch (error) {
const message = error && error.message ? error.message : 'Unable to start inspect mode.'
showOverlay(message, target.url || '', null)
log(`startInspector IPC error: ${message}`)
resetState()
}
}
const stopInspector = () => {
if (!state.active) {
hideOverlay()
return
}
window.electronAPI.stopInspector().catch(() => {})
resetState()
hideOverlay()
}
const resetState = () => {
state.active = false
state.lastFrameOrdinal = null
state.lastRelativeOrdinal = null
state.lastUrl = null
state.lastDomPath = null
if (state.button) {
state.button.classList.remove('inspector-active')
state.button.removeAttribute('aria-pressed')
state.button = null
}
}
const handleToggleClick = (event) => {
const button = event.target.closest('button')
if (!button) {
return
}
const isTrigger = (
button.id === 'inspector' ||
button.hasAttribute('data-pinokio-inspector') ||
button.classList.contains('pinokio-inspector-button') ||
(button.dataset && button.dataset.tippyContent === 'Switch to inspect mode')
)
if (!isTrigger) {
return
}
event.preventDefault()
event.stopPropagation()
if (state.active) {
stopInspector()
} else {
startInspector(button)
}
}
const handleInspectorMessage = (event) => {
const data = event && event.data && event.data.pinokioInspector
if (!data) {
return
}
const frameUrl = typeof data.frameUrl === 'string' ? data.frameUrl : state.lastUrl
if (data.type === 'started') {
showOverlay('Inspect mode enabled – hover items and click to capture.', frameUrl || '', null)
return
}
if (data.type === 'update') {
const label = data.nodeName ? `<${String(data.nodeName).toLowerCase()}>` : ''
if (Array.isArray(data.pathKeys) && data.pathKeys.length) {
state.lastDomPath = data.pathKeys.join(' > ')
}
showOverlay(label ? `Hovering ${label}` : 'Inspect mode enabled – hover items and click to capture.', frameUrl || '', null)
return
}
if (data.type === 'complete') {
const html = typeof data.outerHTML === 'string' ? data.outerHTML : ''
const screenshot = typeof data.screenshot === 'string' ? data.screenshot : null
if (Array.isArray(data.pathKeys) && data.pathKeys.length) {
state.lastDomPath = data.pathKeys.join(' > ')
}
showOverlay('Element captured. Inspect again or close.', frameUrl || '', html, screenshot)
state.closing = true
window.electronAPI.stopInspector().catch(() => {}).finally(() => {
state.closing = false
})
resetState()
return
}
if (data.type === 'cancelled') {
window.electronAPI.stopInspector().catch(() => {})
resetState()
hideOverlay()
return
}
if (data.type === 'error') {
const message = data.message || 'Failed to inspect element.'
if (Array.isArray(data.pathKeys) && data.pathKeys.length) {
state.lastDomPath = data.pathKeys.join(' > ')
}
showOverlay(message, frameUrl || '', null)
window.electronAPI.stopInspector().catch(() => {})
resetState()
return
}
}
ipcRenderer.on('pinokio:inspector-cancelled', () => {
if (state.closing) {
state.closing = false
return
}
resetState()
hideOverlay()
})
ipcRenderer.on('pinokio:inspector-error', (_event, payload) => {
const message = payload && payload.message ? payload.message : 'Inspect mode ended.'
hideOverlay()
showOverlay(message, payload && payload.frameUrl ? payload.frameUrl : '', null)
resetState()
})
ipcRenderer.on('pinokio:inspector-started', (_event, payload) => {
const url = payload && payload.frameUrl ? payload.frameUrl : state.lastUrl
showOverlay('Inspect mode enabled – hover items and click to capture.', url || '', null)
})
ipcRenderer.on('pinokio:capture-debug-log', (_event, payload) => {
try {
const serialized = JSON.stringify(payload)
console.log('[Pinokio Capture]', serialized)
} catch (error) {
console.log('[Pinokio Capture]', payload)
}
})
const logCaptureEvent = (label, payload) => {
try {
console.log('[Pinokio Capture]', JSON.stringify({ label, payload }))
} catch (error) {
console.log('[Pinokio Capture]', label)
}
}
const processScreenshotRequest = async (screenshotRequest, messageId, source) => {
logCaptureEvent('renderer-process-start', {
messageId,
relayStage: screenshotRequest && screenshotRequest.__pinokioRelayStage,
relayComplete: screenshotRequest && screenshotRequest.__pinokioRelayComplete,
adjustedFlag: screenshotRequest && screenshotRequest.__pinokioAdjusted,
bounds: screenshotRequest && screenshotRequest.bounds ? screenshotRequest.bounds : null
})
try {
const screenshot = await window.electronAPI.captureScreenshot(screenshotRequest)
source.postMessage({
pinokioScreenshotResponse: true,
messageId: messageId,
success: true,
screenshot: screenshot
}, '*')
} catch (error) {
console.error('Screenshot capture failed:', error)
source.postMessage({
pinokioScreenshotResponse: true,
messageId,
success: false,
error: error.message || 'Screenshot failed'
}, '*')
}
}
// Handle screenshot requests from iframes
const handleScreenshotMessage = async (event) => {
if (event.data && event.data.pinokioScreenshotRequest) {
if (window !== window.top) {
logCaptureEvent('renderer-ignored-non-top', {
currentHref: window.location.href
})
return
}
const screenshotRequest = event.data.pinokioScreenshotRequest
const messageId = event.data.messageId
const source = event.source
logCaptureEvent('renderer-message-received', {
messageId,
relayStage: screenshotRequest.__pinokioRelayStage,
relayComplete: screenshotRequest.__pinokioRelayComplete,
adjustedFlag: screenshotRequest.__pinokioAdjusted,
bounds: screenshotRequest && screenshotRequest.bounds ? screenshotRequest.bounds : null
})
logCaptureEvent('renderer-skip-delegated', {
messageId
})
return
}
}
window.addEventListener('message', handleInspectorMessage)
window.addEventListener('message', handleScreenshotMessage)
window.addEventListener('message', (event) => {
if (!event || !event.data || event.source === window) {
return
}
if (event.data.e !== 'pinokio-start-inspector') {
return
}
try {
console.log('[Inspector] start-request ' + JSON.stringify({
url: event.data.frameUrl || null,
name: event.data.frameName || null,
nodeId: event.data.frameNodeId || null,
active: state.active,
}))
} catch (_) {}
const payload = {}
if (typeof event.data.frameUrl === 'string' && event.data.frameUrl.trim()) {
payload.frameUrl = event.data.frameUrl.trim()
}
if (typeof event.data.frameName === 'string' && event.data.frameName.trim()) {
payload.frameName = event.data.frameName.trim()
}
if (typeof event.data.frameNodeId === 'string' && event.data.frameNodeId.trim()) {
payload.frameNodeId = event.data.frameNodeId.trim()
}
if (!payload.frameUrl && !payload.frameName && !payload.frameNodeId) {
return
}
if (state.active) {
try {
console.log('[Inspector] stopping-current-before-start')
} catch (_) {}
stopInspector()
}
hideOverlay()
state.active = true
state.button = null
state.lastFrameOrdinal = null
state.lastRelativeOrdinal = null
state.lastUrl = payload.frameUrl || null
state.lastDomPath = null
showOverlay('Inspect mode enabled – hover items and click to capture.', payload.frameUrl || '', null)
window.electronAPI.startInspector(payload).then(() => {
try {
console.log('[Inspector] ipc-start-success ' + JSON.stringify(payload))
} catch (_) {}
}).catch((error) => {
const message = error && error.message ? error.message : 'Unable to start inspect mode.'
showOverlay(message, payload.frameUrl || '', null)
try {
console.log('[Inspector] ipc-start-error ' + JSON.stringify({ message }))
} catch (_) {}
resetState()
})
})
document.addEventListener('click', handleToggleClick, true)
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && state.active) {
stopInspector()
}
})
})()
================================================
FILE: prompt.html
================================================
================================================
FILE: script/patch.command
================================================
sudo -s xattr -d com.apple.quarantine /Applications/Pinokio.app
================================================
FILE: script/run-update-banner-test.js
================================================
const { spawn } = require('child_process')
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const child = spawn(cmd, ['run', 'start'], {
stdio: 'inherit',
env: {
...process.env,
PINOKIO_TEST_UPDATE_BANNER: '1'
}
})
child.on('exit', (code) => {
process.exit(code || 0)
})
================================================
FILE: script/zip.js
================================================
const { exec } = require('child_process');
const path = require('path')
const fs = require('fs')
const version = process.env.npm_package_version
// Windows
let exePath = path.resolve(__dirname, `../dist/Pinokio Setup ${version}.exe`)
let zipPath = path.resolve(__dirname, `../dist/Pinokio-${version}-win32.zip`)
exec(`zip -j "${zipPath}" "${exePath}"`, (error, stdout, stderr) => {
if (error) {
console.error(`Error executing command: ${error}`);
return;
}
console.log('Command executed successfully.');
console.log('stdout:', stdout);
console.log('stderr:', stderr);
});
// Mac
// find dmg files
const macPaths = [{
dmg: path.resolve(__dirname, `../dist/Pinokio-${version}-arm64.dmg`),
//temp: path.resolve(__dirname, `../dist/Pinokio-${version}-darwin-arm64-temp`),
temp: `Pinokio-${version}-darwin-arm64`,
//zip: path.resolve(__dirname, `../dist/Pinokio-${version}-darwin-arm64.zip`),
zip: `Pinokio-${version}-darwin-arm64.zip`
}, {
dmg: path.resolve(__dirname, `../dist/Pinokio-${version}.dmg`),
//temp: path.resolve(__dirname, `../dist/Pinokio-${version}-darwin-intel-temp`),
temp: `Pinokio-${version}-darwin-intel`,
//zip: path.resolve(__dirname, `../dist/Pinokio-${version}-darwin-intel.zip`)
zip: `Pinokio-${version}-darwin-intel.zip`
}]
let sentinelPath = path.resolve(__dirname, `../assets/Sentinel.app`)
for(let macPath of macPaths) {
const zipPath = macPath.zip
try {
console.log("mkdirSync", path.resolve(__dirname, "../dist", macPath.temp))
fs.mkdirSync(path.resolve(__dirname, "../dist", macPath.temp), { recursive: true })
} catch (e) {
console.log("E1", e)
}
try {
fs.cpSync(macPath.dmg, path.resolve(__dirname, "../dist", macPath.temp, "install.dmg"), { force: true, recursive: true })
} catch (e) {
console.log("E2", e)
}
try {
fs.cpSync(sentinelPath, path.resolve(__dirname, "../dist", macPath.temp, "Sentinel.app"), { force: true, recursive: true })
} catch (e) {
console.log("E3", e)
}
const cmd = `zip -r "${zipPath}" "${macPath.temp}"`
console.log({ cmd })
exec(cmd, { cwd: path.resolve(__dirname, "../dist") }, (error, stdout, stderr) => {
if (error) {
console.error(`Error executing command: ${error}`);
return;
}
console.log('Command executed successfully.');
console.log('stdout:', stdout);
console.log('stderr:', stderr);
});
// try {
// fs.rmSync(path.resolve(__dirname, "../dist", macPath.temp), { recursive: true })
// } catch (e) {
// }
}
let rmFiles = [
`Pinokio-${version}-arm64-mac.zip`,
`Pinokio-${version}-mac.zip`,
// `Pinokio-${version}-darwin-arm64`,
// `Pinokio-${version}-darwin-intel`,
]
for(let f of rmFiles) {
try {
fs.rmSync(path.resolve(__dirname, "../dist", f), { recursive: true })
} catch (e) {
}
}
================================================
FILE: splash.html
================================================
Starting Pinokio…
This should only take a moment.
================================================
FILE: temp/rebuild.js
================================================
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.rebuild = exports.Rebuilder = void 0;
const debug_1 = __importDefault(require("debug"));
const events_1 = require("events");
const fs = __importStar(require("fs-extra"));
const nodeAbi = __importStar(require("node-abi"));
const os = __importStar(require("os"));
const path = __importStar(require("path"));
const cache_1 = require("./cache");
const types_1 = require("./types");
const module_rebuilder_1 = require("./module-rebuilder");
const module_walker_1 = require("./module-walker");
const d = (0, debug_1.default)('electron-rebuild');
const defaultMode = 'sequential';
const defaultTypes = ['prod', 'optional'];
class Rebuilder {
constructor(options) {
console.log("options", options)
var _a;
this.platform = options.platform || process.platform;
this.lifecycle = options.lifecycle;
this.buildPath = options.buildPath;
this.electronVersion = options.electronVersion;
this.arch = options.arch || process.arch;
this.force = options.force || false;
this.headerURL = options.headerURL || 'https://www.electronjs.org/headers';
this.mode = options.mode || defaultMode;
this.debug = options.debug || false;
this.useCache = options.useCache || false;
this.useElectronClang = options.useElectronClang || false;
this.cachePath = options.cachePath || path.resolve(os.homedir(), '.electron-rebuild-cache');
this.prebuildTagPrefix = options.prebuildTagPrefix || 'v';
this.msvsVersion = process.env.GYP_MSVS_VERSION;
this.disablePreGypCopy = options.disablePreGypCopy || false;
if (this.useCache && this.force) {
console.warn('[WARNING]: Electron Rebuild has force enabled and cache enabled, force take precedence and the cache will not be used.');
this.useCache = false;
}
if (typeof this.electronVersion === 'number') {
if (`${this.electronVersion}`.split('.').length === 1) {
this.electronVersion = `${this.electronVersion}.0.0`;
}
else {
this.electronVersion = `${this.electronVersion}.0`;
}
}
if (typeof this.electronVersion !== 'string') {
throw new Error(`Expected a string version for electron version, got a "${typeof this.electronVersion}"`);
}
this.ABIVersion = (_a = options.forceABI) === null || _a === void 0 ? void 0 : _a.toString();
const onlyModules = options.onlyModules || null;
const extraModules = (options.extraModules || []).reduce((acc, x) => acc.add(x), new Set());
const types = options.types || defaultTypes;
this.moduleWalker = new module_walker_1.ModuleWalker(this.buildPath, options.projectRootPath, types, extraModules, onlyModules);
this.rebuilds = [];
d('rebuilding with args:', this.buildPath, this.electronVersion, this.arch, extraModules, this.force, this.headerURL, types, this.debug);
console.log("THIS>PLATFORM", this.platform)
}
get ABI() {
if (this.ABIVersion === undefined) {
this.ABIVersion = nodeAbi.getAbi(this.electronVersion, 'electron');
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.ABIVersion;
}
get buildType() {
return this.debug ? types_1.BuildType.Debug : types_1.BuildType.Release;
}
async rebuild() {
if (!path.isAbsolute(this.buildPath)) {
throw new Error('Expected buildPath to be an absolute path');
}
this.lifecycle.emit('start');
await this.moduleWalker.walkModules();
for (const nodeModulesPath of await this.moduleWalker.nodeModulesPaths) {
await this.moduleWalker.findAllModulesIn(nodeModulesPath);
}
for (const modulePath of this.moduleWalker.modulesToRebuild) {
this.rebuilds.push(() => this.rebuildModuleAt(modulePath));
}
this.rebuilds.push(() => this.rebuildModuleAt(this.buildPath));
if (this.mode !== 'sequential') {
await Promise.all(this.rebuilds.map(fn => fn()));
}
else {
for (const rebuildFn of this.rebuilds) {
await rebuildFn();
}
}
}
async rebuildModuleAt(modulePath) {
if (!(await fs.pathExists(path.resolve(modulePath, 'binding.gyp')))) {
return;
}
const moduleRebuilder = new module_rebuilder_1.ModuleRebuilder(this, modulePath);
this.lifecycle.emit('module-found', path.basename(modulePath));
if (!this.force && await moduleRebuilder.alreadyBuiltByRebuild()) {
d(`skipping: ${path.basename(modulePath)} as it is already built`);
this.lifecycle.emit('module-done');
this.lifecycle.emit('module-skip');
return;
}
if (await moduleRebuilder.prebuildInstallNativeModuleExists()) {
d(`skipping: ${path.basename(modulePath)} as it was prebuilt`);
return;
}
let cacheKey;
if (this.useCache) {
cacheKey = await (0, cache_1.generateCacheKey)({
ABI: this.ABI,
arch: this.arch,
debug: this.debug,
electronVersion: this.electronVersion,
headerURL: this.headerURL,
modulePath,
});
const applyDiffFn = await (0, cache_1.lookupModuleState)(this.cachePath, cacheKey);
if (typeof applyDiffFn === 'function') {
await applyDiffFn(modulePath);
this.lifecycle.emit('module-done');
return;
}
}
if (await moduleRebuilder.rebuild(cacheKey)) {
this.lifecycle.emit('module-done');
}
}
}
exports.Rebuilder = Rebuilder;
function rebuild(options) {
console.log("(rebuild)", options)
// eslint-disable-next-line prefer-rest-params
d('rebuilding with args:', arguments);
const lifecycle = new events_1.EventEmitter();
const rebuilderOptions = { ...options, lifecycle };
const rebuilder = new Rebuilder(rebuilderOptions);
const ret = rebuilder.rebuild();
ret.lifecycle = lifecycle;
return ret;
}
exports.rebuild = rebuild;
//# sourceMappingURL=rebuild.js.map
================================================
FILE: temp/yarn.js
================================================
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.rebuild = exports.nodeGypRebuild = exports.getGypEnv = exports.installOrRebuild = void 0;
const builder_util_1 = require("builder-util");
const fs_extra_1 = require("fs-extra");
const os_1 = require("os");
const path = require("path");
const electronVersion_1 = require("../electron/electronVersion");
const electronRebuild = require("@electron/rebuild");
const searchModule = require("@electron/rebuild/lib/src/search-module");
async function installOrRebuild(config, appDir, options, forceInstall = false) {
console.log("install or rebuild", { config, options })
let isDependenciesInstalled = false;
for (const fileOrDir of ["node_modules", ".pnp.js"]) {
if (await (0, fs_extra_1.pathExists)(path.join(appDir, fileOrDir))) {
isDependenciesInstalled = true;
break;
}
}
if (forceInstall || !isDependenciesInstalled) {
const effectiveOptions = {
buildFromSource: config.buildDependenciesFromSource === true,
additionalArgs: (0, builder_util_1.asArray)(config.npmArgs),
...options,
};
await installDependencies(appDir, effectiveOptions);
}
else {
await rebuild(appDir, config.buildDependenciesFromSource === true, options);
}
}
exports.installOrRebuild = installOrRebuild;
function getElectronGypCacheDir() {
return path.join((0, os_1.homedir)(), ".electron-gyp");
}
function getGypEnv(frameworkInfo, platform, arch, buildFromSource) {
const npmConfigArch = arch === "armv7l" ? "arm" : arch;
const common = {
...process.env,
npm_config_arch: npmConfigArch,
npm_config_target_arch: npmConfigArch,
npm_config_platform: platform,
npm_config_build_from_source: buildFromSource,
// required for node-pre-gyp
npm_config_target_platform: platform,
npm_config_update_binary: true,
npm_config_fallback_to_build: true,
};
if (platform !== process.platform) {
common.npm_config_force = "true";
}
if (platform === "win32" || platform === "darwin") {
common.npm_config_target_libc = "unknown";
}
if (!frameworkInfo.useCustomDist) {
return common;
}
// https://github.com/nodejs/node-gyp/issues/21
return {
...common,
npm_config_disturl: "https://electronjs.org/headers",
npm_config_target: frameworkInfo.version,
npm_config_runtime: "electron",
npm_config_devdir: getElectronGypCacheDir(),
};
}
exports.getGypEnv = getGypEnv;
function checkYarnBerry() {
var _a;
const npmUserAgent = process.env["npm_config_user_agent"] || "";
const regex = /yarn\/(\d+)\./gm;
const yarnVersionMatch = regex.exec(npmUserAgent);
const yarnMajorVersion = Number((_a = yarnVersionMatch === null || yarnVersionMatch === void 0 ? void 0 : yarnVersionMatch[1]) !== null && _a !== void 0 ? _a : 0);
return yarnMajorVersion >= 2;
}
function installDependencies(appDir, options) {
const platform = options.platform || process.platform;
const arch = options.arch || process.arch;
const additionalArgs = options.additionalArgs;
builder_util_1.log.info({ platform, arch, appDir }, `installing production dependencies`);
let execPath = process.env.npm_execpath || process.env.NPM_CLI_JS;
const execArgs = ["install"];
const isYarnBerry = checkYarnBerry();
if (!isYarnBerry) {
if (process.env.NPM_NO_BIN_LINKS === "true") {
execArgs.push("--no-bin-links");
}
execArgs.push("--production");
}
if (!isRunningYarn(execPath)) {
execArgs.push("--prefer-offline");
}
if (execPath == null) {
execPath = getPackageToolPath();
}
else if (!isYarnBerry) {
execArgs.unshift(execPath);
execPath = process.env.npm_node_execpath || process.env.NODE_EXE || "node";
}
if (additionalArgs != null) {
execArgs.push(...additionalArgs);
}
return (0, builder_util_1.spawn)(execPath, execArgs, {
cwd: appDir,
env: getGypEnv(options.frameworkInfo, platform, arch, options.buildFromSource === true),
});
}
async function nodeGypRebuild(arch) {
return rebuild(process.cwd(), false, arch);
}
exports.nodeGypRebuild = nodeGypRebuild;
function getPackageToolPath() {
if (process.env.FORCE_YARN === "true") {
return process.platform === "win32" ? "yarn.cmd" : "yarn";
}
else {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
}
function isRunningYarn(execPath) {
const userAgent = process.env.npm_config_user_agent;
return process.env.FORCE_YARN === "true" || (execPath != null && path.basename(execPath).startsWith("yarn")) || (userAgent != null && /\byarn\b/.test(userAgent));
}
/** @internal */
async function rebuild(appDir, buildFromSource, options) {
builder_util_1.log.info({ appDir, arch: options.arch, platform: options.platform }, "executing @electron/rebuild");
const effectiveOptions = {
buildPath: appDir,
electronVersion: await (0, electronVersion_1.getElectronVersion)(appDir),
arch: options.arch,
platform: options.platform,
force: true,
debug: builder_util_1.log.isDebugEnabled,
projectRootPath: await searchModule.getProjectRootPath(appDir),
};
if (buildFromSource) {
effectiveOptions.prebuildTagPrefix = "totally-not-a-real-prefix-to-force-rebuild";
}
return electronRebuild.rebuild(effectiveOptions);
}
exports.rebuild = rebuild;
//# sourceMappingURL=yarn.js.map
================================================
FILE: update-banner.html
================================================
Pinokio Update
================================================
FILE: updater.js
================================================
const { autoUpdater } = require("electron-updater");
const ProgressBar = require('electron-progressbar');
const { dialog } = require('electron');
class Updater {
constructor(handlers = {}) {
this.handlers = handlers || {};
this.mainWindow = null;
this.progressBar = null;
}
setHandlers(handlers = {}) {
this.handlers = handlers || {};
}
run(mainWindow, handlers = null) {
if (handlers) {
this.setHandlers(handlers);
}
this.mainWindow = mainWindow || null;
autoUpdater.autoDownload = false;
autoUpdater.on('checking-for-update', () => {
console.log('Checking for update...');
});
autoUpdater.on('update-available', (info) => {
console.log('Update available:', info.version);
if (this.handlers && typeof this.handlers.onUpdateAvailable === 'function') {
this.handlers.onUpdateAvailable(info);
return;
}
this.showDefaultUpdatePrompt(info);
});
autoUpdater.on('update-not-available', () => {
console.log('No update available.');
if (this.handlers && typeof this.handlers.onUpdateNotAvailable === 'function') {
this.handlers.onUpdateNotAvailable();
}
});
autoUpdater.on("download-progress", (progress) => {
console.log(`Downloaded ${Math.round(progress.percent)}%`);
if (this.handlers && typeof this.handlers.onDownloadProgress === 'function') {
this.handlers.onDownloadProgress(progress);
return;
}
this.updateDefaultProgress(progress);
});
autoUpdater.on("update-downloaded", (info) => {
console.log("Update downloaded:", info.version);
if (this.handlers && typeof this.handlers.onUpdateDownloaded === 'function') {
this.handlers.onUpdateDownloaded(info);
return;
}
this.showDefaultRestartPrompt(info);
});
autoUpdater.on("error", (err) => {
console.error("Update error:", err);
if (this.handlers && typeof this.handlers.onError === 'function') {
this.handlers.onError(err);
return;
}
this.closeProgressBar();
});
autoUpdater.checkForUpdates().catch((err) => {
// The updater promise rejects on recoverable network errors; log and continue.
console.error('Failed to check for updates:', err)
});
}
downloadUpdate() {
return autoUpdater.downloadUpdate();
}
quitAndInstall() {
autoUpdater.quitAndInstall();
}
showDefaultUpdatePrompt(info) {
const targetWindow = this.mainWindow;
dialog.showMessageBox(targetWindow, {
type: 'question',
buttons: ['Yes', 'No'],
defaultId: 0,
cancelId: 1,
title: 'Update Available',
message: `Version ${info.version} is available. Do you want to download it now?`
}).then(result => {
if (result.response === 0) {
this.startDefaultDownload();
}
});
}
startDefaultDownload() {
if (this.progressBar) {
this.progressBar.close();
this.progressBar = null;
}
this.progressBar = new ProgressBar({
indeterminate: false,
text: "Downloading update...",
detail: "Please wait...",
browserWindow: {
parent: this.mainWindow,
modal: true,
closable: false,
minimizable: false,
maximizable: false,
width: 400,
height: 120
}
});
autoUpdater.downloadUpdate();
}
updateDefaultProgress(progress) {
if (this.progressBar && !this.progressBar.isCompleted()) {
this.progressBar.value = Math.floor(progress.percent);
this.progressBar.detail = `Downloaded ${Math.round(progress.percent)}% (${(progress.transferred / 1024 / 1024).toFixed(2)} MB of ${(progress.total / 1024 / 1024).toFixed(2)} MB)`;
}
}
showDefaultRestartPrompt(info) {
if (this.progressBar && !this.progressBar.isCompleted()) {
this.progressBar.setCompleted();
this.progressBar = null;
}
dialog.showMessageBox(this.mainWindow, {
type: "info",
buttons: ["Restart Now", "Later"],
title: "Update Ready",
message: "A new version has been downloaded. Restart the application to apply the updates?"
}).then((result) => {
if (result.response === 0) {
autoUpdater.quitAndInstall();
}
});
}
closeProgressBar() {
if (this.progressBar && !this.progressBar.isCompleted()) {
this.progressBar.close();
this.progressBar = null;
}
}
}
module.exports = Updater;
================================================
FILE: wrap-linux-launcher.js
================================================
const fs = require('fs')
const path = require('path')
module.exports = async (context) => {
const { appOutDir, electronPlatformName, packager } = context
if (electronPlatformName !== 'linux') {
return
}
const exeName = packager.executableName || packager.appInfo.productFilename
const exePath = path.join(appOutDir, exeName)
const wrappedExePath = path.join(appOutDir, `${exeName}-bin`)
if (!fs.existsSync(exePath)) {
console.warn(`[wrap-linux-launcher] Executable not found at ${exePath}, skipping wrapper`)
return
}
const originalStat = fs.statSync(exePath)
fs.renameSync(exePath, wrappedExePath)
const wrapperScript = `#!/usr/bin/env sh
export ELECTRON_OZONE_PLATFORM_HINT=x11
export ELECTRON_DISABLE_GPU=1
SCRIPT_PATH="$0"
RESOLVED_PATH=""
if command -v readlink >/dev/null 2>&1; then
RESOLVED_PATH="$(readlink -f "$SCRIPT_PATH" 2>/dev/null || true)"
fi
if [ -z "$RESOLVED_PATH" ] && command -v realpath >/dev/null 2>&1; then
RESOLVED_PATH="$(realpath "$SCRIPT_PATH" 2>/dev/null || true)"
fi
if [ -n "$RESOLVED_PATH" ]; then
SCRIPT_DIR="$(dirname "$RESOLVED_PATH")"
else
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
fi
OPT_BIN="/opt/Pinokio/${exeName}-bin"
LOCAL_BIN="$SCRIPT_DIR/${exeName}-bin"
if [ -x "$OPT_BIN" ]; then
TARGET_BIN="$OPT_BIN"
else
TARGET_BIN="$LOCAL_BIN"
fi
exec "$TARGET_BIN" --ozone-platform=x11 --disable-gpu --disable-gpu-sandbox "$@"
`
fs.writeFileSync(exePath, wrapperScript, { mode: originalStat.mode || 0o755 })
}