maybe improve visual code bundling
This commit is contained in:
parent
e2064a5784
commit
9603e76cea
222 changed files with 1635 additions and 3962 deletions
56
.github/workflows/oneclient_nightly.yml
vendored
56
.github/workflows/oneclient_nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
56
.github/workflows/oneclient_release.yml
vendored
56
.github/workflows/oneclient_release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
117
distribution/windows/bundle-crt.py
Normal file
117
distribution/windows/bundle-crt.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
fn main() {
|
||||
// Embeds the app icon and VERSIONINFO into the Windows executable.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:#})"),
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 { .. }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue