Add connected library for Git modpack manifests
Some checks failed
Build / verify (push) Failing after 18m55s
Some checks failed
Build / verify (push) Failing after 18m55s
This commit is contained in:
34
.codex/project.md
Normal file
34
.codex/project.md
Normal 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.
|
||||
51
.gitea/workflows/build.yml
Normal file
51
.gitea/workflows/build.yml
Normal 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
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
GitGraphIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
@@ -75,6 +76,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.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 AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
@@ -425,6 +427,9 @@ initialize_state()
|
||||
console.error(err)
|
||||
error.showError(err, null, false, 'state_init')
|
||||
})
|
||||
check_all_connected_packs().catch((err) => {
|
||||
console.warn('Connected Library startup check failed', err)
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
stateFailed.value = true
|
||||
@@ -1244,6 +1249,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Connected Library'"
|
||||
to="/library/connected"
|
||||
:is-primary="(r) => r.path === '/library/connected'"
|
||||
>
|
||||
<GitGraphIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Modrinth Hosting'"
|
||||
to="/hosting/manage"
|
||||
|
||||
60
apps/app-frontend/src/helpers/connected-library.ts
Normal file
60
apps/app-frontend/src/helpers/connected-library.ts
Normal 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 })
|
||||
}
|
||||
334
apps/app-frontend/src/pages/library/Connected.vue
Normal file
334
apps/app-frontend/src/pages/library/Connected.vue
Normal 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>
|
||||
@@ -41,13 +41,14 @@ onUnmounted(() => {
|
||||
:links="[
|
||||
{ label: 'All instances', href: `/library` },
|
||||
{ label: 'Modpacks', href: `/library/modpacks` },
|
||||
{ label: 'Connected Library', href: `/library/connected` },
|
||||
{ label: 'Servers', href: `/library/servers` },
|
||||
{ label: 'Custom', href: `/library/custom` },
|
||||
{ label: 'Shared with me', href: `/library/shared`, 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" />
|
||||
</template>
|
||||
<div v-else class="no-instance">
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -115,6 +115,11 @@ export default new createRouter({
|
||||
name: 'Modpacks',
|
||||
component: Library.Modpacks,
|
||||
},
|
||||
{
|
||||
path: 'connected',
|
||||
name: 'ConnectedLibrary',
|
||||
component: Library.Connected,
|
||||
},
|
||||
{
|
||||
path: 'servers',
|
||||
name: 'LibraryServers',
|
||||
|
||||
62
apps/app/src/api/connected_library.rs
Normal file
62
apps/app/src/api/connected_library.rs
Normal 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?)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
518
packages/app-lib/src/api/connected_library.rs
Normal file
518
packages/app-lib/src/api/connected_library.rs
Normal 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())
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user