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