maybe improve visual code bundling

This commit is contained in:
Julian Chang 2026-08-07 22:24:10 +07:00
parent e2064a5784
commit 9603e76cea
No known key found for this signature in database
222 changed files with 1635 additions and 3962 deletions

View file

@ -132,25 +132,28 @@ jobs:
rm -f "$out/OneClient.icns"
test -f "$out/Assets.car" || { echo "::error::actool produced no Assets.car"; exit 1; }
- name: (Windows) Bundle VC++ redistributable into the installer
- name: (Windows) Stage app-local VC++ runtime
if: matrix.platform == 'windows-latest'
shell: bash
run: python3 distribution/windows/bundle-vcredist.py
run: python3 distribution/windows/bundle-crt.py
- name: (Windows) Verify bundled redistributable is Microsoft-signed
- name: (Windows) Verify staged runtime is Microsoft-signed
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$sig = Get-AuthenticodeSignature 'target/vcredist/vc_redist.x64.exe'
Write-Host "Authenticode status: $($sig.Status)"
Write-Host "Signer: $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') {
Write-Error "vc_redist.x64.exe signature is $($sig.Status), refusing to bundle it"
exit 1
}
if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
Write-Error "vc_redist.x64.exe is not signed by Microsoft: $($sig.SignerCertificate.Subject)"
exit 1
$dlls = @(Get-ChildItem 'target/crt/*.dll')
if ($dlls.Count -eq 0) { Write-Error 'no CRT DLLs staged'; exit 1 }
foreach ($dll in $dlls) {
$sig = Get-AuthenticodeSignature $dll.FullName
Write-Host "$($dll.Name): $($sig.Status) $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') {
Write-Error "$($dll.Name) signature is $($sig.Status), refusing to ship it"
exit 1
}
if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
Write-Error "$($dll.Name) is not signed by Microsoft"
exit 1
}
}
- name: Package (unsigned)
@ -189,6 +192,33 @@ jobs:
cargo generate-rpm -p packages/oneclient_app
cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/"
- name: (Windows) Smoke-test installer layout
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dest = 'C:\oneclient-smoke'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
$exe = Resolve-Path (Get-ChildItem '${{ steps.outdir.outputs.dir }}/*-setup.exe')[0].FullName
# NSIS: /S is silent, /D= sets the install dir and must be the last arg.
$p = Start-Process -FilePath $exe -ArgumentList '/S', "/D=$dest" -Wait -PassThru
if ($p.ExitCode -ne 0) { Write-Error "installer exited $($p.ExitCode)"; exit 1 }
Write-Host '--- installed layout ---'
Get-ChildItem -Recurse $dest | ForEach-Object { Write-Host $_.FullName }
if (-not (Test-Path "$dest\oneclient_app.exe")) {
Write-Error "oneclient_app.exe is not at the root of $dest"
exit 1
}
foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) {
if (-not (Test-Path (Join-Path $dest $dll.Name))) {
Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work"
exit 1
}
}
Write-Host "OK: $($(Get-ChildItem 'target/crt/*.dll').Count) CRT DLLs sit beside oneclient_app.exe"
# Upload debug info (dSYM / .dwp / .pdb produced by the release profile's
# split-debuginfo) to Sentry so nightly crash reports get real file/function/
# line stack traces instead of raw addresses. Runs on every main push, so

View file

@ -345,25 +345,28 @@ jobs:
rm -f "$out/OneClient.icns"
test -f "$out/Assets.car" || { echo "::error::actool produced no Assets.car"; exit 1; }
- name: (Windows) Bundle VC++ redistributable into the installer
- name: (Windows) Stage app-local VC++ runtime
if: matrix.platform == 'windows-latest'
shell: bash
run: python3 distribution/windows/bundle-vcredist.py
run: python3 distribution/windows/bundle-crt.py
- name: (Windows) Verify bundled redistributable is Microsoft-signed
- name: (Windows) Verify staged runtime is Microsoft-signed
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$sig = Get-AuthenticodeSignature 'target/vcredist/vc_redist.x64.exe'
Write-Host "Authenticode status: $($sig.Status)"
Write-Host "Signer: $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') {
Write-Error "vc_redist.x64.exe signature is $($sig.Status), refusing to bundle it"
exit 1
}
if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
Write-Error "vc_redist.x64.exe is not signed by Microsoft: $($sig.SignerCertificate.Subject)"
exit 1
$dlls = @(Get-ChildItem 'target/crt/*.dll')
if ($dlls.Count -eq 0) { Write-Error 'no CRT DLLs staged'; exit 1 }
foreach ($dll in $dlls) {
$sig = Get-AuthenticodeSignature $dll.FullName
Write-Host "$($dll.Name): $($sig.Status) $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') {
Write-Error "$($dll.Name) signature is $($sig.Status), refusing to ship it"
exit 1
}
if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
Write-Error "$($dll.Name) is not signed by Microsoft"
exit 1
}
}
- name: Package
@ -441,6 +444,33 @@ jobs:
shell: bash
run: echo "path=$(ls ${{ steps.outdir.outputs.dir }}/*-setup.exe | head -n1)" >> "$GITHUB_OUTPUT"
- name: (Windows) Smoke-test installer layout
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dest = 'C:\oneclient-smoke'
Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue
$exe = Resolve-Path '${{ steps.win_installer.outputs.path }}'
# NSIS: /S is silent, /D= sets the install dir and must be the last arg.
$p = Start-Process -FilePath $exe -ArgumentList '/S', "/D=$dest" -Wait -PassThru
if ($p.ExitCode -ne 0) { Write-Error "installer exited $($p.ExitCode)"; exit 1 }
Write-Host '--- installed layout ---'
Get-ChildItem -Recurse $dest | ForEach-Object { Write-Host $_.FullName }
if (-not (Test-Path "$dest\oneclient_app.exe")) {
Write-Error "oneclient_app.exe is not at the root of $dest"
exit 1
}
foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) {
if (-not (Test-Path (Join-Path $dest $dll.Name))) {
Write-Error "$($dll.Name) did not land beside oneclient_app.exe — cargo-packager put resources elsewhere, so app-local CRT loading will not work"
exit 1
}
}
Write-Host "OK: $($(Get-ChildItem 'target/crt/*.dll').Count) CRT DLLs sit beside oneclient_app.exe"
- name: (Windows) Upload unsigned installer artifact
if: matrix.platform == 'windows-latest'
id: win_upload_unsigned

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
import argparse
import re
import shutil
import subprocess
import sys
from pathlib import Path
BEGIN_RES = "# >>> CRT_RESOURCES >>>"
END_RES = "# <<< CRT_RESOURCES <<<"
BEGIN_SEC = "; >>> VC_REDIST_SECTION >>>"
END_SEC = "; <<< VC_REDIST_SECTION <<<"
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CARGO_TOML = REPO_ROOT / "packages" / "oneclient_app" / "Cargo.toml"
DEFAULT_OUT = REPO_ROOT / "target" / "crt"
VSWHERE = Path(
r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
)
CRT_GLOB = r"VC\Redist\MSVC\**\x64\Microsoft.VC*.CRT\*.dll"
SECTION_REPLACEMENT = """; >>> VC_REDIST_SECTION >>>
; <<< VC_REDIST_SECTION <<<"""
def region(begin: str, end: str) -> re.Pattern:
return re.compile(
r"^[ \t]*" + re.escape(begin) + r".*?^[ \t]*" + re.escape(end) + r"[ \t]*$",
re.DOTALL | re.MULTILINE,
)
REGION_RES = region(BEGIN_RES, END_RES)
REGION_SEC = region(BEGIN_SEC, END_SEC)
def find_crt_dir() -> Path:
"""Locate the newest x64 Microsoft.VC*.CRT redist directory via vswhere."""
if not VSWHERE.is_file():
sys.exit(f"error: vswhere not found at {VSWHERE}; pass --crt-dir explicitly")
out = subprocess.run(
[str(VSWHERE), "-latest", "-products", "*", "-find", CRT_GLOB],
capture_output=True,
text=True,
check=True,
).stdout
dirs = {Path(line).parent for line in out.splitlines() if line.strip()}
if not dirs:
sys.exit(f"error: vswhere found no DLLs matching {CRT_GLOB}")
newest = sorted(dirs, key=lambda p: p.parts)[-1]
print(f"CRT redist dir: {newest}")
return newest
def collect(crt_dir: Path, out_dir: Path) -> list[Path]:
dlls = sorted(crt_dir.glob("*.dll"))
if not dlls:
sys.exit(f"error: no DLLs in {crt_dir}")
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
copied = []
for dll in dlls:
dest = out_dir / dll.name
shutil.copy2(dll, dest)
copied.append(dest)
print(f" {dll.name} ({dest.stat().st_size} bytes)")
return copied
def patch(cargo_toml: Path, dlls: list[Path]) -> None:
text = cargo_toml.read_text(encoding="utf-8")
for pat, name in ((REGION_RES, "CRT_RESOURCES"), (REGION_SEC, "VC_REDIST_SECTION")):
if not pat.search(text):
sys.exit(f"error: {name} region not found in {cargo_toml}")
entries = "\n".join(
f' {{ src = "{str(d).replace(chr(92), chr(92) * 2)}", target = "{d.name}" }},'
for d in dlls
)
block = (
f" {BEGIN_RES}\n"
f" # Generated by distribution/windows/bundle-crt.py -- do not edit by hand.\n"
f"{entries}\n"
f" {END_RES}"
)
text = REGION_RES.sub(lambda _: block, text, count=1)
text = REGION_SEC.sub(lambda _: SECTION_REPLACEMENT, text, count=1)
cargo_toml.write_text(text, encoding="utf-8")
print(f"patched {cargo_toml} with {len(dlls)} app-local CRT DLLs")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--cargo-toml", type=Path, default=DEFAULT_CARGO_TOML)
ap.add_argument("--out-dir", type=Path, default=DEFAULT_OUT)
ap.add_argument(
"--crt-dir",
type=Path,
help="Microsoft.VC*.CRT directory to copy from (default: ask vswhere)",
)
args = ap.parse_args()
crt_dir = args.crt_dir.resolve() if args.crt_dir else find_crt_dir()
dlls = collect(crt_dir, args.out_dir.resolve())
patch(args.cargo_toml.resolve(), dlls)
if __name__ == "__main__":
main()

View file

@ -1,81 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import re
import sys
import urllib.request
from pathlib import Path
REDIST_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
BEGIN = "; >>> VC_REDIST_INSTALL >>>"
END = "; <<< VC_REDIST_INSTALL <<<"
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CARGO_TOML = REPO_ROOT / "packages" / "oneclient_app" / "Cargo.toml"
DEFAULT_REDIST = REPO_ROOT / "target" / "vcredist" / "vc_redist.x64.exe"
BLOCK = """ {begin}
; Generated by distribution/windows/bundle-vcredist.py -- do not edit by hand.
DetailPrint "Installing Microsoft Visual C++ Redistributable (x64)..."
InitPluginsDir
File "/oname=$PLUGINSDIR\\\\vc_redist.x64.exe" "{redist}"
ExecWait '"$PLUGINSDIR\\\\vc_redist.x64.exe" /quiet /norestart' $R3
${{If}} $R3 == 3010
SetRebootFlag true
${{EndIf}}
{end}"""
REGION = re.compile(
r"^[ \t]*" + re.escape(BEGIN) + r".*?^[ \t]*" + re.escape(END) + r"[ \t]*$",
re.DOTALL | re.MULTILINE,
)
def download(dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"downloading {REDIST_URL} -> {dest}")
with urllib.request.urlopen(REDIST_URL) as resp, dest.open("wb") as out:
while chunk := resp.read(1 << 20):
out.write(chunk)
data = dest.read_bytes()
if data[:2] != b"MZ":
sys.exit(f"error: {dest} is not a PE image (no MZ header)")
if len(data) < 10 * 1024 * 1024:
sys.exit(f"error: {dest} is only {len(data)} bytes, expected >10MB")
print(f" {len(data)} bytes, sha256={hashlib.sha256(data).hexdigest()}")
def patch(cargo_toml: Path, redist: Path) -> None:
text = cargo_toml.read_text(encoding="utf-8")
if not REGION.search(text):
sys.exit(f"error: {BEGIN} .. {END} region not found in {cargo_toml}")
block = BLOCK.format(begin=BEGIN, end=END, redist=str(redist).replace("\\", "\\\\"))
cargo_toml.write_text(REGION.sub(lambda _: block, text, count=1), encoding="utf-8")
print(f"patched {cargo_toml} to embed {redist}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--cargo-toml", type=Path, default=DEFAULT_CARGO_TOML)
ap.add_argument("--redist", type=Path, default=DEFAULT_REDIST)
ap.add_argument(
"--skip-download",
action="store_true",
help="reuse an already-downloaded --redist instead of fetching it",
)
args = ap.parse_args()
redist = args.redist.resolve()
if args.skip_download:
if not redist.is_file():
sys.exit(f"error: --skip-download given but {redist} does not exist")
else:
download(redist)
patch(args.cargo_toml.resolve(), redist)
if __name__ == "__main__":
main()

View file

@ -77,7 +77,11 @@ icons = [
"icons/icon.icns",
"icons/icon.ico",
]
resources = [{ src = "icons/generated/Assets.car", target = "Assets.car" }]
resources = [
{ src = "icons/generated/Assets.car", target = "Assets.car" },
# >>> CRT_RESOURCES >>>
# <<< CRT_RESOURCES <<<
]
[package.metadata.packager.macos]
minimum-system-version = "10.14"
@ -104,6 +108,7 @@ Section "-OneClientTauriMigration"
Delete /REBOOTOK "$INSTDIR\\OneClient.exe"
SectionEnd
; >>> VC_REDIST_SECTION >>>
Section "-VCRedist"
SetRegView 64
ReadRegDWORD $R0 HKLM "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\X64" "Installed"
@ -115,14 +120,13 @@ Section "-VCRedist"
Goto vcredist_done
${EndIf}
; >>> VC_REDIST_INSTALL >>>
${If} $PassiveMode != 1
MessageBox MB_OK|MB_ICONEXCLAMATION "OneClient needs the Microsoft Visual C++ Redistributable (x64) 14.40 or newer. Install it from https://aka.ms/vs/17/release/vc_redist.x64.exe, then run this installer again."
MessageBox MB_OK|MB_ICONEXCLAMATION "This build of OneClient needs the Microsoft Visual C++ Redistributable (x64) 14.40 or newer. Install it from https://aka.ms/vs/17/release/vc_redist.x64.exe, then run this installer again."
${EndIf}
; <<< VC_REDIST_INSTALL <<<
vcredist_done:
SectionEnd
; <<< VC_REDIST_SECTION <<<
"""
[package.metadata.packager.deb]

View file

@ -1,5 +1,4 @@
fn main() {
// Embeds the app icon and VERSIONINFO into the Windows executable.
#[cfg(windows)]
{
let mut res = winresource::WindowsResource::new();

View file

@ -54,7 +54,6 @@ impl Component for AccountPanel {
});
let progress = intro.read().value();
// active account first, then the rest
let mut ordered: Vec<MinecraftAccount> = Vec::new();
if let Some(account) = default_account.clone() {
ordered.push(account);

View file

@ -26,7 +26,6 @@ pub enum ButtonSize {
Icon,
}
/// How much a disabled button is faded out.
const DISABLED_OPACITY: f32 = 0.5;
struct ButtonColors {
@ -198,8 +197,7 @@ impl Button {
self
}
/// The name a button carrying only an icon goes by, since there is no label
/// for accessibility to read out.
/// Accessible name for icon-only buttons which have no label to read out
pub fn alt(mut self, alt: impl Into<Box<str>>) -> Self {
self.alt = Some(alt.into());
self

View file

@ -22,7 +22,6 @@ impl ValueUnit {
const DEFAULT_HEIGHT: f32 = 150.;
const Y_AXIS_WIDTH: f32 = 44.;
/// Slice colors, cycled when there are more slices than entries.
pub const SLICE_COLORS: [Color; 10] = [
Color::from_rgb(43, 75, 255),
Color::from_rgb(27, 217, 106),
@ -41,11 +40,9 @@ pub fn slice_color(i: usize) -> Color {
}
const PIE_SIZE: f32 = 190.;
/// Inner hole as a fraction of the radius, making it a donut.
/// Fraction of the radius
const PIE_HOLE: f32 = 0.58;
/// A donut chart. Hovering a slice reports it via `on_hover` so callers can
/// sync an external legend; the hovered slice is also expanded slightly.
pub struct PieChart {
values: Vec<i64>,
labels: Vec<String>,
@ -70,7 +67,6 @@ impl PieChart {
self
}
/// Shares the hovered slice index with the caller (e.g. to highlight a legend row).
pub fn hovered(mut self, hovered: State<Option<usize>>) -> Self {
self.hovered = Some(hovered);
self
@ -114,7 +110,6 @@ impl Component for PieChart {
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Fill);
// With nothing recorded, draw an empty track so the ring is still there.
if total <= 0 {
paint.set_color(SkColor::from(colors::component_bg_hover()));
ctx.canvas.draw_circle((cx, cy), radius - 5.0, &paint);
@ -130,7 +125,6 @@ impl Component for PieChart {
continue;
}
let sweep = (v as f32 / total as f32) * 360.0;
// Grow the hovered slice a touch, and dim the rest.
let is_active = Some(i) == active;
let r = if is_active { radius } else { radius - 5.0 };
let color = slice_color(i);
@ -151,7 +145,6 @@ impl Component for PieChart {
start += sweep;
}
// Punch the donut hole using the card background.
paint.set_color(SkColor::from(colors::page_elevated()));
ctx.canvas
.draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint);
@ -189,8 +182,7 @@ impl Component for PieChart {
.height(Size::fill())
.position(Position::new_absolute())
.interactive(false)
// The canvas defaults to auto-sizing and has no children, so
// without an explicit size it measures 0x0 and draws nothing.
// Childless canvas auto-sizes to 0x0 without an explicit size
.child(canvas(render_cb).width(Size::fill()).height(Size::fill())),
)
.child(
@ -199,11 +191,9 @@ impl Component for PieChart {
.height(Size::fill())
.position(Position::new_absolute())
.interactive(false)
// Sibling absolute rects on an equal layer paint in undefined
// order, so pin the readout above the canvas.
// Sibling absolute rects on an equal layer paint in undefined order
.layer(Layer::Relative(1))
.center()
// Keeps the readout inside the donut hole (Gaps is vertical, horizontal).
.padding(Gaps::new_symmetric(0., size * 0.25))
.child(
rect()
@ -235,7 +225,6 @@ impl Component for PieChart {
}
}
/// Maps a pointer position inside the chart box to a slice index.
fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option<usize> {
let total: i64 = values.iter().sum();
if total <= 0 {
@ -251,7 +240,7 @@ fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option<usize> {
return None;
}
// Angle measured clockwise from 12 o'clock, matching the drawing order.
// Clockwise from 12 o'clock matching the drawing order
let mut angle = dy.atan2(dx).to_degrees() + 90.0;
if angle < 0.0 {
angle += 360.0;
@ -451,15 +440,13 @@ impl Component for BarChart {
}
}
/// How many bars to skip between shown labels so text never overlaps.
/// Derived from the measured plot width: a wider chart (e.g. maximised window)
/// fits more labels and returns a smaller stride.
/// Bars to skip between shown labels derived from measured plot width
fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize {
let n = labels.len();
if n == 0 {
return 1;
}
// Before the first measurement, thin conservatively to avoid an overlap flash.
// Before the first measurement thin conservatively to avoid an overlap flash
if plot_width <= 0.0 {
return (n / 12).max(1);
}
@ -470,7 +457,7 @@ fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize {
.max()
.unwrap_or(0)
.max(1);
// ~6.2px per char at font_size 10, plus breathing room between labels.
// ~6.2px per char at font_size 10
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0;
let max_labels = (plot_width / slot_px).floor().max(1.0) as usize;

View file

@ -34,7 +34,6 @@ impl UpdateTab {
}
}
/// Empty-state copy; the tab labels don't read as sentences on their own.
fn empty_text(self) -> &'static str {
match self {
UpdateTab::Updates => "Nothing was updated.",
@ -68,7 +67,6 @@ impl UpdateTab {
}
}
/// One cluster's resolved package names for the active tab.
struct ClusterGroup {
cluster_id: i64,
cluster_name: String,
@ -86,9 +84,7 @@ impl Component for ClusterUpdatePopup {
let summaries = snapshot.cluster_update.clone();
// Resolve pretty display names through the package-meta cache. Hooks
// must run unconditionally, so gather ids (empty when no summary) and
// query every remote provider before the early return below.
// Hooks must run unconditionally so query every provider before the early return below
let all_items: Vec<&ClusterUpdateItem> = summaries
.iter()
.flatten()
@ -170,8 +166,7 @@ fn content(
let total: usize = summaries.iter().map(|s| s.total()).sum();
let active_tab = *active.read();
// A batch sync can touch several clusters; a single-cluster update keeps the
// plain wording and the "Open cluster" shortcut in the footer.
// A batch sync can touch several clusters single-cluster keeps the footer shortcut
let single = match summaries {
[only] => Some((only.cluster_id, only.cluster_name.clone())),
_ => None,
@ -189,8 +184,7 @@ fn content(
),
};
// Tab row: every category always shown with a count pill, even at zero, so
// the modal shape stays stable across clusters.
// Every category is shown even at zero so the modal shape stays stable
let tabs = TabBar::new()
.width(Size::fill())
.height(Size::px(30.))
@ -203,8 +197,7 @@ fn content(
.on_press(move |_| *set.write() = tab)
}));
// Clusters with nothing in the active category drop out, so the list never
// shows an empty section header.
// Clusters empty in the active category drop out avoiding empty section headers
let groups: Vec<ClusterGroup> = summaries
.iter()
.filter_map(|summary| {
@ -344,9 +337,7 @@ fn change_list(
scroll.into_element()
}
/// Section header naming the cluster a run of changes belongs to. Pressing it
/// jumps straight to that cluster, which is the multi-cluster stand-in for the
/// footer's "Open cluster" shortcut.
/// Multi-cluster stand-in for the footer's "Open cluster" shortcut
fn cluster_header(
group: &ClusterGroup,
first: bool,

View file

@ -78,7 +78,7 @@ impl ContextMenu {
impl PartialEq for ContextMenu {
fn eq(&self, _other: &Self) -> bool {
// the menu is rebuilt from scratch whenever it is reopened (kind of a bad idea but who cares lol)
// Always unequal the menu is rebuilt from scratch whenever it is reopened
false
}
}

View file

@ -9,9 +9,7 @@ use crate::AppAssets;
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
use crate::layout::HOME_BACKGROUND_ASSET;
/// The size the cluster cards ask for, and so the one variant of a version's
/// art that is usually already cached by the time anything else wants it.
/// Full-screen art stands in with it while its own download is in flight.
/// The size cluster cards request so it is usually already cached when anything else wants it
pub const ART_PREVIEW_EDGE: u32 = 512;
#[derive(PartialEq, Clone)]
@ -61,13 +59,7 @@ impl DynamicArt {
self
}
/// Stand in with a smaller variant of the same art until the full-size one
/// has downloaded.
///
/// A full-size background is a few hundred KB and the cluster cards have
/// usually already cached the small one, so this puts the right picture on
/// screen right away and sharpens it later, instead of sitting on the
/// built-in fallback for the length of a download.
/// Stand in with a smaller cached variant until the full-size art downloads
#[must_use]
pub fn preview_edge(mut self, preview_edge: u32) -> Self {
self.preview_edge = Some(preview_edge);
@ -98,8 +90,7 @@ pub fn use_art_bytes(
let image_query = use_cached_image(art_url.clone(), max_edge);
// Subscribed unconditionally to keep the hook order stable. Without a
// preview size it is the same query as the one above, so it costs nothing.
// Subscribed unconditionally to keep hook order stable same query when no preview size
let preview_edge = preview_edge.unwrap_or(max_edge);
let preview_query = use_cached_image(art_url.clone(), preview_edge);

View file

@ -1,14 +1,5 @@
//! Window-wide file drops.
//!
//! [`FileDropOverlay`] is mounted by the app shell and owns every drop: while
//! files hover the window it shows a veil, and once they land it asks which
//! cluster they belong to and what each one should be installed as. No view
//! handles drops itself, so dropping on the mods page and dropping on the home
//! screen behave identically.
//!
//! Freya emits one `FileDrop` event per file, all within the same batch, so the
//! shell handler accumulates them and the prompt's first render already sees
//! the whole drop.
//! Freya emits one `FileDrop` event per file within a single batch so the shell
//! handler accumulates them before the prompt first renders
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@ -32,16 +23,13 @@ const FILE_ROW_H: f32 = 34.;
const FILE_LIST_MAX_ROWS: usize = 5;
const TYPE_DROPDOWN_W: f32 = 108.;
/// Content types a dropped entry can be imported as. Worlds are directories and
/// modpacks/datapacks have no view of their own, so they're deliberately absent.
/// Worlds are directories and modpacks/datapacks have no view so both are excluded
const IMPORTABLE: [ContentType; 3] = [
ContentType::Mod,
ContentType::ResourcePack,
ContentType::Shader,
];
/// Names that give away a shaderpack. Only a starting guess; every row has a
/// dropdown, so a wrong sniff costs the user one click.
const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"];
fn content_label(content_type: ContentType) -> &'static str {
@ -62,9 +50,7 @@ fn file_name(path: &Path) -> String {
.unwrap_or_else(|| path.display().to_string())
}
/// Best guess at what a dropped entry is. A `.jar` is only ever a mod;
/// everything else is ambiguous, so the page the drop landed on wins, then a
/// name sniff, then the most common case.
/// Precedence `.jar` is always a mod then the current route then a name sniff
fn infer_content_type(path: &Path, route_type: Option<ContentType>) -> ContentType {
if extension(path).as_deref() == Some("jar") {
return ContentType::Mod;
@ -89,11 +75,7 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> {
}
}
/// Renders the shell's drop affordance and, once files land, the prompt asking
/// where they should go.
///
/// The state lives in the shell (which owns the bubbling `on_file_drop`
/// handler) and is passed down so the shell itself never re-renders on hover.
/// State lives in the shell (owner of the bubbling `on_file_drop`) so it never re-renders on hover
#[derive(PartialEq)]
pub struct FileDropOverlay {
pub hovering: State<bool>,
@ -120,7 +102,6 @@ impl Component for FileDropOverlay {
}
}
/// The full-window "drop anywhere" affordance shown while files are dragged over.
#[derive(PartialEq)]
struct DropVeil;
@ -140,7 +121,7 @@ impl Component for DropVeil {
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
// Purely decorative: the drop must reach the shell handler beneath.
// Purely decorative the drop must reach the shell handler beneath
.interactive(false)
.center()
.opacity(progress)
@ -181,7 +162,6 @@ impl Component for DropVeil {
}
}
/// Brand-tinted circle holding an icon, used by the veil card and the dialog header.
fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement {
rect()
.width(Size::px(size))
@ -211,8 +191,7 @@ impl Component for DropPrompt {
let target = route_target(&route);
let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek());
let selected_cluster = use_state(move || initial_cluster);
// Only the rows the user actually changed. Everything else falls back to
// the inferred type, so a second drop landing mid-prompt still gets one.
// Absent entries fall back to the inferred type covering drops landing mid-prompt
let overrides = use_state(HashMap::<PathBuf, ContentType>::new);
let clusters: Vec<Cluster> = settled_or_loading(&clusters_query).unwrap_or_default();
@ -267,7 +246,7 @@ fn dialog_header(title: String, subtitle: String) -> impl IntoElement {
.horizontal()
.width(Size::fill())
// Without Content::Flex the flexed text column doesn't resolve and the
// badge stops centring against it.
// badge stops centring against it
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
@ -327,7 +306,7 @@ fn prompt_body(
dispatch: crate::Actions,
mut pending: State<Vec<PathBuf>>,
) -> Element {
// The remembered cluster may have been deleted while the prompt was open.
// The remembered cluster may have been deleted while the prompt was open
let cluster_id = selected_cluster
.read()
.filter(|id| clusters.iter().any(|c| c.id == *id))

View file

@ -1,15 +1,5 @@
//! The fallback renderer for prompts nothing else claims.
//!
//! [`Prompt`](oneclient_events::Prompt) lets any part of the core ask the user a
//! question, and two overlays render particular ones richly: the Java one and
//! the updater's. Everything else used to render as nothing at all — the core
//! would sit on `ask()` forever waiting for a reply no one could give, and
//! because the app holds a single pending prompt at a time, the stuck one also
//! kept every later prompt (the Java one included) from appearing.
//!
//! So this is a safety net, not a style: anything not claimed above is rendered
//! from the prompt's own choices, and a new prompt in the core needs no UI work
//! to be answerable.
//! Fallback renderer for prompts no dedicated overlay claims
//! Without it an unrendered prompt leaves the core blocked on `ask()` blocking all later prompts
use freya::prelude::*;
use oneclient_events::{Answer, ChoiceInput, ChoiceStyle};
@ -24,8 +14,6 @@ use crate::updater::UPDATE_CHOICE_INSTALL;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
/// Prompts with an overlay of their own, recognised the same way those overlays
/// recognise themselves: by the choice they offer.
fn claimed_elsewhere(prompt: &PendingPromptView) -> bool {
prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL)
}
@ -35,8 +23,7 @@ pub struct GenericPromptOverlay;
impl Component for GenericPromptOverlay {
fn render(&self) -> impl IntoElement {
// Hooks run unconditionally, before any early return, so the hook count
// stays stable whether or not a prompt is pending.
// Hooks run unconditionally before any early return to keep the hook count stable
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
@ -79,9 +66,7 @@ impl Component for GenericPromptOverlay {
};
buttons = buttons.child(button.on_press(move |_| match &input {
// A folder choice cannot be answered until the user has picked
// one, so the answer is sent from the dialog's callback rather
// than from the press.
// Answered from the dialog callback not the press since no folder is picked yet
Some(ChoiceInput::Folder { title }) => {
let dispatch = dispatch.clone();
let title = title.clone();
@ -97,9 +82,7 @@ impl Component for GenericPromptOverlay {
}
});
}
// A selection needs a picker only the prompt's own overlay could
// supply. Answering without one is still better than leaving the
// core waiting forever; the caller sees `selection() == None`.
// No picker here answering with `selection() == None` beats blocking the core
Some(ChoiceInput::Selection { hint }) => {
tracing::warn!(
choice = id,
@ -179,10 +162,6 @@ mod tests {
#[test]
fn every_other_prompt_falls_through_to_the_generic_overlay() {
// The bug this overlay exists for: a prompt whose choices no overlay
// recognised rendered as nothing, the core waited on `ask()` forever,
// and the stuck prompt blocked every later one. Any new prompt in the
// core has to land here without UI work.
for choices in [
vec![Choice::new("launch", "Launch anyway")],
vec![Choice::primary("verify", "Verify and repair")],

View file

@ -38,7 +38,7 @@ impl Icon {
self
}
/// Rotation in degrees, clockwise.
/// Degrees clockwise
pub fn rotate(mut self, degrees: f32) -> Self {
self.rotation = Some(degrees);
self
@ -54,7 +54,6 @@ impl Component for Icon {
.show_loader(false)
.width(Size::px(self.size_px))
.height(Size::px(self.size_px))
// .fill(color)
.map(self.color, |svg, color| svg.color(color))
.map(self.rotation, |svg, degrees| svg.rotation(degrees))
}

View file

@ -15,9 +15,7 @@ fn providers() -> Vec<(JavaVendor, &'static str)> {
]
}
/// Reusable "Install Java" picker. Lists downloadable runtimes per provider and
/// calls `on_install` with the chosen `(vendor, major)`. When `suggested` is set,
/// the matching major is pulled to the top and highlighted.
/// Calls `on_install` with the chosen `(vendor, major)`
#[derive(PartialEq)]
pub struct JavaInstallManager {
suggested: Option<u32>,
@ -68,7 +66,6 @@ impl Component for JavaInstallManager {
let versions_query = use_provider_versions(vendor.clone());
let (mut versions, loading) = provider_versions(&versions_query);
// Pull the suggested major to the top so it reads as the default choice.
if let Some(major) = suggested {
versions.sort_by_key(|v| v.major != major);
}

View file

@ -14,8 +14,7 @@ pub struct JavaPromptOverlay;
impl Component for JavaPromptOverlay {
fn render(&self) -> impl IntoElement {
// Hooks must run unconditionally, before any early return, so the hook
// count stays stable whether or not a Java prompt is pending.
// Hooks must run unconditionally before any early return to keep the hook count stable
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let mut show_manager = use_state(|| false);
@ -24,14 +23,12 @@ impl Component for JavaPromptOverlay {
return rect().into_element();
};
// Identified by the choices it offers rather than a prompt-kind enum.
// Identified by the choices it offers rather than a prompt-kind enum
if !prompt.has_choice(JAVA_CHOICE_DOWNLOAD) {
return rect().into_element();
}
// The vendor list needs a major to highlight. It is not on the prompt (the
// core only sends display text) so recover it from the folder choice's dialog
// title, which the core builds from the same number.
// The prompt carries no major so recover it from the folder choice's dialog title
let major = prompt
.choice(JAVA_CHOICE_FOLDER)
.and_then(|choice| match &choice.input {
@ -42,13 +39,11 @@ impl Component for JavaPromptOverlay {
})
.unwrap_or(0);
// Download opens the install manager with the required major highlighted.
if *show_manager.read() {
let install = dispatch.clone();
return JavaInstallManager::new()
.suggested(Some(major))
.on_install(move |(vendor, _row_major): (JavaVendor, u32)| {
// The launch needs `major`; the manager only picks the vendor.
install.answer_prompt(
Answer::new(JAVA_CHOICE_DOWNLOAD).with_selection(vendor.to_string()),
);

View file

@ -87,7 +87,6 @@ pub fn use_microsoft_login() -> MicrosoftLogin {
return;
}
// open the browser immediately
platform::open_url(session.auth_url());
finish.mutate(session.clone());
pending_login.set(Some(session));
@ -103,7 +102,7 @@ pub fn use_microsoft_login() -> MicrosoftLogin {
}
});
// is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress
// True once the flow reports progress ensuring the user has seen the popup
let locked = status.as_ref().is_some_and(|s| s.current > 0);
let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading();
let pending = in_flight && !*cancelled.read();
@ -141,10 +140,7 @@ where
| MutationStateData::Loading {
res: Some(Err(err)),
} => {
// A cancellation is the user's own decision arriving back as an
// error. Reporting it would put a red line under a button they
// pressed on purpose, and it would still be there when they start
// the next sign-in.
// Cancellation arrives as an error surfacing it would persist into the next sign-in
if err.is_login_cancelled() {
return None;
}

View file

@ -117,9 +117,7 @@ fn navbar_center() -> impl IntoElement {
})
}
/// Browsing always happens for some cluster, so the navbar picks the one the
/// shell has active and falls back to the most recently played one. With no
/// clusters at all there is nothing to browse for, so it points at Versions.
/// Falls back active cluster then most recently played then Versions when none exist
fn browse_target() -> Route {
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
let active = *use_active_cluster_id().read();
@ -178,8 +176,8 @@ impl Component for NavLink {
.cross_align(Alignment::Center)
.spacing(2.)
.width(Size::px(nav_label.len() as f32 * 10. + 10.))
// TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly.
// This basically fools freya into thinking it indeed has a box and properly handles pointer events
// TODO workaround for a Freya measurement bug a fully transparent background
// measures wrongly so give it alpha 0 red to keep pointer events working
.background(Color::RED.with_a(0))
.a11y_id(a11y_id)
.a11y_focusable(true)
@ -230,18 +228,15 @@ impl Component for NavbarRight {
.map(|account| account.id.to_string())
.unwrap_or_else(|| uuid::Uuid::nil().to_string());
// open notification center
let notif_dispatch = dispatch.clone();
let open_notifications = move |_| {
notif_dispatch.toggle_notification_center();
};
// open account switcher
let open_account_switcher = move |_| {
dispatch.toggle_account_switcher();
};
// open settings
let open_settings = |_| {
let _ = RouterContext::get().push(Route::SettingsLauncher {});
};

View file

@ -279,7 +279,6 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement {
.into_element()
}
/// A right-facing chevron that rotates 90° down when `expanded`, animated.
#[derive(PartialEq)]
struct ChevronToggle {
expanded: bool,

View file

@ -36,7 +36,6 @@ impl OverlayPopup {
self
}
/// Whether to darken everything behind the popup
pub fn backdrop(mut self, backdrop: bool) -> Self {
self.backdrop = backdrop;
self

View file

@ -44,12 +44,9 @@ pub struct PackageEntry {
pub installed: bool,
pub hash: Option<String>,
pub manifest_default: bool,
/// The bundle ships this as a private dependency. Only ever set for bundle
/// rows, and only surfaced when the user asks for hidden packages.
/// Private bundle dependency only set for bundle rows
pub hidden: bool,
/// The launch-time check found a newer version for this artifact. Only ever
/// set for browser-installed content: bundle packages report their staleness
/// through the bundle update flow instead.
/// Only set for browser-installed content bundle packages use the bundle update flow
pub update_available: bool,
}
@ -62,9 +59,7 @@ impl PackageEntry {
self.bundle_name.is_some()
}
/// Whether to mark the row out of date. A bundle package is never marked
/// here even if a row somehow says so, so the two update flows cannot
/// contradict each other on screen.
/// Bundle packages are never marked here so the two update flows cannot contradict
pub fn is_outdated(&self) -> bool {
self.update_available && self.is_remote() && !self.in_bundle()
}
@ -463,8 +458,6 @@ pub fn provider_badge(provider: ProviderId) -> Element {
)
}
/// Says the installed version is behind the provider's newest compatible one.
/// The update itself is offered by the launch-time modal, not from here.
fn outdated_badge() -> Element {
accent_badge(
Icon::new(IconType::RefreshCw01)

View file

@ -1,12 +1,4 @@
//! The launch-time "these packages are out of date" modal.
//!
//! Only ever shows packages the user installed from the browser; bundle content
//! has its own flow and is never listed here.
//!
//! Every row carries its own dropdown, so a user who wants a single mod moved
//! forward is not made to take the rest with it. Nothing happens until Proceed:
//! the choices are collected first and applied in one pass, which is why the
//! rows stay put while they are being answered.
//! Only lists packages installed from the browser bundle content has its own flow
use std::collections::HashMap;
@ -30,12 +22,7 @@ const CHOICE_DROPDOWN_W: f32 = 150.;
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
/// What Proceed should do with one row.
///
/// The two skips differ only in whether the answer outlives the modal: a plain
/// skip leaves the package stale and it is offered again next launch, while
/// [`RowChoice::SkipVersion`] records that this exact version was declined and
/// stops it being raised until a newer one appears.
/// `Skip` is re-offered next launch `SkipVersion` persists the decline until a newer version
#[derive(Clone, Copy, Default, PartialEq, Eq)]
enum RowChoice {
#[default]
@ -60,7 +47,6 @@ impl RowChoice {
}
}
/// Identifies a row across the whole modal, which may span several clusters.
type RowKey = (ClusterId, String);
fn row_key(update: &BrowserPackageUpdate) -> RowKey {
@ -75,15 +61,12 @@ impl Component for PackageUpdatePopup {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
// Only the rows the user actually touched. Everything absent is an
// Update, so a group that arrives while the modal is open still gets a
// sensible default without being reconciled into the map.
// Absent entries default to Update so groups arriving mid-modal need no reconciliation
let choices = use_state(HashMap::<RowKey, RowChoice>::new);
let groups = snapshot.package_updates.clone();
// Hooks run unconditionally, so the meta lookup is built from a possibly
// empty id list and happens before the early return below.
// Hooks run unconditionally so this must precede the early return below
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
let ids: Vec<String> = groups
@ -233,8 +216,6 @@ fn content(
RowChoice::Update => {
proceed_dispatch.apply_package_update(update.clone());
}
// Nothing to record: the package stays
// stale and is offered again next launch.
RowChoice::Skip => {}
RowChoice::SkipVersion => proceed_dispatch
.skip_package_update(
@ -349,8 +330,6 @@ impl Component for UpdateRow {
let mut choices = self.choices;
let key = row_key(&update);
// "1.0.2 to 1.1.0" when both are known; a provider that records neither
// still gets a usable row rather than an empty line.
let versions = match (
update.installed_version_name.is_empty(),
update.latest_version_name.is_empty(),

View file

@ -37,8 +37,7 @@ impl Pagination {
self
}
/// Greys out every control without unmounting the pager, for callers that
/// are still loading the page the user just asked for.
/// Greys out controls without unmounting the pager
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self

View file

@ -108,9 +108,8 @@ impl Component for PlayerModel {
let mut drag = use_state(|| None::<(f32, f32, f32, f32)>);
let mut hovering = use_state(|| false);
// Always derive the cursor from both states instead of setting it per event:
// Freya re-emits `pointer enter` on the first move after a press, so a plain
// `Grab` there would drop the grabbing cursor as soon as the drag starts.
// Derive from both states Freya re-emits `pointer enter` after a press which
// would otherwise drop the grabbing cursor as the drag starts
let apply_cursor = move || {
Cursor::set(if drag.peek().is_some() {
CursorIcon::Grabbing
@ -203,8 +202,7 @@ impl Component for PlayerModel {
Platform::get().send(UserEvent::RequestRedraw);
})
// Global so a release outside the widget still ends the drag and,
// since the pointer is no longer over us, restores the default cursor.
// Global so a release outside the widget still ends the drag
.on_global_pointer_press(move |_: Event<PointerEventData>| {
if drag.peek().is_some() {
drag.set(None);

View file

@ -18,7 +18,6 @@ const MAX_CARD_WIDTH_PX: f32 = 480.0;
const CARD_MS: u64 = 460;
const STAGGER_MS: u64 = 85;
const CARD_RISE_PX: f32 = 48.0;
/// Items the intro staggers individually; past this they rise together.
const INTRO_ITEMS: usize = 6;
#[derive(PartialEq)]
@ -34,12 +33,8 @@ impl Component for RecentsRow {
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
let slots = *visible_slots.read();
// Budgeted off the clusters we have, never off the slots we can show. An
// offset is the one thing a card must not change on a frame where the row
// is re-measured: the layout engine treats an offset-only change as "inner
// layout" and hands back the card's *cached* area instead of measuring it,
// so a rerun triggered by the slot count would freeze the surviving cards
// at their pre-resize widths while the new ones come in at the new width.
// Budget off cluster count not slot count an offset-only change makes the layout
// engine reuse a card's cached area freezing survivors at pre-resize widths
let items = intro_items(sorted.len());
let display = sorted.into_iter().take(slots).collect::<Vec<_>>();
@ -82,7 +77,6 @@ impl Component for RecentsRow {
}
}
/// Items to stagger for `clusters` recents, counting the "other versions" tile.
fn intro_items(clusters: usize) -> usize {
(clusters + 1).min(INTRO_ITEMS)
}
@ -93,8 +87,7 @@ fn intro_ms(items: usize) -> u64 {
fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 {
let elapsed = progress * intro_ms(items) as f32;
// Anything past the budget shares the last slot, so every item still lands on
// a finished 1.0 rather than freezing part-way with a leftover offset.
// Items past the budget share the last slot so every one still lands on a finished 1.0
let start = index.min(items.saturating_sub(1)) as f32 * STAGGER_MS as f32;
let local = ((elapsed - start) / CARD_MS as f32).clamp(0., 1.);
1.0 - (1.0 - local).powi(3)
@ -300,14 +293,9 @@ impl Component for OtherVersionsTile {
}
}
/// How many cards fit beside the "other versions" tile at `row_width_px`.
///
/// `n` cards sit in `n` gaps' worth of spacing — `n - 1` between the cards and
/// one before the tile — so `n * (MIN + GAP) + MORE` has to fit. One card is
/// always shown: below that width it just renders narrower than the minimum,
/// which is why the card carries no `min_width`. With one, a stale count during
/// a live resize used to push the row past its bounds and leave a card cut off
/// or stretched until the next measurement landed.
/// `n` cards occupy `n` gaps (n-1 between cards one before the tile) so `n * (MIN + GAP) + MORE`
/// must fit
/// Always returns at least 1 the card carries no `min_width` and just renders narrower
fn recent_card_slots_for_width(row_width_px: f32) -> usize {
if !row_width_px.is_finite() {
return 1;
@ -348,8 +336,6 @@ mod tests {
#[test]
fn every_item_settles_when_the_intro_ends() {
// A card left with a leftover offset keeps changing it, and an offset-only
// change is what makes the layout engine reuse a card's cached width.
for clusters in 0..24 {
let items = intro_items(clusters);
for index in 0..=clusters {
@ -364,8 +350,6 @@ mod tests {
#[test]
fn the_intro_runs_the_same_length_whatever_fits_on_screen() {
// The window width decides how many cards show, so anything the intro is
// measured against has to be independent of it.
assert_eq!(intro_items(3), intro_items(3));
assert_eq!(intro_ms(intro_items(30)), intro_ms(INTRO_ITEMS));
assert_eq!(intro_ms(intro_items(0)), CARD_MS);

View file

@ -150,7 +150,6 @@ impl ScrollArea {
self
}
/// Called every render
pub fn on_ctx(mut self, handler: impl FnMut(ScrollAreaCtx) + 'static) -> Self {
self.on_ctx = Some(EventHandler::new(handler));
self
@ -237,7 +236,6 @@ impl ScrollArea {
let mut drag_start_x = use_state::<Option<(f32, f32)>>(|| None);
let mut shift_held = use_state(|| false);
// used to jump to the top when the reset key changes
let reset_key = self.reset_key;
let mut last_reset = use_state(|| reset_key);
if reset_key.is_some() && *last_reset.peek() != reset_key {
@ -327,7 +325,6 @@ impl ScrollArea {
let do_h = h_delta != 0.0 && can_scroll_h;
let do_v = v_delta != 0.0 && can_scroll_v;
// check if scrollable
if !do_h && !do_v {
return;
}

View file

@ -6,9 +6,8 @@ use freya::prelude::*;
use crate::hooks::use_splash;
use crate::theme::colors;
/// How long the curtain takes to fade away once home is ready.
const FADE_MS: u64 = 460;
/// Safety net: reveal the app even if the "ready" signal never arrives.
/// Safety net reveal the app even if the "ready" signal never arrives
const FALLBACK_MS: u64 = 8000;
#[derive(PartialEq)]
@ -22,8 +21,7 @@ impl Component for SplashCurtain {
return rect().into_element();
}
// Fresh mount whenever the curtain is raised, so `CurtainFade`'s
// animation and timers start from the beginning each time.
// Fresh mount so `CurtainFade`'s animation and timers restart each time
CurtainFade.into_element()
}
}
@ -43,7 +41,7 @@ impl Component for CurtainFade {
.function(Function::Cubic)
});
// Force-reveal after a while if home never reports ready.
// Force-reveal after a while if home never reports ready
use_hook(move || {
let mut home_ready = splash.home_ready;
spawn(async move {
@ -54,7 +52,6 @@ impl Component for CurtainFade {
});
});
// When home is ready, play the fade-out then drop the curtain.
use_side_effect_with_deps(&ready, move |&ready| {
if !ready {
return;

View file

@ -10,12 +10,9 @@ use crate::theme::colors;
const BAR_HEIGHT: f32 = 34.;
const AMBER: Color = Color::from_rgb(191, 122, 26);
/// `Layer::RelativeOverlay(n)` resolves to `n * (i16::MAX / 16)`, so any `n`
/// past 16 pins to `i16::MAX`. Children resolve to `parent + 1`, which saturates
/// to the *same* layer — and a layer is an unordered set, so the bar's own
/// background painted over its contents about as often as not. That is what
/// made the message and the close button come and go. 14 keeps the bar above
/// every popup while leaving its children room to sit in front of it.
/// `Layer::RelativeOverlay(n)` saturates at `n = 16` putting children on the same
/// (unordered) layer as the bar background
/// 14 leaves headroom above every popup
const BAR_LAYER: u8 = 14;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
@ -131,8 +128,7 @@ impl Component for StatusBanner {
let mut close_hover = use_state(|| false);
// Pressing close unmounts the banner from under the pointer, so there is
// no `leave` to put the cursor back.
// Close unmounts the banner from under the pointer so no `leave` fires
use_drop(|| Cursor::set(CursorIcon::default()));
let close = issue.closeable().then(|| {
@ -161,10 +157,8 @@ impl Component for StatusBanner {
.into_element()
});
// The message is centred by a flex spacer on each side, and the close
// button rides the end of the trailing one. It used to be absolutely
// positioned, which pinned it to the top of the content box — inside the
// bar's horizontal padding, so it sat high and well short of the edge.
// Flex spacers centre the message absolute positioning pinned the close button
// to the top of the content box inside the bar's padding
let leading = rect().width(Size::flex(1.0)).height(Size::fill());
let message = rect()

View file

@ -20,9 +20,7 @@ use crate::{
const TOAST_W: f32 = 300.;
const TRAVEL: f32 = 320.;
const TTL_MS: f32 = 5000.;
/// Toasts sit below the navbar rather than on top of it: the startup/bundle-sync
/// progress toast is on screen exactly when Home first appears, and at any
/// smaller offset its card covered the nav links and account controls.
/// Below the navbar a smaller offset let the startup progress toast cover the nav links
const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.;
#[derive(Clone, Copy, PartialEq)]
@ -61,7 +59,7 @@ pub struct Toasts;
impl Component for Toasts {
fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot();
// How many toasts the pointer is over; while any is hovered, every toast's timer pauses.
// While any toast is hovered every toast's timer pauses
let hovering = use_state(|| 0usize);
let entries: Vec<InboxEntry> = snapshot
@ -99,7 +97,6 @@ impl Component for Toasts {
#[derive(PartialEq)]
struct ToastCard {
entry: InboxEntry,
/// Shared across every toast: how many of them the pointer is currently over.
hovering: State<usize>,
key: DiffKey,
}
@ -129,7 +126,6 @@ impl Component for ToastCard {
AnimNum::new(0., 1.).time(260).ease(Ease::Out)
});
// Hovering any toast freezes this bar where it is; leaving runs out the remaining TTL.
let paused = *self.hovering.read() > 0;
let mut was_paused = use_state(|| false);
let mut from_pct = use_state(|| 100.);
@ -166,7 +162,7 @@ impl Component for ToastCard {
let opacity = fade.read().value();
let ttl_pct = ttl_bar_anim.read().value();
// A card can be unmounted while hovered (dismissing it), which emits no `out` event.
// A card can be unmounted while hovered which emits no `out` event
let mut hovering = self.hovering;
let hover_flag = use_hook(|| Rc::new(Cell::new(false)));
let (over_flag, out_flag, drop_flag) =
@ -195,8 +191,8 @@ impl Component for ToastCard {
.width(Size::px(TOAST_W))
.offset_x(offset)
.opacity(opacity)
// `over`/`out` rather than `enter`/`leave`: the latter are exclusive to the deepest
// node, so moving onto the close button would count as leaving the toast.
// `enter`/`leave` are exclusive to the deepest node so the close button would
// read as leaving the toast
.on_pointer_over(move |_| {
if over_flag.replace(true) {
return;

View file

@ -22,8 +22,7 @@ impl Component for UpdatePromptOverlay {
return rect().into_element();
};
// Recognise the update prompt by the choice it offers rather than by a
// prompt-kind enum, so the event layer needn't enumerate every prompt.
// Recognised by the choice it offers so the event layer needn't enumerate prompts
if !prompt.has_choice(UPDATE_CHOICE_INSTALL) {
return rect().into_element();
}

View file

@ -1,12 +1,5 @@
//! Drains the core's event stream into the radio station.
//!
//! An event drain, the toast expiry timers, and the game-log flush. It runs
//! under freya's `spawn`, not `tokio::spawn`, because radio state is `!Send`.
//!
//! The batching matters. During a download the core emits tens of thousands of
//! progress events, and a radio write wakes every subscriber to that channel, so
//! events are drained into a batch, folded, and written once per channel rather
//! than once per event.
//! Runs under freya's `spawn` not `tokio::spawn` radio state is `!Send`
//! Events are batched and folded because a radio write wakes every subscriber
use std::collections::{HashMap, HashSet};
use std::time::Duration;
@ -20,13 +13,9 @@ use crate::hooks::PumpSignal;
use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView};
use crate::state::{AppChannel, AppState, LoginProgress};
/// How long game log lines accumulate before being written.
///
/// A launch emits lines continuously; without this every line would wake the
/// log view.
/// Quiet period before log lines are written without it every line wakes the log view
const GAME_LOG_FLUSH: Duration = Duration::from_millis(120);
/// Cap on how many queued events are folded into a single write.
const COALESCE_BUDGET: usize = 1024;
pub struct EventPump {
@ -38,8 +27,7 @@ pub struct EventPump {
impl EventPump {
pub async fn run(mut self) {
let mut armed: HashMap<u64, ToastTimer> = HashMap::new();
// Hovering any toast pauses every toast, including ones that arrive
// while hovering.
// Hovering any toast pauses every toast including ones arriving while hovering
let mut paused = false;
let mut game_flush: Option<tokio::time::Instant> = None;
@ -86,8 +74,7 @@ impl EventPump {
if game_flush.is_some_and(|d| d <= now) {
game_flush = None;
// Nothing to write: log lines were folded in as they
// arrived; this only ends the quiet period.
// Lines were already folded in on arrival this only ends the quiet period
self.station.write_channel(AppChannel::Game);
}
}
@ -133,7 +120,6 @@ impl EventPump {
}
}
/// Folds a batch of events into the station, one write per channel touched.
fn apply(&mut self, batch: Vec<Event>) -> Folded {
let mut folded = Folded::default();
let mut engine_events = Vec::new();
@ -162,8 +148,7 @@ impl EventPump {
cluster_id,
message,
}) => failed = Some((cluster_id, message)),
// The sign-in modal renders this inline rather than as a toast,
// so it is lifted out here and never reaches the engine.
// Lifted out so it never reaches the engine the sign-in modal renders it inline
Event::Progress(ProgressEvent::Update {
id,
ref label,
@ -247,8 +232,6 @@ struct Folded {
java: bool,
}
/// Arms a timer for every toast that should have one, and drops timers for
/// toasts that have gone.
fn reconcile(
station: &RadioStation<AppState, AppChannel>,
armed: &mut HashMap<u64, ToastTimer>,
@ -268,7 +251,6 @@ fn reconcile(
armed.retain(|id, _| want.contains(id));
}
/// A toast's time-to-live, which hovering pauses and un-hovering resumes.
enum ToastTimer {
Running(tokio::time::Instant),
Paused(Duration),
@ -283,7 +265,7 @@ impl ToastTimer {
}
}
/// The instant this toast expires, or `None` while paused.
/// `None` while paused
fn deadline(&self) -> Option<tokio::time::Instant> {
match self {
Self::Running(deadline) => Some(*deadline),
@ -306,7 +288,6 @@ impl ToastTimer {
}
}
/// Builds the view the prompt overlays render.
#[must_use]
pub fn prompt_view(state: &AppState) -> Option<PendingPromptView> {
state.prompt.as_ref().map(|prompt| PendingPromptView {
@ -317,8 +298,6 @@ pub fn prompt_view(state: &AppState) -> Option<PendingPromptView> {
})
}
/// Everything the app needs before the UI can do anything: build the launcher,
/// publish the handle, then start the background work.
pub async fn start_launcher(
station: RadioStation<AppState, AppChannel>,
events: oneclient_events::EventBus,
@ -359,7 +338,6 @@ pub async fn start_launcher(
Ok(())
}
/// Records a startup failure where the user can see it.
pub fn report_startup_failure(
station: &RadioStation<AppState, AppChannel>,
err: &anyhow::Error,

View file

@ -1,14 +1,7 @@
//! Everything the UI can ask for.
//! Core operations go onto freya's UI-thread executor radio state is `!Send`
//!
//! A method per operation, each free to return a result. Core operations go
//! onto freya's UI-thread executor, since radio state is `!Send` and anything
//! that writes it must run there; pure UI toggles write it immediately.
//!
//! Always `spawn_forever`, never `spawn`. Freya's `spawn` binds a task to the
//! calling component's scope and cancels it on unmount; these are started from
//! event handlers, so a launch kicked off from a panel that then re-renders
//! would have its log tail and exit watcher killed underneath it. The work is
//! app-scoped, not component-scoped.
//! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when
//! the calling component unmounts this work is app-scoped not component-scoped
use std::path::PathBuf;
use std::sync::Arc;
@ -33,38 +26,25 @@ use crate::notifications::{
};
use crate::state::{AppChannel, AppState, AsyncStatus};
/// How long the launch button stays disabled after a click, at minimum.
///
/// Over-disabling is the cheaper mistake: a button that is dead for two seconds
/// after a failed launch beats one that lets a second game start.
/// Over-disabling is the cheaper mistake a dead button beats a second game
const LAUNCH_HOLD: Duration = Duration::from_secs(2);
/// How long the pre-launch update check may take before the launch goes ahead
/// without it.
///
/// Mirrors `oneclient_net`'s reachability probe rather than reqwest's own
/// per-request timeouts, which are minutes long and sized for downloads. This
/// one sits between the user pressing Play and the game starting.
/// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request
/// timeouts which are minutes long This sits between Play and the game
const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8);
/// What a launch should do about the updates its check turned up.
#[derive(Debug, Clone, PartialEq)]
enum LaunchUpdatePlan {
/// Launch straight away. The cache has still been written.
Nothing,
Apply(Vec<oneclient_core::BrowserPackageUpdate>),
Prompt(Vec<oneclient_core::BrowserPackageUpdate>),
}
/// Decides what a launch does with its pending updates.
///
/// Pure, and deliberately separate from the work it describes: the mode is the
/// one piece of this flow with three-way behaviour worth pinning down in tests,
/// and none of it needs a launcher, a database or a provider to be true.
/// Kept pure and separate from the work it describes so the three-way mode is
/// testable without a launcher database or provider
///
/// `pending` has already had declined versions filtered out by
/// `BrowserUpdateCheck::pending`, so nothing here re-asks a question the user
/// has answered.
/// `BrowserUpdateCheck::pending`
fn plan_launch_updates(
mode: PackageUpdateMode,
pending: Vec<oneclient_core::BrowserPackageUpdate>,
@ -74,22 +54,17 @@ fn plan_launch_updates(
}
match mode {
// The check ran and the cache was written before this point; skipping
// is only ever about what the user is shown.
// The cache was already written skipping only affects what is shown
PackageUpdateMode::Skip => LaunchUpdatePlan::Nothing,
PackageUpdateMode::Automatic => LaunchUpdatePlan::Apply(pending),
PackageUpdateMode::Prompt => LaunchUpdatePlan::Prompt(pending),
}
}
/// Asks the event pump to do something it alone owns.
///
/// The pump holds the toast timers, so an action that adds or removes a toast
/// has to tell it to re-arm; and hover-pause is a property of the timers, not of
/// any state a component reads.
/// The pump alone owns the toast timers so adding or removing a toast has to
/// tell it to re-arm hover-pause is a timer property not component state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpSignal {
/// The notification engine changed; re-arm toast timers.
Reconcile,
PauseToasts,
ResumeToasts,
@ -114,7 +89,6 @@ impl Actions {
let _ = self.pump.send(signal);
}
/// Folds a change into the notification engine, then re-arms toast timers.
fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) {
{
let mut guard = self.station.clone().write_channel(AppChannel::Notifications);
@ -131,8 +105,6 @@ impl Actions {
.error = error;
}
// --- launcher settings -------------------------------------------------
pub fn reload_settings(&self) {
let station = self.station;
spawn_forever(async move {
@ -178,8 +150,6 @@ impl Actions {
});
}
/// Applies a settings change in memory and publishes it, returning the
/// snapshot to persist.
fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option<LauncherSettings> {
let state = launcher::state().ok()?;
let updated = {
@ -214,8 +184,7 @@ impl Actions {
self.persist(updated);
}
/// Not persisted: "seen" versions are only used to decide whether to show
/// the changelog this run, and the next real save carries them along.
/// Not persisted the next real save carries the seen versions along
pub fn record_seen_version(&self, version: impl Into<String>) {
let version = version.into();
self.mutate_settings(|settings| {
@ -255,8 +224,6 @@ impl Actions {
}
}
// --- settings profiles -------------------------------------------------
pub fn save_global_profile(&self, profile: GameSettingsProfile) {
let actions = self.clone();
spawn_forever(async move {
@ -411,8 +378,7 @@ impl Actions {
tracing::error!("failed to set loader version: {err:#}");
return;
}
// Best effort: the cluster is still usable if the stage reset fails,
// it just will not re-download on next launch.
// Best effort on failure the cluster just will not re-download
let _ = state
.clusters
.set_stage(cluster_id, ClusterStage::NotReady)
@ -421,8 +387,6 @@ impl Actions {
});
}
// --- migration ---------------------------------------------------------
pub fn import_launcher(
&self,
source: oneclient_core::MigrationSource,
@ -452,8 +416,6 @@ impl Actions {
});
}
// --- java --------------------------------------------------------------
pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
@ -501,8 +463,6 @@ impl Actions {
});
}
// --- notifications (pure UI) -------------------------------------------
pub fn toggle_notification_center(&self) {
self.with_engine(|state| {
state.center_open = state
@ -568,8 +528,8 @@ impl Actions {
self.with_engine(|state| state.notifications.close_package_updates());
}
/// Hovering any toast pauses every toast, including ones that arrive while
/// hovering, which is why this is a pump signal rather than a state flag.
/// Hovering any toast pauses every toast including ones arriving while
/// hovering hence a pump signal rather than a state flag
pub fn pause_toasts(&self) {
self.nudge(PumpSignal::PauseToasts);
}
@ -623,7 +583,6 @@ impl Actions {
});
}
/// Debug-only: drives a fake progress notification through the real path.
pub fn send_test_progress(&self, current: u64, total: u64) {
let Ok(state) = launcher::state() else { return };
let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0);
@ -633,19 +592,8 @@ impl Actions {
.progress(id, "Downloading assets", current, total);
}
// --- game --------------------------------------------------------------
/// The Play button. Starts a launch, at most one per cluster.
///
/// The pending flag is raised synchronously, before the first `await`, so
/// the button is already disabled by the time a second click of a
/// double-click could be dispatched; waiting for core to report `Checking`
/// left a few hundred ms where every click spawned its own game. The claim
/// itself is the guard, so a click that bypasses the disabled attribute
/// (keyboard activation, replayed events) is dropped here too. It also
/// covers the pre-launch work in [`launch`], which the core does not own —
/// otherwise the update modal could sit open above a Play button still
/// inviting a second press.
/// The pending flag is raised synchronously before the first `await` the
/// claim itself is the guard against a double-click spawning two games
pub fn launch_cluster(&self, cluster_id: ClusterId) {
let claimed = self
.station
@ -663,8 +611,8 @@ impl Actions {
let started = Instant::now();
launch(&actions, cluster_id).await;
// A launch that fails instantly would hand the button back inside
// the same click burst, so hold it for the floor either way.
// An instant failure would hand the button back inside the same
// click burst so hold it for the floor either way
if let Some(remaining) = LAUNCH_HOLD.checked_sub(started.elapsed()) {
tokio::time::sleep(remaining).await;
}
@ -691,8 +639,6 @@ impl Actions {
.error = None;
}
// --- content -----------------------------------------------------------
pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
@ -761,8 +707,8 @@ impl Actions {
)
.await;
// The download already raised a progress notification; the outcome
// replaces it in place rather than arriving as a second one.
// Replaces the download's progress notification in place rather
// than arriving as a second one
let spec = match &install.result {
Ok(name) => NotificationSpec {
title: "Installed".to_string(),
@ -793,10 +739,8 @@ impl Actions {
});
if install.result.is_ok() {
// Installing a second version of something the cluster already
// had leaves the newest live and the rest switched off. Done
// before the refresh below so the version list never renders the
// moment where both copies read as active.
// Before the refresh below so the version list never renders
// the moment where both copies read as active
if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity(
cluster_id,
&state.services.content(),
@ -808,8 +752,6 @@ impl Actions {
super::invalidate_cluster_content_queries().await;
// Everything else the install touched is nobody's blocker and
// catches up on the usual signal.
state
.services
.events
@ -820,15 +762,11 @@ impl Actions {
});
}
/// Drops one installed version of a browsed package.
/// Shares the installs channel with [`Self::install_package`] so every row's
/// button reads as busy while one is being acted on
///
/// Shares the installs channel with [`Self::install_package`] so the whole
/// project reads as busy while this runs: the version list has a button per
/// row, and none of them should be pressable while one is being acted on.
///
/// The removal is recorded as a bundle override when a bundle owns the
/// artifact — the same thing the package manager's remove does. Without it
/// the next sync puts the version straight back.
/// Recorded as a bundle override when a bundle owns the artifact otherwise
/// the next sync puts the version straight back
pub fn remove_package_version(
&self,
cluster_id: ClusterId,
@ -874,9 +812,8 @@ impl Actions {
Ok(()) => {
events.notify("Removed").body(display_name).send();
// Same reasoning as the install path: what the row turns
// back into is read straight off the cluster's content, so
// that is refreshed before the busy flag drops.
// The row's next state is read off the cluster's content
// so refresh that before the busy flag drops
super::invalidate_cluster_content_queries().await;
events.signal(oneclient_events::Signal::ClustersChanged);
}
@ -960,11 +897,8 @@ impl Actions {
});
}
/// Syncs the bundle catalog and brings every cluster up to date.
///
/// `syncing_bundles` gates every launch button, so the flag must not be
/// raised unless this can actually clear it, hence the readiness check before
/// the write rather than inside the task.
/// `syncing_bundles` gates every launch button so the readiness check must
/// happen before the flag is raised not inside the task
pub fn sync_bundles(&self) {
let state = match launcher::state() {
Ok(state) => state,
@ -993,10 +927,8 @@ impl Actions {
tracing::error!("bundle cluster provisioning failed: {err:#}");
}
// One shared grouped session for the whole batch, so every cluster's
// downloads surface as a single "update in progress" notification.
// `detach` hands that entry over so it can be converted to the
// finished "view changes" state in place rather than replaced.
// One grouped session for the batch so all downloads surface as a
// single notification `detach` lets it be converted in place
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
"Updating mods",
@ -1025,24 +957,11 @@ impl Actions {
});
}
// --- browser package updates -------------------------------------------
/// Brings the launching cluster's browser-installed packages up to date,
/// or asks about them, before the game starts.
/// The check runs and the cache is written whatever the mode says keeping
/// the package manager's "Update available" markers honest
///
/// The check runs and the cache is written whatever the mode says — that is
/// what keeps the "Update available" markers in the package manager honest
/// for users who never want the modal. The mode only decides what the user
/// sees and whether anything is installed.
///
/// Returns once the launch may proceed. In `Prompt` mode that means once
/// the user has answered every row or dismissed the modal; the applies
/// themselves complete before their rows disappear, so nothing is still
/// downloading into the cluster when the game starts.
///
/// Never fails the launch. An unreachable provider, a check that runs long,
/// a failed install: all of them log and let the game start on what is
/// already installed.
/// Returns once the launch may proceed nothing is still downloading then
/// Never fails the launch every error logs and lets the game start
async fn resolve_package_updates_before_launch(
&self,
state: &Arc<oneclient_core::LauncherState>,
@ -1050,11 +969,8 @@ impl Actions {
) {
let content = state.services.content();
// Bounded because this sits between the user pressing Play and the game
// starting. A network that hangs rather than refuses would otherwise
// hold the launch for reqwest's own much longer timeouts, so the check
// gets the same budget the reachability probe uses and the launch
// continues on whatever is already installed.
// Bounded a hanging network would otherwise hold the launch for
// reqwest's much longer timeouts
let check = match tokio::time::timeout(
UPDATE_CHECK_BUDGET,
oneclient_core::refresh_browser_package_updates(cluster_id, &content),
@ -1099,9 +1015,8 @@ impl Actions {
}
}
/// The launching cluster's effective mode, with the usual profile-over-
/// global inheritance. Anything unreadable falls back to the default rather
/// than blocking the launch on a settings lookup.
/// Anything unreadable falls back to the default rather than blocking the
/// launch on a settings lookup
async fn cluster_update_mode(
&self,
state: &Arc<oneclient_core::LauncherState>,
@ -1122,8 +1037,7 @@ impl Actions {
.unwrap_or_default()
}
/// Installs every pending update, in one grouped progress notification so
/// the pre-launch pause has something to show for itself.
/// One grouped progress notification so the pre-launch pause shows something
async fn apply_updates_for_launch(
&self,
state: &Arc<oneclient_core::LauncherState>,
@ -1150,8 +1064,8 @@ impl Actions {
match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await
{
Ok(_) => applied += 1,
// One package failing is not a reason to hold the game back;
// the cache row survives, so the next launch offers it again.
// Not a reason to hold the game back the cache row survives
// so the next launch offers it again
Err(err) => tracing::warn!(
package = %update.display_name,
error = %err,
@ -1184,7 +1098,6 @@ impl Actions {
}
}
/// Opens the modal and waits for the user to be done with it.
async fn prompt_package_updates(&self, group: PackageUpdateGroup) {
let (done, wait) = tokio::sync::oneshot::channel();
@ -1193,12 +1106,10 @@ impl Actions {
state.center_open = false;
});
// An error means the modal state was replaced or torn down, which is
// equally a reason to stop waiting and get on with the launch.
// A replaced or torn-down modal is equally a reason to stop waiting
let _ = wait.await;
}
/// Applies one update from the modal, then drops that row from it.
pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) {
let actions = self.clone();
spawn_forever(async move {
@ -1252,8 +1163,7 @@ impl Actions {
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
});
// A failed update stays in the list: the row is still out of date,
// and the user may want to try again.
// A failed update stays in the list so the user can retry
if result.is_ok() {
actions.with_engine(|app| {
app.notifications
@ -1264,8 +1174,8 @@ impl Actions {
});
}
/// Declines one update. The package stays marked out of date; only the
/// modal stops asking, and only for this version.
/// The package stays marked out of date only the modal stops asking and
/// only for this version
pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into<String>) {
let hash = hash.into();
let actions = self.clone();
@ -1289,19 +1199,14 @@ impl Actions {
}
}
/// The launch itself. Failures are reported as game events, so the caller only
/// has to know that the attempt is over.
///
/// Everything before [`oneclient_core::launch_cluster`] is pre-launch work the
/// core does not own: renewing the account token, and bringing
/// browser-installed packages up to date.
/// Failures are reported as game events so the caller only has to know the
/// attempt is over Everything before `launch_cluster` is pre-launch work
async fn launch(actions: &Actions, cluster_id: ClusterId) {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
events.game_stage(cluster_id, oneclient_events::LaunchStage::Checking);
// Renews a lapsed token before launching.
let account = match state.auth.default_account_for_launch().await {
Ok(account) => account,
Err(err) => {
@ -1318,18 +1223,15 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
return;
};
// Before the game process, never after: Minecraft reads its mods once at
// startup, so an update applied to a live session would do nothing until
// the next launch anyway.
// Before the game process never after Minecraft reads its mods once at
// startup
actions
.resolve_package_updates_before_launch(&state, cluster_id)
.await;
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
// A launch that failed because a file simply is not there is the one
// failure the launcher can fix by itself. Showing the user a path inside
// our own metadata folder and stopping leaves them with nothing to act
// on, so repair first and only report if that does not help.
// A missing file is the one failure the launcher can fix itself and a
// path inside our metadata folder gives the user nothing to act on
if err.indicates_missing_files() {
repair_and_relaunch(&state, cluster_id, &account, err).await;
return;
@ -1339,12 +1241,8 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
}
}
/// Runs a verify-and-repair pass, then tries the launch once more.
///
/// Only ever one retry: if the game still cannot start after every file has
/// been hashed and the broken ones re-downloaded, the problem is not missing
/// files, and trying again would just spend another few minutes to show the
/// same error.
/// Only ever one retry if the game still will not start after a full rehash and
/// repair the problem is not missing files
async fn repair_and_relaunch(
state: &std::sync::Arc<oneclient_core::LauncherState>,
cluster_id: ClusterId,
@ -1366,8 +1264,8 @@ async fn repair_and_relaunch(
Ok(report) => report,
Err(repair_err) => {
tracing::error!(cluster_id, "repair failed: {repair_err:#}");
// The original failure is what the user was trying to do, so lead
// with it; the repair failure explains why it was not fixed.
// Lead with the original failure the repair failure explains why
// it was not fixed
events.game_failed(
cluster_id,
format!("{original:#} (repair also failed: {repair_err:#})"),

View file

@ -15,14 +15,11 @@ pub fn use_active_cluster_id() -> State<Option<ClusterId>> {
consume_root_context::<ActiveClusterState>().0
}
/// Drives the startup splash "curtain": a full-screen cover that stays up after
/// the router leaves the Startup route until the home view's clusters/art have
/// settled, then fades out, so the app never reveals a half-populated home.
/// Startup curtain stays up past the Startup route until home content settles
/// so the app never reveals a half-populated home
#[derive(Clone, Copy)]
pub struct SplashState {
/// The curtain is covering the screen (raised when heading into Home).
pub active: State<bool>,
/// Home content (clusters + background) has finished loading.
pub home_ready: State<bool>,
}
@ -89,20 +86,17 @@ pub fn use_browser_state_store() -> State<HashMap<String, BrowserUiState>> {
#[derive(Clone)]
pub struct OnboardingSelectionState {
pub selected: State<HashSet<String>>,
/// Set once the user changes the selection themselves. Until then the shell
/// keeps re-deriving `selected` from the catalog, so a bundle list that
/// settles late still gets the right defaults.
/// Until set the shell keeps re-deriving `selected` from the catalog so a
/// late-settling bundle list still gets the right defaults
pub user_touched: State<bool>,
/// Bundle categories the user had in their v1 install, once the migration
/// step has been answered. `None` means "no migration decision yet".
/// `None` means "no migration decision yet"
pub migrated_categories: State<Option<Vec<String>>>,
pub language: State<String>,
pub reduce_motion: State<bool>,
pub predownload: State<bool>,
pub setup_started: State<bool>,
/// v1-migration: source folder to import files from (`None` = don't import).
/// `None` = don't import
pub import_folder: State<Option<String>>,
/// v1-migration: import into the matching cluster's own dir instead of shared.
pub import_dedicated: State<bool>,
}

View file

@ -2,16 +2,13 @@ use std::time::Duration;
use freya::prelude::*;
/// Returns a debounced mirror of `value`. The returned state only updates to a
/// new `value` once `delay` has elapsed without any further change. Rapid
/// changes (e.g. per-keystroke) collapse into a single trailing update.
/// Trailing-edge debounce updates only after `delay` with no further change
pub fn use_debounced<T>(value: T, delay: Duration) -> State<T>
where
T: Clone + PartialEq + 'static,
{
let debounced = use_state(|| value.clone());
// Bumped on every change; the async task commits only if its generation is
// still the latest when the delay expires, dropping superseded changes.
// Task commits only if its generation is still latest dropping superseded changes
let mut generation = use_state(|| 0u64);
use_side_effect_with_deps(&value, move |value| {

View file

@ -62,8 +62,6 @@ use crate::state::{
use freya::prelude::*;
use freya::radio::use_radio;
/// Publishes the actions handle so components can reach it without prop
/// drilling. Provided once, at the root.
pub fn use_provide_actions(actions: &Actions) {
let actions = actions.clone();
use_provide_root_context(move || actions.clone());
@ -73,10 +71,8 @@ pub fn use_dispatch() -> Actions {
consume_root_context::<Actions>()
}
/// Subscribes to one concern of the app state.
///
/// Each of these wakes its component only when *that* channel is written, so a
/// toast timer tick does not re-render a component that reads only `data_dir`.
/// Wakes its component only when that channel is written so a toast tick does
/// not re-render a component reading only `data_dir`
pub fn use_launcher() -> LauncherInit {
use_radio(AppChannel::Launcher).read().launcher.clone()
}
@ -85,11 +81,8 @@ pub fn use_settings_snapshot() -> SettingsState {
use_radio(AppChannel::Settings).read().settings.clone()
}
/// Derives the render view from the engine.
///
/// Built on read rather than published on write: during a download the engine
/// changes tens of thousands of times, and cloning the inbox each time was the
/// snapshot channel's main cost.
/// Built on read not published on write cloning the inbox on every one of a
/// download's tens of thousands of engine changes was the channel's main cost
pub fn use_notifications_snapshot() -> NotificationSnapshot {
let radio = use_radio(AppChannel::Notifications);
let state = radio.read();

View file

@ -160,8 +160,7 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginMutation;
/// Drops a pending browser login server-side when the user cancels before the
/// flow reaches the point of no return. Keyed by the session's CSRF state token.
/// Keyed by the session's CSRF state token
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginKeys {
pub state_token: String,
@ -354,12 +353,8 @@ pub fn use_refresh_all_accounts() -> UseMutation<RefreshAllAccountsMutation> {
use_mutation(Mutation::new(RefreshAllAccountsMutation))
}
/// Whether the mutation has yet to produce a settled result.
///
/// True *before* it has ever been run as well as while it is running —
/// `MutationStateData::Pending` means "has not loaded yet", not "in progress".
/// For driving a button's label or disabled state, use [`mutation_is_running`];
/// this one reads as "no result to show yet".
/// True before it has ever been run as well as while running For button
/// disabled state use [`mutation_is_running`] instead
pub fn mutation_is_pending<M: MutationCapability>(mutation: &UseMutation<M>) -> bool {
matches!(
&*mutation.read().state(),
@ -367,7 +362,6 @@ pub fn mutation_is_pending<M: MutationCapability>(mutation: &UseMutation<M>) ->
)
}
/// Whether the mutation is actually in flight right now.
pub fn mutation_is_running<M: MutationCapability>(mutation: &UseMutation<M>) -> bool {
mutation.read().state().is_loading()
}

View file

@ -23,11 +23,8 @@ pub fn use_clusters() -> UseQuery<ListClustersQuery> {
use_query(Query::new(ListClustersKeys, ListClustersQuery))
}
/// The one cluster a route is about, pulled out of the list query.
///
/// There is no per-cluster query, since the list is always loaded and small, so
/// this is a lookup, not a fetch. It is a hook because it subscribes to the
/// list on the caller's behalf.
/// A lookup not a fetch there is no per-cluster query the list is always
/// loaded and small A hook because it subscribes to the list
pub fn use_cluster(cluster_id: i64) -> Option<Cluster> {
let clusters = super::state::settled_or_loading(&use_clusters()).unwrap_or_default();
clusters.into_iter().find(|c| c.id == cluster_id)

View file

@ -35,10 +35,8 @@ impl QueryCapability for CachedImageQuery {
}
}
/// The bytes of a cached image, once they are there.
///
/// The url comes back with them because callers key their image handle on it;
/// a stale url paired with fresh bytes renders the wrong picture for a frame.
/// Returns the url alongside the bytes callers key their image handle on it
/// and a stale url with fresh bytes renders the wrong picture for a frame
pub fn loaded_image(url: Option<&str>, query: &UseQuery<CachedImageQuery>) -> Option<(String, Bytes)> {
let url = url?;
let bytes = super::state::settled_or_loading(query)?;

View file

@ -19,9 +19,8 @@ impl QueryCapability for MigrationQuery {
return Ok(None);
};
// remote cluster migrations run on startup (so before we even get to launcher migrations)
// this pretty much tries to migrate the source instance to the latest version in the migration chain,
// so that we can import it into the correct cluster
// Runs before launcher migrations walks the source instance up the
// migration chain so it can be imported into the correct cluster
let rules = crate::launcher::state()?.versions.migrations().await;
if !rules.is_empty() {
for instance in &mut detection.instances {

View file

@ -1,11 +1,7 @@
use crate::hooks::use_settings_snapshot;
/// The cache key for anything fetched from the metadata server.
///
/// Not the url itself, just enough of the setting to notice when it changes,
/// so pointing the launcher at a different meta host refetches instead of
/// serving the old host's answer. The queries read the real base out of
/// settings when they run.
/// Not the url itself just enough to notice when the meta host setting
/// changes so a different host refetches instead of serving the old answer
fn use_meta_url_key() -> String {
use_settings_snapshot()
.settings

View file

@ -24,12 +24,8 @@ pub async fn invalidate_cluster_queries() {
QueriesStorage::<LoaderVersionsQuery>::try_invalidate_all().await;
}
/// Just the query that decides whether a cluster already has a package.
///
/// Split out of [`invalidate_cluster_queries`] so an install can wait for
/// exactly this before it stops calling itself busy. The full sweep reaches the
/// network several times over, and waiting for all of it would trade one stale
/// button for a slower one.
/// Split out of [`invalidate_cluster_queries`] so an install can wait for just
/// this before dropping its busy flag the full sweep hits the network
pub async fn invalidate_cluster_content_queries() {
QueriesStorage::<ClusterContentQuery>::try_invalidate_all().await;
}
@ -64,9 +60,8 @@ pub enum ClusterAction {
bundle_name: String,
package_id: String,
enabled: bool,
/// The bundle manifest's `enabled` flag, so we know whether the user's
/// choice matches the default (clear the override) or contradicts it
/// (write `Enabled` / `Disabled`).
/// Manifest default matching it clears the override contradicting it
/// writes `Enabled` / `Disabled`
manifest_default: bool,
},
ImportLocalFile {
@ -78,7 +73,6 @@ pub enum ClusterAction {
cluster_id: ClusterId,
dedicated: bool,
},
/// Rehash every installed file and re-download whatever no longer matches.
VerifyFiles {
cluster_id: ClusterId,
},
@ -95,10 +89,8 @@ impl MutationCapability for ClusterMutation {
let content = &state.services.content();
let result = match keys {
ClusterAction::ToggleArtifact { cluster_id, hash } => {
// Recorded in the database and applied to the game folder at the
// next launch, whether or not a session is live: Minecraft reads
// its mods once at startup, so there is nothing a mid-session
// write could achieve.
// Applied to the game folder at next launch never mid-session
// Minecraft reads its mods once at startup
oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content)
.await
.map(|_| ())
@ -154,9 +146,8 @@ impl MutationCapability for ClusterMutation {
})
}
ClusterAction::VerifyFiles { cluster_id } => {
// Reports its own outcome rather than going through the generic
// failure toast: a verify that finds nothing wrong is a useful
// result the user asked for, not a silent no-op.
// Reports its own outcome not the generic failure toast a
// verify that finds nothing wrong is still a useful result
match oneclient_core::verify_cluster_files(&state, *cluster_id).await {
Ok(report) => {
let notify = services.events.notify("Verification complete");

View file

@ -4,10 +4,8 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{BrowserPackageUpdate, LauncherError};
use oneclient_db::models::ClusterId;
/// What the last launch-time check recorded for one cluster.
///
/// Reads the cache table only: the check itself runs on launch, so rendering a
/// package list must never be what talks to a provider.
/// Reads the cache table only rendering a package list must never talk to a
/// provider The check itself runs on launch
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PackageUpdatesQuery {
pub cluster_id: ClusterId,
@ -44,7 +42,6 @@ pub fn package_updates(query: &UseQuery<PackageUpdatesQuery>) -> Vec<BrowserPack
super::state::settled_or_loading(query).unwrap_or_default()
}
/// The artifact hashes with a newer version waiting, for marking rows stale.
pub fn stale_hashes(query: &UseQuery<PackageUpdatesQuery>) -> HashSet<String> {
package_updates(query)
.into_iter()

View file

@ -13,10 +13,8 @@ use oneclient_core::LauncherError;
pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE;
pub const VERSIONS_PAGE_SIZE: usize = 20;
/// A published version list barely moves, and the browser listing now asks for
/// one per visible card to know what "latest" is. Holding them for a while
/// keeps scrolling from refiring a request per card every time one comes back
/// into view.
/// The listing asks for one per visible card holding them stops scrolling from
/// refiring a request per card each time it comes back into view
const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60);
pub fn content_type_for_slug(slug: &str) -> ContentType {
@ -85,9 +83,8 @@ pub fn use_package_search(
PackageSearchKeys {
provider,
content_type,
// Canonical form, because the key is also the cache key: without it
// "sodium" and "sodium " are two entries, and adding a trailing
// space throws the results away and refetches for nothing.
// The key is also the cache key without normalizing "sodium" and
// "sodium " are two entries and a trailing space refetches for nothing
query: normalize_query(&query),
game_versions,
loaders,
@ -245,11 +242,8 @@ pub fn use_package_versions(
use_package_versions_when(true, provider, project_id, game_version, loader, page)
}
/// The version list, fetched only when `enabled`.
///
/// A listing row keeps its install button after the package is in the cluster,
/// so the hook still has to run there — but there is nothing left for it to
/// install, and a request per already-installed row is pure waste.
/// A listing row keeps its install button once installed so the hook still
/// runs there but a request per already-installed row is pure waste
pub fn use_package_versions_when(
enabled: bool,
provider: ProviderId,

View file

@ -4,7 +4,6 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::LauncherError;
use oneclient_mc::{self as minecraft, PlayerProfileView};
// image cache
const PROFILE_STALE: Duration = Duration::from_secs(30 * 60);
const PROFILE_CLEAN: Duration = Duration::from_secs(2 * 60 * 60);
@ -31,7 +30,7 @@ impl QueryCapability for FetchPlayerProfileQuery {
Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?)
}
// TODO: Cache
// TODO Cache
// fn matches(&self, keys: &Self::Keys) -> bool {
// keys.uuid.as_deref().is_some_and(|id| !id.is_empty())
// }

View file

@ -14,7 +14,6 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default());
let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default());
// No custom skin: pick alex (slim) or steve (classic) from the UUID.
let default_slim = (java_string_hash(&uuid) & 1) == 1;
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {

View file

@ -1,21 +1,12 @@
//! Reading a query's state without spelling out the match every time.
//!
//! `QueryStateData` has three variants and the useful cases straddle two of
//! them: a query that is refetching still holds the previous value, and the UI
//! wants to keep showing it rather than flash a spinner. Written out by hand
//! that is a five-line match repeated over a hundred times, with enough small
//! divergences (does a settled `Err` read as empty or as absent? does
//! "loading" include a refetch?) that the differences were impossible to see.
//! Query state readers The useful cases straddle two `QueryStateData`
//! variants a refetch still holds the previous value the UI wants to show
use std::fmt::Display;
use freya::query::{QueryCapability, QueryStateData, UseQuery};
/// The query's value, keeping the previous one visible across a refetch.
///
/// `None` means there has never been a successful result: either the query
/// has not finished yet or the only outcome so far is an error. Callers that
/// treat "failed" the same as "empty" finish with `.unwrap_or_default()`.
/// Keeps the previous value visible across a refetch `None` means there has
/// never been a successful result (not finished yet or only errors so far)
pub fn settled_or_loading<Q>(query: &UseQuery<Q>) -> Option<Q::Ok>
where
Q: QueryCapability,
@ -26,10 +17,8 @@ where
state.ok().cloned()
}
/// The error from a settled query, rendered for display.
///
/// Not reported while loading: a refetch that is still in flight has not
/// failed yet, even if the previous attempt did.
/// Not reported while loading a refetch in flight has not failed yet even if
/// the previous attempt did
pub fn query_error<Q>(query: &UseQuery<Q>) -> Option<String>
where
Q: QueryCapability,
@ -43,11 +32,8 @@ where
}
}
/// Whether the query is working with nothing to show yet.
///
/// A refetch that still holds a previous value is *not* loading by this
/// definition, since the UI has something to render and a spinner would be a
/// step backwards. Use [`query_is_busy`] where the distinction matters.
/// A refetch still holding a previous value is *not* loading here Use
/// [`query_is_busy`] where the distinction matters
pub fn query_is_loading<Q: QueryCapability>(query: &UseQuery<Q>) -> bool {
let reader = query.read();
let state = reader.state();
@ -57,8 +43,6 @@ pub fn query_is_loading<Q: QueryCapability>(query: &UseQuery<Q>) -> bool {
)
}
/// Whether the query is working at all, including a refetch over a value that
/// is already on screen.
pub fn query_is_busy<Q: QueryCapability>(query: &UseQuery<Q>) -> bool {
let reader = query.read();
let state = reader.state();

View file

@ -24,14 +24,8 @@ impl QueryCapability for StorageReportQuery {
}
}
/// How long a scan stays good for.
///
/// The report walks every file under the launcher directory, which on a
/// well-used install is tens of thousands of them. `stale_time` defaults to
/// zero, meaning the scan would re-run every single time the page is mounted —
/// so leaving the page and coming back would rescan the whole tree. Disk usage
/// does not move on its own, and the page has a refresh button for when the
/// user knows it has.
/// The report walks tens of thousands of files and the default zero stale time
/// would rescan the whole tree on every mount The page has a refresh button
const STORAGE_STALE_TIME: Duration = Duration::from_secs(120);
pub fn use_storage_report() -> UseQuery<StorageReportQuery> {
@ -48,9 +42,7 @@ pub async fn invalidate_storage_queries() {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StorageAction {
/// Delete cached package files no artifact refers to.
CleanUnreferencedCache,
/// Clear content links older launchers wrote into cluster folders.
CleanLegacyClusterContent,
}

View file

@ -50,7 +50,6 @@ pub fn use_version_metadata(
let versions_query = use_versions();
let major = major?;
// Borrowed, not cloned: the manifest is long and this runs per render.
let reader = versions_query.read();
let state = reader.state();
let list = state.ok()?;

View file

@ -28,15 +28,12 @@ pub fn use_versions() -> UseQuery<VersionsMetadataQuery> {
use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery))
}
/// The manifest rows once the query has settled, `None` while it is still
/// loading. A failed fetch reads as an empty list rather than `None`, so
/// callers that block on this don't wait forever when the network is down.
/// A failed fetch reads as an empty list rather than `None` so callers that
/// block on this don't wait forever when the network is down
pub fn versions_metadata(query: &UseQuery<VersionsMetadataQuery>) -> Option<Vec<VersionMetadata>> {
let reader = query.read();
let state = reader.state();
match &*state {
// A failed fetch reads as an empty list rather than `None`, so callers
// that block on this don't wait forever when the network is down.
QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()),
other => other.ok().cloned(),
}

View file

@ -1,8 +1,3 @@
//! Shared helpers for content installs and the notifications they raise.
//!
//! Lifted out of the bridge runtime so the actions layer can use them without
//! the command loop existing.
use std::sync::Arc;
use oneclient_core::LauncherState;
@ -16,8 +11,7 @@ use crate::notifications::{
NotificationSpec, PackageUpdateGroup,
};
/// Names a cluster for a notification or modal header, falling back rather than
/// failing: a missing row is not a reason to lose the whole message.
/// Falls back to a placeholder a missing row must not lose the whole message
pub async fn cluster_display_name(
cluster_id: i64,
services: &oneclient_core::LauncherServices,
@ -28,8 +22,6 @@ pub async fn cluster_display_name(
.unwrap_or_else(|_| "Cluster".to_string())
}
/// Turns the launching cluster's pending browser updates into what the modal
/// renders. `None` when there is nothing to offer.
pub async fn package_update_group(
cluster_id: i64,
updates: &[oneclient_core::BrowserPackageUpdate],
@ -140,10 +132,7 @@ pub async fn cluster_update_notification(
})
}
/// Builds a single notification summarising a batch bundle sync. Every changed
/// cluster rides along on one "View changes" action so the notification keeps
/// exactly two buttons no matter how many clusters moved; the modal does the
/// per-cluster breakdown. `None` when nothing changed.
/// All clusters share one "View changes" action so the notification keeps exactly two buttons
pub async fn combined_cluster_update_spec(
changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)],
services: &oneclient_core::LauncherServices,
@ -182,23 +171,17 @@ pub async fn combined_cluster_update_spec(
})
}
/// An install's outcome together with the progress session it ran under.
///
/// The session is detached rather than finished so the caller can convert that
/// same notification into the "Installed" / "Install failed" result, instead of
/// leaving a finished progress card and raising a second notification beside it.
/// `session_id` is `None` when the install never got as far as downloading.
/// The session is detached not finished so the caller can reuse its notification
/// as the "Installed" / "Install failed" result
/// `session_id` is `None` if no download ran
pub struct PackageInstall {
pub session_id: Option<uuid::Uuid>,
pub result: anyhow::Result<String>,
/// Required dependencies pulled in alongside the package, by display name.
pub dependencies: Vec<String>,
/// Required dependencies that couldn't be resolved or downloaded. The
/// package still installs; the caller says so in the notification.
/// Unresolved or failed dependencies the package still installs
pub missing_dependencies: Vec<String>,
}
/// "Added Sodium with 2 dependencies. Could not add: Fabric API."
pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String {
let mut body = format!("Added {name}");
@ -253,8 +236,7 @@ pub async fn install_package(
Err(err) => return PackageInstall::failed(err),
};
// Worked out before the session starts so its children can be announced up
// front; a failure here leaves the package itself installable.
// Resolved before the session starts so its children can be announced up front
let mut resolution = oneclient_content::packages::DependencyResolution::default();
if oneclient_content::packages::resolves_dependencies(project.content_type) {
match oneclient_content::packages::resolve_required(
@ -292,8 +274,7 @@ pub async fn install_package(
let mut installed_dependencies = Vec::new();
let mut missing_dependencies = resolution.unresolved;
// Dependencies go in first so the package is never sitting in a cluster
// without them, however the run ends.
// Dependencies first so the package is never in a cluster without them however the run ends
for dependency in &resolution.install {
let child = session.child(
dependency.project.name.clone(),

View file

@ -1,13 +1,5 @@
//! The app's handle on the launcher core.
//!
//! The core keeps no process-wide `OnceCell`; a library holding ambient state
//! cannot be constructed twice, or once in a test. The handle lives here
//! instead, in the executable, which is the one place a global is defensible:
//! this *is* the composition root.
//!
//! It exists because `freya::query`'s `QueryCapability::run` and
//! `MutationCapability::run` take only their keys, with no context and no
//! hooks, so there is nowhere else to thread the state through.
//! The global lives here not in the core so the library stays constructible in tests
//! It is needed because `freya::query`'s `run` methods take only keys with no context
use std::sync::{Arc, OnceLock};
@ -15,11 +7,7 @@ use oneclient_core::{LauncherError, LauncherResult, LauncherState};
static LAUNCHER: OnceLock<Arc<LauncherState>> = OnceLock::new();
/// Installs the launcher handle. Called once, from the startup path.
///
/// Returns the existing handle if one was already installed, rather than
/// failing: a second call is a bug in startup ordering, not something a user
/// can trigger, and silently keeping the first is safer than panicking.
/// Returns the existing handle on a second call rather than panicking
pub fn install(state: Arc<LauncherState>) -> Arc<LauncherState> {
if let Err(existing) = LAUNCHER.set(Arc::clone(&state)) {
tracing::warn!("launcher state was already installed; keeping the first");
@ -28,8 +16,7 @@ pub fn install(state: Arc<LauncherState>) -> Arc<LauncherState> {
state
}
/// The launcher handle, or [`LauncherError::NotInitialized`] before startup has
/// finished installing it.
/// Errors with [`LauncherError::NotInitialized`] before startup installs the handle
pub fn state() -> LauncherResult<Arc<LauncherState>> {
LAUNCHER
.get()

View file

@ -14,9 +14,8 @@ enum Enter {
Fade,
}
/// Every route nested under [`SettingsShell`](crate::layout::SettingsShell). The
/// shell runs its own content-only transition, so this outlet must stay still for
/// them — otherwise the sidebar animates along with the page.
/// This outlet must stay still for these the shell runs its own content-only
/// transition else the sidebar animates along with the page
fn is_sidebar_route(route: &Route) -> bool {
matches!(
route,

View file

@ -43,10 +43,7 @@ impl Component for AppShell {
let browser_state = use_state(HashMap::new);
use_provide_browser_state(BrowserStateStore(browser_state));
// Window-wide file drops. `FileDrop` bubbles, so anything a dedicated
// drop zone doesn't claim with `stop_propagation()` ends up here and the
// user gets asked where it should go. Only the overlay reads these, so
// hovering a file doesn't re-render the shell.
// `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here
let mut drop_hovering = use_state(|| false);
let mut drop_pending = use_state(Vec::<PathBuf>::new);
@ -259,8 +256,6 @@ pub(crate) fn appshell_overlay() -> Rect {
pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg";
/// The cluster the home background is drawn from: the active one, or the first
/// in the list before anything is active.
fn home_cluster(clusters: &[Cluster], active: Option<ClusterId>) -> Option<&Cluster> {
active
.and_then(|id| clusters.iter().find(|c| c.id == id))
@ -273,25 +268,15 @@ fn home_art(cluster: Option<&Cluster>) -> DynamicArt {
.preview_edge(ART_PREVIEW_EDGE)
}
/// Loads the home background art as soon as the launcher is up, rather than
/// when Home mounts.
///
/// The art is three async hops deep — the cluster list, then the version
/// manifest holding its url, then the image itself — and none of them start
/// until something subscribes. Mounted at the root, they overlap the rest of
/// startup while the splash curtain is still up; left to Home, they run after
/// it has lifted and show as the built-in fallback lingering on screen.
///
/// The queries are global and outlive this component's subscription, so Home
/// finds them already settled and renders the real art on its first frame.
/// Warms the home art at the root so its three async hops overlap startup behind the
/// splash left to Home the fallback lingers on screen after the curtain lifts
#[derive(PartialEq, Clone, Copy)]
pub struct HomeArtPrefetch;
impl Component for HomeArtPrefetch {
fn render(&self) -> impl IntoElement {
// Before the launcher is ready every query errors out, and a settled
// error is not retried until a new subscriber mounts. Waiting means the
// inner component's hooks run once, when they can actually succeed.
// Before the launcher is ready every query errors and a settled error is not
// retried until a new subscriber mounts
if !use_launcher().ready {
return rect().into_element();
}
@ -308,8 +293,7 @@ impl Component for HomeArtWarm {
let art = {
let reader = clusters_query.read();
let state = reader.state();
// No active cluster this early, so this is the first in the list —
// the same one `AppHomeBackground` picks.
// No active cluster this early so this picks the same one `AppHomeBackground` will
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
home_art(home_cluster(clusters, None))
};
@ -330,8 +314,7 @@ impl Component for AppHomeBackground {
let launcher = use_launcher();
let splash = use_splash();
// Once the cluster list has settled and the startup fetch is done, the
// home view is populated, so let the splash curtain fade out.
// The home view is populated once these settle which releases the splash curtain
let clusters_settled = matches!(
&*clusters_query.read().state(),
QueryStateData::Settled { .. }

View file

@ -105,9 +105,8 @@ impl Component for ClusterShell {
let show_game_log = game.is_active(cluster_id);
let launch_state = launch_button_state(&game, cluster_id, syncing);
// Queried out here rather than where it is used: the shell can mount
// before the cluster list settles, and a hook that only runs once the
// cluster resolves would change this component's hook count mid-life.
// Queried unconditionally the shell can mount before the cluster list settles
// and a conditional hook would change this component's hook count mid-life
let parsed = cluster.as_ref().and_then(|c| parse_mc_version(&c.mc_version));
let metadata = use_version_metadata(
parsed.as_ref().map(|p| p.major),

View file

@ -21,15 +21,12 @@ impl Component for RootLayout {
let home_ready = use_state(|| false);
use_provide_splash(SplashState { active, home_ready });
// macOS draws the corners natively (rounded window + shadow via the
// window attributes), so Freya must not round on top of it.
// macOS rounds the window natively so Freya must not round on top of it
#[cfg(target_os = "macos")]
let corner = 0.;
// Other platforms are borderless: Freya rounds, squared when maximized so
// there's no gap to the screen edge. No reactive maximized signal exists,
// so mirror it: root_size changes on every maximize/restore/resize, which
// re-runs the effect to re-query.
// Elsewhere the window is borderless and squared when maximized
// No reactive maximized signal exists so root_size changes stand in as the trigger
#[cfg(not(target_os = "macos"))]
let corner = {
let root_size = Platform::get().root_size;
@ -72,8 +69,7 @@ impl Component for RootLayout {
.child(Toasts)
.child(UpdatePromptOverlay)
.child(JavaPromptOverlay)
// Last of the prompt overlays: it renders whatever the two above
// did not claim, so no prompt can reach the user as silence.
// Must stay last it renders whatever the overlays above did not claim
.child(GenericPromptOverlay)
.child(ClusterUpdatePopup)
.child(PackageUpdatePopup)

View file

@ -51,9 +51,8 @@ struct SearchItem {
route: Route,
}
// TODO: Replace this with inventory crate or something
// TODO Replace this with inventory crate or something
const SEARCH_INDEX: &[SearchItem] = &[
// Launcher
SearchItem {
id: "launcher.discord_rpc",
icon: IconType::Link03,
@ -70,7 +69,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["data dir", "directory", "folder", "open"],
route: Route::SettingsLauncher {},
},
// Appearance
SearchItem {
id: "appearance.accent_color",
icon: IconType::PaintPour,
@ -103,7 +101,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["reduce motion", "animations", "effects"],
route: Route::SettingsAppearance {},
},
// APIs
SearchItem {
id: "apis.modrinth_key",
icon: IconType::Key01,
@ -128,7 +125,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["endpoint", "api", "backend", "url"],
route: Route::SettingsApis {},
},
// Minecraft settings
SearchItem {
id: "minecraft.force_fullscreen",
icon: IconType::Maximize01,
@ -177,7 +173,7 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["hook", "command", "post exit", "postexit"],
route: Route::SettingsMinecraft {},
},
// Java (routing only, page isn't built from settings_row_focus yet)
// Java routing only the page is not built from settings_row_focus yet
SearchItem {
id: "java.install",
icon: IconType::Download01,
@ -194,7 +190,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["java", "runtime", "custom", "folder", "path"],
route: Route::SettingsJava {},
},
// Storage
SearchItem {
id: "storage.usage",
icon: IconType::Database01,
@ -214,7 +209,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
],
route: Route::SettingsStorage {},
},
// Language
SearchItem {
id: "language.select",
icon: IconType::Globe01,
@ -226,7 +220,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
],
route: Route::SettingsLanguage {},
},
// Developer
SearchItem {
id: "developer.compat_only",
icon: IconType::SearchMd,
@ -259,7 +252,6 @@ const SEARCH_INDEX: &[SearchItem] = &[
keywords: &["debug", "page"],
route: Route::SettingsDeveloper {},
},
// Navigation-level items
SearchItem {
id: "nav.accounts",
icon: IconType::Users01,
@ -505,9 +497,7 @@ fn search_box(mut search: State<String>) -> impl IntoElement {
)
}
/// How well a settings entry answers the query. The title is what the user is
/// looking for, so a hit there outranks one in the blurb or the hidden keywords
/// however well those scored — a description match is a hint, not the thing.
/// A title hit outranks a keyword or description hit however well those scored
fn settings_score(item: &SearchItem, query: &SearchQuery) -> Option<(u8, MatchScore)> {
if let Some(score) = query.score(item.title) {
return Some((2, score));
@ -524,8 +514,7 @@ fn search_results(query: &SearchQuery, mut search: State<String>) -> Vec<Element
.filter_map(|item| settings_score(item, query).map(|(f, s)| (f, s, item.clone())))
.collect();
// Best field, then best score, then the shortest title — the entry whose
// name is closest to being just the query.
// Shortest title last it is the entry closest to being just the query
scored.sort_by_key(|(field, score, item)| {
(
std::cmp::Reverse(*field),
@ -621,9 +610,7 @@ fn search_results(query: &SearchQuery, mut search: State<String>) -> Vec<Element
out
}
/// True when the newest changelog entry is newer than the one the user last read.
/// While the changelog is still loading or failed to load we show no dot rather
/// than guessing.
/// False while the changelog is loading or failed rather than guessing
fn has_unread_changelog() -> bool {
let settings = use_settings_snapshot().settings;
let query = use_changelog();

View file

@ -1,6 +1,6 @@
// TODO: Remove this once freya has some workaround for this
// TODO Remove this once freya has some workaround for this
#![allow(float_literal_f32_fallback)]
// Deeply nested async call chains through instrumented oneclient_core fns exceed the default limit
// Instrumented oneclient_core async call chains exceed the default limit
#![recursion_limit = "256"]
mod assets;

View file

@ -17,9 +17,8 @@ struct OneClientApp;
impl App for OneClientApp {
fn render(&self) -> impl IntoElement {
// The station holds all app state, and radio state is `!Send`, so
// everything that writes it runs on the UI thread. `spawn_forever`, not
// `spawn`: these must outlive any component scope.
// Radio state is `!Send` so every writer runs on the UI thread `spawn_forever`
// rather than `spawn` because these must outlive any component scope
let station = use_init_radio_station::<AppState, AppChannel>(AppState::default);
let actions = use_hook(move || {
@ -27,8 +26,7 @@ impl App for OneClientApp {
let (events_bus, events_rx) = oneclient_events::EventBus::channel();
let actions = Actions::new(station, signals_tx);
// Started first so nothing emitted during startup waits on a
// consumer, though the bus is unbounded either way.
// Started first so nothing emitted during startup waits on a consumer
spawn_forever(
EventPump {
events: events_rx,
@ -41,10 +39,8 @@ impl App for OneClientApp {
let startup = actions.clone();
spawn_forever(async move {
match events::start_launcher(station, events_bus).await {
// Chained rather than called alongside: `sync_bundles` needs
// the launcher handle, and it sets `syncing_bundles` before
// spawning its work, so firing it early would both fail and
// leave the flag stuck, disabling every launch button.
// Must follow startup `sync_bundles` needs the launcher handle and firing
// it early leaves `syncing_bundles` stuck disabling every launch button
Ok(()) => startup.sync_bundles(),
Err(err) => events::report_startup_failure(&station, &err),
}
@ -70,8 +66,7 @@ fn main() {
let mut builder = Builder::new_multi_thread();
builder.enable_all().max_blocking_threads(64);
// Debug builds emit unoptimized async code, which can cause stack overflows in some cases.
// Default stack size is 2MB, so we'll increase it to 3MB for debug builds
// Debug builds emit unoptimized async code that can overflow the default 2MB stack
#[cfg(debug_assertions)]
builder.thread_stack_size(3 * 1024 * 1024);
@ -126,7 +121,7 @@ fn main() {
std::env::var("ONECLIENT_GPU_CACHE_BYTES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(512 * 1024 * 1024), // 512 MB
.unwrap_or(512 * 1024 * 1024),
)
.with_default_font(theme::DEFAULT_FONT);

View file

@ -23,17 +23,12 @@ pub struct NotificationAction {
#[derive(Clone, Debug, PartialEq)]
pub enum NotificationActionKind {
/// Opens the changes modal for every cluster touched by one sync. Kept as a
/// list so a batch sync stays a single "View changes" action instead of one
/// button per cluster.
/// A list so a batch sync stays a single "View changes" action not one button per cluster
OpenClusterUpdate(Vec<ClusterUpdateSummary>),
}
/// The stale browser packages of the cluster being launched.
///
/// Carries the core updates whole rather than a display projection: the modal's
/// Update button hands one straight back to the apply path, and a trimmed copy
/// would have to be re-looked-up to find the version it is meant to install.
/// Carries core updates whole not a display projection the modal's Update button
/// hands one straight back to the apply path
#[derive(Clone, Debug, PartialEq)]
pub struct PackageUpdateGroup {
pub cluster_id: ClusterId,
@ -41,13 +36,11 @@ pub struct PackageUpdateGroup {
pub packages: Vec<BrowserPackageUpdate>,
}
/// One changed package in a cluster update, carrying enough identity to
/// resolve a pretty display name through the package-meta cache in the UI.
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateItem {
pub provider: ProviderId,
pub project_id: Option<String>,
/// Name shown when the meta cache has no entry (file name / package id).
/// Used when the meta cache has no entry (file name / package id)
pub fallback: String,
}
@ -89,12 +82,11 @@ pub struct InboxEntry {
pub created_at: Instant,
pub actions: Vec<NotificationAction>,
pub tasks: Vec<TaskView>,
/// Live transfer stats for grouped downloads (bytes/sec, seconds remaining).
/// Bytes/sec and seconds remaining for grouped downloads
pub transfer: Option<TransferStats>,
}
/// One row in the expandable task list, an aggregate over all children of a
/// single [`TaskCategory`] (e.g. all libraries collapse into one "Libraries" row).
/// One aggregate row per [`TaskCategory`] (all libraries collapse into one "Libraries" row)
#[derive(Clone, Debug, PartialEq)]
pub struct TaskView {
pub label: String,
@ -138,12 +130,10 @@ pub struct PendingPrompt {
pub question: String,
pub choices: Vec<Choice>,
pub dismiss: Option<String>,
/// Taken when answered, so a second answer cannot fire the oneshot twice.
/// Taken when answered so a second answer cannot fire the oneshot twice
pub reply_tx: Option<oneshot::Sender<Option<Answer>>>,
}
/// The renderable half of a [`PendingPrompt`]: everything except the reply
/// channel, which cannot be cloned into the snapshot.
#[derive(Clone, Debug)]
pub struct PendingPromptView {
pub title: String,
@ -153,9 +143,7 @@ pub struct PendingPromptView {
}
impl PendingPromptView {
/// Whether this prompt offers a choice with the given id. Lets a
/// specialised overlay (the Java one) recognise a prompt it knows how to
/// render richly, without the event layer naming the subsystem.
/// Lets a specialised overlay recognise a prompt without the event layer naming it
pub fn has_choice(&self, id: &str) -> bool {
self.choices.iter().any(|choice| choice.id == id)
}
@ -199,14 +187,12 @@ pub struct NotificationState {
pending_timers: Vec<ToastDismissTimer>,
cluster_update: Option<Vec<ClusterUpdateSummary>>,
package_updates: Option<Vec<PackageUpdateGroup>>,
/// Resumes the launch that opened the update modal. Held here rather than
/// by the launch task because every way the modal can end — the last row
/// answered, "Not now", Escape, the scrim — goes through this state, and
/// each of them has to let the game start.
/// Resumes the launch that opened the update modal
/// Held here not by the launch task
/// because every way the modal can end goes through this state
package_updates_done: Option<oneshot::Sender<()>>,
}
/// Fixed display order for the category rows.
const CATEGORY_ORDER: [TaskCategory; 7] = [
TaskCategory::Java,
TaskCategory::Client,
@ -231,15 +217,13 @@ struct GroupedTasks {
done_units: HashMap<TaskCategory, u64>,
done_count: HashMap<TaskCategory, u64>,
seen_count: HashMap<TaskCategory, u64>,
/// Expected counts/bytes announced up-front so totals don't climb mid-download.
/// Expected counts/bytes announced up-front so totals don't climb mid-download
reserved_count: HashMap<TaskCategory, u64>,
reserved_units: HashMap<TaskCategory, u64>,
/// Transfer rate and ETA for this group's downloads.
meter: TransferMeter,
}
impl GroupedTasks {
/// Aggregate live + finished children into one row per category, in display order.
fn task_list(&self) -> Vec<TaskView> {
CATEGORY_ORDER
.iter()
@ -259,7 +243,6 @@ impl GroupedTasks {
let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum();
let live_total: u64 = live.iter().map(|c| c.total).sum();
// Surface the most advanced phase among live children; default Downloading.
let phase = live
.iter()
.map(|c| c.phase)
@ -270,8 +253,7 @@ impl GroupedTasks {
label: cat.label().to_string(),
phase,
current: done_units + live_current,
// Reserved total keeps the denominator stable while children are
// still being added; fall back to what we've actually seen.
// Reserved total keeps the denominator stable while children are still added
total: reserved_units.max(done_units + live_total),
done_count,
total_count,
@ -280,7 +262,6 @@ impl GroupedTasks {
.collect()
}
/// Which coarse group is currently downloading, for the notification body.
fn active_body(&self) -> Option<&'static str> {
let mut minecraft = false;
let mut packages = false;
@ -318,7 +299,6 @@ impl NotificationState {
}
}
/// Entry ids with a toast currently showing.
#[must_use]
pub fn active_toast_entry_ids(&self) -> Vec<u64> {
self.active_toasts.iter().map(|t| t.entry_id).collect()
@ -335,11 +315,8 @@ impl NotificationState {
self.cluster_update = None;
}
/// Opens the pre-launch update modal, with `done` as the launch's
/// continuation.
///
/// Nothing to show is answered immediately rather than left pending: a
/// launch must never be able to wait on a modal that was never drawn.
/// `done` is the launch's continuation it fires immediately when there is nothing
/// to show so a launch never waits on a modal that was never drawn
pub fn open_package_updates(
&mut self,
groups: Vec<PackageUpdateGroup>,
@ -357,8 +334,7 @@ impl NotificationState {
return;
}
// A second modal would strand the first launch, so hand the old
// continuation back before taking the new one.
// A second modal would strand the first launch so release the old continuation first
self.finish_package_updates();
self.package_updates = Some(groups);
self.package_updates_done = done;
@ -368,10 +344,7 @@ impl NotificationState {
self.finish_package_updates();
}
/// Drops one package from the open modal, closing it once the user has
/// answered for every row. Applying and skipping are the same thing here:
/// both are answers, and neither should leave a row the user has already
/// dealt with sitting in the list.
/// Applying and skipping are both answers either drops the row and can close the modal
pub fn resolve_package_update(&mut self, cluster_id: ClusterId, hash: &str) {
let Some(groups) = self.package_updates.as_mut() else {
return;
@ -389,14 +362,11 @@ impl NotificationState {
}
}
/// Closes the modal and releases the launch waiting on it. Safe to call
/// when nothing is open; that is what makes every exit path able to call it
/// without first checking which one it is.
/// Safe to call when nothing is open so every exit path can call it unconditionally
fn finish_package_updates(&mut self) {
self.package_updates = None;
if let Some(done) = self.package_updates_done.take() {
// The launch task is gone if this fails, which is not this layer's
// problem: there is simply nothing left to resume.
// A send failure means the launch task is gone nothing left to resume
let _ = done.send(());
}
}
@ -405,10 +375,9 @@ impl NotificationState {
inbox.iter().filter(|entry| !entry.read).count()
}
/// Folds one notification into the engine state. Does not build a
/// [`NotificationSnapshot`]: during a download this runs tens of thousands
/// of times, and every snapshot deep-clones the whole inbox only for the
/// caller to discard all but the last. Callers snapshot once, after draining.
/// Deliberately does not snapshot this runs tens of thousands of times per download
/// and each snapshot deep-clones the inbox
/// Callers snapshot once after draining
pub fn dispatch(
&mut self,
inbox: &mut Vec<InboxEntry>,
@ -428,9 +397,7 @@ impl NotificationState {
);
self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL);
}
// The Microsoft login renders its own progress in the sign-in
// modal, so it must not also become a toast. This is a front-end
// policy decision about a progress id the core merely names.
// The sign-in modal renders this progress itself it must not also become a toast
Event::Progress(ProgressEvent::Update { id, .. })
if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {}
Event::Progress(ProgressEvent::Update {
@ -447,8 +414,7 @@ impl NotificationState {
Event::Progress(ProgressEvent::Grouped(event)) => {
self.handle_grouped_progress(inbox, event);
}
// Signals and game events drive the snapshot directly in the
// runtime loop; they never become inbox entries.
// Handled directly by the runtime loop they never become inbox entries
Event::Signal(_) | Event::Game(_) => {}
Event::Notification(Notification::Prompt(request)) => {
let pending_prompt = Some(PendingPrompt {
@ -682,15 +648,12 @@ impl NotificationState {
self.update_inbox_entry(inbox, entry_id, label, body, progress, !done);
self.ensure_progress_toast(entry_id);
// The id->entry mapping is kept even once `done`, so a later
// `ProgressComplete` can convert this same card into its finished state (see
// `handle_progress_complete`). It is cleaned up when the entry is dismissed
// or the engine is reset.
// The id->entry mapping is kept even once `done` so a later `ProgressComplete`
// can convert this same card into its finished state
}
/// Convert an in-flight progress card into a finished message in place. Used so an
/// operation and its "done" notice stay a single notification. Falls back to a fresh
/// message if the progress entry is already gone (e.g. dismissed mid-flight).
/// Converts the in-flight card in place so an operation and its "done" notice stay one
/// notification falls back to a fresh message if the entry was dismissed mid-flight
fn handle_progress_complete(
&mut self,
inbox: &mut Vec<InboxEntry>,
@ -830,11 +793,9 @@ impl NotificationState {
}
}
/// Converts a still-open grouped-progress entry into its finished state,
/// reusing the same inbox id. `spec: None` forgets the entry entirely (used
/// when a sync completed with nothing to show). This lets a download progress
/// notification morph into a "view changes" notification without ever
/// becoming a second entry.
/// Reuses the same inbox id so a progress card morphs into its result instead of
/// becoming a second entry
/// `spec: None` forgets the entry entirely
pub fn finish_grouped_as_actions(
&mut self,
inbox: &mut Vec<InboxEntry>,
@ -845,7 +806,6 @@ impl NotificationState {
let entry_id = self.grouped_entries.remove(&session_id);
let Some(spec) = spec else {
// Nothing changed: drop the progress entry if one was created.
if let Some(entry_id) = entry_id {
self.forget_entry(inbox, entry_id);
}
@ -874,14 +834,11 @@ impl NotificationState {
entry.actions = actions;
entry.tasks = Vec::new();
entry.transfer = None;
// The progress toast (if any) is already armed for this entry;
// leaving it in `active_toasts` lets the loop give it a dismiss
// timer now that it is no longer loading.
// Keeping it in `active_toasts` lets the loop arm a dismiss timer
// now that the entry is no longer loading
self.ensure_progress_toast(entry.id);
}
None => {
// Progress entry never materialised (e.g. no downloads happened);
// fall back to a fresh notification so the result still shows.
self.push_custom(
inbox,
NotificationSpec {
@ -915,7 +872,7 @@ impl NotificationState {
let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum();
let total: u64 = tasks.iter().map(|t| t.total).sum::<u64>().max(1);
// Below 1 B/s the estimate is noise, not information.
// Below 1 B/s the estimate is noise
let transfer = group
.meter
.sample(completed, total)

View file

@ -1,13 +1,5 @@
//! The app's own state, held in a freya-radio station.
//!
//! One struct republished over a `watch` channel woke every consumer on every
//! change (a toast timer tick re-rendering components that only read
//! `data_dir`) and deep-cloned the whole notification inbox on publish, tens of
//! thousands of times during a download.
//!
//! [`AppChannel`] gives per-concern subscription instead, and because the
//! station only requires `'static`, this owns [`NotificationState`] outright
//! rather than cloning a snapshot out of it on every event.
//! [`AppChannel`] gives per-concern subscription a single `watch` channel woke every
//! consumer and deep-cloned the inbox on every event
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@ -19,23 +11,15 @@ use oneclient_events::LaunchStage;
use crate::notifications::{InboxEntry, NotificationState, PendingPrompt};
/// What a component can subscribe to. Writing through a channel wakes only the
/// components that asked for it.
/// Writing through a channel wakes only the components that subscribed to it
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AppChannel {
/// Startup progress and the resolved data directory.
Launcher,
/// Launcher settings and their save state.
Settings,
/// Inbox, toasts and the pending prompt.
Notifications,
/// Per-cluster launch stage and live log lines.
Game,
/// Whether the account switcher popover is open.
AccountSwitcher,
/// Live progress of an in-flight Microsoft sign-in.
MicrosoftLogin,
/// Packages currently being installed into a cluster.
Installs,
}
@ -45,17 +29,12 @@ impl RadioChannel<AppState> for AppChannel {}
pub struct AppState {
pub launcher: LauncherInit,
pub settings: SettingsState,
/// The notification engine itself, not a snapshot of it.
///
/// Both the event pump and UI actions fold into this, so it has to live where
/// both can reach it, and nothing has to clone the inbox per event.
/// The engine itself not a snapshot both the pump and UI actions fold into it
pub notifications: NotificationState,
/// Held beside the engine because it folds events into the inbox by `&mut`
/// reference; keeping them together lets the engine stay a plain
/// state machine with no channels of its own.
/// Held beside the engine which folds events into it by `&mut` so the engine
/// stays a plain state machine with no channels of its own
pub inbox: Vec<InboxEntry>,
pub prompt: Option<PendingPrompt>,
/// Whether the notification centre panel is open.
pub center_open: bool,
pub game: GameState,
pub account_switcher_open: bool,
@ -63,8 +42,7 @@ pub struct AppState {
pub installs: InstallState,
}
/// Package installs that are in flight, so the button that started one can stay
/// disabled until it lands — and come back if it failed.
/// In-flight installs so the button that started one stays disabled until it lands
#[derive(Clone, Debug, Default, PartialEq)]
pub struct InstallState {
pending: HashSet<(i64, ProviderId, String)>,
@ -91,8 +69,7 @@ impl InstallState {
pub struct LauncherInit {
pub ready: bool,
pub fetching: bool,
/// True while the background cluster-bundle download is running. Launch is
/// disabled until it clears.
/// Launch is disabled until this clears
pub syncing_bundles: bool,
pub error: Option<String>,
pub data_dir: String,
@ -115,9 +92,8 @@ pub struct SettingsState {
pub error: Option<String>,
}
/// Live progress of an in-flight Microsoft sign-in, rendered inside the sign-in
/// modal rather than as a toast. UI state: the core reports this as ordinary
/// progress and has no opinion about where it is shown.
/// Rendered inside the sign-in modal rather than as a toast the core reports it
/// as ordinary progress and has no opinion about where it is shown
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LoginProgress {
pub label: String,
@ -130,11 +106,8 @@ pub struct GameState {
pub stages: HashMap<i64, LaunchStage>,
pub error: Option<String>,
pub logs: HashMap<i64, Arc<Vec<Arc<str>>>>,
/// Clusters whose launch was started from the UI but has not been answered
/// by core yet.
///
/// Core takes a few hundred ms to report [`LaunchStage::Checking`], and
/// every click that lands in that window used to spawn its own game.
/// Launches started from the UI but not yet answered by core which takes a few
/// hundred ms every click in that window otherwise spawns its own game
pending: HashSet<i64>,
}
@ -144,8 +117,7 @@ impl GameState {
self.stages.get(&cluster_id).copied()
}
/// Claims the launch for this cluster, returning false if one is already in
/// flight — the re-entrancy guard behind the button's disabled state.
/// Returns false if a launch is already in flight the re-entrancy guard
pub fn begin_launch(&mut self, cluster_id: i64) -> bool {
if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) {
return false;
@ -223,7 +195,7 @@ mod tests {
game.begin_launch(1);
assert_eq!(launch_button_state(&game, 1, false), ("Launching", false));
// Held past a failure, which parks the stage at `Exited`.
// Held past a failure which parks the stage at `Exited`
game.stages.insert(1, LaunchStage::Exited);
assert_eq!(launch_button_state(&game, 1, false), ("Launching", false));

View file

@ -10,7 +10,6 @@ pub const MONO_FONT: &str = "JetBrains Mono";
pub fn load_fonts() -> Vec<(&'static str, Bytes)> {
const FONTS: [(&str, &str); 11] = [
// UI Font
(DEFAULT_FONT, "fonts/Poppins/Poppins-Bold.ttf"),
(DEFAULT_FONT, "fonts/Poppins/Poppins-BoldItalic.ttf"),
(DEFAULT_FONT, "fonts/Poppins/Poppins-SemiBold.ttf"),
@ -21,7 +20,6 @@ pub fn load_fonts() -> Vec<(&'static str, Bytes)> {
(DEFAULT_FONT, "fonts/Poppins/Poppins-Italic.ttf"),
(DEFAULT_FONT, "fonts/Poppins/Poppins-Light.ttf"),
(DEFAULT_FONT, "fonts/Poppins/Poppins-LightItalic.ttf"),
// Mono Font
(MONO_FONT, "fonts/JetBrainsMono/JetBrainsMonoVariable.ttf"),
];

View file

@ -1,21 +1,10 @@
//! Download speed and time-remaining estimation.
//!
//! One shared estimator, so the notification engine and the onboarding
//! installer show the same numbers arrived at the same way.
use std::time::Instant;
/// Weight given to the existing average when a new sample arrives.
///
/// Downloads are bursty (a single asset finishing can spike the instantaneous
/// rate several-fold) so the average is kept sluggish on purpose.
/// Weight of the existing average per sample kept sluggish because downloads are bursty
const SMOOTHING: f64 = 0.7;
/// An exponentially-weighted average of transfer rate, plus the ETA that falls
/// out of it.
#[derive(Debug, Clone, Copy, Default)]
pub struct TransferMeter {
/// Timestamp and byte count of the last sample.
last_sample: Option<(Instant, u64)>,
speed_bps: f64,
}
@ -23,19 +12,14 @@ pub struct TransferMeter {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransferStats {
pub speed_bps: f64,
/// `None` until there is enough signal to estimate, or when the total is
/// unknown.
/// `None` until there is enough signal to estimate or when the total is unknown
pub eta_secs: Option<u64>,
}
impl TransferMeter {
/// Records progress and returns the current estimate.
///
/// `completed` and `total` are cumulative byte counts. Samples closer
/// together than a few hundred milliseconds are ignored rather than folded
/// in: the elapsed divisor gets small enough to swamp the average with
/// noise, which is what the fixed-tick version was working around by
/// sampling on a timer instead.
/// `completed` and `total` are cumulative bytes
/// Samples under 250ms apart are not folded in
/// the tiny elapsed divisor would swamp the average with noise
pub fn sample(&mut self, completed: u64, total: u64) -> Option<TransferStats> {
let now = Instant::now();
@ -70,8 +54,7 @@ impl TransferMeter {
let remaining = total.saturating_sub(completed);
Some(TransferStats {
speed_bps: self.speed_bps,
// No total means no estimate, which beats a countdown derived from a
// denominator that is still climbing.
// No total means no estimate the denominator may still be climbing
eta_secs: (total > 0).then(|| (remaining as f64 / self.speed_bps) as u64),
})
}
@ -111,8 +94,6 @@ mod tests {
assert!(late < early, "eta should shrink: {early} -> {late}");
}
/// Rapid updates are the common case during an asset fan-out; they must not
/// divide by a near-zero elapsed and blow the average up.
#[test]
fn samples_taken_too_close_together_do_not_distort_the_average() {
let mut meter = TransferMeter::default();

View file

@ -28,12 +28,8 @@ pub fn fmt_date(ts: chrono::DateTime<chrono::Utc>) -> String {
ts.format("%Y-%m-%d %H:%M").to_string()
}
/// How long ago, for a timestamp shown next to something that happened.
///
/// Nothing re-renders on a timer, so the shortest band is a whole minute: a
/// seconds counter would freeze at whatever it read when the row was drawn and
/// quietly lie from then on. The version this replaced had a seconds band and
/// no callers.
/// Shortest band is a minute nothing re-renders on a timer so a seconds counter
/// would freeze at whatever it read when the row was drawn
pub fn relative_time(created_at: Instant) -> String {
let secs = created_at.elapsed().as_secs();
match secs {
@ -44,10 +40,7 @@ pub fn relative_time(created_at: Instant) -> String {
}
}
/// A one-pixel rule in the component-border colour.
///
/// Returns the `Rect` rather than an `Element` so a caller that wants it inset
/// or rounded can say so without copying the whole thing out again.
/// Returns the `Rect` not an `Element` so callers can inset or round it
pub fn divider() -> Rect {
rect()
.width(Size::fill())
@ -55,8 +48,6 @@ pub fn divider() -> Rect {
.background(theme::colors::component_border())
}
/// A quiet line of text centred in a fixed block: "nothing here yet", or a
/// failure where a chart would otherwise be.
pub fn centered_note(text: &str) -> Element {
rect()
.width(Size::fill())
@ -71,10 +62,7 @@ pub fn centered_note(text: &str) -> Element {
.into_element()
}
/// How many columns no wider than `max_col` fit across `width`.
///
/// One column until the grid has measured itself, so the first frame renders
/// something rather than dividing by an unknown.
/// Returns 1 until the grid has measured itself so the first frame still renders
pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize {
if width <= 0. {
return 1;
@ -82,13 +70,8 @@ pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize {
(((width + gap) / (max_col + gap)).ceil() as usize).max(1)
}
/// Lays `items` out in `cols` equal columns, filling row by row.
///
/// There is no wrapping flex row to lean on, so the split is done here from the
/// width the grid last measured. It reports its own size back through `width`,
/// which the caller feeds to [`grid_columns_for_width`] on the next render.
/// Short final rows are padded with empty flex cells so their tiles keep the
/// column width instead of stretching across the row.
/// Reports its measured size back through `width` for [`grid_columns_for_width`]
/// Short final rows are padded with empty flex cells so tiles keep the column width
pub fn flow_grid(items: Vec<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
let cols = cols.max(1);

View file

@ -34,7 +34,7 @@ pub fn spawn_update_check(auto_install: bool) {
});
}
/// Debug-only: drives the full auto-update UX
/// Debug-only drives the full auto-update UX
pub fn spawn_simulated_update() {
tokio::spawn(async move {
if let Err(err) = run_simulated_update().await {
@ -45,7 +45,7 @@ pub fn spawn_simulated_update() {
async fn run_simulated_update() -> anyhow::Result<()> {
const FAKE_VERSION: &str = "9999.9999.9999";
const FAKE_TOTAL: u64 = 48 * 1024 * 1024; // 48 MiB
const FAKE_TOTAL: u64 = 48 * 1024 * 1024;
let events = crate::launcher::state()?.services.events.clone();
@ -77,7 +77,7 @@ async fn run_simulated_update() -> anyhow::Result<()> {
async fn run_check(auto_install: bool) -> anyhow::Result<()> {
let events = crate::launcher::state()?.services.events.clone();
// `check_update` performs a blocking HTTP request, so offload it to a thread pool.
// `check_update` performs a blocking HTTP request so offload it to a thread pool
let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else {
tracing::info!("no update available");
return Ok(());
@ -85,12 +85,8 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> {
tracing::info!("update available: {}", update.version);
// The bundled self-updater can only replace an AppImage in place: on Linux
// cargo-packager-updater rejects every format except AppImage and locates the
// target via the `APPIMAGE` env var. deb/rpm installs run from a package-managed
// path (e.g. /usr/bin) we can't overwrite without root. Attempting an install
// would fail with a permission error, or clobber the system binary if elevated.
// Detect that case and point the user at the release page instead.
// cargo-packager-updater can only replace an AppImage in place deb/rpm installs live
// under a package-managed path that an install would fail on or clobber
if !can_self_update() {
tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only");
events
@ -111,7 +107,6 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> {
download_and_install(update, events).await
}
/// Whether the running install can be updated in place by the bundled updater.
fn can_self_update() -> bool {
if cfg!(debug_assertions) {
return false;
@ -175,8 +170,7 @@ async fn download_and_install(update: Update, events: EventBus) -> anyhow::Resul
update.install(bytes)?;
// Convert the same download card into its finished state instead of emitting a
// second notification, so the user sees one notification through to completion.
// Converts the same download card into its finished state rather than adding a second
events.finish_progress(
progress_id,
"Finished Downloading",

View file

@ -8,14 +8,12 @@ use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_
pub type ClusterGroups = BTreeMap<ReleaseLine, Vec<Cluster>>;
/// One entry on the versions page. The modern scheme puts a full release in the
/// first two components (`26.1`, `26.2`), so each minor is its own line; legacy
/// `1.x` versions keep the whole major (`1.21`) as one line. A line that holds a
/// single concrete version is presented as that version (see [`line_title`]).
/// The modern scheme puts a full release in the first two components (`26.1`) so each
/// minor is its own line legacy `1.x` versions keep the whole major (`1.21`) as one line
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ReleaseLine {
pub major: u32,
/// `Some` only for the modern scheme, where the minor is part of the line.
/// `Some` only for the modern scheme where the minor is part of the line
pub minor: Option<u32>,
}
@ -40,7 +38,7 @@ impl ReleaseLine {
Self::from_version(&cluster.mc_version)
}
/// The generic name of the line: `26.1`, `1.21`.
/// The generic name of the line `26.1` `1.21`
pub fn pretty_name(&self) -> String {
match self.minor {
Some(minor) => format!("{}.{minor}", self.major),
@ -48,8 +46,6 @@ impl ReleaseLine {
}
}
/// Metadata/art lookup key for the line itself, when no single version
/// stands in for it.
fn art_key(&self) -> Option<VersionKey> {
self.minor.map(|minor| (minor, None))
}
@ -59,7 +55,6 @@ fn cluster_key(cluster: &Cluster) -> Option<VersionKey> {
parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key())
}
/// The one concrete version behind `clusters`, if they all share it.
fn sole_version_key(clusters: &[Cluster]) -> Option<VersionKey> {
match version_keys(clusters).as_slice() {
[only] => Some(*only),
@ -67,8 +62,7 @@ fn sole_version_key(clusters: &[Cluster]) -> Option<VersionKey> {
}
}
/// A line with a single version is shown as that full version (`26.1.2`);
/// otherwise the generic line name (`1.21`), with the versions in a dropdown.
/// A line with a single version is shown as that full version (`26.1.2`) else `1.21`
pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String {
match sole_version_key(clusters) {
Some(key) => version_label(line.major, key),
@ -76,7 +70,6 @@ pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String {
}
}
/// Metadata/art key matching [`line_title`].
pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option<VersionKey> {
sole_version_key(clusters).or_else(|| line.art_key())
}
@ -151,7 +144,7 @@ pub fn resolve_cluster(
.cloned()
}
/// The line the page opens on: the active cluster's line, else the newest.
/// The line the page opens on the active cluster's line else the newest
pub fn default_line(groups: &ClusterGroups, active: Option<Cluster>) -> Option<ReleaseLine> {
if let Some(cluster) = active
&& let Some(line) = ReleaseLine::for_cluster(&cluster)
@ -194,7 +187,6 @@ pub fn version_label(major: u32, (minor, patch): VersionKey) -> String {
format_mc_version(major, minor, patch)
}
/// Human-readable byte size (B / KB / MB).
pub fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.;
const MB: f64 = KB * 1024.;
@ -212,7 +204,7 @@ pub fn format_res((w, h): (u32, u32)) -> String {
format!("{w}×{h}")
}
/// `7384` -> `2h 3m`, `540` -> `9m`, `0` -> `0m`.
/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m`
pub fn format_duration_hm(secs: i64) -> String {
if secs <= 0 {
return "0m".to_string();
@ -226,7 +218,7 @@ pub fn format_duration_hm(secs: i64) -> String {
}
}
/// `3723` -> `1h 2m`, `83` -> `1m 23s`, `45` -> `45s`.
/// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s`
pub fn format_duration_hms(secs: i64) -> String {
if secs <= 0 {
return "0s".to_string();
@ -240,7 +232,7 @@ pub fn format_duration_hms(secs: i64) -> String {
}
}
/// Compact large counts: `1500` -> `1.5K`, `2_400_000` -> `2.4M`.
/// `1500` -> `1.5K` `2_400_000` -> `2.4M`
pub fn abbreviate_number(n: u64) -> String {
let f = n as f64;
if f >= 1_000_000.0 {
@ -252,7 +244,6 @@ pub fn abbreviate_number(n: u64) -> String {
}
}
/// English plural suffix: `""` for 1, `"s"` otherwise.
pub fn plural(n: i64) -> &'static str {
if n == 1 { "" } else { "s" }
}
@ -265,7 +256,7 @@ pub fn capitalize(s: &str) -> String {
}
}
/// 24-hour clock label: `9` -> `09:00`.
/// 24-hour clock label `9` -> `09:00`
pub fn format_hour(hour: usize) -> String {
format!("{hour:02}:00")
}
@ -274,7 +265,7 @@ pub fn parse_day(date: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
}
/// `2026-07-05` -> `Jul 5`.
/// `2026-07-05` -> `Jul 5`
pub fn format_day(date: NaiveDate) -> String {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
@ -289,11 +280,11 @@ pub fn sort_clusters_for_home(mut clusters: Vec<Cluster>) -> Vec<Cluster> {
fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering {
match (a.last_played, b.last_played) {
// Most recently played first.
// Most recently played first
(Some(a), Some(b)) => b.cmp(&a),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
// Never played: latest version first (major, then minor).
// Never played latest version first (major then minor)
(None, None) => version_sort_key(b).cmp(&version_sort_key(a)),
}
}

View file

@ -19,9 +19,7 @@ pub fn analytics_body(analytics: &Analytics) -> Element {
analytics_body_inner(analytics, false)
}
/// `force_all` keeps the sections that are normally hidden for lack of data
/// (session length, servers) on screen, so the zeroed empty state still shows
/// the whole layout.
/// `force_all` keeps the normally-hidden sections (session length servers) on screen
fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element {
let stats = &analytics.playtime;
@ -49,10 +47,7 @@ fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element {
root.into_element()
}
/// Renders the full analytics layout with zeroed data, dimmed behind a scrim
/// with `note` centered on top. Used by the cluster overview and global
/// statistics pages when nothing has been recorded yet, so the charts and stats
/// are always visible without inventing numbers.
/// Zeroed data behind a scrim with `note` on top so charts stay visible without inventing numbers
pub fn analytics_placeholder(note: &str) -> Element {
rect()
.width(Size::fill())
@ -88,9 +83,7 @@ pub fn analytics_placeholder(note: &str) -> Element {
.into_element()
}
/// Every field zeroed: real, honest "nothing recorded" data rather than
/// invented numbers. The daily series is a run of zero-second days ending today
/// so the timeline still renders its columns for each range selector.
/// A run of zero-second days ending today so the timeline still renders columns for each range
fn empty_analytics() -> Analytics {
const EMPTY_DAYS: i64 = 90;

View file

@ -12,7 +12,7 @@ use crate::utils::{format_duration_hm, plural};
use super::{card, card_header};
/// Number of servers charted; the rest are grouped into an "Other" slice.
/// Number of servers charted the rest are grouped into an "Other" slice
const TOP_SERVERS: usize = 10;
pub(super) fn servers_section(servers: &[ServerStat]) -> Element {
@ -166,9 +166,7 @@ fn legend(values: &[i64], labels: &[String], active: Option<usize>) -> Element {
list.into_element()
}
/// A Component (not a plain builder) because it mounts a `ScrollArea`, whose
/// hooks run in the caller's render scope. Mounting it conditionally from
/// `ServersSection` would change that scope's hook count and panic Freya.
/// A Component not a builder mounting its `ScrollArea` conditionally would change the caller's hook count and panic Freya
#[derive(PartialEq)]
struct ServerDetailsPopup {
servers: Vec<ServerStat>,
@ -250,8 +248,7 @@ impl Component for ServerDetailsPopup {
}
}
/// IP addresses stay masked in the always-visible chart legend; the details
/// popup reveals them on hover only.
/// IPs stay masked in the always-visible legend the details popup reveals them on hover
fn server_label(s: &ServerStat) -> String {
if s.is_ip {
return "Direct IP".to_string();

View file

@ -17,7 +17,7 @@ use crate::ui::border_all_color;
type InstalledMap = HashMap<(ProviderId, String), Installed>;
/// Diameter of the round install button.
/// Diameter of the round install button
const INSTALL_BUTTON: f32 = 30.;
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<InstallSource> {
@ -137,9 +137,7 @@ impl Component for PackageCard {
.corner_radius(CornerRadius::new(10., 10., 0., 0.))
.overflow(Overflow::Clip)
.child(PackageBanner::new(icon_url, BANNER_H))
// Sat in the banner's top-left corner rather than down in
// the footer: it is the first thing worth knowing about a
// result, and the opposite corner is the install button's.
// Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's
.maybe_child(self.installed.map(|installed| {
rect()
.position(Position::new_absolute().top(8.).left(8.))
@ -298,9 +296,7 @@ impl Component for ListRow {
.color(colors::fg_secondary()),
)
.child(Icon::new(item.provider).size(12.))
// Beside the attribution rather than out at the far
// right, where it drifted away from the package it
// describes and crowded the install button.
// Beside the attribution rather than far right where it drifted from the package and crowded the install button
.maybe_child(
self.installed.map(|installed| {
installed_badge(installed, 10.).into_element()
@ -317,9 +313,7 @@ impl Component for ListRow {
),
)
.child(downloads_row(item.downloads))
// Kept in place once the package is in the cluster, rather than
// dropped: a row that loses its button leaves the ones around it
// looking misaligned. Disabled says the same thing and holds still.
// Kept once installed rather than dropped a row that loses its button leaves its neighbours misaligned
.child(InstallButton::new(
&self.item,
self.cluster_id,
@ -329,17 +323,14 @@ impl Component for ListRow {
}
}
/// Installs a listing entry's latest compatible version without making the user
/// open its page first. Same version pick and same dispatch as the install
/// button on the package page.
/// Same version pick and dispatch as the install button on the package page
#[derive(PartialEq)]
struct InstallButton {
provider: ProviderId,
project_id: String,
cluster_id: i64,
content_type: ContentType,
/// The cluster already has this one, so there is nothing left to press.
/// Callers that hide the button entirely in that case pass `false`.
/// Cluster already has this one callers that hide the button entirely pass `false`
installed: bool,
}
@ -365,8 +356,7 @@ impl Component for InstallButton {
let compat = *use_browser_compat().read();
let cluster = use_cluster(cluster_id);
// The same narrowing the package page does before calling the first
// entry of the list "latest", so both agree on what gets installed.
// The same narrowing the package page does so both agree on what "latest" installs
let (game_version, loader) = match (compat, &cluster) {
(true, Some(c)) => (
Some(c.mc_version.clone()),
@ -387,14 +377,11 @@ impl Component for InstallButton {
));
let latest = versions.first().map(|v| v.version_id.clone());
// Nothing to start twice while the install that was just kicked off is
// still running, and nothing to start before the version list arrives.
// Nothing to start twice while an install is running or before versions arrive
let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id);
rect()
// The card behind this is one big press target. A press on the
// button is not a press on the card, so it stops here rather than
// opening the package page on top of the install.
// The card behind is one big press target stop here so the package page doesn't open on top of the install
.on_all_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
Button::new()

View file

@ -68,8 +68,7 @@ fn encode_package_id(provider: ProviderId, id: &str) -> String {
pub struct Browser {
pub cluster_id: i64,
pub package_type: String,
/// Entered from the navbar rather than from a cluster's content page, so
/// the cluster it browses for is picked here instead of being decided.
/// Entered from the navbar so the cluster to install into is picked here
pub pick_cluster: bool,
}
@ -104,7 +103,7 @@ impl Component for Browser {
});
}
// Debounce the search query so typing doesn't fire a request per keystroke.
// Debounce the search query so typing doesn't fire a request per keystroke
let debounced_query = use_debounced(
query.read().clone(),
Duration::from_millis(SEARCH_DEBOUNCE_MS),
@ -128,8 +127,7 @@ impl Component for Browser {
};
let mut page_state = page;
// Normalised the same way the search key is, so a query that only picked
// up a space is not treated as a new search that resets the page.
// Normalised like the search key so a query differing only by a space does not reset the page
let signature = format!(
"{provider_id:?}|{}|{compat}|{}",
normalize_query(&debounced_query.read()).to_lowercase(),
@ -166,9 +164,7 @@ impl Component for Browser {
let pending = search_pending(&search);
let current_page = *page.read();
// A page switch re-runs the search, which reports no total until it
// settles. Holding the last count keeps the pager in place — disabled
// while the page loads — instead of dropping out and back in.
// A page switch reports no total until it settles holding the last count keeps the pager in place
let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1));
let mut settled_pages = use_state(|| live_pages);
if !pending {
@ -298,7 +294,7 @@ fn page_title(
)
.maybe_child(match picker {
Some(picker) => Some(picker.into_element()),
// Without the picker the cluster is fixed, so it's just a subtitle.
// Without the picker the cluster is fixed so it's just a subtitle
None => cluster_name.map(|name| {
label()
.text(format!("for {name}"))
@ -309,8 +305,7 @@ fn page_title(
})
}
/// The version's display name from the manifest ("Tricky Trials"), falling back
/// to the bare version while the manifest hasn't arrived or doesn't cover it.
/// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it
fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
parse_mc_version(&cluster.mc_version)
.and_then(|parsed| {
@ -325,8 +320,7 @@ fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
.unwrap_or_else(|| cluster.mc_version.clone())
}
/// Switches which cluster the browser installs into. The choice lives in the
/// route, so going back and forward keeps the cluster that was being browsed.
/// The choice lives in the route so back and forward keep the cluster being browsed
#[derive(PartialEq)]
struct ClusterPicker {
cluster_id: i64,

View file

@ -17,12 +17,9 @@ use crate::ui::border_all_color;
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
/// How a browsed package is already present in the cluster.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum InstallSource {
/// Installed on its own.
Manual,
/// Comes from one of the cluster's bundles.
Bundled,
}
@ -34,8 +31,7 @@ impl InstallSource {
}
}
/// Bundled reads blue rather than green: the package is there, but the
/// cluster's bundle put it there, not the user.
/// Bundled reads blue rather than green the cluster's bundle put it there not the user
fn color(self) -> Color {
match self {
Self::Manual => colors::success(),
@ -44,32 +40,22 @@ impl InstallSource {
}
}
/// One version of a browsed package that the cluster has.
#[derive(Clone, PartialEq)]
pub(crate) struct InstalledVersion {
pub version_id: String,
/// The artifact to remove. `None` when a bundle names this version but
/// nothing is linked yet, which is a state the user cannot act on.
/// The artifact to remove
/// `None` when a bundle names this version but nothing is linked
pub hash: Option<String>,
/// Whether the game loads this one. Only meaningful alongside a `hash`;
/// a bundle pin with nothing linked is neither on nor off, and the badge
/// that reads this never renders for one.
/// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off
pub enabled: bool,
pub source: InstallSource,
}
/// A browsed package the cluster already has.
///
/// More than one version is not a state the launcher aims for, but nothing
/// stops a cluster reaching it, so this holds every one it finds rather than an
/// arbitrary winner — the version list marks each of them.
/// Holds every version found rather than an arbitrary winner a cluster can end up with more than one
#[derive(Clone, PartialEq)]
pub(crate) struct Installed {
/// How the package as a whole got here, for the badge on cards and the
/// sidebar. A bundle claiming the project wins over a hand-installed copy.
/// A bundle claiming the project wins over a hand-installed copy
pub source: InstallSource,
/// Every version in the cluster the provider recorded one for, in the order
/// the cluster reports them.
pub versions: Vec<InstalledVersion>,
}
@ -82,19 +68,13 @@ impl Installed {
self.versions.iter().find(|v| v.version_id == version_id)
}
/// Whether the cluster holds more than one copy of this package.
///
/// Counts linked artifacts only: a bundle pin the cluster never downloaded
/// is listed as a version but is not a second copy of anything.
/// Counts linked artifacts only a bundle pin the cluster never downloaded is not a second copy
pub fn is_duplicated(&self) -> bool {
self.versions.iter().filter(|v| v.hash.is_some()).count() > 1
}
}
/// Indexes what the cluster already has by the project it came from, so a
/// search result can tell at a glance whether it's in there and how it got
/// there. Local files have no project to match a search result against and
/// are left out, as are a bundle's external (non-provider) files.
/// Local files and a bundle's external files are left out they have no project id to match against
pub(crate) fn installed_map(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
@ -106,9 +86,7 @@ pub(crate) fn installed_map(
continue;
};
// The entry is created even for an artifact with no recorded version:
// the package is in the cluster either way, and the card badge only
// asks that much. It just cannot be tied to a row in the version list.
// Created even without a recorded version the package is in the cluster it just cannot be tied to a version row
let installed = map.entry((provider, project_id)).or_insert(Installed {
source: InstallSource::Manual,
versions: Vec::new(),
@ -124,11 +102,8 @@ pub(crate) fn installed_map(
}
}
// Bundle membership wins: a bundle's files land in the cluster as ordinary
// content, so they'd otherwise read as hand-installed. The version on disk
// is the truth when there is one; the manifest's pin is the fallback, and
// is deliberately not added alongside a linked version — a pin the cluster
// has not downloaded is not something the user has installed.
// Bundle membership wins a bundle's files would otherwise read as hand-installed
// The manifest pin is only a fallback for a missing linked version
for bundle in bundles {
for (file, _status) in &bundle.files {
if let BundleFileKind::Managed {
@ -175,9 +150,7 @@ pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl
badge(installed, font_size, installed.color().with_a(38), None)
}
/// The same badge sat on top of a card's artwork. The faint tint the in-card
/// badge uses has nothing to read against there, so this one brings its own
/// backdrop and outlines itself to stay legible over whatever the image is.
/// Brings its own backdrop and outline so it stays legible over card artwork
pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement {
badge(
installed,
@ -187,7 +160,7 @@ pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElem
)
}
/// Which of several installed versions the game actually loads.
/// Which of several installed versions the game actually loads
pub(crate) fn activity_badge(active: bool) -> impl IntoElement {
let (text, color) = if active {
("Active", colors::brand())
@ -195,8 +168,7 @@ pub(crate) fn activity_badge(active: bool) -> impl IntoElement {
("Inactive", colors::fg_secondary())
};
// No icon, unlike the source badges it sits beside: three pills in a row
// all wearing a tick reads as decoration rather than as three facts.
// No icon three pills in a row all wearing a tick reads as decoration
rect()
.horizontal()
.cross_align(Alignment::Center)

View file

@ -1,10 +1,4 @@
//! The package page's gallery tab.
//!
//! Laid out like the cluster's screenshots: a grid of tiles that open into a
//! full-window viewer with arrows between them. The difference is where the
//! pixels come from — these are remote images behind the shared image cache
//! rather than files on disk, so a tile and the opened view ask the cache for
//! the same url at two different sizes instead of sharing one decode.
//! Remote images behind the shared image cache a tile and the opened view ask for the same url at two different sizes
use super::*;
@ -15,14 +9,12 @@ use crate::hooks::{loaded_image, query_is_busy, use_cached_image};
use crate::theme::colors;
use crate::ui::{border_all_color, flow_grid, grid_columns_for_width};
/// How wide a tile is allowed to get before the grid takes another column.
/// How wide a tile is allowed to get before the grid takes another column
const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.;
const TILE_PREVIEW_H: f32 = 168.;
/// Downscale ceilings handed to the image cache. A tile never shows more than
/// its own width, and asking for the full thing there was what made the grid
/// slow to fill; the opened view is the one that needs the detail.
/// Downscale ceilings for the image cache full-size tiles made the grid slow to fill
const TILE_EDGE: u32 = 640;
const FULL_EDGE: u32 = 2048;
@ -159,8 +151,6 @@ impl Component for GalleryTile {
}
}
/// The opened image, filling the window with the rest of the gallery a key
/// press away.
#[derive(PartialEq)]
struct GalleryViewer {
images: Vec<GalleryImage>,
@ -204,10 +194,7 @@ impl Component for GalleryViewer {
.aspect_ratio(AspectRatio::Min)
.into_element()
}))
// The tile's smaller copy is already cached, but this one is a
// fresh fetch at full size, so the first moment of the opened view
// says so rather than showing an empty frame. Only while it is
// still working: a fetch that failed is not loading.
// A full-size fetch separate from the tile's cached copy a failed fetch is not loading
.maybe_child(query_is_busy(&query).then(|| {
label()
.text("Loading image...")

View file

@ -200,9 +200,7 @@ fn version_row(
cluster_id: i64,
dispatch: Actions,
installed: Option<InstalledVersion>,
// Whether the cluster has more than one version of this package, which is
// the only situation where saying which one is live tells the user
// anything.
// Saying which version is live only tells the user anything when there are several
duplicated: bool,
installing: bool,
) -> impl IntoElement {
@ -275,11 +273,7 @@ fn version_row(
))
}
/// Install, or remove the version that is already there.
///
/// A bundle that names this version without the cluster having linked anything
/// yet leaves nothing to press: there is no artifact to remove, and installing
/// it by hand would make a copy the bundle does not own.
/// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it
#[allow(clippy::too_many_arguments)]
fn version_button(
installed: Option<InstalledVersion>,

View file

@ -27,8 +27,7 @@ pub(super) fn sidebar(
};
let project_id = project.id.clone();
// Nothing to do when the cluster already has exactly this version, and
// nothing to do twice while the install that was just started is running.
// Nothing to do when this version is already there or while an install is running
let have_latest = match (&installed, &latest_version) {
(Some(installed), Some(latest)) => installed.is_version(latest),
_ => false,
@ -48,10 +47,6 @@ pub(super) fn sidebar(
.overflow(Overflow::Clip)
.background(PANEL_BG)
.border(border_all_color(1., colors::component_border()))
// The badge rides on the artwork here for the same reason it
// does on a listing card: it belongs to the package, and it
// used to sit in a strip of its own between the card and the
// install button.
.child(
rect()
.width(Size::fill())
@ -151,8 +146,7 @@ pub(super) fn sidebar(
.into_element()
}
/// The package's own page on the provider's site. Modrinth doesn't hand one
/// out, so it's rebuilt from the slug; CurseForge already lists it as "Website".
/// Modrinth doesn't hand a url out so it's rebuilt from the slug CurseForge lists it as "Website"
fn provider_project_url(project: &ProjectDetail) -> Option<String> {
match project.provider {
ProviderId::Modrinth => Some(format!(
@ -170,8 +164,7 @@ fn provider_project_url(project: &ProjectDetail) -> Option<String> {
}
}
/// "Mod on Modrinth", linking out to the package's page there when the provider
/// has one. Plain text otherwise, so a local package doesn't look clickable.
/// Plain text when the provider has no page so a local package doesn't look clickable
#[derive(PartialEq)]
struct ProviderTag {
text: String,
@ -225,8 +218,7 @@ fn card(title: &str, rows: Vec<Element>) -> impl IntoElement {
card_spaced(title, rows, 10.)
}
/// `card` with the gap between rows chosen by the caller: a list of one-line
/// links reads better tight than the multi-line author and detail rows do.
/// `card` with a caller-chosen row gap one-line link lists read better tight
fn card_spaced(title: &str, rows: Vec<Element>, spacing: f32) -> impl IntoElement {
rect()
.vertical()

View file

@ -259,9 +259,7 @@ impl Component for VerifyFilesRow {
mutation.mutate(ClusterAction::VerifyFiles { cluster_id });
};
// The pass reads every installed file, so it is worth saying that it
// takes a while — otherwise a button that sits there for a minute reads
// as broken rather than busy.
// The pass reads every installed file so say it takes a while otherwise the button reads as broken
let button = Button::new()
.small()
.secondary()

View file

@ -65,10 +65,7 @@ enum Confirm {
Upload,
}
/// Which half of the cluster's files the picker is listing.
///
/// Crash reports and ordinary logs answer different questions, and a crash
/// report buried among thirty rotated `.log.gz` files is one nobody finds.
/// A crash report buried among thirty rotated `.log.gz` files is one nobody finds
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Category {
Logs,
@ -131,9 +128,7 @@ impl Component for ClusterLogs {
let mut selected = use_state(PathBuf::new);
let mut handled_upload = use_state(|| None::<String>);
// Reads `category` as well as the query, so switching the picker to
// crash reports re-picks a file instead of leaving the viewer on a
// selection that is no longer in the list.
// Reads `category` too so switching the picker re-picks a file instead of keeping a stale selection
use_side_effect(move || {
let files = visible_files(&logs_query, *category.read());
@ -248,7 +243,6 @@ impl Component for ClusterLogs {
}
}
/// The files the picker should be showing right now.
fn visible_files(query: &UseQuery<ClusterLogsQuery>, category: Category) -> Vec<LogFileInfo> {
try_cluster_logs(query)
.unwrap_or_default()
@ -398,9 +392,7 @@ fn viewer_header(
struct LogPicker {
label: String,
files: Vec<LogFileInfo>,
/// Owned by the page rather than by this component: the page filters
/// `files` by it and re-picks a selection when it changes, so both have to
/// be looking at the same value.
/// Owned by the page it filters `files` by this so both must see the same value
category: State<Category>,
on_select: EventHandler<PathBuf>,
}
@ -417,9 +409,7 @@ impl Component for LogPicker {
let label_text = self.label.clone();
let files = self.files.clone();
let on_select = self.on_select.clone();
// Stays usable with an empty list: the category selector lives inside
// the panel, so a cluster with no crash reports still needs a way back
// to its logs.
// Stays usable with an empty list the category selector lives inside the panel
let is_open = open();
let query = SearchQuery::new(&filter.read());
@ -507,8 +497,7 @@ impl Component for LogPicker {
rect()
.margin(Gaps::new(4., 0., 0., 0.))
.width(Size::px(320.))
// Room for the category selector above the search
// box, on top of the list's own maximum.
// Room for the category selector above the search box
.max_height(Size::px(400.))
.layer(Layer::OverlayLevel(12))
.vertical()

View file

@ -71,14 +71,8 @@ pub fn use_content_meta(
out
}
/// Builds the rows for one content type.
///
/// `stale` is the set of artifact hashes the last browser update check found a
/// newer version for. Bundle-provided rows are built before it is consulted and
/// never carry the flag, so the two update flows cannot both claim a package.
///
/// Rows for a bundle's hidden files are included and flagged; whether they
/// reach the screen is [`HiddenFilter`]'s call.
/// `stale` is the artifact hashes with a newer version bundle rows never carry it so the two update flows cannot both claim a package
/// Hidden bundle files get flagged rows [`HiddenFilter`] decides whether they show
pub fn bundle_packages(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
@ -96,9 +90,7 @@ pub fn bundle_packages(
by_hash.insert(info.hash.as_str(), info);
}
// A package another bundle ships as a normal, user-facing file. Hidden is a
// per-bundle property, so one bundle carrying a mod as a private dependency
// must not suppress the bundle that offers it openly.
// Hidden is per-bundle so one bundle carrying a mod as a private dependency must not suppress a bundle that offers it openly
let mut shown_elsewhere: HashSet<String> = HashSet::new();
for bundle in bundles {
for (file, _status) in &bundle.files {
@ -119,8 +111,7 @@ pub fn bundle_packages(
continue;
}
let pid = file.kind.package_id();
// Let the bundle that lists it openly own the row, whatever the
// iteration order happens to be.
// Let the bundle that lists it openly own the row whatever the iteration order
if file.hidden && shown_elsewhere.contains(&pid) {
continue;
}
@ -162,10 +153,7 @@ pub fn bundle_packages(
meta,
file.display_name(),
false,
// Emitted as an ordinary row carrying the flag rather than
// dropped here: the row is what `HiddenFilter` filters on, and
// the id counting as seen keeps the loose-content pass below
// from resurrecting the installed artifact as a local file.
// Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file
file.hidden,
));
}
@ -278,27 +266,20 @@ impl Tab {
pub(super) fn matches(&self, p: &PackageEntry) -> bool {
match self {
// Every package assigned to the cluster, regardless of origin.
Tab::All => true,
Tab::Category(c) => p.categories.iter().any(|pc| pc == c),
// Browser = remote provider content that is NOT provided by a bundle.
Tab::Browser => p.is_remote() && !p.in_bundle(),
Tab::Local => !p.is_remote(),
}
}
}
/// A package is searched by what the user sees (its display name) and by what
/// is on disk (its file name), whichever matches better.
fn query_score(p: &PackageEntry, query: &SearchQuery) -> Option<MatchScore> {
query.best_score([p.name.as_str(), p.file_name.as_str()])
}
/// Rows to show for the current toolbar state. The search is scoped to the
/// active tab: a package has to belong to the tab *and* match the query, so
/// typing in a category never pulls in packages from another one. `Tab::All`
/// admits everything, which keeps a search from there a search over the whole
/// cluster.
/// Search is scoped to the active tab a package must match the tab *and* the query
/// `Tab::All` searches the whole cluster
fn visible_packages(
items: &[PackageEntry],
tab: Option<&Tab>,
@ -314,9 +295,7 @@ fn visible_packages(
.collect()
}
/// Reorders search results so the closest matches come first. Stable, so the
/// sort the user picked in the toolbar survives as the tie-break between rows
/// that matched equally well.
/// Stable so the toolbar's sort survives as the tie-break between equally good matches
fn rank_by_query(rows: &mut [PackageEntry], query: &SearchQuery) {
rows.sort_by_key(|p| std::cmp::Reverse(query_score(p, query)));
}
@ -332,8 +311,7 @@ pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec<String> {
cats
}
/// `hidden` is applied here too, so a category whose only members are hidden
/// bundle dependencies does not offer a tab that leads to an empty list.
/// `hidden` applies here too so a category of only hidden dependencies offers no tab
fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilter) -> Vec<Tab> {
let mut cats: Vec<String> = categories.to_vec();
for item in items.iter().filter(|p| hidden.keep(p)) {
@ -344,7 +322,7 @@ fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilte
}
}
// Category tabs are hidden when empty; All + Browser + Local are always shown.
// Category tabs are hidden when empty All + Browser + Local are always shown
let mut tabs: Vec<Tab> = vec![Tab::All];
tabs.extend(
cats.into_iter()
@ -398,9 +376,7 @@ impl Component for PackageManager {
let cluster_id = self.cluster_id;
let content_type = self.content_type;
// Minecraft reads its content once at startup, so a toggle made now is
// recorded but cannot reach the session already playing. Say so rather
// than letting the switch look like it did nothing.
// Minecraft reads its content once at startup so a toggle now cannot reach the running session
let session_live = use_game_snapshot().is_active(cluster_id);
let active = use_state(|| 0usize);
@ -427,16 +403,12 @@ impl Component for PackageManager {
let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden);
sort_mode.sort(&mut filtered);
// Relevance has to win while searching: with fuzzy matching, a name that
// only matched after forgiving a typo would otherwise sort above the one
// the user actually typed.
// Relevance has to win while searching a fuzzy typo match would otherwise outrank the exact one
if !query.is_empty() {
rank_by_query(&mut filtered, &query);
}
// Coming up empty during a search is about the query, not about the tab
// having nothing in it, so it gets its own empty state naming the tab
// that was searched.
// Coming up empty during a search is about the query not the tab so it gets its own empty state
let content_kind = if filtered.is_empty() && !query.is_empty() {
ContentKind::NoMatches {
scope: match tab_filter {

View file

@ -101,8 +101,7 @@ impl EnabledFilter {
}
}
/// Whether a bundle's hidden files get a row. They are dependencies the bundle
/// manages on the user's behalf, so the list leaves them out until asked.
/// Hidden files are dependencies the bundle manages so the list leaves them out until asked
#[derive(Clone, Copy, PartialEq)]
pub(super) enum HiddenFilter {
Hide,
@ -214,9 +213,7 @@ pub(super) fn toolbar_bar(
.into_element()
}
/// Shown while this cluster is being played. Enabling or disabling still takes
/// effect — it is stored, and the folder is brought in line at the next launch
/// — but the session already running cannot pick it up.
/// Enabling still stores and applies at the next launch but the running session cannot pick it up
pub(super) fn running_notice(noun_plural: &'static str) -> Element {
rect()
.horizontal()
@ -263,8 +260,7 @@ impl Component for FilterButton {
let enabled_filter = self.enabled_filter;
let hidden_filter = self.hidden_filter;
// Hiding hidden packages is the default, so it does not count as the
// filters being touched.
// Hiding hidden packages is the default so it does not count as the filters being touched
let is_open = open();
let active = current_sort != SortMode::NameAsc
|| *enabled_filter.read() != EnabledFilter::All
@ -524,7 +520,6 @@ fn add_from_file_button(
.text("Add from file")
}
/// Opens the content browser scoped to this cluster and package type.
fn open_browser(cluster_id: i64, package_type: &'static str) {
let _ = RouterContext::get().push(Route::Browser {
cluster_id,
@ -546,8 +541,7 @@ pub(super) enum ContentKind {
Browser,
Local,
Other,
/// A search returned nothing. `scope` names the tab that was searched when
/// it is narrower than "All".
/// `scope` names the tab that was searched when it is narrower than "All"
NoMatches {
scope: Option<String>,
},
@ -638,8 +632,7 @@ impl Component for ContentBox {
let header = (count > 0)
.then(|| match kind {
// The toolbar above already carries a permanent "Add Content"
// button, so this list needs no browse action of its own.
// The toolbar already carries a permanent "Add Content" button
ContentKind::Browser => None,
ContentKind::Local => Some(
action_header(add_from_file_button(
@ -656,8 +649,7 @@ impl Component for ContentBox {
let mut bottom_corners = CornerRadius::new_all(0.);
bottom_corners.fill_bottom(12.);
// File drops are handled window-wide by the app shell, which prompts for a
// destination, so this box does not claim them.
// File drops are handled window-wide by the app shell so this box does not claim them
rect()
.vertical()
.width(Size::fill())
@ -772,8 +764,7 @@ fn empty_state(noun_plural: &'static str) -> impl IntoElement {
.into_element()
}
/// The search only ever looks inside the active tab, so say which one came up
/// empty and point at the tab that does cover everything.
/// The search only looks inside the active tab so name it and point at the tab that covers everything
fn search_empty(noun_plural: &'static str, scope: Option<&str>) -> impl IntoElement {
let (title, hint) = match scope {
Some(tab) => (

View file

@ -38,7 +38,7 @@ impl Component for Clusters {
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
let groups = group_clusters_by_release(&clusters);
// Newest version first.
// Newest version first
let lines: Vec<ReleaseLine> = groups.keys().rev().copied().collect();
if lines.is_empty() {
@ -309,8 +309,7 @@ fn tags_row(tags: &[String]) -> Option<Element> {
)
}
/// Only shown when the line holds more than one version; a single-version line
/// already names it on the card and in the heading.
/// A single-version line already names it on the card and in the heading
fn version_rows(
line: ReleaseLine,
keys: &[VersionKey],

View file

@ -288,9 +288,7 @@ impl Component for ClusterUpdateSimulator {
type ClusterUpdatePreset = fn() -> Vec<ClusterUpdateSummary>;
/// Each preset pins down one variation the changes modal has to handle: the
/// singular/plural copy, an empty category tab, the single- vs multi-cluster
/// footer, per-tab cluster grouping, and header overflow.
/// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow
const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
(
"1 cluster · all categories",
@ -407,8 +405,7 @@ fn cluster_update_items(names: &[impl AsRef<str>]) -> Vec<ClusterUpdateItem> {
.collect()
}
/// Mirrors the copy the bridge builds for real syncs so the simulated
/// notification is indistinguishable from the real one.
/// Mirrors the copy the bridge builds so the simulated notification matches the real one
fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSummary>) {
if summaries.is_empty() {
return;
@ -480,9 +477,7 @@ impl Component for LauncherUpdateSimulator {
}
}
/// Pins `oneclient_net::status` so the connectivity banner can be looked at
/// without unplugging anything. Each preset is one banner the bar knows how to
/// draw; the bar itself picks the first active issue.
/// Pins `oneclient_net::status` so the connectivity banner can be seen without unplugging anything
#[derive(PartialEq)]
struct StatusBarSimulator;
@ -594,9 +589,7 @@ struct AuthGuidancePreview;
impl Component for AuthGuidancePreview {
fn render(&self) -> impl IntoElement {
let samples = preview_samples();
// Index 0 is the no-error state; the rest line up with `samples`, so a
// dropdown holds all ~16 cases in one row instead of six rows of
// full-width buttons.
// Index 0 is the no-error state the rest line up with `samples`
let mut choice = use_state(|| 0usize);
let mut open = use_state(|| false);
@ -661,14 +654,7 @@ impl Component for AuthGuidancePreview {
}
}
/// Breaks an installation on purpose, so the repair paths can be driven without
/// waiting for a genuinely bad connection.
///
/// The presets are grouped by *which layer is supposed to catch them*, because
/// that is the thing worth testing: same-length corruption is invisible to the
/// launch-time size check and only the hashing pass finds it, truncation is
/// caught cheaply on the next launch, and a deleted assets tree is the case that
/// used to surface as a raw "No such file or directory".
/// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check
#[derive(PartialEq)]
struct CorruptionSimulator;
@ -846,11 +832,7 @@ impl Component for CorruptionSimulator {
}
}
/// Raises the genuine "some files could not be downloaded" prompt.
///
/// Calls the same function the download path calls, rather than rebuilding the
/// copy here — a simulated modal that has drifted from the real one is worse
/// than no simulator at all.
/// Calls the same function the download path calls so the simulated modal cannot drift from the real one
fn prompt_button(
text: &'static str,
icon: IconType,
@ -877,13 +859,8 @@ fn prompt_button(
})
}
/// Raises the post-crash repair offer, as if the game had just died on a
/// `java.util.zip.ZipException`.
///
/// Accepting really does run the verify pass, so this exercises the whole path
/// and not just the copy. To test the detection itself rather than the offer,
/// corrupt some libraries above and launch the game — the JVM will raise the
/// real exception and the log watcher will read it out of the output.
/// Accepting really runs the verify pass
/// To test detection itself corrupt libraries above and launch the game
fn crash_repair_button(
text: &'static str,
jar: Option<&'static str>,
@ -1123,8 +1100,6 @@ fn split_csv(raw: &str) -> Vec<String> {
.collect()
}
/// The debug page's rule: the shared one, but inset and rounded to sit
/// between its loose stacks of rows rather than butt up against them.
fn divider() -> impl IntoElement {
crate::ui::divider()
.margin(Gaps::new_symmetric(8., 0.))
@ -1148,36 +1123,6 @@ fn section(title: &'static str, rows: Vec<Element>) -> impl IntoElement {
.into_element()
}
// fn field_row(label_text: &'static str, value: &'static str) -> impl IntoElement {
// rect()
// .horizontal()
// .width(Size::fill())
// .cross_align(Alignment::Center)
// .spacing(12.)
// .child(
// label()
// .text(label_text)
// .width(Size::px(80.))
// .font_size(13.)
// .color(colors::fg_secondary()),
// )
// .child(
// rect()
// .width(Size::flex(1.0))
// .padding(Gaps::new_symmetric(7., 12.))
// .corner_radius(CornerRadius::new_all(8.))
// .background(colors::component_bg())
// .border(border_all_color(1., colors::component_border()))
// .child(
// label()
// .text(value)
// .font_size(12.)
// .color(colors::fg_primary()),
// ),
// )
// .into_element()
// }
fn toggle_row(title: &'static str, on: State<bool>) -> Element {
rect()
.horizontal()

View file

@ -22,12 +22,11 @@ pub fn launch_button_state(
Some(LaunchStage::Downloading) => ("Downloading", false),
Some(LaunchStage::Launching) => ("Launching", false),
Some(LaunchStage::Running) => ("Running", false),
// The click's own flag, raised before core has said anything and held
// for a beat after a failure so a burst of clicks starts one game.
// The click's own flag held for a beat after a failure so a burst of clicks starts one game
_ if game.is_launch_pending(cluster_id) => ("Launching", false),
_ => ("Launch", true),
};
// Block launching while the startup bundle download is still running.
// Block launching while the startup bundle download is still running
if syncing && state.1 {
return (state.0, false);
}

View file

@ -18,18 +18,7 @@ use crate::hooks::{
use crate::theme::colors;
use crate::ui::border_all_color;
/// The model is framed by the height of its canvas, so the hero pins both sides
/// instead of letting it grow with the page.
///
/// The SkSL renderer raymarches twelve boxes per fragment, so its cost is linear
/// in `WIDTH * HEIGHT`. At 160x264 that is ~42k fragments — about 2.2x the old
/// 116x168 frame, and still under a tenth of the window, so the larger preview is
/// free in practice.
///
/// The shader scales the model off `res.y` alone and only crops horizontally, so
/// the height is what actually makes the player bigger. The width is kept just
/// wide enough to clear the arms (~26 model units visible vs ~17 needed) and
/// otherwise left to the text column.
/// The shader scales the model off `res.y` alone so height makes the player bigger width only needs to clear the arms
const MODEL_WIDTH_PX: f32 = 160.;
const MODEL_HEIGHT_PX: f32 = 264.;
@ -138,7 +127,6 @@ impl Component for SettingsAccounts {
}
}
/// The active account, its model, and the two ways to add another one.
fn hero(
account: Option<MinecraftAccount>,
has_microsoft: bool,
@ -203,10 +191,7 @@ fn hero(
.width(Size::fill())
.spacing(8.)
.child(
// The bigger model leaves ~245px beside it at the
// 800px minimum, which the two buttons overflow, so
// they wrap onto a second line there and sit side by
// side again once there is room.
// The two buttons overflow the ~245px beside the model at the 800px minimum so they wrap
rect()
.horizontal()
.width(Size::fill())
@ -388,8 +373,7 @@ fn field_label(text: &str) -> impl IntoElement {
.into_element()
}
/// The hint sits in whatever is left of the hero beside the model, so the text
/// has to be free to wrap instead of pushing the row wider.
/// Sits in whatever is left of the hero beside the model so the text must wrap
fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement {
rect()
.horizontal()
@ -526,9 +510,7 @@ impl Component for AccountRow {
.maybe_child(is_default.then(default_badge))
.maybe_child(expired.then(expired_badge)),
)
// The kind rides along with the id on the second line: a
// third badge and a full uuid do not both fit beside the
// settings sidebar.
// The kind rides with the id a third badge and a full uuid do not both fit
.child(
label()
.text(format!("{} · {id}", kind_label(self.kind)))

View file

@ -22,9 +22,7 @@ impl Component for SettingsChangelog {
let mut marked = use_state(|| None::<String>);
let installed_version = env!("CARGO_PKG_VERSION").to_string();
// Opening this page counts as reading the changelog, so clear the sidebar
// dot once the newest entry is known. `marked` keeps us from re-sending the
// command while the settings snapshot catches up.
// Opening this page counts as reading it `marked` avoids re-sending while the settings snapshot catches up
if let Some(latest) = latest_changelog_version(&query) {
let already_marked = marked.peek().as_deref() == Some(latest.as_str());
let already_seen = settings.seen_changelog_version.as_deref() == Some(latest.as_str());
@ -74,8 +72,7 @@ impl Component for SettingsChangelog {
}
}
/// Chevron angle for a collapsed card: `ChevronDown` swung a quarter turn
/// anticlockwise points right.
/// `ChevronDown` swung a quarter turn anticlockwise points right
const CHEVRON_CLOSED_DEG: f32 = -90.;
#[derive(PartialEq)]
@ -91,9 +88,7 @@ impl Component for ReleaseCard {
let mut open = use_state(|| self.initially_open);
let is_open = *open.read();
// One chevron that swings between pointing right (collapsed) and down
// (expanded), so the arrow tracks the section instead of cutting to a
// different glyph that did not follow the state.
// One chevron that swings so the arrow tracks the section instead of cutting to a different glyph
let swing = use_animation_with_dependencies(&is_open, |conf, open| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);

View file

@ -178,9 +178,7 @@ fn build_update(
}
}
/// Dispatched on its own rather than folded into [`build_update`]: that effect
/// mirrors text fields on every keystroke, and a dropdown has no intermediate
/// states to debounce.
/// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states
fn update_mode_field(
selected: PackageUpdateMode,
dispatch: crate::Actions,

View file

@ -4,7 +4,6 @@ mod appearance;
mod apis;
mod changelog;
mod developer;
// mod feedback;
mod java;
mod language;
mod launcher;
@ -17,7 +16,6 @@ pub use accounts::SettingsAccounts;
pub use apis::SettingsApis;
pub use changelog::SettingsChangelog;
pub use developer::SettingsDeveloper;
// pub use feedback::SettingsFeedback;
pub use appearance::SettingsAppearance;
pub use java::SettingsJava;
pub use language::SettingsLanguage;

View file

@ -8,7 +8,7 @@ use crate::hooks::{
};
use crate::theme::colors;
/// Rows narrower than this would render as a sliver, so they get a floor.
/// Rows narrower than this would render as a sliver so they get a floor
const MIN_BAR_FRACTION: f32 = 0.015;
#[derive(PartialEq)]
@ -16,10 +16,7 @@ pub struct SettingsStorage;
impl Component for SettingsStorage {
fn render(&self) -> impl IntoElement {
// Every hook this component owns, before anything can return early. The
// report is absent on the first render while the scan runs, and a hook
// called only on the later render would change the hook order between
// renders.
// Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order
let report_query = use_storage_report();
let Some(report) = try_storage_report(&report_query) else {
@ -88,7 +85,6 @@ impl Component for SettingsStorage {
}
}
/// The total, sized to be read first.
fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement {
let reclaimable = report.unreferenced_cache.bytes + report.legacy_cluster_content.bytes;
@ -149,12 +145,7 @@ fn hero_placeholder() -> impl IntoElement {
.into_element()
}
/// A reclaimable line with its own cleanup button.
///
/// A component rather than a plain function because it owns a hook. Each
/// instance gets its own hook scope, so the button tracks only its own progress
/// — cleaning the cache does not grey out the leftovers button — and the rows
/// can be built without constraining where the parent calls them.
/// A component rather than a function because it owns a hook each instance gets its own scope so a button tracks only its own progress
#[derive(PartialEq)]
struct ReclaimRow {
icon: IconType,
@ -214,8 +205,6 @@ impl Component for ReclaimRow {
}
}
/// One breakdown line: name, a bar showing its share of the largest item, size,
/// and a way to open it.
fn usage_row(entry: &StorageEntry, largest: u64, bar: Color) -> impl IntoElement {
let fraction = if largest == 0 {
0.0

View file

@ -1,9 +1,4 @@
//! The live launcher log console, in a window of its own.
//!
//! Freya gives every window its own component tree, so this one shares nothing
//! with the main window — no radio station, no query storage. All it needs is
//! the process-wide channel in [`oneclient_core::logger::console`], and that
//! channel only carries anything while this window is up.
//! Its own Freya window sharing no radio station or query storage with the main one all it needs is the process-wide channel in [`oneclient_core::logger::console`]
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
@ -20,18 +15,10 @@ use crate::theme::colors;
const WINDOW_TITLE: &str = "OneClient — Log Console";
/// How many lines the console keeps.
///
/// Old lines are dropped from the front rather than kept forever: this window
/// can be left open for an entire session, and a launcher that leaks memory
/// while you watch it for leaks is not much use.
/// Old lines are dropped from the front this window can be left open for a whole session
const MAX_LINES: usize = 5_000;
/// Where the console window is in its lifecycle.
///
/// `Opening` is not set by the task that asks for the window but cleared by the
/// window itself, so a task cancelled mid-launch — the settings page being
/// navigated away from, say — cannot strand the state.
/// `Opening` is cleared by the window itself not the task that asks for it so a cancelled launch cannot strand the state
enum ConsoleWindow {
Closed,
Opening,
@ -40,7 +27,6 @@ enum ConsoleWindow {
static CONSOLE_WINDOW: Mutex<ConsoleWindow> = Mutex::new(ConsoleWindow::Closed);
/// Opens the log console, or focuses it if it is already up.
pub fn open_log_console() {
{
let mut state = CONSOLE_WINDOW.lock().expect("console window state");
@ -54,8 +40,7 @@ pub fn open_log_console() {
}
}
// Taken here, in the caller's scope: the future below runs detached, and
// `Platform::get()` needs a component scope to resolve against.
// Taken in the caller's scope the future below runs detached and `Platform::get()` needs a component scope
let platform = Platform::get();
spawn(async move {
platform.launch_window(window_config()).await;
@ -72,8 +57,7 @@ fn window_config() -> WindowConfig {
.with_size(1000., 640.)
.with_min_size(520., 320.)
.with_background(colors::page())
// Native decorations, unlike the main window's custom titlebar. This is
// a developer tool, and a second bespoke titlebar is not worth the code.
// Native decorations a developer tool is not worth a second bespoke titlebar
.with_decorations(true)
.with_window_handle(|window| {
*CONSOLE_WINDOW.lock().expect("console window state") =
@ -93,11 +77,7 @@ impl App for LogConsoleApp {
}
}
/// The lowest severity the console shows.
///
/// A floor rather than an exact match, unlike the cluster logs page: when you
/// are watching a stream go past, "warnings and worse" is the question you
/// actually have.
/// A floor rather than an exact match unlike the cluster logs page
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Severity {
All,
@ -142,9 +122,7 @@ impl Component for LogConsole {
use_hook(move || {
spawn(async move {
// The subscription lives in this task and nowhere else, so when
// the window closes and its tree is dropped the receiver goes
// with it and capture switches itself off.
// The subscription lives only in this task so closing the window drops the receiver and switches capture off
let mut subscription = console::subscribe();
tracing::info!("live log console attached");
@ -208,8 +186,7 @@ fn push(entries: &mut State<VecDeque<ConsoleLine>>, line: ConsoleLine) {
entries.push_back(line);
}
/// What of the captured lines to show. Purely local: nothing here changes what
/// the launcher records.
/// Purely local nothing here changes what the launcher records
fn view_row(
search: State<String>,
severity: State<Severity>,
@ -268,11 +245,7 @@ fn view_row(
.into_element()
}
/// The `EnvFilter` directives the whole launcher is running under.
///
/// This is the half that matters: the view filter above can only narrow what
/// was already captured, whereas applying here reloads the subscriber's filter,
/// so a target the launcher was not recording at all starts arriving.
/// Applying reloads the subscriber's filter so a target the launcher was not recording at all starts arriving
fn filter_row(directives: State<String>, mut error: State<Option<String>>) -> impl IntoElement {
let apply = move |_| match logger::set_filter(&directives.read()) {
Ok(()) => error.set(None),

View file

@ -149,7 +149,7 @@ struct OptionalMod {
provider: ProviderId,
fallback_name: String,
size: u64,
/// `(cluster_id, bundle_name)` pairs this mod is available from.
/// `(cluster_id, bundle_name)` pairs this mod is available from
locations: Vec<(i64, String)>,
}
@ -255,8 +255,7 @@ impl Component for OnboardingBundles {
let opt_ins = opt_in_bundles(&clusters);
let extras = optional_mods(&clusters);
// These are hooks, so they run every render regardless of whether there
// are any extras to show yet.
// Hooks so they run every render regardless of whether there are extras yet
let (mr_ids, cf_ids) = collect_ids(&extras);
let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids));
let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids));

View file

@ -102,10 +102,7 @@ impl Component for LoadingBackdrop {
let pan_x = cx * sw * PARALLAX_STRENGTH;
let pan_y = cy * sh * PARALLAX_STRENGTH;
// Left at the default edge on purpose: this is the same full-screen art
// the home background shows next, so asking for the same variant means
// the onboarding download is the one home renders instead of it having
// to fetch the same picture again at a different size.
// Default edge on purpose home shows the same art next so the same variant avoids a second fetch
let art = match clusters.get(current) {
Some(cluster) => DynamicArt::for_cluster(cluster),
None => DynamicArt::fallback(),

View file

@ -42,11 +42,9 @@ const MAX_TASK_ROWS: usize = 6;
struct ClusterPlan {
cluster_id: i64,
mc_version: String,
/// `(bundle_name, package_id, override)` for every file whose fate differs
/// from the bundle manifest's default.
/// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default
overrides: Vec<(String, String, OverrideType)>,
/// The manifest's `predownload` flag for this version. Versions without it
/// only ever record their package choices here and fetch on first launch.
/// Manifest `predownload` flag versions without it fetch on first launch
predownload: bool,
}
@ -65,7 +63,7 @@ struct TaskLine {
category: TaskCategory,
}
/// Display order for the task rows, mirroring the notification centre.
/// Display order for the task rows mirroring the notification centre
const TASK_LANES: [TaskCategory; 7] = [
TaskCategory::Java,
TaskCategory::Client,
@ -99,13 +97,7 @@ impl GroupedAgg {
}
}
/// One lane per category, taken round-robin.
///
/// Assets run 32-at-a-time and sort before everything alphabetically, so a
/// flat sorted list showed nothing but assets for the whole game download;
/// libraries, natives and the client were downloading at the same time and
/// never got a row. Round-robin gives every active category a slot before
/// any category gets a second one.
/// Round-robin assets run 32-at-a-time and sort first so a flat sorted list showed nothing but assets for the whole download
fn task_list(&self) -> Vec<TaskLine> {
let mut lanes: Vec<Vec<TaskLine>> = TASK_LANES
.iter()
@ -527,9 +519,7 @@ fn build_plans(
.collect()
}
/// Whether the manifest asks for this cluster's version to be fetched up front.
/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone)
/// are not predownloaded.
/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone) are not predownloaded
fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool {
let loader = cluster.mc_loader.to_string();
versions.iter().any(|m| {
@ -541,9 +531,7 @@ fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool
})
}
/// Whether a bundle keeps its hidden files: true once anything visible in it was
/// taken, including a single opted-in extra that would otherwise lose its
/// dependencies.
/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies
fn bundle_taken(
cluster_id: i64,
archive: &BundleArchive,
@ -559,14 +547,7 @@ fn bundle_taken(
})
}
/// Every package id this cluster is keeping, across all of its bundles.
///
/// Bundles overlap: the library one pack needs is often the library another
/// needs too, shipped hidden by both. Deciding each archive on its own meant a
/// declined bundle recorded its hidden files as removed even when an accepted
/// bundle right beside it was about to install the very same package — leaving a
/// `removed` row for something the cluster demonstrably has, which then answered
/// for it in anything that looks a package up by id.
/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install
fn kept_package_ids(
cluster_id: i64,
archives: &[BundleArchive],
@ -603,8 +584,7 @@ fn archive_overrides(
selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id()))
};
// Hidden files are dependencies the user never sees, so they follow the
// bundle: kept if anything from it was taken, dropped if not.
// Hidden files follow the bundle kept if anything from it was taken dropped if not
let takes_hidden = bundle_taken(cluster_id, archive, selected);
let mut overrides = Vec::new();
@ -616,13 +596,11 @@ fn archive_overrides(
};
let override_type = match (wanted, file.enabled) {
// Matches the manifest default; nothing to record.
// Matches the manifest default nothing to record
(true, true) | (false, false) => continue,
// Opting into a mod the bundle ships turned off.
// Opting into a mod the bundle ships turned off
(true, false) => OverrideType::Enabled,
// Declined — but only really declined if no bundle the user did take
// ships the same package. Recording a removal for something that is
// being installed anyway states the opposite of what happens.
// Only really declined if no bundle the user took ships the same package
(false, true) => {
if kept_elsewhere.contains(&file.kind.package_id()) {
continue;
@ -649,11 +627,7 @@ fn rough_download_estimate(
if !cluster_predownloads(versions, &cb.cluster) {
continue;
}
// What the cluster is keeping is already the answer to "what gets
// downloaded", so the estimate reads it directly rather than
// reconstructing it from the overrides. Counted once per package: two
// bundles shipping the same library is one download, which is what the
// install path does with it too.
// Counted once per package two bundles shipping the same library is one download
let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected);
let mut counted: std::collections::HashSet<String> = std::collections::HashSet::new();
for archive in &cb.archives {
@ -722,7 +696,6 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first {
apply_grouped(&mut local, event);
}
// Drain everything already queued in one pass.
while let Ok(notification) = notif_rx.try_recv() {
if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = notification {
apply_grouped(&mut local, event);
@ -790,10 +763,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let fetch_now = predownload && plan.predownload;
if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await {
// Environmental / expected failures (out of disk, offline, no Java
// build for this version, ...) are surfaced to the user but aren't
// crashes, so log them at warn! (breadcrumb) instead of error!
// (a reported Sentry event). See LauncherError::is_sentry_excluded.
// Expected failures (out of disk offline no Java build) are not crashes warn! is a breadcrumb error! reports to Sentry
if err.is_sentry_excluded() {
tracing::warn!(
cluster_id = plan.cluster_id,
@ -1002,7 +972,7 @@ mod tests {
.collect()
}
/// Manifest row matching `test_support::cluster` (1.21.11 fabric).
/// Manifest row matching `test_support::cluster` (1.21.11 fabric)
fn version_row(predownload: bool) -> VersionMetadata {
VersionMetadata {
major_version: 21,
@ -1027,7 +997,7 @@ mod tests {
let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new(), &versions());
let ov = &plans[0].overrides;
// The hidden dependency must be dropped too, or it installs anyway.
// The hidden dependency must be dropped too or it installs anyway
assert!(ov.contains(&(
sb.manifest.name.clone(),
"sb-dep".to_string(),
@ -1148,7 +1118,7 @@ mod tests {
let all = items(vec![skyblock()]);
assert!(build_plans(&all, &HashSet::new(), &versions())[0].predownload);
assert!(!build_plans(&all, &HashSet::new(), &[version_row(false)])[0].predownload);
// A cluster with no manifest row at all is never predownloaded.
// A cluster with no manifest row at all is never predownloaded
assert!(!build_plans(&all, &HashSet::new(), &[])[0].predownload);
}

View file

@ -94,7 +94,7 @@ pub(super) struct ProgressView<'a> {
pub(super) stage: &'a DownloadStage,
pub(super) agg: &'a GroupedAgg,
pub(super) activity: &'a str,
/// `None` until the meter has two samples to work from.
/// `None` until the meter has two samples to work from
pub(super) transfer: Option<TransferStats>,
pub(super) total_estimate: u64,
pub(super) elapsed_secs: Option<u64>,
@ -249,8 +249,7 @@ fn build_stats(view: StatsView) -> Option<Element> {
if finished {
parts.push("Complete".to_string());
} else {
// Both readings come off the same smoothed rate, so the countdown and
// the throughput beside it always agree.
// Both readings come off the same smoothed rate so they always agree
match transfer.and_then(|t| t.eta_secs) {
Some(remaining) => parts.push(format!(
"~{} left",

View file

@ -35,8 +35,7 @@ fn parse_funfacts(text: &str) -> Vec<String> {
.collect()
}
/// Loads onboarding tips (remote with backup fallback) and returns a single
/// stable random tip for the lifetime of the component.
/// One stable random tip for the lifetime of the component
pub(super) fn use_onboarding_tip() -> String {
let mut tips = use_state(|| {
ONBOARDING_TIPS_BACKUP

View file

@ -26,8 +26,7 @@ fn migrated_categories(source: &[SourceInstance]) -> Vec<String> {
categories
}
/// Whether a new cluster exists matching this source instance's version+loader
/// (needed for the "dedicated dir" import option to have a target).
/// Needed for the "dedicated dir" import option to have a target
pub(crate) fn matching_new_cluster_id(
instance: &SourceInstance,
new: &[ClusterBundles],
@ -47,12 +46,11 @@ impl Component for OnboardingMigration {
let bundles_query = use_onboarding_bundles();
let selection = use_onboarding_selection();
let mut categories = selection.migrated_categories;
// Shared across the flow; the file import is dispatched later, on Setup.
// Shared across the flow the file import is dispatched later on Setup
let mut import_folder = selection.import_folder;
let import_dedicated = selection.import_dedicated;
// No detection yet -> render nothing meaningful; the shell only routes
// here when a source was detected, so this is a transient loading state.
// The shell only routes here when a source was detected so this is transient
let Some(detection) = migration_detection(&migration_query) else {
return rect()
.width(Size::fill())
@ -92,7 +90,7 @@ impl Component for OnboardingMigration {
.width(Size::fill())
.spacing(16.)
.child(heading)
// "Don't import files" is the default choice, pinned above the list.
// "Don't import files" is the default choice pinned above the list
.maybe_child(any_importable.then(|| {
import_choice_card(
"Don't import files",
@ -226,10 +224,7 @@ fn import_body(
.into_element()
}
/// A detected source instance rendered as a card. Shows version + categories
/// (always-reselected info); when the instance has a game dir it also acts as
/// the file-import selector and, when selected, exposes the shared/dedicated
/// target choice inline.
/// When the instance has a game dir it also acts as the file-import selector and exposes the shared/dedicated target choice
fn version_card(
instance: &SourceInstance,
selected: bool,
@ -283,7 +278,7 @@ fn version_card(
colors::fg_secondary()
})),
)
// Selection affordance only when the instance actually has files.
// Selection affordance only when the instance actually has files
.maybe_child(importable.then(|| {
if selected {
Icon::new(IconType::Check).size(18.).into_element()
@ -314,7 +309,6 @@ fn version_card(
.on_press(move |_| on_select(()));
}
// When this version is the chosen import, offer where its files should go.
if selected {
let mut targets = rect()
.vertical()
@ -348,7 +342,7 @@ fn version_card(
card.into_element()
}
/// A standalone import choice with no associated version (e.g. "Don't import").
/// A standalone import choice with no associated version (e.g. "Don't import")
fn import_choice_card(
title: &str,
subtitle: &str,
@ -408,8 +402,7 @@ fn import_choice_card(
.into_element()
}
/// Custom nav: like `onboarding_nav` but the Next button runs `on_next` (which
/// applies the migration + navigates) instead of a plain route replace.
/// Like `onboarding_nav` but Next runs `on_next` (apply migration + navigate) instead of a plain route replace
fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl IntoElement {
let mut on_next = on_next;
rect()

View file

@ -37,8 +37,7 @@ use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection};
use crate::theme::colors;
use crate::ui::{border_all_color, entrance_motion_layer};
/// Number of onboarding steps. The v1-migration step only exists (and is only
/// counted) when old launcher data was detected.
/// The v1-migration step only exists and is only counted when old launcher data was detected
pub fn onboarding_total(has_migration: bool) -> usize {
if has_migration { 8 } else { 7 }
}

View file

@ -58,17 +58,13 @@ impl Component for Startup {
Route::Home {}
};
// Onboarding needs the bundle catalog before it can render its steps,
// so gate it on the fetch. Returning users go straight to the app and
// let the bundle download finish in the background.
// Onboarding needs the bundle catalog before it can render its steps so gate it on the fetch
let heading_to_onboarding = matches!(
destination,
Route::OnboardingWelcome { .. } | Route::OnboardingTerms { .. }
);
if !heading_to_onboarding || !launcher.fetching {
// Heading into Home: raise the splash curtain so the hard route
// swap is hidden until Home's clusters/art have settled, then it
// fades out. Onboarding animates itself, so skip the curtain.
// Raise the splash curtain so the hard route swap is hidden until Home settles onboarding animates itself
if !heading_to_onboarding {
let mut active = splash.active;
let mut home_ready = splash.home_ready;

View file

@ -302,13 +302,8 @@ fn xerr(code: u64) -> MinecraftAuthError {
}
}
/// Which layer of the connection failed.
///
/// reqwest reports every one of these as a connect error, so the text of the
/// source chain is the only thing that separates "your antivirus is
/// intercepting TLS" from "your DNS is broken" — and they need different
/// instructions. A wrong guess here costs the user a wasted support round, so
/// anything unrecognised falls back to the generic advice.
/// reqwest reports all of these as a connect error so the source chain text is
/// the only thing separating them anything unrecognised falls back to Generic
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkFailure {
Certificate,
@ -320,8 +315,8 @@ enum NetworkFailure {
fn classify_network_failure(err: &reqwest::Error) -> NetworkFailure {
let chain = oneclient_net::error_chain(err).to_ascii_lowercase();
// Certificate before handshake: a rejected certificate also aborts the
// handshake, and the certificate wording is the more specific of the two.
// Certificate before handshake a rejected certificate also aborts the
// handshake and the certificate wording is the more specific of the two
const CERTIFICATE: &[&str] = &[
"invalid peer certificate",
"unknownissuer",

View file

@ -33,9 +33,8 @@ pub enum MinecraftAuthError {
source: serde_json::Error,
status_code: reqwest::StatusCode,
},
// `source` is rendered through `error_chain` rather than with `{source}`:
// reqwest's `Display` names only the URL, and the cause we need to tell
// apart (certificate, handshake, DNS, refused) is under it.
// Rendered via `error_chain` reqwest's `Display` names only the URL and the
// cause we need (certificate handshake DNS refused) is under it
#[error("failed to request using HTTP during MSA step {step:?}: {}", oneclient_net::error_chain(source))]
RequestError {
step: MinecraftAuthStep,
@ -106,12 +105,8 @@ pub enum AuthError {
#[error(transparent)]
Request(#[from] RequestError),
/// The user backed out of a Microsoft sign-in that was still running.
///
/// An outcome rather than a failure: it exists so the flow has something to
/// return when it is torn down mid-handshake, and so callers can tell that
/// apart from a sign-in that actually went wrong. Nothing was written by the
/// time this is produced.
/// An outcome rather than a failure nothing was written by the time this is
/// produced
#[error("sign-in was cancelled")]
LoginCancelled,

View file

@ -1,9 +1,6 @@
//! Microsoft/Xbox/Minecraft authentication and the launcher's credentials store.
//!
//! The whole surface is [`AuthService`], which owns the credentials store, the
//! in-flight browser logins and the per-account refresh guards. Construct one
//! in the composition layer and pass it down; nothing in here reaches for a
//! global or a database. Accounts are persisted to `auth.json`.
//! Construct [`AuthService`] in the composition layer and pass it down nothing
//! in here reaches for a global or a database
//! Accounts persist to `auth.json`
mod data;
mod diagnostics;

View file

@ -234,7 +234,6 @@ fn redirect_page_html(page: RedirectPage) -> String {
let (accent, glyph, heading, detail) = match page {
RedirectPage::Waiting => (
"#2567d8",
// Spinning ring.
r#"<div class="ring"></div>"#,
"Signing you in",
"Finish signing in with Microsoft in this window.",

View file

@ -12,59 +12,34 @@ use crate::error::{AuthError, AuthResult, MinecraftAuthError};
use crate::msa::{self, PendingBrowserLogin};
use crate::store::{self, CredentialsStore};
/// Stable id for the Microsoft login's progress, so a front-end can recognise
/// it among everything else on the bus.
///
/// Naming it here is the core stating an identity, not a display decision. It
/// says nothing about whether this is a toast or a modal, which is the
/// front-end's call. It exists because the login is driven by a mutation with
/// no place to own a private bus.
/// Stable id for the Microsoft login's progress so a front-end can recognise
/// it among everything else on the bus
pub const MICROSOFT_LOGIN_PROGRESS: Uuid =
Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001);
/// A Microsoft login that has been started but has not finished.
///
/// The two halves have different owners once the flow is under way, which is
/// the whole reason this is a struct: `finish_microsoft_login` takes the
/// listener and runs with it, while the token stays here so a cancel arriving
/// from the UI has something to flip.
/// `finish_microsoft_login` takes the listener and runs with it while the
/// token stays here so a cancel from the UI has something to flip
struct PendingLogin {
/// The loopback listener, until the flow claims it.
///
/// `None` once someone owns it, or once a cancel freed the port before any
/// flow got that far.
/// `None` once a flow owns the listener or once a cancel freed the port
/// before any flow got that far
browser: Option<PendingBrowserLogin>,
cancel: CancellationToken,
}
/// Owns everything the authentication flows need.
///
/// Constructed once by the composition layer and passed down, so nothing here
/// reaches for a global. No database; accounts live in `auth.json` beside the
/// launcher data.
pub struct AuthService {
store: Mutex<CredentialsStore>,
/// Browser logins waiting on their redirect, keyed by CSRF state token.
/// Keyed by CSRF state token
pending_logins: Mutex<HashMap<String, PendingLogin>>,
/// Serialises token renewal per account.
///
/// Microsoft rotates the refresh token on every use, so two concurrent
/// renewals of one account would race: the loser spends an already-consumed
/// refresh token and the account gets signed out. Clusters can launch in
/// parallel, so this is reachable. The guard makes the second caller wait and
/// reuse the first caller's result instead of starting its own chain.
///
/// A `std::sync::Mutex` because its critical section is one map lookup; the
/// `tokio::Mutex` inside is what is held across the handshake.
/// Serialises token renewal per account Microsoft rotates the refresh token
/// on every use so concurrent renewals would sign the account out
refresh_guards: StdMutex<HashMap<Uuid, Arc<Mutex<()>>>>,
net: RequestClient,
events: EventBus,
}
impl AuthService {
/// Loads the credentials store from disk. A missing or unreadable file
/// yields an empty store rather than failing: a corrupt `auth.json` should
/// mean "sign in again", not "the launcher will not start".
/// A missing or unreadable `auth.json` yields an empty store rather than
/// failing so a corrupt file means "sign in again" not "will not start"
pub async fn load(net: RequestClient, events: EventBus) -> AuthResult<Self> {
Ok(Self::with_store(CredentialsStore::load().await?, net, events))
}
@ -80,8 +55,6 @@ impl AuthService {
}
}
// --- Microsoft login ---------------------------------------------------
#[tracing::instrument(skip_all)]
pub async fn begin_microsoft_login(&self) -> AuthResult<MicrosoftLoginSession> {
tracing::info!("beginning Microsoft login");
@ -92,7 +65,7 @@ impl AuthService {
let mut logins = self.pending_logins.lock().await;
// A login cancelled before its flow ever started has nobody left to remove
// it, so the next login sweeps it up.
// it so the next login sweeps it up
logins.retain(|_, login| !login.cancel.is_cancelled());
logins.insert(
browser.state.clone(),
@ -105,16 +78,15 @@ impl AuthService {
Ok(MicrosoftLoginSession { browser, device })
}
/// Takes the loopback listener for `state_token`'s flow, plus the token that
/// cancels it. Only one caller can ever claim a login.
/// Only one caller can ever claim a login
async fn claim_pending_login(
&self,
state_token: &str,
) -> AuthResult<(PendingBrowserLogin, CancellationToken)> {
let mut logins = self.pending_logins.lock().await;
// Cancelled between `begin` and here: the listener is already gone, and
// there is nothing to run.
// Cancelled between `begin` and here the listener is already gone and
// there is nothing to run
if logins
.get(state_token)
.is_some_and(|login| login.cancel.is_cancelled())
@ -153,30 +125,24 @@ impl AuthService {
},
);
// Racing the token is what makes a cancel prompt: losing the race drops
// `flow`, and with it the device-code poll, the loopback listener and
// whichever request was mid-flight. There is nothing left to check
// between polls, and nothing keeps running behind the closed dialog.
// Losing the race drops `flow` and with it the device-code poll the
// loopback listener and whichever request was mid-flight
let result = tokio::select! {
biased;
() = cancel.cancelled() => Err(AuthError::LoginCancelled),
res = flow => res.map_err(AuthError::from),
};
// Whoever runs the flow owns the cleanup; a cancel only flips the token,
// so that an in-flight flow is the one to tear its own state down.
// Whoever runs the flow owns the cleanup a cancel only flips the token
self.pending_logins.lock().await.remove(&session.browser.state);
// Drives the card to 100%, which is how a front-end knows the flow is
// over and can clear whatever it was rendering. A cancelled flow needs
// this just as much as a successful one, or the modal's progress line
// would outlive the sign-in it belongs to.
// Drives the card to 100% so the front-end can clear it a cancelled flow
// needs this as much as a successful one
self.events
.progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1);
// Re-read rather than trusting `result`: the last handshake step can
// complete in the same breath as a cancel, and an account the user has
// already walked away from must not reach the store or the toast.
// Re-read rather than trusting `result` the last handshake step can
// complete in the same breath as a cancel
if cancel.is_cancelled() {
tracing::info!("Microsoft login cancelled by the user");
return Err(AuthError::LoginCancelled);
@ -201,11 +167,8 @@ impl AuthService {
.await
}
/// Stops an in-flight Microsoft login and leaves nothing of it behind.
///
/// The entry is deliberately left in the map: `finish_microsoft_login` may
/// not have claimed it yet, and it needs to find the cancelled token rather
/// than a hole it would report as "this sign-in is no longer active".
/// The entry is deliberately left in the map `finish_microsoft_login` may
/// not have claimed it yet and must find the cancelled token not a hole
#[tracing::instrument(level = "debug", skip_all)]
pub async fn cancel_microsoft_login(&self, state_token: &str) {
let mut logins = self.pending_logins.lock().await;
@ -214,13 +177,11 @@ impl AuthService {
};
login.cancel.cancel();
// Frees the port now if no flow ever took the listener; once one has, the
// flow drops it as it unwinds.
// Frees the port now if no flow ever took the listener once one has the
// flow drops it as it unwinds
login.browser = None;
}
// --- account management ------------------------------------------------
#[tracing::instrument(skip(self), fields(username = %username))]
pub async fn add_offline_account(&self, username: String) -> AuthResult<MinecraftAccount> {
self.store
@ -258,8 +219,6 @@ impl AuthService {
self.store.lock().await.has_microsoft_account()
}
// --- token renewal -----------------------------------------------------
fn refresh_guard(&self, id: Uuid) -> Arc<Mutex<()>> {
Arc::clone(
self.refresh_guards
@ -270,8 +229,6 @@ impl AuthService {
)
}
/// Clones an account out of the store, so no caller holds the store lock
/// past this call.
async fn account_snapshot(&self, id: Uuid) -> AuthResult<MinecraftAccount> {
self.store
.lock()
@ -281,11 +238,8 @@ impl AuthService {
.ok_or(AuthError::AccountNotFound(id))
}
/// Renews `id`'s access token if it has lapsed, returning a usable account.
///
/// The store lock is not held across the Microsoft handshake, only around the
/// reads and the final write. Holding it across those six sequential round
/// trips stalls every other account read behind a multi-second network call.
/// The store lock is never held across the Microsoft handshake only around
/// the reads and the final write
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult<MinecraftAccount> {
let existing = self.account_snapshot(id).await?;
@ -296,7 +250,7 @@ impl AuthService {
let guard = self.refresh_guard(id);
let _serialised = guard.lock().await;
// Re-read under the guard: whoever held it may have just refreshed.
// Re-read under the guard whoever held it may have just refreshed
let existing = self.account_snapshot(id).await?;
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
return Ok(existing);
@ -341,7 +295,6 @@ impl AuthService {
Ok(refreshed)
}
/// An account with a token good enough to launch with, renewing if needed.
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn account_for_launch(&self, id: Uuid) -> AuthResult<MinecraftAccount> {
let account = self.renew_token(id, false).await?;
@ -374,9 +327,8 @@ mod tests {
AuthService::with_store(CredentialsStore::default(), net, events)
}
/// Registers a login the way `begin_microsoft_login` does, minus the device
/// code request: the browser half is the one that binds a port, and it is
/// the only half that needs no network to set up.
/// Registers a login the way `begin_microsoft_login` does minus the device
/// code request which is the only half that needs the network
async fn register_login(service: &AuthService) -> MicrosoftLoginSession {
let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind");
@ -403,9 +355,7 @@ mod tests {
#[tokio::test]
async fn a_cancel_that_beats_the_flow_is_still_a_cancel() {
// The flow starts on its own task, so a quick cancel can land before it
// claims the login. Answering that with "this sign-in is no longer
// active" would show the user an error for working the dialog correctly.
// A quick cancel can land before the flow claims the login
let (events, _rx) = EventBus::channel();
let service = service(events);
let session = register_login(&service).await;
@ -439,8 +389,7 @@ mod tests {
#[tokio::test]
async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() {
// Once claimed, the entry is the only channel a cancel has to the flow
// that is running, so it has to survive the claim.
// Once claimed the entry is the only channel a cancel has to the flow
let (events, _rx) = EventBus::channel();
let service = service(events);
let session = register_login(&service).await;

Some files were not shown because too many files have changed in this diff Show more