Skip to content

Initialize stats on import #36

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: arduino/setup-task@v1
with:
repo-token: ${{ github.token }}
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/target/
/.task/

/test_app/target/
2 changes: 1 addition & 1 deletion src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ pub struct NameSetArgs {
#[derive(Debug, Parser)]
pub struct NameGenerateArgs {}

#[derive(Debug, Parser)]
#[derive(Debug, Parser, Default)]
pub struct BuildArgs {
/// Path to the project root.
#[arg(default_value = ".")]
Expand Down
123 changes: 26 additions & 97 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::args::BuildArgs;
use crate::audio::convert_wav;
use crate::commands::import::write_stats;
use crate::config::{Config, FileConfig};
use crate::crypto::hash_dir;
use crate::file_names::*;
Expand Down Expand Up @@ -71,7 +72,7 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
show_tip();
}
let old_sizes = collect_sizes(&config.rom_path);
write_meta(&config).context("write metadata file")?;
let meta = write_meta(&config).context("write metadata file")?;
build_bin(&config, args).context("build binary")?;
remove_old_files(&config.rom_path).context("remove old files")?;
if let Some(files) = &config.files {
Expand All @@ -82,7 +83,8 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
write_badges(&config).context("write badges")?;
write_boards(&config).context("write boards")?;
write_installed(&config).context("write app-name")?;
write_stats(&config).context("write stats")?;
create_rom_stats(&config).context("create default stats file")?;
write_stats(&meta, &config.vfs_path).context("write stats")?;
write_key(&config).context("write key")?;
write_hash(&config.rom_path).context("write hash")?;
write_sig(&config).context("sign ROM")?;
Expand All @@ -94,7 +96,7 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> {
}

/// Serialize and write the ROM meta information.
fn write_meta(config: &Config) -> anyhow::Result<()> {
fn write_meta(config: &Config) -> anyhow::Result<firefly_types::Meta<'_>> {
use firefly_types::{validate_id, validate_name, Meta};
if let Err(err) = validate_id(&config.app_id) {
bail!("validate app_id: {err}");
Expand All @@ -121,7 +123,7 @@ fn write_meta(config: &Config) -> anyhow::Result<()> {
fs::create_dir_all(&config.rom_path)?;
let output_path = config.rom_path.join(META);
fs::write(output_path, encoded).context("write file")?;
Ok(())
Ok(meta)
}

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

/// Create or update app stats.
fn write_stats(config: &Config) -> anyhow::Result<()> {
let data_path = config
.vfs_path
.join("data")
.join(&config.author_id)
.join(&config.app_id);
if !data_path.exists() {
fs::create_dir_all(&data_path).context("create data dir")?;
}
let path = data_path.join("stats");
if path.exists() {
update_stats(&path, config).context("update stats")
} else {
create_stats(&path, config).context("create stats")
}
}

/// Update an existing stats file putting new information into it.
fn update_stats(path: &Path, config: &Config) -> anyhow::Result<()> {
let raw = fs::read(path).context("read stats file")?;
let stats = firefly_types::Stats::decode(&raw).context("parse stats")?;

let today = chrono::Local::now().date_naive();
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let today = (
today.year() as u16,
today.month0() as u8,
today.day0() as u8,
);
// The current date might be behind the current date on the device,
// and it might be reflected in the dates recorded in the stats.
// If that happens, try to stay closer to the device time.
let today = today
.max(stats.installed_on)
.max(stats.launched_on)
.max(stats.updated_on);

let mut badges = Vec::new();
let badges_config = config.badges_vec()?;
for (i, badge_config) in badges_config.iter().enumerate() {
let steps = badge_config.steps.unwrap_or(1);
let new_badge = if let Some(old_badge) = stats.badges.get(i) {
firefly_types::BadgeProgress {
new: old_badge.new,
done: old_badge.done.min(steps),
goal: steps,
}
} else {
firefly_types::BadgeProgress {
new: false,
done: 0,
goal: steps,
}
};
badges.push(new_badge);
}

let mut scores = Vec::new();
if let Some(boards_config) = &config.boards {
for i in 0..boards_config.len() {
let score = if let Some(old_score) = stats.scores.get(i) {
old_score.clone()
} else {
let fs = firefly_types::FriendScore { index: 0, score: 0 };
firefly_types::BoardScores {
me: Box::new([0i16; 8]),
friends: Box::new([fs; 8]),
}
};
scores.push(score);
}
}

let stats = firefly_types::Stats {
minutes: stats.minutes,
longest_play: stats.longest_play,
launches: stats.launches,
installed_on: stats.installed_on,
updated_on: today,
launched_on: stats.launched_on,
xp: stats.xp.min(1000),
badges: badges.into_boxed_slice(),
scores: scores.into_boxed_slice(),
};

let encoded = stats.encode_vec().context("serialize")?;
fs::write(path, encoded).context("write file")?;
Ok(())
}

/// Create a new stats file with good defaults.
fn create_stats(path: &Path, config: &Config) -> anyhow::Result<()> {
/// Create default app stats.
fn create_rom_stats(config: &Config) -> anyhow::Result<()> {
let path = config.rom_path.join(STATS);
let today = chrono::Local::now().date_naive();
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let today = (
Expand Down Expand Up @@ -547,3 +459,20 @@ fn show_tip() {
let i = rng.gen_range(0..TIPS.len());
println!("💡 tip: {}.", TIPS[i]);
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::*;

#[test]
fn test_build() {
let vfs = make_tmp_vfs();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let args = BuildArgs {
root: root.join("test_app"),
..Default::default()
};
cmd_build(vfs, &args).unwrap();
}
}
168 changes: 167 additions & 1 deletion src/commands/import.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::args::ImportArgs;
use crate::crypto::hash_dir;
use crate::file_names::{HASH, KEY, META, SIG};
use crate::file_names::{HASH, KEY, META, SIG, STATS};
use crate::vfs::init_vfs;
use anyhow::{bail, Context, Result};
use chrono::Datelike;
use data_encoding::HEXLOWER;
use firefly_types::{Encode, Meta};
use rsa::pkcs1::DecodeRsaPublicKey;
Expand Down Expand Up @@ -41,6 +42,7 @@ pub fn cmd_import(vfs: &Path, args: &ImportArgs) -> Result<()> {
if let Err(err) = verify(&rom_path) {
println!("⚠️ verification failed: {err}");
}
write_stats(&meta, vfs).context("create app stats file")?;
if let Some(rom_path) = rom_path.to_str() {
println!("✅ installed: {rom_path}");
}
Expand Down Expand Up @@ -136,3 +138,167 @@ fn verify(rom_path: &Path) -> anyhow::Result<()> {
.context("verify signature")?;
Ok(())
}

/// Create or update app stats in the data dir based on the default stats file from ROM.
pub(super) fn write_stats(meta: &Meta<'_>, vfs_path: &Path) -> anyhow::Result<()> {
let data_path = vfs_path.join("data").join(meta.author_id).join(meta.app_id);
if !data_path.exists() {
fs::create_dir_all(&data_path).context("create data dir")?;
}
let stats_path = data_path.join("stats");
let rom_path = vfs_path.join("roms").join(meta.author_id).join(meta.app_id);
let default_path = rom_path.join(STATS);
if stats_path.exists() {
update_stats(&default_path, &stats_path)?;
} else {
copy_stats(&default_path, &stats_path)?;
}
Ok(())
}

fn copy_stats(default_path: &Path, stats_path: &Path) -> anyhow::Result<()> {
let today = chrono::Local::now().date_naive();
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let today = (
today.year() as u16,
today.month0() as u8,
today.day0() as u8,
);
let default = if default_path.exists() {
let raw = fs::read(default_path).context("read default stats file")?;
firefly_types::Stats::decode(&raw)?
} else {
firefly_types::Stats {
minutes: [0; 4],
longest_play: [0; 4],
launches: [0; 4],
installed_on: today,
updated_on: today,
launched_on: (0, 0, 0),
xp: 0,
badges: Box::new([]),
scores: Box::new([]),
}
};
let stats = firefly_types::Stats {
minutes: [0; 4],
longest_play: [0; 4],
launches: [0; 4],
installed_on: today,
updated_on: today,
launched_on: (0, 0, 0),
xp: 0,
badges: default.badges,
scores: default.scores,
};
let raw = stats.encode_vec().context("encode stats")?;
fs::write(stats_path, raw).context("write stats file")?;
Ok(())
}

fn update_stats(default_path: &Path, stats_path: &Path) -> anyhow::Result<()> {
if !default_path.exists() {
return Ok(());
}
let raw = fs::read(stats_path).context("read stats file")?;
let old_stats = firefly_types::Stats::decode(&raw).context("parse old stats")?;
let raw = fs::read(default_path).context("read default stats file")?;
let default = firefly_types::Stats::decode(&raw)?;

let today = chrono::Local::now().date_naive();
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let today = (
today.year() as u16,
today.month0() as u8,
today.day0() as u8,
);
// The current date might be behind the current date on the device,
// and it might be reflected in the dates recorded in the stats.
// If that happens, try to stay closer to the device time.
let today = today
.max(old_stats.installed_on)
.max(old_stats.launched_on)
.max(old_stats.updated_on);

let mut badges = Vec::new();
for (i, default_badge) in default.badges.iter().enumerate() {
let new_badge = if let Some(old_badge) = old_stats.badges.get(i) {
firefly_types::BadgeProgress {
new: old_badge.new,
done: old_badge.done.min(default_badge.goal),
goal: default_badge.goal,
}
} else {
firefly_types::BadgeProgress {
new: false,
done: 0,
goal: default_badge.goal,
}
};
badges.push(new_badge);
}

let mut scores = Vec::from(old_stats.scores);
scores.truncate(default.scores.len());
for _ in scores.len()..default.scores.len() {
let fs = firefly_types::FriendScore { index: 0, score: 0 };
let new_score = firefly_types::BoardScores {
me: Box::new([0i16; 8]),
friends: Box::new([fs; 8]),
};
scores.push(new_score);
}

let new_stats = firefly_types::Stats {
minutes: old_stats.minutes,
longest_play: old_stats.longest_play,
launches: old_stats.launches,
installed_on: old_stats.installed_on,
updated_on: today,
launched_on: old_stats.launched_on,
xp: old_stats.xp.min(1000),
badges: badges.into_boxed_slice(),
scores: scores.into_boxed_slice(),
};
let raw = new_stats.encode_vec().context("encode updated stats")?;
fs::write(stats_path, raw).context("write updated stats file")?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::args::*;
use crate::commands::*;
use crate::test_helpers::*;

#[test]
fn test_build_export_import() {
let vfs = make_tmp_vfs();
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let args = BuildArgs {
root: root.join("test_app"),
..Default::default()
};
cmd_build(vfs.clone(), &args).unwrap();

let tmp_dir = make_tmp_dir();
let archive_path = tmp_dir.join("test-app-export.zip");
let args = ExportArgs {
root: root.join("test_app"),
id: Some("demo.cli-test".to_string()),
output: Some(archive_path.clone()),
};
cmd_export(&vfs, &args).unwrap();

let vfs2 = make_tmp_vfs();
let path_str = archive_path.to_str().unwrap();
let args = ImportArgs {
path: path_str.to_string(),
};
cmd_import(&vfs2, &args).unwrap();

dirs_eq(&vfs.join("roms"), &vfs2.join("roms"));
dirs_eq(&vfs.join("data"), &vfs2.join("data"));
}
}
4 changes: 1 addition & 3 deletions src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use sha2::digest::generic_array::GenericArray;
use sha2::{Digest, Sha256};
use std::path::Path;

/// Generate one big hash for all files in the given directory.
pub fn hash_dir(rom_path: &Path) -> anyhow::Result<GenericArray<u8, U32>> {
// generate one big hash for all files
let mut hasher = Sha256::new();
let files = rom_path.read_dir().context("open the ROM dir")?;
let mut file_paths = Vec::new();
Expand All @@ -29,8 +29,6 @@ pub fn hash_dir(rom_path: &Path) -> anyhow::Result<GenericArray<u8, U32>> {
let mut file = std::fs::File::open(path).context("open file")?;
std::io::copy(&mut file, &mut hasher).context("read file")?;
}

// write the hash into a file
let hash = hasher.finalize();
Ok(hash)
}
Expand Down
Loading
Loading