From 87de47fe5eaf237c35a64c425fbe676de54a96f2 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 21 Jul 2025 15:35:05 -0700 Subject: [PATCH 01/17] Use rust-lld linker on MSVC Windows (#4042) The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead. --- .cargo/config.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 7115f0015..085f3158f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,5 +2,8 @@ [target.'cfg(windows)'] rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"] +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" + [build] rustflags = ["--cfg", "tokio_unstable"] From d4516d35270e63f6d5a19e07c75b1539343370c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Tue, 22 Jul 2025 00:55:57 +0200 Subject: [PATCH 02/17] feat(app): configurable Modrinth endpoints through .env files (#4015) --- .github/workflows/theseus-build.yml | 4 ++- .github/workflows/turbo-ci.yml | 4 +++ Cargo.lock | 1 + packages/app-lib/.env.local | 12 ++++++-- packages/app-lib/.env.prod | 10 +++++++ packages/app-lib/.env.staging | 10 +++++++ packages/app-lib/Cargo.toml | 1 + packages/app-lib/build.rs | 20 +++++++++++++ packages/app-lib/src/api/mr_auth.rs | 2 +- packages/app-lib/src/config.rs | 13 --------- packages/app-lib/src/lib.rs | 1 - packages/app-lib/src/state/cache.rs | 42 +++++++++++++++------------ packages/app-lib/src/state/friends.rs | 10 +++---- packages/app-lib/src/state/mr_auth.rs | 9 +++--- packages/app-lib/src/util/fetch.rs | 5 ++-- 15 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 packages/app-lib/.env.prod create mode 100644 packages/app-lib/.env.staging delete mode 100644 packages/app-lib/src/config.rs diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml index 76ae5f900..64ae2b334 100644 --- a/.github/workflows/theseus-build.yml +++ b/.github/workflows/theseus-build.yml @@ -75,7 +75,7 @@ jobs: rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }} chmod: 0755 - - name: ⚙️ Set application version + - name: ⚙️ Set application version and environment shell: bash run: | APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')" @@ -84,6 +84,8 @@ jobs: dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version' + cp packages/app-lib/.env.prod packages/app-lib/.env + - name: 💨 Setup Turbo cache uses: rharkor/caching-for-turbo@v1.8 diff --git a/.github/workflows/turbo-ci.yml b/.github/workflows/turbo-ci.yml index 82e9f333a..64aec819a 100644 --- a/.github/workflows/turbo-ci.yml +++ b/.github/workflows/turbo-ci.yml @@ -74,6 +74,10 @@ jobs: cp .env.local .env sqlx database setup + - name: ⚙️ Set app environment + working-directory: packages/app-lib + run: cp .env.staging .env + - name: 🔍 Lint and test run: pnpm run ci diff --git a/Cargo.lock b/Cargo.lock index f6519c3b1..3d55cb9b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8930,6 +8930,7 @@ dependencies = [ "data-url", "dirs", "discord-rich-presence", + "dotenvy", "dunce", "either", "encoding_rs", diff --git a/packages/app-lib/.env.local b/packages/app-lib/.env.local index e648f5b56..171b37b9e 100644 --- a/packages/app-lib/.env.local +++ b/packages/app-lib/.env.local @@ -1,2 +1,10 @@ -# SQLite database file location -DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db +MODRINTH_URL=http://localhost:3000/ +MODRINTH_API_URL=http://127.0.0.1:8000/v2/ +MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/ +MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/ +MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/ + +# SQLite database file used by sqlx for type checking. Uncomment this to a valid path +# in your system and run `cargo sqlx database setup` to generate an empty database that +# can be used for developing the app DB schema +#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db diff --git a/packages/app-lib/.env.prod b/packages/app-lib/.env.prod new file mode 100644 index 000000000..f721d5078 --- /dev/null +++ b/packages/app-lib/.env.prod @@ -0,0 +1,10 @@ +MODRINTH_URL=https://modrinth.com/ +MODRINTH_API_URL=https://api.modrinth.com/v2/ +MODRINTH_API_URL_V3=https://api.modrinth.com/v3/ +MODRINTH_SOCKET_URL=wss://api.modrinth.com/ +MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/ + +# SQLite database file used by sqlx for type checking. Uncomment this to a valid path +# in your system and run `cargo sqlx database setup` to generate an empty database that +# can be used for developing the app DB schema +#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db diff --git a/packages/app-lib/.env.staging b/packages/app-lib/.env.staging new file mode 100644 index 000000000..be0daeb9b --- /dev/null +++ b/packages/app-lib/.env.staging @@ -0,0 +1,10 @@ +MODRINTH_URL=https://staging.modrinth.com/ +MODRINTH_API_URL=https://staging-api.modrinth.com/v2/ +MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/ +MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/ +MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/ + +# SQLite database file used by sqlx for type checking. Uncomment this to a valid path +# in your system and run `cargo sqlx database setup` to generate an empty database that +# can be used for developing the app DB schema +#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 85200eb25..127edd852 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -82,6 +82,7 @@ ariadne.workspace = true winreg.workspace = true [build-dependencies] +dotenvy.workspace = true dunce.workspace = true [features] diff --git a/packages/app-lib/build.rs b/packages/app-lib/build.rs index 251c4e848..10ed29b99 100644 --- a/packages/app-lib/build.rs +++ b/packages/app-lib/build.rs @@ -4,12 +4,31 @@ use std::process::{Command, exit}; use std::{env, fs}; fn main() { + println!("cargo::rerun-if-changed=.env"); println!("cargo::rerun-if-changed=java/gradle"); println!("cargo::rerun-if-changed=java/src"); println!("cargo::rerun-if-changed=java/build.gradle.kts"); println!("cargo::rerun-if-changed=java/settings.gradle.kts"); println!("cargo::rerun-if-changed=java/gradle.properties"); + set_env(); + build_java_jars(); +} + +fn set_env() { + for (var_name, var_value) in + dotenvy::dotenv_iter().into_iter().flatten().flatten() + { + if var_name == "DATABASE_URL" { + // The sqlx database URL is a build-time detail that should not be exposed to the crate + continue; + } + + println!("cargo::rustc-env={var_name}={var_value}"); + } +} + +fn build_java_jars() { let out_dir = dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap())) .unwrap(); @@ -37,6 +56,7 @@ fn main() { .current_dir(dunce::canonicalize("java").unwrap()) .status() .expect("Failed to wait on Gradle build"); + if !exit_status.success() { println!("cargo::error=Gradle build failed with {exit_status}"); exit(exit_status.code().unwrap_or(1)); diff --git a/packages/app-lib/src/api/mr_auth.rs b/packages/app-lib/src/api/mr_auth.rs index 3c99d1f5b..42ce41fe5 100644 --- a/packages/app-lib/src/api/mr_auth.rs +++ b/packages/app-lib/src/api/mr_auth.rs @@ -1,7 +1,7 @@ use crate::state::ModrinthCredentials; #[tracing::instrument] -pub fn authenticate_begin_flow() -> String { +pub fn authenticate_begin_flow() -> &'static str { crate::state::get_login_url() } diff --git a/packages/app-lib/src/config.rs b/packages/app-lib/src/config.rs deleted file mode 100644 index bf7e47e1e..000000000 --- a/packages/app-lib/src/config.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Configuration structs - -// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/"; -// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/"; -// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/"; - -pub const MODRINTH_URL: &str = "https://modrinth.com/"; -pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/"; -pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/"; - -pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/"; - -pub const META_URL: &str = "https://launcher-meta.modrinth.com/"; diff --git a/packages/app-lib/src/lib.rs b/packages/app-lib/src/lib.rs index 55f4459ac..258e72423 100644 --- a/packages/app-lib/src/lib.rs +++ b/packages/app-lib/src/lib.rs @@ -11,7 +11,6 @@ and launching Modrinth mod packs mod util; mod api; -mod config; mod error; mod event; mod launcher; diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index cd6ee1df2..98f1bb79c 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -1,4 +1,3 @@ -use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3}; use crate::state::ProjectType; use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async}; use chrono::{DateTime, Utc}; @@ -8,6 +7,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use std::collections::HashMap; +use std::env; use std::fmt::Display; use std::hash::Hash; use std::path::{Path, PathBuf}; @@ -945,7 +945,7 @@ impl CachedEntry { CacheValueType::Project => { fetch_original_values!( Project, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "projects", CacheValue::Project ) @@ -953,7 +953,7 @@ impl CachedEntry { CacheValueType::Version => { fetch_original_values!( Version, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "versions", CacheValue::Version ) @@ -961,7 +961,7 @@ impl CachedEntry { CacheValueType::User => { fetch_original_values!( User, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "users", CacheValue::User ) @@ -969,7 +969,7 @@ impl CachedEntry { CacheValueType::Team => { let mut teams = fetch_many_batched::>( Method::GET, - MODRINTH_API_URL_V3, + env!("MODRINTH_API_URL_V3"), "teams?ids=", &keys, fetch_semaphore, @@ -1008,7 +1008,7 @@ impl CachedEntry { CacheValueType::Organization => { let mut orgs = fetch_many_batched::( Method::GET, - MODRINTH_API_URL_V3, + env!("MODRINTH_API_URL_V3"), "organizations?ids=", &keys, fetch_semaphore, @@ -1063,7 +1063,7 @@ impl CachedEntry { CacheValueType::File => { let mut versions = fetch_json::>( Method::POST, - &format!("{MODRINTH_API_URL}version_files"), + concat!(env!("MODRINTH_API_URL"), "version_files"), None, Some(serde_json::json!({ "algorithm": "sha1", @@ -1119,7 +1119,11 @@ impl CachedEntry { .map(|x| { ( x.key().to_string(), - format!("{META_URL}{}/v0/manifest.json", x.key()), + format!( + "{}{}/v0/manifest.json", + env!("MODRINTH_LAUNCHER_META_URL"), + x.key() + ), ) }) .collect::>(); @@ -1154,7 +1158,7 @@ impl CachedEntry { CacheValueType::MinecraftManifest => { fetch_original_value!( MinecraftManifest, - META_URL, + env!("MODRINTH_LAUNCHER_META_URL"), format!( "minecraft/v{}/manifest.json", daedalus::minecraft::CURRENT_FORMAT_VERSION @@ -1165,7 +1169,7 @@ impl CachedEntry { CacheValueType::Categories => { fetch_original_value!( Categories, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "tag/category", CacheValue::Categories ) @@ -1173,7 +1177,7 @@ impl CachedEntry { CacheValueType::ReportTypes => { fetch_original_value!( ReportTypes, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "tag/report_type", CacheValue::ReportTypes ) @@ -1181,7 +1185,7 @@ impl CachedEntry { CacheValueType::Loaders => { fetch_original_value!( Loaders, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "tag/loader", CacheValue::Loaders ) @@ -1189,7 +1193,7 @@ impl CachedEntry { CacheValueType::GameVersions => { fetch_original_value!( GameVersions, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "tag/game_version", CacheValue::GameVersions ) @@ -1197,7 +1201,7 @@ impl CachedEntry { CacheValueType::DonationPlatforms => { fetch_original_value!( DonationPlatforms, - MODRINTH_API_URL, + env!("MODRINTH_API_URL"), "tag/donation_platform", CacheValue::DonationPlatforms ) @@ -1297,14 +1301,12 @@ impl CachedEntry { } }); - let version_update_url = - format!("{MODRINTH_API_URL}version_files/update"); let variations = futures::future::try_join_all(filtered_keys.iter().map( |((loaders_key, game_version), hashes)| { fetch_json::>( Method::POST, - &version_update_url, + concat!(env!("MODRINTH_API_URL"), "version_files/update"), None, Some(serde_json::json!({ "algorithm": "sha1", @@ -1368,7 +1370,11 @@ impl CachedEntry { .map(|x| { ( x.key().to_string(), - format!("{MODRINTH_API_URL}search{}", x.key()), + format!( + "{}search{}", + env!("MODRINTH_API_URL"), + x.key() + ), ) }) .collect::>(); diff --git a/packages/app-lib/src/state/friends.rs b/packages/app-lib/src/state/friends.rs index 008660d9f..0079daa40 100644 --- a/packages/app-lib/src/state/friends.rs +++ b/packages/app-lib/src/state/friends.rs @@ -1,4 +1,3 @@ -use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL}; use crate::data::ModrinthCredentials; use crate::event::FriendPayload; use crate::event::emit::emit_friend; @@ -77,7 +76,8 @@ impl FriendsSocket { if let Some(credentials) = credentials { let mut request = format!( - "{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}", + "{}_internal/launcher_socket?code={}", + env!("MODRINTH_SOCKET_URL"), credentials.session ) .into_client_request()?; @@ -303,7 +303,7 @@ impl FriendsSocket { ) -> crate::Result> { fetch_json( Method::GET, - &format!("{MODRINTH_API_URL_V3}friends"), + concat!(env!("MODRINTH_API_URL_V3"), "friends"), None, None, semaphore, @@ -328,7 +328,7 @@ impl FriendsSocket { ) -> crate::Result<()> { fetch_advanced( Method::POST, - &format!("{MODRINTH_API_URL_V3}friend/{user_id}"), + &format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")), None, None, None, @@ -349,7 +349,7 @@ impl FriendsSocket { ) -> crate::Result<()> { fetch_advanced( Method::DELETE, - &format!("{MODRINTH_API_URL_V3}friend/{user_id}"), + &format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")), None, None, None, diff --git a/packages/app-lib/src/state/mr_auth.rs b/packages/app-lib/src/state/mr_auth.rs index 8aab2a37d..2d0d4ff32 100644 --- a/packages/app-lib/src/state/mr_auth.rs +++ b/packages/app-lib/src/state/mr_auth.rs @@ -1,4 +1,3 @@ -use crate::config::{MODRINTH_API_URL, MODRINTH_URL}; use crate::state::{CacheBehaviour, CachedEntry}; use crate::util::fetch::{FetchSemaphore, fetch_advanced}; use chrono::{DateTime, Duration, TimeZone, Utc}; @@ -31,7 +30,7 @@ impl ModrinthCredentials { let resp = fetch_advanced( Method::POST, - &format!("{MODRINTH_API_URL}session/refresh"), + concat!(env!("MODRINTH_API_URL"), "session/refresh"), None, None, Some(("Authorization", &*creds.session)), @@ -190,8 +189,8 @@ impl ModrinthCredentials { } } -pub fn get_login_url() -> String { - format!("{MODRINTH_URL}auth/sign-in?launcher=true") +pub const fn get_login_url() -> &'static str { + concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true") } pub async fn finish_login_flow( @@ -216,7 +215,7 @@ async fn fetch_info( ) -> crate::Result { let result = fetch_advanced( Method::GET, - &format!("{MODRINTH_API_URL}user"), + concat!(env!("MODRINTH_API_URL"), "user"), None, None, Some(("Authorization", token)), diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index fb62386aa..9b2e87208 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -1,6 +1,5 @@ //! Functions for fetching information from the Internet use super::io::{self, IOError}; -use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3}; use crate::event::LoadingBarId; use crate::event::emit::emit_loading; use bytes::Bytes; @@ -84,8 +83,8 @@ pub async fn fetch_advanced( .as_ref() .is_none_or(|x| &*x.0.to_lowercase() != "authorization") && (url.starts_with("https://cdn.modrinth.com") - || url.starts_with(MODRINTH_API_URL) - || url.starts_with(MODRINTH_API_URL_V3)) + || url.starts_with(env!("MODRINTH_API_URL")) + || url.starts_with(env!("MODRINTH_API_URL_V3"))) { crate::state::ModrinthCredentials::get_active(exec).await? } else { From bb9af18eed9690a3e0ff0af8605b9b2eb41c71d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:31:56 +0200 Subject: [PATCH 03/17] perf(docker): cache image builds through cache mounts and GHA cache (#4020) * perf(docker): cache image builds through cache mounts and GHA cache * tweak(ci/docker): switch to inline registry cache --- .github/workflows/daedalus-docker.yml | 13 ++++++++----- .github/workflows/labrinth-docker.yml | 13 ++++++++----- apps/daedalus_client/Dockerfile | 18 ++++++++++++++---- apps/labrinth/Dockerfile | 21 ++++++++++++++++----- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/.github/workflows/daedalus-docker.yml b/.github/workflows/daedalus-docker.yml index 0dda82541..b0f72c964 100644 --- a/.github/workflows/daedalus-docker.yml +++ b/.github/workflows/daedalus-docker.yml @@ -22,23 +22,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - name: Fetch docker metadata id: docker_meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: ghcr.io/modrinth/daedalus - name: Login to GitHub Images - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: file: ./apps/daedalus_client/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main + cache-to: type=inline diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml index 114c8ee48..43577e662 100644 --- a/.github/workflows/labrinth-docker.yml +++ b/.github/workflows/labrinth-docker.yml @@ -20,23 +20,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - name: Fetch docker metadata id: docker_meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: ghcr.io/modrinth/labrinth - name: Login to GitHub Images - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: file: ./apps/labrinth/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main + cache-to: type=inline diff --git a/apps/daedalus_client/Dockerfile b/apps/daedalus_client/Dockerfile index 9ea70f9ca..271c829aa 100644 --- a/apps/daedalus_client/Dockerfile +++ b/apps/daedalus_client/Dockerfile @@ -1,9 +1,19 @@ +# syntax=docker/dockerfile:1 + FROM rust:1.88.0 AS build WORKDIR /usr/src/daedalus COPY . . -RUN cargo build --release --package daedalus_client +RUN --mount=type=cache,target=/usr/src/daedalus/target \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/usr/local/cargo/registry \ + cargo build --release --package daedalus_client +FROM build AS artifacts + +RUN --mount=type=cache,target=/usr/src/daedalus/target \ + mkdir /daedalus \ + && cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client FROM debian:bookworm-slim @@ -11,7 +21,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates openssl \ && rm -rf /var/lib/apt/lists/* -COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client -WORKDIR /daedalus_client +COPY --from=artifacts /daedalus /daedalus -CMD /daedalus/daedalus_client +WORKDIR /daedalus_client +CMD ["/daedalus/daedalus_client"] diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile index f0677efd5..f8ff754d9 100644 --- a/apps/labrinth/Dockerfile +++ b/apps/labrinth/Dockerfile @@ -1,8 +1,21 @@ +# syntax=docker/dockerfile:1 + FROM rust:1.88.0 AS build WORKDIR /usr/src/labrinth COPY . . -RUN SQLX_OFFLINE=true cargo build --release --package labrinth +RUN --mount=type=cache,target=/usr/src/labrinth/target \ + --mount=type=cache,target=/usr/local/cargo/git/db \ + --mount=type=cache,target=/usr/local/cargo/registry \ + SQLX_OFFLINE=true cargo build --release --package labrinth + +FROM build AS artifacts + +RUN --mount=type=cache,target=/usr/src/labrinth/target \ + mkdir /labrinth \ + && cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \ + && cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \ + && cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth FROM debian:bookworm-slim @@ -14,10 +27,8 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates dumb-init curl \ && rm -rf /var/lib/apt/lists/* -COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth -COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/ -COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets -WORKDIR /labrinth +COPY --from=artifacts /labrinth /labrinth +WORKDIR /labrinth ENTRYPOINT ["dumb-init", "--"] CMD ["/labrinth/labrinth"] From 0e0ca1971af0cd95853c1876e3008a3029b1f191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:43:04 +0200 Subject: [PATCH 04/17] chore(ci): switch back to upstream `cache-cargo-install-action` (#4047) --- .github/workflows/turbo-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/turbo-ci.yml b/.github/workflows/turbo-ci.yml index 64aec819a..fda114e5e 100644 --- a/.github/workflows/turbo-ci.yml +++ b/.github/workflows/turbo-ci.yml @@ -52,7 +52,7 @@ jobs: # cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall # back to a cached cargo install - name: 🧰 Setup cargo-sqlx - uses: AlexTMjugador/cache-cargo-install-action@feat/features-support + uses: taiki-e/cache-cargo-install-action@v2 with: tool: sqlx-cli locked: false From 32793c50e13137c2f12f021780f6f48505a17dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:55:18 +0200 Subject: [PATCH 05/17] feat(app): better external browser Modrinth login flow (#4033) * fix(app-frontend): do not emit exceptions when no loaders are available * refactor(app): simplify Microsoft login code without functional changes * feat(app): external browser auth flow for Modrinth account login * chore: address Clippy lint * chore(app/oauth_utils): simplify `handle_reply` error handling according to review * chore(app-lib): simplify `Url` usage out of MC auth module --- Cargo.lock | 2 + Cargo.toml | 1 + apps/app-frontend/src/App.vue | 28 ++- .../components/ui/InstanceCreationModal.vue | 14 +- .../ui/modal/AuthGrantFlowWaitModal.vue | 42 +++++ apps/app-frontend/src/helpers/mr_auth.js | 4 + apps/app-playground/src/main.rs | 2 +- apps/app/Cargo.toml | 2 + apps/app/build.rs | 7 +- apps/app/src/api/auth.rs | 3 +- apps/app/src/api/mod.rs | 2 + apps/app/src/api/mr_auth.rs | 106 ++++++------ .../src/api/oauth_utils/auth_code_reply.rs | 159 ++++++++++++++++++ .../api/oauth_utils/auth_code_reply/page.html | 1 + apps/app/src/api/oauth_utils/mod.rs | 3 + apps/app/tauri.conf.json | 1 + apps/frontend/src/pages/auth/sign-in.vue | 38 ++++- apps/frontend/src/pages/auth/sign-up.vue | 12 +- packages/app-lib/src/state/minecraft_auth.rs | 99 ++++------- packages/app-lib/src/state/mr_auth.rs | 8 +- 20 files changed, 389 insertions(+), 145 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue create mode 100644 apps/app/src/api/oauth_utils/auth_code_reply.rs create mode 100644 apps/app/src/api/oauth_utils/auth_code_reply/page.html create mode 100644 apps/app/src/api/oauth_utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3d55cb9b6..4dfc698b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8985,6 +8985,8 @@ dependencies = [ "dashmap", "either", "enumset", + "hyper 1.6.0", + "hyper-util", "native-dialog", "paste", "serde", diff --git a/Cargo.toml b/Cargo.toml index d95e9b601..341c8838b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ heck = "0.5.0" hex = "0.4.3" hickory-resolver = "0.25.2" hmac = "0.12.1" +hyper = "1.6.0" hyper-rustls = { version = "0.27.7", default-features = false, features = [ "http1", "native-tokio", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 1bc25942c..abfabdf52 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils' import { useFetch } from '@/helpers/fetch.js' import { check } from '@tauri-apps/plugin-updater' import NavButton from '@/components/ui/NavButton.vue' -import { get as getCreds, login, logout } from '@/helpers/mr_auth.js' +import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { get_user } from '@/helpers/cache.js' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' +import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import FriendsList from '@/components/ui/friends/FriendsList.vue' @@ -263,6 +264,8 @@ const incompatibilityWarningModal = ref() const credentials = ref() +const modrinthLoginFlowWaitModal = ref() + async function fetchCredentials() { const creds = await getCreds().catch(handleError) if (creds && creds.user_id) { @@ -272,8 +275,24 @@ async function fetchCredentials() { } async function signIn() { - await login().catch(handleError) - await fetchCredentials() + modrinthLoginFlowWaitModal.value.show() + + try { + await login() + await fetchCredentials() + } catch (error) { + if ( + typeof error === 'object' && + typeof error['message'] === 'string' && + error.message.includes('Login canceled') + ) { + // Not really an error due to being a result of user interaction, show nothing + } else { + handleError(error) + } + } finally { + modrinthLoginFlowWaitModal.value.hide() + } } async function logOut() { @@ -402,6 +421,9 @@ function handleAuxClick(e) { + + + diff --git a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue index c09255a7c..ee6328ff0 100644 --- a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue +++ b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue @@ -305,12 +305,16 @@ const [ get_game_versions().then(shallowRef).catch(handleError), get_loaders() .then((value) => - value - .filter((item) => item.supported_project_types.includes('modpack')) - .map((item) => item.name.toLowerCase()), + ref( + value + .filter((item) => item.supported_project_types.includes('modpack')) + .map((item) => item.name.toLowerCase()), + ), ) - .then(ref) - .catch(handleError), + .catch((err) => { + handleError(err) + return ref([]) + }), ]) loaders.value.unshift('vanilla') diff --git a/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue b/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue new file mode 100644 index 000000000..3f169faff --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/AuthGrantFlowWaitModal.vue @@ -0,0 +1,42 @@ + + diff --git a/apps/app-frontend/src/helpers/mr_auth.js b/apps/app-frontend/src/helpers/mr_auth.js index ecd9aee35..6be54bd21 100644 --- a/apps/app-frontend/src/helpers/mr_auth.js +++ b/apps/app-frontend/src/helpers/mr_auth.js @@ -16,3 +16,7 @@ export async function logout() { export async function get() { return await invoke('plugin:mr-auth|get') } + +export async function cancelLogin() { + return await invoke('plugin:mr-auth|cancel_modrinth_login') +} diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs index a2c2b8922..13da97d39 100644 --- a/apps/app-playground/src/main.rs +++ b/apps/app-playground/src/main.rs @@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result { println!("A browser window will now open, follow the login flow there."); let login = minecraft_auth::begin_login().await?; - println!("Open URL {} in a browser", login.redirect_uri.as_str()); + println!("Open URL {} in a browser", login.auth_request_uri.as_str()); println!("Please enter URL code: "); let mut input = String::new(); diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index d1c67affc..e1a612e55 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -31,6 +31,8 @@ thiserror.workspace = true daedalus.workspace = true chrono.workspace = true either.workspace = true +hyper = { workspace = true, features = ["server"] } +hyper-util.workspace = true url.workspace = true urlencoding.workspace = true diff --git a/apps/app/build.rs b/apps/app/build.rs index 7a4da8872..4574acee9 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -120,7 +120,12 @@ fn main() { .plugin( "mr-auth", InlinedPlugin::new() - .commands(&["modrinth_login", "logout", "get"]) + .commands(&[ + "modrinth_login", + "logout", + "get", + "cancel_modrinth_login", + ]) .default_permission( DefaultPermissionRule::AllowAllCommands, ), diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs index b084feb4d..c18bd770e 100644 --- a/apps/app/src/api/auth.rs +++ b/apps/app/src/api/auth.rs @@ -33,7 +33,7 @@ pub async fn login( let window = tauri::WebviewWindowBuilder::new( &app, "signin", - tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err( + tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err( |_| { theseus::ErrorKind::OtherError( "Error parsing auth redirect URL".to_string(), @@ -77,6 +77,7 @@ pub async fn login( window.close()?; Ok(None) } + #[tauri::command] pub async fn remove_user(user: uuid::Uuid) -> Result<()> { Ok(minecraft_auth::remove_user(user).await?) diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 294e784f6..a2ccb1178 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -22,6 +22,8 @@ pub mod cache; pub mod friends; pub mod worlds; +mod oauth_utils; + pub type Result = std::result::Result; // // Main returnable Theseus GUI error diff --git a/apps/app/src/api/mr_auth.rs b/apps/app/src/api/mr_auth.rs index 43fee5436..2143d20c5 100644 --- a/apps/app/src/api/mr_auth.rs +++ b/apps/app/src/api/mr_auth.rs @@ -1,79 +1,70 @@ use crate::api::Result; -use chrono::{Duration, Utc}; +use crate::api::TheseusSerializableError; +use crate::api::oauth_utils; +use tauri::Manager; +use tauri::Runtime; use tauri::plugin::TauriPlugin; -use tauri::{Manager, Runtime, UserAttentionType}; +use tauri_plugin_opener::OpenerExt; use theseus::prelude::*; +use tokio::sync::oneshot; pub fn init() -> TauriPlugin { tauri::plugin::Builder::new("mr-auth") - .invoke_handler(tauri::generate_handler![modrinth_login, logout, get,]) + .invoke_handler(tauri::generate_handler![ + modrinth_login, + logout, + get, + cancel_modrinth_login, + ]) .build() } #[tauri::command] pub async fn modrinth_login( app: tauri::AppHandle, -) -> Result> { - let redirect_uri = mr_auth::authenticate_begin_flow(); +) -> Result { + let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel(); + let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen( + auth_code_recv_socket_tx, + )); - let start = Utc::now(); + let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?; - if let Some(window) = app.get_webview_window("modrinth-signin") { - window.close()?; - } + let auth_request_uri = format!( + "{}?launcher=true&ipver={}&port={}", + mr_auth::authenticate_begin_flow(), + if auth_code_recv_socket.is_ipv4() { + "4" + } else { + "6" + }, + auth_code_recv_socket.port() + ); - let window = tauri::WebviewWindowBuilder::new( - &app, - "modrinth-signin", - tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| { - theseus::ErrorKind::OtherError( - "Error parsing auth redirect URL".to_string(), + app.opener() + .open_url(auth_request_uri, None::<&str>) + .map_err(|e| { + TheseusSerializableError::Theseus( + theseus::ErrorKind::OtherError(format!( + "Failed to open auth request URI: {e}" + )) + .into(), ) - .as_error() - })?), - ) - .min_inner_size(420.0, 632.0) - .inner_size(420.0, 632.0) - .max_inner_size(420.0, 632.0) - .zoom_hotkeys_enabled(false) - .title("Sign into Modrinth") - .always_on_top(true) - .center() - .build()?; + })?; - window.request_user_attention(Some(UserAttentionType::Critical))?; + let Some(auth_code) = auth_code.await.unwrap()? else { + return Err(TheseusSerializableError::Theseus( + theseus::ErrorKind::OtherError("Login canceled".into()).into(), + )); + }; - while (Utc::now() - start) < Duration::minutes(10) { - if window.title().is_err() { - // user closed window, cancelling flow - return Ok(None); - } + let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?; - if window - .url()? - .as_str() - .starts_with("https://launcher-files.modrinth.com") - { - let url = window.url()?; - - let code = url.query_pairs().find(|(key, _)| key == "code"); - - window.close()?; - - return if let Some((_, code)) = code { - let val = mr_auth::authenticate_finish_flow(&code).await?; - - Ok(Some(val)) - } else { - Ok(None) - }; - } - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if let Some(main_window) = app.get_window("main") { + main_window.set_focus().ok(); } - window.close()?; - Ok(None) + Ok(credentials) } #[tauri::command] @@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> { pub async fn get() -> Result> { Ok(theseus::mr_auth::get_credentials().await?) } + +#[tauri::command] +pub fn cancel_modrinth_login() { + oauth_utils::auth_code_reply::stop_listeners(); +} diff --git a/apps/app/src/api/oauth_utils/auth_code_reply.rs b/apps/app/src/api/oauth_utils/auth_code_reply.rs new file mode 100644 index 000000000..4e4a52928 --- /dev/null +++ b/apps/app/src/api/oauth_utils/auth_code_reply.rs @@ -0,0 +1,159 @@ +//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP +//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and +//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps. +//! +//! This server is needed for the step 4 of the OAuth authentication dance represented in +//! figure 1 of [RFC 8252]. +//! +//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/ +//! +//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749 +//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252 + +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::{LazyLock, Mutex}, + time::Duration, +}; + +use hyper::body::Incoming; +use hyper_util::rt::{TokioIo, TokioTimer}; +use theseus::ErrorKind; +use tokio::{ + net::TcpListener, + sync::{broadcast, oneshot}, +}; + +static SERVER_SHUTDOWN: LazyLock> = + LazyLock::new(|| broadcast::channel(1024).0); + +/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects +/// on a loopback interface with an ephemeral port. The caller can know the bound socket address +/// by listening on the counterpart channel for `listen_socket_tx`. +/// +/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned. +pub async fn listen( + listen_socket_tx: oneshot::Sender>, +) -> Result, theseus::Error> { + // IPv4 is tried first for the best compatibility and performance with most systems. + // IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided + // to prevent failures deriving from improper name resolution setup. Any available + // ephemeral port is used to prevent conflicts with other services. This is all as per + // RFC 8252's recommendations + const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[ + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), + ]; + + let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await { + Ok(listener) => { + listen_socket_tx + .send(listener.local_addr().map_err(|e| { + ErrorKind::OtherError(format!( + "Failed to get auth code reply socket address: {e}" + )) + .into() + })) + .ok(); + + listener + } + Err(e) => { + let error_msg = + format!("Failed to bind auth code reply socket: {e}"); + + listen_socket_tx + .send(Err(ErrorKind::OtherError(error_msg.clone()).into())) + .ok(); + + return Err(ErrorKind::OtherError(error_msg).into()); + } + }; + + let mut auth_code = Mutex::new(None); + let mut shutdown_notification = SERVER_SHUTDOWN.subscribe(); + + while auth_code.get_mut().unwrap().is_none() { + let client_socket = tokio::select! { + biased; + _ = shutdown_notification.recv() => { + break; + } + conn_accept_result = listener.accept() => { + match conn_accept_result { + Ok((socket, _)) => socket, + Err(e) => { + tracing::warn!("Failed to accept auth code reply: {e}"); + continue; + } + } + } + }; + + if let Err(e) = hyper::server::conn::http1::Builder::new() + .keep_alive(false) + .header_read_timeout(Duration::from_secs(5)) + .timer(TokioTimer::new()) + .auto_date_header(false) + .serve_connection( + TokioIo::new(client_socket), + hyper::service::service_fn(|req| handle_reply(req, &auth_code)), + ) + .await + { + tracing::warn!("Failed to handle auth code reply: {e}"); + } + } + + Ok(auth_code.into_inner().unwrap()) +} + +/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers. +pub fn stop_listeners() { + SERVER_SHUTDOWN.send(()).ok(); +} + +async fn handle_reply( + req: hyper::Request, + auth_code_out: &Mutex>, +) -> Result, hyper::http::Error> { + if req.method() != hyper::Method::GET { + return hyper::Response::builder() + .status(hyper::StatusCode::METHOD_NOT_ALLOWED) + .header("Allow", "GET") + .body("".into()); + } + + // The authorization code is guaranteed to be sent as a "code" query parameter + // in the request URI query string as per RFC 6749 § 4.1.2 + let auth_code = req.uri().query().and_then(|query_string| { + query_string + .split('&') + .filter_map(|query_pair| query_pair.split_once('=')) + .find_map(|(key, value)| (key == "code").then_some(value)) + }); + + let response = if let Some(auth_code) = auth_code { + *auth_code_out.lock().unwrap() = Some(auth_code.to_string()); + + hyper::Response::builder() + .status(hyper::StatusCode::OK) + .header("Content-Type", "text/html;charset=utf-8") + .body( + include_str!("auth_code_reply/page.html") + .replace("{{title}}", "Success") + .replace("{{message}}", "You have successfully signed in! You can close this page now."), + ) + } else { + hyper::Response::builder() + .status(hyper::StatusCode::BAD_REQUEST) + .header("Content-Type", "text/html;charset=utf-8") + .body( + include_str!("auth_code_reply/page.html") + .replace("{{title}}", "Error") + .replace("{{message}}", "Authorization code not found. Please try signing in again."), + ) + }?; + + Ok(response) +} diff --git a/apps/app/src/api/oauth_utils/auth_code_reply/page.html b/apps/app/src/api/oauth_utils/auth_code_reply/page.html new file mode 100644 index 000000000..f0ccff4ad --- /dev/null +++ b/apps/app/src/api/oauth_utils/auth_code_reply/page.html @@ -0,0 +1 @@ +Sign In - Modrinth App
diff --git a/apps/app/src/api/oauth_utils/mod.rs b/apps/app/src/api/oauth_utils/mod.rs new file mode 100644 index 000000000..4182cfb6c --- /dev/null +++ b/apps/app/src/api/oauth_utils/mod.rs @@ -0,0 +1,3 @@ +//! Assorted utilities for OAuth 2.0 authorization flows. + +pub mod auth_code_reply; diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 724e536d8..8667de5c6 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -63,6 +63,7 @@ "height": 800, "resizable": true, "title": "Modrinth App", + "label": "main", "width": 1280, "minHeight": 700, "minWidth": 1100, diff --git a/apps/frontend/src/pages/auth/sign-in.vue b/apps/frontend/src/pages/auth/sign-in.vue index b9a62301b..23d6b8de5 100644 --- a/apps/frontend/src/pages/auth/sign-in.vue +++ b/apps/frontend/src/pages/auth/sign-in.vue @@ -1,6 +1,12 @@