deskify-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/deskify.js +1001 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ipvdan (and contributors)
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Deskify š
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](#)
|
|
6
|
+
|
|
7
|
+
An ambitious, interactive, and **zero-dependency** CLI tool designed to effortlessly convert any web page into a native-feeling Linux desktop application using Electron (via Nativefier) in a single command.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
_ _ _ __
|
|
11
|
+
__| | ___ ___| |_( )/ _|_ _
|
|
12
|
+
/ _` |/ _ \/ __| |/ /| |_| | | |
|
|
13
|
+
| (_| | __/\__ \ <| _| |_| |
|
|
14
|
+
\__,_|\___||___/_|\_\\_| \__, |
|
|
15
|
+
|___/
|
|
16
|
+
Web to Linux Desktop App Packager
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## š” The Problem
|
|
22
|
+
|
|
23
|
+
Running websites as desktop applications in Linux is fantastic for productivity, but doing it manually via Electron or Nativefier involves a tedious and repetitive sequence:
|
|
24
|
+
1. **Compiling**: Running complex CLI arguments for resolution, persisting sessions, and restricting domains.
|
|
25
|
+
2. **Permissions Fix**: Electron applications on modern Linux distros fail to start unless the `chrome-sandbox` file is owned by root and given `4755` setuid permissions (due to kernel user-namespace changes).
|
|
26
|
+
3. **Shortcut Integration**: Manually writing `.desktop` launch files inside `~/.local/share/applications/` and resolving icons.
|
|
27
|
+
4. **The Dash Icon Bug (`StartupWMClass`)**: Nativefier appends a dynamic 6-character hash (e.g. `nextcloud-nativefier-a8e93f`) to the Electron window class (`WM_CLASS`) on compile. If your `.desktop` launcher has an outdated hash, **your launcher icon breaks in the Dash/Dock** (showing a generic gear icon or a duplicate unpinned icon).
|
|
28
|
+
|
|
29
|
+
**Deskify solves all of these pains automatically in one prompt-driven wizard.**
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## ⨠Features
|
|
34
|
+
|
|
35
|
+
- š„ļø **Interactive Wizard**: No command arguments to memorize. Simply type `deskify` and follow the beautiful, colorized prompts.
|
|
36
|
+
- š **Smart Defaults**: Enter any URL (e.g., `https://panggon.danyakmallun.dev`) and Deskify will automatically extract the app name, domain, and set up domain boundary regexes (`.*panggon\.danyakmallun\.dev.*`) so links inside the app stay inside, and outside links open in your default browser.
|
|
37
|
+
- šØ **Favicon Auto-Downloader**: Automatically downloads and configures the highest quality favicon from Google APIs to use as the app launcher icon.
|
|
38
|
+
- š **Auto-Sandbox Permission Fix**: Automatically runs `sudo chown root:root` and `sudo chmod 4755` on `chrome-sandbox` inside the interactive terminal, ensuring the app boots successfully.
|
|
39
|
+
- šÆ **Dynamic StartupWMClass Tracking**: Automatically parses the generated `package.json` inside the Electron source code, extracts the unique Nativefier build hash, and writes the correct `StartupWMClass` to ensure application launcher matching in your system app menu and dock.
|
|
40
|
+
- š¦ **App Directory Organization**: Moves the generated packages out of your home directory and places them inside a clean `~/Apps` folder (or custom directory).
|
|
41
|
+
- ā” **Zero-Dependency**: Written purely in native Node.js API. No `node_modules` required to download or run.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## š Quick Start
|
|
46
|
+
|
|
47
|
+
### 1. Requirements
|
|
48
|
+
|
|
49
|
+
Ensure you have Node.js and NPM installed on your Linux system. Deskify checks for these on boot.
|
|
50
|
+
On Ubuntu/Debian/Mint:
|
|
51
|
+
```bash
|
|
52
|
+
sudo apt update && sudo apt install -y nodejs npm
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Running Deskify
|
|
56
|
+
|
|
57
|
+
You don't even need to install Deskify to try it! Run it directly using `npx`:
|
|
58
|
+
```bash
|
|
59
|
+
npx https://github.com/<your-username>/deskify
|
|
60
|
+
```
|
|
61
|
+
*(Or run it locally if you cloned the repository)*:
|
|
62
|
+
```bash
|
|
63
|
+
node deskify.js
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Global Installation (Optional)
|
|
67
|
+
|
|
68
|
+
Install it globally using NPM so you can call `deskify` anywhere in your terminal:
|
|
69
|
+
```bash
|
|
70
|
+
npm install -g .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## š ļø How It Works
|
|
76
|
+
|
|
77
|
+
```mermaid
|
|
78
|
+
graph TD
|
|
79
|
+
A[User Types URL & Name] --> B[Pre-flight Check: Node/NPM/Apt]
|
|
80
|
+
B --> C[Auto-download High-Res Favicon]
|
|
81
|
+
C --> D[Compile Webapp via Nativefier]
|
|
82
|
+
D --> E[Move app package to ~/Apps/]
|
|
83
|
+
E --> F[Parse generated package.json for Name & Hash]
|
|
84
|
+
F --> G[Reconstruct exact StartupWMClass]
|
|
85
|
+
G --> H[Run Sudo chmod/chown on chrome-sandbox]
|
|
86
|
+
H --> I[Generate ~/.local/share/applications/App.desktop]
|
|
87
|
+
I --> J[Run update-desktop-database]
|
|
88
|
+
J --> K[App appears in System Search & Dock with proper icon!]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## š License
|
|
94
|
+
|
|
95
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
package/deskify.js
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* deskify.js
|
|
5
|
+
* An interactive, zero-dependency CLI tool to turn web pages into Linux desktop applications using Nativefier.
|
|
6
|
+
* Author: Antigravity AI
|
|
7
|
+
* License: MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const { execSync, spawn } = require('child_process');
|
|
15
|
+
const readline = require('readline');
|
|
16
|
+
|
|
17
|
+
// ANSI escape codes for styling
|
|
18
|
+
const colors = {
|
|
19
|
+
reset: '\x1b[0m',
|
|
20
|
+
bright: '\x1b[1m',
|
|
21
|
+
dim: '\x1b[2m',
|
|
22
|
+
underline: '\x1b[4m',
|
|
23
|
+
blink: '\x1b[5m',
|
|
24
|
+
reverse: '\x1b[7m',
|
|
25
|
+
hidden: '\x1b[8m',
|
|
26
|
+
|
|
27
|
+
black: '\x1b[30m',
|
|
28
|
+
red: '\x1b[31m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
blue: '\x1b[34m',
|
|
32
|
+
magenta: '\x1b[35m',
|
|
33
|
+
cyan: '\x1b[36m',
|
|
34
|
+
white: '\x1b[37m',
|
|
35
|
+
|
|
36
|
+
bgBlack: '\x1b[40m',
|
|
37
|
+
bgRed: '\x1b[41m',
|
|
38
|
+
bgGreen: '\x1b[42m',
|
|
39
|
+
bgYellow: '\x1b[43m',
|
|
40
|
+
bgBlue: '\x1b[44m',
|
|
41
|
+
bgMagenta: '\x1b[45m',
|
|
42
|
+
bgCyan: '\x1b[46m',
|
|
43
|
+
bgWhite: '\x1b[47m'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Console output helpers
|
|
47
|
+
const log = {
|
|
48
|
+
info: (msg) => console.log(`${colors.cyan}info:${colors.reset} ${msg}`),
|
|
49
|
+
success: (msg) => console.log(`${colors.green}success:${colors.reset} ${colors.bright}${msg}${colors.reset}`),
|
|
50
|
+
warn: (msg) => console.log(`${colors.yellow}warning:${colors.reset} ${msg}`),
|
|
51
|
+
error: (msg) => console.error(`${colors.red}error:${colors.reset} ${colors.bright}${msg}${colors.reset}`),
|
|
52
|
+
bold: (msg) => console.log(`${colors.bright}${msg}${colors.reset}`),
|
|
53
|
+
accent: (msg) => console.log(`${colors.magenta}${msg}${colors.reset}`)
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Help helper to expand tilde in paths
|
|
57
|
+
function expandHome(pathStr) {
|
|
58
|
+
if (!pathStr) return '';
|
|
59
|
+
if (pathStr.startsWith('~/') || pathStr === '~') {
|
|
60
|
+
return pathStr.replace('~', os.homedir());
|
|
61
|
+
}
|
|
62
|
+
return pathStr;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Helper to ask a question via raw-keypress to capture the Escape (ESC) key
|
|
66
|
+
function ask(questionText, defaultValue = '') {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const prompt = defaultValue
|
|
69
|
+
? `${colors.bright}${questionText}${colors.reset} ${colors.dim}(default: ${defaultValue})${colors.reset}: `
|
|
70
|
+
: `${colors.bright}${questionText}${colors.reset}: `;
|
|
71
|
+
|
|
72
|
+
process.stdout.write(prompt);
|
|
73
|
+
|
|
74
|
+
let buffer = '';
|
|
75
|
+
const rl = readline.createInterface({
|
|
76
|
+
input: process.stdin,
|
|
77
|
+
output: process.stdout
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Enable raw keypress listening
|
|
81
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
82
|
+
if (process.stdin.isTTY) {
|
|
83
|
+
process.stdin.setRawMode(true);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onKeypress(str, key) {
|
|
87
|
+
if (key) {
|
|
88
|
+
if (key.name === 'escape') {
|
|
89
|
+
cleanup();
|
|
90
|
+
reject(new Error('ESC'));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (key.ctrl && key.name === 'c') {
|
|
94
|
+
cleanup();
|
|
95
|
+
process.stdout.write('\n');
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
99
|
+
cleanup();
|
|
100
|
+
process.stdout.write('\n');
|
|
101
|
+
resolve(buffer.trim() || defaultValue);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (key.name === 'backspace') {
|
|
105
|
+
if (buffer.length > 0) {
|
|
106
|
+
buffer = buffer.slice(0, -1);
|
|
107
|
+
// Clear line and redraw prompt + buffer
|
|
108
|
+
process.stdout.write('\r\x1B[2K');
|
|
109
|
+
process.stdout.write(prompt + buffer);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write standard character
|
|
116
|
+
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
117
|
+
buffer += str;
|
|
118
|
+
process.stdout.write(str);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cleanup() {
|
|
123
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
124
|
+
if (process.stdin.isTTY) {
|
|
125
|
+
process.stdin.setRawMode(false);
|
|
126
|
+
}
|
|
127
|
+
rl.close();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process.stdin.on('keypress', onKeypress);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Helper to ask a yes/no question
|
|
135
|
+
async function askYesNo(questionText, defaultYes = true) {
|
|
136
|
+
const defaultStr = defaultYes ? 'Y/n' : 'y/N';
|
|
137
|
+
const answer = await ask(`${questionText} (${defaultStr})`);
|
|
138
|
+
if (!answer) return defaultYes;
|
|
139
|
+
return answer.toLowerCase().startsWith('y');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Helper for arrow-key interactive selection menu (zero-dependency)
|
|
143
|
+
function selectOption(questionText, options, defaultIndex = 0) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
let selectedIndex = defaultIndex;
|
|
146
|
+
const rl = readline.createInterface({
|
|
147
|
+
input: process.stdin,
|
|
148
|
+
output: process.stdout
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Enable raw keypress listening
|
|
152
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
153
|
+
if (process.stdin.isTTY) {
|
|
154
|
+
process.stdin.setRawMode(true);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Hide standard cursor
|
|
158
|
+
process.stdout.write('\x1B[?25l');
|
|
159
|
+
|
|
160
|
+
function renderMenu() {
|
|
161
|
+
console.log(`\n${colors.bright}${colors.cyan}āÆ${colors.reset} ${colors.bright}${questionText}${colors.reset}`);
|
|
162
|
+
options.forEach((opt, idx) => {
|
|
163
|
+
if (idx === selectedIndex) {
|
|
164
|
+
console.log(` ${colors.cyan}${colors.bright}⯠${opt}${colors.reset}`);
|
|
165
|
+
} else {
|
|
166
|
+
console.log(` ${colors.dim}${opt}${colors.reset}`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
renderMenu();
|
|
172
|
+
|
|
173
|
+
function onKeypress(str, key) {
|
|
174
|
+
// Move cursor back up options.length + 2 lines and clear them
|
|
175
|
+
const linesToMove = options.length + 2;
|
|
176
|
+
process.stdout.write(`\x1B[${linesToMove}A`);
|
|
177
|
+
for (let i = 0; i < linesToMove; i++) {
|
|
178
|
+
process.stdout.write('\x1B[2K\n');
|
|
179
|
+
}
|
|
180
|
+
process.stdout.write(`\x1B[${linesToMove}A`);
|
|
181
|
+
|
|
182
|
+
if (key) {
|
|
183
|
+
if (key.name === 'up') {
|
|
184
|
+
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
185
|
+
} else if (key.name === 'down') {
|
|
186
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
187
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
188
|
+
cleanup();
|
|
189
|
+
resolve(selectedIndex);
|
|
190
|
+
return;
|
|
191
|
+
} else if (key.name === 'escape') {
|
|
192
|
+
cleanup();
|
|
193
|
+
resolve(options.length - 1); // select the last option (Cancel/Exit)
|
|
194
|
+
return;
|
|
195
|
+
} else if (key.ctrl && key.name === 'c') {
|
|
196
|
+
cleanup();
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
renderMenu();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cleanup() {
|
|
205
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
206
|
+
if (process.stdin.isTTY) {
|
|
207
|
+
process.stdin.setRawMode(false);
|
|
208
|
+
}
|
|
209
|
+
// Show cursor again
|
|
210
|
+
process.stdout.write('\x1B[?25h');
|
|
211
|
+
rl.close();
|
|
212
|
+
|
|
213
|
+
// Clear the menu before resolving so the console is kept clean
|
|
214
|
+
const linesToMove = options.length + 2;
|
|
215
|
+
process.stdout.write(`\x1B[${linesToMove}A`);
|
|
216
|
+
for (let i = 0; i < linesToMove; i++) {
|
|
217
|
+
process.stdout.write('\x1B[2K\n');
|
|
218
|
+
}
|
|
219
|
+
process.stdout.write(`\x1B[${linesToMove}A`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process.stdin.on('keypress', onKeypress);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Spinner variables and functions
|
|
227
|
+
let spinnerInterval = null;
|
|
228
|
+
function startSpinner(msg) {
|
|
229
|
+
const frames = ['ā ', 'ā ', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ', 'ā '];
|
|
230
|
+
let i = 0;
|
|
231
|
+
// Hide cursor
|
|
232
|
+
process.stdout.write('\x1B[?25l');
|
|
233
|
+
process.stdout.write(`${colors.cyan}${frames[0]}${colors.reset} ${msg}`);
|
|
234
|
+
spinnerInterval = setInterval(() => {
|
|
235
|
+
i = (i + 1) % frames.length;
|
|
236
|
+
process.stdout.write(`\r${colors.cyan}${frames[i]}${colors.reset} ${msg}`);
|
|
237
|
+
}, 80);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function stopSpinner(success = true, statusMsg = '') {
|
|
241
|
+
if (spinnerInterval) {
|
|
242
|
+
clearInterval(spinnerInterval);
|
|
243
|
+
spinnerInterval = null;
|
|
244
|
+
}
|
|
245
|
+
// Clear the spinner line
|
|
246
|
+
process.stdout.write('\r\x1B[2K');
|
|
247
|
+
// Show cursor
|
|
248
|
+
process.stdout.write('\x1B[?25h');
|
|
249
|
+
|
|
250
|
+
if (statusMsg) {
|
|
251
|
+
if (success) {
|
|
252
|
+
console.log(`${colors.green}ā${colors.reset} ${statusMsg}`);
|
|
253
|
+
} else {
|
|
254
|
+
console.log(`${colors.red}ā${colors.reset} ${statusMsg}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Extract domain and subdomains for smart defaults
|
|
260
|
+
function parseUrlDetails(urlStr) {
|
|
261
|
+
try {
|
|
262
|
+
// Add protocol if missing for URL parsing
|
|
263
|
+
let cleanUrl = urlStr;
|
|
264
|
+
if (!/^https?:\/\//i.test(urlStr)) {
|
|
265
|
+
cleanUrl = 'https://' + urlStr;
|
|
266
|
+
}
|
|
267
|
+
const parsed = new URL(cleanUrl);
|
|
268
|
+
const hostname = parsed.hostname;
|
|
269
|
+
|
|
270
|
+
// Extract a nice default app name (first segment of domain, e.g. panggon.danyakmallun.dev -> Panggon)
|
|
271
|
+
const segments = hostname.split('.');
|
|
272
|
+
let nameDefault = 'Webapp';
|
|
273
|
+
if (segments.length > 0) {
|
|
274
|
+
const firstSegment = segments[0] === 'www' && segments.length > 1 ? segments[1] : segments[0];
|
|
275
|
+
nameDefault = firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Escape dots for regex
|
|
279
|
+
const escapedDomain = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
280
|
+
const regexDefault = `.*${escapedDomain}.*`;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
cleanUrl,
|
|
284
|
+
hostname,
|
|
285
|
+
nameDefault,
|
|
286
|
+
regexDefault
|
|
287
|
+
};
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return {
|
|
290
|
+
cleanUrl: urlStr,
|
|
291
|
+
hostname: '',
|
|
292
|
+
nameDefault: 'Webapp',
|
|
293
|
+
regexDefault: '.*'
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Helper to download a file from a URL, following redirects
|
|
299
|
+
function downloadUrl(urlStr, outputPath, redirectCount = 0) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
if (redirectCount > 5) {
|
|
302
|
+
resolve(false); // prevent infinite redirect loops
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
https.get(urlStr, (response) => {
|
|
308
|
+
// Handle redirects (301, 302, 307, 308)
|
|
309
|
+
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
310
|
+
let redirectUrl = response.headers.location;
|
|
311
|
+
// Resolve relative redirects
|
|
312
|
+
if (redirectUrl.startsWith('/')) {
|
|
313
|
+
const parsed = new URL(urlStr);
|
|
314
|
+
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
|
|
315
|
+
}
|
|
316
|
+
resolve(downloadUrl(redirectUrl, outputPath, redirectCount + 1));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (response.statusCode === 200) {
|
|
321
|
+
const file = fs.createWriteStream(outputPath);
|
|
322
|
+
response.pipe(file);
|
|
323
|
+
file.on('finish', () => {
|
|
324
|
+
file.close();
|
|
325
|
+
resolve(true);
|
|
326
|
+
});
|
|
327
|
+
} else {
|
|
328
|
+
resolve(false);
|
|
329
|
+
}
|
|
330
|
+
}).on('error', () => {
|
|
331
|
+
resolve(false);
|
|
332
|
+
});
|
|
333
|
+
} catch (e) {
|
|
334
|
+
resolve(false);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Helper to fetch HTML content of a URL, following redirects
|
|
340
|
+
function getHtml(urlStr, redirectCount = 0) {
|
|
341
|
+
return new Promise((resolve) => {
|
|
342
|
+
if (redirectCount > 5) {
|
|
343
|
+
resolve('');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
https.get(urlStr, (response) => {
|
|
349
|
+
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
350
|
+
let redirectUrl = response.headers.location;
|
|
351
|
+
if (redirectUrl.startsWith('/')) {
|
|
352
|
+
const parsed = new URL(urlStr);
|
|
353
|
+
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
|
|
354
|
+
}
|
|
355
|
+
resolve(getHtml(redirectUrl, redirectCount + 1));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (response.statusCode !== 200) {
|
|
360
|
+
resolve('');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let data = '';
|
|
365
|
+
response.on('data', chunk => { data += chunk; });
|
|
366
|
+
response.on('end', () => { resolve(data); });
|
|
367
|
+
}).on('error', () => {
|
|
368
|
+
resolve('');
|
|
369
|
+
});
|
|
370
|
+
} catch (e) {
|
|
371
|
+
resolve('');
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Helper to extract absolute icon URL from HTML
|
|
377
|
+
function extractIconUrl(html, baseUrl) {
|
|
378
|
+
// Look for rel="icon", rel="shortcut icon", or rel="apple-touch-icon"
|
|
379
|
+
const matches = html.match(/<link[^>]*rel=["'](icon|shortcut icon|apple-touch-icon)["'][^>]*>/gi);
|
|
380
|
+
if (matches) {
|
|
381
|
+
for (const link of matches) {
|
|
382
|
+
const hrefMatch = link.match(/href=["']([^"']+)["']/i);
|
|
383
|
+
if (hrefMatch) {
|
|
384
|
+
let href = hrefMatch[1];
|
|
385
|
+
// Resolve absolute URL
|
|
386
|
+
if (!/^https?:\/\//i.test(href)) {
|
|
387
|
+
try {
|
|
388
|
+
const parsed = new URL(baseUrl);
|
|
389
|
+
if (href.startsWith('//')) {
|
|
390
|
+
href = parsed.protocol + href;
|
|
391
|
+
} else if (href.startsWith('/')) {
|
|
392
|
+
href = `${parsed.protocol}//${parsed.host}${href}`;
|
|
393
|
+
} else {
|
|
394
|
+
const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : path.dirname(parsed.pathname) + '/';
|
|
395
|
+
href = `${parsed.protocol}//${parsed.host}${pathname}${href}`;
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return href;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Dynamic favicon downloader flow supporting Google API, direct scraping, and default fallback
|
|
409
|
+
async function downloadFaviconFlow(targetUrl, domain, outputPath) {
|
|
410
|
+
startSpinner('Fetching website favicon...');
|
|
411
|
+
// 1. Try Google Favicon Service (follows redirects)
|
|
412
|
+
const googleUrl = `https://www.google.com/s2/favicons?sz=128&domain=${domain}`;
|
|
413
|
+
let success = await downloadUrl(googleUrl, outputPath);
|
|
414
|
+
if (success) {
|
|
415
|
+
// Verify it didn't download a tiny 1x1 default spacer/404 image (file size less than 1KB)
|
|
416
|
+
try {
|
|
417
|
+
const stats = fs.statSync(outputPath);
|
|
418
|
+
if (stats.size > 1000) {
|
|
419
|
+
stopSpinner(true, 'Favicon downloaded successfully!');
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
fs.unlinkSync(outputPath); // Delete generic tiny favicon
|
|
423
|
+
} catch (e) {}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 2. Try scraping target URL directly (great for local/private servers)
|
|
427
|
+
try {
|
|
428
|
+
const html = await getHtml(targetUrl);
|
|
429
|
+
if (html) {
|
|
430
|
+
const iconUrl = extractIconUrl(html, targetUrl);
|
|
431
|
+
if (iconUrl) {
|
|
432
|
+
success = await downloadUrl(iconUrl, outputPath);
|
|
433
|
+
if (success) {
|
|
434
|
+
stopSpinner(true, 'Favicon scraped and downloaded successfully!');
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (e) {
|
|
440
|
+
// Ignore scraper errors
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 3. Try default fallback path: https://domain/favicon.ico
|
|
444
|
+
try {
|
|
445
|
+
const parsed = new URL(targetUrl);
|
|
446
|
+
const fallbackUrl = `${parsed.protocol}//${parsed.host}/favicon.ico`;
|
|
447
|
+
success = await downloadUrl(fallbackUrl, outputPath);
|
|
448
|
+
if (success) {
|
|
449
|
+
stopSpinner(true, 'Default favicon.ico downloaded successfully!');
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
// Ignore fallback errors
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
stopSpinner(false, 'Could not download favicon. Fallback to default Electron icon.');
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Print startup header
|
|
461
|
+
function printHeader() {
|
|
462
|
+
console.clear();
|
|
463
|
+
console.log(`${colors.cyan}${colors.bright}`);
|
|
464
|
+
console.log(` _ _ _ __ `);
|
|
465
|
+
console.log(` __| | ___ ___| |_( )/ _|_ _ `);
|
|
466
|
+
console.log(` / _\` |/ _ \\/ __| |/ /| |_| | | |`);
|
|
467
|
+
console.log(` | (_| | __/\\__ \\ <| _| |_| |`);
|
|
468
|
+
console.log(` \\__,_|\\___||___/_|\\_\\\\_| \\__, |`);
|
|
469
|
+
console.log(` |___/ `);
|
|
470
|
+
console.log(` Web to Linux Desktop App Packager${colors.reset}`);
|
|
471
|
+
console.log(`${colors.dim}--------------------------------------------------${colors.reset}\n`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Verification checks
|
|
475
|
+
function runPreflightChecks() {
|
|
476
|
+
startSpinner('Running system pre-flight checks...');
|
|
477
|
+
|
|
478
|
+
// 1. Check Node.js
|
|
479
|
+
try {
|
|
480
|
+
execSync('node -v', { stdio: 'ignore' });
|
|
481
|
+
} catch (e) {
|
|
482
|
+
stopSpinner(false, 'Pre-flight checks failed.');
|
|
483
|
+
log.error('Node.js is not installed or not in PATH.');
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 2. Check NPM
|
|
488
|
+
try {
|
|
489
|
+
execSync('npm -v', { stdio: 'ignore' });
|
|
490
|
+
} catch (e) {
|
|
491
|
+
stopSpinner(false, 'Pre-flight checks failed.');
|
|
492
|
+
log.error('NPM is not installed or not in PATH.');
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 3. Check update-desktop-database
|
|
497
|
+
let hasDesktopUtils = true;
|
|
498
|
+
try {
|
|
499
|
+
execSync('command -v update-desktop-database', { stdio: 'ignore' });
|
|
500
|
+
} catch (e) {
|
|
501
|
+
hasDesktopUtils = false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
stopSpinner(true, 'Pre-flight checks passed successfully!\n');
|
|
505
|
+
if (!hasDesktopUtils) {
|
|
506
|
+
log.warn('update-desktop-database not found. Shortcuts might not register instantly.');
|
|
507
|
+
}
|
|
508
|
+
return { hasDesktopUtils };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Core creation flow (formerly main)
|
|
512
|
+
async function createFlow(hasDesktopUtils) {
|
|
513
|
+
console.clear();
|
|
514
|
+
printHeader();
|
|
515
|
+
log.bold('--- Create a New Desktop Application ---');
|
|
516
|
+
console.log('');
|
|
517
|
+
|
|
518
|
+
// Variables declared outside block for subsequent Nativefier compilation
|
|
519
|
+
let targetUrl = '';
|
|
520
|
+
let appName = '';
|
|
521
|
+
let iconPath = '';
|
|
522
|
+
let persistSession = true;
|
|
523
|
+
let internalUrlsRegex = '';
|
|
524
|
+
let width = '1200';
|
|
525
|
+
let height = '800';
|
|
526
|
+
let installPath = '';
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
// 1. Prompt for URL
|
|
530
|
+
let targetUrlInput = '';
|
|
531
|
+
while (!targetUrlInput) {
|
|
532
|
+
targetUrlInput = await ask('Enter Website URL (e.g., https://nextcloud.com)');
|
|
533
|
+
if (!targetUrlInput) {
|
|
534
|
+
log.error('URL cannot be empty. Please try again.');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Parse details for defaults
|
|
539
|
+
const urlDetails = parseUrlDetails(targetUrlInput);
|
|
540
|
+
targetUrl = urlDetails.cleanUrl;
|
|
541
|
+
|
|
542
|
+
log.info(`Parsed Target URL: ${colors.bright}${targetUrl}${colors.reset}`);
|
|
543
|
+
|
|
544
|
+
// 2. Prompt for Name
|
|
545
|
+
appName = await ask('Enter Application Name', urlDetails.nameDefault);
|
|
546
|
+
|
|
547
|
+
// 3. Prompt for Icon
|
|
548
|
+
const useFavicon = await askYesNo('Download and use website favicon as app icon?', true);
|
|
549
|
+
|
|
550
|
+
if (useFavicon) {
|
|
551
|
+
const tempIconDir = path.join(os.tmpdir(), 'deskify');
|
|
552
|
+
if (!fs.existsSync(tempIconDir)) {
|
|
553
|
+
fs.mkdirSync(tempIconDir, { recursive: true });
|
|
554
|
+
}
|
|
555
|
+
const tempIconPath = path.join(tempIconDir, `${appName.toLowerCase()}_favicon.png`);
|
|
556
|
+
|
|
557
|
+
const success = await downloadFaviconFlow(targetUrl, urlDetails.hostname, tempIconPath);
|
|
558
|
+
|
|
559
|
+
if (success) {
|
|
560
|
+
iconPath = tempIconPath;
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
const localIcon = await ask('Enter local PNG icon file path (leave empty for none)');
|
|
564
|
+
if (localIcon) {
|
|
565
|
+
const expandedIconPath = expandHome(localIcon);
|
|
566
|
+
if (fs.existsSync(expandedIconPath)) {
|
|
567
|
+
iconPath = expandedIconPath;
|
|
568
|
+
} else {
|
|
569
|
+
log.warn(`File "${localIcon}" not found. Fallback to default Electron icon.`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 4. Prompt for Persist option
|
|
575
|
+
persistSession = await askYesNo('Persist session cookies, local storage, and cache?', true);
|
|
576
|
+
|
|
577
|
+
// 5. Prompt for Internal URLs Regex
|
|
578
|
+
internalUrlsRegex = await ask('Internal URL pattern regex', urlDetails.regexDefault);
|
|
579
|
+
|
|
580
|
+
// 6. Window Dimensions
|
|
581
|
+
width = await ask('Window width (pixels)', '1200');
|
|
582
|
+
height = await ask('Window height (pixels)', '800');
|
|
583
|
+
|
|
584
|
+
// 7. Installation Path
|
|
585
|
+
const installPathInput = await ask('Installation directory', '~/Apps');
|
|
586
|
+
installPath = expandHome(installPathInput);
|
|
587
|
+
|
|
588
|
+
// Summary before execution
|
|
589
|
+
console.log(`\n${colors.bright}--- Build Configuration Summary ---${colors.reset}`);
|
|
590
|
+
console.log(`App Name: ${colors.green}${appName}${colors.reset}`);
|
|
591
|
+
console.log(`Target URL: ${targetUrl}`);
|
|
592
|
+
console.log(`Persist: ${persistSession ? 'Yes' : 'No'}`);
|
|
593
|
+
console.log(`Internal URLs: ${internalUrlsRegex}`);
|
|
594
|
+
console.log(`Dimensions: ${width}x${height}`);
|
|
595
|
+
console.log(`Install Dir: ${installPath}`);
|
|
596
|
+
if (iconPath) {
|
|
597
|
+
console.log(`Icon Path: ${iconPath}`);
|
|
598
|
+
}
|
|
599
|
+
console.log(`${colors.dim}------------------------------------${colors.reset}\n`);
|
|
600
|
+
|
|
601
|
+
const proceed = await askYesNo('Proceed with build?', true);
|
|
602
|
+
if (!proceed) {
|
|
603
|
+
log.warn('Build aborted by user.');
|
|
604
|
+
return { success: false, cancelled: true };
|
|
605
|
+
}
|
|
606
|
+
} catch (e) {
|
|
607
|
+
if (e.message === 'ESC') {
|
|
608
|
+
log.warn('\nBuild cancelled by user (ESC pressed). Returning to Main Menu...');
|
|
609
|
+
return { success: false, cancelled: true };
|
|
610
|
+
}
|
|
611
|
+
throw e;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Ensure install path exists
|
|
615
|
+
if (!fs.existsSync(installPath)) {
|
|
616
|
+
log.info(`Creating installation directory: ${installPath}`);
|
|
617
|
+
fs.mkdirSync(installPath, { recursive: true });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 8. Build using Nativefier
|
|
621
|
+
log.bold('\n[1/4] Running Nativefier compiler...');
|
|
622
|
+
|
|
623
|
+
// Construct arguments matching Nativefier's positional syntax:
|
|
624
|
+
// nativefier <targetUrl> [outputDirectory] [options]
|
|
625
|
+
const args = [
|
|
626
|
+
'nativefier',
|
|
627
|
+
targetUrl,
|
|
628
|
+
installPath,
|
|
629
|
+
'--name', appName,
|
|
630
|
+
'--width', `${width}px`,
|
|
631
|
+
'--height', `${height}px`,
|
|
632
|
+
'--internal-urls', internalUrlsRegex
|
|
633
|
+
];
|
|
634
|
+
|
|
635
|
+
if (persistSession) {
|
|
636
|
+
args.push('--persist');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (iconPath) {
|
|
640
|
+
args.push('--icon', iconPath);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
log.info(`Running command: npx ${args.join(' ')}`);
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
// Run npx nativefier synchronously, streaming the output to the console
|
|
647
|
+
execSync(`npx -y ${args.map(a => `"${a}"`).join(' ')}`, { stdio: 'inherit' });
|
|
648
|
+
log.success('Nativefier compile completed!');
|
|
649
|
+
} catch (error) {
|
|
650
|
+
log.error('Nativefier failed to compile the application.');
|
|
651
|
+
console.error(error.message);
|
|
652
|
+
return { success: false };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 9. Find generated directory
|
|
656
|
+
log.bold('\n[2/4] Locating generated application folder...');
|
|
657
|
+
|
|
658
|
+
// Search for the folder in the install directory. Nativefier outputs folder names like Name-linux-x64
|
|
659
|
+
const files = fs.readdirSync(installPath);
|
|
660
|
+
// Find folder matching appname-linux-x64 (case insensitive search)
|
|
661
|
+
const targetDirName = files.find(f => {
|
|
662
|
+
const isDir = fs.statSync(path.join(installPath, f)).isDirectory();
|
|
663
|
+
return isDir && f.toLowerCase().includes('linux-x64') && f.toLowerCase().includes(appName.toLowerCase());
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
if (!targetDirName) {
|
|
667
|
+
log.error(`Could not locate the generated application directory in ${installPath}.`);
|
|
668
|
+
log.info(`Available folders: ${files.join(', ')}`);
|
|
669
|
+
return { success: false };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const appFolder = path.join(installPath, targetDirName);
|
|
673
|
+
log.success(`Located folder at: ${appFolder}`);
|
|
674
|
+
|
|
675
|
+
// Copy icon.png if we downloaded it and nativefier didn't put it in the root
|
|
676
|
+
const destIconPath = path.join(appFolder, 'icon.png');
|
|
677
|
+
if (iconPath && !fs.existsSync(destIconPath)) {
|
|
678
|
+
try {
|
|
679
|
+
fs.copyFileSync(iconPath, destIconPath);
|
|
680
|
+
log.info(`Copied application icon to: ${destIconPath}`);
|
|
681
|
+
} catch (e) {
|
|
682
|
+
log.warn(`Could not copy icon to root: ${e.message}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Adjust permissions on the app directory and icon file to ensure GNOME can load them
|
|
687
|
+
try {
|
|
688
|
+
fs.chmodSync(appFolder, 0o755);
|
|
689
|
+
if (fs.existsSync(destIconPath)) {
|
|
690
|
+
fs.chmodSync(destIconPath, 0o644);
|
|
691
|
+
|
|
692
|
+
// Auto-resize extremely large custom icons to 256x256 using convert (ImageMagick)
|
|
693
|
+
try {
|
|
694
|
+
execSync('command -v convert', { stdio: 'ignore' });
|
|
695
|
+
log.info('Optimizing icon resolution (resizing to 256x256)...');
|
|
696
|
+
execSync(`convert "${destIconPath}" -resize 256x256 "${destIconPath}"`, { stdio: 'ignore' });
|
|
697
|
+
log.success('Icon optimized and resized successfully!');
|
|
698
|
+
} catch (e) {
|
|
699
|
+
// Skip optimization if convert is not installed
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} catch (e) {
|
|
703
|
+
log.warn(`Could not adjust permissions on folder/icon: ${e.message}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 10. Dynamic StartupWMClass resolver by reading package.json
|
|
707
|
+
log.bold('\n[3/4] Resolving Linux window class (StartupWMClass)...');
|
|
708
|
+
|
|
709
|
+
const pkgPath = path.join(appFolder, 'resources', 'app', 'package.json');
|
|
710
|
+
let startupWMClass = appName; // Default fallback
|
|
711
|
+
let packageJsonName = '';
|
|
712
|
+
|
|
713
|
+
if (fs.existsSync(pkgPath)) {
|
|
714
|
+
try {
|
|
715
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
716
|
+
packageJsonName = pkg.name; // e.g. nextcloud-nativefier-7cf0d6
|
|
717
|
+
log.info(`Found internal app ID in package.json: ${colors.bright}${packageJsonName}${colors.reset}`);
|
|
718
|
+
|
|
719
|
+
// Use the exact packageJsonName directly since Electron's window class instance name on Linux
|
|
720
|
+
// always matches the lowercase 'name' field in package.json.
|
|
721
|
+
startupWMClass = packageJsonName;
|
|
722
|
+
log.success(`Calculated StartupWMClass: ${colors.bright}${startupWMClass}${colors.reset}`);
|
|
723
|
+
} catch (e) {
|
|
724
|
+
log.warn(`Could not parse internal package.json: ${e.message}. Using app name fallback.`);
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
log.warn('Could not find internal package.json. StartupWMClass might be inaccurate.');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// 11. Fix chrome-sandbox permissions via sudo
|
|
731
|
+
log.bold('\n[4/4] Setting up sandbox execution permissions (requires sudo)...');
|
|
732
|
+
const chromeSandboxPath = path.join(appFolder, 'chrome-sandbox');
|
|
733
|
+
|
|
734
|
+
if (fs.existsSync(chromeSandboxPath)) {
|
|
735
|
+
log.info(`Applying root ownership and SUID bit to: ${chromeSandboxPath}`);
|
|
736
|
+
try {
|
|
737
|
+
// Execute permissions fix. stdio: 'inherit' displays password prompt nicely
|
|
738
|
+
execSync(`sudo chown root:root "${chromeSandboxPath}" && sudo chmod 4755 "${chromeSandboxPath}"`, { stdio: 'inherit' });
|
|
739
|
+
log.success('Sandbox permissions applied successfully!');
|
|
740
|
+
} catch (e) {
|
|
741
|
+
log.error('Failed to configure chrome-sandbox permissions.');
|
|
742
|
+
log.info('You may need to run this command manually:');
|
|
743
|
+
console.log(` sudo chown root:root "${chromeSandboxPath}" && sudo chmod 4755 "${chromeSandboxPath}"`);
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
log.warn('chrome-sandbox file not found in build directory. Skipping permission adjustment.');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// 12. Create Desktop File
|
|
750
|
+
log.bold('\n[5/5] Generating Desktop Shortcut launcher...');
|
|
751
|
+
|
|
752
|
+
const desktopEntriesDir = expandHome('~/.local/share/applications');
|
|
753
|
+
if (!fs.existsSync(desktopEntriesDir)) {
|
|
754
|
+
fs.mkdirSync(desktopEntriesDir, { recursive: true });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const desktopFilePath = path.join(desktopEntriesDir, `${appName.replace(/\s+/g, '')}.desktop`);
|
|
758
|
+
|
|
759
|
+
// Locate binary executable (usually named after the AppName, case-sensitive)
|
|
760
|
+
const execPath = path.join(appFolder, appName);
|
|
761
|
+
// Locate icon path. Use root icon.png if exists, otherwise fallback to app folder
|
|
762
|
+
const finalIconPath = fs.existsSync(destIconPath) ? destIconPath : (iconPath || 'electron');
|
|
763
|
+
|
|
764
|
+
const desktopContent = `[Desktop Entry]
|
|
765
|
+
Name=${appName}
|
|
766
|
+
Comment=${appName} Web Desktop App
|
|
767
|
+
Exec=${execPath}
|
|
768
|
+
Icon=${finalIconPath}
|
|
769
|
+
Terminal=false
|
|
770
|
+
Type=Application
|
|
771
|
+
Categories=Network;WebBrowser;Application;
|
|
772
|
+
StartupWMClass=${startupWMClass}
|
|
773
|
+
X-Generated-By=deskify
|
|
774
|
+
`;
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
fs.writeFileSync(desktopFilePath, desktopContent, 'utf8');
|
|
778
|
+
fs.chmodSync(desktopFilePath, 0o755); // make executable
|
|
779
|
+
log.success(`Shortcut created successfully at: ${desktopFilePath}`);
|
|
780
|
+
|
|
781
|
+
// Update desktop registry database
|
|
782
|
+
if (hasDesktopUtils) {
|
|
783
|
+
log.info('Updating local desktop shortcut registry database...');
|
|
784
|
+
execSync(`update-desktop-database "${desktopEntriesDir}"`, { stdio: 'ignore' });
|
|
785
|
+
log.success('Desktop shortcuts database updated!');
|
|
786
|
+
}
|
|
787
|
+
} catch (e) {
|
|
788
|
+
log.error(`Failed to write desktop launcher: ${e.message}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Clean up temporary icons if any
|
|
792
|
+
if (useFavicon && iconPath && fs.existsSync(iconPath)) {
|
|
793
|
+
try {
|
|
794
|
+
fs.unlinkSync(iconPath);
|
|
795
|
+
} catch (e) {}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Success summary
|
|
799
|
+
console.log(`\n${colors.green}${colors.bright}==================================================${colors.reset}`);
|
|
800
|
+
console.log(`${colors.green}${colors.bright} š CONGRATULATIONS! APP CREATED SUCCESSFULLY š${colors.reset}`);
|
|
801
|
+
console.log(`${colors.green}${colors.bright}==================================================${colors.reset}`);
|
|
802
|
+
console.log(`App Name: ${colors.bright}${appName}${colors.reset}`);
|
|
803
|
+
console.log(`Folder Path: ${colors.cyan}${appFolder}${colors.reset}`);
|
|
804
|
+
console.log(`Shortcut Path: ${colors.cyan}${desktopFilePath}${colors.reset}`);
|
|
805
|
+
console.log(`WM_Class ID: ${colors.magenta}${startupWMClass}${colors.reset}`);
|
|
806
|
+
console.log(`\nYou can now search for "${appName}" in your Linux App menu! š\n`);
|
|
807
|
+
return { success: true };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Interactive Uninstallation flow
|
|
811
|
+
async function uninstallFlow(hasDesktopUtils) {
|
|
812
|
+
console.clear();
|
|
813
|
+
printHeader();
|
|
814
|
+
log.bold('--- Uninstall an Existing Application ---');
|
|
815
|
+
console.log('');
|
|
816
|
+
const desktopEntriesDir = expandHome('~/.local/share/applications');
|
|
817
|
+
|
|
818
|
+
if (!fs.existsSync(desktopEntriesDir)) {
|
|
819
|
+
log.warn('No local application shortcuts directory found.');
|
|
820
|
+
return { success: false };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const files = fs.readdirSync(desktopEntriesDir);
|
|
824
|
+
const deskifyApps = [];
|
|
825
|
+
|
|
826
|
+
for (const file of files) {
|
|
827
|
+
if (file.endsWith('.desktop')) {
|
|
828
|
+
const filePath = path.join(desktopEntriesDir, file);
|
|
829
|
+
try {
|
|
830
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
831
|
+
if (content.includes('X-Generated-By=deskify')) {
|
|
832
|
+
// Parse metadata
|
|
833
|
+
const nameMatch = content.match(/^Name=(.+)$/m);
|
|
834
|
+
const execMatch = content.match(/^Exec=(.+)$/m);
|
|
835
|
+
const appName = nameMatch ? nameMatch[1].trim() : file.replace('.desktop', '');
|
|
836
|
+
let execPath = execMatch ? execMatch[1].trim() : '';
|
|
837
|
+
|
|
838
|
+
// Strip wrapping quotes from Exec path if present
|
|
839
|
+
if (execPath.startsWith('"') && execPath.endsWith('"')) {
|
|
840
|
+
execPath = execPath.slice(1, -1);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let appFolder = '';
|
|
844
|
+
if (execPath) {
|
|
845
|
+
appFolder = path.dirname(execPath);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
deskifyApps.push({
|
|
849
|
+
file,
|
|
850
|
+
filePath,
|
|
851
|
+
appName,
|
|
852
|
+
appFolder
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
} catch (e) {
|
|
856
|
+
// Skip files that can't be read
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (deskifyApps.length === 0) {
|
|
862
|
+
log.info('No applications generated by deskify were found.');
|
|
863
|
+
return { success: false };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const appOptions = deskifyApps.map(app => `${app.appName} ${colors.dim}(Shortcut: ${app.file})${colors.reset}`);
|
|
867
|
+
appOptions.push('Cancel');
|
|
868
|
+
|
|
869
|
+
const selectedIdx = await selectOption('Select an application to uninstall', appOptions, appOptions.length - 1);
|
|
870
|
+
|
|
871
|
+
if (selectedIdx === appOptions.length - 1) {
|
|
872
|
+
log.info('Uninstallation cancelled.');
|
|
873
|
+
return { success: false, cancelled: true };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const targetApp = deskifyApps[selectedIdx];
|
|
877
|
+
|
|
878
|
+
log.warn(`\nAre you sure you want to permanently delete ${colors.red}${targetApp.appName}${colors.reset}?`);
|
|
879
|
+
log.warn(`This will delete:`);
|
|
880
|
+
log.warn(` - Shortcut: ${targetApp.filePath}`);
|
|
881
|
+
if (targetApp.appFolder && fs.existsSync(targetApp.appFolder)) {
|
|
882
|
+
log.warn(` - App Folder: ${targetApp.appFolder}`);
|
|
883
|
+
}
|
|
884
|
+
console.log('');
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
const confirm = await askYesNo('Confirm uninstallation?', false);
|
|
888
|
+
if (!confirm) {
|
|
889
|
+
log.info('Uninstallation cancelled.');
|
|
890
|
+
return { success: false, cancelled: true };
|
|
891
|
+
}
|
|
892
|
+
} catch (e) {
|
|
893
|
+
if (e.message === 'ESC') {
|
|
894
|
+
log.warn('\nUninstallation cancelled by user (ESC pressed). Returning to Main Menu...');
|
|
895
|
+
return { success: false, cancelled: true };
|
|
896
|
+
}
|
|
897
|
+
throw e;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 1. Delete .desktop entry file
|
|
901
|
+
try {
|
|
902
|
+
if (fs.existsSync(targetApp.filePath)) {
|
|
903
|
+
fs.unlinkSync(targetApp.filePath);
|
|
904
|
+
log.success(`Deleted shortcut file: ${targetApp.file}`);
|
|
905
|
+
}
|
|
906
|
+
} catch (e) {
|
|
907
|
+
log.error(`Failed to delete shortcut file: ${e.message}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// 2. Delete the application installation directory recursively
|
|
911
|
+
if (targetApp.appFolder && fs.existsSync(targetApp.appFolder)) {
|
|
912
|
+
log.info(`Deleting application files at: ${targetApp.appFolder}...`);
|
|
913
|
+
try {
|
|
914
|
+
fs.rmSync(targetApp.appFolder, { recursive: true, force: true });
|
|
915
|
+
log.success('Application directory deleted successfully!');
|
|
916
|
+
} catch (e) {
|
|
917
|
+
log.error(`Failed to delete directory: ${e.message}`);
|
|
918
|
+
log.info('You may need to run this command manually as root/sudo:');
|
|
919
|
+
console.log(` sudo rm -rf "${targetApp.appFolder}"`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 3. Update local desktop registry database
|
|
924
|
+
if (hasDesktopUtils) {
|
|
925
|
+
log.info('Updating desktop shortcuts registry database...');
|
|
926
|
+
try {
|
|
927
|
+
execSync(`update-desktop-database "${desktopEntriesDir}"`, { stdio: 'ignore' });
|
|
928
|
+
log.success('Desktop shortcuts database updated!');
|
|
929
|
+
} catch (e) {}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
log.success(`\nš ${targetApp.appName} has been uninstalled successfully! šļø\n`);
|
|
933
|
+
return { success: true };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Helper to wait for enter key, allowing ESC to also go back
|
|
937
|
+
async function waitForKey() {
|
|
938
|
+
try {
|
|
939
|
+
await ask('Press Enter to return to the Main Menu...');
|
|
940
|
+
} catch (e) {
|
|
941
|
+
// Silently consume ESC or any other rejection to return to main menu
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Main execution function
|
|
946
|
+
async function main() {
|
|
947
|
+
while (true) {
|
|
948
|
+
printHeader();
|
|
949
|
+
const { hasDesktopUtils } = runPreflightChecks();
|
|
950
|
+
|
|
951
|
+
const options = [
|
|
952
|
+
'Create a new Desktop App',
|
|
953
|
+
'Uninstall an existing App',
|
|
954
|
+
'Exit'
|
|
955
|
+
];
|
|
956
|
+
|
|
957
|
+
const selectedIdx = await selectOption('Deskify Main Menu', options, 0);
|
|
958
|
+
|
|
959
|
+
if (selectedIdx === 0) {
|
|
960
|
+
try {
|
|
961
|
+
const result = await createFlow(hasDesktopUtils);
|
|
962
|
+
if (result && result.success) {
|
|
963
|
+
await waitForKey();
|
|
964
|
+
} else if (result && result.cancelled) {
|
|
965
|
+
// Do not wait, return directly to main menu
|
|
966
|
+
} else {
|
|
967
|
+
await waitForKey();
|
|
968
|
+
}
|
|
969
|
+
} catch (e) {
|
|
970
|
+
log.error('An error occurred during application creation:');
|
|
971
|
+
console.error(e);
|
|
972
|
+
await waitForKey();
|
|
973
|
+
}
|
|
974
|
+
} else if (selectedIdx === 1) {
|
|
975
|
+
try {
|
|
976
|
+
const result = await uninstallFlow(hasDesktopUtils);
|
|
977
|
+
if (result && result.success) {
|
|
978
|
+
await waitForKey();
|
|
979
|
+
} else if (result && result.cancelled) {
|
|
980
|
+
// Do not wait, return directly to main menu
|
|
981
|
+
} else {
|
|
982
|
+
await waitForKey();
|
|
983
|
+
}
|
|
984
|
+
} catch (e) {
|
|
985
|
+
log.error('An error occurred during application uninstallation:');
|
|
986
|
+
console.error(e);
|
|
987
|
+
await waitForKey();
|
|
988
|
+
}
|
|
989
|
+
} else if (selectedIdx === 2) {
|
|
990
|
+
log.info('Thank you for using Deskify! Goodbye! š');
|
|
991
|
+
process.exit(0);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Run main program
|
|
997
|
+
main().catch((err) => {
|
|
998
|
+
log.error('An unexpected error occurred during execution:');
|
|
999
|
+
console.error(err);
|
|
1000
|
+
process.exit(1);
|
|
1001
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deskify-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An interactive, zero-dependency CLI tool to turn web pages into Linux desktop applications using Nativefier.",
|
|
5
|
+
"main": "deskify.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"deskify": "./deskify.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node deskify.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"nativefier",
|
|
14
|
+
"linux",
|
|
15
|
+
"desktop-entry",
|
|
16
|
+
"electron",
|
|
17
|
+
"cli",
|
|
18
|
+
"automation",
|
|
19
|
+
"shortcut"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=14.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|