expo-desktop 0.1.16 → 0.1.18

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.
@@ -0,0 +1,369 @@
1
+ import { glob } from "glob";
2
+ import mustache from "mustache";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import packageJson from "../../package.json" with { type: "json" };
8
+ /**
9
+ * Mirrors the effective behaviour of `vnext/templates/cpp-app/template.config.js`
10
+ * from react-native-windows (path renames + Mustache replacements) without
11
+ * executing that file (it pulls undeclared deps and `../templateUtils`).
12
+ *
13
+ * @see https://github.com/microsoft/react-native-windows/blob/main/vnext/templates/cpp-app/template.config.js
14
+ * @see https://github.com/microsoft/react-native-windows/blob/main/packages/%40react-native-windows/cli/src/generator-common/index.ts
15
+ */
16
+ export async function applyWindowsCppAppTemplateAsync(projectRoot, name) {
17
+ const windowsRoot = path.join(projectRoot, "windows");
18
+ try {
19
+ await fs.access(windowsRoot);
20
+ }
21
+ catch (error) {
22
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
23
+ throw error;
24
+ }
25
+ return;
26
+ }
27
+ if (!(await looksLikeCppAppTemplateAsync(windowsRoot))) {
28
+ return;
29
+ }
30
+ const replacements = await buildReplacementsRecord(projectRoot, name);
31
+ await renameCppAppPathsAsync(windowsRoot, name.filesafeName);
32
+ await renderMustacheUnderWindowsAsync(windowsRoot, replacements);
33
+ await finalizeWindowsCppTemplateArtifacts(projectRoot, windowsRoot);
34
+ }
35
+ async function looksLikeCppAppTemplateAsync(windowsRoot) {
36
+ try {
37
+ await fs.access(path.join(windowsRoot, "MyApp"));
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
42
+ throw error;
43
+ }
44
+ const entries = await fs.readdir(windowsRoot).catch(() => []);
45
+ return entries.some((e) => e.includes("MyApp"));
46
+ }
47
+ }
48
+ /** Same as legacy `react-native init-windows --namespace` (strip `-` and `_` only). */
49
+ function windowsNamespaceFromRdns(rdns) {
50
+ return rdns.replaceAll(/[-_]/g, "");
51
+ }
52
+ async function tryReadRnwFromNodeModules(projectRoot) {
53
+ const pkgPath = path.join(projectRoot, "node_modules", "react-native-windows", "package.json");
54
+ try {
55
+ const raw = await fs.readFile(pkgPath, "utf8");
56
+ const version = JSON.parse(raw).version;
57
+ if (!version) {
58
+ return null;
59
+ }
60
+ const rnwPath = path.dirname(pkgPath);
61
+ let devMode = false;
62
+ try {
63
+ await fs.access(path.join(rnwPath, "src-win"));
64
+ devMode = true;
65
+ }
66
+ catch (error) {
67
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
68
+ throw error;
69
+ }
70
+ devMode = false;
71
+ }
72
+ return { path: rnwPath, version, devMode };
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
76
+ throw error;
77
+ }
78
+ return null;
79
+ }
80
+ }
81
+ async function reactNativeWindowsVersionFromPackageJson(projectRoot) {
82
+ const pkgPath = path.join(projectRoot, "package.json");
83
+ try {
84
+ const raw = await fs.readFile(pkgPath, "utf8");
85
+ const parsed = JSON.parse(raw);
86
+ const v = parsed.dependencies?.["react-native-windows"];
87
+ return typeof v === "string" ? v : null;
88
+ }
89
+ catch (error) {
90
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
91
+ throw error;
92
+ }
93
+ return null;
94
+ }
95
+ }
96
+ async function buildReplacementsRecord(projectRoot, name) {
97
+ const projectGuid = crypto.randomUUID();
98
+ const packageGuid = crypto.randomUUID();
99
+ const namespace = windowsNamespaceFromRdns(name.rdns);
100
+ const namespaceCpp = namespace.replaceAll(".", "::");
101
+ const mainComponentName = name.filesafeName;
102
+ const rnw = await tryReadRnwFromNodeModules(projectRoot);
103
+ const rnwVersion = rnw?.version ?? (await reactNativeWindowsVersionFromPackageJson(projectRoot)) ?? "0.0.0";
104
+ const rnwPathFromProjectRoot = rnw
105
+ ? path.relative(projectRoot, rnw.path).replaceAll("/", "\\")
106
+ : "node_modules\\react-native-windows";
107
+ const devMode = rnw?.devMode ?? false;
108
+ const isCanary = rnwVersion.includes("canary");
109
+ return {
110
+ name: name.filesafeName,
111
+ namespace,
112
+ namespaceCpp,
113
+ rnwVersion,
114
+ rnwPathFromProjectRoot,
115
+ mainComponentName,
116
+ projectGuidLower: `{${projectGuid.toLowerCase()}}`,
117
+ projectGuidUpper: `{${projectGuid.toUpperCase()}}`,
118
+ packageGuidLower: `{${packageGuid.toLowerCase()}}`,
119
+ packageGuidUpper: `{${packageGuid.toUpperCase()}}`,
120
+ currentUser: os.userInfo().username,
121
+ devMode,
122
+ useNuGets: !devMode,
123
+ addReactNativePublicAdoFeed: true || isCanary,
124
+ cppNugetPackages: [],
125
+ autolinkPropertiesForProps: "",
126
+ autolinkProjectReferencesForTargets: "",
127
+ autolinkCppIncludes: "",
128
+ autolinkCppPackageProviders: "\n UNREFERENCED_PARAMETER(packageProviders);",
129
+ };
130
+ }
131
+ function cppAppRelativePathTransform(relativeToWindows, filesafeName) {
132
+ const parts = relativeToWindows.split(/[/\\]/);
133
+ const base = parts.at(-1) ?? "";
134
+ if (base === "_gitignore") {
135
+ parts[parts.length - 1] = ".gitignore";
136
+ }
137
+ else if (base === "NuGet_Config") {
138
+ parts[parts.length - 1] = "NuGet.config";
139
+ }
140
+ return parts.join(path.sep).split("MyApp").join(filesafeName);
141
+ }
142
+ function pathDepth(p) {
143
+ return p.split(path.sep).filter(Boolean).length;
144
+ }
145
+ async function collectWindowsPathsAsync(windowsRoot) {
146
+ const out = [];
147
+ async function walk(current) {
148
+ let stat;
149
+ try {
150
+ stat = await fs.lstat(current);
151
+ }
152
+ catch (error) {
153
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
154
+ throw error;
155
+ }
156
+ return;
157
+ }
158
+ if (stat.isSymbolicLink()) {
159
+ return;
160
+ }
161
+ if (stat.isDirectory()) {
162
+ out.push(current);
163
+ for (const ent of await fs.readdir(current, { withFileTypes: true })) {
164
+ await walk(path.join(current, ent.name));
165
+ }
166
+ return;
167
+ }
168
+ if (stat.isFile()) {
169
+ out.push(current);
170
+ }
171
+ }
172
+ await walk(windowsRoot);
173
+ return out;
174
+ }
175
+ /**
176
+ * Renames cpp-app paths containing `MyApp` → filesafeName. Uses iterative
177
+ * shallow-first ordering (directories before files at the same depth). Deeper
178
+ * fixes caused ENOTEMPTY: files were renamed into `*.Package/Images` before the
179
+ * directory `MyApp.Package/Images` moved, leaving a non-empty destination.
180
+ */
181
+ async function renameCppAppPathsAsync(windowsRoot, filesafeName) {
182
+ for (;;) {
183
+ const allPaths = await collectWindowsPathsAsync(windowsRoot);
184
+ const candidates = [];
185
+ for (const abs of allPaths) {
186
+ const rel = path.relative(windowsRoot, abs);
187
+ if (!rel || rel.includes("..")) {
188
+ continue;
189
+ }
190
+ if (!rel.includes("MyApp")) {
191
+ continue;
192
+ }
193
+ let stat;
194
+ try {
195
+ stat = await fs.lstat(abs);
196
+ }
197
+ catch (error) {
198
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
199
+ throw error;
200
+ }
201
+ continue;
202
+ }
203
+ if (stat.isSymbolicLink()) {
204
+ continue;
205
+ }
206
+ candidates.push({
207
+ abs,
208
+ rel,
209
+ depth: pathDepth(rel),
210
+ isDir: stat.isDirectory(),
211
+ });
212
+ }
213
+ if (!candidates.length) {
214
+ break;
215
+ }
216
+ candidates.sort((a, b) => {
217
+ if (a.depth !== b.depth) {
218
+ return a.depth - b.depth;
219
+ }
220
+ if (a.isDir !== b.isDir) {
221
+ return a.isDir ? -1 : 1;
222
+ }
223
+ return a.rel.localeCompare(b.rel);
224
+ });
225
+ const chosen = candidates[0];
226
+ const newRelWin = cppAppRelativePathTransform(chosen.rel, filesafeName);
227
+ if (newRelWin === chosen.rel) {
228
+ throw new Error(`Windows cpp-app rename: expected path containing MyApp to change: "${chosen.rel}"`);
229
+ }
230
+ const newAbs = path.join(windowsRoot, newRelWin);
231
+ if (newAbs === chosen.abs) {
232
+ continue;
233
+ }
234
+ await fs.mkdir(path.dirname(newAbs), { recursive: true });
235
+ try {
236
+ await fs.rename(chosen.abs, newAbs);
237
+ }
238
+ catch (cause) {
239
+ throw new Error(`Failed to rename "${chosen.abs}" -> "${newAbs}"`, { cause });
240
+ }
241
+ }
242
+ }
243
+ const BINARY_EXTENSIONS = new Set([
244
+ ".png",
245
+ ".jpg",
246
+ ".jpeg",
247
+ ".gif",
248
+ ".webp",
249
+ ".jar",
250
+ ".keystore",
251
+ ".ico",
252
+ ".pdb",
253
+ ".dll",
254
+ ".exe",
255
+ ".bin",
256
+ ]);
257
+ function isProbablyBinaryFile(filePath) {
258
+ return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
259
+ }
260
+ function adjustReplacementStringsForLineEndings(view, useCRLF) {
261
+ const out = { ...view };
262
+ for (const [key, value] of Object.entries(out)) {
263
+ if (typeof value === "string") {
264
+ out[key] = useCRLF ? value.replaceAll(/(?<!\r)\n/g, "\r\n") : value.replaceAll(/\r\n/g, "\n");
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ async function collectWindowsFilesAsync(windowsRoot) {
270
+ const all = await collectWindowsPathsAsync(windowsRoot);
271
+ const files = new Array();
272
+ for (const p of all) {
273
+ try {
274
+ if ((await fs.lstat(p)).isFile()) {
275
+ files.push(p);
276
+ }
277
+ }
278
+ catch (error) {
279
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
280
+ throw error;
281
+ }
282
+ // skip
283
+ }
284
+ }
285
+ return files;
286
+ }
287
+ async function renderMustacheUnderWindowsAsync(windowsRoot, view) {
288
+ const filePaths = await collectWindowsFilesAsync(windowsRoot);
289
+ await Promise.all(filePaths.map(async (absoluteFilePath) => {
290
+ if (isProbablyBinaryFile(absoluteFilePath)) {
291
+ return;
292
+ }
293
+ let content;
294
+ try {
295
+ content = await fs.readFile(absoluteFilePath, "utf8");
296
+ }
297
+ catch (error) {
298
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
299
+ throw error;
300
+ }
301
+ return;
302
+ }
303
+ if (!content.includes("{{")) {
304
+ return;
305
+ }
306
+ const useCRLF = content.includes("\r\n");
307
+ const adjustedView = adjustReplacementStringsForLineEndings(view, useCRLF);
308
+ const rendered = mustache.render(content, adjustedView);
309
+ if (rendered !== content) {
310
+ await fs.writeFile(absoluteFilePath, rendered, "utf8");
311
+ }
312
+ }));
313
+ }
314
+ async function finalizeWindowsCppTemplateArtifacts(projectRoot, windowsRoot) {
315
+ const gitignoreRelPaths = await glob("**/_gitignore", {
316
+ cwd: windowsRoot,
317
+ nodir: true,
318
+ dot: true,
319
+ });
320
+ for (const rel of gitignoreRelPaths.sort().reverse()) {
321
+ const fromAbs = path.join(windowsRoot, rel);
322
+ const toAbs = path.join(windowsRoot, path.dirname(rel), ".gitignore");
323
+ await renameIfExistsPreferDest(fromAbs, toAbs);
324
+ }
325
+ await renameIfExistsPreferDest(path.join(projectRoot, "NuGet_Config"), path.join(projectRoot, "NuGet.config"));
326
+ const version = packageJson.version;
327
+ const banner = `<!-- This project was created with expo-desktop ${version} -->`;
328
+ const reactNativeWindowsBanner = /<!--\s*This project was created with react-native-windows[^\n\r]*-->/g;
329
+ const vcxprojRelPaths = await glob("windows/**/*.vcxproj", {
330
+ cwd: projectRoot,
331
+ nodir: true,
332
+ dot: true,
333
+ });
334
+ await Promise.all(vcxprojRelPaths.map(async (relPath) => {
335
+ const absolutePath = path.join(projectRoot, relPath);
336
+ let contents;
337
+ try {
338
+ contents = await fs.readFile(absolutePath, "utf8");
339
+ }
340
+ catch {
341
+ return;
342
+ }
343
+ const replaced = contents.replace(reactNativeWindowsBanner, banner);
344
+ if (replaced !== contents) {
345
+ await fs.writeFile(absolutePath, replaced, "utf8");
346
+ }
347
+ }));
348
+ }
349
+ async function renameIfExistsPreferDest(from, to) {
350
+ try {
351
+ await fs.access(from);
352
+ }
353
+ catch (error) {
354
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
355
+ throw error;
356
+ }
357
+ return;
358
+ }
359
+ try {
360
+ await fs.rename(from, to);
361
+ }
362
+ catch (error) {
363
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
364
+ await fs.unlink(from);
365
+ return;
366
+ }
367
+ throw error;
368
+ }
369
+ }
@@ -1,7 +1,9 @@
1
- import { MacOSConfig } from "expo-desktop-config-plugins";
2
1
  import { glob } from "glob";
3
2
  import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
+ const require = createRequire(import.meta.url);
6
+ const { MacOSConfig } = require("expo-desktop-config-plugins");
5
7
  /**
6
8
  * Returns a list of files within a template matched by the resolved rename
7
9
  * config.
@@ -11,9 +11,10 @@ import fs from "node:fs/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
+ import { applyWindowsCppAppTemplateAsync } from "./apply-windows-cpp-app-template.js";
14
15
  import { promisifiedSpawnTask } from "./child-process.js";
15
16
  import { getTemplateFilesToRenameAsync, renameTemplateAppNameAsync, } from "./rename-template-app-name.js";
16
- export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, }) {
17
+ export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, respectTemplateConfig, }) {
17
18
  const descriptors = getOrderedTemplateDescriptors(selection, enabledPlatforms);
18
19
  if (!descriptors.length) {
19
20
  return;
@@ -29,17 +30,20 @@ export async function applySelectedTemplatesAsync({ projectRoot, selection, enab
29
30
  // windows:
30
31
  // - https://github.com/microsoft/react-native-windows/blob/3d64f71ed8495da6a0dcfc1f97bcb8f761986594/packages/%40react-native-windows/cli/src/generator-windows/index.ts#L57
31
32
  // - https://github.com/microsoft/react-native-windows/tree/main/vnext/templates/cpp-app
32
- for (const descriptor of descriptors) {
33
+ for (const [index, descriptor] of Object.entries(descriptors)) {
33
34
  const source = parseTemplateSource(descriptor.value);
34
- const extracted = await prepareTemplateSourceAsync(source);
35
+ const extracted = await prepareTemplateSourceAsync(`Extracting template ${parseInt(index) + 1}/${descriptors.length} (--template ${descriptor.key})`, source);
35
36
  try {
36
37
  const templateRoot = await resolveTemplateRootAsync(extracted, source);
37
- const templateConfig = await loadTemplateConfigAsync(templateRoot);
38
+ const templateConfig = respectTemplateConfig
39
+ ? await loadTemplateConfigAsync(templateRoot)
40
+ : undefined;
38
41
  await copyTemplateFilesAsync({
39
42
  sourceRoot: templateRoot,
40
43
  projectRoot,
41
44
  name,
42
45
  templateConfig,
46
+ forPlatform: descriptor.forPlatform,
43
47
  });
44
48
  }
45
49
  finally {
@@ -107,7 +111,7 @@ function parseTemplateSource(template) {
107
111
  }
108
112
  return { type: "npm", spec: template };
109
113
  }
110
- async function prepareTemplateSourceAsync(source) {
114
+ async function prepareTemplateSourceAsync(taskTitle, source) {
111
115
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "expo-desktop-template-"));
112
116
  const archivePath = path.join(tempRoot, "template.tgz");
113
117
  switch (source.type) {
@@ -144,7 +148,7 @@ async function prepareTemplateSourceAsync(source) {
144
148
  }
145
149
  await tasks([
146
150
  promisifiedSpawnTask({
147
- title: "extracting template",
151
+ title: taskTitle,
148
152
  command: "tar",
149
153
  args: ["-xzf", archivePath, "-C", tempRoot],
150
154
  }),
@@ -172,12 +176,12 @@ async function loadTemplateConfigAsync(templateRoot) {
172
176
  await fs.access(configPath);
173
177
  }
174
178
  catch {
175
- return null;
179
+ return;
176
180
  }
177
181
  const imported = (await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href)));
178
182
  return imported.default ?? imported;
179
183
  }
180
- async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateConfig, }) {
184
+ async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateConfig, forPlatform, }) {
181
185
  const mappings = templateConfig?.files?.length
182
186
  ? templateConfig.files.map((mapping) => ({
183
187
  from: path.join(sourceRoot, mapping.from),
@@ -213,6 +217,12 @@ async function copyTemplateFilesAsync({ sourceRoot, projectRoot, name, templateC
213
217
  filesafeName: name.filesafeName,
214
218
  files: filesToRename,
215
219
  });
220
+ if (forPlatform === "macos") {
221
+ await renameMacosUnderscoreGitignore(projectRoot);
222
+ }
223
+ if (forPlatform === "windows") {
224
+ await applyWindowsCppAppTemplateAsync(projectRoot, name);
225
+ }
216
226
  }
217
227
  async function discoverAllFilesAsync(sourceRoot) {
218
228
  const out = new Array();
@@ -263,3 +273,26 @@ async function applyExtraReplacementsAsync({ cwd, files, replacements, }) {
263
273
  function normalizeToPosixPath(input) {
264
274
  return input.replaceAll(path.sep, "/");
265
275
  }
276
+ async function renameMacosUnderscoreGitignore(projectRoot) {
277
+ const from = path.join(projectRoot, "macos", "_gitignore");
278
+ const to = path.join(projectRoot, "macos", ".gitignore");
279
+ try {
280
+ await fs.access(from);
281
+ }
282
+ catch (error) {
283
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
284
+ throw error;
285
+ }
286
+ return;
287
+ }
288
+ try {
289
+ await fs.rename(from, to);
290
+ }
291
+ catch (error) {
292
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
293
+ await fs.unlink(from);
294
+ return;
295
+ }
296
+ throw error;
297
+ }
298
+ }
@@ -12,9 +12,9 @@ export async function newExpoDesktopProject(args) {
12
12
  if (skip) {
13
13
  await createExpoDesktopApp({
14
14
  name: {
15
- displayName: "My App Display Name",
16
- filesafeName: "MyApp6",
17
- rdns: "com.example.my-app-123",
15
+ displayName: "Your App Display Name",
16
+ filesafeName: "YourApp456",
17
+ rdns: "uk.co.birchlabs.your-app-456",
18
18
  },
19
19
  packageManager: "bun",
20
20
  templates: {
@@ -42,6 +42,12 @@ export async function createExpoDesktopApp({ name, packageManager, templates, ve
42
42
  selection: templateSelection,
43
43
  enabledPlatforms: ["ios", "android", "macos", "windows"],
44
44
  name,
45
+ // I had originally hoped to consume the template.config.js file provided by
46
+ // the template, but the prototype in cpp-app imports dependencies like
47
+ // "chalk", "lodash", "username", and "../templateUtils" that it doesn't
48
+ // declare in any package.json, so we can't reliably support it. Will
49
+ // revisit the idea in future.
50
+ respectTemplateConfig: false,
45
51
  });
46
52
  console.log(`${green("◆")} Applied templates.\n`);
47
53
  title("Altering app.json…", { spacing: 1 });
@@ -52,6 +52,7 @@ export async function prebuild({ clean, "no-install": noInstall, npm, yarn, bun,
52
52
  selection: templateSelection,
53
53
  enabledPlatforms: platforms,
54
54
  name: appName,
55
+ respectTemplateConfig: false,
55
56
  });
56
57
  log.info("Applied project templates for clean prebuild.", { withGuide: false });
57
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-desktop",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Best-effort desktop support for Expo",
5
5
  "keywords": [
6
6
  "android",
@@ -39,14 +39,16 @@
39
39
  "citty": "^0.2.2",
40
40
  "glob": "^10.5.0",
41
41
  "kleur": "^4.1.5",
42
+ "mustache": "^4.2.0",
42
43
  "toml": "^4.1.1",
43
- "expo-desktop-config-plugins": "1.1.17",
44
- "expo-desktop-prebuild-config": "1.0.6"
44
+ "expo-desktop-config-plugins": "1.1.18",
45
+ "expo-desktop-prebuild-config": "1.0.7"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@expo/config": "^12.0.13",
48
49
  "@expo/config-plugins": "^54.0.4",
49
50
  "@tsconfig/node24": "^24.0.4",
51
+ "@types/mustache": "^4.2.6",
50
52
  "@types/node": "^24.12.2",
51
53
  "@typescript/native-preview": "^7.0.0-dev.20260425.1",
52
54
  "vitest": "^3.2.4"