expo-desktop 0.1.23 → 0.1.25

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.
@@ -30,7 +30,7 @@ export async function applyWindowsCppAppTemplateAsync(projectRoot, name) {
30
30
  const replacements = await buildReplacementsRecord(projectRoot, name);
31
31
  await renameCppAppPathsAsync(windowsRoot, name.filesafeName);
32
32
  await renderMustacheUnderWindowsAsync(windowsRoot, replacements);
33
- await finalizeWindowsCppTemplateArtifacts(projectRoot, windowsRoot);
33
+ await finalizeWindowsCppTemplateArtifacts(projectRoot);
34
34
  }
35
35
  async function looksLikeCppAppTemplateAsync(windowsRoot) {
36
36
  try {
@@ -128,19 +128,33 @@ async function buildReplacementsRecord(projectRoot, name) {
128
128
  autolinkCppPackageProviders: "\n UNREFERENCED_PARAMETER(packageProviders);",
129
129
  };
130
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";
131
+ /** Placeholder app name baked into the react-native-windows cpp-app template. */
132
+ const TEMPLATE_APP_NAME_PLACEHOLDER = "MyApp";
133
+ /**
134
+ * Basenames in the cpp-app template that need a one-shot literal rename
135
+ * (independent of any `MyApp` substitution).
136
+ */
137
+ const SPECIAL_BASENAME_RENAMES = {
138
+ _gitignore: ".gitignore",
139
+ NuGet_Config: "NuGet.config",
140
+ };
141
+ /**
142
+ * Returns the renamed basename for one entry in the cpp-app template, applying
143
+ * (in order):
144
+ *
145
+ * 1. {@link SPECIAL_BASENAME_RENAMES} (e.g. `_gitignore` → `.gitignore`).
146
+ * 2. `MyApp` → {@link filesafeName}, anywhere in the basename.
147
+ *
148
+ * If the user's `filesafeName` happens to equal `"MyApp"`, step (2) is a no-op
149
+ * (avoids degenerate self-renames that previously hung
150
+ * `renameCppAppPathsAsync`).
151
+ */
152
+ function renameTemplateBasename(basename, filesafeName) {
153
+ const renamed = SPECIAL_BASENAME_RENAMES[basename] ?? basename;
154
+ if (filesafeName === TEMPLATE_APP_NAME_PLACEHOLDER) {
155
+ return renamed;
139
156
  }
140
- return parts.join(path.sep).split("MyApp").join(filesafeName);
141
- }
142
- function pathDepth(p) {
143
- return p.split(path.sep).filter(Boolean).length;
157
+ return renamed.split(TEMPLATE_APP_NAME_PLACEHOLDER).join(filesafeName);
144
158
  }
145
159
  async function collectWindowsPathsAsync(windowsRoot) {
146
160
  const out = [];
@@ -173,72 +187,46 @@ async function collectWindowsPathsAsync(windowsRoot) {
173
187
  return out;
174
188
  }
175
189
  /**
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.
190
+ * Walks `windowsRoot` pre-order (parents before children), renaming each entry
191
+ * by its basename via {@link renameTemplateBasename}:
192
+ *
193
+ * - `MyApp` (anywhere in the basename) → `filesafeName`
194
+ * - `_gitignore` → `.gitignore`
195
+ * - `NuGet_Config` → `NuGet.config`
196
+ *
197
+ * Pre-order is essential. `fs.rename()` atomically moves an entire directory
198
+ * subtree, so renaming a parent like `MyApp.Package` → `YourApp.Package`
199
+ * already relocates everything beneath it. A bottom-up order would risk
200
+ * `ENOTEMPTY` when the parent later tries to overwrite a non-empty
201
+ * destination.
202
+ *
203
+ * Symlinks are skipped to avoid escaping the template tree. Empty directories
204
+ * are handled naturally — `readdir` simply returns no children.
180
205
  */
181
206
  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("..")) {
207
+ async function walk(dir) {
208
+ const entries = await fs.readdir(dir, { withFileTypes: true });
209
+ for (const entry of entries) {
210
+ if (entry.isSymbolicLink()) {
188
211
  continue;
189
212
  }
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;
213
+ const sourcePath = path.join(dir, entry.name);
214
+ const newBasename = renameTemplateBasename(entry.name, filesafeName);
215
+ const targetPath = newBasename === entry.name ? sourcePath : path.join(dir, newBasename);
216
+ if (targetPath !== sourcePath) {
217
+ try {
218
+ await fs.rename(sourcePath, targetPath);
219
+ }
220
+ catch (cause) {
221
+ throw new Error(`Failed to rename "${sourcePath}" → "${targetPath}"`, { cause });
200
222
  }
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
223
  }
220
- if (a.isDir !== b.isDir) {
221
- return a.isDir ? -1 : 1;
224
+ if (entry.isDirectory()) {
225
+ await walk(targetPath);
222
226
  }
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
227
  }
241
228
  }
229
+ await walk(windowsRoot);
242
230
  }
243
231
  const BINARY_EXTENSIONS = new Set([
244
232
  ".png",
@@ -311,17 +299,7 @@ async function renderMustacheUnderWindowsAsync(windowsRoot, view) {
311
299
  }
312
300
  }));
313
301
  }
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
- }
302
+ async function finalizeWindowsCppTemplateArtifacts(projectRoot) {
325
303
  await renameIfExistsPreferDest(path.join(projectRoot, "NuGet_Config"), path.join(projectRoot, "NuGet.config"));
326
304
  const version = packageJson.version;
327
305
  const banner = `<!-- This project was created with expo-desktop ${version} -->`;
@@ -39,7 +39,7 @@ function runPromisifiedSpawn({ command, args, options, logLine, debugLogDir, })
39
39
  stdio: stdioEffective,
40
40
  env: envWithForcedColorIfPiped({ ...options, stdio: stdioEffective }),
41
41
  };
42
- const cp = spawn(command, args, spawnOptions);
42
+ const cp = spawn(`${command} ${args.join(" ")}`, spawnOptions);
43
43
  /** Interleaved stdout/stderr lines in arrival order (tagged for readability). */
44
44
  const lineBuffer = [];
45
45
  const pushLine = (stream, line) => {
@@ -0,0 +1,34 @@
1
+ import { Shescape } from "shescape";
2
+ const defaultShell = Symbol("Default shell");
3
+ const shescapes = {};
4
+ export function getShescape(shell) {
5
+ const resolvedShell = shell ?? defaultShell;
6
+ if (shescapes[resolvedShell]) {
7
+ return shescapes[resolvedShell];
8
+ }
9
+ const shescapeOptions = {};
10
+ if (typeof resolvedShell === "string") {
11
+ shescapeOptions.shell = resolvedShell;
12
+ }
13
+ let shescape;
14
+ try {
15
+ shescape = new Shescape(shescapeOptions);
16
+ }
17
+ catch (cause) {
18
+ if (!(cause instanceof Error) || cause.message !== "Shescape does not support the shell sh") {
19
+ throw new Error("Unable to spawn child process due to error being thrown when constructing Shescape instance", { cause });
20
+ }
21
+ // Can't escape for the meta-shell `/bin/sh`. Let's try falling back to a
22
+ // typical Unix shell and hoping for the best.
23
+ // https://github.com/ericcornelissen/shescape/issues/2009
24
+ try {
25
+ shescapeOptions.shell = process.platform === "darwin" ? "zsh" : "bash";
26
+ shescape = new Shescape(shescapeOptions);
27
+ }
28
+ catch (cause) {
29
+ throw new Error("Unable to spawn child process due to error being thrown when constructing fallback Shescape instance", { cause });
30
+ }
31
+ }
32
+ shescapes[resolvedShell] = shescape;
33
+ return shescape;
34
+ }
@@ -14,6 +14,7 @@ import { pathToFileURL } from "node:url";
14
14
  import { applyWindowsCppAppTemplateAsync } from "./apply-windows-cpp-app-template.js";
15
15
  import { promisifiedSpawnTask } from "./child-process.js";
16
16
  import { getTemplateFilesToRenameAsync, renameTemplateAppNameAsync, } from "./rename-template-app-name.js";
17
+ import { getShescape } from "./shescape.js";
17
18
  export async function applySelectedTemplatesAsync({ projectRoot, selection, enabledPlatforms, name, respectTemplateConfig, }) {
18
19
  const descriptors = getOrderedTemplateDescriptors(selection, enabledPlatforms);
19
20
  if (!descriptors.length) {
@@ -112,6 +113,8 @@ function parseTemplateSource(template) {
112
113
  return { type: "npm", spec: template };
113
114
  }
114
115
  async function prepareTemplateSourceAsync(taskTitle, source) {
116
+ // We make sure there are no spaces in the path so that we don't need to
117
+ // quote/escape the shell command.
115
118
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "expo-desktop-template-"));
116
119
  const archivePath = path.join(tempRoot, "template.tgz");
117
120
  switch (source.type) {
@@ -119,6 +122,8 @@ async function prepareTemplateSourceAsync(taskTitle, source) {
119
122
  await fs.copyFile(source.path, archivePath);
120
123
  break;
121
124
  case "github": {
125
+ // Don't think it's possible to have spaces in the owner/repo/ref, so no
126
+ // percent-encoding or quoting needed.
122
127
  const tarballUrl = `https://codeload.github.com/${source.owner}/${source.repo}/tar.gz/${source.ref}`;
123
128
  const response = await fetch(tarballUrl);
124
129
  if (!response.ok || !response.body) {
@@ -129,11 +134,12 @@ async function prepareTemplateSourceAsync(taskTitle, source) {
129
134
  break;
130
135
  }
131
136
  case "npm": {
137
+ const shescape = getShescape();
132
138
  await tasks([
133
139
  promisifiedSpawnTask({
134
140
  title: `npm pack (${source.spec})`,
135
141
  command: "npm",
136
- args: ["pack", source.spec, "--silent"],
142
+ args: ["pack", shescape.quote(source.spec), "--silent"],
137
143
  options: { cwd: tempRoot },
138
144
  }),
139
145
  ]);
@@ -149,7 +155,11 @@ async function prepareTemplateSourceAsync(taskTitle, source) {
149
155
  await tasks([
150
156
  promisifiedSpawnTask({
151
157
  title: taskTitle,
152
- command: "tar",
158
+ // The paths are Windows-style paths rather than POSIX, so make sure to
159
+ // select Windows tar rather than GNU tar (which may be on path, even when
160
+ // invoking from cmd.exe, due to having git bash installed).
161
+ // tar C:\Users\Jamie\AppData\Local\Temp\expo-desktop-template-Uf1F1B\template.tgz -C C:\Users\Jamie\AppData\Local\Temp\expo-desktop-template-Uf1F1B
162
+ command: process.platform === "win32" ? "C:\\Windows\\System32\\tar.exe" : "tar",
153
163
  args: ["-xzf", archivePath, "-C", tempRoot],
154
164
  }),
155
165
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-desktop",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Best-effort desktop support for Expo",
5
5
  "keywords": [
6
6
  "android",
@@ -42,6 +42,7 @@
42
42
  "glob": "^10.5.0",
43
43
  "kleur": "^4.1.5",
44
44
  "mustache": "^4.2.0",
45
+ "shescape": "^2.1.12",
45
46
  "toml": "^4.1.1"
46
47
  },
47
48
  "devDependencies": {