1use crate::migrations::CURRENT;
9use std::path::{Path, PathBuf};
10
11const BINARY_EXTENSIONS: &[&str] = &["png", "ico", "icns", "jpg", "jpeg", "webp"];
13
14#[derive(Debug)]
15pub struct Options {
16 pub slug: String,
18 pub name: String,
20 pub id: String,
22 pub into: PathBuf,
24 pub local: Option<PathBuf>,
29}
30
31pub fn run(root: &Path, options: &Options) -> Result<(), String> {
32 validate_options(options)?;
33
34 let template = root.join("templates").join("app");
35 if !template.is_dir() {
36 return Err(format!(
37 "no template at {} — scaffolding currently needs the Origin repository",
38 template.display()
39 ));
40 }
41
42 let target = options.into.join(&options.slug);
43 if target.exists() {
44 return Err(format!("{} already exists", target.display()));
45 }
46
47 let substitutions = substitutions(options);
48 copy_template(&template, &target, &substitutions)?;
49
50 if let Some(origin) = &options.local {
51 patch_to_local(&target, origin)?;
52 }
53
54 crate::generate(&target)?;
57
58 println!("created {}", target.display());
59 println!("\nnext:");
60 println!(" cd {}", target.display());
61 println!(" pnpm install");
62 println!(" cargo tauri dev");
63 println!("\nReplace the placeholder icons in src-tauri/icons before shipping.");
64 Ok(())
65}
66
67fn validate_options(options: &Options) -> Result<(), String> {
68 let valid_slug = !options.slug.is_empty()
69 && !options.slug.starts_with('-')
70 && !options.slug.ends_with('-')
71 && !options.slug.contains("--")
72 && options
73 .slug
74 .bytes()
75 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
76 if !valid_slug {
77 return Err(format!(
78 "invalid slug `{}` — use lowercase ASCII letters, digits and single path-safe hyphens",
79 options.slug
80 ));
81 }
82
83 if options.name.trim().is_empty()
84 || options
85 .name
86 .chars()
87 .any(|character| character.is_control() || matches!(character, '"' | '\\'))
88 {
89 return Err(
90 "product name must be non-empty and contain no control characters, quotes or backslashes"
91 .to_owned(),
92 );
93 }
94
95 let labels: Vec<&str> = options.id.split('.').collect();
96 let valid_id = labels.len() >= 2
97 && labels.iter().all(|label| {
98 !label.is_empty()
99 && label
100 .bytes()
101 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
102 && label
103 .bytes()
104 .next()
105 .is_some_and(|byte| byte.is_ascii_alphanumeric())
106 && label
107 .bytes()
108 .next_back()
109 .is_some_and(|byte| byte.is_ascii_alphanumeric())
110 });
111 if !valid_id {
112 return Err(format!(
113 "invalid product id `{}` — expected a reverse-DNS identifier",
114 options.id
115 ));
116 }
117
118 Ok(())
119}
120
121fn substitutions(options: &Options) -> Vec<(&'static str, String)> {
122 vec![
123 ("__PRODUCT_ID__", options.id.clone()),
124 ("__PRODUCT_NAME__", options.name.clone()),
125 ("__CRATE_NAME_SNAKE__", options.slug.replace('-', "_")),
126 ("__CRATE_NAME__", options.slug.clone()),
127 ("__PACKAGE_NAME__", options.slug.clone()),
128 ("__ORIGIN_VERSION__", CURRENT.to_string()),
129 (
130 "__ORIGIN_SEMVER__",
131 format!("{}.{}", CURRENT.major, CURRENT.minor),
132 ),
133 ("__ORIGIN_NPM__", format!("^{CURRENT}")),
134 ]
135}
136
137fn copy_template(
138 template: &Path,
139 target: &Path,
140 substitutions: &[(&str, String)],
141) -> Result<(), String> {
142 std::fs::create_dir_all(target)
143 .map_err(|error| format!("cannot create {}: {error}", target.display()))?;
144
145 let entries = std::fs::read_dir(template)
146 .map_err(|error| format!("cannot read {}: {error}", template.display()))?;
147
148 for entry in entries.filter_map(Result::ok) {
149 let source = entry.path();
150 let destination = target.join(entry.file_name());
151
152 if source.is_dir() {
153 copy_template(&source, &destination, substitutions)?;
154 continue;
155 }
156
157 let is_binary = source
158 .extension()
159 .and_then(|extension| extension.to_str())
160 .is_some_and(|extension| BINARY_EXTENSIONS.contains(&extension));
161
162 if is_binary {
163 std::fs::copy(&source, &destination)
164 .map_err(|error| format!("cannot copy {}: {error}", source.display()))?;
165 continue;
166 }
167
168 let contents = std::fs::read_to_string(&source)
169 .map_err(|error| format!("cannot read {}: {error}", source.display()))?;
170
171 let mut rendered = contents;
174 for (placeholder, value) in substitutions {
175 rendered = rendered.replace(placeholder, value);
176 }
177
178 std::fs::write(&destination, rendered)
179 .map_err(|error| format!("cannot write {}: {error}", destination.display()))?;
180 }
181
182 Ok(())
183}
184
185fn patch_to_local(target: &Path, origin: &Path) -> Result<(), String> {
191 let origin = origin
192 .canonicalize()
193 .map_err(|error| format!("cannot resolve {}: {error}", origin.display()))?;
194
195 rewrite_cargo_dependencies(target, &origin)?;
196 rewrite_npm_dependencies(target, &origin)
197}
198
199fn crate_location(name: &str) -> &'static str {
201 match name {
202 "origin-tauri" => "host",
203 "origin-http-reqwest"
204 | "origin-notifications-tauri"
205 | "origin-secrets-system"
206 | "origin-storage-sqlite"
207 | "origin-auth-loopback" => "adapters",
208 _ => "crates",
209 }
210}
211
212fn rewrite_cargo_dependencies(target: &Path, origin: &Path) -> Result<(), String> {
213 let manifest = target.join("Cargo.toml");
214 let contents = std::fs::read_to_string(&manifest)
215 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
216
217 let mut document: toml_edit::DocumentMut = contents
218 .parse()
219 .map_err(|error| format!("{} is not valid TOML: {error}", manifest.display()))?;
220
221 let Some(dependencies) = document
222 .get_mut("workspace")
223 .and_then(|workspace| workspace.get_mut("dependencies"))
224 .and_then(toml_edit::Item::as_table_mut)
225 else {
226 return Err("template Cargo.toml has no [workspace.dependencies]".to_owned());
227 };
228
229 let names: Vec<String> = dependencies
230 .iter()
231 .map(|(name, _)| name.to_owned())
232 .filter(|name| name.starts_with("origin-"))
233 .collect();
234
235 for name in names {
236 let path = origin.join(crate_location(&name)).join(&name);
237 let mut value = toml_edit::InlineTable::new();
238 value.insert("path", path.display().to_string().into());
239 dependencies[&name] = toml_edit::value(value);
240 }
241
242 std::fs::write(&manifest, document.to_string())
243 .map_err(|error| format!("cannot write {}: {error}", manifest.display()))
244}
245
246fn rewrite_npm_dependencies(target: &Path, origin: &Path) -> Result<(), String> {
247 let manifest = target.join("ui").join("package.json");
248 let contents = std::fs::read_to_string(&manifest)
249 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
250
251 let client_release = format!("\"@origin/client\": \"^{CURRENT}\"");
252 let ui_release = format!("\"@origin/ui\": \"^{CURRENT}\"");
253 let rewritten = contents
254 .replace(
255 &client_release,
256 &format!(
257 "\"@origin/client\": \"link:{}\"",
258 origin.join("frontend").join("client").display()
259 ),
260 )
261 .replace(
262 &ui_release,
263 &format!(
264 "\"@origin/ui\": \"link:{}\"",
265 origin.join("frontend").join("ui").display()
266 ),
267 );
268
269 std::fs::write(&manifest, rewritten)
270 .map_err(|error| format!("cannot write {}: {error}", manifest.display()))
271}