crossbind 2.0.0-beta.66 → 2.0.0-beta.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crossbind",
3
- "version": "2.0.0-beta.66",
3
+ "version": "2.0.0-beta.67",
4
4
  "license": "MIT",
5
5
  "homepage": "https://crossbind.dev",
6
6
  "repository": "https://github.com/crossbind/crossbind.git",
@@ -6,7 +6,7 @@ import { appleSigning } from './appleSigning.js';
6
6
  import { updaterSigning } from './appUpdate.js';
7
7
  import { inWindowsImage, jsignScript, windowsSigning } from './windowsSigning.js';
8
8
  import state from '../state/index.js';
9
- import { appDisplayName, appId, appUrlSchemes } from '../utils/appOptions.js';
9
+ import { appCategory, appDisplayName, appId, appUrlSchemes } from '../utils/appOptions.js';
10
10
  import { readAppIcon, windowsIcon } from '../utils/appIcon.js';
11
11
  import { imageRoleFor } from '../utils/pullDockerImage.js';
12
12
  import { isUnderBase } from '../utils/replaceBasePathForDocker.js';
@@ -61,12 +61,13 @@ export function debianControl({ name, version, arch, author, description, label
61
61
 
62
62
  // The desktop entry a Linux menu shows an app by. GTK names the app's window after the command that started it, and
63
63
  // GNOME on Wayland shows the icon of the entry whose file has that name.
64
- // A link of one of its schemes opens it with the link as its argument, %u.
65
- export function desktopEntry({ label, command, hasIcon, urlSchemes = [] }) {
64
+ // A link of one of its schemes opens it with the link as its argument, %u. A menu shows it under its categories.
65
+ export function desktopEntry({ label, command, hasIcon, urlSchemes = [], categories = null }) {
66
66
  const isLinked = urlSchemes.length > 0;
67
67
  return [
68
68
  '[Desktop Entry]', 'Type=Application', `Name=${label}`, `Exec=${command}${isLinked ? ' %u' : ''}`, ...(hasIcon ? [`Icon=${command}`] : []),
69
- ...(isLinked ? [`MimeType=${urlSchemes.map((scheme) => `x-scheme-handler/${scheme};`).join('')}`] : []), 'Terminal=false', '',
69
+ ...(isLinked ? [`MimeType=${urlSchemes.map((scheme) => `x-scheme-handler/${scheme};`).join('')}`] : []),
70
+ ...(categories ? [`Categories=${categories};`] : []), 'Terminal=false', '',
70
71
  ].join('\n');
71
72
  }
72
73
 
@@ -106,7 +107,9 @@ function debianPackage(app, target) {
106
107
  const icon = `${app}/resources/icon.png`;
107
108
  const hasIcon = fs.existsSync(icon);
108
109
  if (hasIcon) fs.copyFileSync(icon, `${dir('usr/share/icons/hicolor/512x512/apps')}/${name}.png`);
109
- fs.writeFileSync(`${dir('usr/share/applications')}/${name}.desktop`, desktopEntry({ label, command: name, hasIcon, urlSchemes: appUrlSchemes(state.config) }));
110
+ fs.writeFileSync(`${dir('usr/share/applications')}/${name}.desktop`, desktopEntry({
111
+ label, command: name, hasIcon, urlSchemes: appUrlSchemes(state.config), categories: appCategory(state.config)?.linux,
112
+ }));
110
113
  const { author, description } = state.config.package ?? {};
111
114
  fs.writeFileSync(`${dir('DEBIAN')}/control`, debianControl({ name, version: packageVersion(), arch: target.arch, author, description, label }));
112
115
  run(null, ['dpkg-deb', '--build', '--root-owner-group', root, `${app}.deb`], null, target);
@@ -30,7 +30,7 @@ import findFiles from '../utils/findFiles.js';
30
30
  import { getContentHash, getFilesFingerprint } from '../utils/hash.js';
31
31
  import { xmlPlist } from '../utils/plist.js';
32
32
  import {
33
- appBackgroundColor, appDisplayName, appHeader, appId, appPermissions, appUrlSchemes, usageDescriptions,
33
+ appBackgroundColor, appCategory, appCopyright, appDisplayName, appHeader, appId, appPermissions, appUrlSchemes, usageDescriptions,
34
34
  } from '../utils/appOptions.js';
35
35
  import { androidIcons, iosIcon, linuxIcon, macIcon, readAppIcon, windowsIcon } from '../utils/appIcon.js';
36
36
  import writeIfChanged from '../utils/writeIfChanged.js';
@@ -195,9 +195,12 @@ const infoPlist = (name, version, hasIcon, urlSchemes) => xmlPlist({
195
195
  CFBundleShortVersionString: version,
196
196
  ...(urlSchemes.length > 0 ? { CFBundleURLTypes: [{ CFBundleURLName: appId(state.config), CFBundleURLSchemes: urlSchemes }] } : {}),
197
197
  CFBundleVersion: version,
198
+ ...(appCategory(state.config) ? { LSApplicationCategoryType: appCategory(state.config).darwin } : {}),
198
199
  LSMinimumSystemVersion: DARWIN_DEPLOYMENT_TARGET,
199
200
  NSPrincipalClass: 'NSApplication',
200
201
  NSHighResolutionCapable: true,
202
+ // The app menu's About item shows it.
203
+ ...(appCopyright(state.config) ? { NSHumanReadableCopyright: appCopyright(state.config) } : {}),
201
204
  ...usageDescriptions(state.config, 'darwin'),
202
205
  });
203
206
 
Binary file
@@ -7,6 +7,7 @@ import state from '../state/index.js';
7
7
  import buildLib from './buildLib.js';
8
8
  import buildBinTools from './buildBinTools.js';
9
9
  import { copyBundledLicenses } from '../utils/bundledLicenses.js';
10
+ import { applyRecipePatches, refreshPatchedSource, writePatchedSourceStamp } from '../utils/recipePatches.js';
10
11
 
11
12
  export default async function buildExternal(targetParams, options = {}) {
12
13
  const version = state.config.package.nativeVersion;
@@ -14,9 +15,15 @@ export default async function buildExternal(targetParams, options = {}) {
14
15
  throw new Error(`crossbind: "${state.config.general?.name}" has a crossbind.build recipe but no "nativeVersion" in package.json — cannot build from source.`);
15
16
  }
16
17
 
17
- const { sha256, replaceList, copyToSource, copyToDist } = state.config.build;
18
+ const { sha256, replaceList, copyToSource, copyToDist, patches } = state.config.build;
19
+ refreshPatchedSource(state.config.paths.build, patches);
18
20
  const isNewlyCreated = await downloadAndExtractFile(sourcesOf(state.config.build, version), state.config.paths.build, sha256);
19
21
  const sourcePath = `${state.config.paths.build}/source`;
22
+ // Upstream fixes are diffs against the source as released, so they go in before crossbind's own edits.
23
+ if (isNewlyCreated) {
24
+ applyRecipePatches(sourcePath, patches);
25
+ writePatchedSourceStamp(state.config.paths.build, patches);
26
+ }
20
27
  if (isNewlyCreated && replaceList) {
21
28
  replaceList.forEach(({ regex, replacement, paths }) => {
22
29
  replace({
@@ -57,12 +57,16 @@ export declare const shell: {
57
57
  openPath(path: string): Promise<void>;
58
58
  /** A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run. */
59
59
  revealInFolder(path: string): Promise<void>;
60
+ /** A file or folder moved to the system's trash, where its user can take it back: one in the app's folders, not one of them itself, or one a dialog picked during this run. Where the system has no trash, nothing moves and it rejects. */
61
+ trash(path: string): Promise<void>;
60
62
  };
61
63
 
62
64
  export declare const app: {
63
65
  paths(): Promise<AppPaths>;
64
66
  /** Ends the app as closing its window does. */
65
67
  quit(): Promise<void>;
68
+ /** Ends the app as quit() does, then opens it again, with no link. */
69
+ relaunch(): Promise<void>;
66
70
  /** The links of app.urlSchemes in crossbind.config.js that opened the app and have not reached the page yet, the one it was launched with among them. Once the page has asked, each later link comes as the event open-url while it listens, so listening first, then asking, misses none. */
67
71
  launchUrls(): Promise<string[]>;
68
72
  };
@@ -93,6 +97,36 @@ export declare const menu: {
93
97
  set(menus: Menu[]): Promise<void>;
94
98
  };
95
99
 
100
+ /** An item of the tray's menu: one the page handles, by its id, a submenu or a separator. */
101
+ export type TrayItem =
102
+ | { label: string; id?: string; enabled?: boolean; checked?: boolean }
103
+ | { label: string; items: TrayItem[] }
104
+ | { type: 'separator' };
105
+
106
+ export interface TrayOptions {
107
+ /** What the icon shows when the pointer rests on it. */
108
+ tooltip?: string;
109
+ /** The menu a click of the icon opens: a click on macOS, a right click on Windows, and on Linux the one its desktop takes. */
110
+ menu?: TrayItem[];
111
+ }
112
+
113
+ export declare const tray: {
114
+ /** The app's icon in the macOS menu bar, the Windows notification area or a Linux desktop's status tray. A click of an item with an id is the event menu, with a MenuClick, and a click of the icon the event tray where it opens no menu. Set again, it changes the icon that shows. */
115
+ set(options?: TrayOptions): Promise<void>;
116
+ /** Takes the icon away. */
117
+ remove(): Promise<void>;
118
+ };
119
+
120
+ export interface NotificationOptions {
121
+ title: string;
122
+ body?: string;
123
+ }
124
+
125
+ export declare const notification: {
126
+ /** A notification of the system's; a click on it is the event notification. macOS asks its user first. */
127
+ show(options: NotificationOptions): Promise<void>;
128
+ };
129
+
96
130
  /** What the event second-instance carries: a later launch's arguments, the program's own left out, and the folder it was launched in. */
97
131
  export interface SecondInstance {
98
132
  args: string[];
@@ -105,12 +139,20 @@ export interface OpenUrl {
105
139
  }
106
140
 
107
141
  export declare const events: {
108
- /** Calls listener with each click of an item with an id in the menus menu.set sets, until the function it returns is called. */
142
+ /** Calls listener with each click of an item with an id in the menus menu.set sets or in the tray's menu, until the function it returns is called. */
109
143
  on(name: 'menu', listener: (click: MenuClick) => void): () => void;
110
144
  /** Calls listener with each link of app.urlSchemes the system opens the app with once the page has called app.launchUrls(), until the function it returns is called. */
111
145
  on(name: 'open-url', listener: (open: OpenUrl) => void): () => void;
112
146
  /** Calls listener with each later launch of a desktop app that app.singleInstance keeps to one instance, which brings its window to the front as far as the system lets it, until the function it returns is called. */
113
147
  on(name: 'second-instance', listener: (launch: SecondInstance) => void): () => void;
148
+ /** Calls listener as the system of a desktop app goes to sleep, which it may do before the page has run, until the function it returns is called. */
149
+ on(name: 'suspend', listener: () => void): () => void;
150
+ /** Calls listener as the system of a desktop app wakes from sleep, until the function it returns is called. */
151
+ on(name: 'resume', listener: () => void): () => void;
152
+ /** Calls listener with each left or right click of the tray's icon that opens no menu, until the function it returns is called. */
153
+ on(name: 'tray', listener: () => void): () => void;
154
+ /** Calls listener with each click on a notification the app's notification.show showed while it runs, until the function it returns is called. */
155
+ on(name: 'notification', listener: () => void): () => void;
114
156
  /** Calls listener with the value of each event of this name the app's native code emits from now on, until the function it returns is called. */
115
157
  on(name: string, listener: (value: unknown) => void): () => void;
116
158
  };
package/src/app/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // What an app's host does for its page: the dialogs that pick files or show a message, opening an address or a file
2
- // outside the app, the app's folders, quitting, its menus, the events of the app's native code and a desktop app's
3
- // updates. Each call goes to the host of an app (target.runtimeEnv: 'app'); in any other build it rejects, or events.on
4
- // throws. The app's native code calls the same through <crossbind/app.h>, but for the menus and events.on.
2
+ // outside the app, the app's folders, quitting, its menus, its tray icon and notifications, the events of the app's
3
+ // native code and a desktop app's updates. Each call goes to the host of an app (target.runtimeEnv: 'app'); in any
4
+ // other build it rejects, or events.on throws. The app's native code calls the same through <crossbind/app.h>, but for
5
+ // the menus, the tray, notifications and events.on.
5
6
 
6
7
  const OPEN = { title: 'string', defaultPath: 'string', filters: 'filters', directory: 'boolean', multiple: 'boolean' };
7
8
  const SAVE = { title: 'string', defaultPath: 'string', defaultName: 'string', filters: 'filters' };
@@ -32,6 +33,7 @@ const isButtons = (value) => Array.isArray(value) && value.length >= 1 && value.
32
33
  const LISTS = {
33
34
  filters: { isFitting: isFilters, name: 'list of { name, extensions }' },
34
35
  buttons: { isFitting: isButtons, name: 'list of one to three labels' },
36
+ list: { isFitting: Array.isArray, name: 'list' },
35
37
  };
36
38
 
37
39
  // The options as the host takes them, so a misspelt or mistyped one fails here, saying which.
@@ -125,6 +127,46 @@ function checkedMenus(menus) {
125
127
  return { menus };
126
128
  }
127
129
 
130
+ // A tray item is { label, id, enabled, checked }, a submenu { label, items } or { type: 'separator' }: a tray's menu has
131
+ // no keys, and no standard item.
132
+ const TRAY = { tooltip: 'string', menu: 'list' };
133
+ const TRAY_ITEM = { label: 'string', id: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', type: 'string' };
134
+ const NOTIFICATION = { title: 'string', body: 'string' };
135
+
136
+ function checkTrayItem(item, where) {
137
+ const refuse = (what) => {
138
+ throw new TypeError(`crossbind: tray.set's ${where} ${what}.`);
139
+ };
140
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) refuse('is no object');
141
+ for (const [key, value] of Object.entries(item)) {
142
+ if (!Object.hasOwn(TRAY_ITEM, key)) refuse(`takes ${Object.keys(TRAY_ITEM).join(', ')}; ${key} is none of them`);
143
+ const kind = TRAY_ITEM[key];
144
+ if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) refuse(`.${key} is no ${kind}`);
145
+ }
146
+ const others = Object.keys(item).filter((key) => key !== 'type' && item[key] !== undefined);
147
+ if (item.type !== undefined) {
148
+ if (item.type !== 'separator' || others.length > 0) refuse("is no separator, which is { type: 'separator' } alone");
149
+ } else if (!item.label) {
150
+ refuse('has no label');
151
+ } else if (item.items !== undefined) {
152
+ if (others.some((key) => key !== 'label' && key !== 'items')) refuse('is a menu, which takes a label and items alone');
153
+ // entries() reads a hole as undefined, which forEach skips.
154
+ for (const [index, child] of item.items.entries()) checkTrayItem(child, `${where}.items[${index}]`);
155
+ }
156
+ }
157
+
158
+ function checkedTray(options) {
159
+ checked('tray.set', options, TRAY);
160
+ for (const [index, item] of (options.menu ?? []).entries()) checkTrayItem(item, `menu[${index}]`);
161
+ return options;
162
+ }
163
+
164
+ function checkedNotification(options) {
165
+ checked('notification.show', options, NOTIFICATION);
166
+ if (!options.title) throw new TypeError('crossbind: notification.show takes a title.');
167
+ return options;
168
+ }
169
+
128
170
  export const dialog = {
129
171
  // The paths of the files or folders the user picked, or null when they cancelled.
130
172
  open: async (options = {}) => call('dialog.open', checked('dialog.open', options, OPEN)),
@@ -145,6 +187,9 @@ export const shell = {
145
187
  openPath: async (path) => call('shell.openPath', checkedPath('shell.openPath', path)),
146
188
  // A file, selected, in the system's file manager: one in the app's folders, or one a dialog picked during this run.
147
189
  revealInFolder: async (path) => call('shell.revealInFolder', checkedPath('shell.revealInFolder', path)),
190
+ // A file or folder moved to the system's trash, where its user can take it back: one in the app's folders, not one of
191
+ // them itself, or one a dialog picked during this run. Where the system has no trash, nothing moves.
192
+ trash: async (path) => call('shell.trash', checkedPath('shell.trash', path)),
148
193
  };
149
194
 
150
195
  export const app = {
@@ -152,6 +197,8 @@ export const app = {
152
197
  paths: async () => call('app.paths', {}),
153
198
  // Ends the app as closing its window does.
154
199
  quit: async () => call('app.quit', {}),
200
+ // Ends the app as quit() does, then opens it again, with no link.
201
+ relaunch: async () => call('app.relaunch', {}),
155
202
  // The links of app.urlSchemes that opened the app and have not reached the page yet, the one it was launched with
156
203
  // among them; later ones come as the event open-url. Listening first, then asking, misses none.
157
204
  launchUrls: async () => call('app.launchUrls', {}),
@@ -164,6 +211,20 @@ export const menu = {
164
211
  set: async (menus) => call('menu.set', checkedMenus(menus)),
165
212
  };
166
213
 
214
+ export const tray = {
215
+ // The app's icon in the macOS menu bar, the Windows notification area or a Linux desktop's status tray, with its
216
+ // tooltip and the menu a click opens. A click of an item with an id is the event menu, as one of menu.set's is, and a
217
+ // click of the icon the event tray where it opens no menu. Set again, it changes the icon that shows.
218
+ set: async (options = {}) => call('tray.set', checkedTray(options)),
219
+ // Takes the icon away.
220
+ remove: async () => call('tray.remove', {}),
221
+ };
222
+
223
+ export const notification = {
224
+ // A notification of the system's, with a title and a body; a click on it is the event notification.
225
+ show: async (options) => call('notification.show', checkedNotification(options)),
226
+ };
227
+
167
228
  export const events = {
168
229
  // Calls listener with the value of each event of this name the app's native code emits from now on, until the
169
230
  // function it returns is called.
@@ -1,55 +1,55 @@
1
1
  {
2
- "version": "1.0.16",
2
+ "version": "1.0.18",
3
3
  "registry": "ghcr.io/crossbind",
4
4
  "toolchains": {
5
5
  "rust": "1.99.1"
6
6
  },
7
7
  "images": {
8
8
  "rust-sysroot": {
9
- "index": "sha256:b0745e62b7bc2d07acebf8dce8edef2e24a5f82f55bf37f51bacdbb67c393339",
9
+ "index": "sha256:31a213289c4dc1125b462ef006cc27b84ee7eca4e3ef0a93259bd698d56c7359",
10
10
  "platforms": {
11
- "linux/amd64": "sha256:42e71c9f4b349e116fb25cf1fc357cb070ae9e294a6087bb111dad2346256594",
12
- "linux/arm64": "sha256:7144a83fa33d2beaad105e88c0067fb6a44d9b9620e576f226758872962cca44"
11
+ "linux/arm64": "sha256:e82b9a4b9661356238b264a9d5b6921d364cbc81c54ec8426349527144b91bbc",
12
+ "linux/amd64": "sha256:33c63cb3ed60a6d771f079a60c97dfa1f183f215d4087bb655ff4deca2366d9a"
13
13
  }
14
14
  },
15
15
  "base": {
16
- "index": "sha256:d1f2fee78a0238aebb70a2aaed3753212f8151afe0430144b4dfb522f1ccf2ac",
16
+ "index": "sha256:ca5b17d648e797193d7f3c67635e1e73e56b58d02699a55ea61a35eb5ca8d0c8",
17
17
  "platforms": {
18
- "linux/arm64": "sha256:7faf80e5692ae636f4bc25af0849711c442275b2405b3d52f9e1943ed8089aa2",
19
- "linux/amd64": "sha256:7d31882ed442c0425746fa1cbadf4bf1f6a5f035b2222cb907ad5006ef1af24d"
18
+ "linux/arm64": "sha256:48666c55a2f943d8be029080fab985839bc50231b8556a00100bc3702a7bcc51",
19
+ "linux/amd64": "sha256:847f3c686dc8a846b05ee76d38a79b9ac68fb293ca014d56967ee12859849f1f"
20
20
  }
21
21
  },
22
22
  "web": {
23
- "index": "sha256:97d873ae67dac443a88d0dede3fee2893177f167e08e8c9096c29aebd95a3d0f",
23
+ "index": "sha256:72cdc6f5b41d946edc7ca8cd5e1bcf7314f03dfc71be9067994b38130f965711",
24
24
  "platforms": {
25
- "linux/arm64": "sha256:a274755f6d2cc1702ff7681b75e44b5d1146b73b45ac2dab5e4b543554ad4b19",
26
- "linux/amd64": "sha256:23414220cfd4839005d3a5c1e3b83cff4760a3e6eb23687c2a71a1187d0cd211"
25
+ "linux/arm64": "sha256:6d1756fa994e5d939ac5696ebe32012f56c95d0428a8bc7b3775d53acbff8735",
26
+ "linux/amd64": "sha256:14aa215c2157e485ce6d664e0077a3cf6938733855b8146e97830e48fda13875"
27
27
  }
28
28
  },
29
29
  "android": {
30
- "index": "sha256:5cae71358e7619d5958dab6f38b5dad9582792dc3a49c07fdf8dbdbf157ab5bd",
30
+ "index": "sha256:7cbd98594a1fb1f48fe156a21c6c5256cd535d2ffd152c1f80f2860d4dc5b6f0",
31
31
  "platforms": {
32
- "linux/amd64": "sha256:96ff7fa364b8fb109fd3f0257f591c3dc6315e2b1a810511bb129807940895bc"
32
+ "linux/amd64": "sha256:a6e99a0a66e038fd0e6d1154df6c7c58d68acd9def896b63f542d810d923bd87"
33
33
  }
34
34
  },
35
35
  "android-app": {
36
- "index": "sha256:b225a3d04d771916c7a2cc0261990f09b71f6c8b5ae63bc0f3869c7cb5a00fb9",
36
+ "index": "sha256:5cfd1df40ae28730d592f76ade7e08e628f0002d73979015308d50727f021f3e",
37
37
  "platforms": {
38
- "linux/amd64": "sha256:e18361ed3cee022767312cd9f63471f4fe4bf41fccb9c7dd53dafec2da67adc0"
38
+ "linux/amd64": "sha256:98d035dfaeb27095d0a4edce83299ebbb3ffa8e360a4cb925db7cd0b0b8a4666"
39
39
  }
40
40
  },
41
41
  "linux": {
42
- "index": "sha256:6a642147d344f804f42241fc80c21b07bfe1eb40a9a364250959c3715a60700a",
42
+ "index": "sha256:9114bdc0e6b2c1b753d2ba154181566c320e9a901030a14c707f174b7e4849f5",
43
43
  "platforms": {
44
- "linux/arm64": "sha256:2582d7cf143f876ad4a324174ccb0a1097c4ca2ce775c0216756415ca797cb93",
45
- "linux/amd64": "sha256:a10f0efc028e8ba63a7a45dda475eb669d8a078bf1e2830ac0a8c026277afe92"
44
+ "linux/arm64": "sha256:47e104db909dc78a995733d3d27db94e135f1044e1f0860bb201930a65273af0",
45
+ "linux/amd64": "sha256:cd13e6b3f5c5303a583da5267531a83839fe8cf7979890463fc45a914a6d5d3f"
46
46
  }
47
47
  },
48
48
  "windows": {
49
- "index": "sha256:c4c436763d559a5346f96369b444b79006d4298c5dfce78c15c06089cfdaaa33",
49
+ "index": "sha256:46e6da18313f4fd3b599fb2505383aa34f32ffe7ceb659426fa3fb71e6f1e8e7",
50
50
  "platforms": {
51
- "linux/arm64": "sha256:6030741b223fcaccf301e165fb6dace4053dafaba966b067d48c376b7aedb290",
52
- "linux/amd64": "sha256:b314de43f2ad7dec0e97e4f0d5033a8c53795118034d13220eb05c9ab885c7a5"
51
+ "linux/arm64": "sha256:b6457367be5c6863f8d336ce426b9dba7fdfa192308876e4bc390d82a6e18474",
52
+ "linux/amd64": "sha256:fd5bdc1f34d64bb9aebae1e1d44fc380c1dd815bd6686349e428d4551732f6e0"
53
53
  }
54
54
  }
55
55
  }
@@ -11,6 +11,16 @@ const WINDOWS_VERSION = /^(\d+(?:\.\d+){0,3})(?:[-+][0-9A-Za-z.+-]*)?$/;
11
11
  // What app.openPath can let the page's shell.openPath open: the app's folders as app.paths() names them, and what a
12
12
  // dialog picked during this run.
13
13
  const OPEN_PATH_PLACES = ['data', 'cache', 'logs', 'picked'];
14
+ // Apple's app categories, which macOS shows an app under, each with the main categories of the freedesktop menu
15
+ // specification a Linux menu shows it under. An audio or a video app is in AudioVideo as well, as the specification asks.
16
+ const GAMES = ['', 'action-', 'adventure-', 'arcade-', 'board-', 'card-', 'casino-', 'dice-', 'educational-', 'family-', 'kids-', 'music-', 'puzzle-',
17
+ 'racing-', 'role-playing-', 'simulation-', 'sports-', 'strategy-', 'trivia-', 'word-'].map((kind) => `${kind}games`);
18
+ const APP_CATEGORIES = {
19
+ business: 'Office', 'developer-tools': 'Development', education: 'Education', entertainment: 'AudioVideo', finance: 'Office',
20
+ 'graphics-design': 'Graphics', 'healthcare-fitness': 'Utility', lifestyle: 'Utility', medical: 'Science', music: 'Audio;AudioVideo', news: 'Network',
21
+ photography: 'Graphics', productivity: 'Office', reference: 'Education', 'social-networking': 'Network', sports: 'Utility', travel: 'Utility',
22
+ utilities: 'Utility', video: 'Video;AudioVideo', weather: 'Utility', ...Object.fromEntries(GAMES.map((category) => [category, 'Game'])),
23
+ };
14
24
 
15
25
  // An app's bundle id, which also names its folders on the desktop: app.id, else one made from the project's name.
16
26
  export function appId(config) {
@@ -38,6 +48,28 @@ export function appDisplayName(config) {
38
48
  return name;
39
49
  }
40
50
 
51
+ // The copyright line a macOS app's About panel and a Windows executable's details show: app.copyright, else none. The
52
+ // host takes it as a C string and a Windows resource string, as it takes the name.
53
+ export function appCopyright(config) {
54
+ const copyright = config.app.copyright;
55
+ if (copyright === undefined) return null;
56
+ if (typeof copyright !== 'string' || !copyright.trim() || /["\\\p{Cc}]/u.test(copyright)) {
57
+ throw new Error(`crossbind: app.copyright ${JSON.stringify(copyright)} is no copyright line, which is text without a quotation mark, a backslash or a control character, as '© 2026 Example Inc.' is.`);
58
+ }
59
+ return copyright;
60
+ }
61
+
62
+ // The category app.category names, under which macOS shows the app, and the ones a Linux menu shows it under; none
63
+ // without app.category.
64
+ export function appCategory(config) {
65
+ const category = config.app.category;
66
+ if (category === undefined) return null;
67
+ if (!Object.hasOwn(APP_CATEGORIES, category)) {
68
+ throw new Error(`crossbind: app.category ${JSON.stringify(category)} is none of Apple's app categories, which are ${Object.keys(APP_CATEGORIES).join(', ')}.`);
69
+ }
70
+ return { darwin: `public.app-category.${category}`, linux: APP_CATEGORIES[category] };
71
+ }
72
+
41
73
  // The desktop window: the size of its page, the least it shrinks to and the most it grows to, whether it resizes and
42
74
  // whether it opens full screen. Its backgroundColor, which a phone takes as well, is appBackgroundColor's.
43
75
  export function appWindow(config) {
@@ -249,6 +281,10 @@ export function appHeader(config, platform) {
249
281
  ...PERMISSIONS.map((name) => `#define CROSSBIND_APP_PERMISSION_${name.toUpperCase()} ${name in permissions ? 1 : 0}`),
250
282
  ];
251
283
  if (!['darwin', 'linux', 'win32'].includes(platform)) return [comment, openPath, ...page, ''].join('\n');
284
+ // The category goes to the Info.plist and the desktop entry, which the build writes later; any desktop refuses a
285
+ // wrong one here.
286
+ const copyright = appCopyright(config);
287
+ appCategory(config);
252
288
  const lines = [
253
289
  comment,
254
290
  openPath,
@@ -277,6 +313,7 @@ export function appHeader(config, platform) {
277
313
  if (platform === 'win32') {
278
314
  const version = windowsVersion(config);
279
315
  lines.push(`#define CROSSBIND_APP_VERSION "${version.text}"`, `#define CROSSBIND_APP_VERSION_NUMBERS ${version.numbers}`);
316
+ if (copyright) lines.push(`#define CROSSBIND_APP_COPYRIGHT "${copyright}"`);
280
317
  // The build writes the icon beside this header.
281
318
  if (config.app.icon !== undefined) lines.push('#define CROSSBIND_APP_ICON 1');
282
319
  }
@@ -26,8 +26,9 @@ const GUARDS = [
26
26
  },
27
27
  ];
28
28
 
29
- // The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'`.
30
- const BIGINT_FROM_NUMBER = /if\s*\(\s*typeof value\s*==\s*["']number["']\s*\)\s*\{\s*value\s*=\s*BigInt\(value\)\s*;?\s*\}/g;
29
+ // The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'`;
30
+ // emscripten 6.0.10 and later write `===`, and their release glue drops the braces.
31
+ const BIGINT_FROM_NUMBER = /if\s*\(\s*typeof value\s*===?\s*["']number["']\s*\)\s*(?:\{\s*value\s*=\s*BigInt\(value\)\s*;?\s*\}|value\s*=\s*BigInt\(value\)\s*;?)/g;
31
32
  const SAFE_BIGINT_FROM_NUMBER = 'if(typeof value=="number"){if(!Number.isSafeInteger(value))throw new TypeError("a 64-bit integer parameter takes a BigInt or a safe integer Number, got "+value);value=BigInt(value)}';
32
33
 
33
34
  // Returns the rewritten glue and whether a bigint registration kept a conversion this rewrite does not recognise.
@@ -43,7 +44,7 @@ const DEBUG_GUARDS = [
43
44
  {
44
45
  registration: '__embind_register_integer',
45
46
  end: 'readValueFromPointer: integerReadValueFromPointer(',
46
- from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value != ["']number["'])/,
47
+ from: /toWireType: \(destructors, value\) => \{(\s*)(?=if \(typeof value !==? ["']number["'])/,
47
48
  to: 'toWireType: (destructors, value) => {$1'
48
49
  + 'if (typeof value == "string" && size == 1 && value.length == 1) value = value.charCodeAt(0);$1'
49
50
  + 'if (value !== null && typeof value == "object" && typeof value.value == "number") value = value.value;$1',
@@ -72,9 +73,11 @@ function guardDebugRegistration(glue, { registration, end, from, to, applied })
72
73
  return { text: glue.slice(0, start) + rewritten + glue.slice(stop), missed: rewritten === code };
73
74
  }
74
75
 
75
- // Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect.
76
+ // Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect. Emscripten
77
+ // 6.0.10 and later write a registration the glue uses once into the wasm imports, as `name:(` rather than `name=(`; a
78
+ // debug glue's wasm imports only name it (`name: __name`).
76
79
  export function guardEmbindArguments(glue) {
77
- const missed = GUARDS.filter(({ registration, from, to }) => glue.includes(`${registration}=`) && !glue.includes(from) && !glue.includes(to))
80
+ const missed = GUARDS.filter(({ registration, from, to }) => new RegExp(`${registration}[=:]\\(`).test(glue) && !glue.includes(from) && !glue.includes(to))
78
81
  .map(({ registration }) => registration);
79
82
  const released = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
80
83
  return DEBUG_GUARDS.reduce(({ text, missed: names }, guard) => {
@@ -3,14 +3,14 @@
3
3
  // once it returns, or once its promise settles for an async (_JSPI) function, so a call started before
4
4
  // then overwrites them and the earlier call frees the later call's arguments instead of its own, which
5
5
  // leak: two pending calls of one _JSPI method, or a method called again from a JavaScript callback it
6
- // runs. Emscripten 6.0.9 builds it this way, as did upstream main on 2026-09-30; this rewrite declares
7
- // the three inside the invoker, so each call gets its own.
6
+ // runs. Emscripten 6.0.9 and 6.0.13 build it this way, as did upstream main on 2026-09-30; this rewrite
7
+ // declares the three inside the invoker, so each call gets its own.
8
8
 
9
9
  // The release glue spells it on one line, the debug glue one statement per line with an argument
10
- // count check first.
11
- const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
12
- const PER_CALL = 'var invokerFn=function(...args){$1var argsWired=new Array(expectedArgCount);var invokerFuncArgs=[];var destructors=[];';
13
- const SEPARATED = /var invokerFn\s*=\s*function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
10
+ // count check first. Emscripten 6.0.10 and later hand the release invoker straight to createNamedFunction.
11
+ const SHARED = /var argsWired\s*=\s*new Array\(expectedArgCount\);\s*var invokerFuncArgs\s*=\s*\[\];\s*var destructors\s*=\s*\[\];\s*((?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{)(\s*(?:checkArgCount\([^;]*\);\s*)?)destructors\.length\s*=\s*0;/g;
12
+ const PER_CALL = '$1$2var argsWired=new Array(expectedArgCount);var invokerFuncArgs=[];var destructors=[];';
13
+ const SEPARATED = /(?:var invokerFn\s*=\s*|return createNamedFunction\(humanName,\s*)function\s*\(\.\.\.args\)\s*\{\s*(?:checkArgCount\([^;]*\);\s*)?var argsWired\s*=/;
14
14
 
15
15
  // Returns the rewritten glue and whether it still holds an invoker this rewrite does not recognise.
16
16
  export function separateCallArguments(glue) {
@@ -90,9 +90,12 @@ export function getOverrideKey(dep) {
90
90
  override,
91
91
  (key, value) => (typeof value === 'function' ? `fn:${value.toString()}` : value),
92
92
  );
93
+ // The package version too: a new release of the package can build the same upstream release differently, as one
94
+ // that adds a patch does, and the rebuild cached under the old one would keep linking the old build.
93
95
  const parts = [
94
96
  dep.general?.name || '',
95
97
  override.nativeVersion || dep.package?.nativeVersion || '',
98
+ dep.package?.version || '',
96
99
  serial,
97
100
  ];
98
101
  return getContentHash(parts.join(' '));
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getContentHash } from './hash.js';
6
+
7
+ const patchPath = (file) => (file instanceof URL ? fileURLToPath(file) : file);
8
+
9
+ // Inside the tree, which every extraction replaces whole: a tree says it is patched only once its own patches went in,
10
+ // so neither one a patch failed on nor one a stopped run left behind passes, whatever stamp an earlier tree had.
11
+ const stampOf = (buildPath) => path.join(buildPath, 'source', '.crossbind-patches');
12
+
13
+ export function recipePatchesFingerprint(patches) {
14
+ if (!patches?.length) return null;
15
+ return getContentHash(JSON.stringify(patches.map(({ file }) => fs.readFileSync(patchPath(file), 'utf8'))));
16
+ }
17
+
18
+ // downloadAndExtractFile keeps a tree whose archive still matches, so a tree patched under another set
19
+ // would keep its old patches or miss the new ones: it is extracted again instead.
20
+ export function refreshPatchedSource(buildPath, patches) {
21
+ const stamp = stampOf(buildPath);
22
+ const recorded = fs.existsSync(stamp) ? fs.readFileSync(stamp, 'utf8') : '';
23
+ if (recorded !== (recipePatchesFingerprint(patches) ?? '')) {
24
+ fs.rmSync(path.join(buildPath, 'source'), { recursive: true, force: true });
25
+ }
26
+ }
27
+
28
+ export function writePatchedSourceStamp(buildPath, patches) {
29
+ const fingerprint = recipePatchesFingerprint(patches);
30
+ if (fingerprint) fs.writeFileSync(stampOf(buildPath), fingerprint);
31
+ else fs.rmSync(stampOf(buildPath), { force: true });
32
+ }
33
+
34
+ // git apply is atomic and refuses a hunk whose context does not match, so a source the patch no longer
35
+ // fits stops the build instead of shipping unpatched. It works on the files alone: a repository named in the
36
+ // environment, as a git hook passes on, would make it skip every path, and one the downloaded tree carries could run
37
+ // its filters on the host, so GIT_DIR names none. autocrlf, which Git for Windows sets, would give the patched files
38
+ // CRLF endings.
39
+ export function applyRecipePatches(sourcePath, patches) {
40
+ const env = {
41
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
42
+ GIT_DIR: `${sourcePath}.no-repository`,
43
+ };
44
+ for (const { file } of patches ?? []) {
45
+ const patch = patchPath(file);
46
+ try {
47
+ execFileSync('git', ['-c', 'core.autocrlf=false', 'apply', '--whitespace=nowarn', '--', patch], {
48
+ cwd: sourcePath,
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ env,
51
+ });
52
+ } catch (error) {
53
+ if (error.code === 'ENOENT') throw new Error(`crossbind: applying ${path.basename(patch)} needs git on the PATH.`, { cause: error });
54
+ throw new Error(
55
+ `crossbind: ${path.basename(patch)} does not apply to the extracted source. A recipe's patches are diffs against the release it pins: `
56
+ + `drop the patch once a release includes it.\n${String(error.stderr ?? error.message).trim()}`,
57
+ { cause: error },
58
+ );
59
+ }
60
+ }
61
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { getContentHash } from './hash.js';
3
+ import { recipePatchesFingerprint } from './recipePatches.js';
3
4
 
4
5
  // The prebuilt-lib cache in buildLib is existence-only, so a nativeVersion or recipe-sha256 bump
5
6
  // would otherwise be served from the previous build - shipping the old upstream under the new
@@ -12,7 +13,13 @@ export function getSourceFingerprint(config, target) {
12
13
  // Local-source packages pin neither; buildLib checks their native source inventory instead.
13
14
  if (!nativeVersion && !sha256) return null;
14
15
  const sourceReplacements = target ? config?.build?.sourceReplaceList?.(target, config.allDependencyPaths?.[target.path] ?? {}) : undefined;
15
- return getContentHash(JSON.stringify({ nativeVersion, sha256, ...(sourceReplacements?.length ? { sourceReplacements } : {}) }));
16
+ const patches = recipePatchesFingerprint(config?.build?.patches);
17
+ return getContentHash(JSON.stringify({
18
+ nativeVersion,
19
+ sha256,
20
+ ...(sourceReplacements?.length ? { sourceReplacements } : {}),
21
+ ...(patches ? { patches } : {}),
22
+ }));
16
23
  }
17
24
 
18
25
  export function isSourceFingerprintStale(libdir, fingerprint) {