|
1 | 1 | use crate::args::ImportArgs;
|
2 | 2 | use crate::crypto::hash_dir;
|
3 |
| -use crate::file_names::{HASH, KEY, META, SIG}; |
| 3 | +use crate::file_names::{HASH, KEY, META, SIG, STATS}; |
4 | 4 | use crate::vfs::init_vfs;
|
5 | 5 | use anyhow::{bail, Context, Result};
|
| 6 | +use chrono::Datelike; |
6 | 7 | use data_encoding::HEXLOWER;
|
7 | 8 | use firefly_types::{Encode, Meta};
|
8 | 9 | use rsa::pkcs1::DecodeRsaPublicKey;
|
@@ -41,6 +42,7 @@ pub fn cmd_import(vfs: &Path, args: &ImportArgs) -> Result<()> {
|
41 | 42 | if let Err(err) = verify(&rom_path) {
|
42 | 43 | println!("⚠️ verification failed: {err}");
|
43 | 44 | }
|
| 45 | + write_stats(&meta, vfs).context("create app stats file")?; |
44 | 46 | if let Some(rom_path) = rom_path.to_str() {
|
45 | 47 | println!("✅ installed: {rom_path}");
|
46 | 48 | }
|
@@ -136,3 +138,167 @@ fn verify(rom_path: &Path) -> anyhow::Result<()> {
|
136 | 138 | .context("verify signature")?;
|
137 | 139 | Ok(())
|
138 | 140 | }
|
| 141 | + |
| 142 | +/// Create or update app stats in the data dir based on the default stats file from ROM. |
| 143 | +pub(super) fn write_stats(meta: &Meta<'_>, vfs_path: &Path) -> anyhow::Result<()> { |
| 144 | + let data_path = vfs_path.join("data").join(meta.author_id).join(meta.app_id); |
| 145 | + if !data_path.exists() { |
| 146 | + fs::create_dir_all(&data_path).context("create data dir")?; |
| 147 | + } |
| 148 | + let stats_path = data_path.join("stats"); |
| 149 | + let rom_path = vfs_path.join("roms").join(meta.author_id).join(meta.app_id); |
| 150 | + let default_path = rom_path.join(STATS); |
| 151 | + if stats_path.exists() { |
| 152 | + update_stats(&default_path, &stats_path)?; |
| 153 | + } else { |
| 154 | + copy_stats(&default_path, &stats_path)?; |
| 155 | + } |
| 156 | + Ok(()) |
| 157 | +} |
| 158 | + |
| 159 | +fn copy_stats(default_path: &Path, stats_path: &Path) -> anyhow::Result<()> { |
| 160 | + let today = chrono::Local::now().date_naive(); |
| 161 | + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] |
| 162 | + let today = ( |
| 163 | + today.year() as u16, |
| 164 | + today.month0() as u8, |
| 165 | + today.day0() as u8, |
| 166 | + ); |
| 167 | + let default = if default_path.exists() { |
| 168 | + let raw = fs::read(default_path).context("read default stats file")?; |
| 169 | + firefly_types::Stats::decode(&raw)? |
| 170 | + } else { |
| 171 | + firefly_types::Stats { |
| 172 | + minutes: [0; 4], |
| 173 | + longest_play: [0; 4], |
| 174 | + launches: [0; 4], |
| 175 | + installed_on: today, |
| 176 | + updated_on: today, |
| 177 | + launched_on: (0, 0, 0), |
| 178 | + xp: 0, |
| 179 | + badges: Box::new([]), |
| 180 | + scores: Box::new([]), |
| 181 | + } |
| 182 | + }; |
| 183 | + let stats = firefly_types::Stats { |
| 184 | + minutes: [0; 4], |
| 185 | + longest_play: [0; 4], |
| 186 | + launches: [0; 4], |
| 187 | + installed_on: today, |
| 188 | + updated_on: today, |
| 189 | + launched_on: (0, 0, 0), |
| 190 | + xp: 0, |
| 191 | + badges: default.badges, |
| 192 | + scores: default.scores, |
| 193 | + }; |
| 194 | + let raw = stats.encode_vec().context("encode stats")?; |
| 195 | + fs::write(stats_path, raw).context("write stats file")?; |
| 196 | + Ok(()) |
| 197 | +} |
| 198 | + |
| 199 | +fn update_stats(default_path: &Path, stats_path: &Path) -> anyhow::Result<()> { |
| 200 | + if !default_path.exists() { |
| 201 | + return Ok(()); |
| 202 | + } |
| 203 | + let raw = fs::read(stats_path).context("read stats file")?; |
| 204 | + let old_stats = firefly_types::Stats::decode(&raw).context("parse old stats")?; |
| 205 | + let raw = fs::read(default_path).context("read default stats file")?; |
| 206 | + let default = firefly_types::Stats::decode(&raw)?; |
| 207 | + |
| 208 | + let today = chrono::Local::now().date_naive(); |
| 209 | + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] |
| 210 | + let today = ( |
| 211 | + today.year() as u16, |
| 212 | + today.month0() as u8, |
| 213 | + today.day0() as u8, |
| 214 | + ); |
| 215 | + // The current date might be behind the current date on the device, |
| 216 | + // and it might be reflected in the dates recorded in the stats. |
| 217 | + // If that happens, try to stay closer to the device time. |
| 218 | + let today = today |
| 219 | + .max(old_stats.installed_on) |
| 220 | + .max(old_stats.launched_on) |
| 221 | + .max(old_stats.updated_on); |
| 222 | + |
| 223 | + let mut badges = Vec::new(); |
| 224 | + for (i, default_badge) in default.badges.iter().enumerate() { |
| 225 | + let new_badge = if let Some(old_badge) = old_stats.badges.get(i) { |
| 226 | + firefly_types::BadgeProgress { |
| 227 | + new: old_badge.new, |
| 228 | + done: old_badge.done.min(default_badge.goal), |
| 229 | + goal: default_badge.goal, |
| 230 | + } |
| 231 | + } else { |
| 232 | + firefly_types::BadgeProgress { |
| 233 | + new: false, |
| 234 | + done: 0, |
| 235 | + goal: default_badge.goal, |
| 236 | + } |
| 237 | + }; |
| 238 | + badges.push(new_badge); |
| 239 | + } |
| 240 | + |
| 241 | + let mut scores = Vec::from(old_stats.scores); |
| 242 | + scores.truncate(default.scores.len()); |
| 243 | + for _ in scores.len()..default.scores.len() { |
| 244 | + let fs = firefly_types::FriendScore { index: 0, score: 0 }; |
| 245 | + let new_score = firefly_types::BoardScores { |
| 246 | + me: Box::new([0i16; 8]), |
| 247 | + friends: Box::new([fs; 8]), |
| 248 | + }; |
| 249 | + scores.push(new_score); |
| 250 | + } |
| 251 | + |
| 252 | + let new_stats = firefly_types::Stats { |
| 253 | + minutes: old_stats.minutes, |
| 254 | + longest_play: old_stats.longest_play, |
| 255 | + launches: old_stats.launches, |
| 256 | + installed_on: old_stats.installed_on, |
| 257 | + updated_on: today, |
| 258 | + launched_on: old_stats.launched_on, |
| 259 | + xp: old_stats.xp.min(1000), |
| 260 | + badges: badges.into_boxed_slice(), |
| 261 | + scores: scores.into_boxed_slice(), |
| 262 | + }; |
| 263 | + let raw = new_stats.encode_vec().context("encode updated stats")?; |
| 264 | + fs::write(stats_path, raw).context("write updated stats file")?; |
| 265 | + Ok(()) |
| 266 | +} |
| 267 | + |
| 268 | +#[cfg(test)] |
| 269 | +mod tests { |
| 270 | + use super::*; |
| 271 | + use crate::args::*; |
| 272 | + use crate::commands::*; |
| 273 | + use crate::test_helpers::*; |
| 274 | + |
| 275 | + #[test] |
| 276 | + fn test_build_export_import() { |
| 277 | + let vfs = make_tmp_vfs(); |
| 278 | + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
| 279 | + let args = BuildArgs { |
| 280 | + root: root.join("test_app"), |
| 281 | + ..Default::default() |
| 282 | + }; |
| 283 | + cmd_build(vfs.clone(), &args).unwrap(); |
| 284 | + |
| 285 | + let tmp_dir = make_tmp_dir(); |
| 286 | + let archive_path = tmp_dir.join("test-app-export.zip"); |
| 287 | + let args = ExportArgs { |
| 288 | + root: root.join("test_app"), |
| 289 | + id: Some("demo.cli-test".to_string()), |
| 290 | + output: Some(archive_path.clone()), |
| 291 | + }; |
| 292 | + cmd_export(&vfs, &args).unwrap(); |
| 293 | + |
| 294 | + let vfs2 = make_tmp_vfs(); |
| 295 | + let path_str = archive_path.to_str().unwrap(); |
| 296 | + let args = ImportArgs { |
| 297 | + path: path_str.to_string(), |
| 298 | + }; |
| 299 | + cmd_import(&vfs2, &args).unwrap(); |
| 300 | + |
| 301 | + dirs_eq(&vfs.join("roms"), &vfs2.join("roms")); |
| 302 | + dirs_eq(&vfs.join("data"), &vfs2.join("data")); |
| 303 | + } |
| 304 | +} |
0 commit comments