pake-cli 3.11.4 โ†’ 3.11.5

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/README.md CHANGED
@@ -177,7 +177,7 @@ First-time packaging requires environment setup and may be slower, subsequent bu
177
177
 
178
178
  ## Development
179
179
 
180
- Requires Rust `>=1.85` and Node `>=22`. For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
180
+ Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=18` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
181
181
 
182
182
  ```bash
183
183
  # Install dependencies
@@ -204,6 +204,6 @@ Pake's development can not be without these Hackers. They contributed a lot of c
204
204
 
205
205
  - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star.
206
206
  - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model.
207
- - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them <a href="https://miaoyan.app/cats.html?name=Pake" target="_blank">canned food ๐Ÿฅฉ</a>.
207
+ - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them <a href="https://cats.tw93.fun?name=Pake" target="_blank">canned food ๐Ÿฅฉ</a>.
208
208
 
209
- <a href="https://miaoyan.app/cats.html?name=Pake"><img src="https://cdn.jsdelivr.net/gh/tw93/MiaoYan@main/assets/sponsors.svg" width="1000" loading="lazy" /></a>
209
+ <a href="https://cats.tw93.fun?name=Pake"><img src="https://cdn.jsdelivr.net/gh/tw93/sponsors@main/assets/sponsors.svg" width="1000" loading="lazy" /></a>
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import log from 'loglevel';
3
+ import chalk from 'chalk';
3
4
  import updateNotifier from 'update-notifier';
4
5
  import path from 'path';
5
6
  import fsExtra from 'fs-extra';
6
7
  import { fileURLToPath } from 'url';
7
- import chalk from 'chalk';
8
8
  import prompts from 'prompts';
9
9
  import os from 'os';
10
10
  import { execa, execaSync } from 'execa';
@@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander';
20
20
  import fs$1 from 'fs';
21
21
 
22
22
  var name = "pake-cli";
23
- var version = "3.11.4";
23
+ var version = "3.11.5";
24
24
  var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚";
25
25
  var engines = {
26
26
  node: ">=18.0.0"
@@ -436,6 +436,41 @@ function generateIdentifierSafeName(name) {
436
436
  return cleaned;
437
437
  }
438
438
 
439
+ /**
440
+ * Pure transform from CLI options to the window-config slice that gets
441
+ * merged into pake.json. Exposed for snapshot testing so option drift
442
+ * (e.g. a new flag added in cli-program.ts but forgotten here) is caught.
443
+ *
444
+ * Keep this function side-effect free.
445
+ */
446
+ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) {
447
+ const platformHideOnClose = options.hideOnClose ?? platform === 'darwin';
448
+ return {
449
+ width: options.width,
450
+ height: options.height,
451
+ fullscreen: options.fullscreen,
452
+ maximize: options.maximize,
453
+ resizable: options.resizable ?? true,
454
+ hide_title_bar: options.hideTitleBar,
455
+ activation_shortcut: options.activationShortcut,
456
+ always_on_top: options.alwaysOnTop,
457
+ dark_mode: options.darkMode,
458
+ disabled_web_shortcuts: options.disabledWebShortcuts,
459
+ hide_on_close: platformHideOnClose,
460
+ incognito: options.incognito,
461
+ title: options.title,
462
+ enable_wasm: options.wasm,
463
+ enable_drag_drop: options.enableDragDrop,
464
+ start_to_tray: options.startToTray && options.showSystemTray,
465
+ force_internal_navigation: options.forceInternalNavigation,
466
+ internal_url_regex: options.internalUrlRegex,
467
+ zoom: options.zoom,
468
+ min_width: options.minWidth,
469
+ min_height: options.minHeight,
470
+ ignore_certificate_errors: options.ignoreCertificateErrors,
471
+ new_window: options.newWindow,
472
+ };
473
+ }
439
474
  function asSupportedPlatform(platform) {
440
475
  if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') {
441
476
  throw new Error(`Pake only supports win32, darwin, and linux; detected '${platform}'.`);
@@ -691,34 +726,9 @@ async function writeAllConfigs(tauriConf, platform) {
691
726
  }
692
727
  async function mergeConfig(url, options, tauriConf) {
693
728
  await copyTemplateConfigs();
694
- const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', resizable = true, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, camera, microphone, } = options;
729
+ const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options;
695
730
  const platform = asSupportedPlatform(process.platform);
696
- const platformHideOnClose = hideOnClose ?? platform === 'darwin';
697
- const tauriConfWindowOptions = {
698
- width,
699
- height,
700
- fullscreen,
701
- maximize,
702
- resizable,
703
- hide_title_bar: hideTitleBar,
704
- activation_shortcut: activationShortcut,
705
- always_on_top: alwaysOnTop,
706
- dark_mode: darkMode,
707
- disabled_web_shortcuts: disabledWebShortcuts,
708
- hide_on_close: platformHideOnClose,
709
- incognito,
710
- title,
711
- enable_wasm: wasm,
712
- enable_drag_drop: enableDragDrop,
713
- start_to_tray: startToTray && showSystemTray,
714
- force_internal_navigation: forceInternalNavigation,
715
- internal_url_regex: internalUrlRegex,
716
- zoom,
717
- min_width: minWidth,
718
- min_height: minHeight,
719
- ignore_certificate_errors: ignoreCertificateErrors,
720
- new_window: newWindow,
721
- };
731
+ const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform);
722
732
  Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions });
723
733
  tauriConf.productName = name;
724
734
  tauriConf.identifier = identifier;
@@ -764,104 +774,138 @@ async function mergeConfig(url, options, tauriConf) {
764
774
  await writeAllConfigs(tauriConf, platform);
765
775
  }
766
776
 
767
- class BaseBuilder {
768
- constructor(options) {
769
- this.options = options;
770
- }
771
- getBuildEnvironment() {
772
- if (!IS_MAC) {
773
- return undefined;
774
- }
775
- const currentPath = process.env.PATH || '';
776
- const systemToolsPath = '/usr/bin';
777
- const buildPath = currentPath.startsWith(`${systemToolsPath}:`)
778
- ? currentPath
779
- : `${systemToolsPath}:${currentPath}`;
780
- return {
781
- CFLAGS: '-fno-modules',
782
- CXXFLAGS: '-fno-modules',
783
- MACOSX_DEPLOYMENT_TARGET: '14.0',
784
- PATH: buildPath,
785
- };
777
+ /**
778
+ * Returns build environment variables overrides for macOS, where Rust crates
779
+ * sometimes need explicit C/C++ flags and a deterministic SDK target. Other
780
+ * platforms inherit `process.env` unchanged.
781
+ */
782
+ function getBuildEnvironment() {
783
+ if (!IS_MAC) {
784
+ return undefined;
786
785
  }
787
- getInstallTimeout() {
788
- // Windows needs more time due to native compilation and antivirus scanning
789
- return process.platform === 'win32' ? 900000 : 600000;
786
+ const currentPath = process.env.PATH || '';
787
+ const systemToolsPath = '/usr/bin';
788
+ const buildPath = currentPath.startsWith(`${systemToolsPath}:`)
789
+ ? currentPath
790
+ : `${systemToolsPath}:${currentPath}`;
791
+ return {
792
+ CFLAGS: '-fno-modules',
793
+ CXXFLAGS: '-fno-modules',
794
+ MACOSX_DEPLOYMENT_TARGET: '14.0',
795
+ PATH: buildPath,
796
+ };
797
+ }
798
+ /**
799
+ * Windows needs more time due to native compilation and antivirus scanning.
800
+ */
801
+ function getInstallTimeout() {
802
+ return process.platform === 'win32' ? 900000 : 600000;
803
+ }
804
+ function getBuildTimeout() {
805
+ return 900000;
806
+ }
807
+ let packageManagerCache = null;
808
+ /**
809
+ * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found.
810
+ * Cached after the first successful detection so tests can call repeatedly.
811
+ */
812
+ async function detectPackageManager() {
813
+ if (packageManagerCache) {
814
+ return packageManagerCache;
790
815
  }
791
- getBuildTimeout() {
792
- return 900000;
816
+ const { execa } = await import('execa');
817
+ try {
818
+ await execa('pnpm', ['--version'], { stdio: 'ignore' });
819
+ logger.info('โœบ Using pnpm for package management.');
820
+ packageManagerCache = 'pnpm';
821
+ return 'pnpm';
793
822
  }
794
- async detectPackageManager() {
795
- if (BaseBuilder.packageManagerCache) {
796
- return BaseBuilder.packageManagerCache;
797
- }
798
- const { execa } = await import('execa');
823
+ catch {
799
824
  try {
800
- await execa('pnpm', ['--version'], { stdio: 'ignore' });
801
- logger.info('โœบ Using pnpm for package management.');
802
- BaseBuilder.packageManagerCache = 'pnpm';
803
- return 'pnpm';
825
+ await execa('npm', ['--version'], { stdio: 'ignore' });
826
+ logger.info('โœบ pnpm not available, using npm for package management.');
827
+ packageManagerCache = 'npm';
828
+ return 'npm';
804
829
  }
805
830
  catch {
806
- try {
807
- await execa('npm', ['--version'], { stdio: 'ignore' });
808
- logger.info('โœบ pnpm not available, using npm for package management.');
809
- BaseBuilder.packageManagerCache = 'npm';
810
- return 'npm';
811
- }
812
- catch {
813
- throw new Error('Neither pnpm nor npm is available. Please install a package manager.');
814
- }
831
+ throw new Error('Neither pnpm nor npm is available. Please install a package manager.');
815
832
  }
816
833
  }
817
- async copyFileWithSamePathGuard(sourcePath, destinationPath) {
818
- if (path.resolve(sourcePath) === path.resolve(destinationPath)) {
834
+ }
835
+ function getInstallCommand(packageManager, useCnMirror) {
836
+ const registryOption = useCnMirror
837
+ ? ' --registry=https://registry.npmmirror.com'
838
+ : '';
839
+ const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : '';
840
+ return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`;
841
+ }
842
+ async function copyFileWithSamePathGuard(sourcePath, destinationPath) {
843
+ if (path.resolve(sourcePath) === path.resolve(destinationPath)) {
844
+ return;
845
+ }
846
+ try {
847
+ await fsExtra.copy(sourcePath, destinationPath, { overwrite: true });
848
+ }
849
+ catch (error) {
850
+ if (error instanceof Error &&
851
+ error.message.includes('Source and destination must not be the same')) {
819
852
  return;
820
853
  }
821
- try {
822
- await fsExtra.copy(sourcePath, destinationPath, { overwrite: true });
823
- }
824
- catch (error) {
825
- if (error instanceof Error &&
826
- error.message.includes('Source and destination must not be the same')) {
827
- return;
828
- }
829
- throw error;
830
- }
854
+ throw error;
831
855
  }
832
- getInstallCommand(packageManager, useCnMirror) {
833
- const registryOption = useCnMirror
834
- ? ' --registry=https://registry.npmmirror.com'
835
- : '';
836
- const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : '';
837
- return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`;
856
+ }
857
+ function isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) {
858
+ return projectConfig.trim() === cnMirrorConfig.trim();
859
+ }
860
+ /**
861
+ * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in
862
+ * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config
863
+ * (or warns about a manual one) when they opt out.
864
+ */
865
+ async function configureCargoRegistry(tauriSrcPath, useCnMirror) {
866
+ const rustProjectDir = path.join(tauriSrcPath, '.cargo');
867
+ const projectConf = path.join(rustProjectDir, 'config.toml');
868
+ const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
869
+ if (useCnMirror) {
870
+ await fsExtra.ensureDir(rustProjectDir);
871
+ await copyFileWithSamePathGuard(projectCnConf, projectConf);
872
+ return;
838
873
  }
839
- isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) {
840
- return projectConfig.trim() === cnMirrorConfig.trim();
874
+ if (!(await fsExtra.pathExists(projectConf))) {
875
+ return;
841
876
  }
842
- async configureCargoRegistry(tauriSrcPath, useCnMirror) {
843
- const rustProjectDir = path.join(tauriSrcPath, '.cargo');
844
- const projectConf = path.join(rustProjectDir, 'config.toml');
845
- const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
846
- if (useCnMirror) {
847
- await fsExtra.ensureDir(rustProjectDir);
848
- await this.copyFileWithSamePathGuard(projectCnConf, projectConf);
849
- return;
850
- }
851
- if (!(await fsExtra.pathExists(projectConf))) {
852
- return;
853
- }
854
- const [projectConfig, cnMirrorConfig] = await Promise.all([
855
- fsExtra.readFile(projectConf, 'utf8'),
856
- fsExtra.readFile(projectCnConf, 'utf8'),
857
- ]);
858
- if (this.isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) {
859
- await fsExtra.remove(projectConf);
860
- return;
861
- }
862
- if (projectConfig.includes('rsproxy.cn')) {
863
- logger.warn(`โœผ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`);
864
- }
877
+ const [projectConfig, cnMirrorConfig] = await Promise.all([
878
+ fsExtra.readFile(projectConf, 'utf8'),
879
+ fsExtra.readFile(projectCnConf, 'utf8'),
880
+ ]);
881
+ if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) {
882
+ await fsExtra.remove(projectConf);
883
+ return;
884
+ }
885
+ if (projectConfig.includes('rsproxy.cn')) {
886
+ logger.warn(`โœผ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`);
887
+ }
888
+ }
889
+ /**
890
+ * Returns true when an error string looks like the well-known Tauri+linuxdeploy
891
+ * strip failure that we automatically retry with NO_STRIP=1.
892
+ */
893
+ function isLinuxDeployStripError(error) {
894
+ if (!(error instanceof Error) || !error.message) {
895
+ return false;
896
+ }
897
+ const message = error.message.toLowerCase();
898
+ return (message.includes('linuxdeploy') ||
899
+ message.includes('failed to run linuxdeploy') ||
900
+ message.includes('strip:') ||
901
+ message.includes('unable to recognise the format of the input file') ||
902
+ message.includes('appimage tool failed') ||
903
+ message.includes('strip tool'));
904
+ }
905
+
906
+ class BaseBuilder {
907
+ constructor(options) {
908
+ this.options = options;
865
909
  }
866
910
  async prepare() {
867
911
  const tauriSrcPath = path.join(npmDirectory, 'src-tauri');
@@ -888,11 +932,10 @@ class BaseBuilder {
888
932
  }
889
933
  const spinner = getSpinner('Installing package...');
890
934
  const useCnMirror = isCnMirrorEnabled();
891
- await this.configureCargoRegistry(tauriSrcPath, useCnMirror);
892
- // Detect available package manager
893
- const packageManager = await this.detectPackageManager();
894
- const timeout = this.getInstallTimeout();
895
- const buildEnv = this.getBuildEnvironment();
935
+ await configureCargoRegistry(tauriSrcPath, useCnMirror);
936
+ const packageManager = await detectPackageManager();
937
+ const timeout = getInstallTimeout();
938
+ const buildEnv = getBuildEnvironment();
896
939
  // Show helpful message for first-time users
897
940
  if (!tauriTargetPathExists) {
898
941
  logger.info(process.platform === 'win32'
@@ -903,7 +946,7 @@ class BaseBuilder {
903
946
  logger.info(`โœบ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`);
904
947
  }
905
948
  try {
906
- await shellExec(this.getInstallCommand(packageManager, useCnMirror), timeout, {
949
+ await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, {
907
950
  ...buildEnv,
908
951
  CI: 'true',
909
952
  });
@@ -926,7 +969,7 @@ class BaseBuilder {
926
969
  async start(url) {
927
970
  logger.info('Pake dev server starting...');
928
971
  await mergeConfig(url, this.options, tauriConfig);
929
- const packageManager = await this.detectPackageManager();
972
+ const packageManager = await detectPackageManager();
930
973
  const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json');
931
974
  const features = this.getBuildFeatures();
932
975
  const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : '';
@@ -937,8 +980,7 @@ class BaseBuilder {
937
980
  async buildAndCopy(url, target) {
938
981
  const { name = 'pake-app' } = this.options;
939
982
  await mergeConfig(url, this.options, tauriConfig);
940
- // Detect available package manager
941
- const packageManager = await this.detectPackageManager();
983
+ const packageManager = await detectPackageManager();
942
984
  // Build app
943
985
  const buildSpinner = getSpinner('Building app...');
944
986
  // Let spinner run for a moment so user can see it, then stop before package manager command
@@ -946,7 +988,7 @@ class BaseBuilder {
946
988
  buildSpinner.stop();
947
989
  // Show static message to keep the status visible
948
990
  logger.warn('โœธ Building app...');
949
- const baseEnv = this.getBuildEnvironment();
991
+ const baseEnv = getBuildEnvironment();
950
992
  let buildEnv = {
951
993
  ...(baseEnv ?? {}),
952
994
  ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}),
@@ -962,7 +1004,7 @@ class BaseBuilder {
962
1004
  }
963
1005
  }
964
1006
  const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`;
965
- const buildTimeout = this.getBuildTimeout();
1007
+ const buildTimeout = getBuildTimeout();
966
1008
  try {
967
1009
  await shellExec(buildCommand, buildTimeout, resolveExecEnv());
968
1010
  }
@@ -970,7 +1012,7 @@ class BaseBuilder {
970
1012
  const shouldRetryWithoutStrip = process.platform === 'linux' &&
971
1013
  target === 'appimage' &&
972
1014
  !buildEnv.NO_STRIP &&
973
- this.isLinuxDeployStripError(error);
1015
+ isLinuxDeployStripError(error);
974
1016
  if (shouldRetryWithoutStrip) {
975
1017
  logger.warn('โš  AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.');
976
1018
  buildEnv = {
@@ -1026,18 +1068,6 @@ class BaseBuilder {
1026
1068
  getFileType(target) {
1027
1069
  return target;
1028
1070
  }
1029
- isLinuxDeployStripError(error) {
1030
- if (!(error instanceof Error) || !error.message) {
1031
- return false;
1032
- }
1033
- const message = error.message.toLowerCase();
1034
- return (message.includes('linuxdeploy') ||
1035
- message.includes('failed to run linuxdeploy') ||
1036
- message.includes('strip:') ||
1037
- message.includes('unable to recognise the format of the input file') ||
1038
- message.includes('appimage tool failed') ||
1039
- message.includes('strip tool'));
1040
- }
1041
1071
  resolveTargetArch(requestedArch) {
1042
1072
  if (requestedArch === 'auto' || !requestedArch) {
1043
1073
  return process.arch;
@@ -1175,7 +1205,6 @@ class BaseBuilder {
1175
1205
  return 'src-tauri/target'; // Override in subclasses if needed
1176
1206
  }
1177
1207
  }
1178
- BaseBuilder.packageManagerCache = null;
1179
1208
  BaseBuilder.ARCH_MAPPINGS = {
1180
1209
  darwin: {
1181
1210
  arm64: 'aarch64-apple-darwin',
@@ -1511,6 +1540,9 @@ function getIconSourcePriority(url, appName) {
1511
1540
  const ICO_HEADER_SIZE = 6;
1512
1541
  const ICO_DIR_ENTRY_SIZE = 16;
1513
1542
  const ICO_TYPE_ICON = 1;
1543
+ // Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48),
1544
+ // shell (48/256) and high-DPI (128/256). Issue #1190.
1545
+ const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256];
1514
1546
  function decodeDimension(value) {
1515
1547
  return value === 0 ? 256 : value;
1516
1548
  }
@@ -1609,6 +1641,91 @@ async function writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize)
1609
1641
  return false;
1610
1642
  }
1611
1643
  }
1644
+ /**
1645
+ * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an
1646
+ * embedded PNG payload (PNG-in-ICO, supported since Windows Vista).
1647
+ */
1648
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
1649
+ function frameLooksLikePng(entry) {
1650
+ return (entry.data.length >= PNG_SIGNATURE.length &&
1651
+ entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE));
1652
+ }
1653
+ async function decodeFrameToPng(entry) {
1654
+ if (frameLooksLikePng(entry)) {
1655
+ return Buffer.from(entry.data);
1656
+ }
1657
+ // BMP DIB frames need to go through sharp's ico-to-PNG path, which only
1658
+ // works on the full ICO container. Fall back to letting the caller use a
1659
+ // sharp pipeline against the original ICO for the missing source.
1660
+ return null;
1661
+ }
1662
+ async function pickLargestFrameAsPng(buffer, entries) {
1663
+ const largest = [...entries].sort((a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height))[0];
1664
+ if (largest) {
1665
+ const decoded = await decodeFrameToPng(largest);
1666
+ if (decoded) {
1667
+ return decoded;
1668
+ }
1669
+ }
1670
+ // Fallback: let sharp render directly from the ICO buffer. sharp picks the
1671
+ // largest embedded frame on its own.
1672
+ try {
1673
+ return await sharp(buffer).png().toBuffer();
1674
+ }
1675
+ catch {
1676
+ return null;
1677
+ }
1678
+ }
1679
+ /**
1680
+ * Ensures the produced ICO carries every Windows standard size so the OS
1681
+ * never has to downsample a 256x256 frame to 16x16 for the tray.
1682
+ * Falls back to `writeIcoWithPreferredSize` if rendering fails.
1683
+ *
1684
+ * Issue #1190.
1685
+ */
1686
+ async function ensureMultiResolutionIco(sourcePath, outputPath, preferredSize = 256, desiredSizes = WIN_STANDARD_ICO_SIZES) {
1687
+ try {
1688
+ const sourceBuffer = await fsExtra.readFile(sourcePath);
1689
+ const entries = parseIcoBuffer(sourceBuffer);
1690
+ const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries);
1691
+ if (!sourcePng) {
1692
+ return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize);
1693
+ }
1694
+ const frames = await Promise.all(desiredSizes.map(async (size) => {
1695
+ // Reuse an existing exact-size PNG frame when possible to keep any
1696
+ // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting).
1697
+ const exact = entries.find((entry) => entry.width === size && entry.height === size);
1698
+ if (exact && frameLooksLikePng(exact)) {
1699
+ return { size, png: Buffer.from(exact.data) };
1700
+ }
1701
+ const png = await sharp(sourcePng)
1702
+ .resize(size, size, {
1703
+ fit: 'contain',
1704
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
1705
+ })
1706
+ .ensureAlpha()
1707
+ .png()
1708
+ .toBuffer();
1709
+ return { size, png };
1710
+ }));
1711
+ // Order frames so the preferred size lands first (Windows shell uses the
1712
+ // first-listed frame as a quality hint when choosing which to display).
1713
+ frames.sort((a, b) => {
1714
+ const aExact = a.size === preferredSize ? 0 : 1;
1715
+ const bExact = b.size === preferredSize ? 0 : 1;
1716
+ if (aExact !== bExact)
1717
+ return aExact - bExact;
1718
+ return b.size - a.size;
1719
+ });
1720
+ const icoBuffer = buildIcoFromPngBuffers(frames);
1721
+ await fsExtra.ensureDir(path.dirname(outputPath));
1722
+ await fsExtra.outputFile(outputPath, icoBuffer);
1723
+ return true;
1724
+ }
1725
+ catch {
1726
+ return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize);
1727
+ }
1728
+ }
1612
1729
  /**
1613
1730
  * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format
1614
1731
  * (supported since Windows Vista). This preserves alpha transparency.
@@ -1658,7 +1775,7 @@ const ICON_CONFIG = {
1658
1775
  },
1659
1776
  };
1660
1777
  const PLATFORM_CONFIG = {
1661
- win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] },
1778
+ win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] },
1662
1779
  linux: { format: '.png', size: 512 },
1663
1780
  macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] },
1664
1781
  };
@@ -1693,10 +1810,15 @@ async function copyWindowsIconIfNeeded(convertedPath, appName) {
1693
1810
  try {
1694
1811
  const finalIconPath = generateIconPath(appName);
1695
1812
  await fsExtra.ensureDir(path.dirname(finalIconPath));
1696
- // Reorder ICO to prioritize 256px icons for better Windows display
1697
- const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256);
1698
- if (!reordered) {
1699
- await fsExtra.copy(convertedPath, finalIconPath);
1813
+ // Re-render ICO so every Windows standard size is present and prefer the
1814
+ // 256px frame as the leading entry; falls back to plain reordering if the
1815
+ // ICO is non-decodable, then to a raw copy. (Issue #1190)
1816
+ const upgraded = await ensureMultiResolutionIco(convertedPath, finalIconPath, 256);
1817
+ if (!upgraded) {
1818
+ const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256);
1819
+ if (!reordered) {
1820
+ await fsExtra.copy(convertedPath, finalIconPath);
1821
+ }
1700
1822
  }
1701
1823
  return finalIconPath;
1702
1824
  }
@@ -2153,6 +2275,28 @@ function normalizeUrl(urlToNormalize) {
2153
2275
  }
2154
2276
  }
2155
2277
 
2278
+ /**
2279
+ * Error class used for user-facing CLI errors.
2280
+ *
2281
+ * The top-level catch in `bin/cli.ts` prints `message` directly without a
2282
+ * stack trace and exits with code 1. Use this for predictable failures
2283
+ * (invalid names, missing files, etc.) so users see a clean message instead
2284
+ * of a Node.js stack dump.
2285
+ */
2286
+ class PakeError extends Error {
2287
+ constructor(message) {
2288
+ super(message);
2289
+ this.isUserError = true;
2290
+ this.name = 'PakeError';
2291
+ }
2292
+ }
2293
+ function isPakeError(error) {
2294
+ return (error instanceof PakeError ||
2295
+ (typeof error === 'object' &&
2296
+ error !== null &&
2297
+ error.isUserError === true));
2298
+ }
2299
+
2156
2300
  function resolveAppName(name, platform) {
2157
2301
  const domain = getDomain(name) || 'pake';
2158
2302
  return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain;
@@ -2195,13 +2339,13 @@ async function handleOptions(options, url) {
2195
2339
  const LINUX_NAME_ERROR = `โœ• Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`;
2196
2340
  const DEFAULT_NAME_ERROR = `โœ• Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`;
2197
2341
  const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR;
2198
- logger.error(errorMsg);
2199
2342
  if (isActions) {
2343
+ logger.error(errorMsg);
2200
2344
  name = resolveAppName(url, platform);
2201
2345
  logger.warn(`โœผ Inside github actions, use the default name: ${name}`);
2202
2346
  }
2203
2347
  else {
2204
- process.exit(1);
2348
+ throw new PakeError(errorMsg);
2205
2349
  }
2206
2350
  }
2207
2351
  const resolvedName = name || 'pake-app';
@@ -2459,21 +2603,46 @@ async function checkUpdateTips() {
2459
2603
  });
2460
2604
  }
2461
2605
  program.action(async (url, options) => {
2462
- await checkUpdateTips();
2463
- if (!url) {
2464
- program.help({
2465
- error: false,
2466
- });
2467
- return;
2606
+ try {
2607
+ await checkUpdateTips();
2608
+ if (!url) {
2609
+ program.help({
2610
+ error: false,
2611
+ });
2612
+ return;
2613
+ }
2614
+ log.setDefaultLevel('info');
2615
+ log.setLevel('info');
2616
+ if (options.debug) {
2617
+ log.setLevel('debug');
2618
+ }
2619
+ const appOptions = await handleOptions(options, url);
2620
+ const builder = BuilderProvider.create(appOptions);
2621
+ await builder.prepare();
2622
+ await builder.build(url);
2623
+ }
2624
+ catch (error) {
2625
+ if (isPakeError(error)) {
2626
+ console.error(chalk.red(error.message));
2627
+ }
2628
+ else if (error instanceof Error) {
2629
+ console.error(chalk.red(`โœ• ${error.message}`));
2630
+ if (options?.debug && error.stack) {
2631
+ console.error(chalk.gray(error.stack));
2632
+ }
2633
+ }
2634
+ else {
2635
+ console.error(chalk.red(`โœ• Unexpected error: ${String(error)}`));
2636
+ }
2637
+ process.exit(1);
2638
+ }
2639
+ });
2640
+ program.parseAsync().catch((error) => {
2641
+ if (error instanceof Error) {
2642
+ console.error(chalk.red(`โœ• ${error.message}`));
2468
2643
  }
2469
- log.setDefaultLevel('info');
2470
- log.setLevel('info');
2471
- if (options.debug) {
2472
- log.setLevel('debug');
2644
+ else {
2645
+ console.error(chalk.red(`โœ• Unexpected error: ${String(error)}`));
2473
2646
  }
2474
- const appOptions = await handleOptions(options, url);
2475
- const builder = BuilderProvider.create(appOptions);
2476
- await builder.prepare();
2477
- await builder.build(url);
2647
+ process.exit(1);
2478
2648
  });
2479
- program.parse();