deskify-cli 1.0.0 → 1.0.1

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/deskify.js CHANGED
@@ -2,462 +2,18 @@
2
2
 
3
3
  /**
4
4
  * deskify.js
5
- * An interactive, zero-dependency CLI tool to turn web pages into Linux desktop applications using Nativefier.
5
+ * Entry point for the deskify CLI.
6
6
  * Author: Antigravity AI
7
7
  * License: MIT
8
8
  */
9
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');
10
+ const { execSync } = require('child_process');
11
+ const { colors, log } = require('./src/utils/colors');
12
+ const { selectOption, waitForKey } = require('./src/utils/prompts');
13
+ const { startSpinner, stopSpinner } = require('./src/utils/spinner');
14
+ const { createFlow } = require('./src/flows/create');
15
+ const { uninstallFlow } = require('./src/flows/uninstall');
16
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
17
  function printHeader() {
462
18
  console.clear();
463
19
  console.log(`${colors.cyan}${colors.bright}`);
@@ -471,11 +27,9 @@ function printHeader() {
471
27
  console.log(`${colors.dim}--------------------------------------------------${colors.reset}\n`);
472
28
  }
473
29
 
474
- // Verification checks
475
30
  function runPreflightChecks() {
476
31
  startSpinner('Running system pre-flight checks...');
477
32
 
478
- // 1. Check Node.js
479
33
  try {
480
34
  execSync('node -v', { stdio: 'ignore' });
481
35
  } catch (e) {
@@ -484,7 +38,6 @@ function runPreflightChecks() {
484
38
  process.exit(1);
485
39
  }
486
40
 
487
- // 2. Check NPM
488
41
  try {
489
42
  execSync('npm -v', { stdio: 'ignore' });
490
43
  } catch (e) {
@@ -493,7 +46,6 @@ function runPreflightChecks() {
493
46
  process.exit(1);
494
47
  }
495
48
 
496
- // 3. Check update-desktop-database
497
49
  let hasDesktopUtils = true;
498
50
  try {
499
51
  execSync('command -v update-desktop-database', { stdio: 'ignore' });
@@ -508,441 +60,6 @@ function runPreflightChecks() {
508
60
  return { hasDesktopUtils };
509
61
  }
510
62
 
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
63
  async function main() {
947
64
  while (true) {
948
65
  printHeader();
@@ -962,7 +79,7 @@ async function main() {
962
79
  if (result && result.success) {
963
80
  await waitForKey();
964
81
  } else if (result && result.cancelled) {
965
- // Do not wait, return directly to main menu
82
+ // Return to main menu directly
966
83
  } else {
967
84
  await waitForKey();
968
85
  }
@@ -977,7 +94,7 @@ async function main() {
977
94
  if (result && result.success) {
978
95
  await waitForKey();
979
96
  } else if (result && result.cancelled) {
980
- // Do not wait, return directly to main menu
97
+ // Return to main menu directly
981
98
  } else {
982
99
  await waitForKey();
983
100
  }
@@ -993,7 +110,6 @@ async function main() {
993
110
  }
994
111
  }
995
112
 
996
- // Run main program
997
113
  main().catch((err) => {
998
114
  log.error('An unexpected error occurred during execution:');
999
115
  console.error(err);