[WIP] A simple wake-on-lan service
1use std::{
2 env::{self, VarError},
3 fs::{self, File},
4 io::{self, Read, Write},
5 path::{Path, PathBuf, StripPrefixError},
6 process::Command,
7};
8
9use serde::Deserialize;
10use thiserror::Error;
11
12const DIST_SRC: &str = "./web/dist";
13const WEB_SRC: &str = "./web";
14const PACKAGE_JSON: &str = "./web/package.json";
15
16#[derive(Deserialize, Debug)]
17struct Package {
18 scripts: Scripts,
19}
20
21#[derive(Deserialize, Debug)]
22struct Scripts {
23 #[serde(rename = "wol.build")]
24 build: String,
25}
26
27#[derive(Error, Debug)]
28enum BuildError {
29 #[error("IO Error: {}", .0)]
30 Io(#[from] io::Error),
31 #[error("Parse Error: {}", .0)]
32 Parse(#[from] serde_json::Error),
33 #[error("Command failed with error {}\nSTDOUT: {}\nSTDERR: {}", .0.map(|x| x.to_string()).unwrap_or(String::from("N/A")), .1, .2)]
34 Command(Option<i32>, String, String),
35}
36
37fn build() -> Result<(), BuildError> {
38 let mut file = File::open(PACKAGE_JSON)?;
39 let mut package = String::new();
40 file.read_to_string(&mut package)?;
41
42 let package = serde_json::from_str::<Package>(&package)?;
43 let sh = package.scripts.build;
44
45 let cmd = Command::new("/bin/sh")
46 .arg("-c")
47 .arg(sh)
48 .current_dir(WEB_SRC)
49 .output()?;
50
51 if !cmd.status.success() {
52 return Err(BuildError::Command(
53 cmd.status.code(),
54 String::from_utf8(cmd.stdout).unwrap_or(String::from("Could not parse STDOUT")),
55 String::from_utf8(cmd.stderr).unwrap_or(String::from("Could not parse STDERR")),
56 ));
57 }
58
59 Ok(())
60}
61
62#[derive(Error, Debug)]
63#[error("{}", .0)]
64struct CopyError(#[from] io::Error);
65
66fn copy_dir(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<Vec<PathBuf>, CopyError> {
67 fs::create_dir_all(&dst)?;
68 let mut paths = Vec::new();
69
70 for entry in fs::read_dir(&src)? {
71 let entry = entry?;
72 let r#type = entry.file_type()?;
73 if r#type.is_dir() {
74 let mut res = copy_dir(entry.path(), dst.as_ref().join(entry.file_name()))?;
75 paths.append(&mut res);
76 } else {
77 fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
78 paths.push(entry.path());
79 }
80 }
81
82 Ok(paths)
83}
84
85#[derive(Error, Debug)]
86enum CodegenError {
87 #[error("Could not strip path: {}", .0)]
88 StripPath(#[from] StripPrefixError),
89 #[error("Error parsing non UTF8 string")]
90 Utf8,
91}
92
93fn codegen(paths: Vec<PathBuf>) -> Result<String, CodegenError> {
94 // idx is a unique id for the current route
95 // this is easier than converting the path to a rust variable and avoiding collisons
96 let mut idx = 0;
97 // source path, route, id
98 let paths = paths
99 .into_iter()
100 .map(|x| {
101 idx += 1;
102
103 let route = Path::new("/").join(x.strip_prefix(DIST_SRC)?);
104 let route = if route.ends_with("index.html") {
105 route.parent().unwrap_or(&route)
106 } else {
107 &route
108 };
109 let route = route.to_str().ok_or(CodegenError::Utf8)?;
110 let route = String::from(route);
111
112 let path = x.strip_prefix(WEB_SRC).map(|x| x.to_owned())?;
113
114 Ok((path, route, idx))
115 })
116 .collect::<Result<Vec<_>, CodegenError>>()?;
117
118 let routes = &paths
119 .iter()
120 .map(|(path, _, idx)| {
121 let ext = path
122 .extension()
123 .and_then(|x| x.to_str())
124 .and_then(|x| match x {
125 // text
126 "html" | "htm" => Some("text/html"),
127 "css" => Some("text/css"),
128 "js" => Some("text/javascript"),
129 "json" => Some("application/json"),
130 "webmanifest" => Some("application/manifest+json"),
131 "txt" => Some("text/plain"),
132
133 // image
134 "png" => Some("image/png"),
135 "jpeg" | "jpg" => Some("image/jpeg"),
136 "svg" => Some("image/svg+xml"),
137 "webp" => Some("image/webp"),
138 "gif" => Some("image/gif"),
139 "ico" => Some("image/vnc.microsoft.icon"),
140
141 _ => None,
142 })
143 .unwrap_or("application/octet-stream");
144
145 Ok(format!(
146 r#"async fn route_{idx}() -> impl axum::response::IntoResponse {{
147 let mut headers = axum::http::HeaderMap::new();
148 headers.insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("{}"));
149 (headers, include_bytes!("{}"))
150 }}"#,
151 ext,
152 path.to_str().ok_or(CodegenError::Utf8)?
153 ))
154 })
155 .collect::<Result<Vec<_>, CodegenError>>()?
156 .join("\n");
157
158 let main = format!(
159 r#"
160 pub fn main() -> axum::Router {{
161 axum::Router::new(){}
162 }}
163 "#,
164 paths
165 .into_iter()
166 .map(|(_, route, idx)| format!(r#".route("{route}", axum::routing::get(route_{idx}))"#))
167 .collect::<Vec<_>>()
168 .join("")
169 );
170
171 Ok(format!("mod dist {{ {routes} {main} }}"))
172}
173
174#[derive(Error, Debug)]
175enum Error {
176 #[error("OUT_DIR env variable was not defined. Is this build.rs? {}", .0)]
177 OutDir(#[from] VarError),
178 #[error("Failed to compile ./web: {}", .0)]
179 Web(#[from] BuildError),
180 #[error("Failed to copy ./web/dist {}", .0)]
181 Copy(#[from] CopyError),
182 #[error("Codegen error: {}", .0)]
183 Codegen(#[from] CodegenError),
184 #[error("IO error: {}", .0)]
185 Io(#[from] io::Error),
186}
187
188fn main() -> Result<(), ()> {
189 fn main() -> Result<(), Error> {
190 let out_dir = env::var("OUT_DIR")?;
191 let dist_dst = Path::new(&out_dir).join("dist");
192
193 build()?;
194 let paths = copy_dir(DIST_SRC, dist_dst)?;
195 let rust = codegen(paths)?;
196 let mut rust_file = fs::File::create(Path::new(&out_dir).join("mod.rs"))?;
197 rust_file.write(rust.as_bytes())?;
198
199 println!("cargo::rerun-if-changed=web/");
200
201 Ok(())
202 }
203
204 let res = main();
205 if let Err(err) = res {
206 eprintln!("{}", err);
207 Err(())
208 } else {
209 Ok(())
210 }
211}