+
diff --git a/apps/app-frontend/src/pages/library/index.js b/apps/app-frontend/src/pages/library/index.js
index 561ac7e19..1cdbb0b9a 100644
--- a/apps/app-frontend/src/pages/library/index.js
+++ b/apps/app-frontend/src/pages/library/index.js
@@ -1,8 +1,9 @@
import Custom from './Custom.vue'
+import Connected from './Connected.vue'
import Downloaded from './Downloaded.vue'
import Index from './Index.vue'
import Modpacks from './Modpacks.vue'
import Overview from './Overview.vue'
import Servers from './Servers.vue'
-export { Custom, Downloaded, Index, Modpacks, Overview, Servers }
+export { Connected, Custom, Downloaded, Index, Modpacks, Overview, Servers }
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index c12306b5a..d5c3384cb 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -115,6 +115,11 @@ export default new createRouter({
name: 'Modpacks',
component: Library.Modpacks,
},
+ {
+ path: 'connected',
+ name: 'ConnectedLibrary',
+ component: Library.Connected,
+ },
{
path: 'servers',
name: 'LibraryServers',
diff --git a/apps/app/src/api/connected_library.rs b/apps/app/src/api/connected_library.rs
new file mode 100644
index 000000000..cddd2c9f6
--- /dev/null
+++ b/apps/app/src/api/connected_library.rs
@@ -0,0 +1,62 @@
+use crate::api::Result;
+use theseus::connected_library::{
+ ConnectedCheckResult, ConnectedPack, check, check_all, connect, install,
+ list, remove, set_auto_update,
+};
+
+pub fn init() -> tauri::plugin::TauriPlugin {
+ tauri::plugin::Builder::new("connected-library")
+ .invoke_handler(tauri::generate_handler![
+ connected_library_list,
+ connected_library_connect,
+ connected_library_remove,
+ connected_library_set_auto_update,
+ connected_library_check,
+ connected_library_check_all,
+ connected_library_install,
+ ])
+ .build()
+}
+
+#[tauri::command]
+pub async fn connected_library_list() -> Result> {
+ Ok(list().await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_connect(
+ source_url: String,
+) -> Result {
+ Ok(connect(source_url).await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_remove(id: String) -> Result<()> {
+ Ok(remove(id).await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_set_auto_update(
+ id: String,
+ auto_update: bool,
+) -> Result {
+ Ok(set_auto_update(id, auto_update).await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_check(
+ id: String,
+) -> Result {
+ Ok(check(id).await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_check_all() -> Result>
+{
+ Ok(check_all().await?)
+}
+
+#[tauri::command]
+pub async fn connected_library_install(id: String) -> Result {
+ Ok(install(id).await?)
+}
diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs
index b865499b5..3f6222143 100644
--- a/apps/app/src/api/mod.rs
+++ b/apps/app/src/api/mod.rs
@@ -19,6 +19,7 @@ pub mod utils;
pub mod ads;
pub mod cache;
+pub mod connected_library;
pub mod files;
pub mod friends;
pub mod worlds;
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index a78c26010..499219bfe 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -233,6 +233,7 @@ fn main() {
.plugin(api::tags::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
+ .plugin(api::connected_library::init())
.plugin(api::files::init())
.plugin(api::ads::init())
.plugin(api::friends::init())
diff --git a/packages/app-lib/migrations/20260503003000_connected-library.sql b/packages/app-lib/migrations/20260503003000_connected-library.sql
new file mode 100644
index 000000000..51dcb80d9
--- /dev/null
+++ b/packages/app-lib/migrations/20260503003000_connected-library.sql
@@ -0,0 +1,24 @@
+CREATE TABLE connected_library_packs (
+ id TEXT NOT NULL,
+ source_url TEXT NOT NULL,
+ manifest_url TEXT NOT NULL,
+
+ name TEXT NOT NULL,
+ version TEXT NOT NULL,
+ version_id TEXT NOT NULL,
+ mrpack_url TEXT NOT NULL,
+ sha512 TEXT NOT NULL,
+ changelog TEXT NULL,
+
+ profile_path TEXT NULL,
+ installed_version_id TEXT NULL,
+ auto_update INTEGER NOT NULL DEFAULT FALSE,
+
+ last_checked INTEGER NULL,
+ last_error TEXT NULL,
+ created INTEGER NOT NULL,
+ updated INTEGER NOT NULL,
+
+ UNIQUE (source_url),
+ PRIMARY KEY (id)
+);
diff --git a/packages/app-lib/src/api/connected_library.rs b/packages/app-lib/src/api/connected_library.rs
new file mode 100644
index 000000000..59192720a
--- /dev/null
+++ b/packages/app-lib/src/api/connected_library.rs
@@ -0,0 +1,518 @@
+use crate::pack::install_from::{CreatePack, CreatePackDescription};
+use crate::pack::install_mrpack::install_zipped_mrpack_files;
+use crate::profile;
+use crate::state::ModLoader;
+use crate::{ErrorKind, State};
+use chrono::{TimeZone, Utc};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha512};
+use sqlx::{Row, SqlitePool};
+use url::Url;
+use uuid::Uuid;
+
+const MANIFEST_FILE_NAME: &str = "modrinth-plus.json";
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConnectedManifest {
+ pub schema_version: u32,
+ pub name: String,
+ pub version: String,
+ pub version_id: String,
+ pub mrpack_url: String,
+ pub sha512: String,
+ pub changelog: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConnectedPack {
+ pub id: String,
+ pub source_url: String,
+ pub manifest_url: String,
+ pub name: String,
+ pub version: String,
+ pub version_id: String,
+ pub mrpack_url: String,
+ pub sha512: String,
+ pub changelog: Option,
+ pub profile_path: Option,
+ pub installed_version_id: Option,
+ pub auto_update: bool,
+ pub last_checked: Option>,
+ pub last_error: Option,
+ pub created: chrono::DateTime,
+ pub updated: chrono::DateTime,
+ pub update_available: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConnectedCheckResult {
+ pub pack: ConnectedPack,
+ pub installed: bool,
+}
+
+struct ConnectedPackRow {
+ id: String,
+ source_url: String,
+ manifest_url: String,
+ name: String,
+ version: String,
+ version_id: String,
+ mrpack_url: String,
+ sha512: String,
+ changelog: Option,
+ profile_path: Option,
+ installed_version_id: Option,
+ auto_update: i64,
+ last_checked: Option,
+ last_error: Option,
+ created: i64,
+ updated: i64,
+}
+
+impl ConnectedPackRow {
+ fn from_row(row: sqlx::sqlite::SqliteRow) -> sqlx::Result {
+ Ok(Self {
+ id: row.try_get("id")?,
+ source_url: row.try_get("source_url")?,
+ manifest_url: row.try_get("manifest_url")?,
+ name: row.try_get("name")?,
+ version: row.try_get("version")?,
+ version_id: row.try_get("version_id")?,
+ mrpack_url: row.try_get("mrpack_url")?,
+ sha512: row.try_get("sha512")?,
+ changelog: row.try_get("changelog")?,
+ profile_path: row.try_get("profile_path")?,
+ installed_version_id: row.try_get("installed_version_id")?,
+ auto_update: row.try_get("auto_update")?,
+ last_checked: row.try_get("last_checked")?,
+ last_error: row.try_get("last_error")?,
+ created: row.try_get("created")?,
+ updated: row.try_get("updated")?,
+ })
+ }
+
+ fn into_pack(self) -> ConnectedPack {
+ let update_available = match self.installed_version_id.as_ref() {
+ Some(installed) => installed != &self.version_id,
+ None => true,
+ };
+
+ ConnectedPack {
+ id: self.id,
+ source_url: self.source_url,
+ manifest_url: self.manifest_url,
+ name: self.name,
+ version: self.version,
+ version_id: self.version_id,
+ mrpack_url: self.mrpack_url,
+ sha512: self.sha512,
+ changelog: self.changelog,
+ profile_path: self.profile_path,
+ installed_version_id: self.installed_version_id,
+ auto_update: self.auto_update != 0,
+ last_checked: self
+ .last_checked
+ .and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
+ last_error: self.last_error,
+ created: Utc
+ .timestamp_opt(self.created, 0)
+ .single()
+ .unwrap_or_else(Utc::now),
+ updated: Utc
+ .timestamp_opt(self.updated, 0)
+ .single()
+ .unwrap_or_else(Utc::now),
+ update_available,
+ }
+ }
+}
+
+pub async fn list() -> crate::Result> {
+ let state = State::get().await?;
+ let rows = sqlx::query(
+ "
+ SELECT id, source_url, manifest_url, name, version, version_id,
+ mrpack_url, sha512, changelog, profile_path, installed_version_id,
+ auto_update, last_checked, last_error, created, updated
+ FROM connected_library_packs
+ ORDER BY name COLLATE NOCASE ASC
+ ",
+ )
+ .map(ConnectedPackRow::from_row)
+ .fetch_all(&state.pool)
+ .await?;
+
+ let packs = rows
+ .into_iter()
+ .collect::>>()?
+ .into_iter()
+ .map(ConnectedPackRow::into_pack)
+ .collect();
+
+ Ok(packs)
+}
+
+pub async fn connect(source_url: String) -> crate::Result {
+ let state = State::get().await?;
+ let manifest_url = normalize_manifest_url(&source_url)?;
+ let manifest = fetch_manifest(&manifest_url).await?;
+ validate_manifest(&manifest)?;
+ upsert_manifest(&state.pool, None, &source_url, &manifest_url, &manifest)
+ .await?;
+ get_by_source(&state.pool, &source_url).await
+}
+
+pub async fn remove(id: String) -> crate::Result<()> {
+ let state = State::get().await?;
+ sqlx::query("DELETE FROM connected_library_packs WHERE id = ?")
+ .bind(id)
+ .execute(&state.pool)
+ .await?;
+ Ok(())
+}
+
+pub async fn set_auto_update(
+ id: String,
+ auto_update: bool,
+) -> crate::Result {
+ let state = State::get().await?;
+ sqlx::query(
+ "
+ UPDATE connected_library_packs
+ SET auto_update = ?, updated = ?
+ WHERE id = ?
+ ",
+ )
+ .bind(auto_update)
+ .bind(Utc::now().timestamp())
+ .bind(&id)
+ .execute(&state.pool)
+ .await?;
+ get_by_id(&state.pool, &id).await
+}
+
+pub async fn check(id: String) -> crate::Result {
+ let state = State::get().await?;
+ let pack = get_by_id(&state.pool, &id).await?;
+ let manifest = fetch_manifest(&pack.manifest_url).await?;
+ validate_manifest(&manifest)?;
+
+ upsert_manifest(
+ &state.pool,
+ Some(&pack.id),
+ &pack.source_url,
+ &pack.manifest_url,
+ &manifest,
+ )
+ .await?;
+
+ let pack = get_by_id(&state.pool, &pack.id).await?;
+ let should_install = pack.auto_update && pack.update_available;
+ if should_install {
+ install(pack.id.clone()).await?;
+ }
+
+ Ok(ConnectedCheckResult {
+ pack: get_by_id(&state.pool, &pack.id).await?,
+ installed: should_install,
+ })
+}
+
+pub async fn check_all() -> crate::Result> {
+ let packs = list().await?;
+ let mut results = Vec::with_capacity(packs.len());
+
+ for pack in packs {
+ match check(pack.id.clone()).await {
+ Ok(result) => results.push(result),
+ Err(err) => {
+ let state = State::get().await?;
+ sqlx::query(
+ "
+ UPDATE connected_library_packs
+ SET last_checked = ?, last_error = ?, updated = ?
+ WHERE id = ?
+ ",
+ )
+ .bind(Utc::now().timestamp())
+ .bind(err.to_string())
+ .bind(Utc::now().timestamp())
+ .bind(&pack.id)
+ .execute(&state.pool)
+ .await?;
+ results.push(ConnectedCheckResult {
+ pack: get_by_id(&state.pool, &pack.id).await?,
+ installed: false,
+ });
+ }
+ }
+ }
+
+ Ok(results)
+}
+
+pub async fn install(id: String) -> crate::Result {
+ let state = State::get().await?;
+ let pack = get_by_id(&state.pool, &id).await?;
+ let bytes = fetch_mrpack(&pack.mrpack_url, &pack.sha512).await?;
+
+ let profile_path = if let Some(profile_path) = &pack.profile_path {
+ profile_path.clone()
+ } else {
+ profile::create::profile_create(
+ pack.name.clone(),
+ "1.20.1".to_string(),
+ ModLoader::Vanilla,
+ None,
+ None,
+ None,
+ Some(true),
+ )
+ .await?
+ };
+
+ let create_pack = CreatePack {
+ file: bytes,
+ description: CreatePackDescription {
+ icon: None,
+ override_title: Some(pack.name.clone()),
+ project_id: None,
+ version_id: Some(pack.version_id.clone()),
+ existing_loading_bar: None,
+ profile_path: profile_path.clone(),
+ },
+ };
+
+ install_zipped_mrpack_files(create_pack, true).await?;
+
+ sqlx::query(
+ "
+ UPDATE connected_library_packs
+ SET profile_path = ?, installed_version_id = ?, last_error = NULL,
+ updated = ?
+ WHERE id = ?
+ ",
+ )
+ .bind(profile_path)
+ .bind(&pack.version_id)
+ .bind(Utc::now().timestamp())
+ .bind(&id)
+ .execute(&state.pool)
+ .await?;
+
+ get_by_id(&state.pool, &id).await
+}
+
+fn normalize_manifest_url(source_url: &str) -> crate::Result {
+ let source = source_url.trim();
+ let parsed = Url::parse(source)?;
+
+ if parsed.scheme() != "https" {
+ return Err(ErrorKind::InputError(
+ "Connected Library only supports public HTTPS URLs".to_string(),
+ )
+ .into());
+ }
+
+ if source.ends_with(MANIFEST_FILE_NAME) || source.contains("/raw/") {
+ return Ok(source.to_string());
+ }
+
+ let host = parsed.host_str().unwrap_or_default();
+ let segments = parsed
+ .path_segments()
+ .map(|parts| parts.collect::>())
+ .unwrap_or_default();
+
+ if host == "github.com" && segments.len() >= 2 {
+ let repo = segments[1].trim_end_matches(".git");
+ return Ok(format!(
+ "https://raw.githubusercontent.com/{}/{}/main/{}",
+ segments[0], repo, MANIFEST_FILE_NAME
+ ));
+ }
+
+ if host == "gitlab.com" && segments.len() >= 2 {
+ let repo = segments[1].trim_end_matches(".git");
+ return Ok(format!(
+ "https://gitlab.com/{}/{}/-/raw/main/{}",
+ segments[0], repo, MANIFEST_FILE_NAME
+ ));
+ }
+
+ if segments.len() >= 2 {
+ let repo = segments[1].trim_end_matches(".git");
+ return Ok(format!(
+ "{}://{}/{}/{}/raw/branch/main/{}",
+ parsed.scheme(),
+ host,
+ segments[0],
+ repo,
+ MANIFEST_FILE_NAME
+ ));
+ }
+
+ Err(ErrorKind::InputError(
+ "Enter a raw modrinth-plus.json URL or a GitHub, GitLab, or Gitea repository URL"
+ .to_string(),
+ )
+ .into())
+}
+
+async fn fetch_manifest(url: &str) -> crate::Result {
+ let manifest = reqwest::get(url)
+ .await?
+ .error_for_status()?
+ .json::()
+ .await?;
+ validate_manifest(&manifest)?;
+ Ok(manifest)
+}
+
+async fn fetch_mrpack(url: &str, expected_sha512: &str) -> crate::Result {
+ let parsed = Url::parse(url)?;
+ if parsed.scheme() != "https" {
+ return Err(ErrorKind::InputError(
+ "Connected Library .mrpack downloads must use HTTPS".to_string(),
+ )
+ .into());
+ }
+
+ let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;
+ let hash = format!("{:x}", Sha512::digest(&bytes[..]));
+ if !hash.eq_ignore_ascii_case(expected_sha512) {
+ return Err(ErrorKind::HashError(
+ expected_sha512.to_string(),
+ hash,
+ )
+ .into());
+ }
+ Ok(bytes)
+}
+
+fn validate_manifest(manifest: &ConnectedManifest) -> crate::Result<()> {
+ if manifest.schema_version != 1 {
+ return Err(ErrorKind::InputError(
+ "Unsupported Connected Library schema version".to_string(),
+ )
+ .into());
+ }
+
+ for (field, value) in [
+ ("name", &manifest.name),
+ ("version", &manifest.version),
+ ("versionId", &manifest.version_id),
+ ("mrpackUrl", &manifest.mrpack_url),
+ ("sha512", &manifest.sha512),
+ ] {
+ if value.trim().is_empty() {
+ return Err(ErrorKind::InputError(format!(
+ "Connected Library manifest field `{field}` is required"
+ ))
+ .into());
+ }
+ }
+
+ let mrpack_url = Url::parse(&manifest.mrpack_url)?;
+ if mrpack_url.scheme() != "https" {
+ return Err(ErrorKind::InputError(
+ "Connected Library .mrpack downloads must use HTTPS".to_string(),
+ )
+ .into());
+ }
+
+ Ok(())
+}
+
+async fn upsert_manifest(
+ pool: &SqlitePool,
+ existing_id: Option<&str>,
+ source_url: &str,
+ manifest_url: &str,
+ manifest: &ConnectedManifest,
+) -> crate::Result<()> {
+ let now = Utc::now().timestamp();
+ let id = existing_id
+ .map(ToString::to_string)
+ .unwrap_or_else(|| Uuid::new_v4().to_string());
+
+ sqlx::query(
+ "
+ INSERT INTO connected_library_packs (
+ id, source_url, manifest_url, name, version, version_id,
+ mrpack_url, sha512, changelog, last_checked, last_error,
+ created, updated
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
+ ON CONFLICT(source_url) DO UPDATE SET
+ manifest_url = excluded.manifest_url,
+ name = excluded.name,
+ version = excluded.version,
+ version_id = excluded.version_id,
+ mrpack_url = excluded.mrpack_url,
+ sha512 = excluded.sha512,
+ changelog = excluded.changelog,
+ last_checked = excluded.last_checked,
+ last_error = NULL,
+ updated = excluded.updated
+ ",
+ )
+ .bind(id)
+ .bind(source_url)
+ .bind(manifest_url)
+ .bind(&manifest.name)
+ .bind(&manifest.version)
+ .bind(&manifest.version_id)
+ .bind(&manifest.mrpack_url)
+ .bind(&manifest.sha512)
+ .bind(&manifest.changelog)
+ .bind(now)
+ .bind(now)
+ .bind(now)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+}
+
+async fn get_by_id(pool: &SqlitePool, id: &str) -> crate::Result {
+ let row = sqlx::query(
+ "
+ SELECT id, source_url, manifest_url, name, version, version_id,
+ mrpack_url, sha512, changelog, profile_path, installed_version_id,
+ auto_update, last_checked, last_error, created, updated
+ FROM connected_library_packs
+ WHERE id = ?
+ ",
+ )
+ .bind(id)
+ .map(ConnectedPackRow::from_row)
+ .fetch_one(pool)
+ .await??;
+
+ Ok(row.into_pack())
+}
+
+async fn get_by_source(
+ pool: &SqlitePool,
+ source_url: &str,
+) -> crate::Result {
+ let row = sqlx::query(
+ "
+ SELECT id, source_url, manifest_url, name, version, version_id,
+ mrpack_url, sha512, changelog, profile_path, installed_version_id,
+ auto_update, last_checked, last_error, created, updated
+ FROM connected_library_packs
+ WHERE source_url = ?
+ ",
+ )
+ .bind(source_url)
+ .map(ConnectedPackRow::from_row)
+ .fetch_one(pool)
+ .await??;
+
+ Ok(row.into_pack())
+}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 5cafca724..6bef7e8b0 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -1,5 +1,6 @@
//! API for interacting with Theseus
pub mod cache;
+pub mod connected_library;
pub mod friends;
pub mod handler;
pub mod jre;
@@ -34,7 +35,8 @@ pub mod prelude {
State,
data::*,
event::CommandPayload,
- jre, metadata, minecraft_auth, mr_auth, pack, process,
+ connected_library, jre, metadata, minecraft_auth, mr_auth, pack,
+ process,
profile::{self, Profile, create},
settings,
util::{