diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 659d66f8..9402ef70 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,9 +7,9 @@ on:
workflow_dispatch:
inputs:
tag:
- description: 'Release Tag (e.g. v2.4.2)'
+ description: 'Release Tag (e.g. v2.5.0)'
required: false
- default: 'v2.4.2-c1'
+ default: 'v2.5.0-c1'
type: string
permissions:
@@ -138,7 +138,7 @@ jobs:
elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
else
- echo "tag=v2.4.2-c1" >> $GITHUB_OUTPUT
+ echo "tag=v2.5.0-c1" >> $GITHUB_OUTPUT
fi
- name: Clean up previous release if exists
@@ -155,9 +155,9 @@ jobs:
name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}"
body: |
### What's New in ${{ steps.release_tag.outputs.tag }}
- - **Rebased onto Official Upstream 2.4.2**: Includes all upstream 2.4.0, 2.4.1, and 2.4.2 updates (Linux discrete GPU offloading, shared/cluster mod symlinks, Microsoft Java provider, UI tooltips, analytics graphs, Ornithe loader, bundle fixes).
+ - **Rebased onto Official Upstream 2.5.0**: Includes all upstream 2.5.0 updates (New Control Center quick settings & system info, refined navbar styling, bundle version-tag matching and compatibility filtering, version metadata self-healing).
- **Guaranteed Offline Game Launch**: Completely eliminated the upstream check in `account_for_launch` that blocked offline accounts from launching without Microsoft accounts. Offline accounts can launch directly with 0 Microsoft accounts required.
- - **Custom Offline Skins & Targeted Multiplayer Injection**: In-launcher 3D skin changer, per-account Java UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation.
+ - **Custom Offline Skins & Targeted Multiplayer Injection**: In-launcher 3D skin changer, slim/classic model toggle, per-account Java UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation.
- **Offline / Cracked Accounts**: Full offline account support in onboarding and settings with inline username editing.
- **Integrated Auto-Updater**: Native background update checks and direct GitHub Releases binary downloads.
- **UI Bug Fixes**: Fixed filter popover blur/text ghosting in the package manager.
diff --git a/Cargo.lock b/Cargo.lock
index be1fd5ef..16d1e700 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4480,7 +4480,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oneclient_app"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"anyhow",
"arboard",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "oneclient_auth"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -4554,7 +4554,7 @@ dependencies = [
[[package]]
name = "oneclient_cluster"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"bytes 1.11.1",
"chrono",
@@ -4577,7 +4577,7 @@ dependencies = [
[[package]]
name = "oneclient_common"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"directories",
"interfrost",
@@ -4590,7 +4590,7 @@ dependencies = [
[[package]]
name = "oneclient_content"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"async-trait",
"async_zip",
@@ -4618,7 +4618,7 @@ dependencies = [
[[package]]
name = "oneclient_core"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"async-compression",
"async-stream",
@@ -4683,7 +4683,7 @@ dependencies = [
[[package]]
name = "oneclient_db"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"async-tempfile",
"chrono",
@@ -4698,7 +4698,7 @@ dependencies = [
[[package]]
name = "oneclient_discord"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"chrono",
"discord-rich-presence",
@@ -4710,7 +4710,7 @@ dependencies = [
[[package]]
name = "oneclient_events"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"thiserror 2.0.18",
"tokio",
@@ -4720,7 +4720,7 @@ dependencies = [
[[package]]
name = "oneclient_java"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"astral-tokio-tar",
"async-tempfile",
@@ -4743,7 +4743,7 @@ dependencies = [
[[package]]
name = "oneclient_macro"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"proc-macro2",
"quote",
@@ -4752,7 +4752,7 @@ dependencies = [
[[package]]
name = "oneclient_mc"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -4774,7 +4774,7 @@ dependencies = [
[[package]]
name = "oneclient_net"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"arc-swap",
"async-trait",
@@ -4796,7 +4796,7 @@ dependencies = [
[[package]]
name = "oneclient_polyplus"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"async-tungstenite",
"bytes 1.11.1",
@@ -5094,7 +5094,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polyio"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
dependencies = [
"astral-tokio-tar",
"async-compression",
diff --git a/Cargo.toml b/Cargo.toml
index 6ef6e423..29cfc669 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ license = "GPL-3.0-only"
readme = "README.md"
repository = "https://github.com/oiupoyt/OneLauncher-Cracked"
rust-version = "1.97"
-version = "2.4.2-c1"
+version = "2.5.0-c1"
[workspace.dependencies]
# core
@@ -172,8 +172,5 @@ lto = false
strip = false
[profile.dev]
-debug = 1
-
-[profile.dev.package."*"]
-opt-level = 3
-debug = false
+debug = 0
+strip = "debuginfo"
diff --git a/distribution/aur/oneclient/PKGBUILD b/distribution/aur/oneclient/PKGBUILD
index 13315293..2dbf52df 100644
--- a/distribution/aur/oneclient/PKGBUILD
+++ b/distribution/aur/oneclient/PKGBUILD
@@ -28,7 +28,7 @@ makedepends=(
)
provides=('oneclient')
conflicts=('oneclient-bin')
-options=('!lto')
+options=('!lto', '!debug')
source=("$pkgname-$pkgver.tar.gz::${url}/archive/refs/tags/oneclient-${pkgver}.tar.gz")
sha256sums=('SKIP')
diff --git a/packages/oneclient_app/assets/icons/bar-chart-square-02.svg b/packages/oneclient_app/assets/icons/bar-chart-square-02.svg
new file mode 100644
index 00000000..50591e14
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/bar-chart-square-02.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/assets/icons/discord.svg b/packages/oneclient_app/assets/icons/discord.svg
new file mode 100644
index 00000000..81605170
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/discord.svg
@@ -0,0 +1 @@
+
diff --git a/packages/oneclient_app/assets/icons/log-out-01.svg b/packages/oneclient_app/assets/icons/log-out-01.svg
new file mode 100644
index 00000000..14cdf901
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/log-out-01.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/assets/icons/moon-01.svg b/packages/oneclient_app/assets/icons/moon-01.svg
new file mode 100644
index 00000000..d6cbd869
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/moon-01.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs
index 673b7dbf..0f18cc61 100644
--- a/packages/oneclient_app/src/components/account_switcher.rs
+++ b/packages/oneclient_app/src/components/account_switcher.rs
@@ -107,11 +107,19 @@ impl Component for AccountPanel {
Color::from_argb(120, 0, 0, 0),
)))
.child(
- label()
- .text("Accounts")
- .font_size(18.)
- .font_weight(FontWeight::MEDIUM)
- .a11y_role(AccessibilityRole::TitleBar),
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .cross_align(Alignment::Center)
+ .spacing(6.)
+ .child(BackToControlCenter)
+ .child(
+ label()
+ .text("Accounts")
+ .font_size(18.)
+ .font_weight(FontWeight::MEDIUM)
+ .a11y_role(AccessibilityRole::TitleBar),
+ ),
)
.child(divider())
.child(rows)
@@ -120,6 +128,42 @@ impl Component for AccountPanel {
}
}
+#[derive(PartialEq)]
+struct BackToControlCenter;
+
+impl Component for BackToControlCenter {
+ fn render(&self) -> impl IntoElement {
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let back = move |_| {
+ dispatch.close_account_switcher();
+ dispatch.open_control_center();
+ };
+
+ rect()
+ .width(Size::px(28.))
+ .height(Size::px(28.))
+ .center()
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .a11y_alt("Back to control center")
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(back)
+ .child(
+ Icon::new(IconType::ArrowLeft)
+ .size(18.)
+ .color(colors::fg_secondary()),
+ )
+ }
+}
+
struct AccountRow {
account: MinecraftAccount,
active: bool,
diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs
index 30a28be8..b4276b9a 100644
--- a/packages/oneclient_app/src/components/button.rs
+++ b/packages/oneclient_app/src/components/button.rs
@@ -56,6 +56,7 @@ pub struct Button {
tooltip: Option>,
tooltip_placement: TooltipPlacement,
on_press: Option>>,
+ overflow: Overflow,
elements: Vec,
key: DiffKey,
@@ -117,6 +118,7 @@ impl Button {
tooltip_placement: TooltipPlacement::default(),
on_press: None,
elements: Vec::new(),
+ overflow: Overflow::Clip,
key: DiffKey::None,
cursor_icon: CursorIcon::Pointer,
}
@@ -156,6 +158,11 @@ impl Button {
self
}
+ pub fn overflow(mut self, overflow: Overflow) -> Self {
+ self.overflow = overflow;
+ self
+ }
+
pub fn font_size(mut self, font_size: impl Into) -> Self {
self.text_style.font_size = Some(font_size.into().into());
self
@@ -314,7 +321,7 @@ impl Component for Button {
.a11y_focusable(enabled() && self.focusable)
.a11y_role(AccessibilityRole::Button)
.map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt))
- .overflow(Overflow::Clip)
+ .overflow(self.overflow)
.layout(self.layout.clone())
.text_style(self.text_style.clone())
.children(self.elements.clone());
diff --git a/packages/oneclient_app/src/components/control_center.rs b/packages/oneclient_app/src/components/control_center.rs
new file mode 100644
index 00000000..1fce04f4
--- /dev/null
+++ b/packages/oneclient_app/src/components/control_center.rs
@@ -0,0 +1,634 @@
+use freya::{
+ animation::{AnimNum, Ease, OnCreation, use_animation},
+ prelude::*,
+ router::RouterContext,
+};
+
+use crate::{
+ Route,
+ components::{Avatar, Button, Icon, IconType, OVERLAY_MAX_LEVEL, OverlayPopup},
+ hooks::{
+ RemoveAccountKeys, query_error, query_is_loading, try_accounts, try_default_account,
+ use_accounts, use_control_center_open, use_current_account, use_dispatch,
+ use_remove_account, use_settings_snapshot,
+ },
+ theme::colors,
+ ui::{border_all_color, divider},
+};
+
+const PANEL_WIDTH: f32 = 344.;
+
+#[derive(PartialEq)]
+pub struct ControlCenter;
+
+impl Component for ControlCenter {
+ fn render(&self) -> impl IntoElement {
+ let open = use_control_center_open();
+ let dispatch = use_dispatch();
+
+ if !open {
+ return rect().into_element();
+ }
+
+ OverlayPopup::new()
+ .position(Position::new_global().top(72.).right(40.))
+ .on_close(move |()| dispatch.close_control_center())
+ .child(ControlPanel)
+ .into_element()
+ }
+}
+
+#[derive(PartialEq)]
+struct ControlPanel;
+
+impl Component for ControlPanel {
+ fn render(&self) -> impl IntoElement {
+ let intro = use_animation(|conf| {
+ conf.on_creation(OnCreation::Run);
+ AnimNum::new(0., 1.).time(200).ease(Ease::Out)
+ });
+ let progress = intro.read().value();
+
+ rect()
+ .vertical()
+ .width(Size::px(PANEL_WIDTH))
+ .padding(Gaps::new_all(12.))
+ .spacing(10.)
+ .opacity(progress)
+ .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
+ .background(colors::page_elevated().with_a(220))
+ .blur(12.)
+ .corner_radius(CornerRadius::new_all(14.))
+ .border(
+ Border::new()
+ .fill(colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ .shadow(Shadow::from((
+ 0.,
+ 8.,
+ 32.,
+ 0.,
+ Color::from_argb(120, 0, 0, 0),
+ )))
+ .child(AccountHeader)
+ .child(divider())
+ .child(
+ rect()
+ .vertical()
+ .width(Size::fill())
+ .spacing(2.)
+ .child(NavRow {
+ icon: IconType::BarChartSquare02,
+ row_label: "Stats",
+ target: Route::Stats {},
+ })
+ .child(NavRow {
+ icon: IconType::Settings02,
+ row_label: "Settings",
+ target: Route::SettingsLauncher {},
+ }),
+ )
+ .child(section_label("QUICK SETTINGS"))
+ .child(QuickSettings)
+ .child(divider())
+ .child(PanelFooter)
+ }
+}
+
+fn section_label(text: &'static str) -> impl IntoElement {
+ label()
+ .text(text)
+ .font_size(11.)
+ .letter_spacing(0.8)
+ .font_weight(FontWeight::SEMI_BOLD)
+ .color(colors::fg_secondary())
+}
+
+#[derive(PartialEq)]
+struct AccountHeader;
+
+impl Component for AccountHeader {
+ fn render(&self) -> impl IntoElement {
+ let current = use_current_account();
+ let accounts_query = use_accounts();
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let loading = query_is_loading(¤t);
+ let unusable = query_error(¤t).is_some();
+ let account = try_default_account(¤t).or_else(|| {
+ unusable
+ .then(|| try_accounts(&accounts_query).unwrap_or_default().into_iter().next())
+ .flatten()
+ });
+
+ let uuid = account
+ .as_ref()
+ .map(|account| account.id.to_string())
+ .unwrap_or_else(|| uuid::Uuid::nil().to_string());
+ let username = match account.as_ref() {
+ Some(account) => account.username.clone(),
+ None if loading => "Loading…".to_string(),
+ None => "Not signed in".to_string(),
+ };
+ let subtitle = match (account.as_ref(), unusable) {
+ (_, true) => "Microsoft Account required",
+ (Some(account), false) if account.is_microsoft() => "Microsoft account",
+ (Some(_), false) => "Offline account",
+ (None, false) if loading => "Checking accounts",
+ (None, false) => "No account selected",
+ };
+ let subtitle_color = if unusable {
+ colors::danger()
+ } else {
+ colors::fg_secondary()
+ };
+
+ let signed_out = account.is_none() && !loading;
+ let action_label = if signed_out { "Sign In" } else { "Switch" };
+ let open_switcher = move |_| {
+ dispatch.close_control_center();
+ if signed_out {
+ let _ = RouterContext::get().push(Route::SettingsAccounts {});
+ } else {
+ dispatch.open_account_switcher();
+ }
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .padding(Gaps::new_all(4.))
+ .child(
+ Avatar::new(uuid)
+ .width(Size::px(40.))
+ .height(Size::px(40.)),
+ )
+ .child(
+ rect()
+ .vertical()
+ .width(Size::flex(1.0))
+ .spacing(2.)
+ .child(
+ label()
+ .text(username)
+ .font_size(16.)
+ .font_weight(FontWeight::MEDIUM)
+ .max_lines(1)
+ .color(colors::fg_primary()),
+ )
+ .child(
+ label()
+ .text(subtitle)
+ .font_size(12.)
+ .max_lines(1)
+ .color(subtitle_color),
+ ),
+ )
+ .child(
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(6.)
+ .padding(Gaps::new_symmetric(6., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(open_switcher)
+ .child(
+ label()
+ .text(action_label)
+ .font_size(13.)
+ .color(colors::fg_secondary()),
+ )
+ .child(
+ Icon::new(IconType::ArrowRight)
+ .size(14.)
+ .color(colors::fg_secondary()),
+ ),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct NavRow {
+ icon: IconType,
+ row_label: &'static str,
+ target: Route,
+}
+
+impl Component for NavRow {
+ fn render(&self) -> impl IntoElement {
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let target = self.target.clone();
+ let go = move |_| {
+ dispatch.close_control_center();
+ let _ = RouterContext::get().push(target.clone());
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .height(Size::px(42.))
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(12.)
+ .padding(Gaps::new_symmetric(0., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(go)
+ .child(
+ Icon::new(self.icon)
+ .size(20.)
+ .color(colors::fg_secondary()),
+ )
+ .child(
+ label()
+ .text(self.row_label)
+ .font_size(15.)
+ .width(Size::flex(1.0))
+ .color(colors::fg_primary()),
+ )
+ .child(
+ Icon::new(IconType::ArrowRight)
+ .size(16.)
+ .color(colors::fg_secondary()),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct QuickSettings;
+
+impl Component for QuickSettings {
+ fn render(&self) -> impl IntoElement {
+ let settings = use_settings_snapshot().settings;
+ let dispatch = use_dispatch();
+
+ let discord_on = settings.discord_enabled;
+ let toggle_discord = move |_| {
+ dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .spacing(8.)
+ .child(
+ QuickTile::new(IconType::Discord, "Discord RPC", discord_on)
+ .on_press(toggle_discord),
+ )
+ .child(QuickTile::new(IconType::Moon01, "Close on launch", false).disabled())
+ }
+}
+
+struct QuickTile {
+ icon: IconType,
+ title: &'static str,
+ on: bool,
+ enabled: bool,
+ on_press: Option>>,
+}
+
+impl PartialEq for QuickTile {
+ fn eq(&self, other: &Self) -> bool {
+ self.icon == other.icon
+ && self.title == other.title
+ && self.on == other.on
+ && self.enabled == other.enabled
+ }
+}
+
+impl QuickTile {
+ fn new(icon: IconType, title: &'static str, on: bool) -> Self {
+ Self {
+ icon,
+ title,
+ on,
+ enabled: true,
+ on_press: None,
+ }
+ }
+
+ fn disabled(mut self) -> Self {
+ self.enabled = false;
+ self
+ }
+
+ fn on_press(mut self, on_press: impl Into>>) -> Self {
+ self.on_press = Some(on_press.into());
+ self
+ }
+}
+
+impl Component for QuickTile {
+ fn render(&self) -> impl IntoElement {
+ let mut hovered = use_state(|| false);
+
+ let on = self.on;
+ let enabled = self.enabled;
+ let handler = self.on_press.clone();
+
+ let background = if !enabled {
+ colors::component_bg().with_a(120)
+ } else if on {
+ if *hovered.read() {
+ colors::brand_hover()
+ } else {
+ colors::brand()
+ }
+ } else if *hovered.read() {
+ colors::component_bg_hover()
+ } else {
+ colors::component_bg()
+ };
+
+ let title_color = if enabled {
+ colors::fg_primary()
+ } else {
+ colors::fg_primary_disabled()
+ };
+
+ let sub_color = if !enabled {
+ colors::fg_primary_disabled()
+ } else if on {
+ colors::fg_primary().with_a(200)
+ } else {
+ colors::fg_secondary()
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::flex(1.0))
+ .height(Size::px(64.))
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .padding(Gaps::new_symmetric(0., 12.))
+ .corner_radius(CornerRadius::new_all(10.))
+ .background(background)
+ .maybe(!enabled, |el| el.opacity(0.45))
+ .maybe(!on || !enabled, |el| {
+ el.border(
+ Border::new()
+ .fill(colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ })
+ .maybe(enabled, |el| {
+ el.cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ })
+ .map(handler, |el, handler| el.on_press(move |e| handler.call(e)))
+ .child(Icon::new(self.icon).size(24.).color(title_color))
+ .child(
+ rect()
+ .vertical()
+ .width(Size::flex(1.0))
+ .child(
+ label()
+ .text(self.title)
+ .font_size(12.)
+ .font_weight(FontWeight::MEDIUM)
+ .max_lines(1)
+ .color(title_color),
+ )
+ .child(
+ label()
+ .text(if on { "On" } else { "Off" })
+ .font_size(11.)
+ .color(sub_color),
+ ),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct PanelFooter;
+
+impl Component for PanelFooter {
+ fn render(&self) -> impl IntoElement {
+ let settings = use_settings_snapshot().settings;
+ let current = use_current_account();
+ let remove = use_remove_account();
+
+ let mut confirming = use_state(|| false);
+
+ let folder = settings
+ .data_dir
+ .clone()
+ .or_else(|| oneclient_core::settings::data_dir::default_path().ok());
+
+ let account = try_default_account(¤t);
+ let account_id = account.as_ref().map(|account| account.id);
+ let username = account
+ .as_ref()
+ .map(|account| account.username.clone())
+ .unwrap_or_default();
+
+ let confirm_sign_out = move |_| {
+ if let Some(id) = account_id {
+ remove.mutate(RemoveAccountKeys { id });
+ }
+ confirming.set(false);
+ };
+
+ rect()
+ .vertical()
+ .width(Size::fill())
+ .child(
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .child(
+ footer_action(
+ IconType::Folder,
+ "Open launcher folder",
+ colors::fg_primary(),
+ )
+ .on_press(move |_| {
+ if let Some(folder) = folder.clone() {
+ std::fs::create_dir_all(&folder).ok();
+ crate::platform::open_path(&folder.to_string_lossy());
+ }
+ }),
+ )
+ .child(rect().width(Size::flex(1.0)))
+ .map(account_id, |el, _| {
+ el.child(
+ footer_action(
+ IconType::LogOut01,
+ "Sign out",
+ colors::fg_primary(),
+ )
+ .on_press(move |_| confirming.set(true)),
+ )
+ }),
+ )
+ .maybe(*confirming.read(), |el| {
+ el.child(
+ OverlayPopup::new()
+ .overlay_level(OVERLAY_MAX_LEVEL)
+ .on_close(move |_| confirming.set(false))
+ .child(
+ rect()
+ .width(Size::window_percent(100.))
+ .height(Size::window_percent(100.))
+ .center()
+ .child(
+ rect()
+ .vertical()
+ .width(Size::px(400.))
+ .max_width(Size::window_percent(90.))
+ .spacing(14.)
+ .padding(Gaps::new_all(20.))
+ .corner_radius(CornerRadius::new_all(14.))
+ .background(colors::page_elevated())
+ .border(border_all_color(
+ 1.,
+ colors::component_border(),
+ ))
+ .child(
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .child(
+ Icon::new(IconType::AlertTriangle)
+ .size(20.)
+ .color(colors::code_warn()),
+ )
+ .child(
+ label()
+ .text("Sign out?")
+ .font_size(16.)
+ .font_weight(FontWeight::SEMI_BOLD)
+ .color(colors::fg_primary()),
+ ),
+ )
+ .child(
+ label()
+ .text(format!(
+ "{username} will be removed from OneClient. You will need to sign in again to use this account."
+ ))
+ .font_size(12.)
+ .max_lines(4)
+ .width(Size::fill())
+ .color(colors::fg_secondary()),
+ )
+ .child(
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .main_align(Alignment::End)
+ .spacing(8.)
+ .child(
+ Button::new()
+ .secondary()
+ .text("Cancel")
+ .on_press(move |_| {
+ confirming.set(false)
+ }),
+ )
+ .child(
+ Button::new()
+ .danger()
+ .text("Sign out")
+ .on_press(confirm_sign_out),
+ ),
+ ),
+ ),
+ ),
+ )
+ })
+ }
+}
+
+fn footer_action(icon: IconType, text: &'static str, color: Color) -> FooterAction {
+ FooterAction {
+ icon,
+ text,
+ color,
+ on_press: None,
+ }
+}
+
+struct FooterAction {
+ icon: IconType,
+ text: &'static str,
+ color: Color,
+ on_press: Option>>,
+}
+
+impl PartialEq for FooterAction {
+ fn eq(&self, other: &Self) -> bool {
+ self.icon == other.icon && self.text == other.text && self.color == other.color
+ }
+}
+
+impl FooterAction {
+ fn on_press(mut self, on_press: impl Into>>) -> Self {
+ self.on_press = Some(on_press.into());
+ self
+ }
+}
+
+impl Component for FooterAction {
+ fn render(&self) -> impl IntoElement {
+ let mut hovered = use_state(|| false);
+ let handler = self.on_press.clone();
+
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(8.)
+ .height(Size::px(34.))
+ .padding(Gaps::new_symmetric(0., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .map(handler, |el, handler| {
+ el.on_press(move |e| handler.call(e))
+ })
+ .child(Icon::new(self.icon).size(16.).color(self.color))
+ .child(
+ label()
+ .text(self.text)
+ .font_size(13.)
+ .max_lines(1)
+ .color(self.color),
+ )
+ }
+}
diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs
index 4847de28..0675cf11 100644
--- a/packages/oneclient_app/src/components/icons.rs
+++ b/packages/oneclient_app/src/components/icons.rs
@@ -87,6 +87,7 @@ pub enum IconType {
ArrowLeft,
ArrowRight,
Brush01,
+ BarChartSquare02,
Bell01,
Calendar,
CheckCircle,
@@ -102,6 +103,7 @@ pub enum IconType {
Copy01,
Curseforge,
Database01,
+ Discord,
DotsGrid,
DotsVertical,
Download01,
@@ -123,10 +125,12 @@ pub enum IconType {
Link03,
LinkExternal01,
Loading02,
+ LogOut01,
Maximize01,
MessageTextSquare01,
Minus,
Modrinth,
+ Moon01,
OnboardingAccount,
OnboardingComplete,
OnboardingLanguage,
diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs
index de7cdae1..5dd33b9e 100644
--- a/packages/oneclient_app/src/components/mod.rs
+++ b/packages/oneclient_app/src/components/mod.rs
@@ -1,4 +1,5 @@
mod account_switcher;
+mod control_center;
mod active_cluster_panel;
mod avatar;
mod button;
@@ -47,6 +48,7 @@ mod version_card;
pub mod upload_mclogs;
pub use account_switcher::AccountSwitcher;
+pub use control_center::ControlCenter;
pub use active_cluster_panel::ActiveClusterPanel;
pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs
index 47f18230..16eea40a 100644
--- a/packages/oneclient_app/src/components/navbar/app_navbar.rs
+++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs
@@ -281,12 +281,8 @@ impl Component for NavbarRight {
notif_dispatch.toggle_notification_center();
};
- let open_account_switcher = move |_| {
- dispatch.toggle_account_switcher();
- };
-
- let open_settings = |_| {
- let _ = RouterContext::get().push(Route::SettingsLauncher {});
+ let open_control_center = move |_| {
+ dispatch.toggle_control_center();
};
let open_stats = |_| {
@@ -313,25 +309,49 @@ impl Component for NavbarRight {
)
.child(
super::navbar_button()
- .tooltip("Settings")
- .child(Icon::new(IconType::Settings02).size(20.))
- .on_press(open_settings),
- )
- .child(
- super::navbar_button()
+ .overflow(Overflow::None)
+ .tooltip("Control Center")
.padding(0.0)
- .tooltip("Switch account")
- .on_press(open_account_switcher)
- .child(
- Avatar::new(account_uuid)
- .width(Size::px(24.))
- .height(Size::px(24.)),
- ),
+ .on_press(open_control_center)
+ .child(avatar_with_gear(account_uuid)),
)
.child(super::window_controls())
}
}
+fn avatar_with_gear(uuid: String) -> impl IntoElement {
+ rect()
+ .width(Size::px(28.))
+ .height(Size::px(28.))
+ .center()
+ .child(
+ Avatar::new(uuid)
+ .width(Size::px(28.))
+ .height(Size::px(28.)),
+ )
+ .child(
+ rect()
+ .position(Position::new_absolute().bottom(-7.).right(-7.))
+ .width(Size::px(16.))
+ .height(Size::px(16.))
+ .corner_radius(CornerRadius::from(7.))
+ .background(theme::colors::page_elevated())
+ .border(
+ Border::new()
+ .fill(theme::colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ .layer(Layer::Relative(3))
+ .center()
+ .child(
+ Icon::new(IconType::Settings02)
+ .size(12.)
+ .color(theme::colors::fg_secondary()),
+ ),
+ )
+}
+
fn notification_bell(unread: usize) -> impl IntoElement {
rect()
.width(Size::px(20.))
diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs
index b83a9ce7..33443417 100644
--- a/packages/oneclient_app/src/hooks/actions.rs
+++ b/packages/oneclient_app/src/hooks/actions.rs
@@ -255,6 +255,16 @@ impl Actions {
});
}
+ pub fn edit_settings(&self, edit: impl FnOnce(&mut LauncherSettings)) {
+ let Some(updated) = self.mutate_settings(edit) else {
+ return;
+ };
+ if let Ok(state) = launcher::state() {
+ state.discord.set_enabled(updated.discord_enabled);
+ }
+ self.persist(updated);
+ }
+
pub fn set_settings(&self, settings: LauncherSettings) {
let Some(updated) = self.mutate_settings(|s| *s = settings) else {
return;
@@ -634,6 +644,13 @@ impl Actions {
.account_switcher_open = !open;
}
+ pub fn open_account_switcher(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::AccountSwitcher)
+ .account_switcher_open = true;
+ }
+
pub fn close_account_switcher(&self) {
self.station
.clone()
@@ -641,6 +658,28 @@ impl Actions {
.account_switcher_open = false;
}
+ pub fn toggle_control_center(&self) {
+ let open = self.station.peek().control_center_open;
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = !open;
+ }
+
+ pub fn open_control_center(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = true;
+ }
+
+ pub fn close_control_center(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = false;
+ }
+
pub fn close_notification_center(&self) {
self.with_engine(|state| state.center_open = false);
}
diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs
index 2adf9020..0e1d3c5f 100644
--- a/packages/oneclient_app/src/hooks/mod.rs
+++ b/packages/oneclient_app/src/hooks/mod.rs
@@ -119,6 +119,10 @@ pub fn use_account_switcher_open() -> bool {
.account_switcher_open
}
+pub fn use_control_center_open() -> bool {
+ use_radio(AppChannel::ControlCenter).read().control_center_open
+}
+
pub fn use_game_snapshot() -> GameState {
use_radio(AppChannel::Game).read().game.clone()
}
diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs
index 7efbb9b7..7aa172cc 100644
--- a/packages/oneclient_app/src/layout/root_layout.rs
+++ b/packages/oneclient_app/src/layout/root_layout.rs
@@ -2,7 +2,7 @@ use freya::prelude::*;
use freya::router::*;
use crate::components::{
- AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
+ AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost,
UpdatePromptOverlay, use_provide_tooltips,
};
@@ -74,6 +74,7 @@ impl Component for RootLayout {
)
.child(NotificationCenter)
.child(AccountSwitcher)
+ .child(ControlCenter)
.child(Toasts)
.child(UpdatePromptOverlay)
.child(JavaPromptOverlay)
diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs
index 51eeed8b..dc04a493 100644
--- a/packages/oneclient_app/src/state.rs
+++ b/packages/oneclient_app/src/state.rs
@@ -20,6 +20,7 @@ pub enum AppChannel {
Notifications,
Game,
AccountSwitcher,
+ ControlCenter,
MicrosoftLogin,
Installs,
StorageScan,
@@ -42,6 +43,7 @@ pub struct AppState {
pub center_open: bool,
pub game: GameState,
pub account_switcher_open: bool,
+ pub control_center_open: bool,
pub microsoft_login: Option,
pub installs: InstallState,
pub storage_scan: Option,
diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs
index 8bc344c4..0497af22 100644
--- a/packages/oneclient_content/src/packages/activity.rs
+++ b/packages/oneclient_content/src/packages/activity.rs
@@ -2,14 +2,17 @@
//! of one package are legal rows
//! the newest stays enabled the rest are switched off rather than unlinked
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use oneclient_common::domain::{ContentType, ProviderId};
use oneclient_db::dao::applied_migration as migration_dao;
use oneclient_db::dao::artifact as artifact_dao;
+use oneclient_db::dao::bundle as bundle_catalog_dao;
+use oneclient_db::dao::cluster_bundle as bundle_dao;
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
+use crate::packages::dependencies::base_game_version;
use crate::packages::store::PackageStore;
use crate::packages::types::LinkedArtifactInfo;
@@ -95,6 +98,7 @@ fn group_duplicates(
pub async fn disable_foreign_game_versions(
cluster_id: i64,
mc_version: &str,
+ mc_loader: i64,
ctx: &ContentCtx,
) -> ContentResult> {
if mc_version.is_empty() {
@@ -102,12 +106,31 @@ pub async fn disable_foreign_game_versions(
}
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
+
+ // bundle installs skip the compatibility check so launch must trust them too
+ let live_bundles: HashSet =
+ bundle_catalog_dao::list_visible_for_version_loader(&ctx.db, mc_version, mc_loader)
+ .await?
+ .into_iter()
+ .filter_map(|row| row.name)
+ .collect();
+
+ let from_bundle: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id)
+ .await?
+ .into_iter()
+ .filter(|row| {
+ row.bundle_name
+ .as_deref()
+ .is_some_and(|name| live_bundles.contains(name))
+ })
+ .map(|row| row.hash)
+ .collect();
+
let mut switched_off = Vec::new();
- for info in linked
- .iter()
- .filter(|info| info.enabled && info.content_type == ContentType::Mod)
- {
+ for info in linked.iter().filter(|info| {
+ info.enabled && info.content_type == ContentType::Mod && !from_bundle.contains(&info.hash)
+ }) {
match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await {
Ok(true) => switched_off.push(
info.display_name
@@ -167,11 +190,13 @@ fn built_for_another_game_version(stated: &[String], mc_version: &str) -> bool {
}
fn covers_game_version(stated: &[String], mc_version: &str) -> bool {
+ let wanted = base_game_version(mc_version);
stated.is_empty()
|| stated.iter().any(|version| {
- version == mc_version
- || mc_version
- .strip_prefix(version.as_str())
+ let built_for = base_game_version(version);
+ built_for == wanted
+ || wanted
+ .strip_prefix(built_for)
.is_some_and(|rest| rest.starts_with('.'))
})
}
@@ -287,6 +312,34 @@ mod tests {
);
}
+ #[test]
+ fn a_pre_release_tag_is_the_same_game_version() {
+ let foreign = |rows: &[&str], mc_version: &str| {
+ built_for_another_game_version(
+ &rows.iter().map(|r| (*r).to_string()).collect::>(),
+ mc_version,
+ )
+ };
+
+ assert!(
+ !foreign(&["[\"26.3-rc-1\"]"], "26.3"),
+ "a jar built against the release candidate runs on the release"
+ );
+ assert!(!foreign(&["[\"26.3-snapshot-7\",\"26.3-pre-2\"]"], "26.3"));
+ assert!(
+ !foreign(&["[\"26.3\"]"], "26.3-snapshot-10"),
+ "and a snapshot cluster keeps the release build"
+ );
+ assert!(
+ !foreign(&["[\"26.3-rc-1\"]"], "26.3.1"),
+ "the component prefix still applies once the tag is off"
+ );
+ assert!(
+ foreign(&["[\"26.2-rc-1\"]"], "26.3"),
+ "stripping the tag must not blur two game versions together"
+ );
+ }
+
#[test]
fn only_projects_with_several_copies_are_reconciled() {
let groups = group_duplicates(&[
diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs
index 44bd8c3d..1a9af701 100644
--- a/packages/oneclient_content/src/packages/dependencies.rs
+++ b/packages/oneclient_content/src/packages/dependencies.rs
@@ -193,9 +193,18 @@ fn choose_version(
.cloned()
}
+#[must_use]
+pub(crate) fn base_game_version(version: &str) -> &str {
+ version
+ .split_once('-')
+ .filter(|(base, _)| base.contains('.'))
+ .map_or(version, |(base, _)| base)
+}
+
#[must_use]
pub(crate) fn supports_game_version(game_versions: &[String], mc_version: &str) -> bool {
- game_versions.is_empty() || game_versions.iter().any(|v| v == mc_version)
+ let wanted = base_game_version(mc_version);
+ game_versions.is_empty() || game_versions.iter().any(|v| base_game_version(v) == wanted)
}
fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool {
@@ -230,6 +239,34 @@ mod tests {
use super::*;
+ #[test]
+ fn a_version_is_offered_only_for_the_game_version_it_names() {
+ let offered = |stated: &[&str], mc_version: &str| {
+ supports_game_version(
+ &stated.iter().map(|v| (*v).to_string()).collect::>(),
+ mc_version,
+ )
+ };
+
+ assert!(offered(&[], "26.3"), "an undescribed version is offered");
+ assert!(offered(&["26.3"], "26.3"));
+ assert!(
+ offered(&["26.3-rc-1"], "26.3"),
+ "a pre-release tag names the same game version"
+ );
+ assert!(offered(&["26.3"], "26.3-snapshot-10"));
+ assert!(
+ !offered(&["26.1"], "26.1.2"),
+ "strict where the launch-time check is lenient: #830 stopped 26.1 \
+ builds being offered to a 26.1.2 cluster"
+ );
+ assert!(!offered(&["1.21.11"], "26.1.2"));
+ assert!(
+ !offered(&["rd-132211"], "rd-160052"),
+ "pre-classic names the build after the dash, so there is no tag to strip"
+ );
+ }
+
fn summary(
version_id: &str,
release_type: ReleaseType,
diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs
index 2d496e26..66178f09 100644
--- a/packages/oneclient_core/src/game/shared_dir.rs
+++ b/packages/oneclient_core/src/game/shared_dir.rs
@@ -139,6 +139,7 @@ pub async fn materialize_content(
match oneclient_content::packages::disable_foreign_game_versions(
cluster.id,
&cluster.mc_version,
+ cluster.mc_loader as i64,
&services.content(),
)
.await