cpclib_runner/
delegated.rs

1use std::collections::BTreeMap;
2use std::fmt::{Debug, Display};
3use std::io::{Cursor, Read};
4use std::ops::Deref;
5use std::rc::Rc;
6
7use bon::Builder;
8use cpclib_common::camino::{Utf8Path, Utf8PathBuf};
9use cpclib_common::itertools::Itertools;
10use directories::ProjectDirs;
11use flate2::read::GzDecoder;
12use scraper::{Html, Selector};
13use tar::Archive;
14use ureq;
15use ureq::Response;
16use xz2::read::XzDecoder;
17
18use crate::event::EventObserver;
19use crate::runner::runner::{ExternRunner, RunInDir, Runner};
20
21static GITHUB_URL: &str = "https://github.com/";
22
23/// Download a HTTP ressource
24pub fn cpclib_download(url: &str) -> Result<Box<dyn Read + Send + Sync>, String> {
25    Ok(ureq::get(url)
26        .set("Cache-Control", "max-age=1")
27        .set("From", "krusty.benediction@gmail.com")
28        .set("User-Agent", "cpclib")
29        .call()
30        .map_err(|e| e.to_string())?
31        .into_reader())
32}
33
34/// From the full release url page, get the url for the given release
35pub fn github_get_assets_for_version_url<GI: GithubInformation>(
36    info: &GI
37) -> Result<String, String> {
38    let url = dbg!(format!(
39        "https://github.com/{}/{}/releases",
40        info.owner(),
41        info.project()
42    ));
43
44    // obtain the base dowload page
45    let mut content = cpclib_download(&url)?;
46    let mut html = String::new();
47    content
48        .read_to_string(&mut html)
49        .map_err(|e| e.to_string())?;
50    let document = Html::parse_document(&html);
51
52    let selector = Selector::parse("a.Link--primary.Link")
53        .map_err(|e| e.to_string())
54        .map_err(|e| e.to_string())?;
55
56    // search for the title link such as
57    // <a href="/EdouardBERGE/rasm/releases/tag/v2.3.6" data-view-component="true" class="Link--primary Link">Rasm 2.3.6 - Beacon 2025</a>
58    for link in document.select(&selector) {
59        if let Some(href) = link.attr("href") {
60            let content = link.inner_html();
61            if content.contains(info.version_name()) && href.contains("/tag/") {
62                return Ok(
63                    format!("https://github.com{href}").replace("/tag/", "/expanded_assets/")
64                );
65            }
66        }
67    }
68
69    Err(format!("No download link found for {info}"))
70}
71
72#[derive(Default, bon::Builder)]
73#[builder(on(String, into))]
74pub struct MutiplatformUrls {
75    pub linux: Option<String>,
76    pub windows: Option<String>,
77    pub macos: Option<String>
78}
79
80impl MutiplatformUrls {
81    pub fn unique_url(url: &str) -> Self {
82        MutiplatformUrls::builder()
83            .linux(url)
84            .windows(url)
85            .macos(url)
86            .build()
87    }
88
89    pub fn target_os_url(&self) -> Option<&String> {
90        #[cfg(target_os = "windows")]
91        return self.windows.as_ref();
92        #[cfg(target_os = "macos")]
93        return self.macosx.as_ref();
94        #[cfg(target_os = "linux")]
95        return self.linux.as_ref();
96    }
97}
98
99pub trait CompilableInformation {
100    /// Returns the list of commands to execute for the target os
101    fn target_os_commands(&self) -> Option<&'static [&'static [&'static str]]>;
102
103    /// Produces the function that executes the list of commands
104    fn target_os_compiler<E: EventObserver>(&self) -> Option<Compiler<E>> {
105        if let Some(commands) = self.target_os_commands() {
106            let install: Box<dyn Fn(&Utf8Path, &E) -> Result<(), String>> =
107                Box::new(|_path: &Utf8Path, o: &E| -> Result<(), String> {
108                    for command in commands.iter() {
109                        ExternRunner::default().inner_run(command, o)?;
110                    }
111                    Ok(())
112                });
113            let install = Compiler::from(install);
114            Some(install)
115        }
116        else {
117            None
118        }
119    }
120}
121
122pub trait DownloadableInformation {
123    fn target_os_archive_format(&self) -> ArchiveFormat;
124    fn target_os_postinstall<E: EventObserver>(&self) -> Option<PostInstall<E>> {
125        None
126    }
127}
128
129pub trait StaticInformation: DownloadableInformation {
130    fn static_download_urls(&self) -> &'static MutiplatformUrls;
131
132    fn target_os_url(&self) -> Option<&'static str> {
133        self.static_download_urls()
134            .target_os_url()
135            .map(|s| s.as_str())
136    }
137
138    fn target_os_url_generator(&self) -> UrlGenerator {
139        let url = self.target_os_url();
140        let deferred: Box<dyn Fn() -> Result<String, String>> = Box::new(move || {
141            url.ok_or_else(|| "No download url for current OS".to_string())
142                .map(|s| s.to_owned())
143        });
144        deferred.into()
145    }
146}
147
148pub trait ExecutableInformation {
149    fn target_os_folder(&self) -> &'static str;
150    fn target_os_exec_fname(&self) -> &'static str;
151    fn target_os_run_in_dir(&self) -> RunInDir {
152        RunInDir::default()
153    }
154}
155
156pub trait DynamicUrlInformation: DownloadableInformation + Clone + 'static {
157    fn dynamic_download_urls(&self) -> Result<MutiplatformUrls, String>;
158
159    fn target_os_url_generator(&self) -> UrlGenerator {
160        let cloned: Self = self.clone();
161        let deferred: Box<dyn Fn() -> Result<String, String>> =
162            Box::new(move || -> Result<String, String> {
163                let inside: Self = cloned.clone();
164                let urls = inside.dynamic_download_urls()?;
165                urls.target_os_url()
166                    .cloned()
167                    .ok_or("No url for this os".to_string())
168            });
169        deferred.into()
170    }
171}
172
173pub trait GithubInformation: DownloadableInformation + Display + Clone + 'static {
174    fn project(&self) -> &'static str;
175    fn owner(&self) -> &'static str;
176    /// The name to search to obtain the assets link
177    fn version_name(&self) -> &'static str;
178    fn linux_key(&self) -> Option<&'static str> {
179        None
180    }
181    fn windows_key(&self) -> Option<&'static str> {
182        None
183    }
184    fn macos_key(&self) -> Option<&'static str> {
185        None
186    }
187
188    // specific implementation of github
189    fn target_os_url_generator(&self) -> UrlGenerator {
190        let cloned = self.clone();
191        let deferred: Box<dyn Fn() -> Result<String, String>> =
192            Box::new(move || -> Result<String, String> {
193                let inside = cloned.clone();
194                let urls = inside.github_download_urls()?;
195                urls.target_os_url()
196                    .cloned()
197                    .ok_or("No url for this os".to_string())
198            });
199        deferred.into()
200    }
201
202    fn github_download_urls(&self) -> Result<MutiplatformUrls, String> {
203        let mut content = cpclib_download(&github_get_assets_for_version_url(self)?)?;
204        let mut html = String::default();
205        content
206            .read_to_string(&mut html)
207            .map_err(|e| e.to_string())?;
208        let document = Html::parse_document(&html);
209        let selector = Selector::parse("a")
210            .map_err(|e| e.to_string())
211            .map_err(|e| e.to_string())?;
212
213        let mut map = BTreeMap::new();
214        for element in document.select(&selector) {
215            let name = element.text().collect::<String>();
216            let name = name
217                .replace("\n", "")
218                .replace("\t", " ")
219                .replace("    ", " ");
220            let name = name.trim();
221            map.insert(name.to_owned(), element.attr("href").unwrap().trim());
222        }
223
224        let mut urls = MutiplatformUrls::default();
225
226        if let Some(key) = self.linux_key() {
227            urls.linux = Some(format!(
228                "{}/{}",
229                GITHUB_URL,
230                map.get(key).ok_or_else(|| {
231                    format!(
232                        "'{}' not found among {}",
233                        key,
234                        map.keys().map(|s| format!("'{s}'")).join(", ")
235                    )
236                })?
237            ));
238        }
239        if let Some(key) = self.windows_key() {
240            urls.windows = Some(format!(
241                "{}/{}",
242                GITHUB_URL,
243                map.get(key).ok_or_else(|| {
244                    format!(
245                        "'{}' not found among {}",
246                        key,
247                        map.keys().map(|s| format!("'{s}'")).join(", ")
248                    )
249                })?
250            ));
251        }
252        if let Some(key) = self.macos_key() {
253            urls.macos = Some(format!(
254                "{}/{}",
255                GITHUB_URL,
256                map.get(key).ok_or_else(|| {
257                    format!(
258                        "'{}' not found among {}",
259                        key,
260                        map.keys().map(|s| format!("'{s}'")).join(", ")
261                    )
262                })?
263            ));
264        }
265
266        Ok(urls)
267    }
268}
269
270impl<G> From<&G> for UrlGenerator
271where G: GithubInformation
272{
273    fn from(g: &G) -> Self {
274        g.target_os_url_generator()
275    }
276}
277
278pub trait HasConfiguration {
279    fn configuration<E: EventObserver + 'static>(&self) -> DelegateApplicationDescription<E>;
280}
281
282pub trait GithubCompilableApplication:
283    CompilableInformation + ExecutableInformation + GithubInformation + Default
284{
285    fn configuration<E: EventObserver>(&self) -> DelegateApplicationDescription<E> {
286        DelegateApplicationDescription::builder()
287            .download_fn_url(self) // we assume a modern CPU
288            .folder(self.target_os_folder())
289            .archive_format(self.target_os_archive_format())
290            .exec_fname(self.target_os_exec_fname())
291            .maybe_compile(self.target_os_compiler())
292            .in_dir(self.target_os_run_in_dir())
293            .maybe_post_install(self.target_os_postinstall())
294            .build()
295    }
296}
297
298pub trait GithubCompiledApplication: ExecutableInformation + GithubInformation + Default {
299    fn configuration<E: EventObserver>(&self) -> DelegateApplicationDescription<E> {
300        DelegateApplicationDescription::builder()
301            .download_fn_url(self) // we assume a modern CPU
302            .folder(self.target_os_folder())
303            .archive_format(self.target_os_archive_format())
304            .exec_fname(self.target_os_exec_fname())
305            .in_dir(self.target_os_run_in_dir())
306            .maybe_post_install(self.target_os_postinstall())
307            .build()
308    }
309}
310
311pub trait InternetStaticCompiledApplication: StaticInformation + ExecutableInformation {
312    fn configuration<E: EventObserver>(&self) -> DelegateApplicationDescription<E> {
313        DelegateApplicationDescription::builder()
314            .download_fn_url(self.target_os_url_generator())
315            .folder(self.target_os_folder())
316            .archive_format(self.target_os_archive_format())
317            .exec_fname(self.target_os_exec_fname())
318            .in_dir(self.target_os_run_in_dir())
319            .maybe_post_install(self.target_os_postinstall())
320            .build()
321    }
322}
323
324pub trait InternetDynamicCompiledApplication:
325    DynamicUrlInformation + ExecutableInformation + Default
326{
327    fn configuration<E: EventObserver>(&self) -> DelegateApplicationDescription<E> {
328        DelegateApplicationDescription::builder()
329            .download_fn_url(self.target_os_url_generator())
330            .folder(self.target_os_folder())
331            .archive_format(self.target_os_archive_format())
332            .exec_fname(self.target_os_exec_fname())
333            .in_dir(self.target_os_run_in_dir())
334            .maybe_post_install(self.target_os_postinstall())
335            .build()
336    }
337}
338
339#[derive(Clone)]
340pub struct UrlGenerator(Rc<Box<dyn Fn() -> Result<String, String>>>);
341
342impl From<Box<dyn Fn() -> String>> for UrlGenerator {
343    fn from(value: Box<dyn Fn() -> String>) -> Self {
344        let wrap = Box::new(move || Ok(value()));
345        Self(Rc::new(wrap))
346    }
347}
348
349impl From<Box<dyn Fn() -> Result<String, String>>> for UrlGenerator {
350    fn from(value: Box<dyn Fn() -> Result<String, String>>) -> Self {
351        Self(Rc::new(value))
352    }
353}
354
355impl From<String> for UrlGenerator {
356    fn from(value: String) -> Self {
357        Self(Rc::new(Box::new(move || Ok(value.clone()))))
358    }
359}
360
361impl From<&str> for UrlGenerator {
362    fn from(value: &str) -> Self {
363        let value = value.to_owned();
364        value.into()
365    }
366}
367
368impl Deref for UrlGenerator {
369    type Target = Box<dyn Fn() -> Result<String, String>>;
370
371    fn deref(&self) -> &Self::Target {
372        &self.0
373    }
374}
375
376#[derive(Clone)]
377pub struct Compiler<E>(Rc<Box<dyn Fn(&Utf8Path, &E) -> Result<(), String>>>);
378impl<E> From<Box<dyn Fn(&Utf8Path, &E) -> Result<(), String>>> for Compiler<E> {
379    fn from(value: Box<dyn Fn(&Utf8Path, &E) -> Result<(), String>>) -> Self {
380        Self(Rc::new(value))
381    }
382}
383
384impl<E> Deref for Compiler<E> {
385    type Target = Box<dyn Fn(&Utf8Path, &E) -> Result<(), String>>;
386
387    fn deref(&self) -> &Self::Target {
388        &self.0
389    }
390}
391
392#[derive(Clone)]
393pub struct PostInstall<E: EventObserver>(
394    Rc<Box<dyn Fn(&DelegateApplicationDescription<E>) -> Result<(), String>>>
395);
396
397impl<E: EventObserver> From<Box<dyn Fn(&DelegateApplicationDescription<E>) -> Result<(), String>>>
398    for PostInstall<E>
399{
400    fn from(value: Box<dyn Fn(&DelegateApplicationDescription<E>) -> Result<(), String>>) -> Self {
401        Self(Rc::new(value))
402    }
403}
404
405impl<E: EventObserver> Deref for PostInstall<E> {
406    type Target = Box<dyn Fn(&DelegateApplicationDescription<E>) -> Result<(), String>>;
407
408    fn deref(&self) -> &Self::Target {
409        &self.0
410    }
411}
412
413#[derive(Clone, Debug)]
414pub enum ArchiveFormat {
415    Raw,
416    Tar,
417    TarGz,
418    TarXz,
419    Zip,
420    SevenZ
421}
422
423#[derive(Builder, Clone)]
424pub struct DelegateApplicationDescription<E: EventObserver> {
425    #[builder(into)]
426    pub download_fn_url: UrlGenerator,
427    pub folder: &'static str,
428    pub exec_fname: &'static str,
429    pub archive_format: ArchiveFormat,
430    #[builder(into)]
431    pub compile: Option<Compiler<E>>,
432    #[builder(into)]
433    pub post_install: Option<PostInstall<E>>,
434    #[builder(default=RunInDir::CurrentDir)]
435    pub in_dir: RunInDir
436}
437
438impl<E: EventObserver> Debug for DelegateApplicationDescription<E> {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        f.debug_struct("DelegateApplicationDescription")
441            .field("folder", &self.folder)
442            .field("exec_fname", &self.exec_fname)
443            .field("archive_format", &self.archive_format)
444            .field("in_dir", &self.in_dir)
445            .finish()
446    }
447}
448
449pub fn base_cache_folder() -> Utf8PathBuf {
450    let proj_dirs = ProjectDirs::from("net.cpcscene", "benediction", "bnd build").unwrap();
451    Utf8Path::from_path(proj_dirs.cache_dir())
452        .unwrap()
453        .to_owned()
454}
455
456pub fn clear_base_cache_folder() -> std::io::Result<()> {
457    std::fs::remove_dir_all(base_cache_folder())
458}
459
460impl<E: EventObserver> DelegateApplicationDescription<E> {
461    pub fn is_cached(&self) -> bool {
462        self.cache_folder().exists()
463    }
464
465    pub fn cache_folder(&self) -> Utf8PathBuf {
466        let base_cache = base_cache_folder();
467
468        if !base_cache.exists() {
469            std::fs::create_dir_all(&base_cache).unwrap();
470        }
471
472        base_cache.join(self.folder).try_into().unwrap()
473    }
474
475    pub fn exec_fname(&self) -> Utf8PathBuf {
476        self.cache_folder().join(self.exec_fname)
477    }
478
479    pub fn install(&self, o: &E) -> Result<(), String> {
480        self.inner_install(o).inspect_err(|e| {
481            dbg!("There was an error, we need to do some cleaning", e);
482            let dest = self.cache_folder();
483            dbg!("Try to remove ", &dest);
484            let _ = std::fs::remove_dir_all(dest); // ignore error
485            dbg!("Should be done");
486        })
487    }
488
489    fn inner_install(&self, o: &E) -> Result<(), String> {
490        // get the file
491        let dest = self.cache_folder();
492
493        let resp = self
494            .download(o)
495            .map_err(|e| format!("Unable to download the expected file. {e}"))?;
496        let mut input = resp.into_reader();
497
498        // uncompress it
499        match self.archive_format {
500            ArchiveFormat::Raw => {
501                o.emit_stdout(&format!(">> Save to {}\n", self.exec_fname()));
502                let mut buffer = Vec::new();
503                input.read_to_end(&mut buffer).unwrap();
504                std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
505                std::fs::write(self.exec_fname(), &buffer).map_err(|e| e.to_string())?;
506            },
507            ArchiveFormat::Tar => {
508                o.emit_stdout(">> Open tar archive\n");
509                let mut archive = Archive::new(input);
510                archive.unpack(dest.clone()).map_err(|e| e.to_string())?;
511            },
512            ArchiveFormat::TarGz => {
513                o.emit_stdout(">> Open targz archive\n");
514                let gz = GzDecoder::new(input);
515                let mut archive = Archive::new(gz);
516                archive.unpack(dest.clone()).map_err(|e| e.to_string())?;
517            },
518            ArchiveFormat::TarXz => {
519                o.emit_stdout(">> Open tarxz archive\n");
520                let xz = XzDecoder::new(input);
521                let mut archive = Archive::new(xz);
522                archive.unpack(dest.clone()).map_err(|e| e.to_string())?;
523            },
524            ArchiveFormat::Zip => {
525                o.emit_stdout(">> Unzip archive\n");
526                let mut buffer = Vec::new();
527                input.read_to_end(&mut buffer).unwrap();
528                zip_extract::extract(Cursor::new(buffer), dest.as_std_path(), true)
529                    .map_err(|e| e.to_string())?;
530            },
531            ArchiveFormat::SevenZ => {
532                o.emit_stdout(">> Open 7z archive\n");
533                let mut buffer = Vec::new();
534                input.read_to_end(&mut buffer).unwrap();
535                sevenz_rust::decompress(Cursor::new(buffer), dest.as_std_path())
536                    .map_err(|e| e.to_string())?;
537            }
538        }
539
540        if let Some(compile) = &self.compile {
541            o.emit_stdout(">> Compile program\n");
542
543            let cwd = std::env::current_dir()
544                .map_err(|e| format!("Unable to get the current working directory {e}.\n"))?;
545            std::env::set_current_dir(&dest)
546                .map_err(|e| format!("Unable to set the current working directory {e}.\n"))?;
547            let res = compile(&dest, o);
548            std::env::set_current_dir(&cwd)
549                .map_err(|e| format!("Unable to set the current working directory {e}.\n"))?;
550            res
551        }
552        else {
553            Ok(())
554        }?;
555
556        if let Some(post_install) = &self.post_install {
557            o.emit_stdout(">> Apply post-installation\n");
558            post_install(self)
559        }
560        else {
561            Ok(())
562        }
563    }
564
565    fn download(&self, o: &E) -> Result<Response, String> {
566        let url = self.download_fn_url.deref()()?;
567        o.emit_stdout(&format!(">> Download file {url}\n"));
568        ureq::get(&url).call().map_err(|e| e.to_string())
569    }
570}
571
572#[derive(Debug)]
573pub struct DelegatedRunner<E: EventObserver> {
574    /// The description of the application to run
575    pub app: DelegateApplicationDescription<E>,
576    /// The command line
577    pub cmd: String,
578    /// Weither if the gui must be hidden or not
579    pub transparent: bool
580}
581
582impl<E: EventObserver> DelegatedRunner<E> {
583    pub fn new(app: DelegateApplicationDescription<E>, cmd: String) -> Self {
584        Self {
585            app,
586            cmd,
587            transparent: false
588        }
589    }
590
591    pub fn new_transparent(app: DelegateApplicationDescription<E>, cmd: String) -> Self {
592        Self {
593            app,
594            cmd,
595            transparent: true
596        }
597    }
598}
599
600impl<E: EventObserver> Runner for DelegatedRunner<E> {
601    type EventObserver = E;
602
603    fn inner_run<S: AsRef<str>>(&self, itr: &[S], o: &E) -> Result<(), String> {
604        let cfg = &self.app;
605
606        // ensure the emulator exists
607        if !cfg.is_cached() {
608            o.emit_stdout("> Install application\n");
609            let res = cfg.install(o);
610            if let Err(res) = res {
611                dbg!("Need to leave");
612                return Err(res);
613            }
614        }
615        assert!(cfg.is_cached());
616
617        // Build the command
618        let mut command = Vec::with_capacity(1 + itr.len());
619        let fname = cfg.exec_fname();
620
621        #[cfg(target_os = "linux")]
622        {
623            if fname.as_str().to_lowercase().ends_with(".exe") {
624                command.push("wine");
625            }
626        }
627
628        command.push(fname.as_str());
629
630        for arg in itr.iter() {
631            command.push(arg.as_ref());
632        }
633
634        // Delegate it to the appropriate luncher
635        let runner = if self.transparent {
636            ExternRunner::<E>::new_transparent(cfg.in_dir)
637        }
638        else {
639            ExternRunner::<E>::new(cfg.in_dir)
640        };
641        runner.inner_run(&command, o)
642    }
643
644    fn get_command(&self) -> &str {
645        &self.cmd
646    }
647}