2
2
mirror of https://github.com/Llewellynvdm/nativefier.git synced 2024-11-15 17:27:08 +00:00

Compare commits

..

No commits in common. "master" and "v7.5.0" have entirely different histories.

187 changed files with 4656 additions and 22938 deletions

18
.codeclimate.yml Normal file
View File

@ -0,0 +1,18 @@
---
engines:
csslint:
enabled: true
duplication:
enabled: true
config:
languages:
- javascript
eslint:
enabled: true
fixme:
enabled: true
ratings:
paths:
- "**.js"
exclude_paths:
- test/

View File

@ -1,6 +1,3 @@
# git
.git*
# OSX
.DS_Store
@ -10,12 +7,11 @@
lib/*
app/lib/*
built-tests
dist
app/dist
# Docs
docs
*.md
# commit a placeholder to keep the app/lib directory
!app/lib/.placeholder
dist
# Logs
logs
@ -45,12 +41,9 @@ build/Release
# Dependency directory
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
app/node_modules
# IntelliJ project files
.idea
*.iml
out
gen
.vscode

View File

@ -13,7 +13,7 @@ insert_final_newline = true
[*.{js,py}]
charset = utf-8
indent_style = space
indent_size = 2
indent_size = 4
# 2 space indentation
[*.{html,css,less,scss,yml,json}]

1
.env
View File

@ -1 +0,0 @@
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules/**
app/node_modules/**
app/lib/**
lib/**
built-tests/**
coverage/**

11
.eslintrc.yml Normal file
View File

@ -0,0 +1,11 @@
extends: airbnb-base
env:
# TODO: find out how to turn this on only for src/**/*.test.js files
jest: true
plugins:
- import
rules:
# TODO: Remove this when we have shifted away from the async package
no-shadow: 'warn'
# Gulpfiles and tests use dev dependencies
import/no-extraneous-dependencies: ['error', { devDependencies: ['gulpfile.babel.js', 'gulp/**/**.js', 'test/**/**.js']}]

View File

@ -13,13 +13,16 @@ Please include the following in your new issue:
## Pull Requests
See [here](https://github.com/nativefier/nativefier/blob/master/HACKING.md) for instructions on how to set up a development environment.
See [here](https://github.com/jiahaog/nativefier#development) for instructions on how to set up a development environment.
We follow the [Airbnb Style Guide](https://github.com/airbnb/javascript), please make sure tests and lints pass when you submit your pull request.
The following commands might be helpful:
```bash
# Run specs and lint
npm run ci
# Run specs only
npm run test

21
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,21 @@
### Description
<Detail your problem here as clearly and concisely as possible 🙂. Help us help you by saying why and giving some context!>
### Steps to reproduce issue
### Details
- Are you nativefying a *public* website?
- Feature request? Have you looked at `nativefier --help` to see if an existing option could fit your needs?
- Full nativefier command used to build your app: `<command here>`
- Version of Nativefier (run `nativefier --version`): `v0.0.0`
- Version of node.js (run `node --version`): `v0.0.0`
- OS: `<OS here>`
- Error message / stack trace (if any):
```
<Error message / Stack trace here>
```

View File

@ -1,93 +0,0 @@
name: Bug Report
description: File a bug report
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report 🙂! Help us help you, **fill this form thoroughly**. An incomplete bug report is a useless bug report.
- type: checkboxes
id: homework
attributes:
label: Homework
options:
- label: I took the time to write a good, descriptive issue title
required: true
- label: I read `nativefier --help` and [API.md](https://github.com/nativefier/nativefier/blob/master/API.md).
required: true
- label: I checked [CATALOG.md](https://github.com/nativefier/nativefier/blob/master/CATALOG.md) for community suggestions & workarounds.
required: true
- label: I searched [existing issues, open & closed](https://github.com/nativefier/nativefier/issues?q=is%3Aissue). Yes, my bug is new.
required: true
- label: I'm running the [latest version](https://github.com/nativefier/nativefier/releases).
required: true
- type: input
id: nativefier-command
attributes:
label: Nativefier command
description: "Your ***full*** nativefier command, on a ***public*** site."
placeholder: nativefier --verbose --some-option https://mysite.com
validations:
required: true
- type: textarea
id: steps-to-repro
attributes:
label: Steps to reproduce
placeholder: |
1. I did this...
2. And then that...
3. Finally, I clicked here.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
placeholder: What you expected to happen.
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
placeholder: What happened instead.
validations:
required: true
- type: textarea
id: debug-info
attributes:
label: Debug info
placeholder: |
- Logs of your full build command, with the `--verbose` flag. Put them in a ```code block``` !
- If the bug happens at app run time, the in-app DevTools console logs (open it with F12)
- Error messages, screenshots, screencasts, anything relevant!
validations:
required: false
- type: input
id: nativefier-version
attributes:
label: Nativefier version
placeholder: "nativefier --version"
validations:
required: true
- type: input
id: node-version
attributes:
label: Node.js version
placeholder: "node --version"
validations:
required: true
- type: input
id: npm-version
attributes:
label: npm version
placeholder: "npm --version"
validations:
required: true
- type: input
id: os
attributes:
label: OS
placeholder: "For example: Windows 10 build 1809"
validations:
required: true

View File

@ -1 +0,0 @@
blank_issues_enabled: false

View File

@ -1,45 +0,0 @@
name: Feature request
description: Suggest an idea for Nativefier
labels: ["feature-request"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request 🙂! Help us help you, **fill this form thoroughly**. An incomplete feature request is a useless feature request.
- type: checkboxes
id: homework
attributes:
label: Homework
options:
- label: I took the time to write a good, descriptive issue title
required: true
- label: I read `nativefier --help` and [API.md](https://github.com/nativefier/nativefier/blob/master/API.md), no existing option fits my needs.
required: true
- label: I checked [CATALOG.md](https://github.com/nativefier/nativefier/blob/master/CATALOG.md) for community suggestions & workarounds.
required: true
- label: I searched [existing issues, open & closed](https://github.com/nativefier/nativefier/issues?q=is%3Aissue). Yes, my feature request is new.
required: true
- label: I'm running the [latest version](https://github.com/nativefier/nativefier/releases). Yes, the feature I'm requesting isn't in it.
required: true
validations:
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem statement
description: A clear and concise description of what your feature would be.
placeholder: |
For example:
Nativefier should XYZ, ... details details details...
Existing option --something is not what I want, because ...
validations:
required: true
- type: textarea
id: motivation-and-context
attributes:
label: Motivation & context
placeholder: |
What makes you want this feature?
Where does it come from?
validations:
required: true

View File

@ -1,78 +0,0 @@
name: Question
description: Ask for help
labels: ["question"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report 🙂! Help us help you, **fill this form thoroughly**. A cryptic question is a question unlikely to be answered.
- type: checkboxes
id: homework
attributes:
label: Homework
options:
- label: I took the time to write a good, descriptive issue title
required: true
- label: I read `nativefier --help` and [API.md](https://github.com/nativefier/nativefier/blob/master/API.md).
required: true
- label: I checked [CATALOG.md](https://github.com/nativefier/nativefier/blob/master/CATALOG.md) for community suggestions & workarounds.
required: true
- label: I searched [existing issues, open & closed](https://github.com/nativefier/nativefier/issues?q=is%3Aissue). Yes, my question is new.
required: true
- label: I'm running the [latest version](https://github.com/nativefier/nativefier/releases).
required: true
validations:
required: false
- type: textarea
id: question
attributes:
label: Your question
description: Your question, expressed clearly and concisely.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to reproduce
description: "If you already have a Nativefier command you're struggling with, paste ***full*** nativefier command and its logs, with the ***`--verbose` flag***, on a ***public*** site:"
value: |
```
nativefier --verbose --some-option https://mysite.com
<paste your verbose build logs, if relevant to your question>
```
validations:
required: false
- type: textarea
id: debug-info
attributes:
label: Debug info
placeholder: |
Error messages, screenshots, screencasts, anything relevant!
- type: input
id: nativefier-version
attributes:
label: Nativefier version
placeholder: "nativefier --version"
validations:
required: true
- type: input
id: node-version
attributes:
label: Node.js version
placeholder: "node --version"
validations:
required: true
- type: input
id: npm-version
attributes:
label: npm version
placeholder: "npm --version"
validations:
required: true
- type: input
id: os
attributes:
label: OS
placeholder: "For example: Windows 10 build 1809"
validations:
required: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,79 +0,0 @@
#!/usr/bin/env bash
# Updates the changelog and version in the package.json
# Will also create a commit with these changes locally
#
# Usage:
# ./.github/generate-changelog -- "7.0.0"
#
# Prerequisites:
# - On master branch
# - No uncommitted changes
#
# Dependencies:
# - git-extras: https://github.com/tj/git-extras/blob/master/Installation.md
# - jq: https://stedolan.github.io/jq/download/
set -eo pipefail
echo 'HEY YOU. Before you release, here is a report of outdated dependencies.'
echo ' - Red upgrades fulfill semver and do *not* need any action'
echo ' - Yellow upgrades *do* need looking at changelogs for breaking changes, and updating package.json'
echo
echo 'CLI:'
npm out || true
echo
echo 'App:'
cd app; npm out || true; cd ..
echo
echo 'Okay with this, or care to do/plan a few upgrades?'
echo 'Press any key to continue, or Ctrl+C to abort'
read -r
echo 'HEY YOU, again. Did you run the quick pre-release smoke test? ( npm run test:manual )'
echo 'Press any key to continue, or Ctrl+C to abort'
read -r
# Checks if we are on the master branch
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != 'master' ]]; then
echo 'ERROR: not on master branch' >&2
exit 1;
fi
# Checks if there are uncommitted changes
git diff-index --quiet HEAD -- || (echo 'ERROR: there are uncommitted changes' >&2 && exit 1)
VERSION="$1"
# Validates the $VERSION
SEMVER_REGEX='^([0-9]+\.){2}([0-9]+)$'
if ! [[ $VERSION =~ $SEMVER_REGEX ]]; then
echo "ERROR: Version '$VERSION' is invalid " >&2
exit 1
fi
# 1. Update the version in the package.json
cat package.json | jq ".version = \"$VERSION\"" > package.json.tmp
mv package.json.tmp package.json # workaround for in-place jq editing
# 2. Compile new commits from CHANGELOG.md, and open it in your EDITOR for cleanup
git changelog CHANGELOG.md --tag "$VERSION"
# 3. Commit the changes
git add CHANGELOG.md
git add package.json
git commit -m "Update changelog for \`v$VERSION\`"
# 4. Create an annotated tag
git tag -a "v$VERSION" -m "v$VERSION"
# 5. List remaining work
echo
echo 'Please verify commit & tag look fine in Git, then:'
echo ' 1. Push: git push --follow-tags origin master'
echo ' 2. Create a GitHub Release at https://github.com/nativefier/nativefier/releases ,'
echo " using created tag v$VERSION and with title \"Nativefier v$VERSION\" (yes, with a \"v\")."
echo
echo 'GitHub Action "publish" will react on the new release, and publish it to npm.'
echo 'The new version will be visible on npm within a few minutes/hours.'

125
.github/manual-test vendored
View File

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# Manual test to validate some hard-to-programmatically-test features work.
set -eo pipefail
missingDeps=false
if ! command -v mktemp > /dev/null; then echo "Missing mktemp"; missingDeps=true; fi
if ! command -v uname > /dev/null; then echo "Missing uname"; missingDeps=true; fi
if ! command -v node > /dev/null; then echo "Missing node"; missingDeps=true; fi
if [ "$missingDeps" = true ]; then exit 1; fi
function launch_app() {
printf '\n*** Running app\n'
if [ "$(uname -s)" = "Darwin" ]; then
open -a "$1/$2-darwin-x64/$2.app"
elif [ "$(uname -o)" = "Msys" ]; then
"$1/$2-win32-x64/$2.exe"
else
"$1/$2-linux-x64/$2"
fi
}
function do_cleanup() {
if [ -n "$1" ]; then
printf '\n***** Deleting test dir %s *****\n' "$1"
rm -rf "$1"
printf '\n'
fi
}
function request_feedback() {
printf '\nDid everything work as expected? [yN] '
read -r response
do_cleanup "$1"
if [ "$response" != 'y' ]; then
echo "Back to fixing"
exit 1
fi
echo "Yayyyyyyyyyyy"
}
printf "\n***** SMOKE TEST 1: Setting up test and building app... *****\n"
script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
nativefier_dir="$script_dir/.."
pushd "$nativefier_dir"
tmp_dir=$(mktemp -d -t nativefier-manual-test-XXXXX)
name="nativefier-smoke-test-1"
resources_dir="$tmp_dir/resources"
mkdir "$resources_dir"
injected_css="$resources_dir/inject.css"
injected_js="$resources_dir/inject.js"
echo '* { background-color: blue; }' > "$injected_css"
echo 'alert("hello world from inject");' > "$injected_js"
node ./lib/cli.js 'https://npmjs.com/' \
--inject "$injected_css" \
--inject "$injected_js" \
--name "$name" \
"$tmp_dir"
printf '\n***** SMOKE TEST 1: Test checklist *****
- Context menu -> Open Link In New Window works
- MAC ONLY: Context menu -> Open Link In New Tab works
- Keyboard shortcuts: {back, forward, zoom in/out/zero} work
- Console: no Electron runtime deprecation warnings/error logged'
launch_app "$tmp_dir" "$name"
request_feedback "$tmp_dir"
# ------------------------------------------------------------------------------
printf '\n***** SMOKE TEST 2: Setting up test and building app... *****\n'
tmp_dir=$(mktemp -d -t nativefier-manual-test-tray-XXXXX)
name='nativefier-smoke-test-2'
node ./lib/cli.js 'https://google.com/' \
--name "$name" \
--tray \
"$tmp_dir"
printf '\n***** SMOKE TEST 2: Test checklist *****
- Should have an app with a tray icon
- Console: no Electron runtime deprecation warnings/error logged'
launch_app "$tmp_dir" "$name"
request_feedback "$tmp_dir"
# ------------------------------------------------------------------------------
printf '\n***** SMOKE TEST 3: Setting up test and building app... *****\n'
tmp_dir=$(mktemp -d -t nativefier-manual-test-start-in-tray-XXXXX)
name='nativefier-smoke-test-3'
node ./lib/cli.js 'https://google.com/' \
--name "$name" \
--tray start-in-tray \
"$tmp_dir"
printf '\n***** SMOKE TEST 3: Test checklist *****
- Should have an app that does not show a window initially,
but will have a tray icon that will show the window.
- Console: no Electron runtime deprecation warnings/error logged'
launch_app "$tmp_dir" "$name"
request_feedback "$tmp_dir"
# ------------------------------------------------------------------------------
printf '\n***** SMOKE TEST 4: Setting up test and building app... *****\n'
tmp_dir=$(mktemp -d -t nativefier-manual-test-get-media-devices)
name='nativefier-smoke-test-4'
node ./lib/cli.js 'https://meet.jit.si/nativefier-test' \
--name "$name" \
"$tmp_dir"
printf '\n***** SMOKE TEST 4: Test checklist *****
- Join the Jitsi meeting and try to share your screen
(third button from the left in the bottom bar)
- An overlay should appear where you can select a screen/window to share
This presently does not work in MacOS as you would have to give the app
"Screen Recording" permissions, but you can''t for an app in the temp directory.
- After selecting a screen, a thumbnail of the shared screen should appear on
the top right
- Console: no Electron runtime deprecation warnings/error logged'
launch_app "$tmp_dir" "$name"
request_feedback "$tmp_dir"

View File

@ -1,81 +0,0 @@
name: ci
on:
push:
branches:
- master
pull_request:
branches:
- master
# - Bumping the *minimum* required Node version? You must bump:
# 1. package.json -> engines.node
# 2. package.json -> devDependencies.@types/node
# 3. tsconfig.json -> {target, lib}
# 4. .github/workflows/ci.yml -> node-version
# - Bumping the *maximum* tested Node version? You must bump also: publish.yml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 20
uses: actions/setup-node@v2
with:
node-version: 20
cache: 'npm'
cache-dependency-path: |
npm-shrinkwrap.json
app/npm-shrinkwrap.json
package-lock.json
app/package-lock.json
- env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: npm ci --no-fund # Will also (via `prepare` hook): 1. install ./app, 2. build
- run: npm run lint
playwright:
runs-on: windows-latest # Doesn't work on headless ubuntu, and is slow on mac
steps:
- uses: actions/checkout@v2
- name: Use Node.js 20
uses: actions/setup-node@v2
with:
node-version: 20
cache: 'npm'
cache-dependency-path: |
npm-shrinkwrap.json
app/npm-shrinkwrap.json
package-lock.json
app/package-lock.json
- env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: npm ci --no-fund # Will also (via `prepare` hook): 1. install ./app, 2. build
- run: npm run test:playwright
timeout-minutes: 5
# Useful to debug PlayWright tests failing in CI
# env:
# DEBUG: pw:browser*
tests:
strategy:
matrix:
node-version:
- '20'
- '16' # the oldest we require in package.json -> engines.node, to check we run on this minimum
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: |
npm-shrinkwrap.json
app/npm-shrinkwrap.json
package-lock.json
app/package-lock.json
- env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: npm ci --no-fund # Will also (via `prepare` hook): 1. install ./app, 2. build
- run: npm run test:noplaywright

View File

@ -1,51 +0,0 @@
name: publish
on:
release:
types:
- created
jobs:
playwright:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2 # Setup .npmrc file to publish to npm
with:
node-version: '20' # Align the version of Node here with ci.yml.
registry-url: 'https://registry.npmjs.org'
- run: npm ci --no-fund # Will also (via `prepare` hook): 1. install ./app, 2. build
- run: npm run test:playwright
timeout-minutes: 5
build:
needs: playwright
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2 # Setup .npmrc file to publish to npm
with:
node-version: '20' # Align the version of Node here with ci.yml.
registry-url: 'https://registry.npmjs.org'
- run: npm ci --no-fund # Will also (via `prepare` hook): 1. install ./app, 2. build
- run: npm run test:noplaywright
- run: npm run lint
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
needs: [ playwright, build ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build Docker image
run: docker build . --file Dockerfile --tag "nativefier/nativefier:latest"
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Tag and push Docker image
run: |
docker tag "nativefier/nativefier:latest" "nativefier/nativefier:${GITHUB_REF_NAME}"
docker push "nativefier/nativefier:latest"
docker push "nativefier/nativefier:${GITHUB_REF_NAME}"

24
.gitignore vendored
View File

@ -1,20 +1,17 @@
# OSX
.DS_Store
# Node.js
# ignore compiled lib files
lib*
lib/*
app/lib/*
app/dist/*
built-tests
# commit a placeholder to keep the app/lib directory
app/inject
!app/inject/_placeholder
!app/lib/.placeholder
dist
package-lock.json
app/package-lock.json
# Logs
logs
@ -45,23 +42,8 @@ build/Release
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
# Python virtual environment in case it's created for the Castlabs code signing tool
venv
# IntelliJ project files
.idea
*.iml
.run
out
gen
# Builds when testing npm pack
nativefier*.tgz
.vscode
# https://github.com/nektos/act
.actrc
tsconfig.tsbuildinfo
scripts

7
.hound.yml Normal file
View File

@ -0,0 +1,7 @@
eslint:
enabled: true
config_file: .eslintrc.yml
ignore_file: .eslintignore
jshint:
enabled: false

View File

@ -1,23 +1,7 @@
# OSX
.DS_Store
/*
!lib/
!icon-scripts
!npm-shrinkwrap.json
.DS_Store
src/
*eslintrc.js
*eslintrc.yml
*tsconfig.tsbuildinfo
*tsconfig.json
*jestSetupFiles*
*-test.js
*-test.js.map
*.test.d.ts
*.test.js
*.test.js.map
app/*
!app/lib/
!app/inject/
!app/nativefier.json
!app/package.json
!app/npm-shrinkwrap.json
.vscode/
!app/lib
!bin

1
.npmrc
View File

@ -1 +0,0 @@
package-lock=false

1
.nvmrc
View File

@ -1 +0,0 @@
16

38
.travis.yml Normal file
View File

@ -0,0 +1,38 @@
language: node_js
addons:
code_climate:
repo_token: CODE_CLIMATE_TOKEN
node_js:
# NPM currently does not support node 9 https://github.com/nodejs/node/issues/16649, we have to
# wait for the next release
# - node
- 'lts/*'
- '8'
- '7'
- '6'
- '5'
- '4'
before_install:
- npm install -g npm
install:
- npm run dev-up
script:
- npm run ci
after_script:
- codeclimate-test-reporter < ./coverage/lcov.info
deploy:
provider: npm
email: jiahaog@gmail.com
api_key:
secure: d/wwkEjXrgP7fJrbkqdSH2779ijL6zIMjRokGP6ojB+7SNiefO0tR8A+hVifeePRC8tDmg4Q/SaVN61+sVNiJc2lDMej6UyRy1WuYW9sYovc5ZRHfba/xUVlq8xQbww/bz2MD8Um9/ayKMViw1Mkt5BQ1sBf/4Q3Ua6WSGsy2rET234rVuk5eR9cYeA/WTH+/w4ae59ki2qyezFwSgCaM9SkxStKez+btKcltIpCBi/k28DjWFPhmGarouR5MYwGG0CqaLzYKgxWqaKS6wN/nO0YrdHBJyciZhccbxWOg9G2sKPTBsebYdyPCe9ykEAGkeibVjvUBGYsDxObTo5W4ccYgq9g/nSbSxyaYn022Xs7EJcGfZf8KWAeZuLrwtb/VgRQyZI5QOMRjN8s50oirVyWBH/lGD4VdKDix3TMbwsgs1Q3VbmU/4bSqeh3HSDK/chEDsw3rzu2c/D/Fl/4kh0MOX4q/qO/2bj9aEpX/Gc4JVqL9y89IeoYGE7ZzecCq64L0oKbNT0qAUjthFAu0a4E+zOedj5z/HpEMaqeH6FBF8a/Ds95QB9NX0REBsHazKcjOCv4wc6sItY4Wdj+l0/lkTSwuPhdgH9gwDbnwlSGG9j01k3aFhdxTm2k2nRzjoUs2iRZjnUwVLxVFn0jUyKh7mkRZZalaNeLQb0zTu4=
on:
tags: true
repo: jiahaog/nativefier
node: '4'

1303
API.md

File diff suppressed because it is too large Load Diff

View File

@ -1,300 +0,0 @@
# Build Commands Catalog
Below you'll find a list of build commands contributed by the Nativefier community. They are here as examples, to help you nativefy "complicated" apps that need a bit of elbow grease to work. We need your help to enrich it, as long as you follow these two guidelines:
1. Only add sites that require something special! No need to document here that `simplesite.com` works with a simple `nativefier simplesite.com` 🙂.
2. Please add commands with the _strict necessary_ to make an app work. For example,
- Yes to mention that `--widevine` or some `--browserwindow-options` are necessary...
- ... but don't add other flags that are pure personal preference (e.g. `--disable-dev-tools` or `--disk-cache-size`).
---
## General recipes
### Videos dont play
Some sites like [HBO Max](https://github.com/nativefier/nativefier/issues/1153) and [Udemy](https://github.com/nativefier/nativefier/issues/1147) host videos using [DRM](https://en.wikipedia.org/wiki/Digital_rights_management).
For those, try passing the [`--widevine`](API.md#widevine) option.
### Settings cached between app rebuilds
You might be surprised to see settings persist after rebuilding your app.
This occurs because the app cache lives separately from the app.
Try deleting your app's cache, found at `<your_app_name_lower_case>-nativefier-<random_id>` in your OSs "App Data" directory (Linux: `$XDG_CONFIG_HOME` or `~/.config` , MacOS: `~/Library/Application Support/` , Windows: `%APPDATA%` or `C:\Users\yourprofile\AppData\Roaming`)
### Window size and position
This allows the last set window size and position to be remembered and applied
after your app is restarted. Note: PR welcome for a built-in fix for that :) .
```sh
nativefier 'https://open.google.com/'
--inject window.js
```
Note: [Inject](https://github.com/nativefier/nativefier/blob/master/API.md#inject)
the following javascript as `windows.js` to prevent the window size and position to reset.
```javascript
function storeWindowPos() {
window.localStorage.setItem('windowX', window.screenX);
window.localStorage.setItem('windowY', window.screenY);
}
window.moveTo(window.localStorage.getItem('windowX'), window.localStorage.getItem('windowY'));
setInterval(storeWindowPos, 250);
```
---
## Site-specific recipes
### Google apps
Lying about the User Agent is required, else Google Login will notice your
"Chrome" isn't a real Chrome, and will: 1. Refuse login, 2. Break notifications.
This example documents Google Sheets, but is applicable to other Google apps,
e.g. Google Calendar, GMail, etc. If `firefox` doesnt work, try `safari` .
```sh
nativefier 'https://docs.google.com/spreadsheets' \
--user-agent firefox
```
### Outlook
```sh
nativefier 'https://outlook.office.com/mail'
--internal-urls '.*?(outlook.live.com|outlook.office365.com).*?'
--file-download-options '{"saveAs": true}'
--browserwindow-options '{"webPreferences": { "webviewTag": true, "nodeIntegration": true, "nodeIntegrationInSubFrames": true } }'
```
Note: `--browserwindow-options` is needed to allow pop-outs when creating/editing an email.
### Udemy
```sh
nativefier 'https://www.udemy.com/'
--internal-urls '.*?udemy.*?'
--file-download-options '{"saveAs": true}'
--widevine
```
Note: most videos will work, but to play some DRMed videos you must pass `--widevine` AND [sign the app](https://github.com/nativefier/nativefier/issues/1147#issuecomment-828750362).
### HBO Max
```sh
nativefier 'https://play.hbomax.com/'
--widevine
--enable-es3-apis
&& python -m castlabs_evs.vmp sign-pkg 'name_of_the_generated_hbo_app'
```
Note: as for Udemy, `--widevine` + [app signing](https://github.com/nativefier/nativefier/issues/1147#issuecomment-828750362) is necessary.
### WhatsApp
```sh
nativefier 'https://web.whatsapp.com/'
--inject whatsapp.js
```
With this `--inject` in `whatsapp.js` (and maybe more, see [#1112](https://github.com/nativefier/nativefier/issues/1112)):
```javascript
if ('serviceWorker' in navigator) {
caches.keys().then(function (cacheNames) {
cacheNames.forEach(function (cacheName) {
caches.delete(cacheName);
});
});
}
```
Another option to see WhatsApp or WhatsApp Business more macOS-like (macos only):
```sh
nativefier https://web.whatsapp.com --name 'WhatsApp Business' --counter true --darwin-dark-mode-support true --title-bar-style hidden --inject whatsappmacos.css
```
with this `whatsappmacos.css` to make the window draggable, and move the user avatar to the right:
```css
header > div:first-child {
flex: 0 0 auto;
margin-right: 15px;
}
div#app > div.os-mac > span:first-child {
position: fixed;
top: 0;
z-index: 1000;
width: 100%;
height: 59px;
pointer-events: none;
-webkit-app-region: drag;
}
```
### Spotify
```sh
nativefier 'https://open.spotify.com/'
--widevine
--inject spotify.js
--inject spotify.css
```
Notes:
- You might have to pass `--user-agent firefox` to circumvent Spotify's detection that your browser isn't a real Chrome. But [maybe not](https://github.com/nativefier/nativefier/issues/1195#issuecomment-855003776).
- [Inject](https://github.com/nativefier/nativefier/blob/master/API.md#inject) the following javascript as `spotify.js` to prevent "Unsupported Browser" messages.
```javascript
function dontShowBrowserNoticePage() {
const browserNotice = document.getElementById('browser-support-notice');
console.log({ browserNotice });
if (browserNotice) {
// When Spotify displays the browser notice, it's not just the notice,
// but the entire page is focused on not allowing you to proceed.
// So in this case, we hide the body element (so nothing shows)
// until our JS deletes the service worker and reload (which will actually load the player)
document.getElementsByTagName('body')[0].style.display = 'none';
}
}
function reload() {
window.location.href = window.location.href;
}
function nukeWorkers() {
dontShowBrowserNoticePage();
if ('serviceWorker' in navigator) {
caches.keys().then(function (cacheNames) {
cacheNames.forEach(function (cacheName) {
console.debug('Deleting cache', cacheName);
caches.delete(cacheName);
});
});
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((worker) =>
worker
.unregister()
.then((u) => {
console.debug('Unregistered worker', worker);
reload();
})
.catch((e) =>
console.error('Unable to unregister worker', error, { worker }),
),
);
});
}
}
document.addEventListener('DOMContentLoaded', () => {
nukeWorkers();
});
if (document.readyState === 'interactive') {
nukeWorkers();
}
```
- It is also required to [sign the app](https://github.com/nativefier/nativefier/blob/master/API.md#widevine), or many songs will not play.
- To hide all download links (as if you were in the actual app), [inject](https://github.com/nativefier/nativefier/blob/master/API.md#inject) the following CSS as `spotify.css`:
```css
a[href='/download'] {
display: none;
}
```
### Notion
You can use Notion pages with Nativefier without much hassle, but Notion itself does not present an easy way to use HTML buttons. As such, if you want to use Notion Pages as a quick way to make dashboards and interactive panels, you will be restricted to only plain links and standard components.
With Nativefier you can now extend Notion's functionality and possibilities by adding HTML buttons that can call other javascript functions, since it enables you to inject custom Javascript and CSS.
```sh
nativefier 'YOUR_NOTION_PAGE_SHARE_URL'
--inject notion.js
--inject notion.css
```
Notes:
- You can inject the notion.js and notion.css files by copying them to the resources/app/inject folder of your nativefier app.
- In your Notion page, use [notionbutton]BUTTON_TEXT|BUTTON_ACTION[/notionbutton], where BUTTON_TEXT is the text contained in your button and BUTTON_ACTION is the action which will be called in your JS function.
```javascript
/* notion.js */
// First, we replace all placeholders in our Notion page to add our interactive buttons to it.
window.onload =
setTimeout(function(){
let htmlCode = document.body.getElementsByTagName("*");
for (let i = 0; i <= htmlCode.length; i++) {
if(htmlCode[i] && htmlCode[i].innerHTML){
let match = htmlCode[i].innerHTML.match(/\[notionbutton\]([\s\S]*?)\[\/notionbutton\]/);
if (match && typeof match == 'object'){
let btnarray = match['1'].split("|");
let btn_text = btnarray[0];
let btn_action = btnarray[1];
htmlCode[i].innerHTML = htmlCode[i].innerHTML.replace(match['0'], "<button class=\"btn-notion\" btnaction=\"" + btn_action + "\" >"+btn_text+"</button>");
}
}
}
let buttons = document.querySelectorAll(".btn-notion");
for (let j=0; j <= buttons.length; j++){
if(buttons[j].hasAttribute("btnaction")){
buttons[j].onclick = function () { runAction(buttons[j].getAttribute("btnaction")) };
}
}
}, 3000);
// And then we define your action below, according to our needs
function runAction(action) {
switch(action){
case '1':
alert('Nice One!');
break;
default:
alert('Hello World!');
}
}
```
After that, set your css file as follows:
```css
.notion-topbar{ /* hiding notion's default navigation bar for a more "app" feeling */
display:none;
}
.btn-notion{ /* defining some style for our buttons */
background-color:#FFC300;
color: #333333;
}
.notion-selectable.notion-page-block.notion-collection-item span{
pointer-events: auto !important; /* notion prevents clicks on items inside databases. Use this to remove that. */
}
```
### Microsoft Teams
You can get an almost macOS look-alike using this:
```sh
nativefier https://teams.microsoft.com --name 'Microsoft Teams' --counter true --darwin-dark-mode-support true --title-bar-style hidden --internal-urls "(.*)" --inject teamsapp.css
```
Note that the `--internal-urls` argument is necessary to login.
Inject the following `teamsapp.css` file to hide the download button at the bottom left and the Office 365 apps waffle button at the top left:
```css
get-app-button.ts-sym.app-bar-link {
display: none;
}
button#ts-waffle-button {
display: none;
}
```

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +1,36 @@
FROM --platform=linux/amd64 node:lts-alpine
LABEL description="Alpine image to build Nativefier apps"
FROM node:7-alpine
LABEL description="Alpine image to build nativfier apps"
### Install wine depedency
RUN apk add --no-cache \
wine \
freetype \
imagemagick \
### make symbolic link to use `wine`
&& ln -s /usr/bin/wine64 /usr/bin/wine
# Add sources
COPY . /nativefier
### Build app package for nativefier installation
RUN cd /nativefier/app && npm install \
# Build and install nativefier binary
&& cd /nativefier && npm install && npm run build && npm install -g \
## Remove no longer needed sources
&& rm -rf /nativefier
# Install dependencies and cleanup extraneous files
RUN apk update \
&& apk add bash wine imagemagick dos2unix \
&& rm -rf /var/cache/apk/* \
&& mkdir /nativefier && chown node:node /nativefier
### Use 1000 as default user not root
USER 1000
# Use node (1000) as default user not root
USER node
ENV NPM_PACKAGES="/home/node/npm-packages"
ENV PATH="$PATH:$NPM_PACKAGES/bin"
ENV MANPATH="$MANPATH:$NPM_PACKAGES/share/man"
# Setup a global packages location for "node" user so we can npm link
RUN mkdir $NPM_PACKAGES \
&& npm config set prefix $NPM_PACKAGES
WORKDIR /nativefier
# Add sources with node as the owner so that it has the power it needs to build in /nativefier
COPY --chown=node:node . .
# Fix line endings that may have gotten mangled in Windows
RUN find ./icon-scripts ./src ./app -type f -print0 | xargs -0 dos2unix
# Link (which will install and build)
# Run tests (to ensure we don't Docker build & publish broken stuff)
# Cleanup leftover files in this step to not waste Docker layer space
# Make sure nativefier is executable
RUN npm i \
&& npm link \
&& npm run test:noplaywright \
&& rm -rf /tmp/nativefier* ~/.npm/_cacache ~/.cache/electron \
&& chmod +x $NPM_PACKAGES/bin/nativefier
# Run a {lin,mac,win} build
# 1. to check installation was sucessful
# 2. to cache electron distributables and avoid downloads at runtime
# Also delete generated apps so they don't get added to the Docker layer
# !Important! The `rm -rf` command must be in the same `RUN` command (using an `&&`), to not waste Docker layer space
RUN nativefier https://github.com/nativefier/nativefier /tmp/nativefier \
&& nativefier -p osx https://github.com/nativefier/nativefier /tmp/nativefier \
&& nativefier -p windows https://github.com/nativefier/nativefier /tmp/nativefier \
### Check that installation was sucessfull and chache all electron installation.
### Ensures that no addtional download will needed at runtime exectuion `docker run`.
RUN nativefier https://github.com/jiahaog/nativefier /tmp/nativefier \
&& nativefier -p osx https://github.com/jiahaog/nativefier /tmp/nativefier \
# TODO: windows are currently not possible, because of non 64-bit `node-rcedit`, see https://github.com/electron/node-rcedit/issues/22.
# && nativefier -p windows https://github.com/jiahaog/nativefier /tmp/nativefier \
#remove not need test aplication
&& rm -rf /tmp/nativefier
RUN echo Generated Electron cache size: $(du -sh ~/.cache/electron) \
&& echo Final image size: $(du -sh / 2>/dev/null)
ENTRYPOINT ["nativefier"]
CMD ["--help"]

View File

@ -1,255 +0,0 @@
# Development Guide
Welcome, soon-to-be contributor 🙂! This document sums up
what you need to know to get started hacking on Nativefier.
## Guidelines
1. **Before starting work on a huge change, gauge the interest**
of community & maintainers through a GitHub issue. For big changes,
create a **[RFC](https://en.wikipedia.org/wiki/Request_for_Comments)**
issue to enable a good peer review.
2. Do your best to **avoid adding new Nativefier command-line options**.
If a new option is inevitable for what you want to do, sure,
but as much as possible try to see if you change works without.
Nativefier already has a ton of them, making it hard to use.
3. Do your best to **limit breaking changes**.
Only introduce breaking changes when necessary, when required by deps, or when
not breaking would be unreasonable. When you can, support the old thing forever.
For example, keep maintaining old flags; to "replace" an flag you want to replace
with a better version, you should keep honoring the old flag, and massage it
to pass parameters to the new flag, maybe using a wrapper/adapter.
Yes, our code will get a tiny bit uglier than it could have been with a hard
breaking change, but that would be to ignore our users.
Introducing breaking changes willy nilly is a comfort to us developers, but is
disrespectful to end users who must constantly bend to the flow of breaking changes
pushed by _all their software_ who think it's "just one breaking change".
See [Rich Hickey - Spec-ulation](https://www.youtube.com/watch?v=oyLBGkS5ICk).
4. **Avoid adding npm dependencies**. Each new dep is a complexity & security liability.
You might be thinking your extra dep is _"just a little extra dep"_, and maybe
you found one that is high-quality & dependency-less. Still, it's an extra dep,
and over the life of Nativefier we requested changes to _dozens_ of PRs to avoid
"just a little extra dep". Without this constant attention, Nativefier would be
more bloated, less stable for users, more annoying to maintainers. Now, don't go
rewriting zlib if you need a zlib dep, for sure use a dep. But if you can write a
little helper function saving us a dep for a mundane task, go for the helper :) .
Also, an in-tree helper will always be less complex than a dep, as inherently
more tailored to our use case, and less complexity is good.
5. Use **types**, avoid `any`, write **tests**.
6. **Document for users** in `API.md`
7. **Document for other devs** in comments, jsdoc, commits, PRs.
Say _why_ more than _what_, the _what_ is your code!
## Setup
First, clone the project:
```bash
git clone https://github.com/nativefier/nativefier.git
cd nativefier
```
Install dependencies (for both the CLI and the Electron app):
```bash
npm ci
```
The above `npm ci` will build automatically (through the `prepare` hook).
When you need to re-build Nativefier,
```bash
npm run build
```
Set up a symbolic link so that running `nativefier` calls your dev version with your changes:
```bash
npm link
which nativefier
# -> Should return a path, e.g. /home/youruser/.node_modules/lib/node_modules/nativefier
# If not, be sure your `npm_config_prefix` env var is set and in your `PATH`
```
After doing so, you can run Nativefier with your test parameters:
```bash
nativefier --your-awesome-new-flag 'https://your-test-site.com'
```
Then run your nativefier app _through the command line too_ (to see logs & errors):
```bash
# Under Linux
./your-test-site-linux-x64/your-test-site
# Under Windows
your-test-site-win32-x64/your-test-site.exe
# Under macOS
./YourTestSite-darwin-x64/YourTestSite.app/Contents/MacOS/YourTestSite --verbose
```
## Linting & formatting
Nativefier uses [Prettier](https://prettier.io/), which will shout at you for
not formatting code exactly like it expects. This guarantees a homogenous style,
but is painful to do manually. Do yourself a favor and install a
[Prettier plugin for your editor](https://prettier.io/docs/en/editors.html).
## Tests
- To run all tests, `npm t`
- To run only unit tests, `npm run test:unit`
- To run only integration tests, `npm run test:integration`
- Logging is suppressed by default in tests, to avoid polluting Jest output.
To get debug logs, `npm run test:withlog` or set the `LOGLEVEL` env. var.
- For a good live experience, open two terminal panes/tabs running code/tests watchers:
1. Run a TSC watcher: `npm run build:watch`
2. Run a Jest unit tests watcher: `npm run test:watch`
3. Here is [a screencast of how the live-reload experience should look like](https://user-images.githubusercontent.com/522085/120407694-abdf3f00-c31b-11eb-9ab5-a531a929adb9.mp4)
- Alternatively, you can run both test processes in the same terminal by running: `npm run watch`
## Maintainers corner
### Deps: major-upgrading Electron
When a new major [Electron release](https://github.com/electron/electron/releases) occurs,
1. Wait a few weeks to let it stabilize. Never upgrade Nativefier to a `.0.0`.
2. Thoroughly digest the new version's [breaking changes](https://www.electronjs.org/docs/breaking-changes)
(also via the [Releases page](https://github.com/electron/electron/releases) and [the blog](https://www.electronjs.org/blog/), the content is different),
grepping our codebase for every changed API.
- If called for by the breaking changes, perform the necessary API changes
3. Bump
- `src/constants.ts` / `DEFAULT_ELECTRON_VERSION` & `DEFAULT_CHROME_VERSION`
- `package.json / devDeps / electron`
- `app / package.json / devDeps / electron`
4. On Windows, macOS, Linux, test for regression and crashes:
1. With `npm test` and `npm run test:manual`
2. With extra manual testing
5. When confident enough, release it in a regression-spelunking-friendly way:
1. If `master` has unreleased commits, make a patch/minor release with them, but without the major Electron bump.
2. Commit your Electron major bump and release it as a major new Nativefier version. Help users identify the breaking change by using a bold **[BREAKING]** marker in `CHANGELOG.md` and in the GitHub release.
### Deps updates
It is important to stay afloat of dependencies upgrades.
In packages ecosystems like npm, there's only one way: forward.
The best time to do package upgrades is now / progressively, because:
1. Slacking on doing these upgrades means you stay behind, and it becomes
risky to do them. Upgrading a woefully out-of-date dep from 3.x to 9.x is
scarier than 3.x to 4.x, release, then 4.x to 5.x, release, etc... to 9.x.
2. Also, dependencies applying security patches to old major versions are rare
in npm. So, by slacking on upgrades, it becomes more and more probable that
we get impacted by a vulnerability. And when this happens, it then becomes
urgent & stressful to A. fix the vulnerability, B. do the required major upgrades.
So: do upgrade CLI & App deps regularly! Our release script will remind you about it.
### Deps lockfile / shrinkwrap
We do use lockfiles (`npm-shrinkwrap.json` & `app/npm-shrinkwrap.json`), for:
1. Security (avoiding supply chain attacks)
2. Reproducibility
3. Performance
It means you might have to update these lockfiles when adding a dependency.
`npm run relock` will help you with that.
Note: we do use `npm-shrinkwrap.json` rather than `package-lock.json` because
the latter is tailored to libraries, and is not publishable.
As [documented](https://docs.npmjs.com/cli/v6/configuring-npm/shrinkwrap-json),
CLI tools like Nativefier should use shrinkwrap.
### Release
While on `master`, with no uncommitted changes, run:
```bash
npm run changelog -- $VERSION
# With no 'v'. For example: npm run changelog -- '42.5.0'
```
Do follow semantic versioning, and give visibility to breaking changes
in release notes by prefixing their line with **[BREAKING]**.
### Triage
These are the guidelines we (try to) follow when triaging [issues](https://github.com/nativefier/nativefier/issues):
1. Do your best to conciliate **empathy & efficiency, and keep your cool**.
Its not always easy 😄😬😭🤬. Get away from triaging if you feel grouchy.
2. **Rename** issues. Most issues are badly named, with titles ranging from
unclear to flat out wrong. A good backlog is a backlog of issues with clear
concise titles, understandable with only the title after you read them once.
Rename and clarify.
3. **Ask for clarification & details** when needed, and add a `need-info` label.
1. In particular, if the issue isnt reproducible (e.g. a non-trivial bug
happening on an internal site), express that we cant work without a
repro scenario, and flag as `need-info`.
4. **Label** issues with _category/sorting_ labels (e.g. `mac` / `linux` / `windows`,
`bug` / `feature-request` ...) and _status_ labels (e.g. `upstream`, `wontfix`,
`need-info`, `cannot-reproduce`).
5. **Close if needed, but not too much**. We _do_ want to close what deserves it,
but closing _too_ ruthlessly frustrates and disappoints users, and does us a
disservice of not having a clear honest backlog available to us & users. So,
1. When in doubt, leave issues open and triaged as `bug` / `feature-request`.
Its okay, reaching 0 open issues is _not_ an objective. Or if it is,
it deserves to be a development objective, not a triage one.
2. That being said, do close whats `upstream`, with a kind message.
3. Also do close bugs that have been `need-info` or `cannot-reproduce` for
too long (weeks / months), with a kind message explaining were okay to
re-open if the requested info / scenario is provided.
4. Finally, carefully close issues we do not want to address, e.g. requests
going against project goals, or bugs & feature requests that are so niche
or far-fetched that theres zero chance of ever seeing them addressed.
But if in doubt, remain at point 1. above: leave open, renamed, labelled.
6. **Close duplicates issues** and link to the original issue.
1. To be able to notice dups implies you must know the backlog (one more
reason to keep it tidy and palatable). Once in a blue moon, do a
"full pass" of the whole backlog from beginning to end, youll often
find lots of now-irrelevant bugs, and duplicates.
7. **Use [GitHub saved replies](https://github.com/settings/replies)** to
automate asking for info and being nice on closing as noanswer / stale-needinfo.
8. **Transform findings stemming from issues discussion** into documentation
(chiefly, [CATALOG.md](CATALOG.md) & [API.md](API.md)), or into code comments.
9. **Dont scold authors of lame "+1" comments**, this only adds to the noise
youre trying to avoid. Instead, hide useless comments as `Off-topic`.
From personal experience, users do understand this signal, and such hidden
comments do avoid an avalanche of extra "+1" comments.
1. There are shades of lame. A literal `"+1"` comment is frankly useless and
is worth hiding. But a comment like `"same for me on Windows"` at least
brings an extra bit of information, so can remain visible.
2. In a perfect world, GitHub would let us add a note when hiding comments to
express _"Please use a 👍 reaction on the issue to vote for it instead of_
_posting a +1 comment"_. In a perfecter world, GitHub would use their AI
skillz to automatically detect such comments, discourage them and nudge
towards a 👍 reaction. Were not there yet, so “hidden as off-topic” will do.
10. **Dont let yourself be abused** by abrasive / entitled users. There are
plenty of articles documenting open-source burnout and trolls-induced misery.
Find an article that speaks to you, and point problematic users to it.
I like [Brett Cannon - The social contract of open source](https://snarky.ca/the-social-contract-of-open-source/).

171
README.md
View File

@ -1,101 +1,138 @@
Note: Nativefier is unmaintained, please see https://github.com/nativefier/nativefier/issues/1577.
# Nativefier
![Example of Nativefier app in the macOS dock](.github/dock-screenshot.png)
[![Build Status](https://travis-ci.org/jiahaog/nativefier.svg?branch=development)](https://travis-ci.org/jiahaog/nativefier)
[![Code Climate](https://codeclimate.com/github/jiahaog/nativefier/badges/gpa.svg)](https://codeclimate.com/github/jiahaog/nativefier)
[![npm version](https://badge.fury.io/js/nativefier.svg)](https://www.npmjs.com/package/nativefier)
[![Dependency Status](https://david-dm.org/jiahaog/nativefier.svg)](https://david-dm.org/jiahaog/nativefier)
You want to make a native-looking wrapper for WhatsApp Web (or any web page).
![Dock](screenshots/dock.png)
You want to make a native wrapper for WhatsApp Web (or any web page).
```bash
nativefier 'web.whatsapp.com'
nativefier web.whatsapp.com
```
![Walkthrough animation](.github/nativefier-walkthrough.gif)
![Walkthrough](screenshots/walkthrough.gif)
You're done.
## Table of Contents
- [Installation](#installation)
- [Usage](#usage)
- [Optional Dependencies](#optional-dependencies)
- [How It Works](#how-it-works)
- [API Documentation](docs/api.md)
- [Changelog](docs/changelog.md)
- [Development](docs/development.md)
- [License](#license)
## Introduction
Nativefier is a command-line tool to easily create a “desktop app” for any web site
with minimal fuss. Apps are wrapped by [Electron](https://www.electronjs.org/)
(which uses Chromium under the hood) in an OS executable (`.app`, `.exe`, etc)
usable on Windows, macOS and Linux.
Nativefier is a command line tool that allows you to easily create a desktop application for any web site with succinct and minimal configuration. Apps are wrapped by [Electron](http://electron.atom.io) in an OS executable (`.app`, `.exe`, etc.) for use on Windows, macOS and Linux.
I built this because I grew tired of having to Alt-Tab to my browser and then search
through numerous open tabs when using Messenger or
Whatsapp Web ([HN thread](https://news.ycombinator.com/item?id=10930718)). Nativefier features:
I did this because I was tired of having to `⌘-tab` or `alt-tab` to my browser and then search through the numerous open tabs when I was using [Facebook Messenger](http://messenger.com) or [Whatsapp Web](http://web.whatsapp.com).
- Automatically retrieval of app icon / name
- Injection of custom JS & CSS
- Many more, see the [API docs](API.md) or `nativefier --help`
View the changelog [here](https://github.com/jiahaog/nativefier/blob/development/docs/changelog.md).
[Relevant Hacker News Thread](https://news.ycombinator.com/item?id=10930718)
### Features
- Automatically retrieves the correct icon and app name
- Flash Support (with [`--flash`](docs/api.md#flash) flag)
- Javascript and CSS injection
## Installation
Install Nativefier globally with `npm install -g nativefier` . Requirements:
- macOS 10.13+ / Windows / Linux
- [Node.js](https://nodejs.org/) ≥ 16.9 and npm ≥ 7.10
Optional dependencies:
- [ImageMagick](http://www.imagemagick.org/) or [GraphicsMagick](http://www.graphicsmagick.org/) to convert icons.
Be sure `convert` + `identify` or `gm` are in your `$PATH`.
- [Wine](https://www.winehq.org/) to build Windows apps from non-Windows platforms.
Be sure `wine` is in your `$PATH`.
<details>
<summary>Or install with Docker (click to expand)</summary>
- Pull the image from [Docker Hub](https://hub.docker.com/r/nativefier/nativefier): `docker pull nativefier/nativefier`
- ... or build it yourself: `docker build -t local/nativefier .`
(in this case, replace `nativefier/` in the below examples with `local/`)
By default, `nativefier --help` will be executed.
To build e.g. a Gmail app into `~/nativefier-apps`,
### Requirements
- macOS 10.9+ / Windows / Linux
- [Node.js](https://nodejs.org/) `>=4`
```bash
docker run --rm -v ~/nativefier-apps:/target/ nativefier/nativefier https://mail.google.com/ /target/
npm install nativefier -g
```
You can pass Nativefier flags, and mount volumes to pass local files. E.g. to use an icon,
```bash
docker run --rm -v ~/my-icons-folder/:/src -v $TARGET-PATH:/target nativefier/nativefier --icon /src/icon.png --name whatsApp -p linux -a x64 https://web.whatsapp.com/ /target/
```
</details>
<details>
<summary>Or install with Snap & AUR (click to expand)</summary>
These repos are *not* managed by Nativefier maintainers; use at your own risk.
If using them, for your security, please inspect the build script.
- [Snap](https://snapcraft.io/nativefier)
- [AUR](https://aur.archlinux.org/packages/nodejs-nativefier)
</details>
See [optional dependencies](#optional-dependencies) for more.
## Usage
To create an app for medium.com, simply `nativefier 'medium.com'`
Creating a native desktop app for [medium.com](http://medium.com):
Nativefier will try to determine the app name, and well as other options that you
can override. For example, to override the name, `nativefier --name 'My Medium App' 'medium.com'`
```bash
nativefier "http://medium.com"
```
**Read the [API docs](API.md) or run `nativefier --help`**
to learn about command-line flags and configure your app.
Nativefier will intelligently attempt to determine the app name, your OS and processor architecture, among other options. If desired, the app name or other options can be overwritten by specifying the `--name "Medium"` as part of the command line options, as such.
## Troubleshooting
```bash
nativefier --name "Some Awesome App" "http://medium.com"
```
Read the [API documentation](docs/api.md) for other command line flags and options that can be used to configure the packaged app.
**See [CATALOG.md](CATALOG.md) for site-specific ideas & workarounds contributed by the community**.
If you would like high resolution icons to be used, please contribute to the [icon repository](https://github.com/jiahaog/nativefier-icons)!
If this doesnt help, go look at our [issue tracker](https://github.com/nativefier/nativefier/issues).
**For Windows Users:** Take note that the application menu is automatically hidden by default, you can press `alt` on your keyboard to access it.
**For Linux Users:** Do not put spaces if you define the app name yourself with `--name`, as this will cause problems (tested on Ubuntu 14.04) when pinning a packaged app to the launcher.
## Optional Dependencies
### Icons for Windows Apps from non-Windows platforms
You need [Wine](https://www.winehq.org/) installed, make sure that `wine` is in your `$PATH`.
### Icon Conversion for macOS
To support conversion of a `.png` or `.ico` into a `.icns` for a packaged macOS app icon (currently only supported on macOS), you need the following dependencies.
#### [iconutil](https://developer.apple.com/library/mac/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Optimizing/Optimizing.html)
You need [Xcode](https://developer.apple.com/xcode/) installed.
#### [imagemagick](http://www.imagemagick.org/script/index.php)
Make sure `convert` and `identify` are in your `$PATH`.
### Flash
#### [Google Chrome](https://www.google.com/chrome/)
Google Chrome is required for flash to be supported. Alternatively, you could download the PepperFlash Chrome plugin and specify the path to it directly with the `--flash` flag. See the command line options below for more details.
## How It Works
A template app with the appropriate event listeners and callbacks set up is included in the `./app` folder. When the `nativefier` command is executed, this folder is copied to a temporary directory with the appropriate parameters in a configuration file, and is packaged into an app with [Electron Packager](https://github.com/electron-userland/electron-packager).
In addition, I built [GitCloud](https://github.com/jiahaog/gitcloud) to use GitHub as an icon index, and also the [pageIcon](https://github.com/jiahaog/page-icon) fallback to infer a relevant icon from a url.
## API Documentation
See [API](docs/api.md).
## Changelog
See [Changelog](docs/changelog.md).
## Development
Help welcome on [bugs](https://github.com/nativefier/nativefier/issues?q=is%3Aopen+is%3Aissue+label%3Abug) and
[feature requests](https://github.com/nativefier/nativefier/issues?q=is%3Aopen+is%3Aissue+label%3Afeature-request)!
See [Development](docs/development.md).
Docs: [Developer / build / hacking](HACKING.md), [API / flags](API.md),
[Changelog](CHANGELOG.md).
## Docker Image
License: [MIT](LICENSE.md).
The [Dockerfile](Dockerfile) is designed that you can use it like the "normal" nativefier app. By default the command `nativefier --version` will be executed. Before you can use the Image you have to build it like follow:
docker build -t local/nativefier .
After that you can build your first nativefier app to the local `$TARGET-PATH`. Please ensure that you have write access to the `$TARGET-PATH`:
docker run -v $TARGET-PATH:/target local/nativefier https://my-web-app.com/ /target/
You can also use additional source or nativefier options like e.g. use a icon:
docker run -v $PATH_TO_ICON/:/src -v $TARGET-PATH:/target local/nativefier --icon /src/icon.png --name whatsApp -p linux -a x64 https://my-web-app.com/ /target/
## License
[MIT](LICENSE.md)

View File

@ -1,21 +0,0 @@
const base = require('../base-eslintrc');
// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
module.exports = {
parser: base.parser,
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: base.plugins,
extends: base.extends,
rules: base.rules,
// https://eslint.org/docs/user-guide/configuring/ignoring-code#ignorepatterns-in-config-files
ignorePatterns: [
'node_modules/**',
'lib/**',
'dist/**',
'built-tests/**',
'coverage/**',
],
};

2
app/.eslintrc.yml Normal file
View File

@ -0,0 +1,2 @@
settings:
import/core-modules: [ electron ]

1170
app/npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

113
app/package-lock.json generated Normal file
View File

@ -0,0 +1,113 @@
{
"name": "nativefier-placeholder",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"electron-dl": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/electron-dl/-/electron-dl-1.8.0.tgz",
"integrity": "sha1-VmUtpVqv2kpwB09jE/33+UHsdSo=",
"requires": {
"pupa": "1.0.0",
"unused-filename": "0.1.0"
},
"dependencies": {
"pupa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/pupa/-/pupa-1.0.0.tgz",
"integrity": "sha1-mpVopa9+ZXuEYqbp1TKHQ1YM7/Y="
},
"unused-filename": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/unused-filename/-/unused-filename-0.1.0.tgz",
"integrity": "sha1-5fM7yeSmP4f2TTwR0xl53vXS5/s=",
"requires": {
"modify-filename": "1.1.0",
"path-exists": "3.0.0"
},
"dependencies": {
"modify-filename": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/modify-filename/-/modify-filename-1.1.0.tgz",
"integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE="
},
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
}
}
}
}
},
"electron-window-state": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-4.1.1.tgz",
"integrity": "sha1-azT9wxs4UU3+yLfI97XUrdtnYy0=",
"requires": {
"deep-equal": "1.0.1",
"jsonfile": "2.4.0",
"mkdirp": "0.5.1"
},
"dependencies": {
"deep-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
"integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU="
},
"jsonfile": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz",
"integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=",
"requires": {
"graceful-fs": "4.1.11"
},
"dependencies": {
"graceful-fs": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
"integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=",
"optional": true
}
}
},
"mkdirp": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"requires": {
"minimist": "0.0.8"
},
"dependencies": {
"minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
}
}
}
}
},
"source-map-support": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.14.tgz",
"integrity": "sha1-nURjdyWYuGJxtPUj9sH04Cp9au8=",
"requires": {
"source-map": "0.5.6"
},
"dependencies": {
"source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
}
}
},
"wurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/wurl/-/wurl-2.1.0.tgz",
"integrity": "sha1-ciS9DmYuUTUiHSWdjWAUHC0T0xA="
}
}
}

View File

@ -3,23 +3,20 @@
"version": "1.0.0",
"description": "Placeholder for the nativefier cli to override with a target url",
"main": "lib/main.js",
"author": "Jia Hao",
"license": "MIT",
"dependencies": {
"electron-dl": "^1.1.0",
"electron-window-state": "^4.1.0",
"source-map-support": "^0.4.0",
"wurl": "^2.1.0"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"desktop",
"electron",
"placeholder"
"electron"
],
"scripts": {},
"dependencies": {
"electron-context-menu": "^3.6.1",
"electron-dl": "^3.5.0",
"electron-squirrel-startup": "^1.0.0",
"electron-window-state": "^5.0.3",
"loglevel": "^1.8.1",
"source-map-support": "^0.5.21"
},
"devDependencies": {
"electron": "^25.7.0"
}
"author": "Jia Hao",
"license": "MIT"
}

View File

@ -1,84 +0,0 @@
import {
BrowserWindow,
ContextMenuParams,
Event as ElectronEvent,
} from 'electron';
import contextMenu from 'electron-context-menu';
import { nativeTabsSupported, openExternal } from '../helpers/helpers';
import * as log from '../helpers/loggingHelper';
import { setupNativefierWindow } from '../helpers/windowEvents';
import { createNewWindow } from '../helpers/windowHelpers';
import {
OutputOptions,
outputOptionsToWindowOptions,
} from '../../../shared/src/options/model';
export function initContextMenu(
options: OutputOptions,
window?: BrowserWindow,
): void {
log.debug('initContextMenu');
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
contextMenu({
prepend: (actions: contextMenu.Actions, params: ContextMenuParams) => {
log.debug('contextMenu.prepend', { actions, params });
const items = [];
if (params.linkURL && window) {
items.push({
label: 'Open Link in Default Browser',
click: () => {
openExternal(params.linkURL).catch((err) =>
log.error('contextMenu Open Link in Default Browser ERROR', err),
);
},
});
items.push({
label: 'Open Link in New Window',
click: () =>
createNewWindow(
outputOptionsToWindowOptions(options, nativeTabsSupported()),
setupNativefierWindow,
params.linkURL,
// window,
),
});
if (nativeTabsSupported()) {
items.push({
label: 'Open Link in New Tab',
click: () =>
// // Fire a new window event for a foreground tab
// // Previously we called createNewTab directly, but it had incosistent and buggy behavior
// // as it was mostly designed for running off of events. So this will create a new event
// // for a foreground-tab for the event handler to grab and take care of instead.
// (window as BrowserWindow).webContents.emit(
// // event name
// 'new-window',
// // event object
// {
// // Leave to the default for a NewWindowWebContentsEvent
// newGuest: undefined,
// ...new Event('new-window'),
// }, // as NewWindowWebContentsEvent,
// // url
// params.linkURL,
// // frameName
// window?.webContents.mainFrame.name ?? '',
// // disposition
// 'foreground-tab',
// ),
window.emit('new-window-for-tab', {
...new Event('new-window-for-tab'),
url: params.linkURL,
} as ElectronEvent<{ url: string }>),
});
}
}
return items;
},
showCopyImage: true,
showCopyImageAddress: true,
showSaveImage: true,
});
}

View File

@ -0,0 +1,48 @@
// Because we are changing the properties of `mainWindow` in initContextMenu()
/* eslint-disable no-param-reassign */
import { Menu, ipcMain, shell, clipboard, BrowserWindow } from 'electron';
function initContextMenu(mainWindow) {
ipcMain.on('contextMenuOpened', (event, targetHref) => {
const contextMenuTemplate = [
{
label: 'Open with default browser',
click: () => {
if (targetHref) {
shell.openExternal(targetHref);
}
},
},
{
label: 'Open in new window',
click: () => {
if (targetHref) {
new BrowserWindow().loadURL(targetHref);
return;
}
mainWindow.useDefaultWindowBehaviour = true;
mainWindow.webContents.send('contextMenuClosed');
},
},
{
label: 'Copy link location',
click: () => {
if (targetHref) {
clipboard.writeText(targetHref);
return;
}
mainWindow.useDefaultWindowBehaviour = true;
mainWindow.webContents.send('contextMenuClosed');
},
},
];
const contextMenu = Menu.buildFromTemplate(contextMenuTemplate);
contextMenu.popup(mainWindow);
mainWindow.contextMenuOpen = true;
});
}
export default initContextMenu;

View File

@ -0,0 +1,20 @@
import { BrowserWindow, ipcMain } from 'electron';
import path from 'path';
function createLoginWindow(loginCallback) {
const loginWindow = new BrowserWindow({
width: 300,
height: 400,
frame: false,
resizable: false,
});
loginWindow.loadURL(`file://${path.join(__dirname, '/static/login/login.html')}`);
ipcMain.once('login-message', (event, usernameAndPassword) => {
loginCallback(usernameAndPassword[0], usernameAndPassword[1]);
loginWindow.close();
});
return loginWindow;
}
export default createLoginWindow;

View File

@ -1,39 +0,0 @@
import * as path from 'path';
import { BrowserWindow, ipcMain } from 'electron';
import * as log from '../helpers/loggingHelper';
import { nativeTabsSupported } from '../helpers/helpers';
export async function createLoginWindow(
loginCallback: (username?: string, password?: string) => void,
parent?: BrowserWindow,
): Promise<BrowserWindow> {
log.debug('createLoginWindow', {
loginCallback,
parent,
});
const loginWindow = new BrowserWindow({
parent: nativeTabsSupported() ? undefined : parent,
width: 300,
height: 400,
frame: false,
resizable: false,
webPreferences: {
nodeIntegration: true, // TODO work around this; insecure
contextIsolation: false, // https://github.com/electron/electron/issues/28017
sandbox: false, // https://www.electronjs.org/blog/electron-20-0#default-changed-renderers-without-nodeintegration-true-are-sandboxed-by-default
},
});
await loginWindow.loadURL(
`file://${path.join(__dirname, 'static/login.html')}`,
);
ipcMain.once('login-message', (event, usernameAndPassword: string[]) => {
log.debug('login-message', { event, username: usernameAndPassword[0] });
loginCallback(usernameAndPassword[0], usernameAndPassword[1]);
loginWindow.close();
});
return loginWindow;
}

View File

@ -1,336 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import {
desktopCapturer,
ipcMain,
BrowserWindow,
Event,
HandlerDetails,
} from 'electron';
import windowStateKeeper from 'electron-window-state';
import { initContextMenu } from './contextMenu';
import { createMenu } from './menu';
import {
getAppIcon,
getCounterValue,
isOSX,
nativeTabsSupported,
} from '../helpers/helpers';
import * as log from '../helpers/loggingHelper';
import { IS_PLAYWRIGHT } from '../helpers/playwrightHelpers';
import { onNewWindow, setupNativefierWindow } from '../helpers/windowEvents';
import {
clearCache,
createNewTab,
getDefaultWindowOptions,
hideWindow,
} from '../helpers/windowHelpers';
import {
OutputOptions,
outputOptionsToWindowOptions,
} from '../../../shared/src/options/model';
export const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json');
type SessionInteractionRequest = {
id?: string;
func?: string;
funcArgs?: unknown[];
property?: string;
propertyValue?: unknown;
};
type SessionInteractionResult<T = unknown> = {
id?: string;
value?: T | Promise<T>;
error?: Error;
};
/**
* @param {{}} nativefierOptions AppArgs from nativefier.json
* @param {function} setDockBadge
*/
export async function createMainWindow(
nativefierOptions: OutputOptions,
setDockBadge: (value: number | string, bounce?: boolean) => void,
): Promise<BrowserWindow> {
const options = { ...nativefierOptions };
const mainWindowState = windowStateKeeper({
defaultWidth: options.width || 1280,
defaultHeight: options.height || 800,
});
const mainWindow = new BrowserWindow({
frame: !options.hideWindowFrame,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: options.minWidth,
minHeight: options.minHeight,
maxWidth: options.maxWidth,
maxHeight: options.maxHeight,
x: options.x,
y: options.y,
autoHideMenuBar: !options.showMenuBar,
icon: getAppIcon(),
fullscreen: options.fullScreen,
// Whether the window should always stay on top of other windows. Default is false.
alwaysOnTop: options.alwaysOnTop,
titleBarStyle: options.titleBarStyle ?? 'default',
// Maximize window visual glitch on Windows fix
// We want a consistent behavior on all OSes, but Windows needs help to not glitch.
// So, we manually mainWindow.show() later, see a few lines below
show: options.tray !== 'start-in-tray' && process.platform !== 'win32',
backgroundColor: options.backgroundColor,
...getDefaultWindowOptions(
outputOptionsToWindowOptions(options, nativeTabsSupported()),
),
});
// Just load about:blank to start, gives playwright something to latch onto initially for testing.
if (IS_PLAYWRIGHT) {
await mainWindow.loadURL('about:blank');
}
mainWindowState.manage(mainWindow);
// after first run, no longer force maximize to be true
if (options.maximize) {
mainWindow.maximize();
options.maximize = undefined;
saveAppArgs(options);
}
if (options.tray === 'start-in-tray') {
mainWindow.hide();
} else if (process.platform === 'win32') {
// See other "Maximize window visual glitch on Windows fix" comment above.
mainWindow.show();
}
const windowOptions = outputOptionsToWindowOptions(
options,
nativeTabsSupported(),
);
createMenu(options, mainWindow);
createContextMenu(options, mainWindow);
setupNativefierWindow(windowOptions, mainWindow);
// Note it is important to add these handlers only to the *main* window,
// else we run into weird behavior like opening tabs twice
mainWindow.webContents.setWindowOpenHandler((details: HandlerDetails) => {
return onNewWindow(
windowOptions,
setupNativefierWindow,
details,
mainWindow,
);
});
mainWindow.on('new-window-for-tab', (event?: Event<{ url?: string }>) => {
log.debug('mainWindow.new-window-for-tab', { event });
createNewTab(
windowOptions,
setupNativefierWindow,
event?.url ?? options.targetUrl,
true,
// mainWindow,
);
});
if (options.counter) {
setupCounter(options, mainWindow, setDockBadge);
} else {
setupNotificationBadge(options, mainWindow, setDockBadge);
}
ipcMain.on('notification-click', () => {
log.debug('ipcMain.notification-click');
mainWindow.show();
});
setupSessionInteraction(mainWindow);
setupSessionPermissionHandler(mainWindow);
if (options.clearCache) {
await clearCache(mainWindow);
}
setupCloseEvent(options, mainWindow);
return mainWindow;
}
function createContextMenu(
options: OutputOptions,
window: BrowserWindow,
): void {
if (!options.disableContextMenu) {
initContextMenu(options, window);
}
}
export function saveAppArgs(newAppArgs: OutputOptions): void {
try {
fs.writeFileSync(APP_ARGS_FILE_PATH, JSON.stringify(newAppArgs, null, 2));
} catch (err: unknown) {
log.warn(
`WARNING: Ignored nativefier.json rewrital (${(err as Error).message})`,
);
}
}
function setupCloseEvent(options: OutputOptions, window: BrowserWindow): void {
window.on('close', (event: Event) => {
log.debug('mainWindow.close', event);
if (window.isFullScreen()) {
if (nativeTabsSupported()) {
window.moveTabToNewWindow();
}
window.setFullScreen(false);
window.once('leave-full-screen', (event: Event) =>
hideWindow(
window,
event,
options.fastQuit ?? false,
options.tray ?? 'false',
),
);
}
hideWindow(
window,
event,
options.fastQuit ?? false,
options.tray ?? 'false',
);
if (options.clearCache) {
clearCache(window).catch((err) => log.error('clearCache ERROR', err));
}
});
}
function setupCounter(
options: OutputOptions,
window: BrowserWindow,
setDockBadge: (value: number | string, bounce?: boolean) => void,
): void {
window.on('page-title-updated', (event, title) => {
log.debug('mainWindow.page-title-updated', { event, title });
const counterValue = getCounterValue(title);
if (counterValue) {
setDockBadge(counterValue, options.bounce);
} else {
setDockBadge('');
}
});
}
function setupSessionPermissionHandler(window: BrowserWindow): void {
window.webContents.session.setPermissionCheckHandler(() => {
return true;
});
window.webContents.session.setPermissionRequestHandler(
(_webContents, _permission, callback) => {
callback(true);
},
);
ipcMain.handle('desktop-capturer-get-sources', () => {
return desktopCapturer.getSources({
types: ['screen', 'window'],
});
});
}
function setupNotificationBadge(
options: OutputOptions,
window: BrowserWindow,
setDockBadge: (value: number | string, bounce?: boolean) => void,
): void {
ipcMain.on('notification', () => {
log.debug('ipcMain.notification');
if (!isOSX() || window.isFocused()) {
return;
}
setDockBadge('•', options.bounce);
});
window.on('focus', () => {
log.debug('mainWindow.focus');
setDockBadge('');
});
}
function setupSessionInteraction(window: BrowserWindow): void {
// See API.md / "Accessing The Electron Session"
ipcMain.on(
'session-interaction',
(event, request: SessionInteractionRequest) => {
log.debug('ipcMain.session-interaction', { event, request });
const result: SessionInteractionResult = { id: request.id };
let awaitingPromise = false;
try {
if (request.func !== undefined) {
// If no funcArgs provided, we'll just use an empty array
if (request.funcArgs === undefined || request.funcArgs === null) {
request.funcArgs = [];
}
// If funcArgs isn't an array, we'll be nice and make it a single item array
if (typeof request.funcArgs[Symbol.iterator] !== 'function') {
request.funcArgs = [request.funcArgs];
}
// Call func with funcArgs
// @ts-expect-error accessing a func by string name
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
result.value = window.webContents.session[request.func](
...request.funcArgs,
);
if (result.value !== undefined && result.value instanceof Promise) {
// This is a promise. We'll resolve it here otherwise it will blow up trying to serialize it in the reply
(result.value as Promise<unknown>)
.then((trueResultValue) => {
result.value = trueResultValue;
log.debug('ipcMain.session-interaction:result', result);
event.reply('session-interaction-reply', result);
})
.catch((err) =>
log.error('session-interaction ERROR', request, err),
);
awaitingPromise = true;
}
} else if (request.property !== undefined) {
if (request.propertyValue !== undefined) {
// Set the property
// @ts-expect-error setting a property by string name
window.webContents.session[request.property] =
request.propertyValue;
}
// Get the property value
// @ts-expect-error accessing a property by string name
result.value = window.webContents.session[request.property];
} else {
// Why even send the event if you're going to do this? You're just wasting time! ;)
throw new Error(
'Received neither a func nor a property in the request. Unable to process.',
);
}
// If we are awaiting a promise, that will return the reply instead, else
if (!awaitingPromise) {
log.debug('session-interaction:result', result);
event.reply('session-interaction-reply', result);
}
} catch (err: unknown) {
log.error('session-interaction:error', err, event, request);
result.error = err as Error;
result.value = undefined; // Clear out the value in case serializing the value is what got us into this mess in the first place
event.reply('session-interaction-reply', result);
}
},
);
}

View File

@ -0,0 +1,231 @@
import fs from 'fs';
import path from 'path';
import { BrowserWindow, shell, ipcMain, dialog } from 'electron';
import windowStateKeeper from 'electron-window-state';
import helpers from './../../helpers/helpers';
import createMenu from './../menu/menu';
import initContextMenu from './../contextMenu/contextMenu';
const { isOSX, linkIsInternal, getCssToInject, shouldInjectCss } = helpers;
const ZOOM_INTERVAL = 0.1;
function maybeHideWindow(window, event, fastQuit, tray) {
if (isOSX() && !fastQuit) {
// this is called when exiting from clicking the cross button on the window
event.preventDefault();
window.hide();
} else if (!fastQuit && tray) {
event.preventDefault();
window.hide();
}
// will close the window on other platforms
}
function maybeInjectCss(browserWindow) {
if (!shouldInjectCss()) {
return;
}
const cssToInject = getCssToInject();
const injectCss = () => {
browserWindow.webContents.insertCSS(cssToInject);
};
browserWindow.webContents.on('did-finish-load', () => {
// remove the injection of css the moment the page is loaded
browserWindow.webContents.removeListener('did-get-response-details', injectCss);
});
// on every page navigation inject the css
browserWindow.webContents.on('did-navigate', () => {
// we have to inject the css in did-get-response-details to prevent the fouc
// will run multiple times
browserWindow.webContents.on('did-get-response-details', injectCss);
});
}
/**
*
* @param {{}} inpOptions AppArgs from nativefier.json
* @param {function} onAppQuit
* @param {function} setDockBadge
* @returns {electron.BrowserWindow}
*/
function createMainWindow(inpOptions, onAppQuit, setDockBadge) {
const options = Object.assign({}, inpOptions);
const mainWindowState = windowStateKeeper({
defaultWidth: options.width || 1280,
defaultHeight: options.height || 800,
});
const mainWindow = new BrowserWindow({
frame: !options.hideWindowFrame,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: options.minWidth,
minHeight: options.minHeight,
maxWidth: options.maxWidth,
maxHeight: options.maxHeight,
x: mainWindowState.x,
y: mainWindowState.y,
autoHideMenuBar: !options.showMenuBar,
// Convert dashes to spaces because on linux the app name is joined with dashes
title: options.name,
webPreferences: {
javascript: true,
plugins: true,
// node globals causes problems with sites like messenger.com
nodeIntegration: false,
webSecurity: !options.insecure,
preload: path.join(__dirname, 'static', 'preload.js'),
zoomFactor: options.zoom,
},
// after webpack path here should reference `resources/app/`
icon: path.join(__dirname, '../', '/icon.png'),
// set to undefined and not false because explicitly setting to false will disable full screen
fullscreen: options.fullScreen || undefined,
});
mainWindowState.manage(mainWindow);
// after first run, no longer force maximize to be true
if (options.maximize) {
mainWindow.maximize();
options.maximize = undefined;
fs.writeFileSync(path.join(__dirname, '..', 'nativefier.json'), JSON.stringify(options));
}
let currentZoom = options.zoom;
const onZoomIn = () => {
currentZoom += ZOOM_INTERVAL;
mainWindow.webContents.send('change-zoom', currentZoom);
};
const onZoomOut = () => {
currentZoom -= ZOOM_INTERVAL;
mainWindow.webContents.send('change-zoom', currentZoom);
};
const onZoomReset = () => {
mainWindow.webContents.send('change-zoom', options.zoom);
};
const clearAppData = () => {
dialog.showMessageBox(mainWindow, {
type: 'warning',
buttons: ['Yes', 'Cancel'],
defaultId: 1,
title: 'Clear cache confirmation',
message: 'This will clear all data (cookies, local storage etc) from this app. Are you sure you wish to proceed?',
}, (response) => {
if (response !== 0) {
return;
}
const session = mainWindow.webContents.session;
session.clearStorageData(() => {
session.clearCache(() => {
mainWindow.loadURL(options.targetUrl);
});
});
});
};
const onGoBack = () => {
mainWindow.webContents.goBack();
};
const onGoForward = () => {
mainWindow.webContents.goForward();
};
const getCurrentUrl = () => mainWindow.webContents.getURL();
const menuOptions = {
nativefierVersion: options.nativefierVersion,
appQuit: onAppQuit,
zoomIn: onZoomIn,
zoomOut: onZoomOut,
zoomReset: onZoomReset,
zoomBuildTimeValue: options.zoom,
goBack: onGoBack,
goForward: onGoForward,
getCurrentUrl,
clearAppData,
disableDevTools: options.disableDevTools,
};
createMenu(menuOptions);
if (!options.disableContextMenu) {
initContextMenu(mainWindow);
}
if (options.userAgent) {
mainWindow.webContents.setUserAgent(options.userAgent);
}
maybeInjectCss(mainWindow);
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.send('params', JSON.stringify(options));
});
if (options.counter) {
mainWindow.on('page-title-updated', (e, title) => {
const itemCountRegex = /[([{](\d*?)\+?[}\])]/;
const match = itemCountRegex.exec(title);
if (match) {
setDockBadge(match[1]);
} else {
setDockBadge('');
}
});
} else {
ipcMain.on('notification', () => {
if (!isOSX() || mainWindow.isFocused()) {
return;
}
setDockBadge('•');
});
mainWindow.on('focus', () => {
setDockBadge('');
});
}
mainWindow.webContents.on('new-window', (event, urlToGo) => {
if (mainWindow.useDefaultWindowBehaviour) {
mainWindow.useDefaultWindowBehaviour = false;
return;
}
if (linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) {
return;
}
event.preventDefault();
shell.openExternal(urlToGo);
});
mainWindow.loadURL(options.targetUrl);
mainWindow.on('close', (event) => {
if (mainWindow.isFullScreen()) {
mainWindow.setFullScreen(false);
mainWindow.once('leave-full-screen', maybeHideWindow.bind(this, mainWindow, event, options.fastQuit));
}
maybeHideWindow(mainWindow, event, options.fastQuit, options.tray);
});
return mainWindow;
}
ipcMain.on('cancelNewWindowOverride', () => {
const allWindows = BrowserWindow.getAllWindows();
allWindows.forEach((window) => {
// eslint-disable-next-line no-param-reassign
window.useDefaultWindowBehaviour = false;
});
});
export default createMainWindow;

View File

@ -1,167 +0,0 @@
import { BrowserWindow, MenuItemConstructorOptions } from 'electron';
jest.mock('../helpers/helpers');
import { isOSX } from '../helpers/helpers';
import { generateMenu } from './menu';
describe('generateMenu', () => {
let window: BrowserWindow;
const mockIsOSX: jest.SpyInstance = isOSX as jest.Mock;
let mockIsFullScreen: jest.SpyInstance;
let mockIsFullScreenable: jest.SpyInstance;
let mockIsSimpleFullScreen: jest.SpyInstance;
let mockSetFullScreen: jest.SpyInstance;
let mockSetSimpleFullScreen: jest.SpyInstance;
beforeEach(() => {
window = new BrowserWindow();
mockIsOSX.mockReset();
mockIsFullScreen = jest
.spyOn(window, 'isFullScreen')
.mockReturnValue(false);
mockIsFullScreenable = jest
.spyOn(window, 'isFullScreenable')
.mockReturnValue(true);
mockIsSimpleFullScreen = jest
.spyOn(window, 'isSimpleFullScreen')
.mockReturnValue(false);
mockSetFullScreen = jest.spyOn(window, 'setFullScreen');
mockSetSimpleFullScreen = jest.spyOn(window, 'setSimpleFullScreen');
});
afterAll(() => {
mockIsFullScreen.mockRestore();
mockIsFullScreenable.mockRestore();
mockIsSimpleFullScreen.mockRestore();
mockSetFullScreen.mockRestore();
mockSetSimpleFullScreen.mockRestore();
});
test('does not have fullscreen if not supported', () => {
mockIsOSX.mockReturnValue(false);
mockIsFullScreenable.mockReturnValue(false);
const menu = generateMenu(
{
nativefierVersion: '1.0.0',
zoom: 1.0,
disableDevTools: false,
},
window,
);
const editMenu = menu.filter((item) => item.label === '&View');
const fullscreen = (
editMenu[0].submenu as MenuItemConstructorOptions[]
).filter((item) => item.label === 'Toggle Full Screen');
expect(fullscreen).toHaveLength(1);
expect(fullscreen[0].enabled).toBe(false);
expect(fullscreen[0].visible).toBe(false);
expect(mockIsOSX).toHaveBeenCalled();
expect(mockIsFullScreenable).toHaveBeenCalled();
});
test('has fullscreen no matter what on mac', () => {
mockIsOSX.mockReturnValue(true);
mockIsFullScreenable.mockReturnValue(false);
const menu = generateMenu(
{
nativefierVersion: '1.0.0',
zoom: 1.0,
disableDevTools: false,
},
window,
);
const editMenu = menu.filter((item) => item.label === '&View');
const fullscreen = (
editMenu[0].submenu as MenuItemConstructorOptions[]
).filter((item) => item.label === 'Toggle Full Screen');
expect(fullscreen).toHaveLength(1);
expect(fullscreen[0].enabled).toBe(true);
expect(fullscreen[0].visible).toBe(true);
expect(mockIsOSX).toHaveBeenCalled();
expect(mockIsFullScreenable).toHaveBeenCalled();
});
test.each([true, false])(
'has a fullscreen menu item that toggles fullscreen',
(isFullScreen) => {
mockIsOSX.mockReturnValue(false);
mockIsFullScreenable.mockReturnValue(true);
mockIsFullScreen.mockReturnValue(isFullScreen);
const menu = generateMenu(
{
nativefierVersion: '1.0.0',
zoom: 1.0,
disableDevTools: false,
},
window,
);
const editMenu = menu.filter((item) => item.label === '&View');
const fullscreen = (
editMenu[0].submenu as MenuItemConstructorOptions[]
).filter((item) => item.label === 'Toggle Full Screen');
expect(fullscreen).toHaveLength(1);
expect(fullscreen[0].enabled).toBe(true);
expect(fullscreen[0].visible).toBe(true);
expect(mockIsOSX).toHaveBeenCalled();
expect(mockIsFullScreenable).toHaveBeenCalled();
// @ts-expect-error click is here TypeScript...
fullscreen[0].click(null, window);
expect(mockSetFullScreen).toHaveBeenCalledWith(!isFullScreen);
expect(mockSetSimpleFullScreen).not.toHaveBeenCalled();
},
);
test.each([true, false])(
'has a fullscreen menu item that toggles simplefullscreen as a fallback on mac',
(isFullScreen) => {
mockIsOSX.mockReturnValue(true);
mockIsFullScreenable.mockReturnValue(false);
mockIsSimpleFullScreen.mockReturnValue(isFullScreen);
const menu = generateMenu(
{
nativefierVersion: '1.0.0',
zoom: 1.0,
disableDevTools: false,
},
window,
);
const editMenu = menu.filter((item) => item.label === '&View');
const fullscreen = (
editMenu[0].submenu as MenuItemConstructorOptions[]
).filter((item) => item.label === 'Toggle Full Screen');
expect(fullscreen).toHaveLength(1);
expect(fullscreen[0].enabled).toBe(true);
expect(fullscreen[0].visible).toBe(true);
expect(mockIsOSX).toHaveBeenCalled();
expect(mockIsFullScreenable).toHaveBeenCalled();
// @ts-expect-error click is here TypeScript...
fullscreen[0].click(null, window);
expect(mockSetSimpleFullScreen).toHaveBeenCalledWith(!isFullScreen);
expect(mockSetFullScreen).not.toHaveBeenCalled();
},
);
});

View File

@ -1,424 +0,0 @@
import * as fs from 'fs';
import path from 'path';
import {
BrowserWindow,
clipboard,
Menu,
MenuItem,
MenuItemConstructorOptions,
} from 'electron';
import { cleanupPlainText, isOSX, openExternal } from '../helpers/helpers';
import * as log from '../helpers/loggingHelper';
import {
clearAppData,
getCurrentURL,
goBack,
goForward,
goToURL,
zoomIn,
zoomOut,
zoomReset,
} from '../helpers/windowHelpers';
import { OutputOptions } from '../../../shared/src/options/model';
type BookmarksLink = {
type: 'link';
title: string;
url: string;
shortcut?: string;
};
type BookmarksSeparator = {
type: 'separator';
};
type BookmarkConfig = BookmarksLink | BookmarksSeparator;
type BookmarksMenuConfig = {
menuLabel: string;
bookmarks: BookmarkConfig[];
};
export function createMenu(
options: OutputOptions,
mainWindow: BrowserWindow,
): void {
log.debug('createMenu', { options });
const menuTemplate = generateMenu(options, mainWindow);
injectBookmarks(menuTemplate);
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
}
export function generateMenu(
options: {
disableDevTools: boolean;
nativefierVersion: string;
zoom?: number;
},
mainWindow: BrowserWindow,
): MenuItemConstructorOptions[] {
const { nativefierVersion, zoom, disableDevTools } = options;
const zoomResetLabel =
!zoom || zoom === 1.0
? 'Reset Zoom'
: `Reset Zoom (to ${(zoom * 100).toFixed(1)}%, set at build time)`;
const editMenu: MenuItemConstructorOptions = {
label: '&Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
role: 'undo',
},
{
label: 'Redo',
accelerator: 'Shift+CmdOrCtrl+Z',
role: 'redo',
},
{
type: 'separator',
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: 'Copy as Plain Text',
accelerator: 'CmdOrCtrl+Shift+C',
click: (): void => {
// We use clipboard.readText to strip down formatting
const text = clipboard.readText('selection');
clipboard.writeText(cleanupPlainText(text), 'clipboard');
},
},
{
label: 'Copy Current URL',
accelerator: 'CmdOrCtrl+L',
click: (): void => clipboard.writeText(getCurrentURL()),
},
{
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
{
label: 'Paste and Match Style',
// https://github.com/nativefier/nativefier/issues/404
// Apple's HIG lists this shortcut for paste and match style
// https://support.apple.com/en-us/HT209651
accelerator: isOSX() ? 'Option+Shift+Cmd+V' : 'Ctrl+Shift+V',
role: 'pasteAndMatchStyle',
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectAll',
},
{
label: 'Clear App Data',
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
): void => {
log.debug('Clear App Data.click', {
item,
focusedWindow,
mainWindow,
});
if (!focusedWindow) {
focusedWindow = mainWindow;
}
clearAppData(focusedWindow).catch((err) =>
log.error('clearAppData ERROR', err),
);
},
},
],
};
const viewMenu: MenuItemConstructorOptions = {
label: '&View',
submenu: [
{
label: 'Back',
accelerator: isOSX() ? 'Cmd+Left' : 'Alt+Left',
click: goBack,
},
{
label: 'BackAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+[', // What old versions of Nativefier used, kept for backwards compat
click: goBack,
},
{
label: 'Forward',
accelerator: isOSX() ? 'Cmd+Right' : 'Alt+Right',
click: goForward,
},
{
label: 'ForwardAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+]', // What old versions of Nativefier used, kept for backwards compat
click: goForward,
},
{
label: 'Reload',
role: 'reload',
},
{
type: 'separator',
},
{
label: 'Toggle Full Screen',
accelerator: isOSX() ? 'Ctrl+Cmd+F' : 'F11',
enabled: mainWindow.isFullScreenable() || isOSX(),
visible: mainWindow.isFullScreenable() || isOSX(),
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
): void => {
log.debug('Toggle Full Screen.click()', {
item,
focusedWindow,
isFullScreen: focusedWindow?.isFullScreen(),
isFullScreenable: focusedWindow?.isFullScreenable(),
});
if (!focusedWindow) {
focusedWindow = mainWindow;
}
if (focusedWindow.isFullScreenable()) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
} else if (isOSX()) {
focusedWindow.setSimpleFullScreen(
!focusedWindow.isSimpleFullScreen(),
);
}
},
},
{
label: 'Zoom In',
accelerator: 'CmdOrCtrl+=',
click: zoomIn,
},
{
label: 'ZoomInAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+numadd',
click: zoomIn,
},
{
label: 'Zoom Out',
accelerator: 'CmdOrCtrl+-',
click: zoomOut,
},
{
label: 'ZoomOutAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+numsub',
click: zoomOut,
},
{
label: zoomResetLabel,
accelerator: 'CmdOrCtrl+0',
click: (): void => zoomReset(options),
},
{
label: 'ZoomResetAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+num0',
click: (): void => zoomReset(options),
},
],
};
if (!disableDevTools) {
(viewMenu.submenu as MenuItemConstructorOptions[]).push(
{
type: 'separator',
},
{
label: 'Toggle Developer Tools',
accelerator: isOSX() ? 'Alt+Cmd+I' : 'Ctrl+Shift+I',
click: (item: MenuItem, focusedWindow: BrowserWindow | undefined) => {
log.debug('Toggle Developer Tools.click()', { item, focusedWindow });
if (!focusedWindow) {
focusedWindow = mainWindow;
}
focusedWindow.webContents.toggleDevTools();
},
},
);
}
const windowMenu: MenuItemConstructorOptions = {
label: '&Window',
role: 'window',
submenu: [
{
label: 'Minimize',
accelerator: 'CmdOrCtrl+M',
role: 'minimize',
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
],
};
const helpMenu: MenuItemConstructorOptions = {
label: '&Help',
role: 'help',
submenu: [
{
label: `Built with Nativefier v${nativefierVersion}`,
click: (): void => {
openExternal('https://github.com/nativefier/nativefier').catch(
(err: unknown): void =>
log.error(
'Built with Nativefier v${nativefierVersion}.click ERROR',
err,
),
);
},
},
{
label: 'Report an Issue',
click: (): void => {
openExternal('https://github.com/nativefier/nativefier/issues').catch(
(err: unknown): void =>
log.error('Report an Issue.click ERROR', err),
);
},
},
],
};
let menuTemplate: MenuItemConstructorOptions[];
if (isOSX()) {
const electronMenu: MenuItemConstructorOptions = {
label: 'E&lectron',
submenu: [
{
label: 'Services',
role: 'services',
submenu: [],
},
{
type: 'separator',
},
{
label: 'Hide App',
accelerator: 'Cmd+H',
role: 'hide',
},
{
label: 'Hide Others',
accelerator: 'Cmd+Shift+H',
role: 'hideOthers',
},
{
label: 'Show All',
role: 'unhide',
},
{
type: 'separator',
},
{
label: 'Quit',
accelerator: 'Cmd+Q',
role: 'quit',
},
],
};
(windowMenu.submenu as MenuItemConstructorOptions[]).push(
{
type: 'separator',
},
{
label: 'Bring All to Front',
role: 'front',
},
);
menuTemplate = [electronMenu, editMenu, viewMenu, windowMenu, helpMenu];
} else {
menuTemplate = [editMenu, viewMenu, windowMenu, helpMenu];
}
return menuTemplate;
}
function injectBookmarks(menuTemplate: MenuItemConstructorOptions[]): void {
const bookmarkConfigPath = path.join(__dirname, '..', 'bookmarks.json');
if (!fs.existsSync(bookmarkConfigPath)) {
return;
}
try {
const bookmarksMenuConfig = JSON.parse(
fs.readFileSync(bookmarkConfigPath, 'utf-8'),
) as BookmarksMenuConfig;
const submenu: MenuItemConstructorOptions[] =
bookmarksMenuConfig.bookmarks.map((bookmark) => {
switch (bookmark.type) {
case 'link':
if (!('title' in bookmark && 'url' in bookmark)) {
throw new Error(
'All links in the bookmarks menu must have a title and url.',
);
}
try {
new URL(bookmark.url);
} catch {
throw new Error('Bookmark URL "' + bookmark.url + '"is invalid.');
}
return {
label: bookmark.title,
click: (): void => {
goToURL(bookmark.url)?.catch((err: unknown): void =>
log.error(`${bookmark.title}.click ERROR`, err),
);
},
accelerator:
'shortcut' in bookmark ? bookmark.shortcut : undefined,
};
case 'separator':
return {
type: 'separator',
};
default:
throw new Error(
'A bookmarks menu entry has an invalid type; type must be one of "link", "separator".',
);
}
});
const bookmarksMenu: MenuItemConstructorOptions = {
label: bookmarksMenuConfig.menuLabel,
submenu,
};
// Insert custom bookmarks menu between menus "View" and "Window"
menuTemplate.splice(menuTemplate.length - 2, 0, bookmarksMenu);
} catch (err: unknown) {
log.error('Failed to load & parse bookmarks configuration JSON file.', err);
}
}

View File

@ -0,0 +1,282 @@
import { Menu, shell, clipboard } from 'electron';
/**
* @param nativefierVersion
* @param appQuit
* @param zoomIn
* @param zoomOut
* @param zoomReset
* @param zoomBuildTimeValue
* @param goBack
* @param goForward
* @param getCurrentUrl
* @param clearAppData
* @param disableDevTools
*/
function createMenu({ nativefierVersion,
appQuit,
zoomIn,
zoomOut,
zoomReset,
zoomBuildTimeValue,
goBack,
goForward,
getCurrentUrl,
clearAppData,
disableDevTools }) {
if (Menu.getApplicationMenu()) {
return;
}
const zoomResetLabel = (zoomBuildTimeValue === 1.0) ?
'Reset Zoom' :
`Reset Zoom (to ${zoomBuildTimeValue * 100}%, set at build time)`;
const template = [
{
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
role: 'undo',
},
{
label: 'Redo',
accelerator: 'Shift+CmdOrCtrl+Z',
role: 'redo',
},
{
type: 'separator',
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: 'Copy Current URL',
accelerator: 'CmdOrCtrl+L',
click: () => {
const currentURL = getCurrentUrl();
clipboard.writeText(currentURL);
},
},
{
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
{
label: 'Paste and Match Style',
accelerator: 'CmdOrCtrl+Shift+V',
role: 'pasteandmatchstyle',
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectall',
},
{
label: 'Clear App Data',
click: () => {
clearAppData();
},
},
],
},
{
label: 'View',
submenu: [
{
label: 'Back',
accelerator: 'CmdOrCtrl+[',
click: () => {
goBack();
},
},
{
label: 'Forward',
accelerator: 'CmdOrCtrl+]',
click: () => {
goForward();
},
},
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.reload();
}
},
},
{
type: 'separator',
},
{
label: 'Toggle Full Screen',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Ctrl+Command+F';
}
return 'F11';
})(),
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
}
},
},
{
label: 'Zoom In',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Command+=';
}
return 'Ctrl+=';
})(),
click: () => {
zoomIn();
},
},
{
label: 'Zoom Out',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Command+-';
}
return 'Ctrl+-';
})(),
click: () => {
zoomOut();
},
},
{
label: zoomResetLabel,
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Command+0';
}
return 'Ctrl+0';
})(),
click: () => {
zoomReset();
},
},
{
label: 'Toggle Developer Tools',
accelerator: (() => {
if (process.platform === 'darwin') {
return 'Alt+Command+I';
}
return 'Ctrl+Shift+I';
})(),
click: (item, focusedWindow) => {
if (focusedWindow) {
focusedWindow.toggleDevTools();
}
},
},
],
},
{
label: 'Window',
role: 'window',
submenu: [
{
label: 'Minimize',
accelerator: 'CmdOrCtrl+M',
role: 'minimize',
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
],
},
{
label: 'Help',
role: 'help',
submenu: [
{
label: `Built with Nativefier v${nativefierVersion}`,
click: () => {
shell.openExternal('https://github.com/jiahaog/nativefier');
},
},
{
label: 'Report an Issue',
click: () => {
shell.openExternal('https://github.com/jiahaog/nativefier/issues');
},
},
],
},
];
if (disableDevTools) {
// remove last item (dev tools) from menu > view
const submenu = template[1].submenu;
submenu.splice(submenu.length - 1, 1);
}
if (process.platform === 'darwin') {
template.unshift({
label: 'Electron',
submenu: [
{
label: 'Services',
role: 'services',
submenu: [],
},
{
type: 'separator',
},
{
label: 'Hide App',
accelerator: 'Command+H',
role: 'hide',
},
{
label: 'Hide Others',
accelerator: 'Command+Shift+H',
role: 'hideothers',
},
{
label: 'Show All',
role: 'unhide',
},
{
type: 'separator',
},
{
label: 'Quit',
accelerator: 'Command+Q',
click: () => {
appQuit();
},
},
],
});
template[3].submenu.push(
{
type: 'separator',
},
{
label: 'Bring All to Front',
role: 'front',
},
);
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
export default createMenu;

View File

@ -1,88 +0,0 @@
import { app, Tray, Menu, ipcMain, nativeImage, BrowserWindow } from 'electron';
import { getAppIcon, getCounterValue, isOSX } from '../helpers/helpers';
import * as log from '../helpers/loggingHelper';
import { OutputOptions } from '../../../shared/src/options/model';
export function createTrayIcon(
nativefierOptions: OutputOptions,
mainWindow: BrowserWindow,
): Tray | undefined {
const options = { ...nativefierOptions };
if (options.tray && options.tray !== 'false') {
const iconPath = getAppIcon();
if (!iconPath) {
throw new Error('Icon path not found found to use with tray option.');
}
const nimage = nativeImage.createFromPath(iconPath);
const appIcon = new Tray(nativeImage.createEmpty());
if (isOSX()) {
//sets the icon to the height of the tray.
appIcon.setImage(
nimage.resize({ height: appIcon.getBounds().height - 2 }),
);
} else {
appIcon.setImage(nimage);
}
const onClick = (): void => {
log.debug('onClick');
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
};
const contextMenu = Menu.buildFromTemplate([
{
label: options.name,
click: onClick,
},
{
label: 'Quit',
click: (): void => app.exit(0),
},
]);
appIcon.on('click', onClick);
if (options.counter) {
mainWindow.on('page-title-updated', (event, title) => {
log.debug('mainWindow.page-title-updated', { event, title });
const counterValue = getCounterValue(title);
if (counterValue) {
appIcon.setToolTip(
`(${counterValue}) ${options.name ?? 'Nativefier'}`,
);
} else {
appIcon.setToolTip(options.name ?? '');
}
});
} else {
ipcMain.on('notification', () => {
log.debug('ipcMain.notification');
if (mainWindow.isFocused()) {
return;
}
if (options.name) {
appIcon.setToolTip(`${options.name}`);
}
});
mainWindow.on('focus', () => {
log.debug('mainWindow.focus');
appIcon.setToolTip(options.name ?? '');
});
}
appIcon.setToolTip(options.name ?? '');
appIcon.setContextMenu(contextMenu);
return appIcon;
}
return undefined;
}

View File

@ -0,0 +1,79 @@
import path from 'path';
const { app, Tray, Menu, ipcMain } = require('electron');
/**
*
* @param {{}} inpOptions AppArgs from nativefier.json
* @param {electron.BrowserWindow} mainWindow MainWindow created from main.js
* @returns {electron.Tray}
*/
function createTrayIcon(inpOptions, mainWindow) {
const options = Object.assign({}, inpOptions);
if (options.tray) {
const iconPath = path.join(__dirname, '../', '/icon.png');
const appIcon = new Tray(iconPath);
const onClick = () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
};
const contextMenu = Menu.buildFromTemplate([
{
label: options.name,
click: onClick,
},
{
label: 'Quit',
click: app.exit,
},
]);
appIcon.on('click', onClick);
mainWindow.on('show', () => {
appIcon.setHighlightMode('always');
});
mainWindow.on('hide', () => {
appIcon.setHighlightMode('never');
});
if (options.counter) {
mainWindow.on('page-title-updated', (e, title) => {
const itemCountRegex = /[([{](\d*?)\+?[}\])]/;
const match = itemCountRegex.exec(title);
if (match) {
appIcon.setToolTip(`(${match[1]}) ${options.name}`);
} else {
appIcon.setToolTip(options.name);
}
});
} else {
ipcMain.on('notification', () => {
if (mainWindow.isFocused()) {
return;
}
appIcon.setToolTip(`${options.name}`);
});
mainWindow.on('focus', () => {
appIcon.setToolTip(options.name);
});
}
appIcon.setToolTip(options.name);
appIcon.setContextMenu(contextMenu);
return appIcon;
}
return null;
}
export default createTrayIcon;

View File

@ -0,0 +1,66 @@
import wurl from 'wurl';
import os from 'os';
import fs from 'fs';
import path from 'path';
const INJECT_CSS_PATH = path.join(__dirname, '..', 'inject/inject.css');
function isOSX() {
return os.platform() === 'darwin';
}
function isLinux() {
return os.platform() === 'linux';
}
function isWindows() {
return os.platform() === 'win32';
}
function linkIsInternal(currentUrl, newUrl, internalUrlRegex) {
if (internalUrlRegex) {
const regex = RegExp(internalUrlRegex);
return regex.test(newUrl);
}
const currentDomain = wurl('domain', currentUrl);
const newDomain = wurl('domain', newUrl);
return currentDomain === newDomain;
}
function shouldInjectCss() {
try {
fs.accessSync(INJECT_CSS_PATH, fs.F_OK);
return true;
} catch (e) {
return false;
}
}
function getCssToInject() {
return fs.readFileSync(INJECT_CSS_PATH).toString();
}
/**
* Helper method to print debug messages from the main process in the browser window
* @param {BrowserWindow} browserWindow
* @param message
*/
function debugLog(browserWindow, message) {
// need the timeout as it takes time for the preload javascript to be loaded in the window
setTimeout(() => {
browserWindow.webContents.send('debug', message);
}, 3000);
// eslint-disable-next-line no-console
console.log(message);
}
export default {
isOSX,
isLinux,
isWindows,
linkIsInternal,
getCssToInject,
debugLog,
shouldInjectCss,
};

View File

@ -1,348 +0,0 @@
import { shell } from 'electron';
jest.mock('./windowHelpers');
import {
cleanupPlainText,
getCounterValue,
linkIsInternal,
openExternal,
removeUserAgentSpecifics,
} from './helpers';
import { showNavigationBlockedMessage } from './windowHelpers';
const internalUrl = 'https://medium.com/';
const internalUrlWww = 'https://www.medium.com/';
const internalUrlSubPathRegex = /https:\/\/www.medium.com\/.*/;
const sameBaseDomainUrl = 'https://app.medium.com/';
const internalUrlCoUk = 'https://medium.co.uk/';
const differentBaseDomainUrlCoUk = 'https://other.domain.co.uk/';
const sameBaseDomainUrlCoUk = 'https://app.medium.co.uk/';
const sameBaseDomainUrlTidalListen = 'https://listen.tidal.com/';
const sameBaseDomainUrlTidalLogin = 'https://login.tidal.com/';
const sameBaseDomainUrlTidalRegex = /https:\/\/(login|listen).tidal.com\/.*/;
const internalUrlSubPath = 'topic/technology';
const externalUrl = 'https://www.wikipedia.org/wiki/Electron';
const wildcardRegex = /.*/;
test('the original url should be internal without --strict-internal-urls', () => {
expect(
linkIsInternal(internalUrl, internalUrl, undefined, undefined),
).toEqual(true);
});
test('the original url should be internal with --strict-internal-urls off', () => {
expect(linkIsInternal(internalUrl, internalUrl, undefined, false)).toEqual(
true,
);
});
test('the original url should be internal with --strict-internal-urls on', () => {
expect(linkIsInternal(internalUrl, internalUrl, undefined, true)).toEqual(
true,
);
});
test('sub-paths of the original url should be internal with --strict-internal-urls off', () => {
expect(
linkIsInternal(
internalUrl,
internalUrl + internalUrlSubPath,
undefined,
false,
),
).toEqual(true);
});
test('sub-paths of the original url should not be internal with --strict-internal-urls on', () => {
expect(
linkIsInternal(
internalUrl,
internalUrl + internalUrlSubPath,
undefined,
true,
),
).toEqual(false);
});
test('sub-paths of the original url should be internal with using a regex and --strict-internal-urls on', () => {
expect(
linkIsInternal(
internalUrl,
internalUrl + internalUrlSubPath,
internalUrlSubPathRegex,
true,
),
).toEqual(false);
});
test("'about:blank' should always be internal", () => {
expect(linkIsInternal(internalUrl, 'about:blank', undefined, true)).toEqual(
true,
);
});
test('urls from different sites should not be internal', () => {
expect(linkIsInternal(internalUrl, externalUrl, undefined, false)).toEqual(
false,
);
});
test('all urls should be internal with wildcard regex', () => {
expect(linkIsInternal(internalUrl, externalUrl, wildcardRegex, true)).toEqual(
true,
);
});
test('a "www." of a domain should be considered internal', () => {
expect(linkIsInternal(internalUrl, internalUrlWww, undefined, false)).toEqual(
true,
);
});
test('urls on the same "base domain" should be considered internal', () => {
expect(
linkIsInternal(internalUrl, sameBaseDomainUrl, undefined, false),
).toEqual(true);
});
test('urls on the same "base domain" should NOT be considered internal using --strict-internal-urls', () => {
expect(
linkIsInternal(internalUrl, sameBaseDomainUrl, undefined, true),
).toEqual(false);
});
test('urls on the same "base domain" should be considered internal, even with a www', () => {
expect(
linkIsInternal(internalUrlWww, sameBaseDomainUrl, undefined, false),
).toEqual(true);
});
test('urls on the same "base domain" should be considered internal, even with different sub domains', () => {
expect(
linkIsInternal(
sameBaseDomainUrlTidalListen,
sameBaseDomainUrlTidalLogin,
undefined,
false,
),
).toEqual(true);
});
test('urls should support sub domain matching with a regex', () => {
expect(
linkIsInternal(
sameBaseDomainUrlTidalListen,
sameBaseDomainUrlTidalLogin,
sameBaseDomainUrlTidalRegex,
false,
),
).toEqual(true);
});
test('urls on the same "base domain" should NOT be considered internal with different sub domains when using --strict-internal-urls', () => {
expect(
linkIsInternal(
sameBaseDomainUrlTidalListen,
sameBaseDomainUrlTidalLogin,
undefined,
true,
),
).toEqual(false);
});
test('urls on the same "base domain" should be considered internal, long SLD', () => {
expect(
linkIsInternal(internalUrlCoUk, sameBaseDomainUrlCoUk, undefined, false),
).toEqual(true);
});
test('urls on the a different "base domain" are considered NOT internal, long SLD', () => {
expect(
linkIsInternal(
internalUrlCoUk,
differentBaseDomainUrlCoUk,
undefined,
false,
),
).toEqual(false);
});
const testLoginPages = [
'https://amazon.co.uk/signin',
'https://amazon.com/signin',
'https://amazon.de/signin',
'https://amazon.com/ap/signin',
'https://facebook.co.uk/login',
'https://facebook.com/login',
'https://facebook.de/login',
'https://github.co.uk/login',
'https://github.com/login',
'https://github.de/login',
// GitHub 2FA flow with FIDO token
'https://github.com/session',
'https://github.com/sessions/two-factor/webauth',
'https://accounts.google.co.uk',
'https://accounts.google.com',
'https://mail.google.com/accounts/SetOSID',
'https://mail.google.co.uk/accounts/SetOSID',
'https://accounts.google.de',
'https://linkedin.co.uk/uas/login',
'https://linkedin.com/uas/login',
'https://linkedin.de/uas/login',
'https://login.live.co.uk',
'https://login.live.com',
'https://login.live.de',
'https://login.microsoftonline.com/common/oauth2/authorize',
'https://login.microsoftonline.co.uk/common/oauth2/authorize',
'https://login.microsoftonline.de/common/oauth2/authorize',
'https://okta.co.uk',
'https://okta.com',
'https://subdomain.okta.com',
'https://okta.de',
'https://twitter.co.uk/oauth/authenticate',
'https://twitter.com/oauth/authenticate',
'https://twitter.de/oauth/authenticate',
'https://appleid.apple.com/auth/authorize',
'https://id.atlassian.com',
'https://auth.atlassian.com',
'https://vmware.workspaceair.com',
'https://vmware.auth.securid.com',
];
test.each(testLoginPages)(
'%s login page should be internal',
(loginUrl: string) => {
expect(linkIsInternal(internalUrl, loginUrl, undefined, false)).toEqual(
true,
);
},
);
// Ensure that we don't over-match service pages
const testNonLoginPages = [
'https://www.amazon.com/Node-Cookbook-techniques-server-side-development-ebook',
'https://github.com/nativefier/nativefier',
'https://github.com/org/nativefier',
'https://microsoft.com',
'https://office.microsoftonline.com',
'https://twitter.com/marcoroth_/status/1325938620906287104',
'https://appleid.apple.com/account',
'https://mail.google.com/',
'https://atlassian.com',
];
test.each(testNonLoginPages)(
'%s page should not be internal',
(url: string) => {
expect(linkIsInternal(internalUrl, url, undefined, false)).toEqual(false);
},
);
const smallCounterTitle = 'Inbox (11) - nobody@example.com - Gmail';
const largeCounterTitle = 'Inbox (8,756) - nobody@example.com - Gmail';
const hourCounterTitle = 'Today (1:23) - nobody@example.com - TimeTracker';
const noCounterTitle = 'Inbox - nobody@example.com - Gmail';
test('getCounterValue should return undefined for titles without counter numbers', () => {
expect(getCounterValue(noCounterTitle)).toEqual(undefined);
});
test('getCounterValue should return a string for small counter numbers in the title', () => {
expect(getCounterValue(smallCounterTitle)).toEqual('11');
});
test('getCounterValue should return a string for large counter numbers in the title', () => {
expect(getCounterValue(largeCounterTitle)).toEqual('8,756');
});
test('getCounterValue should return a string for hour counter numbers in the title', () => {
expect(getCounterValue(hourCounterTitle)).toEqual('1:23');
});
describe('removeUserAgentSpecifics', () => {
const userAgentFallback =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) app-nativefier-804458/1.0.0 Chrome/89.0.4389.128 Electron/12.0.7 Safari/537.36';
test('removes Electron and App specific info', () => {
expect(
removeUserAgentSpecifics(
userAgentFallback,
'app-nativefier-804458',
'1.0.0',
),
).not.toBe(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36',
);
});
test('should not have multiple spaces in a row', () => {
expect(
removeUserAgentSpecifics(
userAgentFallback,
'app-nativefier-804458',
'1.0.0',
),
).toEqual(expect.not.stringMatching(/\s{2,}/));
});
});
describe('cleanupPlainText', () => {
test('removes extra spaces from text', () => {
expect(cleanupPlainText(' this is a test ')).toBe('this is a test');
});
});
describe('openExternal', () => {
const mockShellOpenExternal: jest.SpyInstance = jest.spyOn(
shell,
'openExternal',
);
const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
beforeEach(() => {
mockShellOpenExternal.mockReset();
mockShowNavigationBlockedMessage
.mockReset()
.mockReturnValue(Promise.resolve(undefined));
});
afterAll(() => {
mockShellOpenExternal.mockRestore();
mockShowNavigationBlockedMessage.mockRestore();
});
test('https urls scheme should *not* be blocked', async () => {
await openExternal('https://whatever.foo');
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockShellOpenExternal).toHaveBeenCalled();
});
test('urls with whitelisted scheme should *not* be blocked', async () => {
await openExternal('ircs://irc.libera.chat/whatever');
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockShellOpenExternal).toHaveBeenCalled();
});
test('urls with non-allowlisted scheme *should* be blocked', async () => {
await openExternal('barf://whatever.foo');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
test('potentially-malicious urls *should* be blocked', async () => {
await openExternal('https://hello.com/wor%00ld');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
test('malformed urls *should* be blocked', async () => {
await openExternal('zombocom');
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockShellOpenExternal).not.toHaveBeenCalled();
});
});

View File

@ -1,315 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BrowserWindow, OpenExternalOptions, shell } from 'electron';
import * as log from '../helpers/loggingHelper';
import { showNavigationBlockedMessage } from './windowHelpers';
export const INJECT_DIR = path.join(__dirname, '..', 'inject');
/**
* Firefox's list of protocols for which opening an external handler is allowed without confirmation.
* Taken from Firefox's. Location might vary in codebase, search for one of them, e.g.
* https://searchfox.org/mozilla-central/search?q=%22xmpp%22&path=&case=false&regexp=false
*/
const URL_PROTOCOLS_NOCONFIRMATION_FIREFOX = [
'bitcoin:',
'ftp:',
'ftps:',
'geo:',
'im:',
'irc:',
'ircs:',
'magnet:',
'mailto:',
'matrix:',
'mms:',
'news:',
'nntp:',
'openpgp4fpr:',
'sftp:',
'sip:',
'sms:',
'smsto:',
'ssh:',
'tel:',
'urn:',
'webcal:',
'wtai:',
'xmpp:',
];
/**
* Our extension to Firefox's list. If extending this list too much, we should
* really add a confirmation modal (for now we just block), like browsers do.
* But for now, since nobody shouts at us for bluntly blocking anything else,
* let's keep rolling with it.
*/
const URL_PROTOCOLS_NOCONFIRMATION_EXTRA = ['zoommtg:'];
/**
* List of protocols for which opening an external handler is allowed without confirmation.
* Note: "without confirmation" is currently a lie. It was implemented this way
* as a way to know from user feedback what protocols would cause users to shout,
* but there wasn't much shouting happening, so we currently don't have a confirmation
* mechanism, we just bluntly block. That might need to change at some point.
*/
const URL_PROTOCOLS_NOCONFIRMATION = [
'http:',
'https:',
...URL_PROTOCOLS_NOCONFIRMATION_FIREFOX,
...URL_PROTOCOLS_NOCONFIRMATION_EXTRA,
];
const SHELL_SAFETY_FEEDBACK_STR =
'If you believe this URL should open, you might be right, and our validation might be excessive.' +
'Please share this error & URL at https://github.com/nativefier/nativefier/issues/1459';
export function isUrlShellSafe(
urlToGo: string,
): { blocked: false } | { blocked: true; reason: string } {
let url: URL;
try {
url = new URL(urlToGo.toLowerCase());
} catch (err: unknown) {
return {
blocked: true,
reason: `URL appears malformed. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
if (!URL_PROTOCOLS_NOCONFIRMATION.includes(url.protocol)) {
return {
blocked: true,
reason: `URL protocol is disallowed. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
// https://cwe.mitre.org/data/definitions/177.html
if (
urlToGo.includes('%00') ||
urlToGo.includes('%0a') ||
urlToGo.includes('%2e') ||
urlToGo.includes('%2f') ||
urlToGo.includes('%5c')
) {
return {
blocked: true,
reason: `URL might be malicious. ${SHELL_SAFETY_FEEDBACK_STR}`,
};
}
return { blocked: false };
}
/**
* Helper to print debug messages from the main process in the browser window
*/
export function debugLog(browserWindow: BrowserWindow, message: string): void {
// Need a delay, as it takes time for the preloaded js to be loaded by the window
setTimeout(() => {
browserWindow.webContents.send('debug', message);
}, 3000);
log.debug(message);
}
/**
* Helper to determine domain-ish equality for many cases, the trivial ones
* and the trickier ones, e.g. `blog.foo.com` and `shop.foo.com`,
* in a way that is "good enough", and doesn't need a list of SLDs.
* See chat at https://github.com/nativefier/nativefier/pull/1171#pullrequestreview-649132523
*/
function domainify(url: string): string {
// So here's what we're doing here:
// Get the hostname from the url
const hostname = new URL(url).hostname;
// Drop the first section if the domain
const domain = hostname.split('.').slice(1).join('.');
// Check the length, if it's too short, the hostname was probably the domain
// Or if the domain doesn't have a . in it we went too far
if (domain.length < 6 || domain.split('.').length === 0) {
return hostname;
}
// This SHOULD be the domain, but nothing is 100% guaranteed
return domain;
}
export function getAppIcon(): string | undefined {
// Prefer ICO under Windows, see
// https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions
// https://www.electronjs.org/docs/api/native-image#supported-formats
if (isWindows()) {
const ico = path.join(__dirname, '..', 'icon.ico');
if (fs.existsSync(ico)) {
return ico;
}
}
const png = path.join(__dirname, '..', 'icon.png');
if (fs.existsSync(png)) {
return png;
}
}
export function getCounterValue(title: string): string | undefined {
const itemCountRegex = /[([{]([\d.,:]*)\+?[}\])]/;
const match = itemCountRegex.exec(title);
return match ? match[1] : undefined;
}
export function getCSSToInject(): string {
let cssToInject = '';
const cssFiles = fs
.readdirSync(INJECT_DIR, { withFileTypes: true })
.filter(
(injectFile) => injectFile.isFile() && injectFile.name.endsWith('.css'),
)
.map((cssFileStat) =>
path.resolve(path.join(INJECT_DIR, cssFileStat.name)),
);
for (const cssFile of cssFiles) {
log.debug('Injecting CSS file', cssFile);
const cssFileData = fs.readFileSync(cssFile);
cssToInject += `/* ${cssFile} */\n\n ${cssFileData.toString()}\n\n`;
}
return cssToInject;
}
export function isOSX(): boolean {
return os.platform() === 'darwin';
}
export function isLinux(): boolean {
return os.platform() === 'linux';
}
export function isWindows(): boolean {
return os.platform() === 'win32';
}
function isInternalLoginPage(url: string): boolean {
// Making changes? Remember to update the tests in helpers.test.ts and in API.md
const internalLoginPagesArray = [
'amazon\\.[a-zA-Z\\.]*/[a-zA-Z\\/]*signin', // Amazon
`facebook\\.[a-zA-Z\\.]*\\/login`, // Facebook
'github\\.[a-zA-Z\\.]*\\/(?:login|session)', // GitHub
'accounts\\.google\\.[a-zA-Z\\.]*', // Google
'mail\\.google\\.[a-zA-Z\\.]*\\/accounts/SetOSID', // Google
'linkedin\\.[a-zA-Z\\.]*/uas/login', // LinkedIn
'login\\.live\\.[a-zA-Z\\.]*', // Microsoft
'login\\.microsoftonline\\.[a-zA-Z\\.]*', // Microsoft
'okta\\.[a-zA-Z\\.]*', // Okta
'twitter\\.[a-zA-Z\\.]*/oauth/authenticate', // Twitter
'appleid\\.apple\\.com/auth/authorize', // Apple
'(?:id|auth)\\.atlassian\\.[a-zA-Z]+', // Atlassian
'.*\\.workspaceair\\.com', // VMWare Workspace One SSO
'.*\\.securid\\.com', // SecurID for VMWare Workspace One SSO
];
// Making changes? Remember to update the tests in helpers.test.ts and in API.md
const regex = RegExp(internalLoginPagesArray.join('|'));
return regex.test(url);
}
export function linkIsInternal(
currentUrl: string,
newUrl: string,
internalUrlRegex: string | RegExp | undefined,
isStrictInternalUrlsEnabled: boolean | undefined,
): boolean {
log.debug('linkIsInternal', { currentUrl, newUrl, internalUrlRegex });
if (newUrl.split('#')[0] === 'about:blank') {
return true;
}
if (isInternalLoginPage(newUrl)) {
return true;
}
if (internalUrlRegex) {
const regex = RegExp(internalUrlRegex);
if (regex.test(newUrl)) {
return true;
}
}
if (isStrictInternalUrlsEnabled) {
return currentUrl == newUrl;
}
try {
// Consider as "same domain-ish", without TLD/SLD list:
// 1. app.foo.com and foo.com
// 2. www.foo.com and foo.com
// 3. www.foo.com and app.foo.com
// Only use the tld and the main domain for domain-ish test
// Enables domain-ish equality for blog.foo.com and shop.foo.com
return domainify(currentUrl) === domainify(newUrl);
} catch (err: unknown) {
log.error(
'Failed to parse domains as determining if link is internal. From:',
currentUrl,
'To:',
newUrl,
err,
);
return false;
}
}
export function nativeTabsSupported(): boolean {
return isOSX();
}
/**
* Open the given external protocol URL in the desktop's default manner
* (e.g. `mailto:` URLs in the user's default mail agent), with extra validation.
*/
export function openExternal(
url: string,
options?: OpenExternalOptions,
): Promise<void> {
const urlShellSafety = isUrlShellSafe(url);
log.debug('openExternal', { url, options, urlShellSafety });
if (urlShellSafety.blocked) {
return new Promise((resolve) => {
showNavigationBlockedMessage(
`Navigation blocked to ${url}\n\n${urlShellSafety.reason}`,
)
.then(() => resolve())
.catch((err: unknown) => {
throw err;
});
});
}
return shell.openExternal(url, options);
}
// Copy-pastaed as unable to get imports to work in preload.
// If modifying, update also app/src/preload.ts
export function isWayland(): boolean {
return (
isLinux() &&
(Boolean(process.env.WAYLAND_DISPLAY) ||
process.env.XDG_SESSION_TYPE === 'wayland')
);
}
export function removeUserAgentSpecifics(
userAgentFallback: string,
appName: string,
appVersion: string,
): string {
// Electron userAgentFallback is the user agent used if none is specified when creating a window.
// For our purposes, it's useful because its format is similar enough to a real Chrome's user agent to not need
// to infer the userAgent. userAgentFallback normally looks like this:
// Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) app-nativefier-804458/1.0.0 Chrome/89.0.4389.128 Electron/12.0.7 Safari/537.36
// We just need to strip out the appName/1.0.0 and Electron/electronVersion
return userAgentFallback
.replace(`Electron/${process.versions.electron} `, '')
.replace(`${appName}/${appVersion} `, '');
}
/** Removes extra spaces from a text */
export function cleanupPlainText(text: string): string {
return text.trim().replace(/\s+/g, ' ');
}

View File

@ -0,0 +1,82 @@
import fs from 'fs';
import path from 'path';
import helpers from './helpers';
const { isOSX, isWindows, isLinux } = helpers;
/**
* Synchronously find a file or directory
* @param {RegExp} pattern regex
* @param {string} base path
* @param {boolean} [findDir] if true, search results will be limited to only directories
* @returns {Array}
*/
function findSync(pattern, basePath, findDir) {
const matches = [];
(function findSyncRecurse(base) {
let children;
try {
children = fs.readdirSync(base);
} catch (exception) {
if (exception.code === 'ENOENT') {
return;
}
throw exception;
}
children.forEach((child) => {
const childPath = path.join(base, child);
const childIsDirectory = fs.lstatSync(childPath).isDirectory();
const patternMatches = pattern.test(childPath);
if (!patternMatches) {
if (!childIsDirectory) {
return;
}
findSyncRecurse(childPath);
return;
}
if (!findDir) {
matches.push(childPath);
return;
}
if (childIsDirectory) {
matches.push(childPath);
}
});
}(basePath));
return matches;
}
function linuxMatch() {
return findSync(/libpepflashplayer\.so/, '/opt/google/chrome')[0];
}
function windowsMatch() {
return findSync(/pepflashplayer\.dll/, 'C:\\Program Files (x86)\\Google\\Chrome')[0];
}
function darwinMatch() {
return findSync(/PepperFlashPlayer.plugin/, '/Applications/Google Chrome.app/', true)[0];
}
function inferFlash() {
if (isOSX()) {
return darwinMatch();
}
if (isWindows()) {
return windowsMatch();
}
if (isLinux()) {
return linuxMatch();
}
console.warn('Unable to determine OS to infer flash player');
return null;
}
export default inferFlash;

View File

@ -1,90 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { isOSX, isWindows, isLinux } from './helpers';
import * as log from './loggingHelper';
type fsError = Error & { code: string };
/**
* Find a file or directory
*/
function findSync(
pattern: RegExp,
basePath: string,
limitSearchToDirectories = false,
): string[] {
const matches: string[] = [];
(function findSyncRecurse(base): void {
let children: string[];
try {
children = fs.readdirSync(base);
} catch (err: unknown) {
if ((err as fsError).code === 'ENOENT') {
return;
}
throw err;
}
for (const child of children) {
const childPath = path.join(base, child);
const childIsDirectory = fs.lstatSync(childPath).isDirectory();
const patternMatches = pattern.test(childPath);
if (!patternMatches) {
if (!childIsDirectory) {
return;
}
findSyncRecurse(childPath);
return;
}
if (!limitSearchToDirectories) {
matches.push(childPath);
return;
}
if (childIsDirectory) {
matches.push(childPath);
}
}
})(basePath);
return matches;
}
function findFlashOnLinux(): string {
return findSync(/libpepflashplayer\.so/, '/opt/google/chrome')[0];
}
function findFlashOnWindows(): string {
return findSync(
/pepflashplayer\.dll/,
'C:\\Program Files (x86)\\Google\\Chrome',
)[0];
}
function findFlashOnMac(): string {
return findSync(
/PepperFlashPlayer.plugin/,
'/Applications/Google Chrome.app/',
true,
)[0];
}
export function inferFlashPath(): string | undefined {
if (isOSX()) {
return findFlashOnMac();
}
if (isWindows()) {
return findFlashOnWindows();
}
if (isLinux()) {
return findFlashOnLinux();
}
log.warn('Unable to determine OS to infer flash player');
return undefined;
}

View File

@ -1,82 +0,0 @@
// This helper allows logs to either be printed to the console as they would normally or if
// the USE_LOG_FILE environment variable is set (such as through our playwright tests), then
// the logs can be diverted from the command line to a log file, so that they can be displayed
// later (such as at the end of a playwright test run to help diagnose potential failures).
// Use this instead of loglevel whenever logging messages inside the app.
import * as fs from 'fs';
import * as path from 'path';
import loglevel from 'loglevel';
import { safeGetEnv } from './playwrightHelpers';
const USE_LOG_FILE = safeGetEnv('USE_LOG_FILE') === '1';
const LOG_FILE_DIR = safeGetEnv('LOG_FILE_DIR') ?? process.cwd();
const LOG_FILENAME = path.join(LOG_FILE_DIR, `${new Date().getTime()}.log`);
const logLevelNames = ['TRACE', 'DEBUG', 'INFO ', 'WARN ', 'ERROR'];
function _logger(
logFunc: (...args: unknown[]) => void,
level: loglevel.LogLevelNumbers,
...args: unknown[]
): void {
if (USE_LOG_FILE && loglevel.getLevel() >= level) {
for (const arg of args) {
try {
const lines =
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
JSON.stringify(arg, null, 2)?.split('\n') ?? `${arg}`.split('\n');
for (const line of lines) {
fs.appendFileSync(
LOG_FILENAME,
`${new Date().getTime()} ${logLevelNames[level]} ${line}\n`,
);
}
} catch {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
fs.appendFileSync(LOG_FILENAME, `${logLevelNames[level]} ${arg}\n`);
}
}
} else {
logFunc(...args);
}
}
export function debug(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.debug, loglevel.levels.DEBUG, ...args);
}
export function error(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.error, loglevel.levels.ERROR, ...args);
}
export function info(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.info, loglevel.levels.INFO, ...args);
}
export function log(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.info, loglevel.levels.INFO, ...args);
}
export function setLevel(
level: loglevel.LogLevelDesc,
persist?: boolean,
): void {
loglevel.setLevel(level, persist);
}
export function trace(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.trace, loglevel.levels.TRACE, ...args);
}
export function warn(...args: unknown[]): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
_logger(loglevel.warn, loglevel.levels.WARN, ...args);
}

View File

@ -1,6 +0,0 @@
export const IS_PLAYWRIGHT = safeGetEnv('PLAYWRIGHT_TEST') === '1';
export const PLAYWRIGHT_CONFIG = safeGetEnv('PLAYWRIGHT_CONFIG');
export function safeGetEnv(key: string): string | undefined {
return key in process.env ? process.env[key] : undefined;
}

View File

@ -1,353 +0,0 @@
jest.mock('./helpers');
jest.mock('./windowEvents');
jest.mock('./windowHelpers');
import { dialog, BrowserWindow, HandlerDetails, WebContents } from 'electron';
import { WindowOptions } from '../../../shared/src/options/model';
import { linkIsInternal, openExternal, nativeTabsSupported } from './helpers';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const {
onNewWindowHelper,
onWillNavigate,
onWillPreventUnload,
}: {
onNewWindowHelper: (
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
details: Partial<HandlerDetails>,
parent?: BrowserWindow,
) => ReturnType<Parameters<WebContents['setWindowOpenHandler']>[0]>;
onWillNavigate: (
options: {
blockExternalUrls: boolean;
internalUrls?: string | RegExp;
targetUrl: string;
},
event: unknown,
urlToGo: string,
) => Promise<void>;
onWillPreventUnload: (event: unknown) => void;
} = jest.requireActual('./windowEvents');
import {
showNavigationBlockedMessage,
createAboutBlankWindow,
createNewTab,
} from './windowHelpers';
describe('onNewWindowHelper', () => {
const originalURL = 'https://medium.com/';
const internalURL = 'https://medium.com/topics/technology';
const externalURL = 'https://www.wikipedia.org/wiki/Electron';
const foregroundDisposition = 'foreground-tab';
const backgroundDisposition = 'background-tab';
const baseOptions = {
autoHideMenuBar: true,
blockExternalUrls: false,
insecure: false,
name: 'TEST_APP',
targetUrl: originalURL,
zoom: 1.0,
} as WindowOptions;
const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
const mockCreateAboutBlank: jest.SpyInstance =
createAboutBlankWindow as jest.Mock;
const mockCreateNewTab: jest.SpyInstance = createNewTab as jest.Mock;
const mockLinkIsInternal: jest.SpyInstance = (
linkIsInternal as jest.Mock
).mockImplementation(() => true);
const mockNativeTabsSupported: jest.SpyInstance =
nativeTabsSupported as jest.Mock;
const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock;
const setupWindow = jest.fn();
beforeEach(() => {
mockShowNavigationBlockedMessage
.mockReset()
.mockReturnValue(Promise.resolve(undefined));
mockCreateAboutBlank.mockReset();
mockCreateNewTab.mockReset();
mockLinkIsInternal.mockReset().mockReturnValue(true);
mockNativeTabsSupported.mockReset().mockReturnValue(false);
mockOpenExternal.mockReset();
setupWindow.mockReset();
});
afterAll(() => {
mockShowNavigationBlockedMessage.mockRestore();
mockCreateAboutBlank.mockRestore();
mockCreateNewTab.mockRestore();
mockLinkIsInternal.mockRestore();
mockNativeTabsSupported.mockRestore();
mockOpenExternal.mockRestore();
});
test('internal urls should not be handled', () => {
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: internalURL,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('allow');
});
test('external urls should be opened externally', () => {
mockLinkIsInternal.mockReturnValue(false);
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: externalURL,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(result.action).toEqual('deny');
});
test('external urls should be ignored if blockExternalUrls is true', () => {
mockLinkIsInternal.mockReturnValue(false);
const options = {
...baseOptions,
blockExternalUrls: true,
};
const result = onNewWindowHelper(options, setupWindow, {
url: externalURL,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('deny');
});
test('tab disposition should be ignored if tabs are not enabled', () => {
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: internalURL,
disposition: foregroundDisposition,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('allow');
});
test('tab disposition should be ignored if url is external', () => {
mockLinkIsInternal.mockReturnValue(false);
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: externalURL,
disposition: foregroundDisposition,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(result.action).toEqual('deny');
});
test('foreground tabs with internal urls should be opened in the foreground', () => {
mockNativeTabsSupported.mockReturnValue(true);
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: internalURL,
disposition: foregroundDisposition,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).toHaveBeenCalledWith(
baseOptions,
setupWindow,
internalURL,
true,
);
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('deny');
});
test('background tabs with internal urls should be opened in background tabs', () => {
mockNativeTabsSupported.mockReturnValue(true);
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: internalURL,
disposition: backgroundDisposition,
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).toHaveBeenCalledWith(
baseOptions,
setupWindow,
internalURL,
false,
);
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('deny');
});
test('about:blank urls should be handled', () => {
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: 'about:blank',
});
expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('deny');
});
test('about:blank#blocked urls should be handled', () => {
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: 'about:blank#blocked',
});
expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1);
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('deny');
});
test('about:blank#other urls should not be handled', () => {
const result = onNewWindowHelper(baseOptions, setupWindow, {
url: 'about:blank#other',
});
expect(mockCreateAboutBlank).not.toHaveBeenCalled();
expect(mockCreateNewTab).not.toHaveBeenCalled();
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(result.action).toEqual('allow');
});
});
describe('onWillNavigate', () => {
const originalURL = 'https://medium.com/';
const internalURL = 'https://medium.com/topics/technology';
const externalURL = 'https://www.wikipedia.org/wiki/Electron';
const mockShowNavigationBlockedMessage: jest.SpyInstance =
showNavigationBlockedMessage as jest.Mock;
const mockLinkIsInternal: jest.SpyInstance = linkIsInternal as jest.Mock;
const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock;
const preventDefault = jest.fn();
beforeEach(() => {
mockShowNavigationBlockedMessage
.mockReset()
.mockReturnValue(Promise.resolve(undefined));
mockLinkIsInternal.mockReset().mockReturnValue(false);
mockOpenExternal.mockReset();
preventDefault.mockReset();
});
afterAll(() => {
mockShowNavigationBlockedMessage.mockRestore();
mockLinkIsInternal.mockRestore();
mockOpenExternal.mockRestore();
});
test('internal urls should not be handled', async () => {
mockLinkIsInternal.mockReturnValue(true);
const options = {
blockExternalUrls: false,
targetUrl: originalURL,
};
const event = { preventDefault };
await onWillNavigate(options, event, internalURL);
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
test('external urls should be opened externally', async () => {
const options = {
blockExternalUrls: false,
targetUrl: originalURL,
};
const event = { preventDefault };
await onWillNavigate(options, event, externalURL);
expect(mockShowNavigationBlockedMessage).not.toHaveBeenCalled();
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
test('external urls should be blocked if blockExternalUrls is true', async () => {
const options = {
blockExternalUrls: true,
targetUrl: originalURL,
};
const event = { preventDefault };
await onWillNavigate(options, event, externalURL);
expect(mockShowNavigationBlockedMessage).toHaveBeenCalledTimes(1);
expect(mockOpenExternal).not.toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledTimes(1);
});
});
describe('onWillPreventUnload', () => {
const mockFromWebContents: jest.SpyInstance = jest
.spyOn(BrowserWindow, 'fromWebContents')
.mockImplementation(() => new BrowserWindow());
const mockShowDialog: jest.SpyInstance = jest.spyOn(
dialog,
'showMessageBoxSync',
);
const preventDefault: jest.SpyInstance = jest.fn();
beforeEach(() => {
mockFromWebContents.mockReset();
mockShowDialog.mockReset().mockReturnValue(undefined);
preventDefault.mockReset();
});
afterAll(() => {
mockFromWebContents.mockRestore();
mockShowDialog.mockRestore();
});
test('with no sender', () => {
const event = {};
onWillPreventUnload(event);
expect(mockFromWebContents).not.toHaveBeenCalled();
expect(mockShowDialog).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
test('shows dialog and calls preventDefault on ok', () => {
mockShowDialog.mockReturnValue(0);
const event = { preventDefault, sender: {} };
onWillPreventUnload(event);
expect(mockFromWebContents).toHaveBeenCalledWith(event.sender);
expect(mockShowDialog).toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalledWith();
});
test('shows dialog and does not call preventDefault on cancel', () => {
mockShowDialog.mockReturnValue(1);
const event = { preventDefault, sender: {} };
onWillPreventUnload(event);
expect(mockFromWebContents).toHaveBeenCalledWith(event.sender);
expect(mockShowDialog).toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
});

View File

@ -1,188 +0,0 @@
import {
dialog,
BrowserWindow,
Event,
WebContents,
HandlerDetails,
} from 'electron';
import { linkIsInternal, nativeTabsSupported, openExternal } from './helpers';
import * as log from './loggingHelper';
import {
createAboutBlankWindow,
createNewTab,
injectCSS,
sendParamsOnDidFinishLoad,
setProxyRules,
showNavigationBlockedMessage,
} from './windowHelpers';
import { WindowOptions } from '../../../shared/src/options/model';
type NewWindowHandlerResult = ReturnType<
Parameters<WebContents['setWindowOpenHandler']>[0]
>;
export function onNewWindow(
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
details: HandlerDetails,
parent?: BrowserWindow,
): NewWindowHandlerResult {
log.debug('onNewWindow', {
details,
});
return onNewWindowHelper(
options,
setupWindow,
details,
nativeTabsSupported() ? undefined : parent,
);
}
export function onNewWindowHelper(
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
details: HandlerDetails,
parent?: BrowserWindow,
): NewWindowHandlerResult {
log.debug('onNewWindowHelper', {
options,
details,
});
try {
if (
!linkIsInternal(
options.targetUrl,
details.url,
options.internalUrls,
options.strictInternalUrls,
)
) {
if (options.blockExternalUrls) {
showNavigationBlockedMessage(
`Navigation to external URL blocked by options: ${details.url}`,
)
.then(() => {
// blockExternalURL(details.url).then(resolve).catch((err: unknown) => {
// log.error('blockExternalURL', err);
// });
})
.catch((err: unknown) => {
throw err;
});
return { action: 'deny' };
} else {
openExternal(details.url).catch((err: unknown) => {
log.error('openExternal', err);
});
return { action: 'deny' };
}
}
// Normally the following would be:
// if (urlToGo.startsWith('about:blank'))...
// But due to a bug we resolved in https://github.com/nativefier/nativefier/issues/1197
// Some sites use about:blank#something to use as placeholder windows to fill
// with content via JavaScript. So we'll stay specific for now...
else if (['about:blank', 'about:blank#blocked'].includes(details.url)) {
createAboutBlankWindow(
options,
setupWindow,
nativeTabsSupported() ? undefined : parent,
);
return { action: 'deny' };
} else if (nativeTabsSupported()) {
createNewTab(
options,
setupWindow,
details.url,
details.disposition === 'foreground-tab',
);
return { action: 'deny' };
}
return { action: 'allow' };
} catch (err: unknown) {
return { action: 'deny' };
}
}
export function onWillNavigate(
options: WindowOptions,
event: Event,
urlToGo: string,
): Promise<void> {
log.debug('onWillNavigate', urlToGo);
if (
!linkIsInternal(
options.targetUrl,
urlToGo,
options.internalUrls,
options.strictInternalUrls,
)
) {
event.preventDefault();
if (options.blockExternalUrls) {
return new Promise((resolve) => {
showNavigationBlockedMessage(
`Navigation to external URL blocked by options: ${urlToGo}`,
)
.then(() => resolve())
.catch((err: unknown) => {
throw err;
});
});
} else {
return openExternal(urlToGo);
}
}
return Promise.resolve(undefined);
}
export function onWillPreventUnload(
event: Event & { sender?: WebContents },
): void {
log.debug('onWillPreventUnload', event);
const webContents = event.sender;
if (!webContents) {
return;
}
const browserWindow =
BrowserWindow.fromWebContents(webContents) ??
BrowserWindow.getFocusedWindow();
if (browserWindow) {
const choice = dialog.showMessageBoxSync(browserWindow, {
type: 'question',
buttons: ['Proceed', 'Stay'],
message:
'You may have unsaved changes, are you sure you want to proceed?',
title: 'Changes you made may not be saved.',
defaultId: 0,
cancelId: 1,
});
if (choice === 0) {
event.preventDefault();
}
}
}
export function setupNativefierWindow(
options: WindowOptions,
window: BrowserWindow,
): void {
if (options.proxyRules) {
setProxyRules(window, options.proxyRules);
}
injectCSS(window);
window.webContents.on('will-navigate', (event: Event, url: string) => {
onWillNavigate(options, event, url).catch((err) => {
log.error('window.webContents.on.will-navigate ERROR', err);
event.preventDefault();
});
});
window.webContents.on('will-prevent-unload', onWillPreventUnload);
sendParamsOnDidFinishLoad(options, window);
}

View File

@ -1,292 +0,0 @@
import { dialog, BrowserWindow } from 'electron';
jest.mock('loglevel');
import { error } from 'loglevel';
import { WindowOptions } from '../../../shared/src/options/model';
jest.mock('./helpers');
import { getCSSToInject } from './helpers';
jest.mock('./windowEvents');
import { clearAppData, createNewTab, injectCSS } from './windowHelpers';
describe('clearAppData', () => {
let window: BrowserWindow;
let mockClearCache: jest.SpyInstance;
let mockClearStorageData: jest.SpyInstance;
const mockShowDialog: jest.SpyInstance = jest.spyOn(dialog, 'showMessageBox');
beforeEach(() => {
window = new BrowserWindow();
mockClearCache = jest.spyOn(window.webContents.session, 'clearCache');
mockClearStorageData = jest.spyOn(
window.webContents.session,
'clearStorageData',
);
mockShowDialog.mockReset().mockResolvedValue(undefined);
});
afterAll(() => {
mockClearCache.mockRestore();
mockClearStorageData.mockRestore();
mockShowDialog.mockRestore();
});
test('will not clear app data if dialog canceled', async () => {
mockShowDialog.mockResolvedValue(1);
await clearAppData(window);
expect(mockShowDialog).toHaveBeenCalledTimes(1);
expect(mockClearCache).not.toHaveBeenCalled();
expect(mockClearStorageData).not.toHaveBeenCalled();
});
test('will clear app data if ok is clicked', async () => {
mockShowDialog.mockResolvedValue(0);
await clearAppData(window);
expect(mockShowDialog).toHaveBeenCalledTimes(1);
expect(mockClearCache).not.toHaveBeenCalledTimes(1);
expect(mockClearStorageData).not.toHaveBeenCalledTimes(1);
});
});
describe('createNewTab', () => {
// const window = new BrowserWindow();
const options: WindowOptions = {
autoHideMenuBar: true,
blockExternalUrls: false,
insecure: false,
name: 'Test App',
targetUrl: 'https://github.com/nativefier/natifefier',
zoom: 1.0,
} as WindowOptions;
const setupWindow = jest.fn();
const url = 'https://github.com/nativefier/nativefier';
const mockAddTabbedWindow: jest.SpyInstance = jest.spyOn(
BrowserWindow.prototype,
'addTabbedWindow',
);
const mockFocus: jest.SpyInstance = jest.spyOn(
BrowserWindow.prototype,
'focus',
);
const mockLoadURL: jest.SpyInstance = jest.spyOn(
BrowserWindow.prototype,
'loadURL',
);
test('creates new foreground tab', () => {
const foreground = true;
const tab = createNewTab(options, setupWindow, url, foreground);
expect(mockAddTabbedWindow).toHaveBeenCalledWith(tab);
expect(setupWindow).toHaveBeenCalledWith(options, tab);
expect(mockLoadURL).toHaveBeenCalledWith(url);
expect(mockFocus).not.toHaveBeenCalled();
});
test('creates new background tab', () => {
const foreground = false;
const tab = createNewTab(
options,
setupWindow,
url,
foreground,
// window
);
expect(mockAddTabbedWindow).toHaveBeenCalledWith(tab);
expect(setupWindow).toHaveBeenCalledWith(options, tab);
expect(mockLoadURL).toHaveBeenCalledWith(url);
expect(mockFocus).toHaveBeenCalledTimes(1);
});
});
describe('injectCSS', () => {
jest.setTimeout(10000);
const mockGetCSSToInject: jest.SpyInstance = getCSSToInject as jest.Mock;
const mockLogError: jest.SpyInstance = error as jest.Mock;
const css = 'body { color: white; }';
let responseHeaders: Record<string, string[]>;
beforeEach(() => {
mockGetCSSToInject.mockReset().mockReturnValue('');
mockLogError.mockReset();
responseHeaders = { 'x-header': ['value'], 'content-type': ['test/other'] };
});
afterAll(() => {
mockGetCSSToInject.mockRestore();
mockLogError.mockRestore();
});
test('will not inject if getCSSToInject is empty', () => {
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
expect(mockWebContentsInsertCSS).not.toHaveBeenCalled();
});
test('will inject on did-navigate + onResponseStarted', () => {
mockGetCSSToInject.mockReturnValue(css);
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
window.webContents.emit('did-navigate');
// @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
window.webContents.session.webRequest.send('onResponseStarted', {
responseHeaders,
webContents: window.webContents,
});
expect(mockWebContentsInsertCSS).toHaveBeenCalledWith(css);
});
test.each<string>(['application/json', 'font/woff2', 'image/png'])(
'will not inject for content-type %s',
(contentType: string) => {
mockGetCSSToInject.mockReturnValue(css);
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
responseHeaders['content-type'] = [contentType];
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
expect(window.webContents.emit('did-navigate')).toBe(true);
mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined);
// @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
window.webContents.session.webRequest.send('onResponseStarted', {
responseHeaders,
webContents: window.webContents,
url: `test-${contentType}`,
});
// insertCSS will still run once for the did-navigate
expect(mockWebContentsInsertCSS).not.toHaveBeenCalled();
},
);
test.each<string>(['text/html'])(
'will inject for content-type %s',
(contentType: string) => {
mockGetCSSToInject.mockReturnValue(css);
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
responseHeaders['content-type'] = [contentType];
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
window.webContents.emit('did-navigate');
mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined);
// @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
window.webContents.session.webRequest.send('onResponseStarted', {
responseHeaders,
webContents: window.webContents,
url: `test-${contentType}`,
});
expect(mockWebContentsInsertCSS).toHaveBeenCalledTimes(1);
},
);
test.each<string>(['image', 'script', 'stylesheet', 'xhr'])(
'will not inject for resource type %s',
(resourceType: string) => {
mockGetCSSToInject.mockReturnValue(css);
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
window.webContents.emit('did-navigate');
mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined);
// @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
window.webContents.session.webRequest.send('onResponseStarted', {
responseHeaders,
webContents: window.webContents,
resourceType,
url: `test-${resourceType}`,
});
// insertCSS will still run once for the did-navigate
expect(mockWebContentsInsertCSS).not.toHaveBeenCalled();
},
);
test.each<string>(['html', 'other'])(
'will inject for resource type %s',
(resourceType: string) => {
mockGetCSSToInject.mockReturnValue(css);
const window = new BrowserWindow();
const mockWebContentsInsertCSS: jest.SpyInstance = jest
.spyOn(window.webContents, 'insertCSS')
.mockResolvedValue('');
jest
.spyOn(window.webContents, 'getURL')
.mockReturnValue('https://example.com');
injectCSS(window);
expect(mockGetCSSToInject).toHaveBeenCalled();
window.webContents.emit('did-navigate');
mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined);
// @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
window.webContents.session.webRequest.send('onResponseStarted', {
responseHeaders,
webContents: window.webContents,
resourceType,
url: `test-${resourceType}`,
});
expect(mockWebContentsInsertCSS).toHaveBeenCalledTimes(1);
},
);
});

View File

@ -1,365 +0,0 @@
import path from 'path';
import {
dialog,
BrowserWindow,
BrowserWindowConstructorOptions,
Event,
MessageBoxReturnValue,
WebPreferences,
OnResponseStartedListenerDetails,
} from 'electron';
import { getCSSToInject, isOSX, nativeTabsSupported } from './helpers';
import * as log from './loggingHelper';
import { TrayValue, WindowOptions } from '../../../shared/src/options/model';
import { randomUUID } from 'crypto';
const ZOOM_INTERVAL = 0.1;
export function adjustWindowZoom(adjustment: number): void {
withFocusedWindow((focusedWindow: BrowserWindow) => {
focusedWindow.webContents.zoomFactor =
focusedWindow.webContents.zoomFactor + adjustment;
});
}
export function showNavigationBlockedMessage(
message: string,
): Promise<MessageBoxReturnValue> {
return new Promise((resolve, reject) => {
withFocusedWindow((focusedWindow) => {
dialog
.showMessageBox(focusedWindow, {
message,
type: 'error',
title: 'Navigation blocked',
})
.then((result) => resolve(result))
.catch((err) => {
reject(err);
});
});
});
}
export async function clearAppData(window: BrowserWindow): Promise<void> {
const response = await dialog.showMessageBox(window, {
type: 'warning',
buttons: ['Yes', 'Cancel'],
defaultId: 1,
title: 'Clear cache confirmation',
message:
'This will clear all data (cookies, local storage etc) from this app. Are you sure you wish to proceed?',
});
if (response.response !== 0) {
return;
}
await clearCache(window);
}
export async function clearCache(window: BrowserWindow): Promise<void> {
const { session } = window.webContents;
await session.clearStorageData();
await session.clearCache();
}
export function createAboutBlankWindow(
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
parent?: BrowserWindow,
): BrowserWindow {
const window = createNewWindow(
{ ...options, show: false },
setupWindow,
'about:blank',
nativeTabsSupported() ? undefined : parent,
);
window.webContents.once('did-stop-loading', () => {
if (window.webContents.getURL() === 'about:blank') {
window.close();
} else {
window.show();
}
});
return window;
}
export function createNewTab(
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
url: string,
foreground: boolean,
): BrowserWindow | undefined {
const focusedWindow = BrowserWindow.getFocusedWindow();
log.debug('createNewTab', {
url,
foreground,
focusedWindow,
});
return withFocusedWindow((focusedWindow) => {
const newTab = createNewWindow(options, setupWindow, url);
log.debug('createNewTab.withFocusedWindow', { focusedWindow, newTab });
focusedWindow.addTabbedWindow(newTab);
if (!foreground) {
focusedWindow.focus();
}
return newTab;
});
}
export function createNewWindow(
options: WindowOptions,
setupWindow: (options: WindowOptions, window: BrowserWindow) => void,
url: string,
parent?: BrowserWindow,
): BrowserWindow {
log.debug('createNewWindow', {
url,
parent,
});
const window = new BrowserWindow({
parent: nativeTabsSupported() ? undefined : parent,
...getDefaultWindowOptions(options),
});
setupWindow(options, window);
window.loadURL(url).catch((err) => log.error('window.loadURL ERROR', err));
return window;
}
export function getCurrentURL(): string {
return withFocusedWindow((focusedWindow) =>
focusedWindow.webContents.getURL(),
) as unknown as string;
}
export function getDefaultWindowOptions(
options: WindowOptions,
): BrowserWindowConstructorOptions {
const browserwindowOptions: BrowserWindowConstructorOptions = {
...options.browserwindowOptions,
};
// We're going to remove this and merge it separately into DEFAULT_WINDOW_OPTIONS.webPreferences
// Otherwise the browserwindowOptions.webPreferences object will completely replace the
// webPreferences specified in the DEFAULT_WINDOW_OPTIONS with itself
delete browserwindowOptions.webPreferences;
const webPreferences: WebPreferences = {
...(options.browserwindowOptions?.webPreferences ?? {}),
};
const defaultOptions: BrowserWindowConstructorOptions = {
autoHideMenuBar: options.autoHideMenuBar,
fullscreenable: true,
tabbingIdentifier: nativeTabsSupported()
? options.tabbingIdentifier ?? randomUUID()
: undefined,
title: options.name,
webPreferences: {
javascript: true,
nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com
preload: path.join(__dirname, 'preload.js'),
plugins: true,
sandbox: false, // https://www.electronjs.org/blog/electron-20-0#default-changed-renderers-without-nodeintegration-true-are-sandboxed-by-default
webSecurity: !options.insecure,
zoomFactor: options.zoom,
// `contextIsolation` was switched to true in Electron 12, which:
// 1. Breaks access to global variables in `--inject`-ed scripts:
// https://github.com/nativefier/nativefier/issues/1269
// 2. Might break notifications under Windows, although this was refuted:
// https://github.com/nativefier/nativefier/issues/1292
// So, it was flipped to false in https://github.com/nativefier/nativefier/pull/1308
//
// If attempting to set it back to `true` (for security),
// do test exhaustively these two areas, and more.
contextIsolation: false,
...webPreferences,
},
...browserwindowOptions,
};
log.debug('getDefaultWindowOptions', {
options,
webPreferences,
defaultOptions,
});
return defaultOptions;
}
export function goBack(): void {
log.debug('onGoBack');
withFocusedWindow((focusedWindow) => {
focusedWindow.webContents.goBack();
});
}
export function goForward(): void {
log.debug('onGoForward');
withFocusedWindow((focusedWindow) => {
focusedWindow.webContents.goForward();
});
}
export function goToURL(url: string): Promise<void> | undefined {
return withFocusedWindow((focusedWindow) => focusedWindow.loadURL(url));
}
export function hideWindow(
window: BrowserWindow,
event: Event,
fastQuit: boolean,
tray: TrayValue,
): void {
if (isOSX() && !fastQuit) {
// this is called when exiting from clicking the cross button on the window
event.preventDefault();
window.hide();
} else if (!fastQuit && tray !== 'false') {
event.preventDefault();
window.hide();
}
// will close the window on other platforms
}
export function injectCSS(browserWindow: BrowserWindow): void {
const cssToInject = getCSSToInject();
if (!cssToInject) {
return;
}
browserWindow.webContents.on('did-navigate', () => {
log.debug(
'browserWindow.webContents.did-navigate',
browserWindow.webContents.getURL(),
);
browserWindow.webContents
.insertCSS(cssToInject)
.catch((err: unknown) =>
log.error('browserWindow.webContents.insertCSS', err),
);
// We must inject css early enough; so onResponseStarted is a good place.
browserWindow.webContents.session.webRequest.onResponseStarted(
{ urls: [] }, // Pass an empty filter list; null will not match _any_ urls
(details: OnResponseStartedListenerDetails): void => {
log.debug('onResponseStarted', {
resourceType: details.resourceType,
url: details.url,
});
injectCSSIntoResponse(details, cssToInject).catch((err: unknown) => {
log.error('injectCSSIntoResponse ERROR', err);
});
},
);
});
}
function injectCSSIntoResponse(
details: OnResponseStartedListenerDetails,
cssToInject: string,
): Promise<string | undefined> {
const contentType =
details.responseHeaders && 'content-type' in details.responseHeaders
? details.responseHeaders['content-type'][0]
: undefined;
log.debug('injectCSSIntoResponse', { details, cssToInject, contentType });
// We go with a denylist rather than a whitelist (e.g. only text/html)
// to avoid "whoops I didn't think this should have been CSS-injected" cases
const nonInjectableContentTypes = [
/application\/.*/,
/font\/.*/,
/image\/.*/,
];
const nonInjectableResourceTypes = ['image', 'script', 'stylesheet', 'xhr'];
if (
(contentType &&
nonInjectableContentTypes.filter((x) => {
const matches = x.exec(contentType);
return matches && matches?.length > 0;
})?.length > 0) ||
nonInjectableResourceTypes.includes(details.resourceType) ||
!details.webContents
) {
log.debug(
`Skipping CSS injection for:\n${details.url}\nwith resourceType ${
details.resourceType
} and content-type ${contentType as string}`,
);
return Promise.resolve(undefined);
}
log.debug(
`Injecting CSS for:\n${details.url}\nwith resourceType ${
details.resourceType
} and content-type ${contentType as string}`,
);
return details.webContents.insertCSS(cssToInject);
}
export function sendParamsOnDidFinishLoad(
options: WindowOptions,
window: BrowserWindow,
): void {
window.webContents.on('did-finish-load', () => {
log.debug(
'sendParamsOnDidFinishLoad.window.webContents.did-finish-load',
window.webContents.getURL(),
);
// In children windows too: Restore pinch-to-zoom, disabled by default in recent Electron.
// See https://github.com/nativefier/nativefier/issues/379#issuecomment-598612128
// and https://github.com/electron/electron/pull/12679
window.webContents
.setVisualZoomLevelLimits(1, 3)
.catch((err) => log.error('webContents.setVisualZoomLevelLimits', err));
window.webContents.send('params', JSON.stringify(options));
});
}
export function setProxyRules(
window: BrowserWindow,
proxyRules?: string,
): void {
window.webContents.session
.setProxy({
proxyRules,
pacScript: '',
proxyBypassRules: '',
})
.catch((err) => log.error('session.setProxy ERROR', err));
}
export function withFocusedWindow<T>(
block: (window: BrowserWindow) => T,
): T | undefined {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow) {
return block(focusedWindow);
}
return undefined;
}
export function zoomOut(): void {
log.debug('zoomOut');
adjustWindowZoom(-ZOOM_INTERVAL);
}
export function zoomReset(options: { zoom?: number }): void {
log.debug('zoomReset');
withFocusedWindow((focusedWindow) => {
focusedWindow.webContents.zoomFactor = options.zoom ?? 1.0;
});
}
export function zoomIn(): void {
log.debug('zoomIn');
adjustWindowZoom(ZOOM_INTERVAL);
}

135
app/src/main.js Normal file
View File

@ -0,0 +1,135 @@
import 'source-map-support/register';
import fs from 'fs';
import path from 'path';
import { app, crashReporter } from 'electron';
import electronDownload from 'electron-dl';
import createLoginWindow from './components/login/loginWindow';
import createMainWindow from './components/mainWindow/mainWindow';
import createTrayIcon from './components/trayIcon/trayIcon';
import helpers from './helpers/helpers';
import inferFlash from './helpers/inferFlash';
const { isOSX } = helpers;
electronDownload();
const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json');
const appArgs = JSON.parse(fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8'));
if (appArgs.processEnvs) {
Object.keys(appArgs.processEnvs).forEach((key) => {
/* eslint-env node */
process.env[key] = appArgs.processEnvs[key];
});
}
let mainWindow;
if (typeof appArgs.flashPluginDir === 'string') {
app.commandLine.appendSwitch('ppapi-flash-path', appArgs.flashPluginDir);
} else if (appArgs.flashPluginDir) {
const flashPath = inferFlash();
app.commandLine.appendSwitch('ppapi-flash-path', flashPath);
}
if (appArgs.ignoreCertificate) {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
if (appArgs.ignoreGpuBlacklist) {
app.commandLine.appendSwitch('ignore-gpu-blacklist');
}
if (appArgs.enableEs3Apis) {
app.commandLine.appendSwitch('enable-es3-apis');
}
if (appArgs.diskCacheSize) {
app.commandLine.appendSwitch('disk-cache-size', appArgs.diskCacheSize);
}
if (appArgs.basicAuthUsername) {
app.commandLine.appendSwitch('basic-auth-username', appArgs.basicAuthUsername);
}
if (appArgs.basicAuthPassword) {
app.commandLine.appendSwitch('basic-auth-password', appArgs.basicAuthPassword);
}
// do nothing for setDockBadge if not OSX
let setDockBadge = () => {};
if (isOSX()) {
setDockBadge = app.dock.setBadge;
}
app.on('window-all-closed', () => {
if (!isOSX() || appArgs.fastQuit) {
app.quit();
}
});
app.on('activate', (event, hasVisibleWindows) => {
if (isOSX()) {
// this is called when the dock is clicked
if (!hasVisibleWindows) {
mainWindow.show();
}
}
});
app.on('before-quit', () => {
// not fired when the close button on the window is clicked
if (isOSX()) {
// need to force a quit as a workaround here to simulate the osx app hiding behaviour
// Somehow sokution at https://github.com/atom/electron/issues/444#issuecomment-76492576 does not work,
// e.prevent default appears to persist
// might cause issues in the future as before-quit and will-quit events are not called
app.exit(0);
}
});
if (appArgs.crashReporter) {
app.on('will-finish-launching', () => {
crashReporter.start({
companyName: appArgs.companyName || '',
productName: appArgs.name,
submitURL: appArgs.crashReporter,
autoSubmit: true,
});
});
}
app.on('ready', () => {
mainWindow = createMainWindow(appArgs, app.quit, setDockBadge);
createTrayIcon(appArgs, mainWindow);
});
app.on('login', (event, webContents, request, authInfo, callback) => {
// for http authentication
event.preventDefault();
if (appArgs.basicAuthUsername !== null && appArgs.basicAuthPassword !== null) {
callback(appArgs.basicAuthUsername, appArgs.basicAuthPassword);
} else {
createLoginWindow(callback);
}
});
if (appArgs.singleInstance) {
const shouldQuit = app.makeSingleInstance(() => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
}
});
if (shouldQuit) {
app.quit();
}
}

View File

@ -1,458 +0,0 @@
import 'source-map-support/register';
import fs from 'fs';
import * as path from 'path';
import electron, {
app,
dialog,
globalShortcut,
systemPreferences,
BrowserWindow,
Event,
} from 'electron';
import electronDownload from 'electron-dl';
import { createLoginWindow } from './components/loginWindow';
import {
createMainWindow,
saveAppArgs,
APP_ARGS_FILE_PATH,
} from './components/mainWindow';
import { createTrayIcon } from './components/trayIcon';
import {
isOSX,
isWayland,
isWindows,
removeUserAgentSpecifics,
} from './helpers/helpers';
import { inferFlashPath } from './helpers/inferFlash';
import * as log from './helpers/loggingHelper';
import {
IS_PLAYWRIGHT,
PLAYWRIGHT_CONFIG,
safeGetEnv,
} from './helpers/playwrightHelpers';
import { OutputOptions } from '../../shared/src/options/model';
// Entrypoint for Squirrel, a windows update framework. See https://github.com/nativefier/nativefier/pull/744
if (require('electron-squirrel-startup')) {
app.exit();
}
if (process.argv.indexOf('--verbose') > -1 || safeGetEnv('VERBOSE') === '1') {
log.setLevel('DEBUG');
process.traceDeprecation = true;
process.traceProcessWarnings = true;
process.argv.slice(1);
}
let mainWindow: BrowserWindow;
const appArgs =
IS_PLAYWRIGHT && PLAYWRIGHT_CONFIG
? (JSON.parse(PLAYWRIGHT_CONFIG) as OutputOptions)
: (JSON.parse(
fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8'),
) as OutputOptions);
log.debug('appArgs', appArgs);
// Do this relatively early so that we can start storing appData with the app
if (appArgs.portable) {
log.debug(
'App was built as portable; setting appData and userData to the app folder: ',
path.resolve(path.join(__dirname, '..', 'appData')),
);
app.setPath('appData', path.join(__dirname, '..', 'appData'));
app.setPath('userData', path.join(__dirname, '..', 'appData'));
}
if (!appArgs.userAgentHonest) {
if (appArgs.userAgent) {
app.userAgentFallback = appArgs.userAgent;
} else {
app.userAgentFallback = removeUserAgentSpecifics(
app.userAgentFallback,
app.getName(),
app.getVersion(),
);
}
}
// this step is required to allow app names to be displayed correctly in notifications on windows
// https://www.electronjs.org/docs/latest/api/app#appsetappusermodelidid-windows
// https://www.electronjs.org/docs/latest/tutorial/notifications#windows
if (isWindows()) {
app.setAppUserModelId(app.getName());
}
const urlArgv = process.argv.filter((a) => a.startsWith('http'));
// Take in a URL on the command line as an override
if (urlArgv.length > 0) {
const maybeUrl = urlArgv[0];
try {
new URL(maybeUrl);
appArgs.targetUrl = maybeUrl;
log.info('Loading override URL passed as argument:', maybeUrl);
} catch (err: unknown) {
log.error(
'Not loading override URL passed as argument, because failed to parse:',
maybeUrl,
err,
);
}
}
// Nativefier is a browser, and an old browser is an insecure / badly-performant one.
// Given our builder/app design, we currently don't have an easy way to offer
// upgrades from the app themselves (like browsers do).
// As a workaround, we ask for a manual upgrade & re-build if the build is old.
// The period in days is chosen to be not too small to be exceedingly annoying,
// but not too large to be exceedingly insecure.
const OLD_BUILD_WARNING_THRESHOLD_DAYS = 90;
const OLD_BUILD_WARNING_THRESHOLD_MS =
OLD_BUILD_WARNING_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
const fileDownloadOptions = { ...appArgs.fileDownloadOptions };
electronDownload(fileDownloadOptions);
if (appArgs.processEnvs) {
let processEnvs: Record<string, string> =
appArgs.processEnvs as unknown as Record<string, string>;
// This is compatibility if just a string was passed.
if (typeof appArgs.processEnvs === 'string') {
try {
processEnvs = JSON.parse(appArgs.processEnvs) as Record<string, string>;
} catch {
// This wasn't JSON. Fall back to the old code
processEnvs = {};
process.env.processEnvs = appArgs.processEnvs;
}
}
Object.keys(processEnvs)
.filter((key) => key !== undefined)
.forEach((key) => {
process.env[key] = processEnvs[key];
});
}
if (typeof appArgs.flashPluginDir === 'string') {
app.commandLine.appendSwitch('ppapi-flash-path', appArgs.flashPluginDir);
} else if (appArgs.flashPluginDir) {
const flashPath = inferFlashPath();
app.commandLine.appendSwitch('ppapi-flash-path', flashPath);
}
if (appArgs.ignoreCertificate) {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
if (appArgs.disableGpu) {
app.disableHardwareAcceleration();
}
if (appArgs.ignoreGpuBlacklist) {
app.commandLine.appendSwitch('ignore-gpu-blacklist');
}
if (appArgs.enableEs3Apis) {
app.commandLine.appendSwitch('enable-es3-apis');
}
if (appArgs.diskCacheSize) {
app.commandLine.appendSwitch(
'disk-cache-size',
appArgs.diskCacheSize.toString(),
);
}
if (appArgs.basicAuthUsername) {
app.commandLine.appendSwitch(
'basic-auth-username',
appArgs.basicAuthUsername,
);
}
if (appArgs.basicAuthPassword) {
app.commandLine.appendSwitch(
'basic-auth-password',
appArgs.basicAuthPassword,
);
}
if (isWayland()) {
app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer');
}
if (appArgs.lang) {
const langParts = appArgs.lang.split(',');
// Convert locales to languages, because for some reason locales don't work. Stupid Chromium
const langPartsParsed = Array.from(
// Convert to set to dedupe in case something like "en-GB,en-US" was passed
new Set(langParts.map((l) => l.split('-')[0])),
);
const langFlag = langPartsParsed.join(',');
log.debug('Setting --lang flag to', langFlag);
app.commandLine.appendSwitch('--lang', langFlag);
}
let currentBadgeCount = 0;
const setDockBadge = isOSX()
? (count?: number | string, bounce = false): void => {
if (count !== undefined) {
app.dock.setBadge(count.toString());
if (bounce && typeof count === 'number' && count > currentBadgeCount)
app.dock.bounce();
currentBadgeCount = typeof count === 'number' ? count : 0;
}
}
: (): void => undefined;
app.on('window-all-closed', () => {
log.debug('app.window-all-closed');
if (!isOSX() || appArgs.fastQuit || IS_PLAYWRIGHT) {
app.quit();
}
});
app.on('before-quit', () => {
log.debug('app.before-quit');
// not fired when the close button on the window is clicked
if (isOSX()) {
// need to force a quit as a workaround here to simulate the osx app hiding behaviour
// Somehow sokution at https://github.com/atom/electron/issues/444#issuecomment-76492576 does not work,
// e.prevent default appears to persist
// might cause issues in the future as before-quit and will-quit events are not called
app.exit(0);
}
});
app.on('will-quit', (event) => {
log.debug('app.will-quit', event);
});
app.on('quit', (event, exitCode) => {
log.debug('app.quit', { event, exitCode });
});
app.on('will-finish-launching', () => {
log.debug('app.will-finish-launching');
});
app.on('open-url', (event, url) => {
log.debug('app.open-url', { event, url });
event.preventDefault();
if (mainWindow) {
mainWindow.webContents.send('open-url', url);
}
});
if (appArgs.widevine) {
// @ts-expect-error This event only appears on the widevine version of electron, which we'd see at runtime
app.on('widevine-ready', (version: string, lastVersion: string) => {
log.debug('app.widevine-ready', { version, lastVersion });
onReady().catch((err) => log.error('onReady ERROR', err));
});
app.on(
// @ts-expect-error This event only appears on the widevine version of electron, which we'd see at runtime
'widevine-update-pending',
(currentVersion: string, pendingVersion: string) => {
log.debug('app.widevine-update-pending', {
currentVersion,
pendingVersion,
});
},
);
// @ts-expect-error This event only appears on the widevine version of electron, which we'd see at runtime
app.on('widevine-error', (error: Error) => {
log.error('app.widevine-error', error);
});
} else {
app.on('ready', () => {
log.debug('ready');
onReady().catch((err) => log.error('onReady ERROR', err));
});
}
app.on('activate', (event: electron.Event, hasVisibleWindows: boolean) => {
log.debug('app.activate', { event, hasVisibleWindows });
if (isOSX() && !IS_PLAYWRIGHT) {
// this is called when the dock is clicked
if (!hasVisibleWindows) {
mainWindow.show();
}
}
});
// quit if singleInstance mode and there's already another instance running
const shouldQuit = appArgs.singleInstance && !app.requestSingleInstanceLock();
if (shouldQuit) {
app.quit();
} else {
app.on('second-instance', () => {
log.debug('app.second-instance');
if (mainWindow) {
if (!mainWindow.isVisible()) {
// try
mainWindow.show();
}
if (mainWindow.isMinimized()) {
// minimized
mainWindow.restore();
}
mainWindow.focus();
}
});
}
app.on('new-window-for-tab', (event: Event) => {
log.debug('app.new-window-for-tab', { event });
if (mainWindow) {
mainWindow.emit('new-window-for-tab', event);
}
});
app.on(
'login',
(
event,
webContents,
request,
authInfo,
callback: (username?: string, password?: string) => void,
) => {
log.debug('app.login', { event, request });
// for http authentication
event.preventDefault();
if (appArgs.basicAuthUsername && appArgs.basicAuthPassword) {
callback(appArgs.basicAuthUsername, appArgs.basicAuthPassword);
} else {
createLoginWindow(
callback,
// mainWindow
).catch((err) => log.error('createLoginWindow ERROR', err));
}
},
);
async function onReady(): Promise<void> {
// Warning: `mainWindow` below is the *global* unique `mainWindow`, created at init time
mainWindow = await createMainWindow(appArgs, setDockBadge);
createTrayIcon(appArgs, mainWindow);
// Register global shortcuts
if (appArgs.globalShortcuts) {
appArgs.globalShortcuts.forEach((shortcut) => {
globalShortcut.register(shortcut.key, () => {
shortcut.inputEvents.forEach((inputEvent) => {
// @ts-expect-error without including electron in our models, these will never match
mainWindow.webContents.sendInputEvent(inputEvent);
});
});
});
if (isOSX() && appArgs.accessibilityPrompt) {
const mediaKeys = [
'MediaPlayPause',
'MediaNextTrack',
'MediaPreviousTrack',
'MediaStop',
];
const globalShortcutsKeys = appArgs.globalShortcuts.map((g) => g.key);
const mediaKeyWasSet = globalShortcutsKeys.find((g) =>
mediaKeys.includes(g),
);
if (
mediaKeyWasSet &&
!systemPreferences.isTrustedAccessibilityClient(false)
) {
// Since we're trying to set global keyboard shortcuts for media keys, we need to prompt
// the user for permission on Mac.
// For reference:
// https://www.electronjs.org/docs/api/global-shortcut?q=MediaPlayPause#globalshortcutregisteraccelerator-callback
const accessibilityPromptResult = dialog.showMessageBoxSync(
mainWindow,
{
type: 'question',
message: 'Accessibility Permissions Needed',
buttons: ['Yes', 'No', 'No and never ask again'],
defaultId: 0,
detail:
`${appArgs.name} would like to use one or more of your keyboard's media keys (start, stop, next track, or previous track) to control it.\n\n` +
`Would you like Mac OS to ask for your permission to do so?\n\n` +
`If so, you will need to restart ${appArgs.name} after granting permissions for these keyboard shortcuts to begin working.`,
},
);
switch (accessibilityPromptResult) {
// User clicked Yes, prompt for accessibility
case 0:
systemPreferences.isTrustedAccessibilityClient(true);
break;
// User cliecked Never Ask Me Again, save that info
case 2:
appArgs.accessibilityPrompt = false;
saveAppArgs(appArgs);
break;
// User clicked No
default:
break;
}
}
}
}
if (
!appArgs.disableOldBuildWarning &&
new Date().getTime() - appArgs.buildDate > OLD_BUILD_WARNING_THRESHOLD_MS
) {
const oldBuildWarningText =
appArgs.oldBuildWarningText ||
'This app was built a long time ago. Nativefier uses the Chrome browser (through Electron), and it is insecure to keep using an old version of it. Please upgrade Nativefier and rebuild this app.';
dialog
.showMessageBox(mainWindow, {
type: 'warning',
message: 'Old build detected',
detail: oldBuildWarningText,
})
.catch((err) => log.error('dialog.showMessageBox ERROR', err));
}
if (appArgs.targetUrl) {
await mainWindow.loadURL(appArgs.targetUrl);
}
}
app.on(
'accessibility-support-changed',
(event: Event, accessibilitySupportEnabled: boolean) => {
log.debug('app.accessibility-support-changed', {
event,
accessibilitySupportEnabled,
});
},
);
app.on(
'activity-was-continued',
(event: Event, type: string, userInfo: unknown) => {
log.debug('app.activity-was-continued', { event, type, userInfo });
},
);
app.on('browser-window-blur', () => {
log.debug('app.browser-window-blur');
});
app.on('browser-window-created', () => {
log.debug('app.browser-window-created');
});
app.on('browser-window-focus', () => {
log.debug('app.browser-window-focus');
});

View File

@ -1,160 +0,0 @@
/* eslint-disable @typescript-eslint/no-extraneous-class */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventEmitter } from 'events';
/*
These mocks are PURPOSEFULLY minimal. A few reasons as to why:
1. I'm l̶a̶z̶y̶ a busy person :)
2. The less we have in here, the less we'll need to fix if an electron API changes
3. Only mocking what we need as we need it helps reveal areas under test where electron
is being accessed in previously unaccounted for ways
4. These mocks will get fleshed out as more unit tests are added, so if you need
something here as you are adding unit tests, then feel free to add exactly what you
need (and no more than that please).
As well, please resist the urge to turn this into a reimplimentation of electron.
When adding functions/classes, keep your implementation to only the minimal amount of code
it takes for TypeScript to recognize what you are doing. For anything more complex (including
implementation code and return values) please do that within your tests via jest with
mockImplementation or mockReturnValue.
*/
class MockBrowserWindow extends EventEmitter {
webContents: MockWebContents;
constructor(options?: unknown) {
// @ts-expect-error options is really EventEmitterOptions, but events.d.ts doesn't expose it...
super(options);
this.webContents = new MockWebContents();
}
addTabbedWindow(tab: MockBrowserWindow): void {
return;
}
focus(): void {
return;
}
static fromWebContents(webContents: MockWebContents): MockBrowserWindow {
return new MockBrowserWindow();
}
static getFocusedWindow(window: MockBrowserWindow): MockBrowserWindow {
return window ?? new MockBrowserWindow();
}
isSimpleFullScreen(): boolean {
throw new Error('Not implemented');
}
isFullScreen(): boolean {
throw new Error('Not implemented');
}
isFullScreenable(): boolean {
throw new Error('Not implemented');
}
loadURL(url: string, options?: unknown): Promise<void> {
return Promise.resolve(undefined);
}
setFullScreen(flag: boolean): void {
return;
}
setSimpleFullScreen(flag: boolean): void {
return;
}
}
class MockDialog {
static showMessageBox(
browserWindow: MockBrowserWindow,
options: unknown,
): Promise<number> {
throw new Error('Not implemented');
}
static showMessageBoxSync(
browserWindow: MockBrowserWindow,
options: unknown,
): number {
throw new Error('Not implemented');
}
}
class MockSession extends EventEmitter {
webRequest: MockWebRequest;
constructor() {
super();
this.webRequest = new MockWebRequest();
}
clearCache(): Promise<void> {
return Promise.resolve();
}
clearStorageData(): Promise<void> {
return Promise.resolve();
}
}
class MockWebContents extends EventEmitter {
session: MockSession;
constructor() {
super();
this.session = new MockSession();
}
getURL(): string {
throw new Error('Not implemented');
}
insertCSS(css: string, options?: unknown): Promise<string> {
throw new Error('Not implemented');
}
}
class MockWebRequest {
emitter: InternalEmitter;
constructor() {
this.emitter = new InternalEmitter();
}
onResponseStarted(
filter: unknown,
listener: ((details: unknown) => void) | null,
): void {
if (listener) {
this.emitter.addListener('onResponseStarted', (details: unknown) =>
listener(details),
);
}
}
send(event: string, ...args: unknown[]): void {
this.emitter.emit(event, ...args);
}
}
class InternalEmitter extends EventEmitter {}
const mockShell = {
openExternal(url: string, options?: unknown): Promise<void> {
return new Promise((resolve) => resolve());
},
};
export {
MockDialog as dialog,
MockBrowserWindow as BrowserWindow,
MockSession as Session,
MockWebContents as WebContents,
MockWebRequest as WebRequest,
mockShell as shell,
};

View File

@ -1,352 +0,0 @@
/**
* Preload file that will be executed in the renderer process.
* Note: This needs to be attached **prior to imports**, as imports
* would delay the attachment till after the event has been raised.
*/
document.addEventListener('DOMContentLoaded', () => {
injectScripts(); // eslint-disable-line @typescript-eslint/no-use-before-define
});
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ipcRenderer } from 'electron';
import { OutputOptions } from '../../shared/src/options/model';
// Do *NOT* add 3rd-party imports here in preload (except for webpack `externals` like electron).
// They will work during development, but break in the prod build :-/ .
// Electron doc isn't explicit about that, so maybe *we*'re doing something wrong.
// At any rate, that's what we have now. If you want an import here, go ahead, but
// verify that apps built with a non-devbuild nativefier (installed from tarball) work.
// Recipe to monkey around this, assuming you git-cloned nativefier in /opt/nativefier/ :
// cd /opt/nativefier/ && rm -f nativefier-43.1.0.tgz && npm run build && npm pack && mkdir -p ~/n4310/ && cd ~/n4310/ \
// && rm -rf ./* && npm i /opt/nativefier/nativefier-43.1.0.tgz && ./node_modules/.bin/nativefier 'google.com'
// See https://github.com/nativefier/nativefier/issues/1175
// and https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions / preload
const log = console; // since we can't have `loglevel` here in preload
export const INJECT_DIR = path.join(__dirname, '..', 'inject');
/**
* Patches window.Notification to:
* - set a callback on a new Notification
* - set a callback for clicks on notifications
* @param createCallback
* @param clickCallback
*/
function setNotificationCallback(
createCallback: {
(title: string, opt: NotificationOptions): void;
(...args: unknown[]): void;
},
clickCallback: { (): void; (this: Notification, ev: Event): unknown },
): void {
const OldNotify = window.Notification;
const newNotify = function (
title: string,
opt: NotificationOptions,
): Notification {
createCallback(title, opt);
const instance = new OldNotify(title, opt);
instance.addEventListener('click', clickCallback);
return instance;
};
newNotify.requestPermission = OldNotify.requestPermission.bind(OldNotify);
Object.defineProperty(newNotify, 'permission', {
get: () => OldNotify.permission,
});
// @ts-expect-error TypeScript says its not compatible, but it works?
window.Notification = newNotify;
}
async function getDisplayMedia(
sourceId: number | string,
): Promise<MediaStream> {
type OriginalVideoPropertyType = boolean | MediaTrackConstraints | undefined;
if (!window?.navigator?.mediaDevices) {
throw Error('window.navigator.mediaDevices is not present');
}
// Electron supports an outdated specification for mediaDevices,
// see https://www.electronjs.org/docs/latest/api/desktop-capturer/
const stream = await window.navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
},
} as unknown as OriginalVideoPropertyType,
});
return stream;
}
function setupScreenSharePickerStyles(id: string): void {
const screenShareStyles = document.createElement('style');
screenShareStyles.id = id;
screenShareStyles.innerHTML = `
.desktop-capturer-selection {
--overlay-color: hsla(0, 0%, 11.8%, 0.75);
--highlight-color: highlight;
--text-content-color: #fff;
--selection-button-color: hsl(180, 1.3%, 14.7%);
}
.desktop-capturer-selection {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: var(--overlay-color);
color: var(--text-content-color);
z-index: 10000000;
display: flex;
align-items: center;
justify-content: center;
}
.desktop-capturer-selection__close {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
padding: 1rem;
color: inherit;
position: absolute;
left: 1rem;
top: 1rem;
cursor: pointer;
}
.desktop-capturer-selection__scroller {
width: 100%;
max-height: 100vh;
overflow-y: auto;
}
.desktop-capturer-selection__list {
max-width: calc(100% - 100px);
margin: 50px;
padding: 0;
display: flex;
flex-wrap: wrap;
list-style: none;
overflow: hidden;
justify-content: center;
}
.desktop-capturer-selection__item {
display: flex;
margin: 4px;
}
.desktop-capturer-selection__btn {
display: flex;
flex-direction: column;
align-items: stretch;
width: 145px;
margin: 0;
border: 0;
border-radius: 3px;
padding: 4px;
background: var(--selection-button-color);
text-align: left;
transition: background-color .15s, box-shadow .15s;
}
.desktop-capturer-selection__btn:hover,
.desktop-capturer-selection__btn:focus {
background: var(--highlight-color);
}
.desktop-capturer-selection__thumbnail {
width: 100%;
height: 81px;
object-fit: cover;
}
.desktop-capturer-selection__name {
margin: 6px 0 6px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
@media (prefers-color-scheme: light) {
.desktop-capturer-selection {
--overlay-color: hsla(0, 0%, 90.2%, 0.75);
--text-content-color: hsl(0, 0%, 12.9%);
--selection-button-color: hsl(180, 1.3%, 85.3%);
}
}`;
document.head.appendChild(screenShareStyles);
}
function setupScreenSharePickerElement(
id: string,
sources: Electron.DesktopCapturerSource[],
): void {
const selectionElem = document.createElement('div');
selectionElem.classList.add('desktop-capturer-selection');
selectionElem.id = id;
selectionElem.innerHTML = `
<button class="desktop-capturer-selection__close" id="${id}-close" aria-label="Close screen share picker" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="32" height="32">
<path fill="currentColor" d="m12 10.586 4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z"/>
</svg>
</button>
<div class="desktop-capturer-selection__scroller">
<ul class="desktop-capturer-selection__list">
${sources
.map(
({ id, name, thumbnail }) => `
<li class="desktop-capturer-selection__item">
<button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}">
<img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" />
<span class="desktop-capturer-selection__name">${name}</span>
</button>
</li>
`,
)
.join('')}
</ul>
</div>
`;
document.body.appendChild(selectionElem);
}
function setupScreenSharePicker(
resolve: (value: MediaStream | PromiseLike<MediaStream>) => void,
reject: (reason?: unknown) => void,
sources: Electron.DesktopCapturerSource[],
): void {
const baseElementsId = 'native-screen-share-picker';
const pickerStylesElementId = baseElementsId + '-styles';
setupScreenSharePickerElement(baseElementsId, sources);
setupScreenSharePickerStyles(pickerStylesElementId);
const clearElements = (): void => {
document.getElementById(pickerStylesElementId)?.remove();
document.getElementById(baseElementsId)?.remove();
};
document
.getElementById(`${baseElementsId}-close`)
?.addEventListener('click', () => {
clearElements();
reject('Screen share was cancelled by the user.');
});
document
.querySelectorAll('.desktop-capturer-selection__btn')
.forEach((button) => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-id');
if (!id) {
log.error("Couldn't find `data-id` of element");
clearElements();
return;
}
const source = sources.find((source) => source.id === id);
if (!source) {
log.error(`Source with id "${id}" does not exist`);
clearElements();
return;
}
getDisplayMedia(source.id)
.then((stream) => {
resolve(stream);
})
.catch((err) => {
log.error('Error selecting desktop capture source:', err);
reject(err);
})
.finally(() => {
clearElements();
});
});
});
}
function setDisplayMediaPromise(): void {
// Since no implementation for `getDisplayMedia` exists in Electron we write our own.
if (!window?.navigator?.mediaDevices) {
return;
}
window.navigator.mediaDevices.getDisplayMedia = (): Promise<MediaStream> => {
return new Promise((resolve, reject) => {
const sources = ipcRenderer.invoke(
'desktop-capturer-get-sources',
) as Promise<Electron.DesktopCapturerSource[]>;
sources
.then(async (sources) => {
if (isWayland()) {
// No documentation is provided wether the first element is always PipeWire-picked or not
// i.e. maybe it's not deterministic, we are only taking a guess here.
const stream = await getDisplayMedia(sources[0].id);
resolve(stream);
} else {
setupScreenSharePicker(resolve, reject, sources);
}
})
.catch((err) => {
reject(err);
});
});
};
}
function injectScripts(): void {
const needToInject = fs.existsSync(INJECT_DIR);
if (!needToInject) {
return;
}
// Dynamically require scripts
try {
const jsFiles = fs
.readdirSync(INJECT_DIR, { withFileTypes: true })
.filter(
(injectFile) => injectFile.isFile() && injectFile.name.endsWith('.js'),
)
.map((jsFileStat) => path.join('..', 'inject', jsFileStat.name));
for (const jsFile of jsFiles) {
log.debug('Injecting JS file', jsFile);
require(jsFile);
}
} catch (err: unknown) {
log.error('Error encoutered injecting JS files', err);
}
}
function notifyNotificationCreate(
title: string,
opt: NotificationOptions,
): void {
ipcRenderer.send('notification', title, opt);
}
function notifyNotificationClick(): void {
ipcRenderer.send('notification-click');
}
// @ts-expect-error TypeScript thinks these are incompatible but they aren't
setNotificationCallback(notifyNotificationCreate, notifyNotificationClick);
setDisplayMediaPromise();
ipcRenderer.on('params', (event, message: string) => {
log.debug('ipcRenderer.params', { event, message });
const appArgs: unknown = JSON.parse(message) as OutputOptions;
log.info('nativefier.json', appArgs);
});
ipcRenderer.on('debug', (event, message: string) => {
log.debug('ipcRenderer.debug', { event, message });
});
// Copy-pastaed as unable to get imports to work in preload.
// If modifying, update also app/src/helpers/helpers.ts
function isWayland(): boolean {
return (
isLinux() &&
(Boolean(process.env.WAYLAND_DISPLAY) ||
process.env.XDG_SESSION_TYPE === 'wayland')
);
}
function isLinux(): boolean {
return os.platform() === 'linux';
}

View File

@ -1,10 +0,0 @@
const { ipcRenderer } = require('electron');
document.getElementById('login-form').addEventListener('submit', (event) => {
event.preventDefault();
const usernameInput = document.getElementById('username-input');
const username = usernameInput.nodeValue || usernameInput.value;
const passwordInput = document.getElementById('password-input');
const password = passwordInput.nodeValue || passwordInput.value;
ipcRenderer.send('login-message', [username, password]);
});

View File

@ -0,0 +1,12 @@
import electron from 'electron';
const { ipcRenderer } = electron;
const form = document.getElementById('login-form');
form.addEventListener('submit', (event) => {
event.preventDefault();
const username = document.getElementById('username-input').value;
const password = document.getElementById('password-input').value;
ipcRenderer.send('login-message', [username, password]);
});

85
app/src/static/preload.js Normal file
View File

@ -0,0 +1,85 @@
/**
Preload file that will be executed in the renderer process
*/
import { ipcRenderer, webFrame } from 'electron';
import path from 'path';
import fs from 'fs';
const INJECT_JS_PATH = path.join(__dirname, '../../', 'inject/inject.js');
/**
* Patches window.Notification to set a callback on a new Notification
* @param callback
*/
function setNotificationCallback(callback) {
const OldNotify = window.Notification;
const newNotify = (title, opt) => {
callback(title, opt);
return new OldNotify(title, opt);
};
newNotify.requestPermission = OldNotify.requestPermission.bind(OldNotify);
Object.defineProperty(newNotify, 'permission', {
get: () => OldNotify.permission,
});
window.Notification = newNotify;
}
function clickSelector(element) {
const mouseEvent = new MouseEvent('click');
element.dispatchEvent(mouseEvent);
}
function injectScripts() {
const needToInject = fs.existsSync(INJECT_JS_PATH);
if (!needToInject) {
return;
}
// Dynamically require scripts
// eslint-disable-next-line global-require, import/no-dynamic-require
require(INJECT_JS_PATH);
}
setNotificationCallback((title, opt) => {
ipcRenderer.send('notification', title, opt);
});
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('contextmenu', (event) => {
event.preventDefault();
let targetElement = event.srcElement;
// the clicked element is the deepest in the DOM, and may not be the <a> bearing the href
// for example, <a href="..."><span>Google</span></a>
while (!targetElement.href && targetElement.parentElement) {
targetElement = targetElement.parentElement;
}
const targetHref = targetElement.href;
if (!targetHref) {
ipcRenderer.once('contextMenuClosed', () => {
clickSelector(event.target);
ipcRenderer.send('cancelNewWindowOverride');
});
}
ipcRenderer.send('contextMenuOpened', targetHref);
}, false);
injectScripts();
});
ipcRenderer.on('params', (event, message) => {
const appArgs = JSON.parse(message);
console.log('nativefier.json', appArgs);
});
ipcRenderer.on('debug', (event, message) => {
// eslint-disable-next-line no-console
console.log('debug:', message);
});
ipcRenderer.on('change-zoom', (event, message) => {
webFrame.setZoomFactor(message);
});

View File

@ -1,36 +0,0 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"outDir": "./dist",
// Here in app/tsconfig.json, we want to set the `target` and `lib` keys to
// the "best" values for the version of Node **coming with the chosen Electron**.
// Careful: we're *not* talking about Nativefier's (CLI) required Node version,
// we're talking about the version of the Node runtime **bundled with Electron**.
//
// Like in our main tsconfig.json, we want to be as conservative as possible,
// to support (as much as reasonable) users using old versions of Electron.
// Then, at some point, an app dependency (declared in app/package.json)
// will require a more recent Node, then it's okay to bump our app compilerOptions
// to what's supported by the more recent Node.
//
// TS doesn't offer any easy "preset" for this, so the best we have is to
// believe people who know which {syntax, library} parts of current EcmaScript
// are supported for the version of Node coming with the Electron being used,
// and use what they recommend. For the current Node version, I followed
// https://stackoverflow.com/questions/51716406/typescript-tsconfig-settings-for-node-js-10
// and 'dom' to tell tsc it's okay to use the URL object (which is in Node >= 7)
"target": "es2018",
"lib": [
"es2018",
"dom"
]
},
"include": [
"./src/**/*"
],
"references": [
{
"path": "../shared"
}
]
}

View File

@ -1,36 +0,0 @@
const path = require('path');
// Q: Why do you use webpack?
// A: https://github.com/nativefier/nativefier/commit/cde5c1e13bdc2739604cab04bac64eae0d719ed1
module.exports = {
target: 'node',
entry: './src/main.ts',
devtool: 'source-map', // https://webpack.js.org/configuration/devtool/
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader', // https://webpack.js.org/guides/typescript/
exclude: /node_modules/,
},
],
},
// Don't mock __dirname; https://webpack.js.org/configuration/node/#root
node: {
__dirname: false,
},
// Prevent bundling of certain imported packages and instead retrieve these
// external deps at runtime. This is what we want for electron, placed in the
// app by electron-packager. https://webpack.js.org/configuration/externals/
externals: {
electron: 'commonjs electron',
},
resolve: {
extensions: [ '.ts', '.js' ],
},
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'lib'),
},
mode: 'none'
};

View File

@ -1,39 +0,0 @@
// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'prettier'],
extends: [
'eslint:recommended',
'prettier',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
rules: {
'no-console': 'error',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-confusing-non-null-assertion': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-extraneous-class': 'error',
'@typescript-eslint/no-invalid-void-type': 'error',
'@typescript-eslint/prefer-ts-expect-error': 'error',
'@typescript-eslint/type-annotation-spacing': 'error',
'@typescript-eslint/typedef': 'error',
'@typescript-eslint/unified-signatures': 'error',
},
// https://eslint.org/docs/user-guide/configuring/ignoring-code#ignorepatterns-in-config-files
ignorePatterns: [
'node_modules/**',
'app/node_modules/**',
'app/lib/**',
'lib/**',
'built-tests/**',
'coverage/**',
],
};

70
bin/convertToIcns Executable file
View File

@ -0,0 +1,70 @@
#!/bin/sh
### USAGE
# ./convertToIcns <input png> <outp icns>
# Example
# ./convertToIcns ~/sample.png ~/Desktop/converted.icns
# exit the shell script on error immediately
set -e
# import script as variable
CONVERT_TO_PNG="${BASH_SOURCE%/*}/convertToPng"
# Exec Paths
type convert >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Convert executable"; exit 1; }
type iconutil >/dev/null 2>&1 || { echo >&2 "Cannot find required iconutil executable"; exit 1; }
# Parameters
SOURCE=$1
DEST=$2
# Check source and destination arguments
if [ -z "${SOURCE}" ]; then
echo "No source image specified"
exit 1
fi
if [ -z "${DEST}" ]; then
echo "No destination specified"
exit 1
fi
# File Infrastructure
NAME=$(basename "${SOURCE}")
EXT="${NAME##*.}"
BASE="${NAME%.*}"
TEMP_DIR="temp_icon"
ICONSET="${BASE}.iconset"
function cleanUp() {
rm -rf "${TEMP_DIR}"
rm -rf "${ICONSET}"
}
trap cleanUp EXIT
mkdir -p "${TEMP_DIR}"
mkdir -p "${ICONSET}"
PNG_PATH="${TEMP_DIR}/icon.png"
${CONVERT_TO_PNG} "${SOURCE}" "${PNG_PATH}"
# Resample image into iconset
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 16x16 "${ICONSET}/icon_16x16.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 32x32 "${ICONSET}/icon_16x16@2x.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 32x32 "${ICONSET}/icon_32x32.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 64x64 "${ICONSET}/icon_32x32@2x.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 128x128 "${ICONSET}/icon_128x128.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 256x256 "${ICONSET}/icon_128x128@2x.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 256x256 "${ICONSET}/icon_256x256.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 512x512 "${ICONSET}/icon_256x256@2x.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 512x512 "${ICONSET}/icon_512x512.png"
convert "${PNG_PATH}" -define png:big-depth=16 -define png:color-type=6 -sample 1024x1024 "${ICONSET}/icon_512x512@2x.png"
# Create an icns file lefrom the iconset
iconutil -c icns "${ICONSET}" -o "${DEST}"
trap - EXIT
cleanUp

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# USAGE
@ -8,12 +8,7 @@
set -e
CONVERT=
type gm &>/dev/null && gm version | grep GraphicsMagick &>/dev/null && CONVERT="gm convert"
type convert &>/dev/null && CONVERT="convert"
[[ -z "$CONVERT" ]] && { echo >&2 "Cannot find required ImageMagick Convert or GraphicsMagick executable"; exit 1; }
type convert >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Convert executable"; exit 1; }
SOURCE=$1
DEST=$2
@ -30,10 +25,11 @@ fi
NAME=$(basename "${SOURCE}")
EXT="${NAME##*.}"
BASE="${NAME%.*}"
if [ "${EXT}" == "ico" ]; then
cp "${SOURCE}" "${DEST}"
exit 0
fi
$CONVERT "${SOURCE}" -resize 256x256 "${DEST}"
convert "${SOURCE}" -resize 256x256 "${DEST}"

58
bin/convertToPng Executable file
View File

@ -0,0 +1,58 @@
#!/bin/sh
# USAGE
# ./convertToPng <input png or ico> <outfilename>.png
# Example
# ./convertToPng ~/sample.ico ~/Desktop/converted.png
set -e
type convert >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Convert executable"; exit 1; }
type identify >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Identify executable"; exit 1; }
# Parameters
SOURCE=$1
DEST=$2
# Check source and destination arguments
if [ -z "${SOURCE}" ]; then
echo "No source image specified"
exit 1
fi
if [ -z "${DEST}" ]; then
echo "No destination specified"
exit 1
fi
# File Infrastructure
NAME=$(basename "${SOURCE}")
EXT="${NAME##*.}"
BASE="${NAME%.*}"
TEMP_DIR="convert_temp"
function cleanUp() {
rm -rf "${TEMP_DIR}"
}
trap cleanUp EXIT
mkdir -p "${TEMP_DIR}"
# check if .ico is a sequence
# pipe into cat so no exit code is given for grep if no matches are found
IS_ICO_SET="$(identify ${SOURCE} | grep -e "\w\.ico\[0" | cat )"
convert "${SOURCE}" "${TEMP_DIR}/${BASE}.png"
if [ "${IS_ICO_SET}" ]; then
# extract the largest(?) image from the set
cp "${TEMP_DIR}/${BASE}-0.png" "${DEST}"
else
cp "${TEMP_DIR}/${BASE}.png" "${DEST}"
fi
rm -rf "${TEMP_DIR}"
trap - EXIT
cleanUp

546
docs/api.md Normal file
View File

@ -0,0 +1,546 @@
# API
## Table of Contents
- [Command Line](#command-line)
- [Target Url](#target-url)
- [[dest]](#dest)
- [Help](#help)
- [Version](#version)
- [[name]](#name)
- [[platform]](#platform)
- [[arch]](#arch)
- [[electron-version]](#electron-version)
- [[no-overwrite]](#no-overwrite)
- [[conceal]](#conceal)
- [[icon]](#icon)
- [[counter]](#counter)
- [[width]](#width)
- [[height]](#height)
- [[min-width]](#min-width)
- [[min-height]](#min-height)
- [[max-width]](#max-width)
- [[max-height]](#max-height)
- [[show-menu-bar]](#show-menu-bar)
- [[fast-quit]](#fast-quit)
- [[user-agent]](#user-agent)
- [[honest]](#honest)
- [[ignore-certificate]](#ignore-certificate)
- [[insecure]](#insecure)
- [[flash]](#flash)
- [[flash-path]](#flash-path)
- [[disk-cache-size]](#disk-cache-size)
- [[inject]](#inject)
- [[full-screen]](#full-screen)
- [[maximize]](#maximize)
- [[hide-window-frame]](#hide-window-frame)
- [[verbose]](#verbose)
- [[disable-context-menu]](#disable-context-menu)
- [[disable-dev-tools]](#disable-dev-tools)
- [[zoom]](#zoom)
- [[crash-reporter]](#crash-reporter)
- [[single-instance]](#single-instance)
- [[tray]](#tray)
- [[basic-auth-username]](#basic-auth-username)
- [[basic-auth-password]](#basic-auth-username)
- [Programmatic API](#programmatic-api)
## Command Line
```bash
nativefier [options] <targetUrl> [dest]
```
Command line options are listed below.
#### Target Url
The url to point the application at.
#### [dest]
Specifies the destination directory to build the app to, defaults to the current working directory.
#### Help
```
-h, --help
```
Prints the usage information.
#### Version
```
-V, --version
```
Prints the version of your `nativefier` install.
#### [name]
```
-n, --name <value>
```
The name of the application, which will affect strings in titles and the icon.
**For Linux Users:** Do not put spaces if you define the app name yourself with `--name`, as this will cause problems (tested on Ubuntu 14.04) when pinning a packaged app to the launcher.
#### [platform]
```
-p, --platform <value>
```
Automatically determined based on the current OS. Can be overwritten by specifying either `linux`, `windows`, or `osx`.
The alternative values `win32` (for Windows) or `darwin`, `mac` (for OSX) can also be used.
#### [arch]
```
-a, --arch <value>
```
Processor architecture, automatically determined based on the current OS. Can be overwritten by specifying either `ia32`, `x64` or `armv7l`.
#### [app-copyright]
```
--app-copyright <value>
```
The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata property on Windows, and `NSHumanReadableCopyright` on OS X.
#### [app-version]
```
--app-version <value>
```
The release version of the application. By default the `version` property in the `package.json` is used but it can be overridden with this argument. If neither are provided, the version of Electron will be used. Maps to the `ProductVersion` metadata property on Windows, and `CFBundleShortVersionString` on OS X.
#### [build-version]
```
--build-version <value>
```
The build version of the application. Maps to the `FileVersion` metadata property on Windows, and `CFBundleVersion` on OS X.
#### [electron-version]
```
-e, --electron-version <value>
```
Electron version without the `v`, see https://github.com/atom/electron/releases.
#### [no-overwrite]
```
--no-overwrite
```
Specifies if the destination directory should be not overwritten, defaults to false.
#### [conceal]
```
-c, --conceal
```
Specifies if the source code within the nativefied app should be packaged into an archive, defaults to false, [read more](http://electron.atom.io/docs/v0.36.0/tutorial/application-packaging/).
#### [icon]
```
-i, --icon <path>
```
##### Packaging for Windows and Linux
The icon parameter should be a path to a `.png` file.
##### Packaging for OSX
The icon parameter can either be a `.icns` or a `.png` file if the [optional dependencies](../README.md#optional-dependencies) are installed.
If you have the optional dependencies `iconutil`, Imagemagick `convert`, and Imagemagick `identify` in your `PATH`, Nativefier will automatically convert the `.png` to a `.icns` for you.
###### Manually Converting `.icns`
[iConvertIcons](https://iconverticons.com/online/) can be used to convert `.pngs`, though it can be quite cumbersome.
To retrieve the `.icns` file from the downloaded file, extract it first and press File > Get Info. Then select the icon in the top left corner of the info window and press `⌘-C`. Open Preview and press File > New from clipboard and save the `.icns` file. It took me a while to figure out how to do that and question why a `.icns` file was not simply provided in the downloaded archive.
#### [counter]
```
--counter
```
Use a counter that persists even with window focus for the application badge for sites that use an "(X)" format counter in the page title (i.e. Gmail). Same limitations as the badge option (above).
#### [width]
```
--width <value>
```
Width of the packaged application, defaults to `1280px`.
#### [height]
```
--height <value>
```
Height of the packaged application, defaults to `800px`.
#### [min-width]
```
--min-width <value>
```
Minimum width of the packaged application, defaults to `0`.
#### [min-height]
```
--min-height <value>
```
Minimum height of the packaged application, defaults to `0`.
#### [max-width]
```
--max-width <value>
```
Maximum width of the packaged application, default is no limit.
#### [max-height]
```
--max-height <value>
```
Maximum height of the packaged application, default is no limit.
#### [show-menu-bar]
```
-m, --show-menu-bar
```
Specifies if the menu bar should be shown.
#### [fast-quit]
```
-f, --fast-quit
```
(OSX Only) Specifies to quit the app after closing all windows, defaults to false.
#### [user-agent]
```
-u, --user-agent <value>
```
Set the user agent to run the created app with.
#### [honest]
```
--honest
```
By default, Nativefier uses a preset user agent string for your OS and masquerades as a regular Google Chrome browser, so that sites like WhatsApp Web will not say that the current browser is unsupported.
If this flag is passed, it will not override the user agent.
#### [ignore-certificate]
```
--ignore-certificate
```
Forces the packaged app to ignore certificate errors.
#### [ignore-gpu-blacklist]
```
--ignore-gpu-blacklist
```
Passes the ignore-gpu-blacklist flag to the Chrome engine, to allow for WebGl apps to work on non supported graphics cards.
#### [enable-es3-apis]
```
--enable-es3-apis
```
Passes the enable-es3-apis flag to the Chrome engine, to force the activation of WebGl 2.0.
#### [insecure]
```
--insecure
```
Forces the packaged app to ignore web security errors, such as [Mixed Content](https://developer.mozilla.org/en-US/docs/Security/Mixed_content) errors when receiving HTTP content on a HTTPS site.
#### [flash]
```
--flash
```
If `--flash` is specified, Nativefier will automatically try to determine the location of your Google Chrome flash binary. Take note that the version of Chrome on your computer should be the same as the version used by the version of Electron for the Nativefied package.
Take note that if this flag is specified, the `--insecure` flag will be added automatically, to prevent the Mixed Content errors on sites such as [Twitch.tv](https://www.twitch.tv/).
#### [flash-path]
```
--flash-path <value>
```
You can also specify the path to the Chrome flash plugin directly with this flag. The path can be found at [chrome://plugins](chrome://plugins), under `Adobe Flash Player` > `Location`. This flag automatically enables the `--flash` flag as well.
#### [disk-cache-size]
```
--disk-cache-size <value>
```
Forces the maximum disk space to be used by the disk cache. Value is given in bytes.
#### [inject]
```
--inject <value>
```
Allows you to inject a javascript or css file. This command can be run multiple times to inject the files.
Example:
```bash
nativefier http://google.com --inject ./some-js-injection.js --inject ./some-css-injection.css ~/Desktop
```
#### [full-screen]
```
--full-screen
```
Makes the packaged app start in full screen.
#### [maximize]
```
--maximize
```
Makes the packaged app start maximized.
#### [hide-window-frame]
```
--hide-window-frame
```
Disable window frame and controls
#### [verbose]
```
--verbose
```
Shows detailed logs in the console.
#### [disable-context-menu]
```
--disable-context-menu
```
Disable the context menu
#### [disable-dev-tools]
```
--disable-dev-tools
```
Disable the Chrome developer tools
#### [crash-reporter]
```
--crash-reporter <value>
```
Enables crash reporting and set the URL to submit crash reports to
Example:
```bash
nativefier http://google.com --crash-reporter https://electron-crash-reporter.appspot.com/PROJECT_ID/create/
```
#### [zoom]
```
--zoom <value>
```
Sets a default zoom factor to be used when the app is opened, defaults to `1.0`.
#### [single-instance]
```
--single-instance
```
Prevents application from being run multiple times. If such an attempt occurs the already running instance is brought to front.
#### [tray]
```
--tray
```
Application will stay as an icon in the system tray. Prevents application from being closed from clicking the window close button.
#### [basic-auth-username]
```
--basic-auth-username <value> --basic-auth-password <value>
```
Set basic http(s) auth via the command line to have the app automatically log you in to a protected site. Both fields are required if one is set.
#### [processEnvs]
```
--processEnvs <json-string>
```
a JSON string of key/value pairs to be set as environment variables before any browser windows are opened.
Example:
```bash
nativefier <your-geolocation-enabled-website> --processEnvs '{"GOOGLE_API_KEY": "<your-google-api-key>"}'
```
## Programmatic API
You can use the Nativefier programmatic API as well.
```bash
# install and save to package.json
npm install --save nativefier
```
In your `.js` file:
```javascript
var nativefier = require('nativefier').default;
// possible options, defaults unless specified otherwise
var options = {
name: 'Web WhatsApp', // will be inferred if not specified
targetUrl: 'http://web.whatsapp.com', // required
platform: 'darwin', // defaults to the current system
arch: 'x64', // defaults to the current system
version: '0.36.4',
out: '.',
overwrite: false,
asar: false, // see conceal
icon: '~/Desktop/icon.png',
counter: false,
width: 1280,
height: 800,
showMenuBar: false,
fastQuit: false,
userAgent: 'Mozilla ...', // will infer a default for your current system
ignoreCertificate: false,
ignoreGpuBlacklist: false,
enableEs3Apis: false,
insecure: false,
honest: false,
zoom: 1.0,
singleInstance: false,
processEnvs: {
"GOOGLE_API_KEY": "<your-google-api-key>"
}
};
nativefier(options, function(error, appPath) {
if (error) {
console.error(error);
return;
}
console.log('App has been nativefied to', appPath);
});
```
### Addition packaging options for Windows
#### [version-string]
*Object* (**deprecated** and will be removed in a future major version (of `electron-packager`), please use the
[`win32metadata`](#win32metadata) parameter instead)
#### [win32metadata]
```
--win32metadata <json-string>
```
a JSON string of key/value pairs of application metadata (ProductName, InternalName, FileDescription) to embed into the executable (Windows only).
Example:
```bash
nativefier <your-geolocation-enabled-website> --win32metadata '{"ProductName": "Your Product Name", "InternalName", "Your Internal Name", "FileDescription": "Your File Description"}'
```
##### Programmatic API
*Object*
Object (also known as a "hash") of application metadata to embed into the executable:
- `CompanyName`
- `FileDescription`
- `OriginalFilename`
- `ProductName`
- `InternalName`
_(Note that `win32metadata` was added to `electron-packager` in version 8.0.0)_
In your `.js` file:
```javascript
var options = {
...
win32metadata: {
CompanyName: 'Your Company Name',
FileDescription: 'Your File Description',
OriginalFilename: 'Your Original Filename',
ProductName: 'Your Product Name',
InternalName: 'Your Internal Name'
}
};
```
More description about the options for `nativefier` can be found at the above [section](#command-line).

298
docs/changelog.md Normal file
View File

@ -0,0 +1,298 @@
7.5.0 / 2017-11-12
==================
* Add `--tray` flag to let app running in background on window close. Supports in-title counter. (Issue #304, PR #457)
* Add HTTP `--basic-auth-{username,password}` flags (Issue #275, PR #444)
* Add offline build detection and advice (Issue #448, PR #452)
* Add 'Paste and Match Style' to Edit menu (Issue #404, PR #447)
* Add setting environment variables (PR #419)
* Add `app-copyright`, `app-version`, `build-version`, `version-string` and `win32metadata` flags (Issue #226, PR #244)
* Fix: Make title counter regex match '+' after number, used by certain sites (PR #424)
7.4.1 / 2017-08-06
==================
* Add support for `--disk-cache-size` Electron flag (PR #400)
* Add `--ignore-gpu-blacklist` and `--enable-es3-apis` flags to allow WebGL
apps to work on graphics cards unsupported/blacklisted by Chrome (PR #410)
* Fix #28 - Executable name being always `Electron` under Windows (PR #389)
* Fix #353 - `--crash-reporter` option crashing packaged app at startup
* Fix #402 - Force fullscreen even after first startup, as `electron-window-state`
does not appear to remember fullscreen in all cases (PR #403).
7.4.0 / 2017-05-21
==================
* Add jq to docs as release dependency
* Run Nativefier with Docker (#311)
* Add hound config (#369)
* Add codeclimate config
* Promisfy and parallelise config generation, add unit tests
* Add ARM build support (#360)
7.3.1 / 2017-04-30
====================
* Add script to update version and changelog
* Update changelog for 7.3.0
* Remove Windows tests
* Cleanup travis config
* Update eslint and use Airbnb style
* Change Mocha to not need a babel build to run (#349)
* Promisify inferTitle module
* Add autodeploy to NPM on tag
7.2.0 / 2017-04-20
==================
* Update dependencies, default to latest Electron 1.6.6 (#327, PR #341). **Feedback welcome in case of issues/regressions!**
* [Feature] Add `--single-instance` switch (PR #323)
* [Bug] Better honor `--zoom` option (#253, PR #347)
* [Bug] Allow mDNS addresses (ending with `local.`) during URL validation (#308, PR #346)
* [Docs] Readme and CLI cleanup
* [Misc] Remove duplicate dependencies (#337)
* [Misc] Rename 'Open in default browser' contextMenu to 'Open with default browser' (#338)
7.1.0 / 2017-04-07
==================
* Feature: Add "Copy link location" context menu (#230)
* Feature: Add `--internal-urls <regex>` option to customize what should open in external browser (#230)
* Feature: Add `--zoom` option for setting default zoom (#218)
* Bug: Fix context menu actions broken on <a> elements containing nested markup (#263)
* Bug: Fix counter notifications (#256)
* Bug: Remove non-ascii characters or use default for app name (#217)
* Doc: various fixes, including clarifying optional OSX dependencies for generating icons
* CI: Fix Travis tests which require wine
* Dev: Add editorconfig to trim trailing whitespace
7.0.1 / 2016-06-16
==================
* Fix/performance issues with FOUC (#214)
* Fix bug where convert icons script fails silently if dependency is not found
* Use original eslint module for linting instead of gulp
7.0.0 / 2016-05-27
==================
* Only support node.js >=4
* Implement downloading of files #185
* Implement min/max window width and height #82
* Implement disabling of developer tools #194
* Update default electron version to stable v1.1.3 #206
* Update electron-packager to v7.0.1 #193
* Update validator to v5.2.0
* Update shelljs to v0.7.0
* Update cheerio to v0.20.0
* Update axios to v0.11.1
* Update eslint to v2.0.0
* Increase timeout for test
* Fix bug where gitcloud matching of icons with multiple words is not supported
* Fix bug where inferred title is too long #195
* Fix flash of unstyled content #159
6.14.0 / 2016-05-08
===================
* Properly log errors with injected files
* Fix slowdown bug #191
* Revert fix for FOUC with injected CSS files #202
* Allow fast quit of app after window close on OSX #178
* Allow hiding of window frame #188
* Allow disabling the context menu #187
* Rebind 'Copy Current URL' to 'CmdOrCtrl+L' to mimic 'Open Location' in browsers #181
* Add walkthrough gif in readme
* No longer enable flash by default
* Bump default electron version to 0.37.2
6.13.0 / 2016-03-25
===================
* Source files will not be included in the packaged app
* Fix bug where state of mainWindow is not managed properly
* Implement setting of verbose log level
* Implement infer of user agent from electron version
* Implement initial maximization of main window from cli
* Fix FOUC with inject CSS files
* No need to run CI test for gulp release
6.12.1 / 2016-03-14
===================
* Fix bugs retrieving icons from nativefier-icons
* Add resize flag to convertToIco convert so that large `.png` will not throw errors when converting to `.ico`
6.12.0 / 2016-03-14
===================
* Try to retrieve icons from `nativefier-icons` first before inferring
* Add progress bar
* Use `windows` and `osx` to specify platform
* Override output directory by default
* Add checks for icon format
* Implement conversion to `.ico` for windows target
* Support only node `0.12` onwards with `babel-polyfill`
* Organise documentation
6.11.0 / 2016-03-11
===================
* Use local page-icon dependency instead of bestIcon server to infer icons for a target url
* Add conversion of images from `.ico` to `.png`
* Implement conversion of images on Linux in addition to OSX
* Fix bug in setting icon on for a Windows app while on Windows OS
* Trim whitespace from inferred page title
* Remove non-ascii characters from app name to prevent weird Wine error
* Remove dependency on `sips`
* Fix bug where shell scripts fail silently
* Modularize `gulpfile`
6.10.1 / 2016-02-26
===================
* Fix #117 ENOENT when infering flash
6.10.0 / 2016-02-25
===================
* Fix bug in mocha where next task is executed before mocha callback
* Implement command line flag to start app in full screen, resolves #109
* Implement injection of css and js
6.9.1 / 2016-02-25
==================
* Do not npm ignore binaries
6.9.0 / 2016-02-25
==================
* Preserve app data upon regeneration of app
* Add menu option to clear the app data
* Change flag usage
* `--ignore-certificate` to ignore invalid certificate errors,
* `--insecure` to disable web security to allow mixed content
* Add flag to allow mixed content over https
* Add preliminary flash support
* NPM ignore everything except compiled files
* Fix #146 Specifying `--electron-version` does not work
* Update example api usage for `require('nativefier').default`
* Add issue template
* #114 Allow [x] and {x} forms of notification count
* #112 Counter: Allow for [x] and {x} forms of notification count
* #90 Add keyboard shortcuts for back, forward
* Add note about not putting spaces in user defined app name
* Merge pull request #107 from zweicoder/fix/respect-user-choice
* Do not print done statement if app already exists and `--overwrite` is not passed
* Respect user choice for naming
* Allow npm publish to log to stdout
6.8.0 / 2016-01-30
==================
* Use ES6 for placeholder app
* Massive refactor of cli code
* Rename `--app-name` to `--name`
* Fix #103 App name should not be capitalized
* Remove electron prebuilt as a dev dependency to speed up ci builds
* Fix test for non darwin platforms
* Implement check for wine before attempting to pass icon to electron packager
* Update gulpfile - Build tests in `gulp build` - Watch test files - Clean test files as well
* Implement automatic retrieval of png which resolves #16
6.7.0 / 2016-01-28
==================
* Allow using png to for icon on OSX
* Use manual compiling of mocha so that sourcemaps can be used
* Convert app name to capitalized camel case if building for linux to prevent dock problems
* Fix the icon parameter bug for linux and windows, fix #92, fix #53
* Make Browserwindow always reference `app/icon.png` for the icon
6.6.2 / 2016-01-26
==================
* Fix #87, Fix #89 - Sanitize app name before packaging
* Add command line flag to make the packaged app ignore certificate errors, fixes #69
* Fix #32 Ability to copy and paste a URL
* Implement right click context menu for regular href links
* Allow es6 for app static files
6.6.1 / 2016-01-25
==================
* Remove unused files
* Fix #76 where all placeholder app modules are treated as externals
* Add contributing
* Update gulp release to also run lints
6.6.0 / 2016-01-25
==================
* Add CI Integration with travis
* Add tests and lints
* Fixes bug where electron packager returns appPath as an array instead of a string
* Add sourcemaps support
* Exposes buildApp as a programmatic api for npm
* Remove shorthand command for height and width to fix conflicts with `-h`. Closes #30, closes #64 and closes #67
* Automatically hide the menu bar by default on Windows. Users can press `alt` to show it
* Implement proper build system with ES6 support to facilitate development
* App window now remembers its previous position
* Fix #59 Fullscreen goes to a black screen when clicking close
* Set window title immediately when the window is created, fixes #54
* Implement navigating backward and forward from the application menu
* Implement proper notification listeners to change the badge
* Refactor main.js into separate files, and put static files such as preload and login.html into `app/src/static`
* Implement changing of zoom which fixes #17
6.5.6 / 2016-01-22
==================
* Workaround for windows `mkdir -p`, fixes #57
6.5.5 / 2016-01-22
==================
* Implement script to set up dev environment
* Fix bug in invalid parameter for link in default browser
* App is now precompiled with browserify as a workaround for an extremely annoying npm issue
* Reorganised folder of app
6.5.4 / 2016-01-22
==================
* Fix #46 Url is not defined
* Override user agent by default, disable with `--honest` flag
* Implement counter which closes #33, thanks to @jfouchard
* Improve automatic retrieval of app name by faking a user agent to make the request
6.5.0 / 2016-01-22
==================
* Implement support for http authentication, fixes #19
* Implement authentication that requires a new window to be opened (e.g. OAuth)
* Under the hood changes:
* Target web page no longer loads in a `<webview>`, the `BrowserWindow` loads the target url directly
6.4.0 / 2016-01-21
==================
* Make debug script automatically open the packaged app on OSX
* Remove "About Electron" from app menu, add nativefier version to help, which fixes #18
* Implement `--pretend` flag to easily simulate user agent strings, fixes #11
* Merge branch 'master' of github.com:jiahaog/nativefier
* Fix bug in error when response is undefined
* Add helper scripts to debug easily
* Hide app instead of exiting on OSX to fix #14
* Update deprecated electron loadUrl usage Remove crash reporter Remove commented code
* Merge pull request #20 from mattchue/master
* Merge pull request #25 from PoziWorld/patch-1
* Merge pull request #24 from himynameisdave/master
* Make app resource folder contain a short id string, fix #21
* Minor copy fixes
* Fixes the issue with "/"'s in the page title
* Update documentation, no longer need to add the full url with the protocol
* Fix wrong bool
* Allow intranet URLs
* Update readme
* Hide the webview until it finishes loading

52
docs/development.md Normal file
View File

@ -0,0 +1,52 @@
# Development
## Environment Setup
First clone the project
```bash
git clone https://github.com/jiahaog/nativefier.git
cd nativefier
```
Install dependencies
```bash
# OSX and Linux
npm run dev-up
# Windows
npm install
cd app
npm install
```
Don't forget to compile source files:
```bash
npm run build
```
You can set up symlinks so that you can run `nativefier` for your local changes
```bash
npm link
```
After doing so, you can then run Nativefier with your test parameters
```bash
nativefier <...>
```
Or you can automatically watch the files for changes with:
```bash
npm run watch
```
## Tests
```bash
npm test
```

36
docs/release.md Normal file
View File

@ -0,0 +1,36 @@
# Release
Releases are automatically deployed to NPM on Travis, when they are tagged. However, we have to make sure that the version in the `package.json`, and the changelog is updated.
## Dependencies
- [Git Extras](https://github.com/tj/git-extras/blob/master/Installation.md)
- [jq](https://stedolan.github.io/jq/download/)
## How to Release `$VERSION`
While on `master`, with no uncommitted changes,
```bash
npm run changelog -- $VERSION
```
This command does 3 things:
1. Update the version in the `package.json`
2. Update the changelog
3. Creates a new commit with the changes
Now we may want to cleanup the changelog:
```bash
vim docs/changelog.md
git commit --amend
```
Once we are satisfied,
```bash
git push origin master
```
On [GitHub Releases](https://github.com/jiahaog/nativefier/releases), draft and publish a new release with title `Nativefier vX.X.X`.

18
gulp/build.js Normal file
View File

@ -0,0 +1,18 @@
import gulp from 'gulp';
import del from 'del';
import runSequence from 'run-sequence';
import PATHS from './helpers/src-paths';
gulp.task('build', (callback) => {
runSequence('clean', ['build-cli', 'build-app', 'build-tests'], callback);
});
gulp.task('clean', (callback) => {
del(PATHS.CLI_DEST).then(() => {
del(PATHS.APP_DEST).then(() => {
del(PATHS.TEST_DEST).then(() => {
callback();
});
});
});
});

9
gulp/build/build-app.js Normal file
View File

@ -0,0 +1,9 @@
import gulp from 'gulp';
import webpack from 'webpack-stream';
import PATHS from './../helpers/src-paths';
const webpackConfig = require('./../../webpack.config.js');
gulp.task('build-app', ['build-static'], () => gulp.src(PATHS.APP_MAIN_JS)
.pipe(webpack(webpackConfig))
.pipe(gulp.dest(PATHS.APP_DEST)));

7
gulp/build/build-cli.js Normal file
View File

@ -0,0 +1,7 @@
import gulp from 'gulp';
import PATHS from './../helpers/src-paths';
import helpers from './../helpers/gulp-helpers';
const { buildES6 } = helpers;
gulp.task('build-cli', done => buildES6(PATHS.CLI_SRC_JS, PATHS.CLI_DEST, done));

View File

@ -0,0 +1,12 @@
import gulp from 'gulp';
import PATHS from './../helpers/src-paths';
import helpers from './../helpers/gulp-helpers';
const { buildES6 } = helpers;
gulp.task('build-static-not-js', () => gulp.src([PATHS.APP_STATIC_ALL, '!**/*.js'])
.pipe(gulp.dest(PATHS.APP_STATIC_DEST)));
gulp.task('build-static-js', done => buildES6(PATHS.APP_STATIC_JS, PATHS.APP_STATIC_DEST, done));
gulp.task('build-static', ['build-static-js', 'build-static-not-js']);

View File

@ -0,0 +1,28 @@
import gulp from 'gulp';
import shellJs from 'shelljs';
import sourcemaps from 'gulp-sourcemaps';
import babel from 'gulp-babel';
function shellExec(cmd, silent, callback) {
shellJs.exec(cmd, { silent }, (code, stdout, stderr) => {
if (code) {
callback(JSON.stringify({ code, stdout, stderr }));
return;
}
callback();
});
}
function buildES6(src, dest, callback) {
return gulp.src(src)
.pipe(sourcemaps.init())
.pipe(babel())
.on('error', callback)
.pipe(sourcemaps.write('.'))
.pipe(gulp.dest(dest));
}
export default {
shellExec,
buildES6,
};

22
gulp/helpers/src-paths.js Normal file
View File

@ -0,0 +1,22 @@
import path from 'path';
const paths = {
APP_SRC: 'app/src',
APP_DEST: 'app/lib',
CLI_SRC: 'src',
CLI_DEST: 'lib',
TEST_SRC: 'test',
TEST_DEST: 'built-tests',
};
paths.APP_MAIN_JS = path.join(paths.APP_SRC, '/main.js');
paths.APP_ALL = `${paths.APP_SRC}/**/*`;
paths.APP_STATIC_ALL = `${path.join(paths.APP_SRC, 'static')}/**/*`;
paths.APP_STATIC_JS = `${path.join(paths.APP_SRC, 'static')}/**/*.js`;
paths.APP_STATIC_DEST = path.join(paths.APP_DEST, 'static');
paths.CLI_SRC_JS = `${paths.CLI_SRC}/**/*.js`;
paths.CLI_DEST_JS = `${paths.CLI_DEST}/**/*.js`;
paths.TEST_SRC_JS = `${paths.TEST_SRC}/**/*.js`;
paths.TEST_DEST_JS = `${paths.TEST_DEST}/**/*.js`;
export default paths;

11
gulp/release.js Normal file
View File

@ -0,0 +1,11 @@
import gulp from 'gulp';
import runSequence from 'run-sequence';
import helpers from './helpers/gulp-helpers';
const { shellExec } = helpers;
gulp.task('publish', (done) => {
shellExec('npm publish', false, done);
});
gulp.task('release', callback => runSequence('build', 'publish', callback));

11
gulp/test.js Normal file
View File

@ -0,0 +1,11 @@
import gulp from 'gulp';
import runSequence from 'run-sequence';
import helpers from './helpers/gulp-helpers';
const { shellExec } = helpers;
gulp.task('prune', (done) => {
shellExec('npm prune', true, done);
});
gulp.task('test', callback => runSequence('prune', 'mocha', callback));

View File

@ -0,0 +1,7 @@
import gulp from 'gulp';
import PATHS from './../helpers/src-paths';
import helpers from './../helpers/gulp-helpers';
const { buildES6 } = helpers;
gulp.task('build-tests', done => buildES6(PATHS.TEST_SRC_JS, PATHS.TEST_DEST, done));

27
gulp/tests/mocha.js Normal file
View File

@ -0,0 +1,27 @@
import gulp from 'gulp';
import istanbul from 'gulp-istanbul';
import { Instrumenter } from 'isparta';
import mocha from 'gulp-mocha';
import PATHS from './../helpers/src-paths';
gulp.task('mocha', (done) => {
gulp.src([PATHS.CLI_SRC_JS, '!src/cli.js'])
.pipe(istanbul({
instrumenter: Instrumenter,
includeUntested: true,
}))
.pipe(istanbul.hookRequire()) // Force `require` to return covered files
.on('finish', () => gulp.src(PATHS.TEST_SRC, { read: false })
.pipe(mocha({
compilers: 'js:babel-core/register',
recursive: true,
}))
.pipe(istanbul.writeReports({
dir: './coverage',
reporters: ['lcov'],
reportOpts: { dir: './coverage' },
}))
.on('end', done));
});
gulp.task('tdd', ['mocha'], () => gulp.watch(['src/**/*.js', 'test/**/*.js'], ['mocha']));

16
gulp/watch.js Normal file
View File

@ -0,0 +1,16 @@
import gulp from 'gulp';
import PATHS from './helpers/src-paths';
gulp.task('watch', ['build'], () => {
const handleError = function (error) {
console.error(error);
};
gulp.watch(PATHS.APP_ALL, ['build-app'])
.on('error', handleError);
gulp.watch(PATHS.CLI_SRC_JS, ['build-cli'])
.on('error', handleError);
gulp.watch(PATHS.TEST_SRC_JS, ['build-tests'])
.on('error', handleError);
});

9
gulpfile.babel.js Normal file
View File

@ -0,0 +1,9 @@
import gulp from 'gulp';
import requireDir from 'require-dir';
requireDir('./gulp', {
recurse: true,
duplicates: true,
});
gulp.task('default', ['build']);

View File

@ -1,56 +0,0 @@
#!/usr/bin/env bash
### USAGE
# ./convertToIcns <input png> <outp icns>
# Example
# ./convertToIcns ~/sample.png ~/Desktop/converted.icns
# exit the shell script on error immediately
set -e
# Exec Paths
HAVE_IMAGEMAGICK=
HAVE_ICONUTIL=
HAVE_SIPS=
HAVE_GRAPHICSMAGICK=
type convert &>/dev/null && HAVE_IMAGEMAGICK=true
type iconutil &>/dev/null && HAVE_ICONUTIL=true
type sips &>/dev/null && HAVE_SIPS=true
type gm &>/dev/null && gm version | grep GraphicsMagick &>/dev/null && HAVE_GRAPHICSMAGICK=true
[[ -z "$HAVE_ICONUTIL" ]] && { echo >&2 "Cannot find required iconutil executable"; exit 1; }
[[ -z "$HAVE_IMAGEMAGICK" && -z "$HAVE_SIPS" && -z "$HAVE_GRAPHICSMAGICK" ]] && { echo >&2 "Cannot find required image converter, please install sips, imagemagick or graphicsmagick"; exit 1; }
# Parameters
SOURCE="$1"
DEST="$2"
# Check source and destination arguments
if [ -z "${SOURCE}" ]; then
echo "No source image specified"
exit 1
fi
if [ -z "${DEST}" ]; then
echo "No destination specified"
exit 1
fi
TEMP_DIR="$(mktemp -d -t nativefier-icns-XXXXXX)"
ICONSET="${TEMP_DIR}/converted.iconset"
function cleanUp() {
rm -rf "${TEMP_DIR}"
}
trap cleanUp EXIT
"${BASH_SOURCE%/*}/convertToIconset" "${SOURCE}" "${ICONSET}"
# Create an icns file lefrom the iconset
iconutil -c icns "${ICONSET}" -o "${DEST}"
trap - EXIT
cleanUp

View File

@ -1,68 +0,0 @@
#!/usr/bin/env bash
### USAGE
# ./convertToIconset <input png> <outp iconset>
# Example
# ./convertToIconset ~/sample.png ~/Desktop/converted.iconset
# exit the shell script on error immediately
set -e
make_iconset_imagemagick() {
local file iconset
file="${1}"
iconset="${2}"
mkdir "$iconset"
for size in {16,32,64,128,256,512}; do
$CONVERT "${file}" -define png:big-depth=16 -define png:color-type=6 -sample "${size}x${size}" "${iconset}/icon_${size}x${size}.png"
$CONVERT "${file}" -define png:big-depth=16 -define png:color-type=6 -sample "$((size * 2))x$((size * 2))" "${iconset}/icon_${size}x${size}@2x.png"
done
}
make_iconset_sips() {
local file iconset
file="${1}"
iconset="${2}"
mkdir "$iconset"
for size in {16,32,64,128,256,512}; do
sips --setProperty format png --resampleHeightWidth "${size}" "${size}" "${file}" --out "${iconset}/icon_${size}x${size}.png" &> /dev/null
sips --setProperty format png --resampleHeightWidth "$((size * 2))" "$((size * 2))" "${file}" --out "${iconset}/icon_${size}x${size}@2x.png" &> /dev/null
done
}
# Parameters
SOURCE="$1"
DEST="$2"
# Check source and destination arguments
if [ -z "${SOURCE}" ]; then
echo >&2 "No source image specified"; exit 1
fi
if [ -z "${DEST}" ]; then
echo >&2 "No destination specified"; exit 1
fi
HAVE_IMAGEMAGICK=
HAVE_SIPS=
HAVE_GRAPHICSMAGICK=
CONVERT=
type gm &>/dev/null && gm version | grep GraphicsMagick &>/dev/null && HAVE_GRAPHICSMAGICK=true && CONVERT="gm convert"
type convert &>/dev/null && HAVE_IMAGEMAGICK=true && CONVERT="convert"
type sips &>/dev/null && HAVE_SIPS=true
if [[ -n "$HAVE_IMAGEMAGICK" || -n "$HAVE_GRAPHICSMAGICK" ]]; then
PNG_PATH="$(mktemp -d -t nativefier-iconset-XXXXXX)/icon.png"
"${BASH_SOURCE%/*}/convertToPng" "${SOURCE}" "${PNG_PATH}"
make_iconset_imagemagick "${PNG_PATH}" "${DEST}"
elif [[ -n "$HAVE_SIPS" ]]; then
make_iconset_sips "${SOURCE}" "${DEST}"
else
echo >&2 "Cannot find convert or sips executables"; exit 1;
fi

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
# USAGE
# ./convertToPng <input png or ico> <outfilename>.png
# Example
# ./convertToPng ~/sample.ico ~/Desktop/converted.png
set -e
HAVE_IMAGEMAGICK=
HAVE_GRAPHICSMAGICK=
type convert &>/dev/null && type identify &>/dev/null && HAVE_IMAGEMAGICK=true
type gm &>/dev/null && gm version | grep GraphicsMagick &>/dev/null && HAVE_GRAPHICSMAGICK=true
if [[ -z "$HAVE_IMAGEMAGICK" && -z "$HAVE_GRAPHICSMAGICK" ]]; then
type convert >/dev/null 2>&1 || echo >&2 "Cannot find required ImageMagick 'convert' executable"
type identify >/dev/null 2>&1 || echo >&2 "Cannot find required ImageMagick 'identify' executable"
type gm &>/dev/null && gm version | grep GraphicsMagick &>/dev/null && echo >&2 "Cannot find GraphicsMagick"
echo >&2 "ImageMagic or GraphicsMagic is required, please ensure they are in your PATH"
exit 1
fi
CONVERT="convert"
IDENTIFY="identify"
if [[ -z "$HAVE_IMAGEMAGICK" ]]; then
# we must have GraphicsMagick then
CONVERT="gm convert"
IDENTIFY="gm identify"
fi
# Parameters
SOURCE="$1"
DEST="$2"
# Check source and destination arguments
if [ -z "${SOURCE}" ]; then
echo "No source image specified"
exit 1
fi
if [ -z "${DEST}" ]; then
echo "No destination specified"
exit 1
fi
# File Infrastructure
NAME=$(basename "${SOURCE}")
BASE="${NAME%.*}"
TEMP_DIR="convert_temp"
function cleanUp() {
rm -rf "${TEMP_DIR}"
}
trap cleanUp EXIT
mkdir -p "${TEMP_DIR}"
# check if .ico is a sequence
# pipe into cat so no exit code is given for grep if no matches are found
IS_ICO_SET="$($IDENTIFY "${SOURCE}" | grep -e "\w\.ico\[0" | cat )"
$CONVERT "${SOURCE}" "${TEMP_DIR}/${BASE}.png"
if [ "${IS_ICO_SET}" ]; then
# extract the largest(?) image from the set
cp "${TEMP_DIR}/${BASE}-0.png" "${DEST}"
else
cp "${TEMP_DIR}/${BASE}.png" "${DEST}"
fi
rm -rf "${TEMP_DIR}"
trap - EXIT
cleanUp

View File

@ -1,32 +0,0 @@
#!/usr/bin/env bash
# USAGE
# ./convertToTrayIcon <input png or icns> <outfilename>.png
# Example
# ./convertToTrayIcon ~/sample.icns ~/converted.png
set -e
SOURCE=$1
DEST=$2
if [ -z "${SOURCE}" ]; then
echo "No source image specified"
exit 1
fi
if [ -z "${DEST}" ]; then
echo "No destination specified"
exit 1
fi
NAME=$(basename "${SOURCE}")
EXT="${NAME##*.}"
if [ "${EXT}" == "png" ]; then
cp "${SOURCE}" "${DEST}"
exit 0
fi
sips --setProperty format png --resampleHeightWidth "256" "256" "${SOURCE}" --out "${DEST}"

8144
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,7 @@
{
"name": "nativefier",
"version": "52.0.0",
"version": "7.5.0",
"description": "Wrap web apps natively",
"license": "MIT",
"author": "Goh Jia Hao",
"engines_README": "Bumping the minimum required Node version? You must bump: 1. package.json -> engines.node, 2. package.json -> devDependencies.@types/node , 3. tsconfig.json -> {target, lib} , 4. .github/workflows/ci.yml -> node-version",
"engines_READMEforEnginesNode": "Here in engines.node, we require a version as old as possible, for Nativefier to be easily installable using the stock Node.js shipped by conservative Linux distros. It's a balancing act between this, and our own dependencies requiring more a recent Node; as much as possible, try to keep supporting Debian stable; https://packages.debian.org/search?suite=stable&keywords=nodejs",
"engines": {
"node": ">= 16.16.0",
"npm": ">= 8.11.0"
},
"keywords": [
"desktop",
"electron",
@ -17,129 +9,89 @@
"native",
"wrapper"
],
"main": "lib/main.js",
"typings": "lib/main.d.ts",
"main": "lib/index.js",
"scripts": {
"dev-up": "npm install && (cd ./app && npm install) && npm run build",
"test": "jest && gulp test",
"jest": "jest",
"tdd": "gulp tdd",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"ci": "gulp build test && npm run lint",
"clean": "gulp clean",
"build": "gulp build",
"watch": "while true ; do gulp watch ; done",
"package-placeholder": "npm run build && node lib/cli.js http://www.bennish.net/web-notifications.html ~/Desktop --overwrite --name notification-test --icon ./test-resources/iconSampleGrey.png --inject ./test-resources/test-injection.js --inject ./test-resources/test-injection.css && open ~/Desktop/notification-test-darwin-x64/notification-test.app",
"start-placeholder": "npm run build && electron app",
"changelog": "./scripts/changelog"
},
"bin": {
"nativefier": "lib/cli.js"
},
"homepage": "https://github.com/nativefier/nativefier",
"repository": {
"type": "git",
"url": "git+https://github.com/nativefier/nativefier.git"
"url": "git+https://github.com/jiahaog/nativefier.git"
},
"author": "Goh Jia Hao",
"license": "MIT",
"bugs": {
"url": "https://github.com/nativefier/nativefier/issues"
},
"scripts": {
"build-app": "cd app && webpack",
"build-app-static": "ncp app/src/static/ app/lib/static/ && ncp app/dist/preload.js app/lib/preload.js && ncp app/dist/preload.js.map app/lib/preload.js.map",
"build": "npm run clean && tsc --build shared src app && npm run build-app && npm run build-app-static",
"build:watch": "npm run clean && tsc --build shared src app --watch",
"changelog": "./.github/generate-changelog",
"clean": "rimraf coverage/ lib/ app/lib/ app/dist/ shared/lib",
"clean:full": "npm run clean && rimraf app/node_modules/ node_modules/",
"lint:fix": "cd src && eslint . --ext .ts --fix && cd ../shared && eslint src --ext .ts --fix && cd ../app && eslint src --ext .ts --fix",
"lint:format": "prettier --write 'src/**/*.ts' 'app/src/**/*.ts' 'shared/src/**/*.ts'",
"lint": "eslint shared app src --ext .ts",
"list-outdated-deps": "npm out -l; cd app && npm out -l; true",
"prepare": "cd app && npm ci && cd .. && npm run build",
"relock:cli": "rm -rf ./node_modules/ ./npm-shrinkwrap.json && npm install --ignore-scripts --package-lock && mv package-lock.json npm-shrinkwrap.json && npm out -l",
"relock:app": "rm -rf ./app/node_modules/ ./app/npm-shrinkwrap.json && cd app && npm install --ignore-scripts --package-lock && mv package-lock.json npm-shrinkwrap.json && npm out -l",
"relock": "npm run relock:cli; npm run relock:app",
"test:integration": "jest --testRegex=integration-test",
"test:manual": "npm run build && bash .github/manual-test",
"test:playwright": "jest --detectOpenHandles --testRegex=playwright-test",
"test:noplaywright": "jest --testPathIgnorePatterns=playwright",
"test:unit": "jest",
"test:watch": "echo 'Remember to run npm run build:watch for the test watcher to work!' && jest --watchAll --collectCoverage=false",
"test:watch:unit": "echo 'Remember to run npm run build:watch for the test watcher to work!' && jest --watchAll --collectCoverage=false --testPathIgnorePatterns=integration --testPathIgnorePatterns=playwright",
"test:withlog": "LOGLEVEL=trace npm run test",
"test": "jest",
"watch": "npx concurrently \"npm:*:watch\""
"url": "https://github.com/jiahaog/nativefier/issues"
},
"homepage": "https://github.com/jiahaog/nativefier#readme",
"dependencies": {
"@electron/asar": "^3.2.4",
"axios": "^1.4.0",
"electron-packager": "^17.1.1",
"fs-extra": "^11.1.1",
"gitcloud": "^0.2.4",
"hasbin": "^1.2.3",
"loglevel": "^1.8.1",
"async": "^2.3.0",
"axios": "^0.16.1",
"babel-polyfill": "^6.7.2",
"cheerio": "^0.22.0",
"commander": "^2.9.0",
"electron-packager": "^8.6.0",
"gitcloud": "^0.1.0",
"hasbin": "^1.2.0",
"lodash": "^4.0.0",
"loglevel": "^1.4.0",
"ncp": "^2.0.0",
"page-icon": "^0.4.0",
"sanitize-filename": "^1.6.3",
"source-map-support": "^0.5.21",
"tmp": "^0.2.1",
"yargs": "^17.7.2"
"page-icon": "^0.3.0",
"progress": "^2.0.0",
"sanitize-filename": "^1.5.3",
"shelljs": "^0.7.0",
"source-map-support": "^0.4.0",
"tmp": "0.0.31",
"validator": "^7.0.0"
},
"devDependencies": {
"@types/debug": "^4.1.8",
"@types/fs-extra": "^11.0.1",
"@types/hasbin": "^1.2.0",
"@types/jest": "^29.5.4",
"@types/ncp": "^2.0.5",
"@types/node": "^20.5.6",
"@types/page-icon": "^0.3.4",
"@types/tmp": "^0.2.3",
"@types/yargs": "^17.0.24",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"electron": "^25.7.0",
"eslint": "^8.46.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.6.2",
"playwright": "^1.36.2",
"prettier": "^3.0.1",
"rimraf": "^5.0.1",
"ts-loader": "^9.4.4",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
"babel-core": "^6.4.5",
"babel-jest": "^19.0.0",
"babel-loader": "^6.2.1",
"babel-preset-es2015": "^6.6.0",
"babel-register": "^6.6.0",
"chai": "^3.4.1",
"del": "^2.2.0",
"eslint": "^3.19.0",
"eslint-config-airbnb-base": "^11.1.3",
"eslint-plugin-import": "^2.2.0",
"gulp": "^3.9.0",
"gulp-babel": "^6.1.1",
"gulp-istanbul": "^1.1.1",
"gulp-mocha": "^4.3.0",
"gulp-sourcemaps": "^2.6.0",
"isparta": "^4.0.0",
"jest": "^20.0.3",
"regenerator-runtime": "^0.10.5",
"require-dir": "^0.3.2",
"run-sequence": "^1.1.5",
"webpack-stream": "^3.1.0"
},
"jest_COMMENTS": {
"testPathIgnorePatterns": "See https://jestjs.io/docs/configuration#testpathignorepatterns-arraystring . We set it to 1. ignore coverage for deps, and 2. be sure we test the compiled JS, which is in `lib`, not `src` or `dist`",
"watchPathIgnorePatterns": "See https://jestjs.io/docs/configuration#watchpathignorepatterns-arraystring . We set it for `jest --watch` (a.k.a. `npm run test:watch`) to trigger only after `tsc --watch` (a.k.a. `npm run build:watch`) completes its incremental compilation. Else, jest will pick up immediately on changes in `src` when TSC is barely running, hence testing not-recompiled-yet code and being super confusing, as 1. your changes won't be taken during this first run, and 2. the *next* run (e.g. after a second 'Save' in your editor) will actually have the new code :D"
"engines": {
"node": ">= 4.0"
},
"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"./app/dist/**/*.js",
"./lib/**/*.js",
"./shared/lib/**/*.js"
],
"coveragePathIgnorePatterns": [
"[.-]test.js$"
],
"moduleNameMapper": {
"^electron$": "<rootDir>/app/dist/mocks/electron.js"
},
"setupFiles": [
"./lib/jestSetupFiles"
],
"testEnvironment": "node",
"testPathIgnorePatterns": [
"<rootDir>/app/node_modules.*",
"<rootDir>/app/src.*",
"<rootDir>/app/lib.*",
"<rootDir>/src.*",
".+\\.d\\.ts",
".+\\.js\\.map"
],
"testRegex": "test\\.js",
"testTimeout": 15000,
"watchPathIgnorePatterns": [
"<rootDir>/app/lib.*",
"<rootDir>/app/src.*",
"<rootDir>/app/tsconfig.json",
"<rootDir>/shared/tsconfig.json",
"<rootDir>/src.*",
"<rootDir>/tsconfig-base.json"
"babel": {
"presets": [
"es2015"
]
},
"prettier": {
"arrowParens": "always",
"singleQuote": true,
"trailingComma": "all"
"jest": {
"testMatch": [
"**/src/**/?(*.)(spec|test).js?(x)"
]
}
}

BIN
screenshots/dock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

51
scripts/changelog Executable file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Updates the changelog and version in the package.json
# Will also create a commit with these changes locally
# Run `git commit --amend` after that if you wish to make changes
#
# Usage:
# ./changelog "7.0.0"
#
# Prerequisites:
# - On master branch
# - No uncommitted changes
#
# Dependencies:
# - git-extras https://github.com/tj/git-extras/blob/master/Installation.md
set -eo pipefail
# Checks if we are on the master branch
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != 'master' ]]; then
echo 'ERROR: not on master branch' >&2
exit 1;
fi
# Checks if there are uncommitted changes
git diff-index --quiet HEAD -- || (echo 'ERROR: there are uncommitted changes' >&2 && exit 1)
VERSION="$1"
# Validates the $VERSION
SEMVER_REGEX='^([0-9]+\.){2}([0-9]+)$'
if ! [[ $VERSION =~ $SEMVER_REGEX ]]; then
echo "ERROR: Version '$VERSION' is invalid " >&2
exit 1
fi
# Change the version in the package.json
cat package.json | jq ".version = \"$VERSION\"" > package.json.tmp
# Workaround for inplace jq editing
mv package.json.tmp package.json
# Unset the editor so that git changelog does not open a editor
EDITOR=:
git changelog docs/changelog.md --tag "$VERSION"
# Commit these changes
git add docs/changelog.md
git add package.json
git commit -m "Update changelog for \`v$VERSION\`"

View File

@ -1,14 +0,0 @@
const base = require('../base-eslintrc');
// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
module.exports = {
parser: base.parser,
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: base.plugins,
extends: base.extends,
rules: base.rules,
ignorePatterns: ['lib/**'],
};

View File

@ -1,238 +0,0 @@
import { CreateOptions } from '@electron/asar';
import { randomUUID } from 'crypto';
import * as electronPackager from 'electron-packager';
export type TitleBarValue =
| 'default'
| 'hidden'
| 'hiddenInset'
| 'customButtonsOnHover';
export type TrayValue = 'true' | 'false' | 'start-in-tray';
export interface ElectronPackagerOptions extends electronPackager.Options {
arch: string;
portable: boolean;
platform?: string;
targetUrl: string;
upgrade: boolean;
upgradeFrom?: string;
}
export interface AppOptions {
packager: ElectronPackagerOptions;
nativefier: {
accessibilityPrompt: boolean;
alwaysOnTop: boolean;
backgroundColor?: string;
basicAuthPassword?: string;
basicAuthUsername?: string;
blockExternalUrls: boolean;
bookmarksMenu?: string;
bounce: boolean;
browserwindowOptions?: BrowserWindowOptions;
clearCache: boolean;
counter: boolean;
crashReporter?: string;
disableContextMenu: boolean;
disableDevTools: boolean;
disableGpu: boolean;
disableOldBuildWarning: boolean;
diskCacheSize?: number;
electronVersionUsed?: string;
enableEs3Apis: boolean;
fastQuit: boolean;
fileDownloadOptions?: Record<string, unknown>;
flashPluginDir?: string;
fullScreen: boolean;
globalShortcuts?: GlobalShortcut[];
hideWindowFrame: boolean;
ignoreCertificate: boolean;
ignoreGpuBlacklist: boolean;
inject?: string[];
insecure: boolean;
internalUrls?: string;
lang?: string;
maximize: boolean;
nativefierVersion: string;
processEnvs?: string;
proxyRules?: string;
quiet?: boolean;
showMenuBar: boolean;
singleInstance: boolean;
strictInternalUrls: boolean;
titleBarStyle?: TitleBarValue;
tray: TrayValue;
userAgent?: string;
userAgentHonest: boolean;
verbose: boolean;
versionString?: string;
width?: number;
widevine: boolean;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
x?: number;
y?: number;
zoom: number;
};
}
export type BrowserWindowOptions = Record<string, unknown> & {
webPreferences?: Record<string, unknown>;
};
export type GlobalShortcut = {
key: string;
inputEvents: {
type:
| 'mouseDown'
| 'mouseUp'
| 'mouseEnter'
| 'mouseLeave'
| 'contextMenu'
| 'mouseWheel'
| 'mouseMove'
| 'keyDown'
| 'keyUp'
| 'char';
keyCode: string;
}[];
};
export type NativefierOptions = Partial<
AppOptions['packager'] & AppOptions['nativefier']
>;
export type OutputOptions = NativefierOptions & {
blockExternalUrls: boolean;
browserwindowOptions?: BrowserWindowOptions;
buildDate: number;
companyName?: string;
disableDevTools: boolean;
fileDownloadOptions?: Record<string, unknown>;
internalUrls: string | RegExp | undefined;
isUpgrade: boolean;
name: string;
nativefierVersion: string;
oldBuildWarningText: string;
strictInternalUrls: boolean;
tabbingIdentifier?: string;
targetUrl: string;
userAgent?: string;
zoom?: number;
};
export type PackageJSON = {
name: string;
};
export type RawOptions = {
accessibilityPrompt?: boolean;
alwaysOnTop?: boolean;
appCopyright?: string;
appVersion?: string;
arch?: string;
asar?: boolean | CreateOptions;
backgroundColor?: string;
basicAuthPassword?: string;
basicAuthUsername?: string;
blockExternalUrls?: boolean;
bookmarksMenu?: string;
bounce?: boolean;
browserwindowOptions?: BrowserWindowOptions;
buildVersion?: string;
clearCache?: boolean;
conceal?: boolean;
counter?: boolean;
crashReporter?: string;
darwinDarkModeSupport?: boolean;
disableContextMenu?: boolean;
disableDevTools?: boolean;
disableGpu?: boolean;
disableOldBuildWarning?: boolean;
disableOldBuildWarningYesiknowitisinsecure?: boolean;
diskCacheSize?: number;
electronVersion?: string;
electronVersionUsed?: string;
enableEs3Apis?: boolean;
fastQuit?: boolean;
fileDownloadOptions?: Record<string, unknown>;
flashPath?: string;
flashPluginDir?: string;
fullScreen?: boolean;
globalShortcuts?: string | GlobalShortcut[];
height?: number;
hideWindowFrame?: boolean;
icon?: string;
ignoreCertificate?: boolean;
ignoreGpuBlacklist?: boolean;
inject?: string[];
insecure?: boolean;
internalUrls?: string;
lang?: string;
maxHeight?: number;
maximize?: boolean;
maxWidth?: number;
minHeight?: number;
minWidth?: number;
name?: string;
nativefierVersion?: string;
out?: string;
overwrite?: boolean;
platform?: string;
portable?: boolean;
processEnvs?: string;
proxyRules?: string;
quiet?: boolean;
showMenuBar?: boolean;
singleInstance?: boolean;
strictInternalUrls?: boolean;
targetUrl?: string;
titleBarStyle?: TitleBarValue;
tray?: TrayValue;
upgrade?: string | boolean;
upgradeFrom?: string;
userAgent?: string;
userAgentHonest?: boolean;
verbose?: boolean;
versionString?: string;
widevine?: boolean;
width?: number;
win32metadata?: electronPackager.Win32MetadataOptions;
x?: number;
y?: number;
zoom?: number;
};
export type WindowOptions = {
autoHideMenuBar: boolean;
blockExternalUrls: boolean;
browserwindowOptions?: BrowserWindowOptions;
insecure: boolean;
internalUrls?: string | RegExp;
strictInternalUrls?: boolean;
name: string;
proxyRules?: string;
show?: boolean;
tabbingIdentifier?: string;
targetUrl: string;
userAgent?: string;
zoom: number;
};
export function outputOptionsToWindowOptions(
options: OutputOptions,
generateTabbingIdentifierIfMissing: boolean,
): WindowOptions {
return {
...options,
autoHideMenuBar: !options.showMenuBar,
insecure: options.insecure ?? false,
tabbingIdentifier: generateTabbingIdentifierIfMissing
? options.tabbingIdentifier ?? randomUUID()
: options.tabbingIdentifier,
zoom: options.zoom ?? 1.0,
};
}

View File

@ -1,18 +0,0 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"composite": true,
"outDir": "./lib",
// Here we want to set target and lib to the *worst* of app/tsconfig.json and src/tsconfig.json
// (plus "dom"), because shared code will run both in CLI Node and app Node.
// See comments in app/tsconfig.json and src/tsconfig.json
"target": "es2018",
"lib": [
"es2018",
"dom"
]
},
"include": [
"./src/**/*"
],
}

View File

@ -1,13 +0,0 @@
const base = require('../base-eslintrc');
// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md
module.exports = {
parser: base.parser,
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: base.plugins,
extends: base.extends,
rules: base.rules,
};

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