├── bin
├── run.cmd
└── run
├── tslint.json
├── installation
├── linux
│ └── app.desktop
└── macos
│ └── Info.plist
├── .gitignore
├── .editorconfig
├── tsconfig.json
├── app
├── package.json
├── index.js
└── notification_polyride.js
├── .github
└── workflows
│ └── npmpublish.yml
├── LICENSE
├── package.json
├── README.md
└── src
└── index.ts
/bin/run.cmd:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | node "%~dp0\run" %*
4 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@oclif/tslint"
3 | }
4 |
--------------------------------------------------------------------------------
/installation/linux/app.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=@@NAME@@
4 | Exec=@@PATH@@
5 | Icon=@@FILENAME@@
6 | Terminal=false
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *-debug.log
2 | *-error.log
3 | /.nyc_output
4 | /dist
5 | /lib
6 | /tmp
7 | /yarn.lock
8 | node_modules
9 | .profile
10 | tsconfig.tsbuildinfo
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.md]
11 | trim_trailing_whitespace = false
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "importHelpers": true,
5 | "module": "commonjs",
6 | "outDir": "lib",
7 | "rootDir": "src",
8 | "strict": true,
9 | "target": "es2017",
10 | "composite": true
11 | },
12 | "include": [
13 | "src/**/*"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/bin/run:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require('fs')
4 | const path = require('path')
5 | const project = path.join(__dirname, '../tsconfig.json')
6 | const dev = fs.existsSync(project)
7 |
8 | if (dev) {
9 | require('ts-node').register({project})
10 | }
11 |
12 | require(`../${dev ? 'src' : 'lib'}`).run()
13 | .catch(require('@oclif/errors/handle'))
14 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "quark-carlo-placeholder",
3 | "version": "0.0.0",
4 | "description": "Placeholder for quark-carlo based app",
5 | "main": "index.js",
6 | "bin": "index.js",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "author": "sidevesh",
11 | "license": "MIT",
12 | "dependencies": {
13 | "carlo-quark-fork": "^0.9.46",
14 | "node-notifier": "5.4.3"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/installation/macos/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleExecutable
6 | @@FILENAME@@
7 | CFBundleDisplayName
8 | @@NAME@@
9 | CFBundleIconFile
10 | @@FILENAME@@
11 | CFBundleIdentifier
12 | com.quark.@@FILENAME@@
13 | CFBundleName
14 | @@FILENAME@@
15 | CFBundlePackageType
16 | APPL
17 | CFBundleSignature
18 | ????
19 | CFBundleSupportedPlatforms
20 |
21 | MacOSX
22 |
23 | CFBundleVersion
24 | 1.0
25 |
26 |
27 |
--------------------------------------------------------------------------------
/.github/workflows/npmpublish.yml:
--------------------------------------------------------------------------------
1 | name: NPM Publish
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - master
7 | push:
8 | branches:
9 | - master
10 |
11 | jobs:
12 | npm-publish:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@v1
17 | - name: Setup node
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: 10
21 | registry-url: https://registry.npmjs.org/
22 | - name: Install dependencies
23 | run: npm install
24 | - name: Publish package
25 | run: |
26 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
27 | LATEST=`npm view . version`
28 | CURRENT=`cat package.json | jq -r .version`
29 | if [ "$LATEST" != "$CURRENT" ]
30 | then
31 | npm publish
32 | fi
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Swapnil Devesh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "quark-carlo",
3 | "description": "Turn web apps into lightweight native desktop applications that use chrome as webview, powered by the awesome carlo",
4 | "version": "1.0.39",
5 | "author": "sidevesh",
6 | "bin": {
7 | "quark-carlo": "./bin/run"
8 | },
9 | "bugs": "https://github.com/sidevesh/quark-carlo/issues",
10 | "dependencies": {
11 | "@fiahfy/icns-convert": "0.0.6",
12 | "@oclif/command": "^1.5.18",
13 | "@oclif/config": "^1.13.3",
14 | "@oclif/plugin-help": "^2.2.1",
15 | "create-nodew-exe": "^1.0.5",
16 | "dedent-js": "^1.0.1",
17 | "icojs": "^0.13.1",
18 | "node-fetch": "^2.6.0",
19 | "page-icon": "^0.3.0",
20 | "pkg": "4.3.8",
21 | "png-to-ico": "^2.0.4",
22 | "semver": "^6.3.0",
23 | "sharp": "^0.21.3",
24 | "minipass": "2.7.0",
25 | "shelljs": "^0.8.3",
26 | "tmp": "0.0.33",
27 | "tslib": "^1.10.0",
28 | "windows-shortcuts": "^0.1.6"
29 | },
30 | "devDependencies": {
31 | "@oclif/tslint": "^3.1.1",
32 | "@types/node": "^10.14.15",
33 | "@types/node-fetch": "^2.5.0",
34 | "@types/page-icon": "^0.3.2",
35 | "@types/semver": "^6.0.2",
36 | "@types/sharp": "^0.21.3",
37 | "@types/shelljs": "^0.8.5",
38 | "@types/tmp": "0.0.33",
39 | "ts-node": "^7.0.1",
40 | "tslint": "^5.18.0",
41 | "typescript": "^3.5.3"
42 | },
43 | "engines": {
44 | "node": ">=8.0.0"
45 | },
46 | "homepage": "https://github.com/sidevesh/quark-carlo",
47 | "keywords": [
48 | "oclif"
49 | ],
50 | "license": "MIT",
51 | "main": "lib/index.js",
52 | "oclif": {
53 | "bin": "quark-carlo"
54 | },
55 | "files": [
56 | "/app",
57 | "/bin",
58 | "/installation",
59 | "/lib",
60 | "LICENSE",
61 | "README.md"
62 | ],
63 | "repository": "sidevesh/quark-carlo",
64 | "scripts": {
65 | "posttest": "tslint -p test -t stylish",
66 | "prepack": "tsc -b",
67 | "test": "echo \"Error: no test specified\" && exit 1"
68 | },
69 | "types": "lib/index.d.ts"
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | quark-carlo
2 | ===========
3 |
4 | Turn web apps into lightweight native desktop applications that use chrome as webview, powered by the awesome [carlo](https://github.com/GoogleChromeLabs/carlo)
5 |
6 | [](https://oclif.io)
7 | [](https://npmjs.org/package/quark-carlo)
8 | [](https://npmjs.org/package/quark-carlo)
9 | [](https://github.com/SiDevesh/quark-carlo/blob/master/package.json)
10 |
11 | 
12 | 
13 |
14 | ## Usage
15 | ```sh-session
16 | $ npm install --global quark-carlo
17 | $ quark-carlo --name Whatsapp --url https://web.whatsapp.com --install
18 | $ quark-carlo (-v|--version|version)
19 | quark-carlo/1.0.15 linux-x64 node-v10.16.2
20 | $ quark-carlo --help [COMMAND]
21 | USAGE
22 | $ quark-carlo
23 |
24 | OPTIONS
25 | -D, --debug Create debug app to identify required additional internal hostnames, on encountering navigation to an external hostname the app will show an alert with the hostname value to pass in additionalInternalHostnames
26 | -a, --additionalInternalHostnames=additionalInternalHostnames Comma separated list of additional hostnames that are to be opened within the app, for example oauth login page hostnames (for Google: accounts.google.com)
27 | -d, --dimensions=dimensions [default: 1280x720] Dimensions of application window as [width]x[height], for example 1280x720
28 | -h, --help show CLI help
29 | -i, --install Install a shortcut so that the app shows up in the application menu
30 | -n, --name=name (required) name of application
31 | -p, --platform=platform [default: host] Platform to build the binary for, defaults to the running platform, possible options are linux, macos, win
32 | -u, --url=url (required) url to load in application
33 | -v, --version show CLI version
34 | ```
35 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | const os = require('os');
2 | const fs = require('fs');
3 | const carlo = require('carlo-quark-fork');
4 | const notifier = require('node-notifier');
5 | const path = require('path');
6 | const config = require('./config.json');
7 | const polyRideNotification = require('./notification_polyride');
8 |
9 | const generateProfilePath = (platform, dirName) => {
10 | if (platform === 'win') {
11 | if (!fs.existsSync(path.join(os.homedir(), 'AppData', 'Local', dirName))) {
12 | fs.mkdirSync(path.join(os.homedir(), 'AppData', 'Local', dirName));
13 | }
14 | return path.join(os.homedir(), 'AppData', 'Local', dirName, 'profile');
15 | } else if (platform === 'macos') {
16 | if (!fs.existsSync(path.join(os.homedir(), 'Library', 'Application Support', dirName))) {
17 | fs.mkdirSync(path.join(os.homedir(), 'Library', 'Application Support', dirName));
18 | }
19 | return path.join(os.homedir(), 'Library', 'Application Support', dirName, 'profile');
20 | } else if (platform === 'linux') {
21 | if (!fs.existsSync(path.join(os.homedir(), '.config', dirName))) {
22 | fs.mkdirSync(path.join(os.homedir(), '.config', dirName));
23 | }
24 | return path.join(os.homedir(), '.config', dirName, 'profile');
25 | } else {
26 | return path.join(path.dirname(process.argv[0]), 'profile');
27 | }
28 | };
29 |
30 | (async () => {
31 | const app = await carlo.launch({
32 | width: config.width,
33 | height: config.height,
34 | title: config.name,
35 | url: config.url,
36 | icon: config.platform === 'macos' ? path.join(path.dirname(path.dirname(process.argv[0])), 'Resources', config.iconPath) : path.join(path.dirname(process.argv[0]), config.iconPath),
37 | userDataDir: generateProfilePath(config.platform, config.dirName),
38 | bgcolor: '#eeeeee',
39 | });
40 | app.on('exit', () => process.exit());
41 | app.mainWindow().pageForTest().setBypassCSP(true);
42 | app.serveHandler((req) => {
43 | if (
44 | req.params_.isNavigationRequest &&
45 | req.params_.frameId === app.mainWindow().pageForTest().mainFrame()._id &&
46 | (
47 | new URL(req.url()).hostname !== new URL(config.url).hostname &&
48 | !config.additionalInternalHostnames.includes(new URL(req.url()).hostname)
49 | )
50 | ) {
51 | req.abort();
52 | if (config.debug) {
53 | app.evaluate(url => window.alert(url), `Attempted to open external url: ${req.url()}\nHostname to enter in cli: ${new URL(req.url()).hostname}`);
54 | }
55 | app.evaluate(url => window.open(url), req.url());
56 | } else {
57 | req.continue();
58 | }
59 | });
60 | app.serveOrigin(new URL(config.url).origin);
61 |
62 | let notifierInstance = notifier;
63 | if (config.platform === 'macos') {
64 | notifierInstance = new notifier.NotificationCenter({
65 | customPath: path.join(path.dirname(path.dirname(process.argv[0])), 'Resources', 'terminal-notifier.app', 'Contents', 'MacOS', 'terminal-notifier'),
66 | });
67 | }
68 |
69 | notifierInstance.on('click', () => {
70 | app.mainWindow().bringToFront();
71 | app.evaluate(`window.Notification.getLastNotification().dispatchEvent(new Event('click'))`);
72 | });
73 | notifierInstance.on('timeout', () => {
74 | app.evaluate(`window.Notification.getLastNotification().dispatchEvent(new Event('close'))`);
75 | });
76 |
77 | await app.exposeFunction('notify', (serializedOpts) => {
78 | const opts = JSON.parse(serializedOpts);
79 | notifierInstance.notify({
80 | title: opts.title,
81 | message: opts.message,
82 | sound: opts.sound,
83 | icon: os.type() === 'Linux' && process.env.DESKTOP_SESSION === 'pantheon' ? config.appName : path.join(path.dirname(process.argv[0]), config.iconPath),
84 | // Internally used in place of appID for Windows,
85 | // more apt name as it shows the app's name on notification as whatever is given here,
86 | // no uniqueness or pre register constraints of appID seem to apply,
87 | // and appName for Windows is display name, not tokenized name
88 | appName: config.appName,
89 | wait: true,
90 | });
91 | app.evaluate(`window.Notification.notifyNotificationInstances['${opts.uniqueId}'].dispatchEvent(new Event('show'))`);
92 | });
93 | await app.load(new URL(config.url).pathname);
94 | await app.evaluate(polyRideNotification);
95 | })();
96 |
--------------------------------------------------------------------------------
/app/notification_polyride.js:
--------------------------------------------------------------------------------
1 | /*
2 | Nativefier's implementation:
3 | https://github.com/jiahaog/nativefier/blob/master/app/src/static/preload.js
4 | function setNotificationCallback(createCallback, clickCallback) {
5 | const OldNotify = window.Notification;
6 | const newNotify = (title, opt) => {
7 | createCallback(title, opt);
8 | const instance = new OldNotify(title, opt);
9 | instance.addEventListener('click', clickCallback);
10 | return instance;
11 | };
12 | newNotify.requestPermission = OldNotify.requestPermission.bind(OldNotify);
13 | Object.defineProperty(newNotify, 'permission', {
14 | get: () => OldNotify.permission,
15 | });
16 |
17 | window.Notification = newNotify;
18 | }
19 | */
20 |
21 | const polyRideNotification = `
22 | class Notification extends EventTarget {
23 | constructor(title, opts = {}) {
24 | const {
25 | body = 'New notification',
26 | silent = false,
27 | data = null,
28 | } = opts;
29 | super();
30 | this._uniqueId = Math.random().toString(36).substr(2, 9);
31 | this._title = title;
32 | this._body = body;
33 | this._silent = silent;
34 | this._data = data;
35 | this._timestamp = Date.now();
36 | this._icon = '';
37 | this._requireInteraction = false;
38 | this._tag = '';
39 | this._renotify = false;
40 | this._actions = [];
41 | this._image = '';
42 | this._dir = 'auto';
43 | this._lang = '';
44 | this._badge = '';
45 | this._vibrate = [];
46 | this._onshow = null;
47 | this._onclose = null;
48 | this._onclick = null;
49 | // this._onclickWrapped = null;
50 | this._onerror = null;
51 |
52 | Notification.notifyNotificationInstances[this._uniqueId] = this;
53 | notify(JSON.stringify({
54 | title: this._title,
55 | message: this._body,
56 | sound: !this._silent,
57 | uniqueId: this._uniqueId,
58 | }));
59 | }
60 |
61 | close() {
62 | // implement closing notification here
63 | }
64 |
65 | get title() { return this._title; }
66 | get body() { return this._body; }
67 | get silent() { return this._silent; }
68 | get icon() { return this._icon; }
69 | get timestamp() { return this._timestamp; }
70 | get data() { return this._data; }
71 | get requireInteraction() { return this._requireInteraction; }
72 | get tag() { return this._tag; }
73 | get renotify() { return this._renotify; }
74 | get actions() { return this._actions; }
75 | get image() { return this._image; }
76 | get dir() { return this._dir; }
77 | get lang() { return this._lang; }
78 | get badge() { return this._badge; }
79 | get vibrate() { return this._vibrate; }
80 |
81 | get onshow() {
82 | return this._onshow;
83 | }
84 | set onshow(callback) {
85 | if (typeof callback !== 'function') {
86 | callback = null;
87 | }
88 | if (typeof this._onshow === 'function') {
89 | this.removeEventListener('show', this._onshow);
90 | }
91 | this._onshow = callback;
92 | if (typeof callback === 'function') {
93 | this.addEventListener('show', callback);
94 | }
95 | }
96 |
97 | get onclose() {
98 | return this._onclose;
99 | }
100 | set onclose(callback) {
101 | if (typeof callback !== 'function') {
102 | callback = null;
103 | }
104 | if (typeof this._onclose === 'function') {
105 | this.removeEventListener('close', this._onclose);
106 | }
107 | this._onclose = callback;
108 | if (typeof callback === 'function') {
109 | this.addEventListener('close', callback);
110 | }
111 | }
112 |
113 | get onclick() {
114 | return this._onclick;
115 | }
116 | set onclick(callback) {
117 | if (typeof callback !== 'function') {
118 | callback = null;
119 | }
120 | if (typeof this._onclick === 'function') {
121 | this.removeEventListener('click', this._onclick);
122 | }
123 | this._onclick = callback;
124 | if (typeof callback === 'function') {
125 | this.addEventListener('click', callback);
126 | }
127 | }
128 | // set onclick(callback) {
129 | // const notificationAttachedCallback = null;
130 | // if (typeof callback !== 'function') {
131 | // notificationAttachedCallback = null;
132 | // } else {
133 | // notificationAttachedCallback = (e) => {
134 | // const notificationAttachedEvent = e;
135 | // notificationAttachedCallback.notification = this;
136 | // callback(notificationAttachedCallback);
137 | // };
138 | // }
139 | // if (typeof this._onclick === 'function') {
140 | // this.removeEventListener('click', this._onclickWrapped);
141 | // }
142 | // this._onclick = callback;
143 | // this._onclickWrapped = notificationAttachedCallback;
144 | // if (typeof callback === 'function') {
145 | // this.addEventListener('click', notificationAttachedCallback);
146 | // }
147 | // }
148 |
149 | get onerror() {
150 | return this._onerror;
151 | }
152 | set onerror(callback) {
153 | if (typeof callback !== 'function') {
154 | callback = null;
155 | }
156 | if (typeof this._onerror === 'function') {
157 | this.removeEventListener('error', this._onerror);
158 | }
159 | this._onerror = callback;
160 | if (typeof callback === 'function') {
161 | this.addEventListener('error', callback);
162 | }
163 | }
164 |
165 | static get permission() {
166 | return Notification.PERMISSION_GRANTED;
167 | }
168 |
169 | static requestPermission(callback = () => {}) {
170 | callback(Notification.PERMISSION_GRANTED);
171 | return new Promise((resolve) => resolve(Notification.PERMISSION_GRANTED));
172 | }
173 |
174 | static getLastNotification() {
175 | const notifyNotificationInstancesKeys = Object.keys(Notification.notifyNotificationInstances);
176 | if (notifyNotificationInstancesKeys.length === 0) {
177 | return null;
178 | } else {
179 | return Notification.notifyNotificationInstances[notifyNotificationInstancesKeys[notifyNotificationInstancesKeys.length - 1]];
180 | }
181 | }
182 | }
183 | Notification.PERMISSION_DEFAULT = 'default';
184 | Notification.PERMISSION_GRANTED = 'granted';
185 | Notification.PERMISSION_DENIED = 'denied';
186 | Notification.notifyNotificationInstances = {};
187 | window.Notification = Notification;
188 | `;
189 |
190 | module.exports = polyRideNotification;
191 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {Command, flags} from '@oclif/command'
2 | import { platform as osPlatform, type as platformType, release as platformRelease } from 'os'
3 | import { writeFile, existsSync } from 'fs'
4 | import { dir as tempDir } from 'tmp'
5 | import { cp, mv, echo, exec, pwd, cd, cat, mkdir, test, rm } from 'shelljs'
6 | import fetch from 'node-fetch'
7 | import pageIcon = require('page-icon')
8 | import sharp = require('sharp')
9 | import pngToIco = require('png-to-ico')
10 | import ICO = require('icojs')
11 | import dedent = require('dedent-js')
12 | import semver = require('semver')
13 | const icnsConvert = require('@fiahfy/icns-convert')
14 | const createNodeAppWithoutTerminal = require('create-nodew-exe')
15 | const windowsShortcut = require('windows-shortcuts')
16 | const { exec: pkgExec } = require('pkg')
17 |
18 | const placeholderAppName = 'quark-carlo-placeholder'
19 | const iconSizes = [16, 24, 32, 48, 64, 72, 96, 128, 256]
20 |
21 | const guranteeSemverFormat = (version:string) => {
22 | if (version.split('.').length === 2) {
23 | version += '.0'
24 | }
25 | return version
26 | }
27 | const isLessThanWin8 =() => {
28 | return (
29 | platformType() === 'Windows_NT' &&
30 | semver.satisfies(guranteeSemverFormat(platformRelease()), '<6.2.9200')
31 | )
32 | }
33 | const isLinux = () => osPlatform() === 'linux'
34 | const isWindows = () => osPlatform() === 'win32'
35 | const isMac = () => osPlatform() === 'darwin'
36 | const getPlatform = () => {
37 | switch (osPlatform()) {
38 | case 'win32':
39 | return 'win'
40 | case 'darwin':
41 | return 'macos'
42 | case 'linux':
43 | return 'linux'
44 | default:
45 | return osPlatform()
46 | }
47 | }
48 | const getNormalizedPlatform = (platform:string) => platform !== 'host' ? platform : getPlatform()
49 |
50 | const execPath = pwd().valueOf()
51 |
52 | const getLinuxInstallationDesktopFilesPath = () => {
53 | cd()
54 | const homePath = pwd().valueOf()
55 | cd(execPath)
56 | return `${homePath}/.local/share/applications`
57 | }
58 |
59 | const getLinuxInstallationDesktopFilesIconFilesPath = (dimension:number, tillDimension:boolean = false) => {
60 | cd()
61 | const homePath = pwd().valueOf()
62 | cd(execPath)
63 | return `${homePath}/.local/share/icons/hicolor/${dimension}x${dimension}${tillDimension ? '' : '/apps'}`
64 | }
65 |
66 | const getWindowsInstallationStartMenuShortcutFilesPath = () => {
67 | cd()
68 | const homePath = pwd().valueOf()
69 | cd(execPath)
70 | return `${homePath}/AppData/Roaming/Microsoft/Windows/Start Menu/Programs`
71 | }
72 |
73 | const getMacOSApplicationsPath = () => {
74 | cd()
75 | const homePath = pwd().valueOf()
76 | cd(execPath)
77 | return `${homePath}/Applications`
78 | }
79 |
80 | const filenameSafe = (str:string) => str.replace(/[^a-z0-9]/gi, '_').toLowerCase()
81 | const filenameSafeDisplayName = (str:string) => str.replace(/[^a-z0-9 ]/gi, '_')
82 |
83 | const getProperPageIcon = (url:string):Promise => new Promise((resolve, reject) => {
84 | pageIcon(url)
85 | .then((icon) => {
86 | if (icon === undefined) {
87 | return reject('icon fetch failed')
88 | }
89 | if (icon.ext.toLowerCase() !== '.png') {
90 | return reject('icon not png')
91 | }
92 | resolve(icon)
93 | })
94 | .catch(() => {
95 | return reject('size calculation failed')
96 | })
97 | })
98 |
99 | const getIconFiles = (
100 | url:string,
101 | log:Function,
102 | isIcoNeeded = true,
103 | isIcnsNeeded = true,
104 | {
105 | tempPngOutPath = null,
106 | tempIcoOutPath = null,
107 | tempIcnsOutPath = null,
108 | pngOutPath = null,
109 | icoOutPath = null,
110 | icnsOutPath = null,
111 | }:{
112 | tempPngOutPath:string|null,
113 | tempIcoOutPath:string|null,
114 | tempIcnsOutPath:string|null,
115 | pngOutPath:string|null,
116 | icoOutPath:string|null,
117 | icnsOutPath:string|null,
118 | },
119 | ) => new Promise((resolve, reject) => {
120 | log('Looking for appropriate icon image...')
121 | if (tempPngOutPath=== null || tempIcoOutPath === null || tempIcnsOutPath === null) {
122 | return reject('tempPngOutPath, tempIcoOutPath or tempIcnsOutPath not supplied')
123 | }
124 | if (pngOutPath === null) {
125 | return reject('pngOutPath not supplied')
126 | }
127 | getProperPageIcon(url)
128 | .then((icon) => {
129 | fetch(icon.source)
130 | .then(response => response.buffer())
131 | .then((pngBuf) => {
132 | writeFile(
133 | tempPngOutPath,
134 | pngBuf,
135 | (err) => {
136 | if (err) {
137 | reject('writing png file failed')
138 | } else {
139 | cp(tempPngOutPath, pngOutPath)
140 | log('Appropriate icon file saved...')
141 | if (isIcoNeeded) {
142 | pngToIco(icon.source)
143 | .then((icoBuf:any) => {
144 | writeFile(
145 | tempIcoOutPath,
146 | icoBuf,
147 | (err) => {
148 | if (err) {
149 | reject('writing ico file failed')
150 | } else {
151 | if (icoOutPath === null) {
152 | reject('icoOutPath not supplied')
153 | } else {
154 | cp(tempIcoOutPath, icoOutPath)
155 | log('Ico file generated...')
156 | if (isIcnsNeeded) {
157 | icnsConvert(pngBuf)
158 | .then((icnsBuf:any) => {
159 | writeFile(
160 | tempIcnsOutPath,
161 | icnsBuf,
162 | (err) => {
163 | if (err) {
164 | reject('writing icns file failed')
165 | } else {
166 | if (icnsOutPath === null) {
167 | reject('icnsOutPath not supplied')
168 | } else {
169 | cp(tempIcnsOutPath, icnsOutPath)
170 | log('Icns file generated...')
171 | resolve()
172 | }
173 | }
174 | },
175 | )
176 | })
177 | .catch((err:any) => reject(err))
178 | } else {
179 | resolve()
180 | }
181 | }
182 | }
183 | },
184 | )
185 | })
186 | .catch((err:any) => reject(err))
187 | } else {
188 | if (isIcnsNeeded) {
189 | icnsConvert(pngBuf)
190 | .then((icnsBuf:any) => {
191 | writeFile(
192 | tempIcnsOutPath,
193 | icnsBuf,
194 | (err) => {
195 | if (err) {
196 | reject('writing icns file failed')
197 | } else {
198 | if (icnsOutPath === null) {
199 | reject('icnsOutPath not supplied')
200 | } else {
201 | cp(tempIcnsOutPath, icnsOutPath)
202 | log('Icns file generated...')
203 | resolve()
204 | }
205 | }
206 | },
207 | )
208 | })
209 | .catch((err:any) => reject(err))
210 | } else {
211 | resolve()
212 | }
213 | }
214 | }
215 | },
216 | )
217 | })
218 | .catch((err:any) => reject(err))
219 | })
220 | .catch((err) => {
221 | log('Ico generation failed, falling back to using favicon.ico...')
222 | fetch(`${url}/favicon.ico`)
223 | .then(response => response.buffer())
224 | .then((icoBuf) => {
225 | writeFile(
226 | tempIcoOutPath,
227 | icoBuf,
228 | (err) => {
229 | if (err) {
230 | reject('writing ico file failed')
231 | } else {
232 | if (isIcoNeeded) {
233 | if (icoOutPath === null) {
234 | return reject('icoOutPath not supplied')
235 | } else {
236 | cp(tempIcoOutPath, icoOutPath)
237 | log('Ico file saved...')
238 | }
239 | }
240 | ICO.parse(icoBuf, 'image/png')
241 | .then((images) => {
242 | const largestImage = images.sort((a, b) => b.width - a.width)[0]
243 | return sharp(Buffer.from(largestImage.buffer))
244 | .resize(iconSizes[iconSizes.length - 1], iconSizes[iconSizes.length - 1])
245 | .png()
246 | .toBuffer()
247 | .then((pngBuf) => {
248 | writeFile(
249 | tempPngOutPath,
250 | pngBuf,
251 | (err) => {
252 | if (err) {
253 | reject('writing png file failed')
254 | } else {
255 | cp(tempPngOutPath, pngOutPath)
256 | log('Png icon file saved...')
257 | if (isIcnsNeeded) {
258 | icnsConvert(pngBuf)
259 | .then((icnsBuf:any) => {
260 | writeFile(
261 | tempIcnsOutPath,
262 | icnsBuf,
263 | (err) => {
264 | if (err) {
265 | reject('writing icns file failed')
266 | } else {
267 | if (icnsOutPath === null) {
268 | reject('icnsOutPath not supplied')
269 | } else {
270 | cp(tempIcnsOutPath, icnsOutPath)
271 | log('Icns file generated...')
272 | resolve()
273 | }
274 | }
275 | },
276 | )
277 | })
278 | .catch((err:any) => reject(err))
279 | } else {
280 | resolve()
281 | }
282 | }
283 | },
284 | )
285 | })
286 | .catch((err) => {
287 | throw err
288 | })
289 | })
290 | .catch(() => reject('Converting favicon.ico into png failed'))
291 | }
292 | },
293 | )
294 | })
295 | .catch(() => reject('Saving favicon.ico failed'))
296 | })
297 | })
298 |
299 | class QuarkCarlo extends Command {
300 | static description = 'Create native app from any web app, optionally install a shortcut so that the app shows up in the application menu'
301 | static flags = {
302 | version: flags.version({ char: 'v' }),
303 | help: flags.help({ char: 'h' }),
304 | name: flags.string({ char: 'n', description: 'name of application', required: true }),
305 | url: flags.string({ char: 'u', description: 'url to load in application', required: true }),
306 | platform: flags.string({
307 | char: 'p',
308 | description: 'Platform to build the binary for, defaults to the running platform, possible options are linux, macos, win',
309 | default: 'host',
310 | }),
311 | install: flags.boolean({
312 | char: 'i',
313 | description: 'Install a shortcut so that the app shows up in the application menu',
314 | default: false,
315 | }),
316 | dimensions: flags.string({
317 | char: 'd',
318 | description: 'Dimensions of application window as [width]x[height], for example 1280x720',
319 | default: '1280x720',
320 | }),
321 | additionalInternalHostnames: flags.string({
322 | char: 'a',
323 | description: 'Comma separated list of additional hostnames that are to be opened within the app, for example oauth login page hostnames (for Google: accounts.google.com)',
324 | default: '',
325 | }),
326 | debug: flags.boolean({
327 | char: 'D',
328 | description: 'Create debug app to identify required additional internal hostnames, on encountering navigation to an external hostname the app will show an alert with the hostname value to pass in additionalInternalHostnames',
329 | default: false,
330 | }),
331 | }
332 |
333 | async installShortcut(
334 | binaryName:string,
335 | platform:string,
336 | pngOutPath:string,
337 | {
338 | url = null,
339 | binaryPath = null,
340 | shortcutFilePath = null,
341 | shortcutName = null,
342 | icoOutPath = null,
343 | launcherName = null,
344 | outPkgDirectoryPath = null,
345 | }:{
346 | url:string|null,
347 | binaryPath:string|null,
348 | shortcutFilePath:string|null,
349 | shortcutName:string|null,
350 | icoOutPath:string|null,
351 | launcherName:string|null,
352 | outPkgDirectoryPath:string|null,
353 | }
354 | ) {
355 | if (platform === 'host' || platform === getPlatform()) {
356 | this.log('Installing shortcut...')
357 | if (isLinux()) {
358 | if (url === null) throw 'no url supplied'
359 | if (binaryPath === null) throw 'no binary path supplied'
360 | if (launcherName === null) throw 'no launcher name supplied'
361 | const iconGenerationPromises:Array> = iconSizes.map((size) => new Promise((resolve, reject) => {
362 | sharp(pngOutPath)
363 | .resize(size, size)
364 | .toBuffer()
365 | .then((resizedIconData) => {
366 | if (!test('-d', getLinuxInstallationDesktopFilesIconFilesPath(size, true))) {
367 | mkdir(getLinuxInstallationDesktopFilesIconFilesPath(size, true))
368 | }
369 | if (!test('-d', getLinuxInstallationDesktopFilesIconFilesPath(size))) {
370 | mkdir(getLinuxInstallationDesktopFilesIconFilesPath(size))
371 | }
372 | writeFile(
373 | `${getLinuxInstallationDesktopFilesIconFilesPath(size)}/${binaryName}.png`,
374 | resizedIconData,
375 | { mode: 0o666, flag: 'w' },
376 | (err) => {
377 | if (err) {
378 | return reject(err)
379 | } else {
380 | this.log(`Icon file of ${size}x${size} generated...`)
381 | resolve(`${getLinuxInstallationDesktopFilesIconFilesPath(size)}/${binaryName}.png`)
382 | }
383 | }
384 | )
385 | })
386 | .catch((err) => {
387 | return reject(err)
388 | })
389 | }))
390 | Promise.all(iconGenerationPromises)
391 | .then((iconPaths) => {
392 | cat(`${__dirname}/../installation/linux/app.desktop`)
393 | .sed('@@NAME@@', launcherName)
394 | .sed('@@PATH@@', binaryPath)
395 | .sed('@@FILENAME@@', `${binaryName}`)
396 | .to(`${getLinuxInstallationDesktopFilesPath()}/${binaryName}.desktop`)
397 | this.log('Desktop file generated...')
398 | this.log('Shortcut installation complete...')
399 | this.log('To remove installation of shortcut, remove following files:')
400 | this.log(`${getLinuxInstallationDesktopFilesPath()}/${binaryName}.desktop`)
401 | iconPaths.forEach((iconPath) => {
402 | this.log(iconPath)
403 | })
404 | })
405 | .catch((err) => {
406 | throw err
407 | })
408 | } else if (isWindows()) {
409 | if (shortcutFilePath === null) throw 'no shortcut file path supplied'
410 | if (shortcutName === null) throw 'no shortcut name supplied'
411 | if (outPkgDirectoryPath === null) throw 'no out package directory path supplied'
412 | if (binaryPath === null) throw 'no binary path supplied'
413 | if (icoOutPath === null) throw 'no ico out path supplied'
414 | const windowsInstallationStartMenuShortcutFilesPath = `${getWindowsInstallationStartMenuShortcutFilesPath()}/${shortcutName}.lnk`
415 | this.log('Installing shortcut to Start Menu...')
416 | if (isLessThanWin8()) {
417 | cp(shortcutFilePath, windowsInstallationStartMenuShortcutFilesPath)
418 | } else {
419 | exec(`${outPkgDirectoryPath}/notifier/SnoreToast.exe -install "${windowsInstallationStartMenuShortcutFilesPath}" "${binaryPath}" "${binaryName}"`, { silent: true }, (code) => {
420 | if (code === 0) {
421 | windowsShortcut.edit(
422 | windowsInstallationStartMenuShortcutFilesPath,
423 | {
424 | icon: icoOutPath,
425 | },
426 | (err:string) => {
427 | if (err === null) {
428 | this.log('Shortcut installation complete...')
429 | this.log('To remove installation of shortcut, remove following files:')
430 | this.log(windowsInstallationStartMenuShortcutFilesPath)
431 | } else {
432 | this.error('Shortcut installation failed')
433 | }
434 | },
435 | )
436 | } else {
437 | throw 'shortcut install failed'
438 | }
439 | })
440 | }
441 | } else if (isMac()) {
442 | this.log(`To install the app, drag and drop the ${binaryName}.app file onto the Applications folder in Finder.`)
443 | } else {
444 | this.log('Creating shortcut for the current platform isn\'t supported yet.')
445 | }
446 | } else {
447 | this.error('Shortcut can only be installed if the platform is the same as the running platform')
448 | }
449 | }
450 |
451 | async run() {
452 | const { flags } = this.parse(QuarkCarlo)
453 | const { name, url, dimensions, install, additionalInternalHostnames, debug } = flags
454 | let { platform } = flags
455 |
456 | let width = 1280
457 | let height = 720
458 | let parsedAdditionalInternalHostnames = []
459 |
460 | try {
461 | if (dimensions === undefined) throw 'dimensions undefined'
462 | if (dimensions.split('x').length !== 2) throw 'dimensions invalid format'
463 | const parsedWidth = parseInt(dimensions.split('x')[0])
464 | const parsedHeight = parseInt(dimensions.split('x')[1])
465 | if (isNaN(parsedWidth) || isNaN(parsedHeight)) throw 'dimension is not a number'
466 | width = parsedWidth
467 | height = parsedHeight
468 | } catch (err) {
469 | this.warn('Invalid dimensions format, using default value')
470 | }
471 | try {
472 | if (!['host', 'linux', 'win', 'macos'].includes(platform)) throw 'supplied platform invalid'
473 | } catch (err) {
474 | platform = 'host'
475 | this.warn('Invalid platform value, building for running platform')
476 | }
477 | try {
478 | if (additionalInternalHostnames.length !== 0) {
479 | const hostnames = additionalInternalHostnames.split(',')
480 | .map(hostname => hostname.trim())
481 | .map(hostname => new URL(`https://${hostname}`).hostname)
482 | parsedAdditionalInternalHostnames = parsedAdditionalInternalHostnames.concat(hostnames)
483 | }
484 | } catch (err) {
485 | this.warn('Invalid additional internal hostnames supplied, make sure you pass a comma separated list of hostnames')
486 | }
487 |
488 | const binaryName = getNormalizedPlatform(platform) === 'win' ? filenameSafeDisplayName(name) : filenameSafe(name)
489 | const outPkgDirectoryPath = `${execPath}/${filenameSafe(name)}`
490 |
491 | const config = JSON.stringify({
492 | name,
493 | url,
494 | width,
495 | height,
496 | iconPath: 'icon.png',
497 | additionalInternalHostnames: parsedAdditionalInternalHostnames,
498 | appName: binaryName,
499 | dirName: filenameSafe(name),
500 | platform: getNormalizedPlatform(platform),
501 | debug,
502 | })
503 | tempDir({ unsafeCleanup: true }, (err, tempDirPath) => {
504 | if (err) throw err
505 | cp('-R', `${__dirname}/../app/*`, tempDirPath)
506 | this.log(`Config options:`)
507 | echo(config).to(`${tempDirPath}/config.json`)
508 | cd(tempDirPath)
509 | this.log('Installing dependencies...')
510 | exec('npm install', { silent: true }, (code) => {
511 | cd(execPath)
512 | if (code === 0) {
513 | this.log('Successfully installed dependencies...')
514 | this.log('Building binaries...')
515 | pkgExec([ tempDirPath, '--out-path', tempDirPath, '--targets', `node10-${getNormalizedPlatform(platform)}`, '--no-bytecode' ])
516 | .then(() => {
517 | const tempPkgBinaryName = getNormalizedPlatform(platform) === 'win' ? `${placeholderAppName}.exe` : placeholderAppName
518 | const outPkgBinaryName = getNormalizedPlatform(platform) === 'win' ? `${binaryName}.exe` : binaryName
519 | const tempPkgBinaryPath = `${tempDirPath}/${tempPkgBinaryName}`
520 | const outPkgBinaryPath = `${outPkgDirectoryPath}/${outPkgBinaryName}`
521 | if (existsSync(outPkgDirectoryPath)) {
522 | rm('-rf', outPkgDirectoryPath)
523 | }
524 | mkdir(outPkgDirectoryPath)
525 | if (!test('-f', tempPkgBinaryPath)) {
526 | throw 'Binary packaging failed'
527 | }
528 | this.log('Generated binary successfully...')
529 | cp(tempPkgBinaryPath, outPkgBinaryPath)
530 | const icoOutPath = `${outPkgDirectoryPath}/icon.ico`
531 | const icnsOutPath = `${outPkgDirectoryPath}/icon.icns`
532 | const pngOutPath = `${outPkgDirectoryPath}/icon.png`
533 | getIconFiles(
534 | url,
535 | (msg:any) => this.log(msg),
536 | getNormalizedPlatform(platform) === 'win',
537 | getNormalizedPlatform(platform) === 'macos',
538 | {
539 | tempIcoOutPath: `${tempDirPath}/icon.ico`,
540 | icoOutPath,
541 | tempIcnsOutPath: `${tempDirPath}/icon.icns`,
542 | icnsOutPath,
543 | tempPngOutPath: `${tempDirPath}/icon.png`,
544 | pngOutPath,
545 | },
546 | )
547 | .then(() => {
548 | if (getNormalizedPlatform(platform) === 'win') {
549 | this.log('Making binary silent on launch...')
550 | createNodeAppWithoutTerminal({
551 | src: outPkgBinaryPath,
552 | dst: outPkgBinaryPath,
553 | })
554 | mkdir(`${outPkgDirectoryPath}/notifier`)
555 | cp(`${tempDirPath}/node_modules/node-notifier/vendor/notifu/notifu.exe`, `${outPkgDirectoryPath}/notifier/notifu.exe`)
556 | cp(`${tempDirPath}/node_modules/node-notifier/vendor/notifu/notifu64.exe`, `${outPkgDirectoryPath}/notifier/notifu64.exe`)
557 | cp(`${tempDirPath}/node_modules/node-notifier/vendor/snoreToast/SnoreToast.exe`, `${outPkgDirectoryPath}/notifier/SnoreToast.exe`)
558 | // Making SnoreToast binary silent too, although this library is only meant for node exe
559 | createNodeAppWithoutTerminal({
560 | src: `${outPkgDirectoryPath}/notifier/SnoreToast.exe`,
561 | dst: `${outPkgDirectoryPath}/notifier/SnoreToast.exe`,
562 | })
563 | if (isWindows()) {
564 | const shortcutOutPath = `${execPath}/${binaryName}.lnk`
565 | this.log('Creating shortcut for the app...')
566 | windowsShortcut.create(
567 | shortcutOutPath,
568 | {
569 | target: outPkgBinaryPath,
570 | icon: icoOutPath,
571 | },
572 | (err:string) => {
573 | if (err === null) {
574 | this.log('Shortcut file created...')
575 | if (install) {
576 | this.installShortcut(
577 | binaryName,
578 | platform,
579 | pngOutPath,
580 | {
581 | shortcutName: binaryName,
582 | shortcutFilePath: shortcutOutPath,
583 | icoOutPath: icoOutPath,
584 | binaryPath: outPkgBinaryPath,
585 | url: null,
586 | launcherName: null,
587 | outPkgDirectoryPath: outPkgDirectoryPath,
588 | },
589 | )
590 | } else {
591 | this.log('Application created successfully.')
592 | }
593 | } else {
594 | this.error('Creating shortcut file failed')
595 | }
596 | },
597 | )
598 | } else {
599 | this.log(dedent(`
600 | Shortcut can only be ${install ? 'installed' : 'created'} on Windows,
601 | Please create a shortcut of the binary manually,
602 | and assign icon.ico to the shortcut manually on Windows.
603 | `))
604 | this.log('Application created successfully.')
605 | }
606 | } else if (getNormalizedPlatform(platform) === 'linux') {
607 | if (install) {
608 | this.installShortcut(binaryName, platform, pngOutPath, { launcherName: filenameSafeDisplayName(name), url, binaryPath: outPkgBinaryPath, shortcutFilePath: null, shortcutName: null, icoOutPath: null, outPkgDirectoryPath: null })
609 | } else {
610 | this.log('Application created successfully.')
611 | }
612 | } else if (getNormalizedPlatform(platform) === 'macos') {
613 | mkdir(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app`)
614 | mkdir(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents`)
615 | mkdir(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/MacOS`)
616 | mkdir(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Resources`)
617 | cat(`${__dirname}/../installation/macos/Info.plist`)
618 | .sed('@@NAME@@', filenameSafeDisplayName(name))
619 | .sed('@@FILENAME@@', binaryName)
620 | .to(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Info.plist`)
621 | mv(`${outPkgDirectoryPath}/icon.png`, `${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Resources/icon.png`)
622 | mv(`${outPkgDirectoryPath}/icon.icns`, `${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Resources/${binaryName}.icns`)
623 | mkdir(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Resources/terminal-notifier.app`)
624 | cp('-R', `${tempDirPath}/node_modules/node-notifier/vendor/mac.noindex/terminal-notifier.app/*`, `${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/Resources/terminal-notifier.app`)
625 | mv(`${outPkgDirectoryPath}/${binaryName}`, `${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app/Contents/MacOS/${binaryName}`)
626 | if (existsSync(`${filenameSafeDisplayName(name)}.app`)) {
627 | rm('-rf', `${filenameSafeDisplayName(name)}.app`)
628 | }
629 | mv(`${outPkgDirectoryPath}/${filenameSafeDisplayName(name)}.app`, `${filenameSafeDisplayName(name)}.app`)
630 | rm('-rf', outPkgDirectoryPath)
631 | this.log('Application created successfully.')
632 | if (install) {
633 | const installedApplicationsDirectoryPath = `${getMacOSApplicationsPath()}/${filenameSafeDisplayName(name)}.app`
634 | if (existsSync(installedApplicationsDirectoryPath)) {
635 | rm('-rf', installedApplicationsDirectoryPath)
636 | }
637 | mkdir(installedApplicationsDirectoryPath)
638 | cp('-R', `${filenameSafeDisplayName(name)}.app/*`, installedApplicationsDirectoryPath)
639 | rm('-rf', `${filenameSafeDisplayName(name)}.app`)
640 | this.log('Application installed successfully...')
641 | this.log('To remove installation of shortcut, remove the following:')
642 | this.log(installedApplicationsDirectoryPath)
643 | }
644 | } else {
645 | if (install) {
646 | this.installShortcut(binaryName, platform, pngOutPath, { launcherName: filenameSafeDisplayName(name), url, binaryPath: outPkgBinaryPath, shortcutFilePath: null, shortcutName: null, icoOutPath: null, outPkgDirectoryPath: null })
647 | } else {
648 | this.log('Application created successfully.')
649 | }
650 | }
651 | })
652 | .catch((err:any) => this.error(err))
653 | })
654 | .catch(() => this.error('Binary packaging failed'))
655 | } else {
656 | this.error('npm install failed')
657 | }
658 | })
659 | })
660 | }
661 | }
662 |
663 | export = QuarkCarlo
664 |
--------------------------------------------------------------------------------