Skip to content

Commit 3c68751

Browse files
authored
Merge pull request #36 from firefly-zero/import-stats
Initialize stats on import
2 parents c7e0e2b + ed2fb35 commit 3c68751

File tree

13 files changed

+314
-117
lines changed

13 files changed

+314
-117
lines changed

.github/workflows/main.yml

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
steps:
1818
- uses: actions/checkout@v4
1919
- uses: dtolnay/rust-toolchain@stable
20+
with:
21+
targets: wasm32-unknown-unknown
2022
- uses: arduino/setup-task@v1
2123
with:
2224
repo-token: ${{ github.token }}

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
/target/
22
/.task/
3-
3+
/test_app/target/

src/args.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ pub struct NameSetArgs {
147147
#[derive(Debug, Parser)]
148148
pub struct NameGenerateArgs {}
149149

150-
#[derive(Debug, Parser)]
150+
#[derive(Debug, Parser, Default)]
151151
pub struct BuildArgs {
152152
/// Path to the project root.
153153
#[arg(default_value = ".")]

src/commands/build.rs

+26-97
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::args::BuildArgs;
22
use crate::audio::convert_wav;
3+
use crate::commands::import::write_stats;
34
use crate::config::{Config, FileConfig};
45
use crate::crypto::hash_dir;
56
use crate::file_names::*;
@@ -71,7 +72,7 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
7172
show_tip();
7273
}
7374
let old_sizes = collect_sizes(&config.rom_path);
74-
write_meta(&config).context("write metadata file")?;
75+
let meta = write_meta(&config).context("write metadata file")?;
7576
build_bin(&config, args).context("build binary")?;
7677
remove_old_files(&config.rom_path).context("remove old files")?;
7778
if let Some(files) = &config.files {
@@ -82,7 +83,8 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
8283
write_badges(&config).context("write badges")?;
8384
write_boards(&config).context("write boards")?;
8485
write_installed(&config).context("write app-name")?;
85-
write_stats(&config).context("write stats")?;
86+
create_rom_stats(&config).context("create default stats file")?;
87+
write_stats(&meta, &config.vfs_path).context("write stats")?;
8688
write_key(&config).context("write key")?;
8789
write_hash(&config.rom_path).context("write hash")?;
8890
write_sig(&config).context("sign ROM")?;
@@ -94,7 +96,7 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
9496
}
9597

9698
/// Serialize and write the ROM meta information.
97-
fn write_meta(config: &Config) -> anyhow::Result<()> {
99+
fn write_meta(config: &Config) -> anyhow::Result<firefly_types::Meta<'_>> {
98100
use firefly_types::{validate_id, validate_name, Meta};
99101
if let Err(err) = validate_id(&config.app_id) {
100102
bail!("validate app_id: {err}");
@@ -121,7 +123,7 @@ fn write_meta(config: &Config) -> anyhow::Result<()> {
121123
fs::create_dir_all(&config.rom_path)?;
122124
let output_path = config.rom_path.join(META);
123125
fs::write(output_path, encoded).context("write file")?;
124-
Ok(())
126+
Ok(meta)
125127
}
126128

127129
/// Write the latest installed app name into internal DB.
@@ -306,99 +308,9 @@ fn write_boards(config: &Config) -> anyhow::Result<()> {
306308
Ok(())
307309
}
308310

309-
/// Create or update app stats.
310-
fn write_stats(config: &Config) -> anyhow::Result<()> {
311-
let data_path = config
312-
.vfs_path
313-
.join("data")
314-
.join(&config.author_id)
315-
.join(&config.app_id);
316-
if !data_path.exists() {
317-
fs::create_dir_all(&data_path).context("create data dir")?;
318-
}
319-
let path = data_path.join("stats");
320-
if path.exists() {
321-
update_stats(&path, config).context("update stats")
322-
} else {
323-
create_stats(&path, config).context("create stats")
324-
}
325-
}
326-
327-
/// Update an existing stats file putting new information into it.
328-
fn update_stats(path: &Path, config: &Config) -> anyhow::Result<()> {
329-
let raw = fs::read(path).context("read stats file")?;
330-
let stats = firefly_types::Stats::decode(&raw).context("parse stats")?;
331-
332-
let today = chrono::Local::now().date_naive();
333-
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
334-
let today = (
335-
today.year() as u16,
336-
today.month0() as u8,
337-
today.day0() as u8,
338-
);
339-
// The current date might be behind the current date on the device,
340-
// and it might be reflected in the dates recorded in the stats.
341-
// If that happens, try to stay closer to the device time.
342-
let today = today
343-
.max(stats.installed_on)
344-
.max(stats.launched_on)
345-
.max(stats.updated_on);
346-
347-
let mut badges = Vec::new();
348-
let badges_config = config.badges_vec()?;
349-
for (i, badge_config) in badges_config.iter().enumerate() {
350-
let steps = badge_config.steps.unwrap_or(1);
351-
let new_badge = if let Some(old_badge) = stats.badges.get(i) {
352-
firefly_types::BadgeProgress {
353-
new: old_badge.new,
354-
done: old_badge.done.min(steps),
355-
goal: steps,
356-
}
357-
} else {
358-
firefly_types::BadgeProgress {
359-
new: false,
360-
done: 0,
361-
goal: steps,
362-
}
363-
};
364-
badges.push(new_badge);
365-
}
366-
367-
let mut scores = Vec::new();
368-
if let Some(boards_config) = &config.boards {
369-
for i in 0..boards_config.len() {
370-
let score = if let Some(old_score) = stats.scores.get(i) {
371-
old_score.clone()
372-
} else {
373-
let fs = firefly_types::FriendScore { index: 0, score: 0 };
374-
firefly_types::BoardScores {
375-
me: Box::new([0i16; 8]),
376-
friends: Box::new([fs; 8]),
377-
}
378-
};
379-
scores.push(score);
380-
}
381-
}
382-
383-
let stats = firefly_types::Stats {
384-
minutes: stats.minutes,
385-
longest_play: stats.longest_play,
386-
launches: stats.launches,
387-
installed_on: stats.installed_on,
388-
updated_on: today,
389-
launched_on: stats.launched_on,
390-
xp: stats.xp.min(1000),
391-
badges: badges.into_boxed_slice(),
392-
scores: scores.into_boxed_slice(),
393-
};
394-
395-
let encoded = stats.encode_vec().context("serialize")?;
396-
fs::write(path, encoded).context("write file")?;
397-
Ok(())
398-
}
399-
400-
/// Create a new stats file with good defaults.
401-
fn create_stats(path: &Path, config: &Config) -> anyhow::Result<()> {
311+
/// Create default app stats.
312+
fn create_rom_stats(config: &Config) -> anyhow::Result<()> {
313+
let path = config.rom_path.join(STATS);
402314
let today = chrono::Local::now().date_naive();
403315
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
404316
let today = (
@@ -547,3 +459,20 @@ fn show_tip() {
547459
let i = rng.gen_range(0..TIPS.len());
548460
println!("💡 tip: {}.", TIPS[i]);
549461
}
462+
463+
#[cfg(test)]
464+
mod tests {
465+
use super::*;
466+
use crate::test_helpers::*;
467+
468+
#[test]
469+
fn test_build() {
470+
let vfs = make_tmp_vfs();
471+
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
472+
let args = BuildArgs {
473+
root: root.join("test_app"),
474+
..Default::default()
475+
};
476+
cmd_build(vfs, &args).unwrap();
477+
}
478+
}

src/commands/import.rs

+167-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::args::ImportArgs;
22
use crate::crypto::hash_dir;
3-
use crate::file_names::{HASH, KEY, META, SIG};
3+
use crate::file_names::{HASH, KEY, META, SIG, STATS};
44
use crate::vfs::init_vfs;
55
use anyhow::{bail, Context, Result};
6+
use chrono::Datelike;
67
use data_encoding::HEXLOWER;
78
use firefly_types::{Encode, Meta};
89
use rsa::pkcs1::DecodeRsaPublicKey;
@@ -41,6 +42,7 @@ pub fn cmd_import(vfs: &Path, args: &ImportArgs) -> Result<()> {
4142
if let Err(err) = verify(&rom_path) {
4243
println!("⚠️ verification failed: {err}");
4344
}
45+
write_stats(&meta, vfs).context("create app stats file")?;
4446
if let Some(rom_path) = rom_path.to_str() {
4547
println!("✅ installed: {rom_path}");
4648
}
@@ -136,3 +138,167 @@ fn verify(rom_path: &Path) -> anyhow::Result<()> {
136138
.context("verify signature")?;
137139
Ok(())
138140
}
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+
}

src/crypto.rs

+1-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use sha2::digest::generic_array::GenericArray;
55
use sha2::{Digest, Sha256};
66
use std::path::Path;
77

8+
/// Generate one big hash for all files in the given directory.
89
pub fn hash_dir(rom_path: &Path) -> anyhow::Result<GenericArray<u8, U32>> {
9-
// generate one big hash for all files
1010
let mut hasher = Sha256::new();
1111
let files = rom_path.read_dir().context("open the ROM dir")?;
1212
let mut file_paths = Vec::new();
@@ -29,8 +29,6 @@ pub fn hash_dir(rom_path: &Path) -> anyhow::Result<GenericArray<u8, U32>> {
2929
let mut file = std::fs::File::open(path).context("open file")?;
3030
std::io::copy(&mut file, &mut hasher).context("read file")?;
3131
}
32-
33-
// write the hash into a file
3432
let hash = hasher.finalize();
3533
Ok(hash)
3634
}

0 commit comments

Comments
 (0)