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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/deskify.js +1001 -0
  4. 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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Node.js Version](https://img.shields.io/badge/node-%3E%3D14.0.0-blue.svg)](https://nodejs.org/)
5
+ [![Platform](https://img.shields.io/badge/platform-Linux-orange.svg)](#)
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
+ }