Add connected library for Git modpack manifests
Some checks failed
Build / verify (push) Failing after 18m55s

This commit is contained in:
MrSphay
2026-05-03 01:43:34 +02:00
parent 4348664618
commit dd7f9de5ce
14 changed files with 1109 additions and 3 deletions

34
.codex/project.md Normal file
View File

@@ -0,0 +1,34 @@
# Modrinth Plus
Modrinth Plus is a fork of the official Modrinth monorepo focused on the
desktop app. Its first custom feature is Connected Library: public Git-hosted
modpack manifests that can install and update exported `.mrpack` releases.
## Stack
- Monorepo package manager: pnpm
- Desktop app frontend: Vue
- Desktop app shell: Tauri
- Core app logic: Rust crate `theseus` in `packages/app-lib`
- Local app storage: SQLite migrations in `packages/app-lib/migrations`
- CI target: Gitea Actions runner
## Connected Library Defaults
- Supports public HTTPS raw manifest URLs and GitHub/GitLab/Gitea repo URLs.
- Manifest filename is `modrinth-plus.json`.
- Auto-update is disabled by default and can be enabled per connected pack.
- Private repository auth, SSH, and token storage are out of scope for v1.
## Verification
Use the cheapest relevant checks first:
```bash
pnpm install
pnpm --filter @modrinth/app-frontend run lint
cargo fmt --check
cargo clippy --package theseus
```
Full app packaging may require platform-specific Tauri dependencies.

View File

@@ -0,0 +1,51 @@
name: Build
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Install Tauri Linux prerequisites
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Frontend lint
run: pnpm --filter @modrinth/app-frontend run lint
- name: Rust format
run: cargo fmt --check
- name: Rust clippy
run: cargo clippy --package theseus --all-targets -- -D warnings

View File

@@ -14,6 +14,7 @@ import {
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
ExternalIcon, ExternalIcon,
GitGraphIcon,
HomeIcon, HomeIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
@@ -75,6 +76,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue' import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue' import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import { check_all_connected_packs } from '@/helpers/connected-library'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
@@ -425,6 +427,9 @@ initialize_state()
console.error(err) console.error(err)
error.showError(err, null, false, 'state_init') error.showError(err, null, false, 'state_init')
}) })
check_all_connected_packs().catch((err) => {
console.warn('Connected Library startup check failed', err)
})
}) })
.catch((err) => { .catch((err) => {
stateFailed.value = true stateFailed.value = true
@@ -1244,6 +1249,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<LibraryIcon /> <LibraryIcon />
</NavButton> </NavButton>
<NavButton
v-tooltip.right="'Connected Library'"
to="/library/connected"
:is-primary="(r) => r.path === '/library/connected'"
>
<GitGraphIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="'Modrinth Hosting'" v-tooltip.right="'Modrinth Hosting'"
to="/hosting/manage" to="/hosting/manage"

View File

@@ -0,0 +1,60 @@
import { invoke } from '@tauri-apps/api/core'
export interface ConnectedPack {
id: string
sourceUrl: string
manifestUrl: string
name: string
version: string
versionId: string
mrpackUrl: string
sha512: string
changelog: string | null
profilePath: string | null
installedVersionId: string | null
autoUpdate: boolean
lastChecked: string | null
lastError: string | null
created: string
updated: string
updateAvailable: boolean
}
export interface ConnectedCheckResult {
pack: ConnectedPack
installed: boolean
}
export async function list_connected_packs(): Promise<ConnectedPack[]> {
return await invoke('plugin:connected-library|connected_library_list')
}
export async function connect_pack(sourceUrl: string): Promise<ConnectedPack> {
return await invoke('plugin:connected-library|connected_library_connect', { sourceUrl })
}
export async function remove_connected_pack(id: string): Promise<void> {
return await invoke('plugin:connected-library|connected_library_remove', { id })
}
export async function set_connected_pack_auto_update(
id: string,
autoUpdate: boolean,
): Promise<ConnectedPack> {
return await invoke('plugin:connected-library|connected_library_set_auto_update', {
id,
autoUpdate,
})
}
export async function check_connected_pack(id: string): Promise<ConnectedCheckResult> {
return await invoke('plugin:connected-library|connected_library_check', { id })
}
export async function check_all_connected_packs(): Promise<ConnectedCheckResult[]> {
return await invoke('plugin:connected-library|connected_library_check_all')
}
export async function install_connected_pack(id: string): Promise<ConnectedPack> {
return await invoke('plugin:connected-library|connected_library_install', { id })
}

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
import {
CheckIcon,
DownloadIcon,
GitGraphIcon,
PlugIcon,
RefreshCwIcon,
TrashIcon,
} from '@modrinth/assets'
import { Button, Card, injectNotificationManager, ProgressSpinner, Toggle } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
check_all_connected_packs,
connect_pack,
install_connected_pack,
list_connected_packs,
remove_connected_pack,
set_connected_pack_auto_update,
type ConnectedPack,
} from '@/helpers/connected-library'
const { addNotification, handleError } = injectNotificationManager()
const packs = ref<ConnectedPack[]>([])
const sourceUrl = ref('')
const connecting = ref(false)
const checking = ref(false)
const installing = ref(new Set<string>())
const hasPacks = computed(() => packs.value.length > 0)
async function refresh() {
packs.value = await list_connected_packs().catch(handleError)
}
async function connect() {
if (!sourceUrl.value.trim()) return
connecting.value = true
try {
const pack = await connect_pack(sourceUrl.value.trim())
sourceUrl.value = ''
await refresh()
addNotification({
title: 'Connected modpack',
text: `${pack.name} is now in your Connected Library.`,
type: 'success',
})
} catch (error) {
handleError(error)
} finally {
connecting.value = false
}
}
async function checkForUpdates() {
checking.value = true
try {
await check_all_connected_packs()
await refresh()
} catch (error) {
handleError(error)
} finally {
checking.value = false
}
}
async function installPack(pack: ConnectedPack) {
installing.value = new Set(installing.value).add(pack.id)
try {
await install_connected_pack(pack.id)
await refresh()
addNotification({
title: pack.profilePath ? 'Updated modpack' : 'Installed modpack',
text: `${pack.name} is ready in your Library.`,
type: 'success',
})
} catch (error) {
handleError(error)
} finally {
const next = new Set(installing.value)
next.delete(pack.id)
installing.value = next
}
}
async function toggleAutoUpdate(pack: ConnectedPack, value: boolean) {
try {
await set_connected_pack_auto_update(pack.id, value)
await refresh()
} catch (error) {
handleError(error)
}
}
async function removePack(pack: ConnectedPack) {
try {
await remove_connected_pack(pack.id)
await refresh()
} catch (error) {
handleError(error)
}
}
onMounted(refresh)
</script>
<template>
<div class="connected-library">
<section class="connect-row">
<div class="input-wrap">
<GitGraphIcon />
<input
v-model="sourceUrl"
type="url"
placeholder="Git repo URL or raw modrinth-plus.json URL"
@keydown.enter="connect"
/>
</div>
<Button color="primary" :disabled="connecting || !sourceUrl.trim()" @click="connect">
<ProgressSpinner v-if="connecting" />
<PlugIcon v-else />
Connect modpack
</Button>
<Button :disabled="checking || !hasPacks" @click="checkForUpdates">
<ProgressSpinner v-if="checking" />
<RefreshCwIcon v-else />
Check for updates
</Button>
</section>
<div v-if="hasPacks" class="pack-list">
<Card v-for="pack in packs" :key="pack.id" class="pack-card">
<div class="pack-main">
<div class="pack-title">
<h2>{{ pack.name }}</h2>
<span v-if="pack.updateAvailable" class="status update">Update available</span>
<span v-else-if="pack.profilePath" class="status current">Current</span>
<span v-else class="status">Not installed</span>
</div>
<p v-if="pack.changelog">{{ pack.changelog }}</p>
<div class="meta">
<span>Latest {{ pack.version }}</span>
<span v-if="pack.installedVersionId">Installed {{ pack.installedVersionId }}</span>
<span v-if="pack.lastChecked">Checked {{ new Date(pack.lastChecked).toLocaleString() }}</span>
</div>
<p v-if="pack.lastError" class="error">{{ pack.lastError }}</p>
</div>
<div class="pack-actions">
<label class="auto-update">
<Toggle
:model-value="pack.autoUpdate"
@update:model-value="toggleAutoUpdate(pack, $event)"
/>
Auto update
</label>
<Button
v-if="pack.updateAvailable || !pack.profilePath"
color="primary"
:disabled="installing.has(pack.id)"
@click="installPack(pack)"
>
<ProgressSpinner v-if="installing.has(pack.id)" />
<DownloadIcon v-else />
{{ pack.profilePath ? 'Update' : 'Install' }}
</Button>
<Button v-else disabled>
<CheckIcon />
Installed
</Button>
<Button color="danger" @click="removePack(pack)">
<TrashIcon />
Remove
</Button>
</div>
</Card>
</div>
<div v-else class="empty">
<GitGraphIcon />
<h2>No connected modpacks</h2>
<p>Add a public Git repository or raw manifest URL to track exported .mrpack releases.</p>
</div>
</div>
</template>
<style scoped lang="scss">
.connected-library {
display: flex;
flex-direction: column;
gap: var(--gap-lg);
}
.connect-row {
display: grid;
grid-template-columns: minmax(16rem, 1fr) auto auto;
gap: var(--gap-sm);
align-items: center;
}
.input-wrap {
display: flex;
align-items: center;
gap: var(--gap-sm);
min-width: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-raised-bg);
border-radius: var(--radius-md);
background: var(--color-bg);
svg {
width: 1rem;
height: 1rem;
flex: none;
color: var(--color-brand);
}
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--color-text);
}
}
.pack-list {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.pack-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--gap-lg);
align-items: start;
}
.pack-main {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
min-width: 0;
h2,
p {
margin: 0;
}
}
.pack-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--gap-sm);
}
.status {
border-radius: var(--radius-sm);
padding: 0.125rem 0.375rem;
background: var(--color-raised-bg);
font-size: var(--font-size-xs);
&.update {
color: var(--color-brand);
}
&.current {
color: var(--color-green);
}
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--gap-sm);
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.error {
color: var(--color-red);
}
.pack-actions {
display: flex;
align-items: center;
gap: var(--gap-sm);
flex-wrap: wrap;
justify-content: flex-end;
}
.auto-update {
display: flex;
align-items: center;
gap: var(--gap-xs);
white-space: nowrap;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 18rem;
gap: var(--gap-sm);
text-align: center;
color: var(--color-secondary);
svg {
width: 3rem;
height: 3rem;
color: var(--color-brand);
}
h2,
p {
margin: 0;
}
}
@media (max-width: 860px) {
.connect-row,
.pack-card {
grid-template-columns: 1fr;
}
.pack-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -41,13 +41,14 @@ onUnmounted(() => {
:links="[ :links="[
{ label: 'All instances', href: `/library` }, { label: 'All instances', href: `/library` },
{ label: 'Modpacks', href: `/library/modpacks` }, { label: 'Modpacks', href: `/library/modpacks` },
{ label: 'Connected Library', href: `/library/connected` },
{ label: 'Servers', href: `/library/servers` }, { label: 'Servers', href: `/library/servers` },
{ label: 'Custom', href: `/library/custom` }, { label: 'Custom', href: `/library/custom` },
{ label: 'Shared with me', href: `/library/shared`, shown: false }, { label: 'Shared with me', href: `/library/shared`, shown: false },
{ label: 'Saved', href: `/library/saved`, shown: false }, { label: 'Saved', href: `/library/saved`, shown: false },
]" ]"
/> />
<template v-if="instances && instances.length > 0"> <template v-if="route.path.startsWith('/library/connected') || (instances && instances.length > 0)">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" /> <RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template> </template>
<div v-else class="no-instance"> <div v-else class="no-instance">

View File

@@ -1,8 +1,9 @@
import Custom from './Custom.vue' import Custom from './Custom.vue'
import Connected from './Connected.vue'
import Downloaded from './Downloaded.vue' import Downloaded from './Downloaded.vue'
import Index from './Index.vue' import Index from './Index.vue'
import Modpacks from './Modpacks.vue' import Modpacks from './Modpacks.vue'
import Overview from './Overview.vue' import Overview from './Overview.vue'
import Servers from './Servers.vue' import Servers from './Servers.vue'
export { Custom, Downloaded, Index, Modpacks, Overview, Servers } export { Connected, Custom, Downloaded, Index, Modpacks, Overview, Servers }

View File

@@ -115,6 +115,11 @@ export default new createRouter({
name: 'Modpacks', name: 'Modpacks',
component: Library.Modpacks, component: Library.Modpacks,
}, },
{
path: 'connected',
name: 'ConnectedLibrary',
component: Library.Connected,
},
{ {
path: 'servers', path: 'servers',
name: 'LibraryServers', name: 'LibraryServers',

View File

@@ -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<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
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<Vec<ConnectedPack>> {
Ok(list().await?)
}
#[tauri::command]
pub async fn connected_library_connect(
source_url: String,
) -> Result<ConnectedPack> {
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<ConnectedPack> {
Ok(set_auto_update(id, auto_update).await?)
}
#[tauri::command]
pub async fn connected_library_check(
id: String,
) -> Result<ConnectedCheckResult> {
Ok(check(id).await?)
}
#[tauri::command]
pub async fn connected_library_check_all() -> Result<Vec<ConnectedCheckResult>>
{
Ok(check_all().await?)
}
#[tauri::command]
pub async fn connected_library_install(id: String) -> Result<ConnectedPack> {
Ok(install(id).await?)
}

View File

@@ -19,6 +19,7 @@ pub mod utils;
pub mod ads; pub mod ads;
pub mod cache; pub mod cache;
pub mod connected_library;
pub mod files; pub mod files;
pub mod friends; pub mod friends;
pub mod worlds; pub mod worlds;

View File

@@ -233,6 +233,7 @@ fn main() {
.plugin(api::tags::init()) .plugin(api::tags::init())
.plugin(api::utils::init()) .plugin(api::utils::init())
.plugin(api::cache::init()) .plugin(api::cache::init())
.plugin(api::connected_library::init())
.plugin(api::files::init()) .plugin(api::files::init())
.plugin(api::ads::init()) .plugin(api::ads::init())
.plugin(api::friends::init()) .plugin(api::friends::init())

View File

@@ -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)
);

View File

@@ -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<String>,
}
#[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<String>,
pub profile_path: Option<String>,
pub installed_version_id: Option<String>,
pub auto_update: bool,
pub last_checked: Option<chrono::DateTime<Utc>>,
pub last_error: Option<String>,
pub created: chrono::DateTime<Utc>,
pub updated: chrono::DateTime<Utc>,
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<String>,
profile_path: Option<String>,
installed_version_id: Option<String>,
auto_update: i64,
last_checked: Option<i64>,
last_error: Option<String>,
created: i64,
updated: i64,
}
impl ConnectedPackRow {
fn from_row(row: sqlx::sqlite::SqliteRow) -> sqlx::Result<Self> {
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<Vec<ConnectedPack>> {
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::<sqlx::Result<Vec<_>>>()?
.into_iter()
.map(ConnectedPackRow::into_pack)
.collect();
Ok(packs)
}
pub async fn connect(source_url: String) -> crate::Result<ConnectedPack> {
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<ConnectedPack> {
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<ConnectedCheckResult> {
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<Vec<ConnectedCheckResult>> {
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<ConnectedPack> {
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<String> {
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::<Vec<_>>())
.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<ConnectedManifest> {
let manifest = reqwest::get(url)
.await?
.error_for_status()?
.json::<ConnectedManifest>()
.await?;
validate_manifest(&manifest)?;
Ok(manifest)
}
async fn fetch_mrpack(url: &str, expected_sha512: &str) -> crate::Result<bytes::Bytes> {
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<ConnectedPack> {
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<ConnectedPack> {
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())
}

View File

@@ -1,5 +1,6 @@
//! API for interacting with Theseus //! API for interacting with Theseus
pub mod cache; pub mod cache;
pub mod connected_library;
pub mod friends; pub mod friends;
pub mod handler; pub mod handler;
pub mod jre; pub mod jre;
@@ -34,7 +35,8 @@ pub mod prelude {
State, State,
data::*, data::*,
event::CommandPayload, event::CommandPayload,
jre, metadata, minecraft_auth, mr_auth, pack, process, connected_library, jre, metadata, minecraft_auth, mr_auth, pack,
process,
profile::{self, Profile, create}, profile::{self, Profile, create},
settings, settings,
util::{ util::{