electron-cli 0.3.0-alpha.1 → 0.3.0-alpha.10
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/Cargo.lock +1227 -67
- package/Cargo.toml +10 -1
- package/README.md +57 -9
- package/package.json +2 -1
- package/src/cli.rs +260 -1
- package/src/commands/init.rs +814 -0
- package/src/commands/make.rs +1710 -0
- package/src/commands/mod.rs +5 -0
- package/src/commands/package.rs +1626 -0
- package/src/commands/plan.rs +64 -5
- package/src/commands/publish.rs +432 -0
- package/src/commands/start.rs +287 -0
- package/src/main.rs +5 -0
- package/src/project.rs +6 -0
- package/templates/minimal/gitignore +5 -0
- package/templates/minimal/src/index.html +82 -0
- package/templates/minimal/src/main.js +33 -0
- package/templates/minimal/src/preload.js +6 -0
- package/templates/minimal/src/renderer.js +5 -0
|
@@ -0,0 +1,1626 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
collections::{BTreeMap, BTreeSet, VecDeque},
|
|
3
|
+
fs,
|
|
4
|
+
path::{Path, PathBuf},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
use anyhow::{bail, Context, Result};
|
|
8
|
+
use camino::Utf8PathBuf;
|
|
9
|
+
use plist::{Dictionary as PlistDictionary, Value as PlistValue};
|
|
10
|
+
use serde::Serialize;
|
|
11
|
+
use serde_json::Value as JsonValue;
|
|
12
|
+
|
|
13
|
+
use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
|
|
14
|
+
|
|
15
|
+
#[derive(Debug, Serialize)]
|
|
16
|
+
pub(crate) struct PackageReport {
|
|
17
|
+
project: ProjectSnapshot,
|
|
18
|
+
app_name: String,
|
|
19
|
+
executable_name: String,
|
|
20
|
+
metadata: PackageMetadata,
|
|
21
|
+
platform: String,
|
|
22
|
+
arch: String,
|
|
23
|
+
electron_dist: Utf8PathBuf,
|
|
24
|
+
output_dir: Utf8PathBuf,
|
|
25
|
+
bundle_dir: Utf8PathBuf,
|
|
26
|
+
app_resources_dir: Utf8PathBuf,
|
|
27
|
+
dry_run: bool,
|
|
28
|
+
status: PackageStatus,
|
|
29
|
+
create_dirs: Vec<Utf8PathBuf>,
|
|
30
|
+
copy_steps: Vec<CopyStep>,
|
|
31
|
+
warnings: Vec<String>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, Serialize)]
|
|
35
|
+
struct CopyStep {
|
|
36
|
+
from: Utf8PathBuf,
|
|
37
|
+
to: Utf8PathBuf,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[derive(Debug, Serialize)]
|
|
41
|
+
struct PackageMetadata {
|
|
42
|
+
bundle_identifier: String,
|
|
43
|
+
app_version: Option<String>,
|
|
44
|
+
build_version: Option<String>,
|
|
45
|
+
app_category_type: Option<String>,
|
|
46
|
+
app_copyright: Option<String>,
|
|
47
|
+
icon: Option<IconResource>,
|
|
48
|
+
extra_resources: Vec<CopyStep>,
|
|
49
|
+
darwin_dark_mode_support: bool,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Debug, Serialize)]
|
|
53
|
+
struct IconResource {
|
|
54
|
+
from: Utf8PathBuf,
|
|
55
|
+
to: Utf8PathBuf,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Debug, Serialize)]
|
|
59
|
+
#[serde(rename_all = "kebab-case")]
|
|
60
|
+
enum PackageStatus {
|
|
61
|
+
Planned,
|
|
62
|
+
Packaged,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[derive(Debug, Default)]
|
|
66
|
+
struct PackageJsonConfig {
|
|
67
|
+
product_name: Option<String>,
|
|
68
|
+
app_version: Option<String>,
|
|
69
|
+
packager: PackagerConfig,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[derive(Debug, Default)]
|
|
73
|
+
struct PackagerConfig {
|
|
74
|
+
name: Option<String>,
|
|
75
|
+
executable_name: Option<String>,
|
|
76
|
+
app_bundle_id: Option<String>,
|
|
77
|
+
app_category_type: Option<String>,
|
|
78
|
+
app_version: Option<String>,
|
|
79
|
+
build_version: Option<String>,
|
|
80
|
+
app_copyright: Option<String>,
|
|
81
|
+
icon: Vec<String>,
|
|
82
|
+
extra_resource: Vec<String>,
|
|
83
|
+
darwin_dark_mode_support: bool,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pub fn run(args: PackageArgs) -> Result<()> {
|
|
87
|
+
let snapshot = crate::project::inspect(&args.cwd)?;
|
|
88
|
+
let mut report = build_report(snapshot, &args)?;
|
|
89
|
+
|
|
90
|
+
if args.dry_run {
|
|
91
|
+
return print_report(&report, args.json);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
execute_package(&report, args.force)?;
|
|
95
|
+
report.status = PackageStatus::Packaged;
|
|
96
|
+
|
|
97
|
+
print_report(&report, args.json)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Result<PackageReport> {
|
|
101
|
+
let root = Path::new(snapshot.root.as_str());
|
|
102
|
+
let package_config = read_package_json_config(&snapshot)?;
|
|
103
|
+
let platform = args.platform.clone().unwrap_or_else(current_platform);
|
|
104
|
+
let arch = args.arch.clone().unwrap_or_else(current_arch);
|
|
105
|
+
let app_name = clean_app_name(
|
|
106
|
+
&args
|
|
107
|
+
.name
|
|
108
|
+
.clone()
|
|
109
|
+
.or_else(|| package_config.packager.name.clone())
|
|
110
|
+
.or_else(|| package_config.product_name.clone())
|
|
111
|
+
.or_else(|| snapshot.name.clone())
|
|
112
|
+
.unwrap_or_else(|| "electron-app".to_string()),
|
|
113
|
+
);
|
|
114
|
+
let executable_base = package_config
|
|
115
|
+
.packager
|
|
116
|
+
.executable_name
|
|
117
|
+
.clone()
|
|
118
|
+
.map(|name| clean_app_name(&name))
|
|
119
|
+
.unwrap_or_else(|| app_name.clone());
|
|
120
|
+
let executable_name = executable_name(&executable_base, &platform);
|
|
121
|
+
let artifact_name = sanitize_artifact_name(&app_name);
|
|
122
|
+
let output_dir = resolve_output_dir(root, &args.out_dir);
|
|
123
|
+
let package_root = output_dir.join(format!("{artifact_name}-{platform}-{arch}"));
|
|
124
|
+
let bundle_dir = bundle_dir(&package_root, &app_name, &platform);
|
|
125
|
+
let app_resources_dir = app_resources_dir(&bundle_dir, &platform);
|
|
126
|
+
let electron_dist = root.join("node_modules/electron/dist");
|
|
127
|
+
let electron_source = electron_source(&electron_dist, &platform);
|
|
128
|
+
let (metadata, metadata_warnings) = package_metadata(
|
|
129
|
+
root,
|
|
130
|
+
&package_config,
|
|
131
|
+
&artifact_name,
|
|
132
|
+
&app_resources_dir,
|
|
133
|
+
&platform,
|
|
134
|
+
)?;
|
|
135
|
+
|
|
136
|
+
let mut warnings = Vec::new();
|
|
137
|
+
if snapshot.package_json.is_none() {
|
|
138
|
+
warnings.push("No package.json found.".to_string());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if snapshot.electron_dependency.is_none() {
|
|
142
|
+
warnings.push("No electron dependency is declared in package.json.".to_string());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if snapshot.main.is_none() {
|
|
146
|
+
warnings.push("No package.json main field found.".to_string());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if !electron_source.exists() {
|
|
150
|
+
warnings.push(format!(
|
|
151
|
+
"Electron runtime was not found at {}.",
|
|
152
|
+
electron_source.display()
|
|
153
|
+
));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if platform != current_platform() {
|
|
157
|
+
warnings.push(format!(
|
|
158
|
+
"Cross-platform packaging is not implemented yet; this host can package {}.",
|
|
159
|
+
current_platform()
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if arch != current_arch() {
|
|
164
|
+
warnings.push(format!(
|
|
165
|
+
"Cross-architecture packaging is not implemented yet; this host can package {}.",
|
|
166
|
+
current_arch()
|
|
167
|
+
));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
warnings.extend(runtime_dependency_warnings(root, &snapshot));
|
|
171
|
+
warnings.extend(metadata_warnings);
|
|
172
|
+
|
|
173
|
+
let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
|
|
174
|
+
let mut copy_steps = vec![
|
|
175
|
+
(electron_source, bundle_dir.clone()),
|
|
176
|
+
(root.to_path_buf(), app_resources_dir.join("app")),
|
|
177
|
+
];
|
|
178
|
+
if has_runtime_dependencies(&snapshot) {
|
|
179
|
+
copy_steps.push((
|
|
180
|
+
root.join("node_modules"),
|
|
181
|
+
app_resources_dir.join("app/node_modules"),
|
|
182
|
+
));
|
|
183
|
+
}
|
|
184
|
+
if let Some(icon) = &metadata.icon {
|
|
185
|
+
copy_steps.push((
|
|
186
|
+
Path::new(icon.from.as_str()).to_path_buf(),
|
|
187
|
+
Path::new(icon.to.as_str()).to_path_buf(),
|
|
188
|
+
));
|
|
189
|
+
}
|
|
190
|
+
for resource in &metadata.extra_resources {
|
|
191
|
+
copy_steps.push((
|
|
192
|
+
Path::new(resource.from.as_str()).to_path_buf(),
|
|
193
|
+
Path::new(resource.to.as_str()).to_path_buf(),
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
Ok(PackageReport {
|
|
198
|
+
project: snapshot,
|
|
199
|
+
app_name,
|
|
200
|
+
executable_name,
|
|
201
|
+
metadata,
|
|
202
|
+
platform,
|
|
203
|
+
arch,
|
|
204
|
+
electron_dist: utf8_path(electron_dist)?,
|
|
205
|
+
output_dir: utf8_path(output_dir)?,
|
|
206
|
+
bundle_dir: utf8_path(bundle_dir)?,
|
|
207
|
+
app_resources_dir: utf8_path(app_resources_dir)?,
|
|
208
|
+
dry_run: args.dry_run,
|
|
209
|
+
status: PackageStatus::Planned,
|
|
210
|
+
create_dirs: create_dirs
|
|
211
|
+
.into_iter()
|
|
212
|
+
.map(utf8_path)
|
|
213
|
+
.collect::<Result<Vec<_>>>()?,
|
|
214
|
+
copy_steps: copy_steps
|
|
215
|
+
.into_iter()
|
|
216
|
+
.map(|(from, to)| {
|
|
217
|
+
Ok(CopyStep {
|
|
218
|
+
from: utf8_path(from)?,
|
|
219
|
+
to: utf8_path(to)?,
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
.collect::<Result<Vec<_>>>()?,
|
|
223
|
+
warnings,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()> {
|
|
228
|
+
if report.project.package_json.is_none() {
|
|
229
|
+
bail!("No package.json found. Run electron-cli package inside an Electron project.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if report.project.electron_dependency.is_none() {
|
|
233
|
+
bail!("No electron dependency found. Install Electron before packaging the app.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if report.platform != current_platform() {
|
|
237
|
+
bail!(
|
|
238
|
+
"Cross-platform packaging is not implemented yet. Requested {}, host is {}.",
|
|
239
|
+
report.platform,
|
|
240
|
+
current_platform()
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if report.arch != current_arch() {
|
|
245
|
+
bail!(
|
|
246
|
+
"Cross-architecture packaging is not implemented yet. Requested {}, host is {}.",
|
|
247
|
+
report.arch,
|
|
248
|
+
current_arch()
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let bundle_dir = Path::new(report.bundle_dir.as_str());
|
|
253
|
+
let package_root = package_root(bundle_dir, &report.platform);
|
|
254
|
+
let app_resources_dir = Path::new(report.app_resources_dir.as_str());
|
|
255
|
+
let app_dir = app_resources_dir.join("app");
|
|
256
|
+
|
|
257
|
+
if package_root.exists() {
|
|
258
|
+
if force {
|
|
259
|
+
fs::remove_dir_all(&package_root)
|
|
260
|
+
.with_context(|| format!("Could not remove {}", package_root.display()))?;
|
|
261
|
+
} else {
|
|
262
|
+
bail!(
|
|
263
|
+
"Package output already exists: {}. Use --force to overwrite it.",
|
|
264
|
+
package_root.display()
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let electron_source = Path::new(report.copy_steps[0].from.as_str());
|
|
270
|
+
if !electron_source.exists() {
|
|
271
|
+
bail!(
|
|
272
|
+
"Electron runtime was not found at {}. Run your package manager install first.",
|
|
273
|
+
electron_source.display()
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fs::create_dir_all(&package_root)
|
|
278
|
+
.with_context(|| format!("Could not create {}", package_root.display()))?;
|
|
279
|
+
copy_recursively(electron_source, bundle_dir).with_context(|| {
|
|
280
|
+
format!(
|
|
281
|
+
"Could not copy Electron runtime to {}",
|
|
282
|
+
bundle_dir.display()
|
|
283
|
+
)
|
|
284
|
+
})?;
|
|
285
|
+
rename_runtime_executable(bundle_dir, &report.executable_name, &report.platform)?;
|
|
286
|
+
apply_package_metadata(report)?;
|
|
287
|
+
copy_package_resources(report)?;
|
|
288
|
+
|
|
289
|
+
fs::create_dir_all(&app_dir)
|
|
290
|
+
.with_context(|| format!("Could not create {}", app_dir.display()))?;
|
|
291
|
+
copy_project_files(
|
|
292
|
+
Path::new(report.project.root.as_str()),
|
|
293
|
+
&app_dir,
|
|
294
|
+
Path::new(report.output_dir.as_str()),
|
|
295
|
+
)?;
|
|
296
|
+
copy_runtime_dependencies(
|
|
297
|
+
Path::new(report.project.root.as_str()),
|
|
298
|
+
&app_dir,
|
|
299
|
+
&report.project,
|
|
300
|
+
)?;
|
|
301
|
+
|
|
302
|
+
Ok(())
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn print_report(report: &PackageReport, json: bool) -> Result<()> {
|
|
306
|
+
if json {
|
|
307
|
+
return output::json(report);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
println!("electron-cli package");
|
|
311
|
+
println!();
|
|
312
|
+
println!("Project");
|
|
313
|
+
println!(" root: {}", report.project.root);
|
|
314
|
+
match report.project.package_label() {
|
|
315
|
+
Some(label) => println!(" package: {label}"),
|
|
316
|
+
None => println!(" package: not found"),
|
|
317
|
+
}
|
|
318
|
+
println!(" app name: {}", report.app_name);
|
|
319
|
+
println!(" executable: {}", report.executable_name);
|
|
320
|
+
println!(" bundle id: {}", report.metadata.bundle_identifier);
|
|
321
|
+
if let Some(version) = &report.metadata.app_version {
|
|
322
|
+
println!(" app version: {version}");
|
|
323
|
+
}
|
|
324
|
+
println!(" target: {} {}", report.platform, report.arch);
|
|
325
|
+
println!(" status: {}", report.status.as_str());
|
|
326
|
+
|
|
327
|
+
println!();
|
|
328
|
+
println!("Output");
|
|
329
|
+
println!(" {}", report.bundle_dir);
|
|
330
|
+
|
|
331
|
+
println!();
|
|
332
|
+
println!("Copy");
|
|
333
|
+
for step in &report.copy_steps {
|
|
334
|
+
println!(" {} -> {}", step.from, step.to);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if !report.warnings.is_empty() {
|
|
338
|
+
println!();
|
|
339
|
+
println!("Warnings");
|
|
340
|
+
for warning in &report.warnings {
|
|
341
|
+
println!(" {warning}");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
Ok(())
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn read_package_json_config(snapshot: &ProjectSnapshot) -> Result<PackageJsonConfig> {
|
|
349
|
+
let Some(package_json_path) = &snapshot.package_json else {
|
|
350
|
+
return Ok(PackageJsonConfig::default());
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
let package_json_path = Path::new(package_json_path.as_str());
|
|
354
|
+
let raw = fs::read_to_string(package_json_path)
|
|
355
|
+
.with_context(|| format!("Could not read {}", package_json_path.display()))?;
|
|
356
|
+
let package = serde_json::from_str::<JsonValue>(&raw)
|
|
357
|
+
.with_context(|| format!("Could not parse {}", package_json_path.display()))?;
|
|
358
|
+
|
|
359
|
+
let mut packager = PackagerConfig::default();
|
|
360
|
+
if let Some(config) = package
|
|
361
|
+
.get("config")
|
|
362
|
+
.and_then(|config| config.get("forge"))
|
|
363
|
+
.and_then(|forge| forge.get("packagerConfig"))
|
|
364
|
+
{
|
|
365
|
+
packager.merge(parse_packager_config(config));
|
|
366
|
+
}
|
|
367
|
+
if let Some(config) = package.get("electronPackagerConfig") {
|
|
368
|
+
packager.merge(parse_packager_config(config));
|
|
369
|
+
}
|
|
370
|
+
if let Some(config) = package
|
|
371
|
+
.get("electronCli")
|
|
372
|
+
.or_else(|| package.get("electron-cli"))
|
|
373
|
+
.and_then(|config| config.get("packagerConfig"))
|
|
374
|
+
{
|
|
375
|
+
packager.merge(parse_packager_config(config));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
Ok(PackageJsonConfig {
|
|
379
|
+
product_name: package
|
|
380
|
+
.get("productName")
|
|
381
|
+
.and_then(JsonValue::as_str)
|
|
382
|
+
.map(ToOwned::to_owned),
|
|
383
|
+
app_version: package
|
|
384
|
+
.get("version")
|
|
385
|
+
.and_then(JsonValue::as_str)
|
|
386
|
+
.map(ToOwned::to_owned),
|
|
387
|
+
packager,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
fn parse_packager_config(value: &JsonValue) -> PackagerConfig {
|
|
392
|
+
PackagerConfig {
|
|
393
|
+
name: string_value(value, "name"),
|
|
394
|
+
executable_name: string_value(value, "executableName"),
|
|
395
|
+
app_bundle_id: string_value(value, "appBundleId"),
|
|
396
|
+
app_category_type: string_value(value, "appCategoryType"),
|
|
397
|
+
app_version: string_value(value, "appVersion"),
|
|
398
|
+
build_version: string_value(value, "buildVersion"),
|
|
399
|
+
app_copyright: string_value(value, "appCopyright"),
|
|
400
|
+
icon: string_list(value.get("icon")),
|
|
401
|
+
extra_resource: string_list(value.get("extraResource")),
|
|
402
|
+
darwin_dark_mode_support: value
|
|
403
|
+
.get("darwinDarkModeSupport")
|
|
404
|
+
.and_then(JsonValue::as_bool)
|
|
405
|
+
.unwrap_or(false),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn string_value(value: &JsonValue, key: &str) -> Option<String> {
|
|
410
|
+
value
|
|
411
|
+
.get(key)
|
|
412
|
+
.and_then(JsonValue::as_str)
|
|
413
|
+
.map(ToOwned::to_owned)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
fn string_list(value: Option<&JsonValue>) -> Vec<String> {
|
|
417
|
+
match value {
|
|
418
|
+
Some(JsonValue::String(value)) => vec![value.clone()],
|
|
419
|
+
Some(JsonValue::Array(values)) => values
|
|
420
|
+
.iter()
|
|
421
|
+
.filter_map(JsonValue::as_str)
|
|
422
|
+
.map(ToOwned::to_owned)
|
|
423
|
+
.collect(),
|
|
424
|
+
_ => Vec::new(),
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn package_metadata(
|
|
429
|
+
root: &Path,
|
|
430
|
+
config: &PackageJsonConfig,
|
|
431
|
+
artifact_name: &str,
|
|
432
|
+
app_resources_dir: &Path,
|
|
433
|
+
platform: &str,
|
|
434
|
+
) -> Result<(PackageMetadata, Vec<String>)> {
|
|
435
|
+
let mut warnings = Vec::new();
|
|
436
|
+
let icon = resolve_icon_resource(
|
|
437
|
+
root,
|
|
438
|
+
&config.packager.icon,
|
|
439
|
+
artifact_name,
|
|
440
|
+
app_resources_dir,
|
|
441
|
+
platform,
|
|
442
|
+
&mut warnings,
|
|
443
|
+
)?;
|
|
444
|
+
let extra_resources = resolve_extra_resources(
|
|
445
|
+
root,
|
|
446
|
+
&config.packager.extra_resource,
|
|
447
|
+
app_resources_dir,
|
|
448
|
+
&mut warnings,
|
|
449
|
+
)?;
|
|
450
|
+
let app_version = config
|
|
451
|
+
.packager
|
|
452
|
+
.app_version
|
|
453
|
+
.clone()
|
|
454
|
+
.or_else(|| config.app_version.clone());
|
|
455
|
+
|
|
456
|
+
Ok((
|
|
457
|
+
PackageMetadata {
|
|
458
|
+
bundle_identifier: config
|
|
459
|
+
.packager
|
|
460
|
+
.app_bundle_id
|
|
461
|
+
.clone()
|
|
462
|
+
.unwrap_or_else(|| default_bundle_identifier(artifact_name)),
|
|
463
|
+
app_version: app_version.clone(),
|
|
464
|
+
build_version: config
|
|
465
|
+
.packager
|
|
466
|
+
.build_version
|
|
467
|
+
.clone()
|
|
468
|
+
.or_else(|| app_version.clone()),
|
|
469
|
+
app_category_type: config.packager.app_category_type.clone(),
|
|
470
|
+
app_copyright: config.packager.app_copyright.clone(),
|
|
471
|
+
icon,
|
|
472
|
+
extra_resources,
|
|
473
|
+
darwin_dark_mode_support: config.packager.darwin_dark_mode_support,
|
|
474
|
+
},
|
|
475
|
+
warnings,
|
|
476
|
+
))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fn resolve_icon_resource(
|
|
480
|
+
root: &Path,
|
|
481
|
+
configured_icons: &[String],
|
|
482
|
+
artifact_name: &str,
|
|
483
|
+
app_resources_dir: &Path,
|
|
484
|
+
platform: &str,
|
|
485
|
+
warnings: &mut Vec<String>,
|
|
486
|
+
) -> Result<Option<IconResource>> {
|
|
487
|
+
let candidates = configured_icons
|
|
488
|
+
.iter()
|
|
489
|
+
.filter_map(|icon| icon_candidate(root, icon, platform))
|
|
490
|
+
.collect::<Vec<_>>();
|
|
491
|
+
let source = if platform == "darwin" {
|
|
492
|
+
candidates
|
|
493
|
+
.iter()
|
|
494
|
+
.find(|candidate| candidate.exists() && path_extension(candidate) == Some("icns"))
|
|
495
|
+
.cloned()
|
|
496
|
+
.or_else(|| {
|
|
497
|
+
candidates
|
|
498
|
+
.iter()
|
|
499
|
+
.find(|candidate| candidate.exists())
|
|
500
|
+
.cloned()
|
|
501
|
+
})
|
|
502
|
+
} else {
|
|
503
|
+
candidates
|
|
504
|
+
.iter()
|
|
505
|
+
.find(|candidate| candidate.exists())
|
|
506
|
+
.cloned()
|
|
507
|
+
};
|
|
508
|
+
let Some(source) = source else {
|
|
509
|
+
if let Some(first) = configured_icons.first() {
|
|
510
|
+
let expected = icon_candidate(root, first, platform)
|
|
511
|
+
.unwrap_or_else(|| resolve_project_path(root, first));
|
|
512
|
+
warnings.push(format!(
|
|
513
|
+
"Configured icon was not found for {platform}: {}.",
|
|
514
|
+
expected.display()
|
|
515
|
+
));
|
|
516
|
+
}
|
|
517
|
+
return Ok(None);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if platform == "darwin" && path_extension(&source) == Some("icon") {
|
|
521
|
+
warnings.push(
|
|
522
|
+
"macOS .icon files are not applied yet; provide an .icns icon for now.".to_string(),
|
|
523
|
+
);
|
|
524
|
+
return Ok(None);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if platform == "win32" {
|
|
528
|
+
warnings.push("Windows executable icon embedding is not implemented yet.".to_string());
|
|
529
|
+
return Ok(None);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if platform == "linux" {
|
|
533
|
+
warnings.push(
|
|
534
|
+
"Linux executable icons are not embedded; set the BrowserWindow icon in app code."
|
|
535
|
+
.to_string(),
|
|
536
|
+
);
|
|
537
|
+
return Ok(None);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let extension = path_extension(&source).unwrap_or("icns");
|
|
541
|
+
let destination = app_resources_dir.join(format!("{artifact_name}.{extension}"));
|
|
542
|
+
|
|
543
|
+
Ok(Some(IconResource {
|
|
544
|
+
from: utf8_path(source)?,
|
|
545
|
+
to: utf8_path(destination)?,
|
|
546
|
+
}))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
fn path_extension(path: &Path) -> Option<&str> {
|
|
550
|
+
path.extension().and_then(|extension| extension.to_str())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
fn icon_candidate(root: &Path, configured_icon: &str, platform: &str) -> Option<PathBuf> {
|
|
554
|
+
if configured_icon.trim().is_empty() {
|
|
555
|
+
return None;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let path = resolve_project_path(root, configured_icon);
|
|
559
|
+
if path.extension().is_some() {
|
|
560
|
+
return Some(path);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let extension = match platform {
|
|
564
|
+
"darwin" => "icns",
|
|
565
|
+
"win32" => "ico",
|
|
566
|
+
"linux" => "png",
|
|
567
|
+
_ => return Some(path),
|
|
568
|
+
};
|
|
569
|
+
Some(path.with_extension(extension))
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
fn resolve_extra_resources(
|
|
573
|
+
root: &Path,
|
|
574
|
+
extra_resources: &[String],
|
|
575
|
+
app_resources_dir: &Path,
|
|
576
|
+
warnings: &mut Vec<String>,
|
|
577
|
+
) -> Result<Vec<CopyStep>> {
|
|
578
|
+
extra_resources
|
|
579
|
+
.iter()
|
|
580
|
+
.filter(|resource| !resource.trim().is_empty())
|
|
581
|
+
.map(|resource| {
|
|
582
|
+
let source = resolve_project_path(root, resource);
|
|
583
|
+
if !source.exists() {
|
|
584
|
+
warnings.push(format!(
|
|
585
|
+
"Configured extra resource does not exist and packaging will fail: {}.",
|
|
586
|
+
source.display()
|
|
587
|
+
));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let file_name = source
|
|
591
|
+
.file_name()
|
|
592
|
+
.with_context(|| format!("Extra resource has no file name: {}", source.display()))?
|
|
593
|
+
.to_owned();
|
|
594
|
+
Ok(CopyStep {
|
|
595
|
+
from: utf8_path(source)?,
|
|
596
|
+
to: utf8_path(app_resources_dir.join(file_name))?,
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
.collect()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
fn resolve_project_path(root: &Path, path: &str) -> PathBuf {
|
|
603
|
+
let path = Path::new(path);
|
|
604
|
+
if path.is_absolute() {
|
|
605
|
+
path.to_path_buf()
|
|
606
|
+
} else {
|
|
607
|
+
root.join(path)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
fn default_bundle_identifier(artifact_name: &str) -> String {
|
|
612
|
+
let component = artifact_name
|
|
613
|
+
.chars()
|
|
614
|
+
.map(|char| {
|
|
615
|
+
if char.is_ascii_alphanumeric() || char == '-' {
|
|
616
|
+
char
|
|
617
|
+
} else {
|
|
618
|
+
'.'
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
.collect::<String>()
|
|
622
|
+
.trim_matches(['.', '-'])
|
|
623
|
+
.to_string();
|
|
624
|
+
let component = if component.is_empty() {
|
|
625
|
+
"electron-app".to_string()
|
|
626
|
+
} else {
|
|
627
|
+
component
|
|
628
|
+
};
|
|
629
|
+
format!("com.electron.{component}")
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> Result<()> {
|
|
633
|
+
for entry in
|
|
634
|
+
fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
|
|
635
|
+
{
|
|
636
|
+
let entry = entry?;
|
|
637
|
+
let source_path = entry.path();
|
|
638
|
+
let file_name = entry.file_name();
|
|
639
|
+
let file_name = file_name.to_string_lossy();
|
|
640
|
+
|
|
641
|
+
if should_skip_project_entry(&source_path, &file_name, output_dir) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
let destination_path = destination.join(file_name.as_ref());
|
|
646
|
+
if source_path.is_dir() {
|
|
647
|
+
copy_project_files(&source_path, &destination_path, output_dir)?;
|
|
648
|
+
} else {
|
|
649
|
+
if let Some(parent) = destination_path.parent() {
|
|
650
|
+
fs::create_dir_all(parent)
|
|
651
|
+
.with_context(|| format!("Could not create {}", parent.display()))?;
|
|
652
|
+
}
|
|
653
|
+
fs::copy(&source_path, &destination_path).with_context(|| {
|
|
654
|
+
format!(
|
|
655
|
+
"Could not copy {} to {}",
|
|
656
|
+
source_path.display(),
|
|
657
|
+
destination_path.display()
|
|
658
|
+
)
|
|
659
|
+
})?;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
Ok(())
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#[derive(Debug)]
|
|
667
|
+
struct DependencyRequest {
|
|
668
|
+
name: String,
|
|
669
|
+
requested_by: Option<PathBuf>,
|
|
670
|
+
optional: bool,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
fn copy_runtime_dependencies(
|
|
674
|
+
root: &Path,
|
|
675
|
+
app_dir: &Path,
|
|
676
|
+
snapshot: &ProjectSnapshot,
|
|
677
|
+
) -> Result<()> {
|
|
678
|
+
if !has_runtime_dependencies(snapshot) {
|
|
679
|
+
return Ok(());
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let root_node_modules = root.join("node_modules");
|
|
683
|
+
let app_node_modules = app_dir.join("node_modules");
|
|
684
|
+
let mut queue = VecDeque::new();
|
|
685
|
+
let mut copied_paths = BTreeSet::new();
|
|
686
|
+
|
|
687
|
+
for name in snapshot.dependencies.keys() {
|
|
688
|
+
queue.push_back(DependencyRequest {
|
|
689
|
+
name: name.clone(),
|
|
690
|
+
requested_by: None,
|
|
691
|
+
optional: false,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
for name in snapshot.optional_dependencies.keys() {
|
|
696
|
+
queue.push_back(DependencyRequest {
|
|
697
|
+
name: name.clone(),
|
|
698
|
+
requested_by: None,
|
|
699
|
+
optional: true,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
while let Some(request) = queue.pop_front() {
|
|
704
|
+
let Some(package_dir) = resolve_dependency_dir(
|
|
705
|
+
&root_node_modules,
|
|
706
|
+
request.requested_by.as_deref(),
|
|
707
|
+
&request.name,
|
|
708
|
+
) else {
|
|
709
|
+
if request.optional {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
bail!(
|
|
714
|
+
"Runtime dependency '{}' is not installed. Run your package manager install first.",
|
|
715
|
+
request.name
|
|
716
|
+
);
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
let canonical_package_dir = package_dir
|
|
720
|
+
.canonicalize()
|
|
721
|
+
.with_context(|| format!("Could not resolve {}", package_dir.display()))?;
|
|
722
|
+
let canonical_root_node_modules = root_node_modules
|
|
723
|
+
.canonicalize()
|
|
724
|
+
.with_context(|| format!("Could not resolve {}", root_node_modules.display()))?;
|
|
725
|
+
if !copied_paths.insert(canonical_package_dir.clone()) {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let relative_path = canonical_package_dir
|
|
730
|
+
.strip_prefix(&canonical_root_node_modules)
|
|
731
|
+
.with_context(|| {
|
|
732
|
+
format!(
|
|
733
|
+
"Could not make dependency {} relative to {}",
|
|
734
|
+
canonical_package_dir.display(),
|
|
735
|
+
canonical_root_node_modules.display()
|
|
736
|
+
)
|
|
737
|
+
})?;
|
|
738
|
+
let destination = app_node_modules.join(relative_path);
|
|
739
|
+
copy_recursively(&canonical_package_dir, &destination).with_context(|| {
|
|
740
|
+
format!(
|
|
741
|
+
"Could not copy runtime dependency {} to {}",
|
|
742
|
+
canonical_package_dir.display(),
|
|
743
|
+
destination.display()
|
|
744
|
+
)
|
|
745
|
+
})?;
|
|
746
|
+
|
|
747
|
+
let package_json = read_dependency_package_json(&canonical_package_dir)?;
|
|
748
|
+
for name in string_map(package_json.get("dependencies")).keys() {
|
|
749
|
+
queue.push_back(DependencyRequest {
|
|
750
|
+
name: name.clone(),
|
|
751
|
+
requested_by: Some(canonical_package_dir.clone()),
|
|
752
|
+
optional: false,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
for name in string_map(package_json.get("optionalDependencies")).keys() {
|
|
756
|
+
queue.push_back(DependencyRequest {
|
|
757
|
+
name: name.clone(),
|
|
758
|
+
requested_by: Some(canonical_package_dir.clone()),
|
|
759
|
+
optional: true,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
Ok(())
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
fn apply_package_metadata(report: &PackageReport) -> Result<()> {
|
|
768
|
+
if report.platform == "darwin" {
|
|
769
|
+
apply_macos_metadata(report)?;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
Ok(())
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
fn apply_macos_metadata(report: &PackageReport) -> Result<()> {
|
|
776
|
+
let bundle_dir = Path::new(report.bundle_dir.as_str());
|
|
777
|
+
let info_plist_path = bundle_dir.join("Contents/Info.plist");
|
|
778
|
+
let mut dictionary = if info_plist_path.exists() {
|
|
779
|
+
match PlistValue::from_file(&info_plist_path)
|
|
780
|
+
.with_context(|| format!("Could not read {}", info_plist_path.display()))?
|
|
781
|
+
{
|
|
782
|
+
PlistValue::Dictionary(dictionary) => dictionary,
|
|
783
|
+
_ => bail!("{} is not a plist dictionary", info_plist_path.display()),
|
|
784
|
+
}
|
|
785
|
+
} else {
|
|
786
|
+
PlistDictionary::new()
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
set_plist_string(&mut dictionary, "CFBundleName", &report.app_name);
|
|
790
|
+
set_plist_string(&mut dictionary, "CFBundleDisplayName", &report.app_name);
|
|
791
|
+
set_plist_string(
|
|
792
|
+
&mut dictionary,
|
|
793
|
+
"CFBundleExecutable",
|
|
794
|
+
&report.executable_name,
|
|
795
|
+
);
|
|
796
|
+
set_plist_string(
|
|
797
|
+
&mut dictionary,
|
|
798
|
+
"CFBundleIdentifier",
|
|
799
|
+
&report.metadata.bundle_identifier,
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
if let Some(version) = &report.metadata.app_version {
|
|
803
|
+
set_plist_string(&mut dictionary, "CFBundleShortVersionString", version);
|
|
804
|
+
}
|
|
805
|
+
if let Some(version) = &report.metadata.build_version {
|
|
806
|
+
set_plist_string(&mut dictionary, "CFBundleVersion", version);
|
|
807
|
+
}
|
|
808
|
+
if let Some(category) = &report.metadata.app_category_type {
|
|
809
|
+
set_plist_string(&mut dictionary, "LSApplicationCategoryType", category);
|
|
810
|
+
}
|
|
811
|
+
if let Some(copyright) = &report.metadata.app_copyright {
|
|
812
|
+
set_plist_string(&mut dictionary, "NSHumanReadableCopyright", copyright);
|
|
813
|
+
}
|
|
814
|
+
if let Some(icon) = &report.metadata.icon {
|
|
815
|
+
let icon_name = Path::new(icon.to.as_str())
|
|
816
|
+
.file_name()
|
|
817
|
+
.and_then(|file_name| file_name.to_str())
|
|
818
|
+
.context("Icon destination has no file name")?;
|
|
819
|
+
set_plist_string(&mut dictionary, "CFBundleIconFile", icon_name);
|
|
820
|
+
}
|
|
821
|
+
if report.metadata.darwin_dark_mode_support {
|
|
822
|
+
dictionary.insert(
|
|
823
|
+
"NSRequiresAquaSystemAppearance".to_string(),
|
|
824
|
+
PlistValue::Boolean(false),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if let Some(parent) = info_plist_path.parent() {
|
|
829
|
+
fs::create_dir_all(parent)
|
|
830
|
+
.with_context(|| format!("Could not create {}", parent.display()))?;
|
|
831
|
+
}
|
|
832
|
+
PlistValue::Dictionary(dictionary)
|
|
833
|
+
.to_file_xml(&info_plist_path)
|
|
834
|
+
.with_context(|| format!("Could not write {}", info_plist_path.display()))?;
|
|
835
|
+
|
|
836
|
+
Ok(())
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
fn set_plist_string(dictionary: &mut PlistDictionary, key: &str, value: &str) {
|
|
840
|
+
dictionary.insert(key.to_string(), PlistValue::String(value.to_string()));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fn copy_package_resources(report: &PackageReport) -> Result<()> {
|
|
844
|
+
if let Some(icon) = &report.metadata.icon {
|
|
845
|
+
copy_recursively(Path::new(icon.from.as_str()), Path::new(icon.to.as_str()))
|
|
846
|
+
.with_context(|| format!("Could not copy icon to {}", icon.to))?;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
for resource in &report.metadata.extra_resources {
|
|
850
|
+
copy_recursively(
|
|
851
|
+
Path::new(resource.from.as_str()),
|
|
852
|
+
Path::new(resource.to.as_str()),
|
|
853
|
+
)
|
|
854
|
+
.with_context(|| format!("Could not copy extra resource to {}", resource.to))?;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
Ok(())
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
fn runtime_dependency_warnings(root: &Path, snapshot: &ProjectSnapshot) -> Vec<String> {
|
|
861
|
+
let mut warnings = Vec::new();
|
|
862
|
+
let root_node_modules = root.join("node_modules");
|
|
863
|
+
|
|
864
|
+
for name in snapshot.dependencies.keys() {
|
|
865
|
+
if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
|
|
866
|
+
warnings.push(format!(
|
|
867
|
+
"Runtime dependency is not installed and packaging will fail: {name}."
|
|
868
|
+
));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for name in snapshot.optional_dependencies.keys() {
|
|
873
|
+
if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
|
|
874
|
+
warnings.push(format!(
|
|
875
|
+
"Optional runtime dependency is not installed and will be skipped: {name}."
|
|
876
|
+
));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
warnings
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
fn resolve_dependency_dir(
|
|
884
|
+
root_node_modules: &Path,
|
|
885
|
+
requested_by: Option<&Path>,
|
|
886
|
+
name: &str,
|
|
887
|
+
) -> Option<PathBuf> {
|
|
888
|
+
let relative_path = dependency_relative_path(name);
|
|
889
|
+
|
|
890
|
+
if let Some(requested_by) = requested_by {
|
|
891
|
+
let nested = requested_by.join("node_modules").join(&relative_path);
|
|
892
|
+
if nested.exists() {
|
|
893
|
+
return Some(nested);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let hoisted = root_node_modules.join(relative_path);
|
|
898
|
+
hoisted.exists().then_some(hoisted)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
fn dependency_relative_path(name: &str) -> PathBuf {
|
|
902
|
+
let mut path = PathBuf::new();
|
|
903
|
+
for part in name.split('/') {
|
|
904
|
+
if !part.is_empty() {
|
|
905
|
+
path.push(part);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
path
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
fn read_dependency_package_json(package_dir: &Path) -> Result<JsonValue> {
|
|
912
|
+
let package_json_path = package_dir.join("package.json");
|
|
913
|
+
let raw = fs::read_to_string(&package_json_path)
|
|
914
|
+
.with_context(|| format!("Could not read {}", package_json_path.display()))?;
|
|
915
|
+
serde_json::from_str::<JsonValue>(&raw)
|
|
916
|
+
.with_context(|| format!("Could not parse {}", package_json_path.display()))
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
fn string_map(value: Option<&JsonValue>) -> BTreeMap<String, String> {
|
|
920
|
+
value
|
|
921
|
+
.and_then(JsonValue::as_object)
|
|
922
|
+
.map(|object| {
|
|
923
|
+
object
|
|
924
|
+
.iter()
|
|
925
|
+
.filter_map(|(key, value)| {
|
|
926
|
+
value.as_str().map(|value| (key.clone(), value.to_string()))
|
|
927
|
+
})
|
|
928
|
+
.collect()
|
|
929
|
+
})
|
|
930
|
+
.unwrap_or_default()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
fn copy_recursively(source: &Path, destination: &Path) -> Result<()> {
|
|
934
|
+
if source.is_dir() {
|
|
935
|
+
fs::create_dir_all(destination)
|
|
936
|
+
.with_context(|| format!("Could not create {}", destination.display()))?;
|
|
937
|
+
|
|
938
|
+
for entry in
|
|
939
|
+
fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
|
|
940
|
+
{
|
|
941
|
+
let entry = entry?;
|
|
942
|
+
let source_path = entry.path();
|
|
943
|
+
let destination_path = destination.join(entry.file_name());
|
|
944
|
+
copy_recursively(&source_path, &destination_path)?;
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
if let Some(parent) = destination.parent() {
|
|
948
|
+
fs::create_dir_all(parent)
|
|
949
|
+
.with_context(|| format!("Could not create {}", parent.display()))?;
|
|
950
|
+
}
|
|
951
|
+
fs::copy(source, destination).with_context(|| {
|
|
952
|
+
format!(
|
|
953
|
+
"Could not copy {} to {}",
|
|
954
|
+
source.display(),
|
|
955
|
+
destination.display()
|
|
956
|
+
)
|
|
957
|
+
})?;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
Ok(())
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
fn should_skip_project_entry(source_path: &Path, file_name: &str, output_dir: &Path) -> bool {
|
|
964
|
+
if matches!(file_name, ".git" | "node_modules" | "target") {
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
same_path_or_inside(source_path, output_dir)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
fn same_path_or_inside(path: &Path, parent: &Path) -> bool {
|
|
972
|
+
match (path.canonicalize(), parent.canonicalize()) {
|
|
973
|
+
(Ok(path), Ok(parent)) => path == parent || path.starts_with(parent),
|
|
974
|
+
_ => false,
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
fn rename_runtime_executable(
|
|
979
|
+
bundle_dir: &Path,
|
|
980
|
+
executable_name: &str,
|
|
981
|
+
platform: &str,
|
|
982
|
+
) -> Result<()> {
|
|
983
|
+
let current = if platform == "darwin" {
|
|
984
|
+
bundle_dir.join("Contents/MacOS/Electron")
|
|
985
|
+
} else if platform == "win32" {
|
|
986
|
+
bundle_dir.join("electron.exe")
|
|
987
|
+
} else {
|
|
988
|
+
bundle_dir.join("electron")
|
|
989
|
+
};
|
|
990
|
+
let target = if platform == "darwin" {
|
|
991
|
+
bundle_dir.join("Contents/MacOS").join(executable_name)
|
|
992
|
+
} else {
|
|
993
|
+
bundle_dir.join(executable_name)
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
if current.exists() && current != target {
|
|
997
|
+
fs::rename(¤t, &target).with_context(|| {
|
|
998
|
+
format!(
|
|
999
|
+
"Could not rename {} to {}",
|
|
1000
|
+
current.display(),
|
|
1001
|
+
target.display()
|
|
1002
|
+
)
|
|
1003
|
+
})?;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
Ok(())
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
fn resolve_output_dir(root: &Path, out_dir: &Path) -> PathBuf {
|
|
1010
|
+
if out_dir.is_absolute() {
|
|
1011
|
+
out_dir.to_path_buf()
|
|
1012
|
+
} else {
|
|
1013
|
+
root.join(out_dir)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
fn electron_source(electron_dist: &Path, platform: &str) -> PathBuf {
|
|
1018
|
+
if platform == "darwin" {
|
|
1019
|
+
electron_dist.join("Electron.app")
|
|
1020
|
+
} else {
|
|
1021
|
+
electron_dist.to_path_buf()
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
fn bundle_dir(package_root: &Path, app_name: &str, platform: &str) -> PathBuf {
|
|
1026
|
+
if platform == "darwin" {
|
|
1027
|
+
package_root.join(format!("{app_name}.app"))
|
|
1028
|
+
} else {
|
|
1029
|
+
package_root.to_path_buf()
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
fn package_root(bundle_dir: &Path, platform: &str) -> PathBuf {
|
|
1034
|
+
if platform == "darwin" {
|
|
1035
|
+
bundle_dir
|
|
1036
|
+
.parent()
|
|
1037
|
+
.expect("macOS bundle should have package parent")
|
|
1038
|
+
.to_path_buf()
|
|
1039
|
+
} else {
|
|
1040
|
+
bundle_dir.to_path_buf()
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
fn app_resources_dir(bundle_dir: &Path, platform: &str) -> PathBuf {
|
|
1045
|
+
if platform == "darwin" {
|
|
1046
|
+
bundle_dir.join("Contents/Resources")
|
|
1047
|
+
} else {
|
|
1048
|
+
bundle_dir.join("resources")
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
fn executable_name(app_name: &str, platform: &str) -> String {
|
|
1053
|
+
let mut name = sanitize_artifact_name(app_name);
|
|
1054
|
+
if platform == "win32" {
|
|
1055
|
+
name.push_str(".exe");
|
|
1056
|
+
}
|
|
1057
|
+
name
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
fn clean_app_name(name: &str) -> String {
|
|
1061
|
+
let cleaned = name
|
|
1062
|
+
.chars()
|
|
1063
|
+
.map(|char| {
|
|
1064
|
+
if char.is_ascii_alphanumeric() || matches!(char, ' ' | '-' | '_' | '.') {
|
|
1065
|
+
char
|
|
1066
|
+
} else {
|
|
1067
|
+
'-'
|
|
1068
|
+
}
|
|
1069
|
+
})
|
|
1070
|
+
.collect::<String>()
|
|
1071
|
+
.trim_matches([' ', '-', '.', '_'])
|
|
1072
|
+
.to_string();
|
|
1073
|
+
|
|
1074
|
+
if cleaned.is_empty() {
|
|
1075
|
+
"electron-app".to_string()
|
|
1076
|
+
} else {
|
|
1077
|
+
cleaned
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
fn sanitize_artifact_name(name: &str) -> String {
|
|
1082
|
+
let sanitized = name
|
|
1083
|
+
.to_ascii_lowercase()
|
|
1084
|
+
.chars()
|
|
1085
|
+
.map(|char| {
|
|
1086
|
+
if char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.') {
|
|
1087
|
+
char
|
|
1088
|
+
} else {
|
|
1089
|
+
'-'
|
|
1090
|
+
}
|
|
1091
|
+
})
|
|
1092
|
+
.collect::<String>()
|
|
1093
|
+
.trim_matches(['-', '.', '_'])
|
|
1094
|
+
.to_string();
|
|
1095
|
+
|
|
1096
|
+
if sanitized.is_empty() {
|
|
1097
|
+
"electron-app".to_string()
|
|
1098
|
+
} else {
|
|
1099
|
+
sanitized
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
fn has_runtime_dependencies(snapshot: &ProjectSnapshot) -> bool {
|
|
1104
|
+
!snapshot.dependencies.is_empty() || !snapshot.optional_dependencies.is_empty()
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
fn current_platform() -> String {
|
|
1108
|
+
if cfg!(target_os = "macos") {
|
|
1109
|
+
"darwin".to_string()
|
|
1110
|
+
} else if cfg!(target_os = "windows") {
|
|
1111
|
+
"win32".to_string()
|
|
1112
|
+
} else {
|
|
1113
|
+
"linux".to_string()
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
fn current_arch() -> String {
|
|
1118
|
+
match std::env::consts::ARCH {
|
|
1119
|
+
"aarch64" => "arm64".to_string(),
|
|
1120
|
+
"x86_64" => "x64".to_string(),
|
|
1121
|
+
arch => arch.to_string(),
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
|
|
1126
|
+
Utf8PathBuf::from_path_buf(path).map_err(|path| {
|
|
1127
|
+
anyhow::anyhow!(
|
|
1128
|
+
"Path contains invalid UTF-8 and cannot be represented in JSON: {}",
|
|
1129
|
+
path.display()
|
|
1130
|
+
)
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
impl PackageStatus {
|
|
1135
|
+
fn as_str(&self) -> &'static str {
|
|
1136
|
+
match self {
|
|
1137
|
+
PackageStatus::Planned => "planned",
|
|
1138
|
+
PackageStatus::Packaged => "packaged",
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
impl PackagerConfig {
|
|
1144
|
+
fn merge(&mut self, other: PackagerConfig) {
|
|
1145
|
+
self.name = other.name.or_else(|| self.name.take());
|
|
1146
|
+
self.executable_name = other
|
|
1147
|
+
.executable_name
|
|
1148
|
+
.or_else(|| self.executable_name.take());
|
|
1149
|
+
self.app_bundle_id = other.app_bundle_id.or_else(|| self.app_bundle_id.take());
|
|
1150
|
+
self.app_category_type = other
|
|
1151
|
+
.app_category_type
|
|
1152
|
+
.or_else(|| self.app_category_type.take());
|
|
1153
|
+
self.app_version = other.app_version.or_else(|| self.app_version.take());
|
|
1154
|
+
self.build_version = other.build_version.or_else(|| self.build_version.take());
|
|
1155
|
+
self.app_copyright = other.app_copyright.or_else(|| self.app_copyright.take());
|
|
1156
|
+
if !other.icon.is_empty() {
|
|
1157
|
+
self.icon = other.icon;
|
|
1158
|
+
}
|
|
1159
|
+
if !other.extra_resource.is_empty() {
|
|
1160
|
+
self.extra_resource = other.extra_resource;
|
|
1161
|
+
}
|
|
1162
|
+
self.darwin_dark_mode_support =
|
|
1163
|
+
other.darwin_dark_mode_support || self.darwin_dark_mode_support;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
impl PackageReport {
|
|
1168
|
+
pub(crate) fn project(&self) -> &ProjectSnapshot {
|
|
1169
|
+
&self.project
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
pub(crate) fn mark_packaged(&mut self) {
|
|
1173
|
+
self.status = PackageStatus::Packaged;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
pub(crate) fn app_name(&self) -> &str {
|
|
1177
|
+
&self.app_name
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
pub(crate) fn executable_name(&self) -> &str {
|
|
1181
|
+
&self.executable_name
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
pub(crate) fn artifact_stem(&self) -> String {
|
|
1185
|
+
sanitize_artifact_name(&self.app_name)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
pub(crate) fn platform(&self) -> &str {
|
|
1189
|
+
&self.platform
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
pub(crate) fn arch(&self) -> &str {
|
|
1193
|
+
&self.arch
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
pub(crate) fn output_dir(&self) -> &Utf8PathBuf {
|
|
1197
|
+
&self.output_dir
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
pub(crate) fn bundle_dir(&self) -> &Utf8PathBuf {
|
|
1201
|
+
&self.bundle_dir
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
pub(crate) fn warnings(&self) -> &[String] {
|
|
1205
|
+
&self.warnings
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
#[cfg(test)]
|
|
1210
|
+
mod tests {
|
|
1211
|
+
use super::*;
|
|
1212
|
+
|
|
1213
|
+
#[test]
|
|
1214
|
+
fn plans_package_output_for_current_platform() {
|
|
1215
|
+
let root = unique_temp_dir("plan");
|
|
1216
|
+
write_package_json(&root);
|
|
1217
|
+
write_fake_electron_dist(&root);
|
|
1218
|
+
|
|
1219
|
+
let args = PackageArgs {
|
|
1220
|
+
cwd: root.clone(),
|
|
1221
|
+
out_dir: PathBuf::from("out"),
|
|
1222
|
+
name: None,
|
|
1223
|
+
platform: None,
|
|
1224
|
+
arch: None,
|
|
1225
|
+
force: false,
|
|
1226
|
+
dry_run: true,
|
|
1227
|
+
json: true,
|
|
1228
|
+
};
|
|
1229
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1230
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1231
|
+
|
|
1232
|
+
assert_eq!(report.app_name, "starter-app");
|
|
1233
|
+
assert_eq!(report.platform, current_platform());
|
|
1234
|
+
assert_eq!(report.arch, current_arch());
|
|
1235
|
+
assert!(report.warnings.is_empty());
|
|
1236
|
+
|
|
1237
|
+
let _ = fs::remove_dir_all(root);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#[test]
|
|
1241
|
+
fn packages_fake_electron_runtime_and_app_files() {
|
|
1242
|
+
let root = unique_temp_dir("execute");
|
|
1243
|
+
write_package_json(&root);
|
|
1244
|
+
write_app_file(&root);
|
|
1245
|
+
write_fake_electron_dist(&root);
|
|
1246
|
+
fs::create_dir_all(root.join("node_modules/ignored"))
|
|
1247
|
+
.expect("node_modules should be created");
|
|
1248
|
+
fs::write(root.join("node_modules/ignored/file.js"), "")
|
|
1249
|
+
.expect("ignored node module should be written");
|
|
1250
|
+
|
|
1251
|
+
let args = PackageArgs {
|
|
1252
|
+
cwd: root.clone(),
|
|
1253
|
+
out_dir: PathBuf::from("out"),
|
|
1254
|
+
name: Some("Starter App".to_string()),
|
|
1255
|
+
platform: None,
|
|
1256
|
+
arch: None,
|
|
1257
|
+
force: false,
|
|
1258
|
+
dry_run: false,
|
|
1259
|
+
json: false,
|
|
1260
|
+
};
|
|
1261
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1262
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1263
|
+
|
|
1264
|
+
execute_package(&report, false).expect("package should succeed");
|
|
1265
|
+
|
|
1266
|
+
let app_dir = Path::new(report.app_resources_dir.as_str()).join("app");
|
|
1267
|
+
assert!(app_dir.join("package.json").exists());
|
|
1268
|
+
assert!(app_dir.join("src/main.js").exists());
|
|
1269
|
+
assert!(!app_dir.join("node_modules").exists());
|
|
1270
|
+
|
|
1271
|
+
if current_platform() == "darwin" {
|
|
1272
|
+
assert!(Path::new(report.bundle_dir.as_str())
|
|
1273
|
+
.join("Contents")
|
|
1274
|
+
.exists());
|
|
1275
|
+
} else {
|
|
1276
|
+
assert!(Path::new(report.bundle_dir.as_str())
|
|
1277
|
+
.join(report.executable_name)
|
|
1278
|
+
.exists());
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
let _ = fs::remove_dir_all(root);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
#[test]
|
|
1285
|
+
fn packages_runtime_dependency_closure_from_node_modules() {
|
|
1286
|
+
let root = unique_temp_dir("runtime-deps");
|
|
1287
|
+
fs::write(
|
|
1288
|
+
root.join("package.json"),
|
|
1289
|
+
r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"dep-a":"1.0.0"},"devDependencies":{"electron":"30.0.0","dev-only":"1.0.0"}}"#,
|
|
1290
|
+
)
|
|
1291
|
+
.expect("package.json should be written");
|
|
1292
|
+
write_app_file(&root);
|
|
1293
|
+
write_fake_electron_dist(&root);
|
|
1294
|
+
write_dependency_package(
|
|
1295
|
+
&root,
|
|
1296
|
+
"dep-a",
|
|
1297
|
+
r#"{"name":"dep-a","version":"1.0.0","dependencies":{"dep-b":"1.0.0"}}"#,
|
|
1298
|
+
);
|
|
1299
|
+
write_dependency_package(&root, "dep-b", r#"{"name":"dep-b","version":"1.0.0"}"#);
|
|
1300
|
+
write_dependency_package(
|
|
1301
|
+
&root,
|
|
1302
|
+
"dev-only",
|
|
1303
|
+
r#"{"name":"dev-only","version":"1.0.0"}"#,
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
let args = PackageArgs {
|
|
1307
|
+
cwd: root.clone(),
|
|
1308
|
+
out_dir: PathBuf::from("out"),
|
|
1309
|
+
name: None,
|
|
1310
|
+
platform: None,
|
|
1311
|
+
arch: None,
|
|
1312
|
+
force: false,
|
|
1313
|
+
dry_run: false,
|
|
1314
|
+
json: false,
|
|
1315
|
+
};
|
|
1316
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1317
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1318
|
+
|
|
1319
|
+
assert!(report.warnings.is_empty());
|
|
1320
|
+
execute_package(&report, false).expect("package should succeed");
|
|
1321
|
+
|
|
1322
|
+
let app_node_modules = Path::new(report.app_resources_dir.as_str())
|
|
1323
|
+
.join("app")
|
|
1324
|
+
.join("node_modules");
|
|
1325
|
+
assert!(app_node_modules.join("dep-a/package.json").exists());
|
|
1326
|
+
assert!(app_node_modules.join("dep-b/package.json").exists());
|
|
1327
|
+
assert!(!app_node_modules.join("dev-only").exists());
|
|
1328
|
+
|
|
1329
|
+
let _ = fs::remove_dir_all(root);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
#[test]
|
|
1333
|
+
fn plans_packager_metadata_from_package_json() {
|
|
1334
|
+
let root = unique_temp_dir("metadata-plan");
|
|
1335
|
+
write_metadata_package_json(&root);
|
|
1336
|
+
write_app_file(&root);
|
|
1337
|
+
write_fake_electron_dist(&root);
|
|
1338
|
+
write_icon_and_resource_files(&root);
|
|
1339
|
+
|
|
1340
|
+
let args = PackageArgs {
|
|
1341
|
+
cwd: root.clone(),
|
|
1342
|
+
out_dir: PathBuf::from("out"),
|
|
1343
|
+
name: None,
|
|
1344
|
+
platform: None,
|
|
1345
|
+
arch: None,
|
|
1346
|
+
force: false,
|
|
1347
|
+
dry_run: true,
|
|
1348
|
+
json: true,
|
|
1349
|
+
};
|
|
1350
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1351
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1352
|
+
|
|
1353
|
+
assert_eq!(report.app_name, "Starter Pro");
|
|
1354
|
+
assert_eq!(
|
|
1355
|
+
report.executable_name,
|
|
1356
|
+
executable_name("StarterExec", &report.platform)
|
|
1357
|
+
);
|
|
1358
|
+
assert_eq!(report.metadata.bundle_identifier, "com.example.starter");
|
|
1359
|
+
assert_eq!(report.metadata.app_version.as_deref(), Some("2.3.4"));
|
|
1360
|
+
assert_eq!(report.metadata.build_version.as_deref(), Some("234"));
|
|
1361
|
+
assert_eq!(
|
|
1362
|
+
report.metadata.app_category_type.as_deref(),
|
|
1363
|
+
Some("public.app-category.developer-tools")
|
|
1364
|
+
);
|
|
1365
|
+
assert_eq!(
|
|
1366
|
+
report.metadata.app_copyright.as_deref(),
|
|
1367
|
+
Some("Copyright 2026 Example")
|
|
1368
|
+
);
|
|
1369
|
+
assert_eq!(report.metadata.extra_resources.len(), 1);
|
|
1370
|
+
assert!(report
|
|
1371
|
+
.copy_steps
|
|
1372
|
+
.iter()
|
|
1373
|
+
.any(|step| step.to.as_str().ends_with("config.json")));
|
|
1374
|
+
|
|
1375
|
+
if current_platform() == "darwin" {
|
|
1376
|
+
assert!(report.metadata.icon.is_some());
|
|
1377
|
+
assert!(report.warnings.is_empty());
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
let _ = fs::remove_dir_all(root);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
#[test]
|
|
1384
|
+
fn packages_macos_info_plist_metadata() {
|
|
1385
|
+
if current_platform() != "darwin" {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
let root = unique_temp_dir("metadata-execute");
|
|
1390
|
+
write_metadata_package_json(&root);
|
|
1391
|
+
write_app_file(&root);
|
|
1392
|
+
write_fake_electron_dist(&root);
|
|
1393
|
+
write_icon_and_resource_files(&root);
|
|
1394
|
+
|
|
1395
|
+
let args = PackageArgs {
|
|
1396
|
+
cwd: root.clone(),
|
|
1397
|
+
out_dir: PathBuf::from("out"),
|
|
1398
|
+
name: None,
|
|
1399
|
+
platform: None,
|
|
1400
|
+
arch: None,
|
|
1401
|
+
force: false,
|
|
1402
|
+
dry_run: false,
|
|
1403
|
+
json: false,
|
|
1404
|
+
};
|
|
1405
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1406
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1407
|
+
|
|
1408
|
+
execute_package(&report, false).expect("package should succeed");
|
|
1409
|
+
|
|
1410
|
+
let bundle_dir = Path::new(report.bundle_dir.as_str());
|
|
1411
|
+
assert!(bundle_dir
|
|
1412
|
+
.join("Contents/MacOS")
|
|
1413
|
+
.join(&report.executable_name)
|
|
1414
|
+
.exists());
|
|
1415
|
+
assert!(bundle_dir
|
|
1416
|
+
.join("Contents/Resources/starter-pro.icns")
|
|
1417
|
+
.exists());
|
|
1418
|
+
assert!(bundle_dir.join("Contents/Resources/config.json").exists());
|
|
1419
|
+
|
|
1420
|
+
let plist = PlistValue::from_file(bundle_dir.join("Contents/Info.plist"))
|
|
1421
|
+
.expect("Info.plist should parse");
|
|
1422
|
+
let dictionary = plist
|
|
1423
|
+
.as_dictionary()
|
|
1424
|
+
.expect("Info.plist should be a dictionary");
|
|
1425
|
+
|
|
1426
|
+
assert_eq!(
|
|
1427
|
+
plist_string(dictionary, "CFBundleDisplayName"),
|
|
1428
|
+
Some("Starter Pro")
|
|
1429
|
+
);
|
|
1430
|
+
assert_eq!(
|
|
1431
|
+
plist_string(dictionary, "CFBundleExecutable"),
|
|
1432
|
+
Some(report.executable_name.as_str())
|
|
1433
|
+
);
|
|
1434
|
+
assert_eq!(
|
|
1435
|
+
plist_string(dictionary, "CFBundleIdentifier"),
|
|
1436
|
+
Some("com.example.starter")
|
|
1437
|
+
);
|
|
1438
|
+
assert_eq!(
|
|
1439
|
+
plist_string(dictionary, "CFBundleShortVersionString"),
|
|
1440
|
+
Some("2.3.4")
|
|
1441
|
+
);
|
|
1442
|
+
assert_eq!(plist_string(dictionary, "CFBundleVersion"), Some("234"));
|
|
1443
|
+
assert_eq!(
|
|
1444
|
+
plist_string(dictionary, "LSApplicationCategoryType"),
|
|
1445
|
+
Some("public.app-category.developer-tools")
|
|
1446
|
+
);
|
|
1447
|
+
assert_eq!(
|
|
1448
|
+
plist_string(dictionary, "CFBundleIconFile"),
|
|
1449
|
+
Some("starter-pro.icns")
|
|
1450
|
+
);
|
|
1451
|
+
assert_eq!(
|
|
1452
|
+
dictionary
|
|
1453
|
+
.get("NSRequiresAquaSystemAppearance")
|
|
1454
|
+
.and_then(PlistValue::as_boolean),
|
|
1455
|
+
Some(false)
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
let _ = fs::remove_dir_all(root);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
#[test]
|
|
1462
|
+
fn missing_required_runtime_dependency_fails() {
|
|
1463
|
+
let root = unique_temp_dir("runtime-deps");
|
|
1464
|
+
fs::write(
|
|
1465
|
+
root.join("package.json"),
|
|
1466
|
+
r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"left-pad":"1.3.0"},"devDependencies":{"electron":"30.0.0"}}"#,
|
|
1467
|
+
)
|
|
1468
|
+
.expect("package.json should be written");
|
|
1469
|
+
write_app_file(&root);
|
|
1470
|
+
write_fake_electron_dist(&root);
|
|
1471
|
+
|
|
1472
|
+
let args = PackageArgs {
|
|
1473
|
+
cwd: root.clone(),
|
|
1474
|
+
out_dir: PathBuf::from("out"),
|
|
1475
|
+
name: None,
|
|
1476
|
+
platform: None,
|
|
1477
|
+
arch: None,
|
|
1478
|
+
force: false,
|
|
1479
|
+
dry_run: false,
|
|
1480
|
+
json: false,
|
|
1481
|
+
};
|
|
1482
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1483
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1484
|
+
|
|
1485
|
+
assert!(report.warnings.contains(
|
|
1486
|
+
&"Runtime dependency is not installed and packaging will fail: left-pad.".to_string()
|
|
1487
|
+
));
|
|
1488
|
+
assert!(execute_package(&report, false).is_err());
|
|
1489
|
+
|
|
1490
|
+
let _ = fs::remove_dir_all(root);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
#[test]
|
|
1494
|
+
fn missing_optional_runtime_dependency_is_skipped() {
|
|
1495
|
+
let root = unique_temp_dir("optional-runtime-deps");
|
|
1496
|
+
fs::write(
|
|
1497
|
+
root.join("package.json"),
|
|
1498
|
+
r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","optionalDependencies":{"optional-native":"1.0.0"},"devDependencies":{"electron":"30.0.0"}}"#,
|
|
1499
|
+
)
|
|
1500
|
+
.expect("package.json should be written");
|
|
1501
|
+
write_app_file(&root);
|
|
1502
|
+
write_fake_electron_dist(&root);
|
|
1503
|
+
|
|
1504
|
+
let args = PackageArgs {
|
|
1505
|
+
cwd: root.clone(),
|
|
1506
|
+
out_dir: PathBuf::from("out"),
|
|
1507
|
+
name: None,
|
|
1508
|
+
platform: None,
|
|
1509
|
+
arch: None,
|
|
1510
|
+
force: false,
|
|
1511
|
+
dry_run: false,
|
|
1512
|
+
json: false,
|
|
1513
|
+
};
|
|
1514
|
+
let snapshot = crate::project::inspect(&root).expect("project should inspect");
|
|
1515
|
+
let report = build_report(snapshot, &args).expect("report should build");
|
|
1516
|
+
|
|
1517
|
+
assert!(report.warnings.contains(
|
|
1518
|
+
&"Optional runtime dependency is not installed and will be skipped: optional-native."
|
|
1519
|
+
.to_string()
|
|
1520
|
+
));
|
|
1521
|
+
execute_package(&report, false).expect("optional dependency should be skipped");
|
|
1522
|
+
|
|
1523
|
+
let _ = fs::remove_dir_all(root);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
#[test]
|
|
1527
|
+
fn cleans_scoped_package_names_for_bundle_paths() {
|
|
1528
|
+
assert_eq!(clean_app_name("@scope/app"), "scope-app");
|
|
1529
|
+
assert_eq!(sanitize_artifact_name("Starter App"), "starter-app");
|
|
1530
|
+
assert_eq!(
|
|
1531
|
+
dependency_relative_path("@scope/app"),
|
|
1532
|
+
PathBuf::from("@scope/app")
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
fn write_package_json(root: &Path) {
|
|
1537
|
+
fs::write(
|
|
1538
|
+
root.join("package.json"),
|
|
1539
|
+
r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
|
|
1540
|
+
)
|
|
1541
|
+
.expect("package.json should be written");
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
fn write_metadata_package_json(root: &Path) {
|
|
1545
|
+
fs::write(
|
|
1546
|
+
root.join("package.json"),
|
|
1547
|
+
r#"{
|
|
1548
|
+
"name": "starter-app",
|
|
1549
|
+
"productName": "Starter Pro",
|
|
1550
|
+
"version": "2.3.4",
|
|
1551
|
+
"main": "src/main.js",
|
|
1552
|
+
"devDependencies": {
|
|
1553
|
+
"electron": "30.0.0"
|
|
1554
|
+
},
|
|
1555
|
+
"electronCli": {
|
|
1556
|
+
"packagerConfig": {
|
|
1557
|
+
"executableName": "StarterExec",
|
|
1558
|
+
"appBundleId": "com.example.starter",
|
|
1559
|
+
"appCategoryType": "public.app-category.developer-tools",
|
|
1560
|
+
"buildVersion": "234",
|
|
1561
|
+
"appCopyright": "Copyright 2026 Example",
|
|
1562
|
+
"icon": "assets/starter",
|
|
1563
|
+
"extraResource": "assets/config.json",
|
|
1564
|
+
"darwinDarkModeSupport": true
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}"#,
|
|
1568
|
+
)
|
|
1569
|
+
.expect("package.json should be written");
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
fn write_app_file(root: &Path) {
|
|
1573
|
+
fs::create_dir_all(root.join("src")).expect("src should be created");
|
|
1574
|
+
fs::write(root.join("src/main.js"), "console.log('hello');")
|
|
1575
|
+
.expect("main file should be written");
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
fn write_dependency_package(root: &Path, name: &str, package_json: &str) {
|
|
1579
|
+
let package_dir = root
|
|
1580
|
+
.join("node_modules")
|
|
1581
|
+
.join(dependency_relative_path(name));
|
|
1582
|
+
fs::create_dir_all(&package_dir).expect("dependency package dir should be created");
|
|
1583
|
+
fs::write(package_dir.join("package.json"), package_json)
|
|
1584
|
+
.expect("dependency package.json should be written");
|
|
1585
|
+
fs::write(package_dir.join("index.js"), "module.exports = true;")
|
|
1586
|
+
.expect("dependency index should be written");
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
fn write_icon_and_resource_files(root: &Path) {
|
|
1590
|
+
fs::create_dir_all(root.join("assets")).expect("assets should be created");
|
|
1591
|
+
fs::write(root.join("assets/starter.icns"), b"icns").expect("icon should be written");
|
|
1592
|
+
fs::write(root.join("assets/config.json"), "{}").expect("resource should be written");
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
fn plist_string<'a>(dictionary: &'a PlistDictionary, key: &str) -> Option<&'a str> {
|
|
1596
|
+
dictionary.get(key).and_then(PlistValue::as_string)
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
fn write_fake_electron_dist(root: &Path) {
|
|
1600
|
+
let dist = root.join("node_modules/electron/dist");
|
|
1601
|
+
if current_platform() == "darwin" {
|
|
1602
|
+
let app = dist.join("Electron.app/Contents/MacOS");
|
|
1603
|
+
fs::create_dir_all(&app).expect("fake macOS electron app should be created");
|
|
1604
|
+
fs::write(app.join("Electron"), "").expect("fake macOS binary should be written");
|
|
1605
|
+
} else if current_platform() == "win32" {
|
|
1606
|
+
fs::create_dir_all(&dist).expect("fake electron dist should be created");
|
|
1607
|
+
fs::write(dist.join("electron.exe"), "").expect("fake exe should be written");
|
|
1608
|
+
} else {
|
|
1609
|
+
fs::create_dir_all(&dist).expect("fake electron dist should be created");
|
|
1610
|
+
fs::write(dist.join("electron"), "").expect("fake binary should be written");
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
fn unique_temp_dir(label: &str) -> PathBuf {
|
|
1615
|
+
let nanos = std::time::SystemTime::now()
|
|
1616
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1617
|
+
.expect("clock should be after epoch")
|
|
1618
|
+
.as_nanos();
|
|
1619
|
+
let path = std::env::temp_dir().join(format!(
|
|
1620
|
+
"electron-cli-package-{label}-{}-{nanos}",
|
|
1621
|
+
std::process::id()
|
|
1622
|
+
));
|
|
1623
|
+
fs::create_dir_all(&path).expect("temp dir should be created");
|
|
1624
|
+
path
|
|
1625
|
+
}
|
|
1626
|
+
}
|