Launcher: registry mods show their 1-5 star rating

Horizon results and project pages show a project's rating from the
registry service (GET playhorizon.dev/api/mods/ratings, fetched with the
results, kept ten minutes): five pixel stars, the mean and how many, or
"No ratings yet". Nothing shows when the service doesn't answer.

The launcher has no GitHub account, so a project's page offers "Sign in
with GitHub to rate": it opens the site's sign-in, which comes back to the
project's page and its stars (one rating per GitHub account).

HORIZON_MOD_RATINGS (a file or https:// address) points a test registry at
its own ratings. The star icon is drawn for Horizon on the icons' grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-26 14:50:40 +01:00
1 parent 590ca775c2
commit 3dd17ad7d7
7 files changed
+179 -2

No files matched your search

+2 -1
View File
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 1h4v2h-4ZM10 3h4v2h-4ZM8 5h8v2h-8ZM0 7h24v2h-24ZM2 9h20v2h-20ZM4 11h16v2h-16ZM6 13h12v2h-12ZM6 15h12v2h-12ZM4 17h6v2h-6ZM14 17h6v2h-6ZM4 19h4v2h-4ZM16 19h4v2h-4ZM2 21h4v2h-4ZM18 21h4v2h-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

+2
View File
@@ -67,6 +67,8 @@ pub mod icons {
"search",
"server",
"sliders",
// Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid.
"star",
"terminal",
"trash",
"user",
+96 -1
View File
@@ -8,6 +8,7 @@ use super::{
download, installed, java, FileRef, Kind, Project, Query, Source, SourceId, Version, GAME,
};
use horizon_mods::index::{self, Index, Trust};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -27,6 +28,11 @@ const FRESH: Duration = Duration::from_secs(600);
const MAX_INDEX: u64 = 16 << 20;
/// The last index fetched, kept in the game folder for when the registry can't be reached.
const CACHE: &str = "mod-browser/horizon-index.json";
/// Players' ratings of every project (the registry service, crates/horizon-mod-registry).
const RATINGS_URL: &str = "https://playhorizon.dev/api/mods/ratings";
/// Where a player rates a project: the site's GitHub sign-in, which comes back to its page.
/// The launcher has no GitHub account of its own.
const RATE: &str = "https://playhorizon.dev/api/mods/auth/login?back=/mods/";
/// Where the index comes from: `HORIZON_MOD_INDEX` (a file or an `https://` address, for a
/// test registry), else [`INDEX_URL`].
@@ -110,6 +116,92 @@ fn fetch_index() -> Result<(Index, String), String> {
Ok((index, text))
}
/// A project's ratings: 1 to 5 stars, their mean and how many.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rating {
pub average: f32,
pub count: u64,
}
/// When the ratings were fetched last, and every rated project's [`Rating`] (None: that failed).
type Ratings = Option<Arc<HashMap<String, Rating>>>;
static RATINGS: Mutex<Option<(Instant, Ratings)>> = Mutex::new(None);
/// A project's rating as fetched last, without asking the network: None when the ratings
/// aren't known (not fetched, or the service didn't answer), `Some(None)` when it has none.
pub fn rating(id: &str) -> Option<Option<Rating>> {
let ratings = RATINGS.lock().unwrap();
let known = ratings.as_ref()?.1.as_ref()?;
Some(known.get(id).copied())
}
/// The page that signs the player in with GitHub on the site and lets them rate `id`.
pub fn rate_url(id: &str) -> String {
format!("{RATE}{id}")
}
/// Fetches the ratings again when the last try is ten minutes old: from `HORIZON_MOD_RATINGS`
/// (a file or an `https://` address, for a test registry), else [`RATINGS_URL`]; a local test
/// registry has none unless given. After a failure the browser shows downloads only.
fn refresh_ratings() {
if RATINGS
.lock()
.unwrap()
.as_ref()
.is_some_and(|(at, _)| at.elapsed() < FRESH)
{
return;
}
let given = std::env::var("HORIZON_MOD_RATINGS")
.ok()
.filter(|v| !v.is_empty());
if given.is_none() && local() {
return;
}
let at = given.unwrap_or_else(|| RATINGS_URL.to_owned());
let text = if at.starts_with("https://") {
download::agent().get(&at).call().ok().and_then(|r| {
use std::io::Read;
let mut text = String::new();
r.into_reader()
.take(MAX_INDEX)
.read_to_string(&mut text)
.ok()
.map(|_| text)
})
} else {
fs::read_to_string(&at).ok()
};
let fetched = text.map(|t| Arc::new(parse_ratings(&t)));
*RATINGS.lock().unwrap() = Some((Instant::now(), fetched));
}
/// `{"projects": {"<id>": {"average", "count"}}}`, keeping only well-formed entries.
fn parse_ratings(text: &str) -> HashMap<String, Rating> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else {
return HashMap::new();
};
let Some(projects) = json["projects"].as_object() else {
return HashMap::new();
};
projects
.iter()
.filter_map(|(id, r)| {
let average = r["average"].as_f64().filter(|a| (1.0..=5.0).contains(a))?;
let count = r["count"].as_u64().filter(|&c| c > 0)?;
index::valid_id(id).then(|| {
(
id.clone(),
Rating {
average: average as f32,
count,
},
)
})
})
.collect()
}
/// The browser's kind for a registry kind; None for one the launcher doesn't install yet
/// (kinds newer than this launcher).
fn kind_of(kind: &index::Kind) -> Option<Kind> {
@@ -245,7 +337,9 @@ impl Source for Registry {
}
fn search(&self, query: &Query) -> Result<Vec<Project>, String> {
Ok(search(&*index()?, query))
let found = search(&*index()?, query);
refresh_ratings();
Ok(found)
}
fn project(&self, id: &str) -> Result<Project, String> {
@@ -275,6 +369,7 @@ impl Source for Registry {
}
fn details(&self, project: &Project) -> Result<Details, String> {
refresh_ratings();
let index = index()?;
let p = index
.project(&project.id)
+28
View File
@@ -228,3 +228,31 @@ fn versions_for_a_newer_horizon_are_not_installed() {
assert_eq!(v.blocked.as_deref(), Some(NEEDS_NEWER));
assert_eq!(version_of(&index, p, &p.versions[0], Some(1)).blocked, None);
}
#[test]
fn ratings_keep_only_what_is_well_formed() {
let text = r#"{"projects": {
"zoom": {"average": 4.25, "count": 12},
"minimap": {"average": 9, "count": 3},
"keystrokes": {"average": 3, "count": 0},
"Bad Id": {"average": 4, "count": 1},
"fps_overlay": {"average": "5", "count": 1}
}}"#;
let ratings = parse_ratings(text);
assert_eq!(
ratings.into_iter().collect::<Vec<_>>(),
[(
"zoom".to_owned(),
Rating {
average: 4.25,
count: 12
}
)]
);
assert!(parse_ratings("not json").is_empty());
assert!(parse_ratings(r#"{"projects": []}"#).is_empty());
assert_eq!(
rate_url("zoom"),
"https://playhorizon.dev/api/mods/auth/login?back=/mods/zoom"
);
}
+33
View File
@@ -175,6 +175,34 @@ fn count(n: u64) -> String {
}
}
/// A registry project's rating: five pixel stars, the mean's nearest whole number of them lit, the
/// mean and how many; None while the ratings aren't known.
pub(super) fn stars(id: &str, t: &theme::Theme) -> Option<Div> {
let row = div().flex().items_center().gap(px(2.));
Some(match horizon::rating(id)? {
None => row.text_color(t.muted).child("No ratings yet"),
Some(r) => {
let lit = r.average.round() as usize;
row.children((0..5).map(|i| {
blocks::icon(
"star",
12.,
if i < lit {
t.warn
} else {
t.muted.opacity(0.6)
},
)
}))
.child(div().ml(px(4.)).child(format!(
"{:.1} ({})",
r.average,
count(r.count)
)))
}
})
}
/// A checksum as the page shows it: the algorithm and the first and last hex digits.
fn short_sum(checksum: &str) -> String {
let (algo, hex) = checksum.split_once(':').unwrap_or(("", checksum));
@@ -978,6 +1006,11 @@ impl Launcher {
.flex_col()
.child(div().truncate().child(p.summary.clone()))
.child(by.join(" · "))
.children(
(p.source == SourceId::Horizon)
.then(|| stars(&p.id, &t))
.flatten(),
)
.children(
support
.reason()
+15
View File
@@ -491,6 +491,21 @@ impl Launcher {
.gap(px(24.))
.child(div().flex().child(back))
.child(head);
// Registry projects are rated on the site, signed in with GitHub (one rating per account).
if p.source == SourceId::Horizon {
let url = browser::horizon::rate_url(&p.id);
page = page.child(
div()
.flex()
.items_center()
.gap(px(16.))
.children(super::mods_page::stars(&p.id, &t).map(|s| text(theme::UI).child(s)))
.child(
button("mods-rate", "Sign in with GitHub to rate", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.open_url(&url)),
),
);
}
if let Some(why) = support.reason() {
page = page.child(
text(theme::UI)