expo-desktop 0.1.17 → 0.1.19
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/build/cli.js +5 -0
- package/build/common/apply-windows-cpp-app-template.js +369 -0
- package/build/common/preserve-file.js +13 -0
- package/build/common/template.js +41 -8
- package/build/create-app/command.js +10 -8
- package/build/create-app/create-expo-desktop-app.js +63 -15
- package/build/prebuild/command.js +1 -0
- package/package.json +5 -3
package/build/cli.js
CHANGED
|
@@ -23,6 +23,11 @@ const main = defineCommand({
|
|
|
23
23
|
description: `The ${kleur.bold("display name")} for the app ${grey("(Examples: 'My App 123', '俺のアプリ')")}`,
|
|
24
24
|
valueHint: "name",
|
|
25
25
|
},
|
|
26
|
+
"local-dev": {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "An undocumented switch for use during development to skip the questionnaire.",
|
|
29
|
+
hidden: true,
|
|
30
|
+
},
|
|
26
31
|
rdns: {
|
|
27
32
|
type: "string",
|
|
28
33
|
description: `The ${kleur.bold("reverse DNS")} for the app ${grey("(Example: 'com.example.my-app-123')")}`,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Read the state of a file on first call of the generator, then run the
|
|
4
|
+
* generator again to restore it back to that value that was read.
|
|
5
|
+
*/
|
|
6
|
+
export async function* preserveFile({ filePath, enable, }) {
|
|
7
|
+
if (!enable) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const fileBefore = await fs.readFile(filePath, "utf-8");
|
|
11
|
+
yield;
|
|
12
|
+
await fs.writeFile(filePath, fileBefore, "utf-8");
|
|
13
|
+
}
|
package/build/common/template.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
|
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
|
+
}
|
|
@@ -3,20 +3,21 @@ import { default as kleur } from "kleur";
|
|
|
3
3
|
import { green, grey } from "kleur/colors";
|
|
4
4
|
import { platform } from "node:process";
|
|
5
5
|
import { title } from "../common/clack.js";
|
|
6
|
-
import { createExpoDesktopApp
|
|
6
|
+
import { createExpoDesktopApp } from "./create-expo-desktop-app.js";
|
|
7
7
|
import { previewFileTree } from "./preview-file-tree.js";
|
|
8
8
|
import { promptForVersion } from "./prompt-for-version.js";
|
|
9
9
|
export async function newExpoDesktopProject(args) {
|
|
10
|
-
// A
|
|
11
|
-
const
|
|
12
|
-
if (
|
|
10
|
+
// A switch for skipping the questions
|
|
11
|
+
const localDev = args["local-dev"];
|
|
12
|
+
if (localDev) {
|
|
13
13
|
await createExpoDesktopApp({
|
|
14
|
+
localDev,
|
|
14
15
|
name: {
|
|
15
|
-
displayName: "
|
|
16
|
-
filesafeName: "
|
|
17
|
-
rdns: "
|
|
16
|
+
displayName: "Your App Display Name",
|
|
17
|
+
filesafeName: "YourApp456",
|
|
18
|
+
rdns: "uk.co.birchlabs.your-app-456",
|
|
18
19
|
},
|
|
19
|
-
packageManager: "
|
|
20
|
+
packageManager: "pnpm",
|
|
20
21
|
templates: {
|
|
21
22
|
template: args.template,
|
|
22
23
|
"template-ios": args["template-ios"],
|
|
@@ -57,6 +58,7 @@ export async function newExpoDesktopProject(args) {
|
|
|
57
58
|
process.exit(0);
|
|
58
59
|
}
|
|
59
60
|
await createExpoDesktopApp({
|
|
61
|
+
localDev,
|
|
60
62
|
name,
|
|
61
63
|
packageManager,
|
|
62
64
|
versions,
|
|
@@ -11,18 +11,10 @@ import { makePrettySummary } from "../common/arktype.js";
|
|
|
11
11
|
import { promisifiedSpawnTask, SPAWN_DEBUG_LOG_GLOB } from "../common/child-process.js";
|
|
12
12
|
import { title } from "../common/clack.js";
|
|
13
13
|
import { packageManagerExec } from "../common/npm.js";
|
|
14
|
+
import { preserveFile } from "../common/preserve-file.js";
|
|
14
15
|
import { applySelectedTemplatesAsync } from "../common/template.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
* - Skips the questionnaire at the start.
|
|
19
|
-
* - Installs the local copy of expo-desktop-config-plugins rather than pinning
|
|
20
|
-
* to a published release.
|
|
21
|
-
* - Adds the apply-config-plugins.mjs script.
|
|
22
|
-
*/
|
|
23
|
-
export const localDev = false;
|
|
24
|
-
export async function createExpoDesktopApp({ name, packageManager, templates, versions, }) {
|
|
25
|
-
const { projectPath } = await createExpoApp({ name, packageManager, versions });
|
|
16
|
+
export async function createExpoDesktopApp({ localDev, name, packageManager, templates, versions, }) {
|
|
17
|
+
const { projectPath } = await createExpoApp({ localDev, name, packageManager, versions });
|
|
26
18
|
await appendRootGitignoreSpawnDebugLogs(projectPath);
|
|
27
19
|
const templateSelection = {
|
|
28
20
|
// https://github.com/expo/expo/blob/sdk-54/templates/expo-template-blank-typescript
|
|
@@ -42,19 +34,26 @@ export async function createExpoDesktopApp({ name, packageManager, templates, ve
|
|
|
42
34
|
selection: templateSelection,
|
|
43
35
|
enabledPlatforms: ["ios", "android", "macos", "windows"],
|
|
44
36
|
name,
|
|
37
|
+
// I had originally hoped to consume the template.config.js file provided by
|
|
38
|
+
// the template, but the prototype in cpp-app imports dependencies like
|
|
39
|
+
// "chalk", "lodash", "username", and "../templateUtils" that it doesn't
|
|
40
|
+
// declare in any package.json, so we can't reliably support it. Will
|
|
41
|
+
// revisit the idea in future.
|
|
42
|
+
respectTemplateConfig: false,
|
|
45
43
|
});
|
|
46
44
|
console.log(`${green("◆")} Applied templates.\n`);
|
|
47
45
|
title("Altering app.json…", { spacing: 1 });
|
|
48
46
|
await updateAppJson({ name, projectPath });
|
|
49
47
|
title("Altering package.json…", { spacing: 1 });
|
|
50
48
|
const { name: packageJsonName } = await updatePackageJson({
|
|
49
|
+
localDev,
|
|
51
50
|
name,
|
|
52
51
|
projectPath,
|
|
53
52
|
versions,
|
|
54
53
|
task: { type: "create" },
|
|
55
54
|
});
|
|
56
55
|
title("Installing dependencies…", { spacing: 1 });
|
|
57
|
-
await npmInstall({ cwd: projectPath, packageManager });
|
|
56
|
+
await npmInstall({ asNewWorkspace: true, cwd: projectPath, packageManager });
|
|
58
57
|
await updatePackageJson({
|
|
59
58
|
name,
|
|
60
59
|
projectPath,
|
|
@@ -94,7 +93,22 @@ export async function createExpoDesktopApp({ name, packageManager, templates, ve
|
|
|
94
93
|
await writeBabelConfig({ projectPath });
|
|
95
94
|
// TODO: Set up Windows app.cpp entrypoint
|
|
96
95
|
}
|
|
97
|
-
async function createExpoApp({ name, packageManager, versions, }) {
|
|
96
|
+
async function createExpoApp({ localDev, name, packageManager, versions, }) {
|
|
97
|
+
// `create-expo-app` aggravatingly reconfigures your workspace to use
|
|
98
|
+
// `nodeLinker: hoisted`, which sucks when creating sample projects inside
|
|
99
|
+
// this monorepo during local dev (even with `--no-install`). So we fight
|
|
100
|
+
// back.
|
|
101
|
+
//
|
|
102
|
+
// For non-local dev, it sounds like we can use `nodeLinker: isolated` as of
|
|
103
|
+
// Expo SDK 54, so I'm tempted to enforce that in created templates, too. But
|
|
104
|
+
// one thing at a time.
|
|
105
|
+
// - https://docs.expo.dev/more/create-expo/#pnpm
|
|
106
|
+
// - https://github.com/expo/expo/blob/222b3b12610d69784bab6c5a188a46ea388f866a/packages/create-expo/src/resolvePackageManager.ts#L109
|
|
107
|
+
const gen = preserveFile({
|
|
108
|
+
enable: localDev,
|
|
109
|
+
filePath: localDev ? path.resolve(import.meta.dirname, "../../../../pnpm-workspace.yaml") : "",
|
|
110
|
+
});
|
|
111
|
+
await gen.next();
|
|
98
112
|
// `npm create` drops flags meant for create-expo-app unless you add `--`; use
|
|
99
113
|
// `npx --yes` instead to forward args correctly and skip prompts.
|
|
100
114
|
const command = packageManager === "npm" ? "npx" : packageManager;
|
|
@@ -127,6 +141,9 @@ async function createExpoApp({ name, packageManager, versions, }) {
|
|
|
127
141
|
log.error(`Error running ${yellow("create expo-app")}${error instanceof Error ? `: ${error.message}` : "."}`);
|
|
128
142
|
process.exit(1);
|
|
129
143
|
}
|
|
144
|
+
finally {
|
|
145
|
+
await gen.next();
|
|
146
|
+
}
|
|
130
147
|
return { projectPath };
|
|
131
148
|
}
|
|
132
149
|
async function appendRootGitignoreSpawnDebugLogs(projectPath) {
|
|
@@ -203,7 +220,7 @@ async function updateAppJson({ name, projectPath, }) {
|
|
|
203
220
|
}
|
|
204
221
|
console.log(`${green("◆")} Altered app.json.\n`);
|
|
205
222
|
}
|
|
206
|
-
async function updatePackageJson({ name, projectPath, task, versions, }) {
|
|
223
|
+
async function updatePackageJson({ localDev, name, projectPath, task, versions, }) {
|
|
207
224
|
const packageJsonPath = path.resolve(projectPath, "package.json");
|
|
208
225
|
let packageJson;
|
|
209
226
|
try {
|
|
@@ -275,10 +292,41 @@ async function updatePackageJson({ name, projectPath, task, versions, }) {
|
|
|
275
292
|
console.log(`${green("◆")} Altered package.json.\n`);
|
|
276
293
|
return { name: nameBefore };
|
|
277
294
|
}
|
|
278
|
-
async function npmInstall({ cwd, packageManager, }) {
|
|
295
|
+
async function npmInstall({ asNewWorkspace, cwd, packageManager, }) {
|
|
279
296
|
const command = packageManager;
|
|
280
297
|
const args = ["install"];
|
|
281
298
|
console.log(`${cyan("◆")} Running: ${yellow(`${command} ${args.join(" ")}`)}\n`);
|
|
299
|
+
// Unlike npm and bun, pnpm climbs up to install dependencies in the closest
|
|
300
|
+
// ancestor directory if there is one. This is particularly inconvenient
|
|
301
|
+
// during local dev when we're creating samples inside the monorepo.
|
|
302
|
+
if (asNewWorkspace && packageManager === "pnpm") {
|
|
303
|
+
// (1) Ensure a file name pnpm-workspace.yaml exists.
|
|
304
|
+
//
|
|
305
|
+
// (2) Also ensure that it uses nodeLinker: hoisted, as otherwise
|
|
306
|
+
// `:path => "#{config[:reactNativePath]}-macos"` predicts that there will
|
|
307
|
+
// be a react-native-macos directory right beside the react-native
|
|
308
|
+
// directory by just optimistically appending "-macos" on the end, like so:
|
|
309
|
+
// "../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.29.0_@react-native-community+cli@20.1.3_typescript@5._941d99d35895d1a3626e14fd9f3b3666/node_modules/react-native" + "-macos"
|
|
310
|
+
//
|
|
311
|
+
// As this is not true with pnpm's default `nodeLinker: isolated`, we
|
|
312
|
+
// need to stick to `nodeLinker: hoisted` until we can rewrite the
|
|
313
|
+
// Podfile script to resolve it properly.
|
|
314
|
+
//
|
|
315
|
+
// This is consistent with what the Expo team do for pnpm and yarn:
|
|
316
|
+
// - https://docs.expo.dev/more/create-expo/#pnpm
|
|
317
|
+
// - https://github.com/expo/expo/blob/222b3b12610d69784bab6c5a188a46ea388f866a/packages/create-expo/src/resolvePackageManager.ts#L109
|
|
318
|
+
try {
|
|
319
|
+
await fs.writeFile(path.resolve(cwd, "pnpm-workspace.yaml"), "nodeLinker: hoisted\n", {
|
|
320
|
+
flag: "wx",
|
|
321
|
+
encoding: "utf-8",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
282
330
|
try {
|
|
283
331
|
await tasks([
|
|
284
332
|
promisifiedSpawnTask({
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.19",
|
|
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.
|
|
44
|
-
"expo-desktop-prebuild-config": "1.0.
|
|
44
|
+
"expo-desktop-config-plugins": "1.1.19",
|
|
45
|
+
"expo-desktop-prebuild-config": "1.0.8"
|
|
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"
|