Skip to main content

lovely/targets/
desktop.rs

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}