pake-cli 3.13.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,22 @@
130
130
  <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Excalidraw.png width=600/></td>
131
131
  <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/XiaoHongShu.png width=600/></td>
132
132
  </tr>
133
+ <tr>
134
+ <td>Notion
135
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion.dmg">Mac</a>
136
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion_x64.msi">Windows</a>
137
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion_x86_64.deb">Linux</a>
138
+ </td>
139
+ <td>Flomo
140
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo.dmg">Mac</a>
141
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo_x64.msi">Windows</a>
142
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo_x86_64.deb">Linux</a>
143
+ </td>
144
+ </tr>
145
+ <tr>
146
+ <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Notion.png width=600/></td>
147
+ <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Flomo.png width=600/></td>
148
+ </tr>
133
149
  </table>
134
150
 
135
151
  <details>
@@ -153,8 +169,9 @@
153
169
  | <kbd>⌘</kbd> + <kbd>⇧</kbd> + <kbd>H</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> | Go to Home Page |
154
170
  | <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> | Toggle Developer Tools (Debug Only) |
155
171
  | <kbd>⌘</kbd> + <kbd>⇧</kbd> + <kbd>⌫</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Del</kbd> | Clear Cache & Restart |
172
+ | <kbd>⌃</kbd> + <kbd>⌘</kbd> + <kbd>F</kbd> | <kbd>F11</kbd> | Toggle native window fullscreen |
156
173
 
157
- In addition, double-click the title bar to switch to full-screen mode. For Mac users, you can also use the gesture to go to the previous or next page and drag the title bar to move the window. The new menu also offers options for navigation, zoom, and window controls.
174
+ In addition, double-click the title bar to switch to full-screen mode. On Windows and Linux, use `--hide-window-decorations` for a frameless window with a top drag region. For Mac users, you can also use the gesture to go to the previous or next page and drag the title bar to move the window. The new menu also offers options for navigation, zoom, and window controls.
158
175
 
159
176
  </details>
160
177
 
@@ -175,6 +192,8 @@ pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/week
175
192
 
176
193
  First-time packaging requires environment setup and may be slower, subsequent builds are fast. For complete parameter documentation, see [CLI Usage Guide](docs/cli-usage.md). Don't want to use CLI? Try [GitHub Actions Online Building](docs/github-actions-usage.md).
177
194
 
195
+ Using Pake from a script or AI agent? Pass `--json` for machine-readable results, describe apps declaratively with `--config app.json` ([schema](schema/pake.schema.json)), and package local build output directly with `pake ./dist --name MyTool`. See [llms.txt](llms.txt) for the full agent contract. Claude Code users can install the official skill with `/plugin marketplace add tw93/Pake` and `/plugin install pake@pake`.
196
+
178
197
  ## Development
179
198
 
180
199
  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.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import * as psl from 'psl';
20
20
  import { InvalidArgumentError, program as program$1, Option } from 'commander';
21
21
 
22
22
  var name = "pake-cli";
23
- var version = "3.13.1";
23
+ var version = "3.15.0";
24
24
  var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。";
25
25
  var engines = {
26
26
  node: ">=18.0.0"
@@ -172,6 +172,58 @@ let tauriConfig = {
172
172
  pake: pakeConf,
173
173
  };
174
174
 
175
+ // Stable exit-code contract: 0 success, 2 invalid input, 3 build/network
176
+ // failure, 4 missing environment, 1 unexpected. Documented in cli-usage docs.
177
+ const ERROR_EXIT_CODES = {
178
+ INVALID_INPUT: 2,
179
+ BUILD_FAILED: 3,
180
+ NETWORK: 3,
181
+ ENV_MISSING: 4,
182
+ UNEXPECTED: 1,
183
+ };
184
+ let machineMode = false;
185
+ const capturedWarnings = [];
186
+ /**
187
+ * Route all loglevel output to stderr, capture warnings for the final JSON
188
+ * result, and strip ANSI colors. Must be called before any logging happens.
189
+ */
190
+ function enableMachineMode() {
191
+ if (machineMode)
192
+ return;
193
+ machineMode = true;
194
+ chalk.level = 0;
195
+ log.methodFactory = (methodName) => {
196
+ return (...args) => {
197
+ if (methodName === 'warn') {
198
+ capturedWarnings.push(args.map(String).join(' '));
199
+ }
200
+ console.error(...args);
201
+ };
202
+ };
203
+ // Rebuild logging methods with the new factory.
204
+ log.setLevel(log.getLevel());
205
+ }
206
+ function isMachineMode() {
207
+ return machineMode;
208
+ }
209
+ function getCapturedWarnings() {
210
+ return [...capturedWarnings];
211
+ }
212
+ /**
213
+ * Whether Pake may prompt the user. False in machine mode, without a TTY,
214
+ * or inside CI, where prompts would hang or produce garbage.
215
+ */
216
+ function isInteractive() {
217
+ return (!machineMode &&
218
+ Boolean(process.stdin.isTTY) &&
219
+ Boolean(process.stdout.isTTY) &&
220
+ !process.env.CI &&
221
+ !process.env.GITHUB_ACTIONS);
222
+ }
223
+ function printJsonResult(result) {
224
+ process.stdout.write(`${JSON.stringify(result)}\n`);
225
+ }
226
+
175
227
  // Generates a stable identifier based on the app URL (and optionally name).
176
228
  // When name is provided it is included in the hash so two apps wrapping
177
229
  // the same URL can coexist. Omitting name preserves backward compatibility
@@ -217,6 +269,8 @@ function getSpinner(text) {
217
269
  text: `${chalk.cyan(text)}\n`,
218
270
  spinner: loadingType,
219
271
  color: 'cyan',
272
+ // In machine mode stdout must stay parseable and stderr low-noise.
273
+ isSilent: isMachineMode(),
220
274
  }).start();
221
275
  }
222
276
 
@@ -324,7 +378,11 @@ async function shellExec(command, timeout = 300000, env) {
324
378
  cwd: npmDirectory,
325
379
  // Use 'inherit' to show all output directly to user in real-time.
326
380
  // This ensures linuxdeploy and other tool outputs are visible during builds.
327
- stdio: 'inherit',
381
+ // In machine mode (--json) stdout is reserved for the final JSON result,
382
+ // so subprocess stdout is rerouted to stderr instead.
383
+ stdin: 'inherit',
384
+ stdout: isMachineMode() ? process.stderr : 'inherit',
385
+ stderr: 'inherit',
328
386
  shell: true,
329
387
  timeout,
330
388
  env: env ? { ...process.env, ...env } : process.env,
@@ -502,6 +560,31 @@ function generateIdentifierSafeName(name) {
502
560
  return cleaned;
503
561
  }
504
562
 
563
+ /**
564
+ * Error class used for user-facing CLI errors.
565
+ *
566
+ * The top-level catch in `bin/cli.ts` prints `message` directly without a
567
+ * stack trace and exits with the code mapped from `code` (see
568
+ * ERROR_EXIT_CODES in utils/output.ts). Use this for predictable failures
569
+ * (invalid names, missing files, etc.) so users see a clean message instead
570
+ * of a Node.js stack dump. `code` and `hint` also feed the `--json` result.
571
+ */
572
+ class PakeError extends Error {
573
+ constructor(message, options) {
574
+ super(message);
575
+ this.isUserError = true;
576
+ this.name = 'PakeError';
577
+ this.code = options?.code;
578
+ this.hint = options?.hint;
579
+ }
580
+ }
581
+ function isPakeError(error) {
582
+ return (error instanceof PakeError ||
583
+ (typeof error === 'object' &&
584
+ error !== null &&
585
+ error.isUserError === true));
586
+ }
587
+
505
588
  const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst'];
506
589
  // Returns the valid Linux build targets from a comma-separated targets
507
590
  // string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped.
@@ -535,6 +618,7 @@ function resolveLinuxBundleTargets(targets) {
535
618
  function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) {
536
619
  const platformHideOnClose = options.hideOnClose ?? platform === 'darwin';
537
620
  const platformHideTitleBar = platform === 'darwin' ? options.hideTitleBar : false;
621
+ const platformHideWindowDecorations = platform !== 'darwin' ? options.hideWindowDecorations : false;
538
622
  return {
539
623
  width: options.width,
540
624
  height: options.height,
@@ -542,6 +626,7 @@ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(proc
542
626
  maximize: options.maximize,
543
627
  resizable: options.resizable ?? true,
544
628
  hide_title_bar: platformHideTitleBar,
629
+ hide_window_decorations: platformHideWindowDecorations,
545
630
  activation_shortcut: options.activationShortcut,
546
631
  always_on_top: options.alwaysOnTop,
547
632
  dark_mode: options.darkMode,
@@ -587,30 +672,60 @@ async function copyTemplateConfigs() {
587
672
  }
588
673
  }));
589
674
  }
675
+ // Replace the CLI's own dist/ with the user's static files while keeping the
676
+ // build artifacts (cli.js) the packaged app does not need but the CLI does.
677
+ // dist_bak always holds the ORIGINAL package dist: once it exists, later
678
+ // stagings must not overwrite it with a previous user tree, or the original
679
+ // files would be unrecoverable across repeated local builds.
680
+ async function stageLocalTree(sourceDir) {
681
+ const distDir = path.join(npmDirectory, 'dist');
682
+ const distBakDir = path.join(npmDirectory, 'dist_bak');
683
+ if (await fsExtra.pathExists(distBakDir)) {
684
+ fsExtra.removeSync(distDir);
685
+ }
686
+ else {
687
+ fsExtra.moveSync(distDir, distBakDir);
688
+ }
689
+ fsExtra.copySync(sourceDir, distDir, { overwrite: true });
690
+ const filesToCopyBack = ['cli.js'];
691
+ await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file))));
692
+ }
693
+ // Exported for unit tests (web fallback and directory entry guard).
590
694
  async function handleLocalFile(url, useLocalFile, tauriConf) {
591
695
  const pathExists = await fsExtra.pathExists(url);
592
- if (pathExists) {
593
- logger.warn('✼ Your input might be a local file.');
594
- const fileName = path.basename(url);
595
- const dirName = path.dirname(url);
596
- const distDir = path.join(npmDirectory, 'dist');
597
- const distBakDir = path.join(npmDirectory, 'dist_bak');
598
- if (!useLocalFile) {
599
- const urlPath = path.join(distDir, fileName);
600
- await fsExtra.copy(url, urlPath);
601
- }
602
- else {
603
- fsExtra.moveSync(distDir, distBakDir, { overwrite: true });
604
- fsExtra.copySync(dirName, distDir, { overwrite: true });
605
- const filesToCopyBack = ['cli.js'];
606
- await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file))));
696
+ if (!pathExists) {
697
+ tauriConf.pake.windows[0].url_type = 'web';
698
+ return;
699
+ }
700
+ const stat = await fsExtra.stat(url);
701
+ if (stat.isDirectory()) {
702
+ // A directory of static web assets (e.g. a generated dist/): the whole
703
+ // tree is packaged and the app entry is its root index.html.
704
+ const entryFile = 'index.html';
705
+ if (!(await fsExtra.pathExists(path.join(url, entryFile)))) {
706
+ throw new PakeError(`Local directory "${url}" has no ${entryFile} at its root.`, {
707
+ code: 'INVALID_INPUT',
708
+ hint: 'Point Pake at the built output directory that contains index.html.',
709
+ });
607
710
  }
608
- tauriConf.pake.windows[0].url = fileName;
711
+ logger.info(`✺ Packaging local directory: ${url}`);
712
+ await stageLocalTree(url);
713
+ tauriConf.pake.windows[0].url = entryFile;
609
714
  tauriConf.pake.windows[0].url_type = 'local';
715
+ return;
716
+ }
717
+ logger.info(`✺ Packaging local file: ${url}`);
718
+ const fileName = path.basename(url);
719
+ const distDir = path.join(npmDirectory, 'dist');
720
+ if (!useLocalFile) {
721
+ const urlPath = path.join(distDir, fileName);
722
+ await fsExtra.copy(url, urlPath);
610
723
  }
611
724
  else {
612
- tauriConf.pake.windows[0].url_type = 'web';
725
+ await stageLocalTree(path.dirname(url));
613
726
  }
727
+ tauriConf.pake.windows[0].url = fileName;
728
+ tauriConf.pake.windows[0].url_type = 'local';
614
729
  }
615
730
  function buildLinuxDesktopContent(name, title, linuxBinaryName) {
616
731
  const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
@@ -827,6 +942,9 @@ async function mergeConfig(url, options, tauriConf) {
827
942
  if (options.hideTitleBar && platform !== 'darwin') {
828
943
  logger.warn('✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.');
829
944
  }
945
+ if (options.hideWindowDecorations && platform === 'darwin') {
946
+ logger.warn('✼ --hide-window-decorations is only supported on Windows and Linux and will be ignored on this platform.');
947
+ }
830
948
  const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform);
831
949
  Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions });
832
950
  tauriConf.productName = name;
@@ -1043,8 +1161,61 @@ const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` +
1043
1161
  APPIMAGE_BAR;
1044
1162
  class BaseBuilder {
1045
1163
  constructor(options) {
1164
+ this.artifacts = [];
1046
1165
  this.options = options;
1047
1166
  }
1167
+ /** Final artifacts produced by this build, for the `--json` result. */
1168
+ getArtifacts() {
1169
+ return [...this.artifacts];
1170
+ }
1171
+ /** Architecture reported in the `--json` result. */
1172
+ getReportArch() {
1173
+ return this.options.multiArch ? 'universal' : process.arch;
1174
+ }
1175
+ // Drop a recorded artifact whose file was later removed (e.g. the
1176
+ // temporary .deb consumed by zst repacking), so --json never lists a
1177
+ // path that no longer exists.
1178
+ removeArtifact(artifactPath) {
1179
+ const resolved = path.resolve(artifactPath);
1180
+ this.artifacts = this.artifacts.filter((artifact) => artifact.path !== resolved);
1181
+ }
1182
+ async recordArtifact(artifactPath, format) {
1183
+ try {
1184
+ const stat = await fsExtra.stat(artifactPath);
1185
+ let sizeBytes = stat.size;
1186
+ if (stat.isDirectory()) {
1187
+ sizeBytes = await BaseBuilder.getPathSize(artifactPath);
1188
+ }
1189
+ this.artifacts.push({
1190
+ path: path.resolve(artifactPath),
1191
+ sizeBytes,
1192
+ format,
1193
+ });
1194
+ }
1195
+ catch {
1196
+ // Never fail a finished build over size bookkeeping.
1197
+ this.artifacts.push({
1198
+ path: path.resolve(artifactPath),
1199
+ sizeBytes: 0,
1200
+ format,
1201
+ });
1202
+ }
1203
+ }
1204
+ static async getPathSize(directory) {
1205
+ let size = 0;
1206
+ for (const entry of await fsExtra.readdir(directory, {
1207
+ withFileTypes: true,
1208
+ })) {
1209
+ const entryPath = path.join(directory, entry.name);
1210
+ if (entry.isDirectory()) {
1211
+ size += await BaseBuilder.getPathSize(entryPath);
1212
+ }
1213
+ else if (entry.isFile()) {
1214
+ size += (await fsExtra.stat(entryPath)).size;
1215
+ }
1216
+ }
1217
+ return size;
1218
+ }
1048
1219
  async prepare() {
1049
1220
  const tauriSrcPath = path.join(npmDirectory, 'src-tauri');
1050
1221
  const tauriTargetPath = path.join(tauriSrcPath, 'target');
@@ -1055,6 +1226,12 @@ class BaseBuilder {
1055
1226
  }
1056
1227
  ensureRustEnv();
1057
1228
  if (!checkRustInstalled()) {
1229
+ if (!isInteractive()) {
1230
+ throw new PakeError('Rust required to package your webapp.', {
1231
+ code: 'ENV_MISSING',
1232
+ hint: 'Install Rust via https://rustup.rs, then rerun the same command.',
1233
+ });
1234
+ }
1058
1235
  const res = await prompts({
1059
1236
  type: 'confirm',
1060
1237
  message: 'Rust not detected. Install now?',
@@ -1064,8 +1241,10 @@ class BaseBuilder {
1064
1241
  await installRust();
1065
1242
  }
1066
1243
  else {
1067
- logger.error('Rust required to package your webapp.');
1068
- process.exit(1);
1244
+ throw new PakeError('Rust required to package your webapp.', {
1245
+ code: 'ENV_MISSING',
1246
+ hint: 'Install Rust via https://rustup.rs, then rerun the same command.',
1247
+ });
1069
1248
  }
1070
1249
  }
1071
1250
  const spinner = getSpinner('Installing package...');
@@ -1124,8 +1303,9 @@ class BaseBuilder {
1124
1303
  // Let spinner run for a moment so user can see it, then stop before package manager command
1125
1304
  await new Promise((resolve) => setTimeout(resolve, 500));
1126
1305
  buildSpinner.stop();
1127
- // Show static message to keep the status visible
1128
- logger.warn('✸ Building app...');
1306
+ // Show static message to keep the status visible. Info, not warn: warn
1307
+ // entries feed the --json warnings array and this is a status line.
1308
+ logger.info('✸ Building app...');
1129
1309
  const baseEnv = getBuildEnvironment();
1130
1310
  let buildEnv = {
1131
1311
  ...(baseEnv ?? {}),
@@ -1169,6 +1349,7 @@ class BaseBuilder {
1169
1349
  // executable the build produced instead.
1170
1350
  if (this.options.bundle === false) {
1171
1351
  await this.copyRawBinary(npmDirectory, name);
1352
+ await this.recordArtifact(this.getRawBinaryPath(name), 'binary');
1172
1353
  if (logSuccess) {
1173
1354
  logger.success('✔ Build success!');
1174
1355
  logger.success('✔ Raw binary located in', path.resolve(this.getRawBinaryPath(name)));
@@ -1181,9 +1362,11 @@ class BaseBuilder {
1181
1362
  const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType);
1182
1363
  const distPath = path.resolve(`${name}.${fileType}`);
1183
1364
  await fsExtra.copy(appPath, distPath);
1365
+ await this.recordArtifact(distPath, fileType);
1184
1366
  // Copy raw binary if requested
1185
1367
  if (this.options.keepBinary) {
1186
1368
  await this.copyRawBinary(npmDirectory, name);
1369
+ await this.recordArtifact(this.getRawBinaryPath(name), 'binary');
1187
1370
  }
1188
1371
  await fsExtra.remove(appPath);
1189
1372
  if (logSuccess) {
@@ -1210,6 +1393,13 @@ class BaseBuilder {
1210
1393
  // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back
1211
1394
  // to copy+remove only when moving across volumes.
1212
1395
  await fsExtra.move(appBundlePath, appDest, { overwrite: true });
1396
+ // Keep the JSON result pointing at where the artifact actually lives.
1397
+ const movedFrom = path.resolve(appBundlePath);
1398
+ for (const artifact of this.artifacts) {
1399
+ if (artifact.path === movedFrom) {
1400
+ artifact.path = appDest;
1401
+ }
1402
+ }
1213
1403
  logger.success(`✔ ${appBundleName.replace(/\.app$/, '')} installed to /Applications`);
1214
1404
  }
1215
1405
  catch (error) {
@@ -1425,6 +1615,9 @@ class MacBuilder extends BaseBuilder {
1425
1615
  }
1426
1616
  return `${name}_${tauriConfig.version}_${arch}`;
1427
1617
  }
1618
+ getReportArch() {
1619
+ return this.getActualArch();
1620
+ }
1428
1621
  getActualArch() {
1429
1622
  if (this.buildArch === 'universal' || this.options.multiArch) {
1430
1623
  return 'universal';
@@ -1478,6 +1671,9 @@ class WinBuilder extends BaseBuilder {
1478
1671
  : this.resolveTargetArch('auto');
1479
1672
  this.options.targets = this.buildFormat;
1480
1673
  }
1674
+ getReportArch() {
1675
+ return this.buildArch;
1676
+ }
1481
1677
  getFileName() {
1482
1678
  const { name } = this.options;
1483
1679
  const language = tauriConfig.bundle.windows.wix.language[0];
@@ -1533,6 +1729,9 @@ class LinuxBuilder extends BaseBuilder {
1533
1729
  }
1534
1730
  this.options.targets = this.buildFormat;
1535
1731
  }
1732
+ getReportArch() {
1733
+ return this.buildArch;
1734
+ }
1536
1735
  getFileName() {
1537
1736
  const { name = 'pake-app', targets } = this.options;
1538
1737
  const version = tauriConfig.version;
@@ -1681,12 +1880,14 @@ post_remove() {
1681
1880
  }
1682
1881
  `);
1683
1882
  await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`);
1883
+ await this.recordArtifact(packagePath, 'zst');
1684
1884
  logger.success('✔ Build success!');
1685
1885
  logger.success('✔ App installer located in', packagePath);
1686
1886
  }
1687
1887
  finally {
1688
1888
  if (removeSourceDeb) {
1689
1889
  await fsExtra.remove(debPath);
1890
+ this.removeArtifact(debPath);
1690
1891
  }
1691
1892
  await fsExtra.remove(workDir);
1692
1893
  }
@@ -2384,8 +2585,8 @@ async function handleIcon(options, url) {
2384
2585
  return localIconPath;
2385
2586
  }
2386
2587
  }
2387
- // Try favicon from website
2388
- if (url && options.name) {
2588
+ // Try favicon from website; local file/directory input has no favicon.
2589
+ if (url && options.name && /^https?:\/\//i.test(url)) {
2389
2590
  const faviconPath = await tryGetFavicon(url, options.name);
2390
2591
  if (faviconPath)
2391
2592
  return faviconPath;
@@ -2628,28 +2829,6 @@ function safeDomainsToRegex(domains) {
2628
2829
  : '';
2629
2830
  }
2630
2831
 
2631
- /**
2632
- * Error class used for user-facing CLI errors.
2633
- *
2634
- * The top-level catch in `bin/cli.ts` prints `message` directly without a
2635
- * stack trace and exits with code 1. Use this for predictable failures
2636
- * (invalid names, missing files, etc.) so users see a clean message instead
2637
- * of a Node.js stack dump.
2638
- */
2639
- class PakeError extends Error {
2640
- constructor(message) {
2641
- super(message);
2642
- this.isUserError = true;
2643
- this.name = 'PakeError';
2644
- }
2645
- }
2646
- function isPakeError(error) {
2647
- return (error instanceof PakeError ||
2648
- (typeof error === 'object' &&
2649
- error !== null &&
2650
- error.isUserError === true));
2651
- }
2652
-
2653
2832
  function resolveAppName(name, platform) {
2654
2833
  const domain = getDomain(name) || 'pake';
2655
2834
  return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain;
@@ -2681,9 +2860,14 @@ async function handleOptions(options, url) {
2681
2860
  const defaultName = pathExists
2682
2861
  ? resolveLocalAppName(url, platform)
2683
2862
  : resolveAppName(url, platform);
2684
- const promptMessage = 'Enter your application name';
2685
- const namePrompt = await promptText(promptMessage, defaultName);
2686
- name = namePrompt?.trim() || defaultName;
2863
+ if (isInteractive()) {
2864
+ const promptMessage = 'Enter your application name';
2865
+ const namePrompt = await promptText(promptMessage, defaultName);
2866
+ name = namePrompt?.trim() || defaultName;
2867
+ }
2868
+ else {
2869
+ name = defaultName;
2870
+ }
2687
2871
  }
2688
2872
  if (name && platform === 'linux') {
2689
2873
  name = generateLinuxPackageName(name);
@@ -2727,7 +2911,9 @@ const DEFAULT_PAKE_OPTIONS = {
2727
2911
  width: 1200,
2728
2912
  fullscreen: false,
2729
2913
  maximize: false,
2914
+ resizable: true,
2730
2915
  hideTitleBar: false,
2916
+ hideWindowDecorations: false,
2731
2917
  alwaysOnTop: false,
2732
2918
  appVersion: '1.0.0',
2733
2919
  darkMode: false,
@@ -2752,6 +2938,7 @@ const DEFAULT_PAKE_OPTIONS = {
2752
2938
  systemTrayIcon: '',
2753
2939
  proxyUrl: '',
2754
2940
  debug: false,
2941
+ json: false,
2755
2942
  inject: [],
2756
2943
  installerLanguage: 'en-US',
2757
2944
  hideOnClose: undefined, // Platform-specific: true for macOS, false for others
@@ -2791,9 +2978,16 @@ function validateNumberInput(value) {
2791
2978
  }
2792
2979
  return parsedValue;
2793
2980
  }
2981
+ // Path-shaped input (./x, ../x, /x, ~/x, C:\x). A missing path must fail
2982
+ // loudly: appending https:// to "./typo" would otherwise produce a valid URL
2983
+ // like https://./typo and a silently broken app (worst case for agents).
2984
+ const PATH_LIKE_PATTERN = /^(\.{1,2}[\\/]|[\\/]|~[\\/]|[a-zA-Z]:[\\/])/;
2794
2985
  function validateUrlInput(url) {
2795
2986
  const isFile = fs.existsSync(url);
2796
2987
  if (!isFile) {
2988
+ if (PATH_LIKE_PATTERN.test(url)) {
2989
+ throw new InvalidArgumentError(`Local path "${url}" does not exist. Check the path, or pass a web URL instead.`);
2990
+ }
2797
2991
  try {
2798
2992
  return normalizeUrl(url);
2799
2993
  }
@@ -2829,6 +3023,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2829
3023
  .option('--use-local-file', 'Use local file packaging', DEFAULT_PAKE_OPTIONS.useLocalFile)
2830
3024
  .option('--fullscreen', 'Start in full screen', DEFAULT_PAKE_OPTIONS.fullscreen)
2831
3025
  .option('--hide-title-bar', 'For Mac, hide title bar', DEFAULT_PAKE_OPTIONS.hideTitleBar)
3026
+ .option('--hide-window-decorations', 'Hide native window decorations on Windows and Linux', DEFAULT_PAKE_OPTIONS.hideWindowDecorations)
2832
3027
  .option('--multi-arch', 'For Mac, both Intel and M1', DEFAULT_PAKE_OPTIONS.multiArch)
2833
3028
  .option('--inject <files>', 'Inject local CSS/JS files into the page', (val, previous) => {
2834
3029
  if (!val)
@@ -2842,6 +3037,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2842
3037
  return previous ? [...previous, ...files] : files;
2843
3038
  }, DEFAULT_PAKE_OPTIONS.inject)
2844
3039
  .option('--debug', 'Debug build and more output', DEFAULT_PAKE_OPTIONS.debug)
3040
+ .option('--json', 'Machine-readable output: logs to stderr, one JSON result on stdout', DEFAULT_PAKE_OPTIONS.json)
3041
+ .option('--config <path>', 'Load options from a JSON config file (fields mirror CLI options, see schema/pake.schema.json)')
2845
3042
  .addOption(new Option('--proxy-url <url>', 'Proxy URL for all network requests (http://, https://, socks5://)')
2846
3043
  .default(DEFAULT_PAKE_OPTIONS.proxyUrl)
2847
3044
  .hideHelp())
@@ -2974,16 +3171,181 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2974
3171
  });
2975
3172
  }
2976
3173
 
3174
+ // Invocation concerns, not app manifest fields; pass these as CLI flags.
3175
+ const REJECTED_KEYS = new Set(['config', 'json', 'version']);
3176
+ // Optional CLI options that have no entry in DEFAULT_PAKE_OPTIONS.
3177
+ const EXTRA_STRING_KEYS = new Set(['name', 'title', 'identifier']);
3178
+ function expectedTypeFor(key) {
3179
+ if (key === 'inject')
3180
+ return 'string[]';
3181
+ if (key === 'hideOnClose')
3182
+ return 'boolean';
3183
+ if (EXTRA_STRING_KEYS.has(key))
3184
+ return 'string';
3185
+ const defaultValue = DEFAULT_PAKE_OPTIONS[key];
3186
+ const type = typeof defaultValue;
3187
+ if (type === 'string' || type === 'number' || type === 'boolean') {
3188
+ return type;
3189
+ }
3190
+ return null;
3191
+ }
3192
+ function matchesType(value, type) {
3193
+ if (type === 'string[]') {
3194
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
3195
+ }
3196
+ return typeof value === type;
3197
+ }
3198
+ async function loadConfigFile(configPath, validKeys) {
3199
+ if (!(await fsExtra.pathExists(configPath))) {
3200
+ throw new PakeError(`Config file not found: ${configPath}`, {
3201
+ code: 'INVALID_INPUT',
3202
+ hint: 'Pass a path to a JSON file matching schema/pake.schema.json.',
3203
+ });
3204
+ }
3205
+ let parsed;
3206
+ try {
3207
+ parsed = JSON.parse(await fsExtra.readFile(configPath, 'utf8'));
3208
+ }
3209
+ catch (error) {
3210
+ const detail = error instanceof Error ? error.message : String(error);
3211
+ throw new PakeError(`Config file is not valid JSON: ${detail}`, {
3212
+ code: 'INVALID_INPUT',
3213
+ hint: `Fix the JSON syntax in ${configPath}.`,
3214
+ });
3215
+ }
3216
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
3217
+ throw new PakeError('Config file must contain a JSON object.', {
3218
+ code: 'INVALID_INPUT',
3219
+ hint: 'See schema/pake.schema.json for the expected shape.',
3220
+ });
3221
+ }
3222
+ const result = { options: {} };
3223
+ for (const [key, value] of Object.entries(parsed)) {
3224
+ if (key === '$schema')
3225
+ continue;
3226
+ if (key === 'url') {
3227
+ if (typeof value !== 'string') {
3228
+ throw new PakeError('Config field "url" must be a string.', {
3229
+ code: 'INVALID_INPUT',
3230
+ hint: 'Use a web URL or a local file/directory path.',
3231
+ });
3232
+ }
3233
+ result.url = value;
3234
+ continue;
3235
+ }
3236
+ if (REJECTED_KEYS.has(key)) {
3237
+ throw new PakeError(`Config field "${key}" is not allowed in a config file.`, {
3238
+ code: 'INVALID_INPUT',
3239
+ hint: `Pass --${key} on the command line instead.`,
3240
+ });
3241
+ }
3242
+ if (!validKeys.has(key)) {
3243
+ throw new PakeError(`Unknown config field "${key}".`, {
3244
+ code: 'INVALID_INPUT',
3245
+ hint: 'Field names are camelCase CLI option names; see schema/pake.schema.json.',
3246
+ });
3247
+ }
3248
+ const expected = expectedTypeFor(key);
3249
+ if (expected && !matchesType(value, expected)) {
3250
+ throw new PakeError(`Config field "${key}" must be of type ${expected}.`, {
3251
+ code: 'INVALID_INPUT',
3252
+ hint: 'See schema/pake.schema.json for field types.',
3253
+ });
3254
+ }
3255
+ if (!expected && (typeof value === 'object' || value === null)) {
3256
+ throw new PakeError(`Config field "${key}" must be a string, number, or boolean.`, {
3257
+ code: 'INVALID_INPUT',
3258
+ hint: 'See schema/pake.schema.json for field types.',
3259
+ });
3260
+ }
3261
+ result.options[key] = value;
3262
+ }
3263
+ return result;
3264
+ }
3265
+
2977
3266
  const program = getCliProgram();
3267
+ // Make commander throw instead of exiting so option/argument parse errors
3268
+ // honor the exit-code contract (2 = invalid input) and still emit the JSON
3269
+ // result object when --json was requested.
3270
+ program.exitOverride();
3271
+ function isCommanderExit(error) {
3272
+ return (typeof error === 'object' &&
3273
+ error !== null &&
3274
+ typeof error.code === 'string' &&
3275
+ error.code.startsWith('commander.'));
3276
+ }
3277
+ const PHASE_ERROR_CODES = {
3278
+ input: 'INVALID_INPUT',
3279
+ prepare: 'ENV_MISSING',
3280
+ build: 'BUILD_FAILED',
3281
+ };
3282
+ function classifyError(error, phase) {
3283
+ if (isPakeError(error)) {
3284
+ return {
3285
+ code: error.code ?? PHASE_ERROR_CODES[phase],
3286
+ message: error.message,
3287
+ hint: error.hint ?? null,
3288
+ };
3289
+ }
3290
+ if (error instanceof Error) {
3291
+ return {
3292
+ code: PHASE_ERROR_CODES[phase],
3293
+ message: error.message,
3294
+ hint: null,
3295
+ };
3296
+ }
3297
+ return {
3298
+ code: 'UNEXPECTED',
3299
+ message: `Unexpected error: ${String(error)}`,
3300
+ hint: null,
3301
+ };
3302
+ }
2978
3303
  async function checkUpdateTips() {
2979
3304
  updateNotifier({ pkg: packageJson, updateCheckInterval: 1000 * 60 }).notify({
2980
3305
  isGlobal: true,
2981
3306
  });
2982
3307
  }
2983
- program.action(async (url, options) => {
3308
+ program.action(async (urlArg, options) => {
3309
+ const jsonMode = Boolean(options.json);
3310
+ if (jsonMode) {
3311
+ enableMachineMode();
3312
+ }
3313
+ let phase = 'input';
3314
+ let appName = null;
3315
+ let url = urlArg;
2984
3316
  try {
2985
- await checkUpdateTips();
3317
+ if (!jsonMode) {
3318
+ await checkUpdateTips();
3319
+ }
3320
+ // Config file fills in whatever the command line did not set explicitly:
3321
+ // CLI flag > config field > built-in default.
3322
+ if (options.config) {
3323
+ const validKeys = new Set(program.options.map((option) => option.attributeName()));
3324
+ const loaded = await loadConfigFile(options.config, validKeys);
3325
+ for (const [key, value] of Object.entries(loaded.options)) {
3326
+ if (program.getOptionValueSource(key) !== 'cli') {
3327
+ options[key] = value;
3328
+ }
3329
+ }
3330
+ if (!url && loaded.url) {
3331
+ try {
3332
+ url = validateUrlInput(loaded.url);
3333
+ }
3334
+ catch (error) {
3335
+ const detail = error instanceof Error ? error.message : String(error);
3336
+ throw new PakeError(`Invalid "url" in config file: ${detail}`, {
3337
+ code: 'INVALID_INPUT',
3338
+ });
3339
+ }
3340
+ }
3341
+ }
2986
3342
  if (!url) {
3343
+ if (jsonMode) {
3344
+ throw new PakeError('No URL or local path to package.', {
3345
+ code: 'INVALID_INPUT',
3346
+ hint: 'Pass a URL/path argument or a config file with a "url" field.',
3347
+ });
3348
+ }
2987
3349
  program.help({
2988
3350
  error: false,
2989
3351
  });
@@ -2995,13 +3357,47 @@ program.action(async (url, options) => {
2995
3357
  log.setLevel('debug');
2996
3358
  }
2997
3359
  const appOptions = await handleOptions(options, url);
3360
+ appName = appOptions.name ?? null;
2998
3361
  const builder = BuilderProvider.create(appOptions);
3362
+ phase = 'prepare';
2999
3363
  await builder.prepare();
3364
+ phase = 'build';
3000
3365
  await builder.build(url);
3366
+ if (jsonMode) {
3367
+ printJsonResult({
3368
+ ok: true,
3369
+ name: appName,
3370
+ platform: process.platform,
3371
+ arch: builder.getReportArch(),
3372
+ outputs: builder.getArtifacts(),
3373
+ warnings: getCapturedWarnings(),
3374
+ error: null,
3375
+ });
3376
+ }
3001
3377
  }
3002
3378
  catch (error) {
3003
- if (isPakeError(error)) {
3004
- console.error(chalk.red(error.message));
3379
+ // program.help() and --help/--version throw under exitOverride with
3380
+ // exitCode 0; a clean commander exit is not a failure.
3381
+ if (isCommanderExit(error) && error.exitCode === 0) {
3382
+ process.exit(0);
3383
+ }
3384
+ const classified = classifyError(error, phase);
3385
+ if (jsonMode) {
3386
+ printJsonResult({
3387
+ ok: false,
3388
+ name: appName,
3389
+ platform: process.platform,
3390
+ arch: null,
3391
+ outputs: [],
3392
+ warnings: getCapturedWarnings(),
3393
+ error: classified,
3394
+ });
3395
+ }
3396
+ else if (isPakeError(error)) {
3397
+ console.error(chalk.red(classified.message));
3398
+ if (classified.hint) {
3399
+ console.error(chalk.yellow(`✼ ${classified.hint}`));
3400
+ }
3005
3401
  }
3006
3402
  else if (error instanceof Error) {
3007
3403
  console.error(chalk.red(`✕ ${error.message}`));
@@ -3012,10 +3408,35 @@ program.action(async (url, options) => {
3012
3408
  else {
3013
3409
  console.error(chalk.red(`✕ Unexpected error: ${String(error)}`));
3014
3410
  }
3015
- process.exit(1);
3411
+ process.exit(ERROR_EXIT_CODES[classified.code]);
3016
3412
  }
3017
3413
  });
3018
3414
  program.parseAsync().catch((error) => {
3415
+ if (isCommanderExit(error)) {
3416
+ // --help / --version and friends exit clean; commander already printed.
3417
+ if (error.exitCode === 0) {
3418
+ process.exit(0);
3419
+ }
3420
+ // Parse errors (unknown option, invalid argument, missing value) are
3421
+ // invalid input. Commander already printed the message to stderr; in
3422
+ // json mode also emit the machine-readable result on stdout.
3423
+ if (process.argv.includes('--json')) {
3424
+ printJsonResult({
3425
+ ok: false,
3426
+ name: null,
3427
+ platform: process.platform,
3428
+ arch: null,
3429
+ outputs: [],
3430
+ warnings: [],
3431
+ error: {
3432
+ code: 'INVALID_INPUT',
3433
+ message: error.message.trim(),
3434
+ hint: 'Run pake --help for the accepted options.',
3435
+ },
3436
+ });
3437
+ }
3438
+ process.exit(ERROR_EXIT_CODES.INVALID_INPUT);
3439
+ }
3019
3440
  if (error instanceof Error) {
3020
3441
  console.error(chalk.red(`✕ ${error.message}`));
3021
3442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.13.1",
3
+ "version": "3.15.0",
4
4
  "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.13.1"
2567
+ version = "3.15.0"
2568
2568
  dependencies = [
2569
2569
  "objc2",
2570
2570
  "objc2-app-kit",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.13.1"
3
+ version = "3.15.0"
4
4
  description = "🤱🏻 Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "GPL-3.0-or-later"
Binary file
Binary file
@@ -4,6 +4,7 @@
4
4
  "url": "https://weekly.tw93.fun/en",
5
5
  "url_type": "web",
6
6
  "hide_title_bar": true,
7
+ "hide_window_decorations": false,
7
8
  "fullscreen": false,
8
9
  "width": 1200,
9
10
  "height": 780,
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
4
4
  pub struct WindowConfig {
5
5
  pub url: String,
6
6
  pub hide_title_bar: bool,
7
+ #[serde(default)]
8
+ pub hide_window_decorations: bool,
7
9
  pub fullscreen: bool,
8
10
  pub maximize: bool,
9
11
  pub width: f64,
@@ -22,7 +22,10 @@ pub fn set_system_tray(
22
22
  return Ok(());
23
23
  }
24
24
 
25
- let new_window = MenuItemBuilder::with_id("new_window", "New Window").build(app)?;
25
+ // Menu events are broadcast to every handler in Tauri v2, so the tray item
26
+ // must not share the "new_window" id with the app menu accelerator
27
+ // (Cmd/Ctrl+N), or one click opens two windows.
28
+ let new_window = MenuItemBuilder::with_id("tray_new_window", "New Window").build(app)?;
26
29
  let hide_app = MenuItemBuilder::with_id("hide_app", "Hide").build(app)?;
27
30
  let show_app = MenuItemBuilder::with_id("show_app", "Show").build(app)?;
28
31
  let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
@@ -42,7 +45,7 @@ pub fn set_system_tray(
42
45
  let mut tray_builder = TrayIconBuilder::new()
43
46
  .menu(&menu)
44
47
  .on_menu_event(move |app, event| match event.id().as_ref() {
45
- "new_window" => {
48
+ "tray_new_window" => {
46
49
  open_additional_window_safe(app);
47
50
  }
48
51
  "hide_app" => {
@@ -380,6 +380,10 @@ fn build_window(
380
380
  {
381
381
  window_builder = window_builder.data_directory(_data_dir).theme(theme);
382
382
 
383
+ if window_config.hide_window_decorations {
384
+ window_builder = window_builder.decorations(false);
385
+ }
386
+
383
387
  if !config.proxy_url.is_empty() {
384
388
  if let Ok(proxy_url) = Url::from_str(&config.proxy_url) {
385
389
  parsed_proxy_url = Some(proxy_url.clone());
@@ -15,25 +15,32 @@ function setZoom(zoom) {
15
15
  // CSS hacks. `transform: scale` and `html.style.zoom` break complex SPAs like
16
16
  // ChatGPT: the page shifts right on Windows and parts of the UI stop repainting
17
17
  // on macOS. Native zoom recalculates layout exactly like a browser does.
18
+ const zoomPercent = normalizeZoomPercent(zoom);
19
+ const normalizedZoom = `${zoomPercent}%`;
18
20
  const invoke = window.__TAURI__?.core?.invoke;
19
21
  if (invoke) {
20
- invoke("set_zoom", { percent: parseFloat(zoom) }).catch(() => {});
22
+ invoke("set_zoom", { percent: zoomPercent }).catch(() => {});
21
23
  }
22
24
 
23
- window.localStorage.setItem("htmlZoom", zoom);
25
+ window.localStorage.setItem("htmlZoom", normalizedZoom);
24
26
  }
25
27
 
26
28
  function zoomCommon(zoomChange) {
27
29
  const currentZoom = window.localStorage.getItem("htmlZoom") || "100%";
28
- setZoom(zoomChange(currentZoom));
30
+ setZoom(zoomChange(normalizeZoomPercent(currentZoom)));
29
31
  }
30
32
 
31
33
  function zoomIn() {
32
- zoomCommon((currentZoom) => `${Math.min(parseInt(currentZoom) + 10, 200)}%`);
34
+ zoomCommon((currentZoom) => `${Math.min(currentZoom + 10, 200)}%`);
33
35
  }
34
36
 
35
37
  function zoomOut() {
36
- zoomCommon((currentZoom) => `${Math.max(parseInt(currentZoom) - 10, 30)}%`);
38
+ zoomCommon((currentZoom) => `${Math.max(currentZoom - 10, 30)}%`);
39
+ }
40
+
41
+ function normalizeZoomPercent(zoom) {
42
+ const parsed = parseFloat(zoom);
43
+ return Number.isFinite(parsed) ? parsed : 100;
37
44
  }
38
45
 
39
46
  let pasteAsPlainTextPending = false;
@@ -53,8 +60,51 @@ function handleShortcut(event) {
53
60
  }
54
61
  }
55
62
 
63
+ function toggleNativeFullscreen(appWindow) {
64
+ appWindow
65
+ .isFullscreen()
66
+ .then((fullscreen) => appWindow.setFullscreen(!fullscreen))
67
+ .catch((error) => {
68
+ console.warn("[Pake] Failed to toggle native fullscreen:", error);
69
+ });
70
+ }
71
+
72
+ function handleWindowFullscreenShortcut(event) {
73
+ if (
74
+ !event.isTrusted ||
75
+ event.repeat ||
76
+ event.key !== "F11" ||
77
+ !isNonMacDesktop()
78
+ ) {
79
+ return;
80
+ }
81
+
82
+ const appWindow = window.__TAURI__?.window?.getCurrentWindow?.();
83
+ if (!appWindow) {
84
+ return;
85
+ }
86
+
87
+ event.preventDefault();
88
+ event.stopImmediatePropagation();
89
+ toggleNativeFullscreen(appWindow);
90
+ }
91
+
92
+ function getDesktopPlatform() {
93
+ return (
94
+ navigator.userAgentData?.platform ||
95
+ navigator.platform ||
96
+ navigator.userAgent
97
+ );
98
+ }
99
+
56
100
  function isNonMacDesktop() {
57
- return /windows|linux/i.test(navigator.userAgent);
101
+ return /win|linux/i.test(getDesktopPlatform());
102
+ }
103
+
104
+ function hasImmersiveHeader(config = window["pakeConfig"] || {}) {
105
+ return /mac/i.test(getDesktopPlatform())
106
+ ? config.hide_title_bar === true
107
+ : config.hide_window_decorations === true;
58
108
  }
59
109
 
60
110
  function isEditableElement(element) {
@@ -156,19 +206,9 @@ function insertTextIntoEditableElement(element, text) {
156
206
  return false;
157
207
  }
158
208
 
159
- function runBrowserPasteCommand() {
160
- try {
161
- return document.execCommand("paste") === true;
162
- } catch (error) {
163
- return false;
164
- }
165
- }
209
+ let clipboardPasteFallbackTarget;
166
210
 
167
211
  function pasteClipboardText(activeElement) {
168
- if (runBrowserPasteCommand()) {
169
- return;
170
- }
171
-
172
212
  const readText = navigator.clipboard?.readText;
173
213
  if (typeof readText !== "function") {
174
214
  return;
@@ -211,9 +251,11 @@ function handleClipboardShortcut(event) {
211
251
  }
212
252
 
213
253
  if (key === "v" && canPasteIntoEditableElement(activeElement)) {
214
- event.preventDefault();
215
- pasteClipboardText(activeElement);
216
- return true;
254
+ // Let the native WebView paste event run first so images, files, and rich
255
+ // clipboard formats remain intact. If the platform does not emit paste,
256
+ // keyup applies the existing text-only fallback.
257
+ clipboardPasteFallbackTarget = activeElement;
258
+ return false;
217
259
  }
218
260
 
219
261
  if (key === "a" && isEditable && selectEditableElement(activeElement)) {
@@ -224,6 +266,42 @@ function handleClipboardShortcut(event) {
224
266
  return false;
225
267
  }
226
268
 
269
+ function handleClipboardPasteFallback(event) {
270
+ if (
271
+ event.isTrusted !== true ||
272
+ !isNonMacDesktop() ||
273
+ event.key?.toLowerCase() !== "v"
274
+ ) {
275
+ return false;
276
+ }
277
+
278
+ const activeElement = clipboardPasteFallbackTarget;
279
+ clipboardPasteFallbackTarget = undefined;
280
+ if (
281
+ !activeElement ||
282
+ document.activeElement !== activeElement ||
283
+ !canPasteIntoEditableElement(activeElement)
284
+ ) {
285
+ return false;
286
+ }
287
+
288
+ pasteClipboardText(activeElement);
289
+ return true;
290
+ }
291
+
292
+ function handlePaste(event) {
293
+ clipboardPasteFallbackTarget = undefined;
294
+ if (!pasteAsPlainTextPending) return;
295
+
296
+ event.preventDefault();
297
+ event.stopImmediatePropagation();
298
+
299
+ const text = event.clipboardData?.getData("text/plain") || "";
300
+ if (text) {
301
+ document.execCommand("insertText", false, text);
302
+ }
303
+ }
304
+
227
305
  const DOWNLOADABLE_FILE_EXTENSIONS = {
228
306
  documents: [
229
307
  "pdf",
@@ -502,7 +580,7 @@ document.addEventListener("DOMContentLoaded", () => {
502
580
  }
503
581
  }
504
582
 
505
- if (!document.getElementById("pake-top-dom")) {
583
+ if (!document.getElementById("pake-top-dom") && hasImmersiveHeader()) {
506
584
  const topDom = document.createElement("div");
507
585
  topDom.id = "pake-top-dom";
508
586
  document.body.appendChild(topDom);
@@ -510,24 +588,25 @@ document.addEventListener("DOMContentLoaded", () => {
510
588
 
511
589
  const domEl = document.getElementById("pake-top-dom");
512
590
 
513
- domEl.addEventListener("touchstart", () => {
514
- appWindow.startDragging();
515
- });
516
-
517
- domEl.addEventListener("mousedown", (e) => {
518
- e.preventDefault();
519
- if (e.buttons === 1 && e.detail !== 2) {
591
+ if (domEl) {
592
+ domEl.addEventListener("touchstart", () => {
520
593
  appWindow.startDragging();
521
- }
522
- });
594
+ });
523
595
 
524
- domEl.addEventListener("dblclick", () => {
525
- appWindow.isFullscreen().then((fullscreen) => {
526
- appWindow.setFullscreen(!fullscreen);
596
+ domEl.addEventListener("mousedown", (e) => {
597
+ e.preventDefault();
598
+ if (e.buttons === 1 && e.detail !== 2) {
599
+ appWindow.startDragging();
600
+ }
527
601
  });
528
- });
602
+
603
+ domEl.addEventListener("dblclick", () => {
604
+ toggleNativeFullscreen(appWindow);
605
+ });
606
+ }
529
607
 
530
608
  if (window["pakeConfig"]?.disabled_web_shortcuts !== true) {
609
+ document.addEventListener("keydown", handleWindowFullscreenShortcut, true);
531
610
  document.addEventListener("keyup", (event) => {
532
611
  if (/windows|linux/i.test(navigator.userAgent) && event.ctrlKey) {
533
612
  handleShortcut(event);
@@ -539,22 +618,8 @@ document.addEventListener("DOMContentLoaded", () => {
539
618
  }
540
619
 
541
620
  document.addEventListener("keydown", handleClipboardShortcut, true);
542
-
543
- document.addEventListener(
544
- "paste",
545
- (event) => {
546
- if (pasteAsPlainTextPending) {
547
- event.preventDefault();
548
- event.stopImmediatePropagation();
549
-
550
- const text = event.clipboardData?.getData("text/plain") || "";
551
- if (text) {
552
- document.execCommand("insertText", false, text);
553
- }
554
- }
555
- },
556
- true,
557
- );
621
+ document.addEventListener("keyup", handleClipboardPasteFallback, true);
622
+ document.addEventListener("paste", handlePaste, true);
558
623
 
559
624
  // Trigger a native browser download via a transient anchor click. The Rust
560
625
  // on_download handler then writes the file to the Downloads folder. This is
@@ -403,6 +403,11 @@ window.addEventListener("DOMContentLoaded", (_event) => {
403
403
  padding-top: 0px;
404
404
  }
405
405
 
406
+ #notion-app .notion-sidebar,#notion-app .notion-topbar{
407
+ padding-top: 20px;
408
+ box-sizing: content-box;
409
+ }
410
+
406
411
  #header-area > div > .css-gtiexd > div:nth-child(1) > div, #header-area .logoIcon .user-info{
407
412
  padding-top: 20px;
408
413
  }
@@ -497,9 +502,29 @@ window.addEventListener("DOMContentLoaded", (_event) => {
497
502
  }
498
503
  `;
499
504
  const isMac = /Mac/i.test(navigator.userAgent);
500
- if (window["pakeConfig"]?.hide_title_bar && isMac) {
505
+ if (hasImmersiveHeader(window["pakeConfig"])) {
501
506
  const topPaddingStyleElement = document.createElement("style");
502
- topPaddingStyleElement.textContent = topPaddingCSS;
507
+ topPaddingStyleElement.textContent = isMac
508
+ ? topPaddingCSS
509
+ : `
510
+ #pake-top-dom:active {
511
+ cursor: grabbing;
512
+ cursor: -webkit-grabbing;
513
+ }
514
+
515
+ #pake-top-dom {
516
+ position: fixed;
517
+ background: transparent;
518
+ top: 0;
519
+ width: 100%;
520
+ height: 20px;
521
+ cursor: grab;
522
+ -webkit-app-region: drag;
523
+ user-select: none;
524
+ -webkit-user-select: none;
525
+ z-index: 99999;
526
+ }
527
+ `;
503
528
  document.head.appendChild(topPaddingStyleElement);
504
529
  }
505
530
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.13.1",
4
+ "version": "3.15.0",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {