feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+5
View File
@@ -240,6 +240,11 @@ impl FriendsSocket {
}
async fn handle_notification(notification: Value) -> crate::Result<()> {
tracing::info!(
notification = %notification,
"Received websocket notification payload"
);
if notification
.get("body")
.and_then(|body| body.get("type"))
+16 -1
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::instances::ContentSourceKind;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InstanceInstallStage {
@@ -122,6 +124,7 @@ pub struct ContentFile {
pub metadata: Option<FileMetadata>,
pub update_version_id: Option<String>,
pub project_type: ProjectType,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -130,7 +133,7 @@ pub struct FileMetadata {
pub version_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum ProjectType {
Mod,
@@ -186,6 +189,18 @@ impl ProjectType {
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"mod" | "mods" => Some(ProjectType::Mod),
"datapack" | "datapacks" => Some(ProjectType::DataPack),
"resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack),
"shader" | "shaderpack" | "shaderpacks" => {
Some(ProjectType::ShaderPack)
}
_ => None,
}
}
pub fn get_folder(&self) -> &'static str {
match self {
ProjectType::Mod => "mods",
@@ -89,6 +89,9 @@ fn is_scannable_project_file(
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
ProjectType::DataPack
| ProjectType::ResourcePack
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
| ProjectType::ShaderPack => {
extension.eq_ignore_ascii_case("zip")
|| extension.eq_ignore_ascii_case("jar")
}
}
}
@@ -374,6 +374,56 @@ where
rows.into_iter().map(TryInto::try_into).collect()
}
pub(crate) async fn upsert_content_set_remote_ref(
remote_ref: &ContentSetRemoteRef,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = remote_ref.content_set_id.as_str();
let ref_type = remote_ref.ref_type.as_str();
let ref_id = remote_ref.ref_id.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_remote_refs (
content_set_id,
ref_type,
ref_id
)
VALUES (?, ?, ?)
ON CONFLICT (content_set_id, ref_type) DO UPDATE SET
ref_id = excluded.ref_id
",
content_set_id,
ref_type,
ref_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_remote_ref(
content_set_id: &str,
ref_type: ContentSetRemoteRefType,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let ref_type = ref_type.as_str();
sqlx::query!(
"
DELETE FROM instance_content_set_remote_refs
WHERE content_set_id = ? AND ref_type = ?
",
content_set_id,
ref_type,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_content_set_sync_state<'e, E>(
content_set_id: &str,
exec: E,
@@ -396,6 +446,66 @@ where
row.map(TryInto::try_into).transpose()
}
pub(crate) async fn upsert_content_set_sync_state(
sync_state: &ContentSetSyncState,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = sync_state.content_set_id.as_str();
let provider = sync_state.provider.as_str();
let applied_update_id = sync_state.applied_update_id.as_deref();
let latest_available_update_id =
sync_state.latest_available_update_id.as_deref();
let checked_at = sync_state.checked_at.map(|value| value.timestamp());
let status = sync_state.status.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_sync_state (
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (content_set_id) DO UPDATE SET
provider = excluded.provider,
applied_update_id = excluded.applied_update_id,
latest_available_update_id = excluded.latest_available_update_id,
checked_at = excluded.checked_at,
status = excluded.status
",
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_sync_state(
content_set_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
"
DELETE FROM instance_content_set_sync_state
WHERE content_set_id = ?
",
content_set_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_instance_files<'e, E>(
instance_id: &str,
exec: E,
@@ -523,6 +633,100 @@ pub(crate) async fn upsert_instance_file(
row.try_into()
}
async fn insert_content_entry(
entry: &ContentEntry,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let id = entry.id.as_str();
let instance_id = entry.instance_id.as_str();
let content_set_id = entry.content_set_id.as_str();
let file_id = entry.file_id.as_deref();
let project_type = entry.project_type.get_name();
let project_id = entry.project_id.as_deref();
let version_id = entry.version_id.as_deref();
let source_kind = entry.source_kind.as_str();
let server_requirement = entry.server_requirement.as_str();
let client_requirement = entry.client_requirement.as_str();
let enabled = i64::from(entry.enabled);
let added_at = entry.added_at.timestamp();
let modified_at = entry.modified_at.timestamp();
sqlx::query(
"
INSERT INTO instance_content_entries (
id,
instance_id,
content_set_id,
file_id,
project_type,
project_id,
version_id,
source_kind,
server_requirement,
client_requirement,
enabled,
added_at,
modified_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(id)
.bind(instance_id)
.bind(content_set_id)
.bind(file_id)
.bind(project_type)
.bind(project_id)
.bind(version_id)
.bind(source_kind)
.bind(server_requirement)
.bind(client_requirement)
.bind(enabled)
.bind(added_at)
.bind(modified_at)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn restore_instance_content_snapshot(
instance_id: &str,
files: &[InstanceFile],
entries: &[ContentEntry],
pool: &SqlitePool,
) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query(
"
DELETE FROM instance_content_entries
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"
DELETE FROM instance_files
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut *tx)
.await?;
for file in files {
upsert_instance_file(file, &mut tx).await?;
}
for entry in entries {
insert_content_entry(entry, &mut tx).await?;
}
tx.commit().await?;
Ok(())
}
pub(crate) async fn get_content_entries<'e, E>(
content_set_id: &str,
exec: E,
@@ -1091,16 +1295,12 @@ pub(crate) async fn upsert_content_update_check(
}
fn project_type_from_str(value: &str) -> crate::Result<ProjectType> {
match value {
"mod" => Ok(ProjectType::Mod),
"datapack" => Ok(ProjectType::DataPack),
"resourcepack" => Ok(ProjectType::ResourcePack),
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown content project type {other}"
ProjectType::from_name(value).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown content project type {value}"
))
.into()),
}
.into()
})
}
fn timestamp(value: i64) -> DateTime<Utc> {
@@ -1,9 +1,10 @@
#![allow(dead_code)]
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, playtime_to_storage,
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
SharedInstanceRole, playtime_to_storage,
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
@@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow {
pub hosting_instance_ids: Option<String>,
pub hosting_active_instance_id: Option<String>,
pub shared_instance_id: Option<String>,
pub shared_instance_role: Option<String>,
pub shared_instance_manager_id: Option<String>,
pub shared_instance_linked_user_id: Option<String>,
pub imported_name: Option<String>,
pub imported_version_number: Option<String>,
pub imported_filename: Option<String>,
@@ -137,10 +141,8 @@ impl TryFrom<InstanceLinkRow> for InstanceLink {
filename: row.imported_filename,
}),
"shared_instance" => Ok(Self::SharedInstance {
shared_instance_id: parse_uuid(
row.shared_instance_id,
"shared_instance_id",
)?,
modpack_project_id: row.modrinth_project_id,
modpack_version_id: row.modrinth_version_id,
}),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown instance link kind {other}"
@@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -207,6 +210,14 @@ struct InstanceMetadataRow {
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_server_manager_name: Option<String>,
shared_instance_server_manager_icon_url: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_applied_update_id: Option<String>,
shared_sync_latest_available_update_id: Option<String>,
shared_sync_status: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -300,12 +311,28 @@ impl InstanceMetadataRow {
hosting_server_id: self.hosting_server_id,
hosting_instance_ids: self.hosting_instance_ids,
hosting_active_instance_id: self.hosting_active_instance_id,
shared_instance_id: self.shared_instance_id,
shared_instance_id: self.shared_instance_id.clone(),
shared_instance_role: self.shared_instance_role.clone(),
shared_instance_manager_id: self.shared_instance_manager_id.clone(),
shared_instance_linked_user_id: self
.shared_instance_linked_user_id
.clone(),
imported_name: self.imported_name,
imported_version_number: self.imported_version_number,
imported_filename: self.imported_filename,
}
.try_into()?;
let shared_instance = shared_instance_attachment(
self.shared_instance_id,
self.shared_instance_role,
self.shared_instance_manager_id,
self.shared_instance_server_manager_name,
self.shared_instance_server_manager_icon_url,
self.shared_instance_linked_user_id,
self.shared_sync_status,
self.shared_sync_applied_update_id,
self.shared_sync_latest_available_update_id,
)?;
let groups = parse_groups(self.groups)?;
let launch_overrides =
launch_overrides_from_json(instance_id, self.launch_overrides)?;
@@ -314,6 +341,7 @@ impl InstanceMetadataRow {
instance,
applied_content_set,
link,
shared_instance,
groups,
launch_overrides,
})
@@ -418,75 +446,173 @@ where
Ok(row)
}
pub(crate) async fn is_instance_quarantined<'e, E>(
instance_id: &str,
exec: E,
) -> crate::Result<bool>
where
E: Executor<'e, Database = Sqlite>,
{
let quarantined = sqlx::query_scalar::<_, bool>(
"
SELECT EXISTS (
SELECT 1
FROM instance_quarantines
WHERE instance_id = ?
)
",
)
.bind(instance_id)
.fetch_one(exec)
.await?;
Ok(quarantined)
}
pub(crate) async fn get_quarantined_instance_ids<'e, E>(
exec: E,
) -> crate::Result<std::collections::HashSet<String>>
where
E: Executor<'e, Database = Sqlite>,
{
let instance_ids = sqlx::query_scalar::<_, String>(
"
SELECT instance_id
FROM instance_quarantines
",
)
.fetch_all(exec)
.await?;
Ok(instance_ids.into_iter().collect())
}
pub(crate) async fn set_instance_quarantined(
instance_id: &str,
quarantined: bool,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
if quarantined {
sqlx::query(
"
INSERT INTO instance_quarantines (instance_id)
VALUES (?)
ON CONFLICT (instance_id) DO NOTHING
",
)
.bind(instance_id)
.execute(&mut **tx)
.await?;
} else {
sqlx::query(
"
DELETE FROM instance_quarantines
WHERE instance_id = ?
",
)
.bind(instance_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
macro_rules! query_instance_metadata {
(
$prefix:literal,
$from:literal,
$suffix:literal,
$arg:expr $(,)?
) => {
sqlx::query_as!(
InstanceMetadataRow,
$prefix
+ r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_server_manager_name AS "shared_instance_server_manager_name?: String",
link.shared_instance_server_manager_icon_url AS "shared_instance_server_manager_icon_url?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
"#
+ $from
+ r#"
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
"#
+ $suffix,
$arg,
)
};
}
pub(crate) async fn get_instance_metadata_by_id(
id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadataRecord>> {
let row = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
"#,
id,
)
.fetch_optional(pool)
.await?;
let row =
query_instance_metadata!("", "FROM instances i", "WHERE i.id = ?", id,)
.fetch_optional(pool)
.await?;
row.map(InstanceMetadataRow::into_record).transpose()
}
@@ -500,73 +626,19 @@ pub(crate) async fn get_instance_metadata_many(
}
let ids_json = serde_json::to_string(ids)?;
let rows = sqlx::query_as!(
InstanceMetadataRow,
let rows = query_instance_metadata!(
r#"
WITH requested AS (
SELECT value AS id, key AS ord
FROM json_each(?)
)
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
"#,
r#"
FROM requested
INNER JOIN instances i
ON i.id = requested.id
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
ORDER BY requested.ord
"#,
"ORDER BY requested.ord",
ids_json,
)
.fetch_all(pool)
@@ -580,69 +652,10 @@ pub(crate) async fn get_instance_metadata_many(
pub(crate) async fn list_instance_metadata(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadataRecord>> {
let rows = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
COALESCE((
SELECT json_group_array(group_name)
FROM (
SELECT group_name
FROM instance_groups
WHERE instance_id = i.id
ORDER BY group_name
)
), '[]') AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
"#,
)
.fetch_all(pool)
.await?;
let rows =
query_instance_metadata!("", "FROM instances i", "WHERE 1 = ?", 1_i64,)
.fetch_all(pool)
.await?;
rows.into_iter()
.map(InstanceMetadataRow::into_record)
@@ -653,59 +666,10 @@ pub(crate) async fn get_instance_launch_context(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceLaunchContext>> {
let row = sqlx::query_as!(
InstanceMetadataRow,
r#"
SELECT
i.id AS "id!: String",
i.path AS "path!: String",
i.applied_content_set_id AS "applied_content_set_id?: String",
i.install_stage AS "install_stage!: String",
i.launcher_feature_version AS "launcher_feature_version!: String",
i.update_channel AS "update_channel!: String",
i.name AS "name!: String",
i.icon_path AS "icon_path?: String",
i.created AS "created!: i64",
i.modified AS "modified!: i64",
i.last_played AS "last_played?: i64",
i.submitted_time_played AS "submitted_time_played!: i64",
i.recent_time_played AS "recent_time_played!: i64",
cs.id AS "content_set_id?: String",
cs.instance_id AS "content_set_instance_id?: String",
cs.name AS "content_set_name?: String",
cs.source_kind AS "content_set_source_kind?: String",
cs.status AS "content_set_status?: String",
cs.game_version AS "content_set_game_version?: String",
cs.protocol_version AS "content_set_protocol_version?: i64",
cs.loader AS "content_set_loader?: String",
cs.loader_version AS "content_set_loader_version?: String",
cs.created AS "content_set_created?: i64",
cs.modified AS "content_set_modified?: i64",
COALESCE(link.link_kind, 'unmanaged') AS "link_kind!: String",
link.modrinth_project_id AS "modrinth_project_id?: String",
link.modrinth_version_id AS "modrinth_version_id?: String",
link.server_project_id AS "server_project_id?: String",
link.content_project_id AS "content_project_id?: String",
link.content_version_id AS "content_version_id?: String",
link.hosting_server_id AS "hosting_server_id?: String",
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
'[]' AS "groups!: String",
json(overrides.overrides) AS "launch_overrides?: String"
FROM instances i
LEFT JOIN instance_content_sets cs
ON cs.id = i.applied_content_set_id
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
"#,
let row = query_instance_metadata!(
"",
"FROM instances i",
"WHERE i.id = ?",
instance_id,
)
.fetch_optional(pool)
@@ -753,6 +717,9 @@ where
json(hosting_instance_ids) AS "hosting_instance_ids?: String",
hosting_active_instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_linked_user_id,
imported_name,
imported_version_number,
imported_filename
@@ -949,7 +916,6 @@ pub(crate) async fn upsert_instance_link(
let hosting_instance_ids = columns.hosting_instance_ids.as_deref();
let hosting_active_instance_id =
columns.hosting_active_instance_id.as_deref();
let shared_instance_id = columns.shared_instance_id.as_deref();
let imported_name = columns.imported_name.as_deref();
let imported_version_number = columns.imported_version_number.as_deref();
let imported_filename = columns.imported_filename.as_deref();
@@ -967,12 +933,11 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = excluded.link_kind,
modrinth_project_id = excluded.modrinth_project_id,
@@ -983,7 +948,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id = excluded.hosting_server_id,
hosting_instance_ids = excluded.hosting_instance_ids,
hosting_active_instance_id = excluded.hosting_active_instance_id,
shared_instance_id = excluded.shared_instance_id,
imported_name = excluded.imported_name,
imported_version_number = excluded.imported_version_number,
imported_filename = excluded.imported_filename
@@ -998,7 +962,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename,
@@ -1009,6 +972,64 @@ pub(crate) async fn upsert_instance_link(
Ok(())
}
pub(crate) async fn set_shared_instance_attachment(
instance_id: &str,
attachment: Option<&SharedInstanceAttachment>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let shared_instance_id = attachment.map(|value| value.id.to_string());
let shared_instance_role =
attachment.map(|value| value.role.as_str().to_string());
let shared_instance_manager_id =
attachment.and_then(|value| value.manager_id.as_deref());
let shared_instance_linked_user_id =
attachment.and_then(|value| value.linked_user_id.as_deref());
let shared_instance_server_manager_name =
attachment.and_then(|value| value.server_manager_name.as_deref());
let shared_instance_server_manager_icon_url =
attachment.and_then(|value| value.server_manager_icon_url.as_deref());
sqlx::query!(
"
INSERT INTO instance_links (
instance_id,
link_kind,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_server_manager_name,
shared_instance_server_manager_icon_url,
shared_instance_linked_user_id
)
VALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = CASE
WHEN excluded.shared_instance_id IS NULL
AND instance_links.link_kind = 'shared_instance'
THEN 'unmanaged'
ELSE instance_links.link_kind
END,
shared_instance_id = excluded.shared_instance_id,
shared_instance_role = excluded.shared_instance_role,
shared_instance_manager_id = excluded.shared_instance_manager_id,
shared_instance_server_manager_name = excluded.shared_instance_server_manager_name,
shared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,
shared_instance_linked_user_id = excluded.shared_instance_linked_user_id
",
instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_server_manager_name,
shared_instance_server_manager_icon_url,
shared_instance_linked_user_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn replace_instance_groups(
instance_id: &str,
groups: &[String],
@@ -1094,7 +1115,6 @@ struct InstanceLinkColumns {
hosting_server_id: Option<String>,
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -1114,7 +1134,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1132,7 +1151,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1147,7 +1165,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1166,7 +1183,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1186,7 +1202,6 @@ fn instance_link_columns(
hosting_instance_ids: Some(serde_json::to_string(instance_ids)?),
hosting_active_instance_id: active_instance_id
.map(|value| value.to_string()),
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1207,28 +1222,27 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: name.clone(),
imported_version_number: version_number.clone(),
imported_filename: filename.clone(),
}),
InstanceLink::SharedInstance { shared_instance_id } => {
Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: None,
modrinth_version_id: None,
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: Some(shared_instance_id.to_string()),
imported_name: None,
imported_version_number: None,
imported_filename: None,
})
}
InstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: modpack_project_id.clone(),
modrinth_version_id: modpack_version_id.clone(),
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
}),
}
}
@@ -1273,6 +1287,60 @@ fn launch_overrides_from_json(
}
}
fn shared_instance_attachment(
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_server_manager_name: Option<String>,
shared_instance_server_manager_icon_url: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_status: Option<String>,
applied_update_id: Option<String>,
latest_available_update_id: Option<String>,
) -> crate::Result<Option<SharedInstanceAttachment>> {
let Some(id) = shared_instance_id else {
return Ok(None);
};
let role = match shared_instance_role {
Some(role) => SharedInstanceRole::from_stored_str(&role)?,
None => SharedInstanceRole::Member,
};
let status = match shared_sync_status {
Some(status) => ContentSetSyncStatus::from_str(&status)?,
None => ContentSetSyncStatus::Unknown,
};
Ok(Some(SharedInstanceAttachment {
id,
role,
manager_id: shared_instance_manager_id,
server_manager_name: shared_instance_server_manager_name,
server_manager_icon_url: shared_instance_server_manager_icon_url,
linked_user_id: shared_instance_linked_user_id,
status,
applied_version: optional_i32(applied_update_id, "applied_update_id")?,
latest_version: optional_i32(
latest_available_update_id,
"latest_available_update_id",
)?,
}))
}
fn optional_i32(
value: Option<String>,
column: &str,
) -> crate::Result<Option<i32>> {
value
.map(|value| {
value.parse().map_err(|err| {
crate::ErrorKind::InputError(format!("Invalid {column}: {err}"))
.into()
})
})
.transpose()
}
fn parse_uuid(value: Option<String>, column: &str) -> crate::Result<Uuid> {
let value = required(value, column)?;
@@ -502,6 +502,13 @@ pub(crate) async fn add_project_bytes(
version_id: Option<&str>,
state: &State,
) -> crate::Result<String> {
if !path_util::is_safe_file_name(file_name) {
return Err(crate::ErrorKind::InputError(format!(
"Project file {file_name} has an invalid file name"
))
.into());
}
let _content_lock = state.lock_instance_content(instance_id).await;
let scope = resolve_content_scope(instance_id, None, state).await?;
let project_type = match project_type {
@@ -561,6 +568,7 @@ pub(crate) async fn add_project_bytes(
)
.await?;
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(relative_path)
}
@@ -608,6 +616,7 @@ pub(crate) async fn record_project_file(
)
.await?;
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(())
}
@@ -711,6 +720,8 @@ pub(crate) async fn toggle_disable_project(
}
tx.commit().await?;
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(new_path)
}
@@ -752,9 +763,36 @@ pub(crate) async fn remove_project(
tx.commit().await?;
}
super::mark_shared_instance_stale(instance_id, &state.pool).await?;
Ok(())
}
pub(crate) async fn content_source_kind_for_project_path(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<Option<ContentSourceKind>> {
let scope = resolve_content_scope(instance_id, None, state).await?;
let Some(file) = content_rows::get_instance_file_by_relative_path(
&scope.instance.id,
project_path,
&state.pool,
)
.await?
else {
return Ok(None);
};
let entries =
content_rows::get_content_entries(&scope.content_set_id, &state.pool)
.await?;
Ok(entries.into_iter().find_map(|entry| {
(entry.file_id.as_deref() == Some(file.id.as_str()))
.then_some(entry.source_kind)
}))
}
pub(crate) async fn rename_project_companion_file(
instance_id: &str,
old_project_path: &str,
@@ -52,6 +52,7 @@ struct InstalledProject {
relative_path: String,
project_id: Option<String>,
version_id: Option<String>,
source_kind: ContentSourceKind,
enabled: bool,
}
@@ -295,8 +296,14 @@ async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let updateable_paths =
bulk_updateable_project_paths(instance_id, state).await?;
let shared_instance_member =
is_shared_instance_member(instance_id, state).await?;
let updateable_paths = bulk_updateable_project_paths(
instance_id,
shared_instance_member,
state,
)
.await?;
if updateable_paths.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
@@ -330,6 +337,30 @@ async fn plan_bulk_update(
})?;
let installed =
installed_projects(instance_id, &content_set, state).await?;
let updateable_paths = if shared_instance_member {
let managed_paths = installed
.iter()
.filter(|project| project.source_kind.is_shared_instance_managed())
.map(|project| project.relative_path.clone())
.collect::<HashSet<_>>();
updateable_paths
.into_iter()
.filter(|path| !managed_paths.contains(path))
.collect::<HashSet<_>>()
} else {
updateable_paths
};
let updates = updates
.into_iter()
.filter(|update| updateable_paths.contains(&update.relative_path))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let installed_by_project = installed
.iter()
.filter_map(|project| {
@@ -416,6 +447,7 @@ async fn plan_bulk_update(
async fn bulk_updateable_project_paths(
instance_id: &str,
shared_instance_member: bool,
state: &State,
) -> crate::Result<HashSet<String>> {
let items = super::list_content::list_content(
@@ -426,7 +458,16 @@ async fn bulk_updateable_project_paths(
)
.await?;
Ok(items.into_iter().map(|item| item.file_path).collect())
Ok(items
.into_iter()
.filter(|item| {
!shared_instance_member
|| !item
.source_kind
.is_some_and(ContentSourceKind::is_shared_instance_managed)
})
.map(|item| item.file_path)
.collect())
}
async fn installed_projects(
@@ -471,10 +512,27 @@ fn installed_project_from_row(
relative_path: file.relative_path.clone(),
project_id: entry.project_id.clone(),
version_id: entry.version_id.clone(),
source_kind: entry.source_kind,
enabled: entry.enabled && file.enabled,
})
}
async fn is_shared_instance_member(
instance_id: &str,
state: &State,
) -> crate::Result<bool> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, &state.pool)
.await?
else {
return Ok(false);
};
Ok(metadata
.shared_instance
.is_some_and(|attachment| attachment.role.is_member()))
}
async fn dependency_closure(
root_versions: Vec<Version>,
content_set: &ContentSet,
@@ -102,11 +102,28 @@ pub(crate) async fn edit_instance(
patch: EditInstance,
pool: &SqlitePool,
) -> crate::Result<Instance> {
let modifies_content =
patch.link.is_some() || patch.content_set_patch.is_some();
let should_mark_shared_instance_stale = patch.link.is_some()
|| patch.content_set_patch.as_ref().is_some_and(|patch| {
patch.source_kind.is_some()
|| patch.game_version.is_some()
|| patch.loader.is_some()
|| patch.loader_version.is_some()
});
let mut instance = instance_rows::get_instance_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
if modifies_content
&& instance_rows::is_instance_quarantined(instance_id, pool).await?
{
return Err(crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
.into());
}
let now = Utc::now();
apply_instance_patch(&mut instance, &patch, now);
@@ -169,6 +186,10 @@ pub(crate) async fn edit_instance(
tx.commit().await?;
if should_mark_shared_instance_stale {
super::mark_shared_instance_stale(instance_id, pool).await?;
}
Ok(instance)
}
@@ -1,6 +1,6 @@
use crate::state::instances::{
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
adapters::sqlite::instance_rows,
SharedInstanceAttachment, adapters::sqlite::instance_rows,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
@@ -10,6 +10,9 @@ pub struct InstanceMetadata {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
#[serde(default)]
pub quarantined: bool,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -25,44 +28,62 @@ pub(crate) async fn get_instance_metadata(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceMetadata>> {
Ok(
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.map(Into::into),
)
let Some(record) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(None);
};
let quarantined =
instance_rows::is_instance_quarantined(instance_id, pool).await?;
Ok(Some(instance_metadata(record, quarantined)))
}
pub(crate) async fn get_instances_metadata(
instance_ids: &[&str],
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
Ok(
instance_rows::get_instance_metadata_many(instance_ids, pool)
.await?
.into_iter()
.map(Into::into)
.collect(),
)
let records =
instance_rows::get_instance_metadata_many(instance_ids, pool).await?;
let quarantined_ids =
instance_rows::get_quarantined_instance_ids(pool).await?;
Ok(records
.into_iter()
.map(|record| {
let quarantined = quarantined_ids.contains(&record.instance.id);
instance_metadata(record, quarantined)
})
.collect())
}
pub(crate) async fn list_instances(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceMetadata>> {
Ok(instance_rows::list_instance_metadata(pool)
.await?
let records = instance_rows::list_instance_metadata(pool).await?;
let quarantined_ids =
instance_rows::get_quarantined_instance_ids(pool).await?;
Ok(records
.into_iter()
.map(Into::into)
.map(|record| {
let quarantined = quarantined_ids.contains(&record.instance.id);
instance_metadata(record, quarantined)
})
.collect())
}
impl From<instance_rows::InstanceMetadataRecord> for InstanceMetadata {
fn from(record: instance_rows::InstanceMetadataRecord) -> Self {
Self {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
groups: record.groups,
launch_overrides: record.launch_overrides,
}
fn instance_metadata(
record: instance_rows::InstanceMetadataRecord,
quarantined: bool,
) -> InstanceMetadata {
InstanceMetadata {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
shared_instance: record.shared_instance,
quarantined,
groups: record.groups,
launch_overrides: record.launch_overrides,
}
}
@@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items(
has_update: false,
update_version_id: None,
date_added: None,
source_kind: None,
})
})
.collect::<Vec<_>>();
@@ -738,6 +739,7 @@ async fn content_projects_for_scope(
size: file.size,
metadata: file_metadata_from_entry_or_cache(entry, metadata),
project_type,
source_kind: entry.map(|entry| entry.source_kind),
},
);
}
@@ -897,9 +899,13 @@ async fn content_files_to_content_items(
date_published: Some(version.date_published.to_rfc3339()),
}),
owner,
has_update: file.update_version_id.is_some(),
has_update: file.update_version_id.is_some()
&& !file.source_kind.is_some_and(
ContentSourceKind::is_shared_instance_managed,
),
update_version_id: file.update_version_id.clone(),
date_added: modification_times[index].clone(),
source_kind: file.source_kind,
}
})
.collect::<Vec<_>>();
@@ -1089,6 +1095,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> {
version_id: Some(version_id),
..
} => Some((project_id.clone(), version_id.clone())),
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some((project_id.clone(), version_id.clone())),
_ => None,
}
}
@@ -1103,6 +1113,10 @@ fn linked_modpack_source_kind(
InstanceLink::ServerProjectModpack { .. } => {
Some(ContentSourceKind::ServerProject)
}
InstanceLink::SharedInstance {
modpack_project_id: Some(_),
modpack_version_id: Some(_),
} => Some(ContentSourceKind::ModrinthModpack),
_ => None,
}
}
@@ -1348,12 +1362,7 @@ async fn get_modpack_identifiers(
}
fn project_type_from_api_name(project_type: &str) -> ProjectType {
match project_type {
"resourcepack" => ProjectType::ResourcePack,
"shader" => ProjectType::ShaderPack,
"datapack" => ProjectType::DataPack,
_ => ProjectType::Mod,
}
ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod)
}
fn sort_content_items(items: &mut [ContentItem]) {
@@ -41,3 +41,9 @@ mod check_content_updates;
mod apply_content_update;
pub(crate) use self::apply_content_update::*;
mod shared_instance;
pub(crate) use self::shared_instance::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
quarantine_shared_instance, set_shared_instance_sync_status,
};
@@ -0,0 +1,184 @@
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::instances::{
ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider,
ContentSetSyncState, ContentSetSyncStatus, InstanceLink,
SharedInstanceAttachment, SharedInstanceAttachmentInput,
SharedInstanceRole,
};
use chrono::Utc;
use sqlx::SqlitePool;
pub(crate) async fn attach_shared_instance(
instance_id: &str,
input: SharedInstanceAttachmentInput,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let content_set_id = metadata.applied_content_set.id;
let attachment = SharedInstanceAttachment::from(input);
let sync_state =
shared_sync_state(&content_set_id, &attachment, Some(Utc::now()));
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(
instance_id,
Some(&attachment),
&mut tx,
)
.await?;
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.clone(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: attachment.id.clone(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn clear_shared_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
detach_shared_instance(instance_id, false, pool).await
}
pub(crate) async fn quarantine_shared_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
detach_shared_instance(instance_id, true, pool).await
}
async fn detach_shared_instance(
instance_id: &str,
quarantine: bool,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let content_set_id = metadata.applied_content_set.id;
let retained_modpack_link = match metadata.link {
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
}),
_ => None,
};
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx)
.await?;
if quarantine {
instance_rows::set_instance_quarantined(instance_id, true, &mut tx)
.await?;
}
if let Some(link) = retained_modpack_link.as_ref() {
instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?;
}
content_rows::delete_content_set_remote_ref(
&content_set_id,
ContentSetRemoteRefType::SharedContentSet,
&mut tx,
)
.await?;
content_rows::delete_content_set_sync_state(&content_set_id, &mut tx)
.await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn set_shared_instance_sync_status(
instance_id: &str,
status: ContentSetSyncStatus,
applied_version: Option<i32>,
latest_version: Option<i32>,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(mut attachment) = metadata.shared_instance else {
return Ok(());
};
attachment.status = status;
attachment.applied_version = applied_version;
attachment.latest_version = latest_version;
let sync_state = shared_sync_state(
&metadata.applied_content_set.id,
&attachment,
Some(Utc::now()),
);
let mut tx = pool.begin().await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn mark_shared_instance_stale(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let Some(attachment) = metadata.shared_instance else {
return Ok(());
};
if attachment.role != SharedInstanceRole::Owner {
return Ok(());
}
set_shared_instance_sync_status(
instance_id,
ContentSetSyncStatus::Stale,
attachment.applied_version,
attachment.latest_version,
pool,
)
.await
}
fn shared_sync_state(
content_set_id: &str,
attachment: &SharedInstanceAttachment,
checked_at: Option<chrono::DateTime<Utc>>,
) -> ContentSetSyncState {
ContentSetSyncState {
content_set_id: content_set_id.to_string(),
provider: ContentSetSyncProvider::SharedInstance,
applied_update_id: attachment
.applied_version
.map(|value| value.to_string()),
latest_available_update_id: attachment
.latest_version
.map(|value| value.to_string()),
checked_at,
status: attachment.status,
}
}
@@ -75,6 +75,7 @@ pub(crate) async fn sync_instance_content_files(
let now = Utc::now();
let mut files = Vec::new();
let mut present_without_hash_ids = Vec::new();
let mut restored_without_hash = false;
for file in scanned {
let hash_key = file.hash_cache_key.trim_end_matches(".disabled");
@@ -82,6 +83,7 @@ pub(crate) async fn sync_instance_content_files(
let Some(hash) = hashes_by_key.get(hash_key) else {
if let Some(existing_file) = existing_file {
present_without_hash_ids.push(existing_file.id.clone());
restored_without_hash |= existing_file.missing;
}
continue;
};
@@ -102,6 +104,19 @@ pub(crate) async fn sync_instance_content_files(
});
}
let content_changed = !missing_file_ids.is_empty()
|| restored_without_hash
|| files.iter().any(|file| {
existing_files_by_path.get(&file.relative_path).is_none_or(
|existing| {
existing.missing
|| existing.enabled != file.enabled
|| existing.sha1 != file.sha1
|| existing.size != file.size
},
)
});
let mut tx = state.pool.begin().await?;
for file_id in missing_file_ids {
sqlite::content_rows::set_instance_file_missing(
@@ -129,6 +144,10 @@ pub(crate) async fn sync_instance_content_files(
tx.commit().await?;
if content_changed {
super::mark_shared_instance_stale(&instance.id, &state.pool).await?;
}
Ok(stored_files)
}
@@ -1,3 +1,4 @@
use super::ContentSourceKind;
use crate::state::{Project, ProjectType, Version};
use serde::{Deserialize, Serialize};
@@ -15,6 +16,7 @@ pub struct ContentItem {
pub has_update: bool,
pub update_version_id: Option<String>,
pub date_added: Option<String>,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -10,6 +10,10 @@ pub use self::commands::{
AppliedContentSetPatch, CreateInstance, EditInstance,
InstanceLaunchOverridesPatch, InstanceMetadata,
};
pub(crate) use self::commands::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
quarantine_shared_instance, set_shared_instance_sync_status,
};
pub(crate) use self::commands::{
create_instance, edit_instance, get_instance, get_instances_metadata,
list_instances, refresh_all_instances, remove_instance,
@@ -16,6 +16,15 @@ pub enum ContentSourceKind {
}
impl ContentSourceKind {
pub fn is_shared_instance_managed(self) -> bool {
matches!(
self,
Self::SharedInstance
| Self::ModrinthModpack
| Self::ImportedModpack
)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::ContentSetSyncStatus;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceLink {
@@ -32,7 +34,85 @@ pub enum InstanceLink {
version_number: Option<String>,
filename: Option<String>,
},
/// Modpack provenance installed by a shared instance. Remote membership,
/// manager identity, and synchronization state belong to
/// [`SharedInstanceAttachment`].
SharedInstance {
shared_instance_id: Uuid,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceRole {
Owner,
Member,
}
impl SharedInstanceRole {
pub fn is_member(self) -> bool {
self == Self::Member
}
pub fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Member => "member",
}
}
pub fn from_stored_str(value: &str) -> crate::Result<Self> {
match value {
"owner" => Ok(Self::Owner),
"member" => Ok(Self::Member),
other => Err(super::unknown_value("shared instance role", other)),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// The remote shared-instance relationship for a local instance. This is
/// independent from the optional modpack provenance stored in [`InstanceLink`].
pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: ContentSetSyncStatus,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
#[derive(Clone, Debug)]
pub(crate) struct SharedInstanceAttachmentInput {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: ContentSetSyncStatus,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
impl From<SharedInstanceAttachmentInput> for SharedInstanceAttachment {
fn from(input: SharedInstanceAttachmentInput) -> Self {
Self {
id: input.id,
role: input.role,
manager_id: input.manager_id,
server_manager_name: input.server_manager_name,
server_manager_icon_url: input.server_manager_icon_url,
linked_user_id: input.linked_user_id,
status: input.status,
applied_version: input.applied_version,
latest_version: input.latest_version,
}
}
}
@@ -1,4 +1,5 @@
use crate::State;
use crate::api::instance::CONFIG_DIRECTORY;
use crate::event::InstancePayloadType;
use crate::event::emit::{emit_instance, emit_warning};
use crate::state::{
@@ -128,17 +129,41 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
Some(InstancePayloadType::WorldUpdated {
world,
})
} else if first_file_name
.as_ref()
.is_none_or(|x| *x != "saves")
{
} else if first_file_name.as_ref().is_none_or(
|x| *x != "saves" && *x != CONFIG_DIRECTORY,
) {
Some(InstancePayloadType::Synced)
} else {
None
};
if let Some(event) = event {
let emit_instance_id = instance_id.clone();
let mark_shared_stale = first_file_name
.as_ref()
.is_some_and(|name| {
ProjectType::iterator().any(
|project_type| {
*name
== project_type
.get_folder()
},
)
});
tokio::spawn(async move {
if mark_shared_stale
&& let Ok(state) =
State::get().await
&& let Err(error) =
crate::state::mark_shared_instance_stale(
&emit_instance_id,
&state.pool,
)
.await
{
tracing::error!(
"Failed to mark shared instance stale after filesystem sync: {error}"
);
}
let _ = emit_instance(
&emit_instance_id,
event,
@@ -194,10 +219,11 @@ pub(crate) async fn watch_instance_folder(
}
let mut to_watch = Vec::new();
for sub_path in ProjectType::iterator()
.map(|x| x.get_folder())
.chain(["crash-reports", "saves"])
{
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
"crash-reports",
"saves",
CONFIG_DIRECTORY,
]) {
let full_path = full_instance_path.join(sub_path);
let meta = tokio::fs::symlink_metadata(&full_path).await;
+4
View File
@@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in")
}
pub const fn get_signup_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-up")
}
pub async fn finish_login_flow(
code: &str,
semaphore: &FetchSemaphore,