pake-cli 3.10.0 โ†’ 3.11.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
@@ -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://tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
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.
181
181
 
182
182
  ```bash
183
183
  # Install dependencies
@@ -202,7 +202,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c
202
202
 
203
203
  ## Support
204
204
 
205
- <a href="https://miaoyan.app/cats.html?name=Pake"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000px" /></a>
205
+ <a href="https://miaoyan.app/cats.html?name=Pake"><img src="https://rawcdn.githack.com/tw93/MiaoYan/vercel/assets/sponsors.svg" width="1000px" /></a>
206
206
 
207
207
  1. 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">food ๐Ÿฅฉ</a>.
208
208
  2. If you like Pake, you can star it on GitHub. Also, welcome to [recommend Pake](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) to your friends.
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import * as psl from 'psl';
22
22
  import { InvalidArgumentError, program as program$1, Option } from 'commander';
23
23
 
24
24
  var name = "pake-cli";
25
- var version = "3.10.0";
25
+ var version = "3.11.0";
26
26
  var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚";
27
27
  var engines = {
28
28
  node: ">=18.0.0"
@@ -171,14 +171,29 @@ let tauriConfig = {
171
171
  pake: pakeConf,
172
172
  };
173
173
 
174
- // Generates an identifier based on the given URL.
175
- function getIdentifier(url) {
174
+ // Generates a stable identifier based on the app URL (and optionally name).
175
+ // When name is provided it is included in the hash so two apps wrapping
176
+ // the same URL can coexist. Omitting name preserves backward compatibility
177
+ // with identifiers generated before V3.10.1.
178
+ function getIdentifier(url, name) {
179
+ const hashInput = name ? `${url}::${name}` : url;
176
180
  const postFixHash = crypto
177
181
  .createHash('md5')
178
- .update(url)
182
+ .update(hashInput)
179
183
  .digest('hex')
180
184
  .substring(0, 6);
181
- return `com.pake.${postFixHash}`;
185
+ return `com.pake.a${postFixHash}`;
186
+ }
187
+ function resolveIdentifier(url, explicitName, customIdentifier) {
188
+ const trimmedIdentifier = customIdentifier?.trim();
189
+ if (trimmedIdentifier) {
190
+ if (!/^[a-zA-Z][a-zA-Z0-9.-]*[a-zA-Z0-9]$/.test(trimmedIdentifier)) {
191
+ throw new Error(`Invalid identifier "${trimmedIdentifier}". Must start with a letter, ` +
192
+ `contain only letters, digits, hyphens, and dots, and end with a letter or digit.`);
193
+ }
194
+ return trimmedIdentifier;
195
+ }
196
+ return getIdentifier(url, explicitName);
182
197
  }
183
198
  async function promptText(message, initial) {
184
199
  const response = await prompts({
@@ -484,7 +499,7 @@ async function mergeConfig(url, options, tauriConf) {
484
499
  await fsExtra.copy(sourcePath, destPath);
485
500
  }
486
501
  }));
487
- const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name = 'pake-app', resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, multiInstance, multiWindow, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, } = options;
502
+ const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name = 'pake-app', resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, multiInstance, multiWindow, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, camera, microphone, } = options;
488
503
  const { platform } = process;
489
504
  const platformHideOnClose = hideOnClose ?? platform === 'darwin';
490
505
  const tauriConfWindowOptions = {
@@ -739,6 +754,26 @@ Terminal=false
739
754
  },
740
755
  };
741
756
  }
757
+ // Write entitlements dynamically on macOS so camera/microphone are opt-in
758
+ if (platform === 'darwin') {
759
+ const entitlementEntries = [];
760
+ if (camera) {
761
+ entitlementEntries.push(' <key>com.apple.security.device.camera</key>\n <true/>');
762
+ }
763
+ if (microphone) {
764
+ entitlementEntries.push(' <key>com.apple.security.device.audio-input</key>\n <true/>');
765
+ }
766
+ const entitlementsContent = `<?xml version="1.0" encoding="UTF-8"?>
767
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
768
+ <plist version="1.0">
769
+ <dict>
770
+ ${entitlementEntries.join('\n')}
771
+ </dict>
772
+ </plist>
773
+ `;
774
+ const entitlementsPath = path.join(npmDirectory, 'src-tauri', 'entitlements.plist');
775
+ await fsExtra.writeFile(entitlementsPath, entitlementsContent);
776
+ }
742
777
  // Save config file.
743
778
  const platformConfigPaths = {
744
779
  win32: 'tauri.windows.conf.json',
@@ -962,6 +997,27 @@ class BaseBuilder {
962
997
  const binaryPath = this.getRawBinaryPath(name);
963
998
  logger.success('โœ” Raw binary located in', path.resolve(binaryPath));
964
999
  }
1000
+ if (IS_MAC && fileType === 'app' && this.options.install) {
1001
+ await this.installAppToApplications(distPath, name);
1002
+ }
1003
+ }
1004
+ async installAppToApplications(appBundlePath, appName) {
1005
+ try {
1006
+ logger.info(`- Installing ${appName} to /Applications...`);
1007
+ const appBundleName = path.basename(appBundlePath);
1008
+ const appDest = path.join('/Applications', appBundleName);
1009
+ if (await fsExtra.pathExists(appDest)) {
1010
+ logger.warn(` Existing ${appBundleName} in /Applications will be replaced.`);
1011
+ }
1012
+ // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back
1013
+ // to copy+remove only when moving across volumes.
1014
+ await fsExtra.move(appBundlePath, appDest, { overwrite: true });
1015
+ logger.success(`โœ” ${appBundleName.replace(/\.app$/, '')} installed to /Applications`);
1016
+ }
1017
+ catch (error) {
1018
+ logger.error(`โœ• Failed to install ${appName}: ${error}`);
1019
+ logger.info(` App bundle still available at: ${appBundlePath}`);
1020
+ }
965
1021
  }
966
1022
  getFileType(target) {
967
1023
  return target;
@@ -1162,7 +1218,9 @@ class MacBuilder extends BaseBuilder {
1162
1218
  this.buildArch = validArchs.includes(options.targets || '')
1163
1219
  ? options.targets
1164
1220
  : 'auto';
1165
- if (options.iterativeBuild || process.env.PAKE_CREATE_APP === '1') {
1221
+ if (options.iterativeBuild ||
1222
+ options.install ||
1223
+ process.env.PAKE_CREATE_APP === '1') {
1166
1224
  this.buildFormat = 'app';
1167
1225
  }
1168
1226
  else {
@@ -1804,6 +1862,23 @@ function generateIconServiceUrls(domain) {
1804
1862
  `https://www.${domain}/favicon.ico`,
1805
1863
  ];
1806
1864
  }
1865
+ /**
1866
+ * Generates dashboard-icons URLs for an app name.
1867
+ * Uses walkxcode/dashboard-icons as a final fallback for selfhosted apps.
1868
+ * Keeps matching conservative to avoid overriding valid site-specific icons.
1869
+ */
1870
+ function generateDashboardIconUrls(appName) {
1871
+ const baseUrl = 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png';
1872
+ const name = appName.toLowerCase().trim();
1873
+ const slugs = new Set();
1874
+ // Exact name
1875
+ slugs.add(name);
1876
+ // Replace spaces with hyphens
1877
+ slugs.add(name.replace(/\s+/g, '-'));
1878
+ return [...slugs]
1879
+ .filter((s) => s.length > 0)
1880
+ .map((slug) => `${baseUrl}/${slug}.png`);
1881
+ }
1807
1882
  /**
1808
1883
  * Attempts to fetch favicon from website
1809
1884
  */
@@ -1811,11 +1886,11 @@ async function tryGetFavicon(url, appName) {
1811
1886
  try {
1812
1887
  const domain = new URL(url).hostname;
1813
1888
  const spinner = getSpinner(`Fetching icon from ${domain}...`);
1814
- const serviceUrls = generateIconServiceUrls(domain);
1815
1889
  const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
1816
1890
  const downloadTimeout = isCI
1817
1891
  ? ICON_CONFIG.downloadTimeout.ci
1818
1892
  : ICON_CONFIG.downloadTimeout.default;
1893
+ const serviceUrls = generateIconServiceUrls(domain);
1819
1894
  for (const serviceUrl of serviceUrls) {
1820
1895
  try {
1821
1896
  const faviconPath = await downloadIcon(serviceUrl, false, downloadTimeout);
@@ -1835,6 +1910,30 @@ async function tryGetFavicon(url, appName) {
1835
1910
  continue;
1836
1911
  }
1837
1912
  }
1913
+ // Final fallback for selfhosted apps behind auth where domain-based
1914
+ // services cannot access the site favicon.
1915
+ if (appName) {
1916
+ const dashboardIconUrls = generateDashboardIconUrls(appName);
1917
+ for (const iconUrl of dashboardIconUrls) {
1918
+ try {
1919
+ const iconPath = await downloadIcon(iconUrl, false, downloadTimeout);
1920
+ if (!iconPath)
1921
+ continue;
1922
+ const convertedPath = await convertIconFormat(iconPath, appName);
1923
+ if (convertedPath) {
1924
+ const finalPath = await copyWindowsIconIfNeeded(convertedPath, appName);
1925
+ spinner.succeed(chalk.green(`Icon found via dashboard-icons fallback for "${appName}"!`));
1926
+ return finalPath;
1927
+ }
1928
+ }
1929
+ catch (error) {
1930
+ if (error instanceof Error) {
1931
+ logger.debug(`Dashboard icon ${iconUrl} failed: ${error.message}`);
1932
+ }
1933
+ continue;
1934
+ }
1935
+ }
1936
+ }
1838
1937
  spinner.warn(`No favicon found for ${domain}. Using default.`);
1839
1938
  return null;
1840
1939
  }
@@ -1992,10 +2091,11 @@ async function handleOptions(options, url) {
1992
2091
  process.exit(1);
1993
2092
  }
1994
2093
  }
2094
+ const resolvedName = name || 'pake-app';
1995
2095
  const appOptions = {
1996
2096
  ...options,
1997
- name,
1998
- identifier: getIdentifier(url),
2097
+ name: resolvedName,
2098
+ identifier: resolveIdentifier(url, options.name, options.identifier),
1999
2099
  };
2000
2100
  const iconPath = await handleIcon(appOptions, url);
2001
2101
  appOptions.icon = iconPath || '';
@@ -2051,6 +2151,9 @@ const DEFAULT_PAKE_OPTIONS = {
2051
2151
  minHeight: 0,
2052
2152
  ignoreCertificateErrors: false,
2053
2153
  newWindow: false,
2154
+ install: false,
2155
+ camera: false,
2156
+ microphone: false,
2054
2157
  };
2055
2158
 
2056
2159
  function validateNumberInput(value) {
@@ -2090,6 +2193,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2090
2193
  .showHelpAfterError()
2091
2194
  .argument('[url]', 'The web URL you want to package', validateUrlInput)
2092
2195
  .option('--name <string>', 'Application name')
2196
+ .addOption(new Option('--identifier <string>', 'Application identifier / bundle ID').hideHelp())
2093
2197
  .option('--icon <string>', 'Application icon', DEFAULT_PAKE_OPTIONS.icon)
2094
2198
  .option('--width <number>', 'Window width', validateNumberInput, DEFAULT_PAKE_OPTIONS.width)
2095
2199
  .option('--height <number>', 'Window height', validateNumberInput, DEFAULT_PAKE_OPTIONS.height)
@@ -2207,9 +2311,16 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2207
2311
  .addOption(new Option('--iterative-build', 'Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging')
2208
2312
  .default(DEFAULT_PAKE_OPTIONS.iterativeBuild)
2209
2313
  .hideHelp())
2210
- .addOption(new Option('--new-window', 'Allow new window for third-party login')
2314
+ .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)')
2211
2315
  .default(DEFAULT_PAKE_OPTIONS.newWindow)
2212
2316
  .hideHelp())
2317
+ .option('--install', 'Auto-install app to /Applications (macOS) after build and remove local bundle', DEFAULT_PAKE_OPTIONS.install)
2318
+ .addOption(new Option('--camera', 'Request camera permission on macOS')
2319
+ .default(DEFAULT_PAKE_OPTIONS.camera)
2320
+ .hideHelp())
2321
+ .addOption(new Option('--microphone', 'Request microphone permission on macOS')
2322
+ .default(DEFAULT_PAKE_OPTIONS.microphone)
2323
+ .hideHelp())
2213
2324
  .version(packageJson.version, '-v, --version')
2214
2325
  .configureHelp({
2215
2326
  sortSubcommands: true,
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { execSync } from 'child_process';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ class CodeReviewGraph {
7
+ graph;
8
+ constructor() {
9
+ this.graph = {
10
+ contributors: new Map(),
11
+ prs: [],
12
+ reviewRelations: new Map(),
13
+ timeRange: { start: '', end: '' }
14
+ };
15
+ }
16
+ async build() {
17
+ console.log('Analyzing repository data...\n');
18
+ await this.analyzeCommits();
19
+ await this.analyzePRs();
20
+ this.analyzeReviewRelations();
21
+ return;
22
+ }
23
+ async analyzeCommits() {
24
+ try {
25
+ // Commands are hardcoded internal commands, not user input
26
+ const logOutput = execSync('git log --format="%H|%an|%ae|%ad|%s" --date=short -500', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
27
+ const lines = logOutput.trim().split('\n');
28
+ for (const line of lines) {
29
+ const parts = line.split('|');
30
+ if (parts.length < 5)
31
+ continue;
32
+ const [, name, email, date] = parts;
33
+ if (!this.graph.contributors.has(email)) {
34
+ this.graph.contributors.set(email, {
35
+ name,
36
+ email,
37
+ commits: 0,
38
+ prsOpened: 0,
39
+ prsMerged: 0,
40
+ reviewsGiven: 0,
41
+ reviewsReceived: 0,
42
+ firstCommit: date,
43
+ lastCommit: date,
44
+ filesChanged: new Set()
45
+ });
46
+ }
47
+ const contributor = this.graph.contributors.get(email);
48
+ contributor.commits++;
49
+ contributor.lastCommit = date;
50
+ if (!this.graph.timeRange.start || date < this.graph.timeRange.start) {
51
+ this.graph.timeRange.start = date;
52
+ }
53
+ if (!this.graph.timeRange.end || date > this.graph.timeRange.end) {
54
+ this.graph.timeRange.end = date;
55
+ }
56
+ }
57
+ }
58
+ catch (error) {
59
+ console.warn('Could not analyze commits:', error.message);
60
+ }
61
+ }
62
+ async analyzePRs() {
63
+ try {
64
+ const prOutput = execSync('gh pr list --state all --limit 100 --json number,title,author,state,mergedAt,createdAt,mergedBy 2>/dev/null || echo "[]"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
65
+ const prs = JSON.parse(prOutput.trim() || '[]');
66
+ this.graph.prs = prs;
67
+ for (const pr of prs) {
68
+ const authorEmail = this.findEmailByUsername(pr.author.login);
69
+ if (authorEmail && this.graph.contributors.has(authorEmail)) {
70
+ const contributor = this.graph.contributors.get(authorEmail);
71
+ contributor.prsOpened++;
72
+ if (pr.state === 'MERGED') {
73
+ contributor.prsMerged++;
74
+ }
75
+ }
76
+ if (pr.mergedBy?.login) {
77
+ const mergerEmail = this.findEmailByUsername(pr.mergedBy.login);
78
+ if (mergerEmail && this.graph.contributors.has(mergerEmail)) {
79
+ const merger = this.graph.contributors.get(mergerEmail);
80
+ merger.reviewsGiven++;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ catch (error) {
86
+ console.warn('Could not analyze PRs:', error.message);
87
+ }
88
+ }
89
+ findEmailByUsername(username) {
90
+ const usernameLower = username.toLowerCase();
91
+ for (const [email, contributor] of this.graph.contributors) {
92
+ if (contributor.name.toLowerCase().includes(usernameLower) ||
93
+ email.toLowerCase().includes(usernameLower)) {
94
+ return email;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ analyzeReviewRelations() {
100
+ for (const pr of this.graph.prs) {
101
+ if (pr.mergedBy?.login && pr.author.login !== pr.mergedBy.login) {
102
+ const authorKey = pr.author.login;
103
+ const mergerKey = pr.mergedBy.login;
104
+ if (!this.graph.reviewRelations.has(mergerKey)) {
105
+ this.graph.reviewRelations.set(mergerKey, new Map());
106
+ }
107
+ const relations = this.graph.reviewRelations.get(mergerKey);
108
+ relations.set(authorKey, (relations.get(authorKey) || 0) + 1);
109
+ }
110
+ }
111
+ }
112
+ generateMermaidGraph() {
113
+ const lines = [
114
+ '%% Code Review Graph for Pake',
115
+ '%% Generated automatically - do not edit manually',
116
+ '',
117
+ 'flowchart TB',
118
+ ' subgraph Contributors["Top Contributors"]'
119
+ ];
120
+ const sortedContributors = Array.from(this.graph.contributors.values())
121
+ .sort((a, b) => b.commits - a.commits)
122
+ .slice(0, 15);
123
+ const nodeMap = new Map();
124
+ let idx = 0;
125
+ for (const c of sortedContributors) {
126
+ const nodeId = `C${idx}`;
127
+ nodeMap.set(c.email, nodeId);
128
+ const prBadge = c.prsMerged > 0 ? ` PRs:${c.prsMerged}` : '';
129
+ const label = `${c.name}(${c.commits})${prBadge}`;
130
+ lines.push(` ${nodeId}["${label}"]`);
131
+ idx++;
132
+ }
133
+ lines.push(' end');
134
+ lines.push('');
135
+ const addedEdges = new Set();
136
+ for (const [reviewer, relations] of this.graph.reviewRelations) {
137
+ // reviewer is already a string (the login), not an object
138
+ const reviewerEmail = this.findEmailByUsername(reviewer);
139
+ if (!reviewerEmail || !nodeMap.has(reviewerEmail))
140
+ continue;
141
+ const reviewerNode = nodeMap.get(reviewerEmail);
142
+ for (const [author, count] of relations) {
143
+ // author is already a string (the login)
144
+ const authorEmail = this.findEmailByUsername(author);
145
+ if (!authorEmail || !nodeMap.has(authorEmail))
146
+ continue;
147
+ const authorNode = nodeMap.get(authorEmail);
148
+ const edgeKey = `${reviewerNode}-${authorNode}`;
149
+ if (!addedEdges.has(edgeKey)) {
150
+ lines.push(` ${reviewerNode} -.->|"reviews"| ${authorNode}`);
151
+ addedEdges.add(edgeKey);
152
+ }
153
+ }
154
+ }
155
+ lines.push('');
156
+ lines.push(' subgraph Recent_Merges["Recent Merged PRs"]');
157
+ const mergedPRs = this.graph.prs
158
+ .filter(pr => pr.state === 'MERGED')
159
+ .slice(0, 8);
160
+ for (let i = 0; i < mergedPRs.length; i++) {
161
+ const pr = mergedPRs[i];
162
+ const prNode = `PR${i}`;
163
+ const title = pr.title.length > 30 ? pr.title.substring(0, 30) + '...' : pr.title;
164
+ lines.push(` ${prNode}["#${pr.number}: ${title}"]`);
165
+ }
166
+ lines.push(' end');
167
+ lines.push('');
168
+ lines.push(' subgraph Stats["Statistics"]');
169
+ lines.push(` TotalContributors["Total Contributors: ${this.graph.contributors.size}"]`);
170
+ lines.push(` TotalPRs["Total PRs Analyzed: ${this.graph.prs.length}"]`);
171
+ lines.push(` MergedPRs["Merged PRs: ${this.graph.prs.filter(p => p.state === 'MERGED').length}"]`);
172
+ lines.push(` TimeRange["Period: ${this.graph.timeRange.start} to ${this.graph.timeRange.end}"]`);
173
+ lines.push(' end');
174
+ lines.push('');
175
+ lines.push(' %% Styling');
176
+ lines.push(' classDef contributor fill:#e1f5fe,stroke:#01579b,stroke-width:2px');
177
+ lines.push(' classDef reviewer fill:#fff3e0,stroke:#e65100,stroke-width:2px');
178
+ lines.push(' classDef pr fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px');
179
+ lines.push(' classDef stats fill:#f3e5f5,stroke:#6a1b9a,stroke-width:1px');
180
+ for (let i = 0; i < idx; i++) {
181
+ lines.push(` class C${i} contributor`);
182
+ }
183
+ for (let i = 0; i < mergedPRs.length; i++) {
184
+ lines.push(` class PR${i} pr`);
185
+ }
186
+ lines.push(' class TotalContributors,TotalPRs,MergedPRs,TimeRange stats');
187
+ return lines.join('\n');
188
+ }
189
+ generateJSON() {
190
+ const contributors = Array.from(this.graph.contributors.values())
191
+ .map(c => ({
192
+ ...c,
193
+ filesChanged: Array.from(c.filesChanged)
194
+ }))
195
+ .sort((a, b) => b.commits - a.commits);
196
+ return {
197
+ metadata: {
198
+ generatedAt: new Date().toISOString(),
199
+ repository: 'tw93/Pake',
200
+ timeRange: this.graph.timeRange
201
+ },
202
+ summary: {
203
+ totalContributors: this.graph.contributors.size,
204
+ totalPRs: this.graph.prs.length,
205
+ mergedPRs: this.graph.prs.filter(p => p.state === 'MERGED').length,
206
+ closedPRs: this.graph.prs.filter(p => p.state === 'CLOSED').length,
207
+ openPRs: this.graph.prs.filter(p => p.state === 'OPEN').length
208
+ },
209
+ contributors: contributors.slice(0, 20),
210
+ recentPRs: this.graph.prs.slice(0, 20),
211
+ reviewRelations: Object.fromEntries(Array.from(this.graph.reviewRelations.entries()).map(([k, v]) => [
212
+ k,
213
+ Object.fromEntries(v)
214
+ ]))
215
+ };
216
+ }
217
+ printSummary() {
218
+ console.log('\n===============================================================');
219
+ console.log(' Code Review Graph Summary ');
220
+ console.log('===============================================================\n');
221
+ console.log(`Time Range: ${this.graph.timeRange.start} to ${this.graph.timeRange.end}`);
222
+ console.log(`Total Contributors: ${this.graph.contributors.size}`);
223
+ console.log(`Total PRs Analyzed: ${this.graph.prs.length}`);
224
+ console.log(` Merged: ${this.graph.prs.filter(p => p.state === 'MERGED').length}`);
225
+ console.log(` Closed: ${this.graph.prs.filter(p => p.state === 'CLOSED').length}`);
226
+ console.log(` Open: ${this.graph.prs.filter(p => p.state === 'OPEN').length}\n`);
227
+ console.log('Top Contributors by Commits:');
228
+ const sorted = Array.from(this.graph.contributors.values())
229
+ .sort((a, b) => b.commits - a.commits)
230
+ .slice(0, 10);
231
+ for (let i = 0; i < sorted.length; i++) {
232
+ const c = sorted[i];
233
+ const rank = i === 0 ? '1.' : i === 1 ? '2.' : i === 2 ? '3.' : ' ';
234
+ const prInfo = c.prsMerged > 0 ? ` (PRs merged: ${c.prsMerged})` : '';
235
+ console.log(` ${rank} ${c.name}: ${c.commits} commits${prInfo}`);
236
+ }
237
+ if (this.graph.reviewRelations.size > 0) {
238
+ console.log('\nReview Relationships (Merger -> Author):');
239
+ for (const [reviewer, relations] of this.graph.reviewRelations) {
240
+ const relationsStr = Array.from(relations.entries())
241
+ .map(([author, count]) => `${author}(${count})`)
242
+ .join(', ');
243
+ console.log(` ${reviewer} -> ${relationsStr}`);
244
+ }
245
+ }
246
+ console.log('\n');
247
+ }
248
+ }
249
+ const program = new Command();
250
+ program
251
+ .name('code-review-graph')
252
+ .description('Generate code review graph for Pake project')
253
+ .version('1.0.0')
254
+ .option('-o, --output <path>', 'Output file path', 'code-review-graph.mmd')
255
+ .option('-f, --format <format>', 'Output format (mermaid|json)', 'mermaid')
256
+ .option('-s, --stdout', 'Print to stdout instead of file', false)
257
+ .action(async (options) => {
258
+ const graph = new CodeReviewGraph();
259
+ await graph.build();
260
+ graph.printSummary();
261
+ let output;
262
+ if (options.format === 'json') {
263
+ output = JSON.stringify(graph.generateJSON(), null, 2);
264
+ }
265
+ else {
266
+ output = graph.generateMermaidGraph();
267
+ }
268
+ if (options.stdout) {
269
+ console.log(output);
270
+ }
271
+ else {
272
+ const outputPath = path.resolve(options.output);
273
+ fs.writeFileSync(outputPath, output, 'utf-8');
274
+ console.log(`Graph saved to: ${outputPath}`);
275
+ if (options.format === 'mermaid') {
276
+ console.log('\nTo view this graph:');
277
+ console.log(' 1. Use GitHub markdown (paste into .md file)');
278
+ console.log(' 2. Use VS Code with Mermaid extension');
279
+ console.log(' 3. Visit https://mermaid.live/ and paste the content');
280
+ }
281
+ }
282
+ });
283
+ program.parse();
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
7
7
  },
8
- "packageManager": "pnpm@10.26.2",
9
8
  "bin": {
10
9
  "pake": "./dist/cli.js"
11
10
  },
@@ -29,22 +28,6 @@
29
28
  "dist",
30
29
  "src-tauri"
31
30
  ],
32
- "scripts": {
33
- "start": "pnpm run dev",
34
- "dev": "pnpm run tauri dev",
35
- "build": "tauri build",
36
- "build:debug": "tauri build --debug",
37
- "build:mac": "tauri build --target universal-apple-darwin",
38
- "analyze": "cd src-tauri && cargo bloat --release --crates",
39
- "tauri": "tauri",
40
- "cli": "cross-env NODE_ENV=development rollup -c -w",
41
- "cli:build": "cross-env NODE_ENV=production rollup -c",
42
- "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js",
43
- "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose",
44
- "format:check": "prettier --check . --ignore-unknown",
45
- "update": "pnpm update --verbose && cd src-tauri && cargo update",
46
- "prepublishOnly": "pnpm run cli:build"
47
- },
48
31
  "type": "module",
49
32
  "exports": "./dist/cli.js",
50
33
  "license": "MIT",
@@ -86,13 +69,19 @@
86
69
  "typescript": "^5.9.3",
87
70
  "vitest": "^4.0.18"
88
71
  },
89
- "pnpm": {
90
- "overrides": {
91
- "sharp": "^0.34.5"
92
- },
93
- "onlyBuiltDependencies": [
94
- "esbuild",
95
- "sharp"
96
- ]
72
+ "scripts": {
73
+ "start": "pnpm run dev",
74
+ "dev": "pnpm run tauri dev",
75
+ "build": "tauri build",
76
+ "build:debug": "tauri build --debug",
77
+ "build:mac": "tauri build --target universal-apple-darwin",
78
+ "analyze": "cd src-tauri && cargo bloat --release --crates",
79
+ "tauri": "tauri",
80
+ "cli": "cross-env NODE_ENV=development rollup -c -w",
81
+ "cli:build": "cross-env NODE_ENV=production rollup -c",
82
+ "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js",
83
+ "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose",
84
+ "format:check": "prettier --check . --ignore-unknown",
85
+ "update": "pnpm update --verbose && cd src-tauri && cargo update"
97
86
  }
98
- }
87
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "windows": [
3
+ {
4
+ "url": "https://twitter.com/",
5
+ "url_type": "web",
6
+ "hide_title_bar": false,
7
+ "fullscreen": false,
8
+ "width": 1200,
9
+ "height": 780,
10
+ "resizable": true,
11
+ "always_on_top": false,
12
+ "dark_mode": false,
13
+ "activation_shortcut": "",
14
+ "disabled_web_shortcuts": false,
15
+ "hide_on_close": true,
16
+ "incognito": false,
17
+ "enable_wasm": false,
18
+ "enable_drag_drop": false,
19
+ "maximize": false,
20
+ "start_to_tray": false,
21
+ "force_internal_navigation": false,
22
+ "internal_url_regex": "",
23
+ "new_window": false,
24
+ "zoom": 100,
25
+ "min_width": 0,
26
+ "min_height": 0,
27
+ "ignore_certificate_errors": false
28
+ }
29
+ ],
30
+ "user_agent": {
31
+ "macos": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
32
+ "linux": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
33
+ "windows": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
34
+ },
35
+ "system_tray": {
36
+ "macos": false,
37
+ "linux": true,
38
+ "windows": true
39
+ },
40
+ "system_tray_path": "png/icon_512.png",
41
+ "inject": [],
42
+ "proxy_url": "",
43
+ "multi_instance": false,
44
+ "multi_window": false
45
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "productName": "twitter",
3
+ "identifier": "com.pake.ac7d1d0",
4
+ "version": "1.0.0",
5
+ "app": {
6
+ "withGlobalTauri": true,
7
+ "security": {
8
+ "headers": {},
9
+ "csp": null
10
+ }
11
+ },
12
+ "build": {
13
+ "frontendDist": "../dist"
14
+ },
15
+ "bundle": {
16
+ "icon": [
17
+ "icons/icon.icns"
18
+ ],
19
+ "active": true,
20
+ "targets": [
21
+ "app"
22
+ ],
23
+ "macOS": {
24
+ "signingIdentity": "-",
25
+ "hardenedRuntime": true,
26
+ "entitlements": "entitlements.plist",
27
+ "infoPlist": "Info.plist",
28
+ "dmg": {
29
+ "background": "assets/macos/dmg/background.png",
30
+ "windowSize": {
31
+ "width": 680,
32
+ "height": 420
33
+ },
34
+ "appPosition": {
35
+ "x": 190,
36
+ "y": 250
37
+ },
38
+ "applicationFolderPosition": {
39
+ "x": 500,
40
+ "y": 250
41
+ }
42
+ }
43
+ }
44
+ },
45
+ "mainBinaryName": "pake-twitter"
46
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "bundle": {
3
+ "icon": ["png/weekly_512.png"],
4
+ "active": true,
5
+ "linux": {
6
+ "deb": {
7
+ "depends": ["curl", "wget"]
8
+ }
9
+ },
10
+ "targets": ["deb", "appimage"]
11
+ }
12
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "bundle": {
3
+ "icon": [
4
+ "icons/icon.icns"
5
+ ],
6
+ "active": true,
7
+ "targets": [
8
+ "app"
9
+ ],
10
+ "macOS": {
11
+ "signingIdentity": "-",
12
+ "hardenedRuntime": true,
13
+ "entitlements": "entitlements.plist",
14
+ "infoPlist": "Info.plist",
15
+ "dmg": {
16
+ "background": "assets/macos/dmg/background.png",
17
+ "windowSize": {
18
+ "width": 680,
19
+ "height": 420
20
+ },
21
+ "appPosition": {
22
+ "x": 190,
23
+ "y": 250
24
+ },
25
+ "applicationFolderPosition": {
26
+ "x": 500,
27
+ "y": 250
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "bundle": {
3
+ "icon": ["png/weekly_256.ico", "png/weekly_32.ico"],
4
+ "active": true,
5
+ "resources": ["png/weekly_32.ico"],
6
+ "targets": ["msi"],
7
+ "windows": {
8
+ "digestAlgorithm": "sha256",
9
+ "wix": {
10
+ "language": ["en-US"],
11
+ "template": "assets/main.wxs"
12
+ }
13
+ }
14
+ }
15
+ }
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.10.0"
2567
+ version = "3.10.1"
2568
2568
  dependencies = [
2569
2569
  "serde",
2570
2570
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.10.0"
3
+ version = "3.11.0"
4
4
  description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "MIT"
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+
6
+ </dict>
7
+ </plist>
Binary file
@@ -1,7 +1,10 @@
1
1
  use crate::app::config::PakeConfig;
2
2
  use crate::util::get_data_dir;
3
3
  use std::{path::PathBuf, str::FromStr, sync::Mutex};
4
- use tauri::{AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
4
+ use tauri::{
5
+ webview::{NewWindowFeatures, NewWindowResponse},
6
+ AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
7
+ };
5
8
 
6
9
  #[cfg(target_os = "macos")]
7
10
  use tauri::{Theme, TitleBarStyle};
@@ -54,6 +57,41 @@ pub fn open_additional_window(app: &AppHandle) -> tauri::Result<WebviewWindow> {
54
57
  build_window_with_label(app, &state.pake_config, &state.tauri_config, &label)
55
58
  }
56
59
 
60
+ struct WindowBuildOptions<'a> {
61
+ label: &'a str,
62
+ url: WebviewUrl,
63
+ visible: bool,
64
+ new_window_features: Option<NewWindowFeatures>,
65
+ }
66
+
67
+ fn open_requested_window(
68
+ app: &AppHandle,
69
+ config: &PakeConfig,
70
+ tauri_config: &Config,
71
+ target_url: Url,
72
+ features: NewWindowFeatures,
73
+ ) -> tauri::Result<WebviewWindow> {
74
+ let state = app.state::<MultiWindowState>();
75
+ let label = state.next_window_label();
76
+ let window = build_window(
77
+ app,
78
+ config,
79
+ tauri_config,
80
+ WindowBuildOptions {
81
+ label: &label,
82
+ url: WebviewUrl::External("about:blank".parse().unwrap()),
83
+ visible: true,
84
+ new_window_features: Some(features),
85
+ },
86
+ )?;
87
+
88
+ let title = target_url.host_str().unwrap_or(target_url.as_str());
89
+ let _ = window.set_title(title);
90
+ let _ = window.set_focus();
91
+
92
+ Ok(window)
93
+ }
94
+
57
95
  pub fn open_additional_window_safe(app: &AppHandle) {
58
96
  #[cfg(target_os = "windows")]
59
97
  {
@@ -81,22 +119,51 @@ fn build_window_with_label(
81
119
  tauri_config: &Config,
82
120
  label: &str,
83
121
  ) -> tauri::Result<WebviewWindow> {
84
- let package_name = tauri_config.clone().product_name.unwrap();
85
- let _data_dir = get_data_dir(app, package_name);
86
-
87
122
  let window_config = config
88
123
  .windows
89
124
  .first()
90
125
  .expect("At least one window configuration is required");
91
-
92
- let user_agent = config.user_agent.get();
93
-
94
126
  let url = match window_config.url_type.as_str() {
95
127
  "web" => WebviewUrl::App(window_config.url.parse().unwrap()),
96
128
  "local" => WebviewUrl::App(PathBuf::from(&window_config.url)),
97
129
  _ => panic!("url type can only be web or local"),
98
130
  };
99
131
 
132
+ build_window(
133
+ app,
134
+ config,
135
+ tauri_config,
136
+ WindowBuildOptions {
137
+ label,
138
+ url,
139
+ visible: false,
140
+ new_window_features: None,
141
+ },
142
+ )
143
+ }
144
+
145
+ fn build_window(
146
+ app: &AppHandle,
147
+ config: &PakeConfig,
148
+ tauri_config: &Config,
149
+ opts: WindowBuildOptions,
150
+ ) -> tauri::Result<WebviewWindow> {
151
+ let WindowBuildOptions {
152
+ label,
153
+ url,
154
+ visible,
155
+ new_window_features,
156
+ } = opts;
157
+ let package_name = tauri_config.clone().product_name.unwrap();
158
+ let _data_dir = get_data_dir(app, package_name);
159
+
160
+ let window_config = config
161
+ .windows
162
+ .first()
163
+ .expect("At least one window configuration is required");
164
+
165
+ let user_agent = config.user_agent.get();
166
+
100
167
  let config_script = format!(
101
168
  "window.pakeConfig = {}",
102
169
  serde_json::to_string(&window_config).unwrap()
@@ -113,7 +180,7 @@ fn build_window_with_label(
113
180
 
114
181
  let mut window_builder = WebviewWindowBuilder::new(app, label, url)
115
182
  .title(effective_title)
116
- .visible(false)
183
+ .visible(visible)
117
184
  .user_agent(user_agent)
118
185
  .resizable(window_config.resizable)
119
186
  .maximized(window_config.maximize);
@@ -164,8 +231,24 @@ fn build_window_with_label(
164
231
  }
165
232
 
166
233
  if window_config.new_window {
167
- window_builder = window_builder
168
- .on_new_window(move |_url, _features| tauri::webview::NewWindowResponse::Allow);
234
+ let app_handle = app.clone();
235
+ let popup_config = config.clone();
236
+ let popup_tauri_config = tauri_config.clone();
237
+ window_builder = window_builder.on_new_window(move |target_url, features| {
238
+ match open_requested_window(
239
+ &app_handle,
240
+ &popup_config,
241
+ &popup_tauri_config,
242
+ target_url,
243
+ features,
244
+ ) {
245
+ Ok(window) => NewWindowResponse::Create { window },
246
+ Err(error) => {
247
+ eprintln!("[Pake] Failed to open requested window: {error}");
248
+ NewWindowResponse::Deny
249
+ }
250
+ }
251
+ });
169
252
  }
170
253
 
171
254
  // Add initialization scripts
@@ -285,6 +368,10 @@ fn build_window_with_label(
285
368
  println!("Proxy configured: {}", config.proxy_url);
286
369
  }
287
370
 
371
+ if let Some(features) = new_window_features {
372
+ window_builder = window_builder.window_features(features).focused(true);
373
+ }
374
+
288
375
  // Allow navigation to OAuth/authentication domains
289
376
  window_builder = window_builder.on_navigation(|url| {
290
377
  let url_str = url.as_str();
@@ -23,6 +23,7 @@ function setZoom(zoom) {
23
23
  body.style.height = `${100 / zoomValue}%`;
24
24
  } else {
25
25
  html.style.zoom = zoom;
26
+ window.dispatchEvent(new Event("resize"));
26
27
  }
27
28
 
28
29
  window.localStorage.setItem("htmlZoom", zoom);
@@ -336,14 +337,21 @@ document.addEventListener("DOMContentLoaded", () => {
336
337
  }
337
338
 
338
339
  function convertBlobUrlToBinary(blobUrl) {
339
- return new Promise((resolve) => {
340
+ return new Promise((resolve, reject) => {
340
341
  const blob = window.blobToUrlCaches.get(blobUrl);
342
+ if (!blob) {
343
+ fetch(blobUrl)
344
+ .then((res) => res.arrayBuffer())
345
+ .then((buffer) => resolve(Array.from(new Uint8Array(buffer))))
346
+ .catch(reject);
347
+ return;
348
+ }
341
349
  const reader = new FileReader();
342
-
343
350
  reader.readAsArrayBuffer(blob);
344
351
  reader.onload = () => {
345
352
  resolve(Array.from(new Uint8Array(reader.result)));
346
353
  };
354
+ reader.onerror = () => reject(reader.error);
347
355
  });
348
356
  }
349
357
 
@@ -503,9 +511,26 @@ document.addEventListener("DOMContentLoaded", () => {
503
511
  const absoluteUrl = hrefUrl.href;
504
512
  let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl);
505
513
 
506
- // Early check: Allow OAuth/authentication links to navigate naturally
514
+ // Keep OAuth/authentication flows inside the app when popup support is enabled.
507
515
  if (window.isAuthLink(absoluteUrl)) {
508
- console.log("[Pake] Allowing OAuth navigation to:", absoluteUrl);
516
+ console.log("[Pake] Handling OAuth navigation in-app:", absoluteUrl);
517
+
518
+ if (window.pakeConfig?.new_window) {
519
+ e.preventDefault();
520
+ e.stopImmediatePropagation();
521
+
522
+ const authWindow = originalWindowOpen.call(
523
+ window,
524
+ absoluteUrl,
525
+ "_blank",
526
+ "width=1200,height=800,scrollbars=yes,resizable=yes",
527
+ );
528
+
529
+ if (!authWindow) {
530
+ window.location.href = absoluteUrl;
531
+ }
532
+ }
533
+
509
534
  return;
510
535
  }
511
536
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.10.0",
4
+ "version": "3.10.1",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {
@@ -6,6 +6,8 @@
6
6
  "macOS": {
7
7
  "signingIdentity": "-",
8
8
  "hardenedRuntime": true,
9
+ "entitlements": "entitlements.plist",
10
+ "infoPlist": "Info.plist",
9
11
  "dmg": {
10
12
  "background": "assets/macos/dmg/background.png",
11
13
  "windowSize": {
@@ -1 +0,0 @@
1
- <html><body><h1>Hello Pake</h1></body></html>
File without changes