@pmate/cli 0.37.1 → 0.38.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
@@ -270,31 +270,36 @@ Meili's Host may pass `--actor-profile-id` with `PMATE_LOG_DELEGATE_TOKEN` in th
270
270
 
271
271
  Packaged installers are automatically uploaded to Aliyun OSS (`parrot-static`), using the same credentials as Android APK releases. The completion output and build report include public download URLs, SHA-256 checksums, and a `release.json` URL. Configure `oss.public.region` and `aliyun.ak/sk` in your PMate configuration. Conflicting published versions require an app version bump. `--dir` produces local unpacked output only; `--dry-run` does not upload.
272
272
 
273
- `pmate app build <ns:app> --target electron --config <file>` builds the configured web app, embeds its static output in an Electron ASAR, and produces a macOS DMG and ZIP. Packaged releases require a CLI login with admin permission in the app namespace; `--dir` and `--dry-run` stay local. The first build downloads pinned Electron and electron-builder versions. Current targets: macOS and Windows, `arm64` and `x64`; macOS packages require a macOS build host.
273
+ `pmate app build <ns:app> --target electron --config <file>` packages a native shell that loads its configured HTTPS origin directly, without building or embedding web resources, and produces macOS DMG/ZIP or Windows NSIS/ZIP artifacts. Packaged releases require a CLI login with admin permission in the app namespace; `--dir` and `--dry-run` stay local. The first build downloads pinned Electron and electron-builder versions. Current targets: macOS and Windows, `arm64` and `x64`; macOS packages require a macOS build host.
274
274
 
275
275
  ```sh
276
276
  pmate app build pmate:hello --target electron --config apps/hello/desktop.config.json --platform mac --arch arm64 --out-dir release/desktop
277
277
  ```
278
278
 
279
- Config (paths relative to the config file, `web.dist` relative to `web.cwd`):
279
+ Config schema 2 (`icon` is relative to the config file; migrate schema 1 by removing `web` and changing `schemaVersion` to 2):
280
280
 
281
281
  ```json
282
282
  {
283
- "schemaVersion": 1,
283
+ "schemaVersion": 2,
284
284
  "app": "pmate:hello",
285
285
  "productName": "Going Merry",
286
286
  "bundleId": "chat.pmate.goingmerry",
287
287
  "version": "0.1.0",
288
288
  "origin": "https://hello.pmate.chat",
289
289
  "authOrigins": ["https://auth.pmate.chat"],
290
- "icon": "desktop/icon.png",
291
- "web": { "cwd": ".", "build": ["pnpm", "run", "build"], "dist": "dist" }
290
+ "icon": "desktop/icon.png"
292
291
  }
293
292
  ```
294
293
 
295
294
  Use `--dry-run` to inspect the plan, `--dir` for an unpacked `.app`, or `--signed` to require a configured macOS signing identity. Default builds are ad-hoc signed for local use; they are not notarized distribution releases. Signed builds use electron-builder's standard signing/notarization environment variables; verify notarization separately before distributing.
296
295
 
297
- The shell serves the bundled renderer under its existing exact HTTPS origin, preserving authentication callbacks and API CORS. Other origins remain normal network requests. Static resources are hashed and checked at launch; missing assets return 404. The renderer is sandboxed with Node disabled and no privileged preload bridge. Sign-in, chat and inference still require internet. The native application menu includes **检查更新…**. It queries apps-api, compares stable versions, and opens the official installer download after confirmation. Installation remains manual. Each build prints its artifact directory and a resource/signing report; packaged releases upload installers and register the version; `--dir` stays local.
296
+ The shell loads the deployed HTTPS website. Packaging requires only the desktop configuration, icon and native runtime dependencies: no frontend package manager, source tree or `dist` is needed. Web UI, business logic and compatible frontend bridge SDK changes ship through web deployment; the next open or user refresh receives them. Do not reload an active conversation or call automatically. Rebuild installers only when installed preload/main-process bridge code, native capabilities/permissions, or the Electron runtime must change. The first migration from an embedded renderer needs one new installer.
297
+
298
+ `@pmate/bridge` provides `getDesktopBridge()`, runtime `appVersion`, `electronVersion`, `bridgeVersion`, `contentSource`, and callable capability detection. npm SDK versions are independent of the installed host protocol. Missing host capabilities must be shown as unsupported or requiring a shell upgrade; never infer them from the Electron engine version. Preload APIs are exposed only to the configured origin's main frame; auth windows do not receive native APIs. IPC validates the sender and frame, with Node disabled, sandbox and context isolation enabled. The local offline page offers retry without clearing the existing user-data partition. Website CSP and cache policy apply to remote pages.
299
+
300
+ Verify changes with `pnpm exec vitest run src/tests/electron*.test.ts` and `pnpm typecheck`. With `PMATE_TEST_ELECTRON_PATH` pointing to an Electron executable, run `node scripts/test-electron-online-runtime.cjs` for real HTTPS loading, web revision changes without a rebuild, IPC isolation, offline retry and data persistence. Set `PMATE_TEST_LIVE_ORIGIN=https://merry.pmate.chat` to include production SPA loading. Run `node scripts/test-electron-calls-runtime.cjs` for the isolated incoming-call flow. Certificate bypass is restricted to the local test process; production code keeps certificate validation enabled.
301
+
302
+ The native application menu includes **检查更新…**, which checks installed shell releases through apps-api and opens the verified installer after confirmation. Installation remains manual. Each build prints its artifact directory and signing report; packaged releases upload installers and register the version; `--dir` stays local.
298
303
 
299
304
  ### 日志统计默认口径
300
305
 
@@ -330,6 +335,6 @@ pmate service ios build td:time-duck-ios --scheme TimeDuck --team D9D59BE76S
330
335
 
331
336
  Register an `ios` service with `repository` and `dir`. `--cwd` overrides the repository root; `--project` / `--workspace` paths are relative to the app directory. Exactly one discovered project/workspace is required unless explicitly selected. Always provide `--scheme`; `--team` overrides the project's signing team. `--json` returns version, Build, archive and log paths after upload.
332
337
 
333
- The first invocation requires `--last-build <highest-used-build>` (0 only for a new app). The next Build is one greater than the maximum of the project value, imported value and locally reserved sequence. A previous date-based Build therefore continues as that integer plus one. Import the current Apple maximum again after uploads outside this CLI or on another machine; this command does not query Apple build history. Do not lower or delete the sequence to reuse a number.
338
+ The first invocation for each marketing version requires `--last-build <highest-used-build-for-this-version>` (0 only for a new version). The next Build is one greater than the maximum of the project value, imported value and locally reserved sequence for that version. Reservations, including failed builds, are kept separately per version. To migrate published date-based numbers, first increase MARKETING_VERSION and set CURRENT_PROJECT_VERSION to 0, then import 0 for the new version to start at Build 1. Import the current Apple maximum again after uploads outside this CLI or on another machine; this command does not query Apple build history. Do not lower or delete the sequence to reuse a number. Burgess performs the Apple history check and version migration automatically.
334
339
 
335
340
  Sequences, retained archives, derived data and logs live in `~/.pmate/ios-builds/`. Project/source version files are not changed. A bundle-level exclusive lock prevents simultaneous builds on the same machine; it does not coordinate different machines. Build/upload failures keep the reserved number and logs; retry consumes the next number. After an interrupted process, remove a leftover `.lock` file only after confirming no build is still running. Builds require automatic signing; resolve Apple account/certificate/profile errors in Xcode. Extension or App targets with fixed plist versions must produce matching archive metadata or the upload is stopped.
package/dist/index.js CHANGED
@@ -17537,7 +17537,6 @@ var StoreCommandHandler = class {
17537
17537
 
17538
17538
  // src/electronBuild.ts
17539
17539
  var import_node_child_process9 = require("child_process");
17540
- var import_node_crypto5 = require("crypto");
17541
17540
  var import_node_fs19 = require("fs");
17542
17541
  var import_node_os9 = require("os");
17543
17542
  var import_node_path24 = __toESM(require("path"));
@@ -17585,7 +17584,7 @@ var ELECTRON_VERSION = "44.5.1";
17585
17584
  var BUILDER_VERSION = "26.15.3";
17586
17585
  function readDesktopConfig(file, appId) {
17587
17586
  const c = JSON.parse((0, import_node_fs19.readFileSync)(file, "utf8"));
17588
- if (c.schemaVersion !== 1 || c.app !== appId) throw new Error("Desktop config schemaVersion/app mismatch");
17587
+ if (c.schemaVersion !== 2 || c.app !== appId) throw new Error("Desktop config schemaVersion/app mismatch");
17589
17588
  for (const key of ["productName", "bundleId", "version", "icon"]) {
17590
17589
  if (typeof c[key] !== "string" || !c[key].trim()) throw new Error(`Missing desktop ${key}`);
17591
17590
  }
@@ -17598,26 +17597,9 @@ function readDesktopConfig(file, appId) {
17598
17597
  origin(c.origin);
17599
17598
  if (!Array.isArray(c.authOrigins)) throw new Error("authOrigins must be an array");
17600
17599
  c.authOrigins.forEach(origin);
17601
- if (!c.web || typeof c.web.cwd !== "string" || typeof c.web.dist !== "string" || !c.web.dist.trim() || !Array.isArray(c.web.build) || !c.web.build.length || !c.web.build.every((v) => typeof v === "string" && v.length > 0)) throw new Error("Invalid web build configuration");
17600
+ if ("web" in c) throw new Error("Remote desktop builds do not accept web build configuration; remove web");
17602
17601
  return c;
17603
17602
  }
17604
- function collectStaticFiles(root) {
17605
- const files = [];
17606
- const walk = (dir) => {
17607
- for (const entry of (0, import_node_fs19.readdirSync)(dir, { withFileTypes: true })) {
17608
- const full = import_node_path24.default.join(dir, entry.name);
17609
- if (entry.isSymbolicLink()) throw new Error(`Symlinks are not allowed in desktop resources: ${entry.name}`);
17610
- if (entry.name.startsWith(".") || /\.(?:pem|key|p12|pfx)$/i.test(entry.name) || /bot-key|session\.json/i.test(entry.name)) throw new Error(`Private or hidden file in desktop resources: ${entry.name}`);
17611
- if (entry.isDirectory()) walk(full);
17612
- else if (entry.isFile() && !entry.name.endsWith(".map")) files.push(import_node_path24.default.relative(root, full));
17613
- else if (!entry.isFile()) throw new Error(`Unsupported resource: ${entry.name}`);
17614
- }
17615
- };
17616
- if ((0, import_node_fs19.lstatSync)(root).isSymbolicLink()) throw new Error("Static resource root cannot be a symlink");
17617
- walk(root);
17618
- if (!files.includes("index.html")) throw new Error("Web build must produce index.html");
17619
- return files.sort();
17620
- }
17621
17603
  async function runBuildCommand(command, cwd, env = process.env) {
17622
17604
  await new Promise((resolve, reject) => {
17623
17605
  const child = (0, import_node_child_process9.spawn)(command[0], command.slice(1), { cwd, env, stdio: "inherit", shell: false });
@@ -17629,40 +17611,21 @@ async function buildDesktop(args) {
17629
17611
  const file = import_node_path24.default.resolve(args.config);
17630
17612
  const c = readDesktopConfig(file, args.id);
17631
17613
  const base = import_node_path24.default.dirname(file);
17632
- const cwd = import_node_path24.default.resolve(base, c.web.cwd);
17633
- const dist = import_node_path24.default.resolve(cwd, c.web.dist);
17634
17614
  const icon = import_node_path24.default.resolve(base, c.icon);
17635
17615
  const output = import_node_path24.default.resolve(args.outDir);
17636
17616
  if (!(0, import_node_fs19.existsSync)(icon) || !(0, import_node_fs19.statSync)(icon).isFile()) throw new Error(`Desktop icon not found: ${icon}`);
17637
17617
  if (!["mac", "win"].includes(args.platform) || !["arm64", "x64"].includes(args.arch)) throw new Error("Supported target: mac|win --arch arm64|x64");
17638
17618
  if (args.platform === "mac" && process.platform !== "darwin") throw new Error("macOS desktop packaging requires a macOS build host");
17639
- const within = (parent, child) => {
17640
- const rel = import_node_path24.default.relative(parent, child);
17641
- return !rel || !rel.startsWith("..") && !import_node_path24.default.isAbsolute(rel);
17642
- };
17643
- if (within(dist, cwd) || within(dist, output) || within(output, cwd)) throw new Error("Build output, web source and static resource paths overlap");
17644
- const plan = { app: c.app, productName: c.productName, version: c.version, target: args.platform, arch: args.arch, command: c.web.build, cwd, dist, output, electron: ELECTRON_VERSION, builder: BUILDER_VERSION, signed: args.signed };
17619
+ const plan = { app: c.app, productName: c.productName, version: c.version, target: args.platform, arch: args.arch, contentSource: "remote", origin: c.origin, output, electron: ELECTRON_VERSION, builder: BUILDER_VERSION, signed: args.signed };
17645
17620
  console.log(JSON.stringify({ phase: "plan", ...plan }, null, 2));
17646
17621
  if (args.dryRun) return;
17647
- const started = Date.now();
17648
- await runBuildCommand(c.web.build, cwd);
17649
- if (!(0, import_node_fs19.existsSync)(import_node_path24.default.join(dist, "index.html")) || (0, import_node_fs19.statSync)(import_node_path24.default.join(dist, "index.html")).mtimeMs < started - 1e3) throw new Error("Build did not produce a fresh index.html; refusing stale resources");
17650
- const files = collectStaticFiles(dist);
17651
17622
  (0, import_node_fs19.mkdirSync)(output, { recursive: true });
17652
17623
  const staging = (0, import_node_fs19.mkdtempSync)(import_node_path24.default.join(output, ".desktop-build-"));
17653
17624
  const appDir = import_node_path24.default.join(staging, "app");
17654
- (0, import_node_fs19.mkdirSync)(import_node_path24.default.join(appDir, "renderer"), { recursive: true });
17655
- const manifest = {};
17656
- for (const name of files) {
17657
- const source = import_node_path24.default.join(dist, name), target = import_node_path24.default.join(appDir, "renderer", name);
17658
- (0, import_node_fs19.mkdirSync)(import_node_path24.default.dirname(target), { recursive: true });
17659
- (0, import_node_fs19.cpSync)(source, target);
17660
- manifest[name] = (0, import_node_crypto5.createHash)("sha256").update((0, import_node_fs19.readFileSync)(source)).digest("hex");
17661
- }
17625
+ (0, import_node_fs19.mkdirSync)(appDir);
17662
17626
  const templateDir = import_node_path24.default.join(__dirname, "templates", "electron");
17663
17627
  (0, import_node_fs19.cpSync)(templateDir, appDir, { recursive: true });
17664
17628
  (0, import_node_fs19.writeFileSync)(import_node_path24.default.join(appDir, "desktop-config.json"), JSON.stringify({ app: c.app, productName: c.productName, origin: c.origin, authOrigins: c.authOrigins, bundleId: c.bundleId }));
17665
- (0, import_node_fs19.writeFileSync)(import_node_path24.default.join(appDir, "resource-manifest.json"), JSON.stringify(manifest, null, 2));
17666
17629
  (0, import_node_fs19.writeFileSync)(import_node_path24.default.join(appDir, "package.json"), JSON.stringify({ name: c.bundleId.replace(/\./g, "-"), version: c.version, description: `${c.productName} desktop application`, author: "PMate", main: "main.cjs", dependencies: { "@pmate/chat-sdk": "0.11.7" } }));
17667
17630
  await runBuildCommand(["npm", "install", "--omit=dev", "--no-audit", "--no-fund"], appDir);
17668
17631
  const cache = import_node_path24.default.join((0, import_node_os9.homedir)(), ".cache", "pmate", "electron", `${ELECTRON_VERSION}-${BUILDER_VERSION}`);
@@ -17687,7 +17650,7 @@ async function buildDesktop(args) {
17687
17650
  const configPath = import_node_path24.default.join(staging, "builder.json");
17688
17651
  (0, import_node_fs19.writeFileSync)(configPath, JSON.stringify(builderConfig));
17689
17652
  await runBuildCommand([process.execPath, import_node_path24.default.join(cache, "node_modules", "electron-builder", "cli.js"), "--config", configPath, args.platform === "mac" ? "--mac" : "--win", `--${args.arch}`, "--publish", "never"], appDir, { ...process.env, ...args.signed ? {} : { CSC_IDENTITY_AUTO_DISCOVERY: "false" } });
17690
- const result = { ...plan, signature: args.signed ? args.platform === "mac" ? "configured-signing-required; verify notarization before distribution" : "configured-signing-required; verify Authenticode before distribution" : args.platform === "mac" ? "ad-hoc; local development" : "unsigned; local development", artifacts: import_node_path24.default.join(staging, "artifacts"), resources: manifest };
17653
+ const result = { ...plan, signature: args.signed ? args.platform === "mac" ? "configured-signing-required; verify notarization before distribution" : "configured-signing-required; verify Authenticode before distribution" : args.platform === "mac" ? "ad-hoc; local development" : "unsigned; local development", artifacts: import_node_path24.default.join(staging, "artifacts") };
17691
17654
  (0, import_node_fs19.writeFileSync)(import_node_path24.default.join(staging, "build-report.json"), JSON.stringify(result, null, 2));
17692
17655
  const publication = args.dir ? void 0 : await uploadDesktopArtifacts(result.artifacts, c.app, c.version, args.platform, args.arch);
17693
17656
  (0, import_node_fs19.writeFileSync)(import_node_path24.default.join(staging, "build-report.json"), JSON.stringify({ ...result, publication }, null, 2));
@@ -18412,14 +18375,14 @@ var import_promises6 = require("readline/promises");
18412
18375
 
18413
18376
  // src/services/IosBuildService.ts
18414
18377
  var import_node_child_process11 = require("child_process");
18415
- var import_node_crypto7 = require("crypto");
18378
+ var import_node_crypto6 = require("crypto");
18416
18379
  var import_node_fs23 = require("fs");
18417
18380
  var import_node_os12 = __toESM(require("os"));
18418
18381
  var import_node_path28 = __toESM(require("path"));
18419
18382
 
18420
18383
  // src/services/AndroidApkReleaseService.ts
18421
18384
  var import_node_child_process10 = require("child_process");
18422
- var import_node_crypto6 = require("crypto");
18385
+ var import_node_crypto5 = require("crypto");
18423
18386
  var import_node_fs22 = require("fs");
18424
18387
  var import_node_os11 = __toESM(require("os"));
18425
18388
  var import_node_path27 = __toESM(require("path"));
@@ -18890,7 +18853,7 @@ function listVersionedToolCandidates(parentDir, relativeToolPath) {
18890
18853
  }
18891
18854
  function sha256File(filePath) {
18892
18855
  return new Promise((resolve, reject) => {
18893
- const hash = (0, import_node_crypto6.createHash)("sha256");
18856
+ const hash = (0, import_node_crypto5.createHash)("sha256");
18894
18857
  const stream = (0, import_node_fs22.createReadStream)(filePath);
18895
18858
  stream.on("data", (chunk) => hash.update(chunk));
18896
18859
  stream.on("error", reject);
@@ -18967,7 +18930,7 @@ var IosBuildService = class {
18967
18930
  if (!bundleId || !/^\d+\.\d+(?:\.\d+)?$/.test(version ?? "") || !/^[A-Z0-9]{10}$/.test(team ?? "")) throw new Error("Missing or invalid bundle ID, MARKETING_VERSION or signing team; use --team if needed");
18968
18931
  if (service.mobile && "bundleId" in service.mobile && service.mobile.bundleId !== bundleId) throw new Error("Service bundle ID does not match application target");
18969
18932
  const currentBuild = iosBuildInteger(cfg.CURRENT_PROJECT_VERSION);
18970
- const key = (0, import_node_crypto7.createHash)("sha256").update(bundleId).digest("hex");
18933
+ const key = (0, import_node_crypto6.createHash)("sha256").update(bundleId).digest("hex");
18971
18934
  const statePath = import_node_path28.default.join(root, `${key}.json`);
18972
18935
  const lockPath = import_node_path28.default.join(root, `${key}.lock`);
18973
18936
  let lock;
@@ -18981,14 +18944,18 @@ var IosBuildService = class {
18981
18944
  (0, import_node_fs23.writeFileSync)(lock, JSON.stringify({ pid: process.pid, bundleId }));
18982
18945
  const previous = (0, import_node_fs23.existsSync)(statePath) ? JSON.parse((0, import_node_fs23.readFileSync)(statePath, "utf8")) : void 0;
18983
18946
  if (previous && previous.bundleId !== bundleId) throw new Error("iOS build state bundle ID mismatch");
18984
- if (!previous && options.lastBuild === void 0) throw new Error("First build on this machine requires --last-build <highest Apple build>; use 0 only for a new app. Import again after external uploads or changing machines.");
18985
- const build = Math.max(currentBuild, previous ? iosBuildInteger(previous.build) : 0, options.lastBuild ?? 0) + 1;
18947
+ const buildsByVersion = { ...previous?.buildsByVersion };
18948
+ if (previous) buildsByVersion[previous.version] = Math.max(buildsByVersion[previous.version] ?? 0, iosBuildInteger(previous.build));
18949
+ const priorBuild = buildsByVersion[version];
18950
+ if (priorBuild === void 0 && options.lastBuild === void 0) throw new Error("First build for this marketing version requires --last-build <highest Apple build for this version>; use 0 only for a new version. Import again after external uploads or changing machines.");
18951
+ const build = Math.max(currentBuild, priorBuild ?? 0, options.lastBuild ?? 0) + 1;
18986
18952
  iosBuildInteger(build);
18987
18953
  const buildDir = import_node_path28.default.join(root, key, `${version}-${build}`);
18988
18954
  (0, import_node_fs23.mkdirSync)(buildDir, { recursive: true, mode: 448 });
18989
18955
  const archivePath = import_node_path28.default.join(buildDir, "App.xcarchive");
18990
18956
  const logPath = import_node_path28.default.join(buildDir, "build.log");
18991
- const state = { bundleId, version, build, status: "reserved", archivePath, logPath };
18957
+ buildsByVersion[version] = build;
18958
+ const state = { bundleId, version, build, buildsByVersion, status: "reserved", archivePath, logPath };
18992
18959
  const persist = () => {
18993
18960
  const temp = `${statePath}.${process.pid}.tmp`;
18994
18961
  (0, import_node_fs23.writeFileSync)(temp, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
@@ -22260,7 +22227,7 @@ var RoleCommandHandler = class {
22260
22227
 
22261
22228
  // src/commands/AccountCommandHandler.ts
22262
22229
  var import_node_fs26 = require("fs");
22263
- var import_node_crypto8 = require("crypto");
22230
+ var import_node_crypto7 = require("crypto");
22264
22231
  var import_node_path31 = __toESM(require("path"));
22265
22232
  init_sessionStore();
22266
22233
  var AccountCommandHandler = class {
@@ -22860,7 +22827,7 @@ var AccountCommandHandler = class {
22860
22827
  return process.env.HOME || process.env.USERPROFILE || process.cwd();
22861
22828
  }
22862
22829
  generateBotKeyFile(app, name) {
22863
- const { publicKey, privateKey } = (0, import_node_crypto8.generateKeyPairSync)("ed25519");
22830
+ const { publicKey, privateKey } = (0, import_node_crypto7.generateKeyPairSync)("ed25519");
22864
22831
  return {
22865
22832
  type: "pmate-bot-key",
22866
22833
  version: 1,
@@ -22982,12 +22949,12 @@ var AccountCommandHandler = class {
22982
22949
  });
22983
22950
  }
22984
22951
  signBotChallenge(challenge, privateKey) {
22985
- const key = (0, import_node_crypto8.createPrivateKey)({
22952
+ const key = (0, import_node_crypto7.createPrivateKey)({
22986
22953
  key: Buffer.from(privateKey, "base64url"),
22987
22954
  format: "der",
22988
22955
  type: "pkcs8"
22989
22956
  });
22990
- return (0, import_node_crypto8.sign)(
22957
+ return (0, import_node_crypto7.sign)(
22991
22958
  null,
22992
22959
  Buffer.from(JSON.stringify({
22993
22960
  id: challenge.id,
@@ -1,5 +1,6 @@
1
1
  const { contextBridge, ipcRenderer } = require('electron')
2
- contextBridge.exposeInMainWorld('pmateIncomingCall', Object.freeze({
2
+ const origin = process.argv.find(value => value.startsWith('--pmate-origin='))?.slice('--pmate-origin='.length)
3
+ if (process.isMainFrame && location.origin === origin && location.pathname === '/_desktop/call') contextBridge.exposeInMainWorld('pmateIncomingCall', Object.freeze({
3
4
  getSession: () => ipcRenderer.invoke('pmate:call:session'),
4
5
  updateState: state => ipcRenderer.invoke('pmate:call:state', state),
5
6
  onCloseRequested: handler => {
@@ -19,12 +19,12 @@ function createDesktopCalls({ appId, instanceId, origin, BrowserWindow, ipcMain,
19
19
  if (current?.callId === callId) return current.ready
20
20
  destroy()
21
21
  const window = new BrowserWindow({ width: 420, height: 680, minWidth: 380, minHeight: 540, title: '来电', show: false,
22
- backgroundColor: '#ffffff', webPreferences: { preload: path.join(__dirname, 'call-preload.cjs'), partition: 'persist:desktop',
22
+ backgroundColor: '#ffffff', webPreferences: { preload: path.join(__dirname, 'call-preload.cjs'), additionalArguments: [`--pmate-origin=${origin}`], partition: 'persist:desktop',
23
23
  nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, autoplayPolicy: 'no-user-gesture-required' } })
24
24
  let resolve, reject
25
25
  const ready = new Promise((yes, no) => { resolve = yes; reject = no })
26
26
  const entry = current = { callId, window, ready, resolve, reject, phase: 'incoming', confirming: false }
27
- entry.timer = setTimeout(() => { if (current === entry) { reject(new Error('Incoming call window timeout')); destroy() } }, 1500)
27
+ entry.timer = setTimeout(() => { if (current === entry) { reject(new Error('Incoming call window timeout')); destroy() } }, 15000)
28
28
  window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
29
29
  for (const event of ['will-navigate', 'will-redirect', 'will-attach-webview']) window.webContents.on(event, e => e.preventDefault())
30
30
  window.webContents.on('did-fail-load', (_event, code) => { if (code !== -3 && current === entry) { reject(new Error('Incoming call window failed')); destroy() } })
@@ -2,14 +2,12 @@ const { createUpdateChecker, installUpdateHandlers } = require('./updates.cjs')
2
2
  const { app, BrowserWindow, session, shell, Menu, desktopCapturer, ipcMain, Notification, dialog } = require('electron')
3
3
  const path = require('node:path')
4
4
  const { installNotificationSettings } = require('./notification-settings.cjs')
5
- const { createResourceHandler } = require('./resources.cjs')
6
5
  const { installMediaPermissions } = require('./media.cjs')
7
6
  const { createDesktopNotifications } = require('./notifications.cjs')
8
7
  const { desktopInstanceId, createDesktopReach } = require('./reach.cjs')
9
8
  const { createRendererReach } = require('./reach-renderer.cjs')
10
9
  const { createDesktopCalls, supportsDesktopCalls } = require('./calls.cjs')
11
10
  const config = require('./desktop-config.json')
12
- const manifest = require('./resource-manifest.json')
13
11
  app.setName(config.productName)
14
12
  if (process.platform === 'win32') app.setAppUserModelId(config.bundleId)
15
13
  // Stable across installer versions; separate from every other packaged PMate app.
@@ -32,13 +30,13 @@ function restrict(window) {
32
30
  window.webContents.on('did-create-window', child => restrict(child))
33
31
  }
34
32
  function createWindow() {
35
- mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 380, minHeight: 540, title: config.productName, backgroundColor: '#f6f8fb', webPreferences: { additionalArguments: [`--pmate-app-version=${app.getVersion()}`], preload: path.join(__dirname, 'preload.cjs'), partition: 'persist:desktop', nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
33
+ mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 380, minHeight: 540, title: config.productName, backgroundColor: '#f6f8fb', webPreferences: { additionalArguments: [`--pmate-app-version=${app.getVersion()}`, `--pmate-origin=${config.origin}`, `--pmate-desktop-calls=${supportsDesktopCalls(config.app)}`], preload: path.join(__dirname, 'preload.cjs'), partition: 'persist:desktop', nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true } })
36
34
  restrict(mainWindow)
37
35
  mainWindow.webContents.on('did-start-navigation', (_event, _url, _inPlace, mainFrame) => { if (mainFrame) desktopNotifications?.clearContext() })
38
36
  mainWindow.webContents.on('did-fail-load', (_event, code, _description, url, mainFrame) => {
39
- if (mainFrame && code !== -3 && url !== `${config.origin}/_desktop/offline`) void mainWindow.loadURL(`${config.origin}/_desktop/offline`)
37
+ if (mainFrame && code !== -3 && url !== `${config.origin}/_desktop/offline`) void mainWindow.loadURL(`${config.origin}/_desktop/offline`).catch(error => console.error("Offline page failed:", error.message))
40
38
  })
41
- void mainWindow.loadURL(config.origin)
39
+ void mainWindow.loadURL(config.origin).catch(error => console.error("Page load failed:", error.message))
42
40
  mainWindow.on('closed', () => { mainWindow = null })
43
41
  }
44
42
  if (!app.requestSingleInstanceLock()) app.quit()
@@ -46,13 +44,12 @@ else {
46
44
  app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus() } })
47
45
  app.whenReady().then(() => {
48
46
  const desktopSession = session.fromPartition('persist:desktop')
49
- const resources = createResourceHandler(path.join(__dirname, 'renderer'), config.origin, manifest)
50
47
  desktopSession.protocol.handle('https', request => {
51
48
  if (request.url === `${config.origin}/_desktop/offline`) {
52
49
  const escape = text => text.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
53
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escape(config.productName)}</title></head><body style="font:18px system-ui;max-width:560px;margin:15vh auto;padding:24px"><h1>${escape(config.productName)}</h1><p>Connection unavailable. Your app is installed locally; sign-in and messages need an internet connection.</p><form action="/"><button style="padding:12px 24px">Try again</button></form></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'" } })
50
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escape(config.productName)}</title></head><body style="font:18px system-ui;max-width:560px;margin:15vh auto;padding:24px"><h1>${escape(config.productName)}</h1><p>Connection unavailable. Connect to the internet to load the app. Your account data has not been cleared.</p><form action="/"><button style="padding:12px 24px">Try again</button></form></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'" } })
54
51
  }
55
- return resources(request) ?? desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
52
+ return desktopSession.fetch(request, { bypassCustomProtocolHandlers: true })
56
53
  })
57
54
  installMediaPermissions(desktopSession, { origin: config.origin, getWindow: () => mainWindow, getWindows: () => [mainWindow, desktopCalls?.getWindow()], desktopCapturer, BrowserWindow, ipcMain })
58
55
  ipcMain.handle('pmate:screen-settings', event => {
@@ -64,7 +61,7 @@ else {
64
61
  const checkForUpdates = createUpdateChecker({ app, dialog, shell, appId: config.app })
65
62
  Menu.setApplicationMenu(Menu.buildFromTemplate([
66
63
  { role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' },
67
- { label: config.productName, submenu: [{ label: '检查更新…', click: checkForUpdates }, { type: 'separator' }, { label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin) } }] },
64
+ { label: config.productName, submenu: [{ label: '检查更新…', click: checkForUpdates }, { type: 'separator' }, { label: 'Back to app', click: () => { if (!mainWindow) createWindow(); else void mainWindow.loadURL(config.origin).catch(error => console.error("Page load failed:", error.message)) } }] },
68
65
  ]))
69
66
  desktopNotifications = createDesktopNotifications({ ipcMain, Notification, origin: config.origin, appId: config.app, getWindow: () => mainWindow, showWindow: () => {
70
67
  if (!mainWindow) createWindow()
@@ -1,6 +1,7 @@
1
1
  const { contextBridge, ipcRenderer } = require('electron')
2
- contextBridge.exposeInMainWorld('pmateDesktop', Object.freeze({
3
- runtime: Object.freeze({ platform: process.platform, appVersion: process.argv.find(value => value.startsWith('--pmate-app-version='))?.slice('--pmate-app-version='.length), electronVersion: process.versions.electron }),
2
+ const origin = process.argv.find(value => value.startsWith('--pmate-origin='))?.slice('--pmate-origin='.length)
3
+ if (process.isMainFrame && location.origin === origin) contextBridge.exposeInMainWorld('pmateDesktop', Object.freeze({
4
+ runtime: Object.freeze({ platform: process.platform, appVersion: process.argv.find(value => value.startsWith('--pmate-app-version='))?.slice('--pmate-app-version='.length), electronVersion: process.versions.electron, bridgeVersion: '1.0.0', contentSource: 'remote' }),
4
5
  updates: Object.freeze({
5
6
  check: () => ipcRenderer.invoke('pmate:update:check'),
6
7
  download: () => ipcRenderer.invoke('pmate:update:download'),
@@ -19,11 +20,11 @@ contextBridge.exposeInMainWorld('pmateDesktop', Object.freeze({
19
20
  configure: identity => ipcRenderer.invoke('pmate:reach:configure', identity),
20
21
  stop: () => ipcRenderer.invoke('pmate:reach:stop'),
21
22
  }),
22
- calls: Object.freeze({
23
+ calls: process.argv.includes('--pmate-desktop-calls=true') ? Object.freeze({
23
24
  incoming: (callId, teamId) => ipcRenderer.invoke('pmate:call:incoming', callId, teamId),
24
25
  handleReach: event => ipcRenderer.invoke('pmate:call:reach', event),
25
26
  setPeople: people => ipcRenderer.invoke('pmate:call:people', people),
26
- }),
27
+ }) : undefined,
27
28
  notifications: Object.freeze({
28
29
  openSettings: () => ipcRenderer.invoke('pmate:notification:settings'),
29
30
  present: input => ipcRenderer.invoke('pmate:notification:present', input),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/cli",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "CLI utilities for the pMate project",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,32 +0,0 @@
1
- const fs = require('node:fs')
2
- const path = require('node:path')
3
- const crypto = require('node:crypto')
4
- const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4' }
5
- function createResourceHandler(root, origin, manifest) {
6
- // Reject a stale or altered renderer before displaying it.
7
- for (const [file, digest] of Object.entries(manifest)) {
8
- if (file.split(/[\\/]/).includes('..') || path.isAbsolute(file)) throw new Error('Invalid resource manifest')
9
- if (crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') !== digest) throw new Error(`Resource integrity check failed: ${file}`)
10
- }
11
- return (request) => {
12
- const url = new URL(request.url)
13
- if (url.origin !== origin) return null
14
- if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method not allowed', { status: 405 })
15
- let name
16
- try { name = decodeURIComponent(url.pathname).slice(1) } catch { return new Response('Invalid path', { status: 400 }) }
17
- if (name.includes('\\') || name.includes('\0') || name.split('/').some(p => p === '..' || p === '.')) return new Response('Invalid path', { status: 400 })
18
- // Exact files come from the build manifest. Only extensionless document routes get the SPA shell.
19
- if (!Object.hasOwn(manifest, name)) {
20
- if (name === '_desktop/call' || (!path.extname(name) && request.headers.get('sec-fetch-dest') === 'document')) name = 'index.html'
21
- else if (!name) name = 'index.html'
22
- else return new Response('Resource not found', { status: 404 })
23
- }
24
- const bytes = fs.readFileSync(path.join(root, name))
25
- return new Response(request.method === 'HEAD' ? null : bytes, { headers: {
26
- 'content-type': TYPES[path.extname(name)] || 'application/octet-stream',
27
- 'content-length': String(bytes.length), 'x-content-type-options': 'nosniff',
28
- 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src https: wss:; media-src 'self' blob: https:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
29
- } })
30
- }
31
- }
32
- module.exports = { createResourceHandler }