1use crate::Result;
2use crate::archive::{self, ArchiveEntry};
3use crate::check::{Diagnostic, DiagnosticReport, Severity};
4use crate::config::{Config, DesktopTargetConfig};
5use crate::fsutil;
6use crate::lockfile::LockFile;
7use crate::runtime::{DEFAULT_CHANNEL, RuntimeKind, RuntimeRegistry};
8use crate::targets::{BuildOutput, TargetAdapter};
9use std::path::Path;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum DesktopPlatform {
13 Windows,
14 Macos,
15 Linux,
16}
17
18pub struct DesktopAdapter {
19 platform: DesktopPlatform,
20}
21
22impl DesktopAdapter {
23 pub fn new(platform: DesktopPlatform) -> Self {
24 Self { platform }
25 }
26
27 fn config<'a>(&self, config: &'a Config) -> &'a DesktopTargetConfig {
28 match self.platform {
29 DesktopPlatform::Windows => &config.targets.windows,
30 DesktopPlatform::Macos => &config.targets.macos,
31 DesktopPlatform::Linux => &config.targets.linux,
32 }
33 }
34
35 fn slug(&self) -> &'static str {
36 match self.platform {
37 DesktopPlatform::Windows => "windows",
38 DesktopPlatform::Macos => "macos",
39 DesktopPlatform::Linux => "linux",
40 }
41 }
42
43 fn artifact_name(&self, game_id: &str) -> String {
44 match self.platform {
45 DesktopPlatform::Windows => format!("{game_id}-windows-x64.zip"),
46 DesktopPlatform::Macos => format!("{game_id}-macos-universal.app.zip"),
47 DesktopPlatform::Linux => format!("{game_id}-linux-x86_64.tar"),
48 }
49 }
50}
51
52impl TargetAdapter for DesktopAdapter {
53 fn name(&self) -> &'static str {
54 self.slug()
55 }
56
57 fn doctor(&self, _root: &Path, config: &Config, lock: &LockFile) -> Result<DiagnosticReport> {
58 let mut report = DiagnosticReport::default();
59 let target_config = self.config(config);
60 if !target_config.enabled {
61 report.push(Diagnostic {
62 id: "desktop.disabled",
63 severity: Severity::Warning,
64 message: format!("{} target is disabled in lovely.toml", self.slug()),
65 path: None,
66 });
67 }
68 if target_config.runtime_archive.is_none()
69 && RuntimeRegistry::new()
70 .find(self.slug(), &lock.runtime_channel)?
71 .is_none()
72 {
73 report.push(Diagnostic {
74 id: "runtime.missing",
75 severity: Severity::Warning,
76 message: format!(
77 "{} has no pinned runtime configured or cached; build will emit a depot-ready skeleton around game.love. Run lovely runtime fetch {} <path> to install one.",
78 self.slug(),
79 self.slug()
80 ),
81 path: None,
82 });
83 }
84 if lock.runtime_channel != DEFAULT_CHANNEL {
85 report.push(Diagnostic {
86 id: "runtime.channel",
87 severity: Severity::Warning,
88 message: format!(
89 "expected runtime channel {}, found {}",
90 DEFAULT_CHANNEL, lock.runtime_channel
91 ),
92 path: None,
93 });
94 }
95 Ok(report)
96 }
97
98 fn build(&self, root: &Path, config: &Config, lock: &LockFile) -> Result<BuildOutput> {
99 let source = root.join(&config.paths.source);
100 let base = root.join(&config.paths.output);
101 let work = base.join(self.slug());
102 fsutil::ensure_dir(&work)?;
103
104 let love_path = work.join(format!("{}.love", config.game.id));
105 archive::create_love_archive(
106 &source,
107 &love_path,
108 &config.paths.includes,
109 &config.paths.excludes,
110 )?;
111
112 let cached_runtime = RuntimeRegistry::new().find(self.slug(), &lock.runtime_channel)?;
113 let runtime_available =
114 self.config(config).runtime_archive.is_some() || cached_runtime.is_some();
115 let readme = desktop_readme(self.slug(), config, lock, runtime_available);
116 let mut entries = vec![
117 ArchiveEntry::file(
118 format!("{}/{}.love", config.game.id, config.game.id),
119 std::fs::read(&love_path).map_err(|err| crate::LovelyError::io(&love_path, err))?,
120 )?,
121 ArchiveEntry::file(
122 format!("{}/README-Lovely.txt", config.game.id),
123 readme.into_bytes(),
124 )?,
125 ];
126
127 if let Some(runtime) = &self.config(config).runtime_archive {
128 let runtime_path = root.join(runtime);
129 append_runtime_entries(&runtime_path, &config.game.id, &mut entries)?;
130 } else if let Some(runtime) = &cached_runtime {
131 append_cached_runtime_entries(runtime, &config.game.id, &mut entries)?;
132 }
133
134 entries.sort_by(|a, b| a.name.cmp(&b.name));
135 let artifact = base.join(self.artifact_name(&config.game.id));
136 match self.platform {
137 DesktopPlatform::Linux => archive::write_tar(&artifact, &entries)?,
138 DesktopPlatform::Windows | DesktopPlatform::Macos => {
139 archive::write_zip(&artifact, &entries)?
140 }
141 }
142
143 let steam_dir = base.join("steam").join(self.slug());
144 fsutil::ensure_dir(&steam_dir)?;
145 fsutil::copy_file(
146 &love_path,
147 &steam_dir.join(format!("{}.love", config.game.id)),
148 )?;
149 if let Some(runtime) = &self.config(config).runtime_archive {
150 copy_runtime_to_depot(&root.join(runtime), &steam_dir)?;
151 } else if let Some(runtime) = &cached_runtime {
152 copy_cached_runtime_to_depot(runtime, &steam_dir)?;
153 }
154 fsutil::write_string(
155 &steam_dir.join("depot_build.vdf"),
156 &depot_vdf(self.slug(), config, &steam_dir),
157 )?;
158 fsutil::write_string(&base.join("steam").join("app_build.vdf"), &app_vdf(config))?;
159
160 Ok(BuildOutput {
161 target: self.slug().to_string(),
162 artifacts: vec![love_path, artifact, steam_dir.join("depot_build.vdf")],
163 })
164 }
165}
166
167fn append_runtime_entries(
168 path: &Path,
169 game_id: &str,
170 entries: &mut Vec<ArchiveEntry>,
171) -> Result<()> {
172 if path.is_dir() {
173 for file in fsutil::collect_files(path)? {
174 let rel = fsutil::relative_path(path, &file)?;
175 entries.push(ArchiveEntry::file(
176 format!("{game_id}/{}", fsutil::normalize_slashes(&rel)),
177 std::fs::read(&file).map_err(|err| crate::LovelyError::io(&file, err))?,
178 )?);
179 }
180 } else if path.is_file() {
181 let name = path
182 .file_name()
183 .ok_or_else(|| crate::LovelyError::Command("runtime file has no name".to_string()))?
184 .to_string_lossy();
185 entries.push(ArchiveEntry::file(
186 format!("{game_id}/runtime/{name}"),
187 std::fs::read(path).map_err(|err| crate::LovelyError::io(path, err))?,
188 )?);
189 }
190 Ok(())
191}
192
193fn append_cached_runtime_entries(
194 runtime: &crate::runtime::CachedRuntime,
195 game_id: &str,
196 entries: &mut Vec<ArchiveEntry>,
197) -> Result<()> {
198 match runtime.manifest.kind {
199 RuntimeKind::Directory => append_runtime_entries(&runtime.path, game_id, entries),
200 RuntimeKind::File => append_runtime_entries(&runtime.path, game_id, entries),
201 }
202}
203
204fn copy_runtime_to_depot(runtime: &Path, depot: &Path) -> Result<()> {
205 if runtime.is_dir() {
206 fsutil::copy_dir_contents(runtime, depot)?;
207 } else if runtime.is_file() {
208 let name = runtime
209 .file_name()
210 .ok_or_else(|| crate::LovelyError::Command("runtime file has no name".to_string()))?;
211 fsutil::copy_file(runtime, &depot.join("runtime").join(name))?;
212 }
213 Ok(())
214}
215
216fn copy_cached_runtime_to_depot(
217 runtime: &crate::runtime::CachedRuntime,
218 depot: &Path,
219) -> Result<()> {
220 copy_runtime_to_depot(&runtime.path, depot)
221}
222
223fn desktop_readme(
224 target: &str,
225 config: &Config,
226 lock: &LockFile,
227 runtime_available: bool,
228) -> String {
229 let runtime_note = if runtime_available {
230 "This artifact includes configured or cached LÖVE runtime content. Platform-specific final fusion/signing is still target-runtime dependent."
231 } else {
232 "This skeleton artifact contains the normalized .love archive. Install a pinned LÖVE runtime with lovely runtime fetch before release builds."
233 };
234 format!(
235 "{name} {version}\nTarget: {target}\nRuntime channel: {channel}\n\n{runtime_note}\n",
236 name = config.game.name,
237 version = config.game.version,
238 target = target,
239 channel = lock.runtime_channel,
240 runtime_note = runtime_note
241 )
242}
243
244fn depot_vdf(target: &str, config: &Config, steam_dir: &Path) -> String {
245 let depot_id = match target {
246 "windows" => config.steam.windows_depot_id.as_deref(),
247 "macos" => config.steam.macos_depot_id.as_deref(),
248 "linux" => config.steam.linux_depot_id.as_deref(),
249 _ => None,
250 }
251 .unwrap_or("TODO_DEPOT_ID");
252
253 format!(
254 r#""DepotBuildConfig"
255{{
256 "DepotID" "{depot_id}"
257 "ContentRoot" "{content_root}"
258 "FileMapping"
259 {{
260 "LocalPath" "*"
261 "DepotPath" "."
262 "recursive" "1"
263 }}
264}}
265"#,
266 depot_id = depot_id,
267 content_root = steam_dir.display()
268 )
269}
270
271fn app_vdf(config: &Config) -> String {
272 format!(
273 r#""AppBuild"
274{{
275 "AppID" "{app_id}"
276 "Desc" "{name} {version} generated by Lovely"
277 "BuildOutput" "steam-output"
278 "ContentRoot" "."
279 "SetLive" ""
280 "Depots"
281 {{
282 "{windows}" "windows/depot_build.vdf"
283 "{macos}" "macos/depot_build.vdf"
284 "{linux}" "linux/depot_build.vdf"
285 }}
286}}
287"#,
288 app_id = config.steam.app_id.as_deref().unwrap_or("TODO_APP_ID"),
289 name = config.game.name,
290 version = config.game.version,
291 windows = config
292 .steam
293 .windows_depot_id
294 .as_deref()
295 .unwrap_or("TODO_WINDOWS_DEPOT_ID"),
296 macos = config
297 .steam
298 .macos_depot_id
299 .as_deref()
300 .unwrap_or("TODO_MACOS_DEPOT_ID"),
301 linux = config
302 .steam
303 .linux_depot_id
304 .as_deref()
305 .unwrap_or("TODO_LINUX_DEPOT_ID"),
306 )
307}