diff --git a/apps/app-frontend/src/features/instances/settings-modal/advanced-installation-settings.vue b/apps/app-frontend/src/features/instances/settings-modal/advanced-installation-settings.vue new file mode 100644 index 0000000000..f4213205cb --- /dev/null +++ b/apps/app-frontend/src/features/instances/settings-modal/advanced-installation-settings.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/app-frontend/src/features/instances/settings-modal/installation-settings.vue b/apps/app-frontend/src/features/instances/settings-modal/installation-settings.vue index 1f9649571a..65ebf3ed9d 100644 --- a/apps/app-frontend/src/features/instances/settings-modal/installation-settings.vue +++ b/apps/app-frontend/src/features/instances/settings-modal/installation-settings.vue @@ -38,6 +38,7 @@ import { instanceKeys } from '@/pages/instance/query-options' import { injectAppEvents } from '@/providers/app-events' import { provideInstanceBackup } from '@/providers/instance-backup' +import AdvancedInstallationSettings from './advanced-installation-settings.vue' import { injectInstanceSettings } from './instance-settings-context.ts' import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue' @@ -502,6 +503,7 @@ provideInstallationSettings({ :unlinking="unlinkingSharedInstance" :unlink="unlinkSharedInstance" /> + diff --git a/apps/app-frontend/src/features/instances/settings-modal/relink-shared-instance/relink-shared-instance-modal.vue b/apps/app-frontend/src/features/instances/settings-modal/relink-shared-instance/relink-shared-instance-modal.vue new file mode 100644 index 0000000000..958aea1e3e --- /dev/null +++ b/apps/app-frontend/src/features/instances/settings-modal/relink-shared-instance/relink-shared-instance-modal.vue @@ -0,0 +1,262 @@ + + + diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 2cd19d9db8..ea544deade 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -3815,6 +3815,60 @@ "hosting.play.world-changed": { "message": "This world is no longer active or has not been shared. Open the server panel and press Play server again." }, + "installation-settings.advanced.title": { + "message": "Advanced" + }, + "installation-settings.relink-shared-instance.button": { + "message": "Relink shared instance" + }, + "installation-settings.relink-shared-instance.description": { + "message": "Reconnect this instance to a shared instance it was unlinked from, using an invite link or ID." + }, + "installation-settings.relink-shared-instance.modal.already-linked": { + "message": "Your instance \"{name}\" is already linked to this shared instance." + }, + "installation-settings.relink-shared-instance.modal.blocked-header": { + "message": "This instance can't be relinked" + }, + "installation-settings.relink-shared-instance.modal.confirm-body": { + "message": "This instance becomes the source of the shared instance again. Members get your changes when you publish an update." + }, + "installation-settings.relink-shared-instance.modal.description": { + "message": "Reconnect this instance to a shared instance you own." + }, + "installation-settings.relink-shared-instance.modal.find-button": { + "message": "Find shared instance" + }, + "installation-settings.relink-shared-instance.modal.header": { + "message": "Relink shared instance" + }, + "installation-settings.relink-shared-instance.modal.input-label": { + "message": "Invite link or shared instance ID" + }, + "installation-settings.relink-shared-instance.modal.input-placeholder": { + "message": "https://modrinth.com/share/..." + }, + "installation-settings.relink-shared-instance.modal.mismatch-body": { + "message": "This instance uses {localLoader} {localGameVersion}, but the shared instance uses {loader} {gameVersion}. Your next publish will change the shared instance to match this instance." + }, + "installation-settings.relink-shared-instance.modal.mismatch-header": { + "message": "Different game or loader version" + }, + "installation-settings.relink-shared-instance.modal.not-owner": { + "message": "Only the owner can relink a shared instance. To get its updates as a member, install it again from your library." + }, + "installation-settings.relink-shared-instance.modal.relink-button": { + "message": "Relink" + }, + "installation-settings.relink-shared-instance.modal.server-managed": { + "message": "This shared instance is managed by a server, so it can't be relinked." + }, + "installation-settings.relink-shared-instance.modal.version-summary": { + "message": "{loader} {gameVersion} · Version {version}" + }, + "installation-settings.relink-shared-instance.title": { + "message": "Relink shared instance" + }, "installation-settings.shared-instance.linked-title": { "message": "Linked shared instance" }, diff --git a/apps/app-frontend/src/platform/app-lib/shared-instances/commands.ts b/apps/app-frontend/src/platform/app-lib/shared-instances/commands.ts index b124c8019f..89f1724c58 100644 --- a/apps/app-frontend/src/platform/app-lib/shared-instances/commands.ts +++ b/apps/app-frontend/src/platform/app-lib/shared-instances/commands.ts @@ -12,6 +12,36 @@ export type CachedSharedInstanceSummary = { loader: InstanceLoader } +export type SharedInstanceRelinkBlocker = + | { type: 'not_owner' } + | { type: 'server_managed' } + | { type: 'already_linked'; instanceId: string; instanceName: string } + +export type SharedInstanceRelinkPreview = { + sharedInstanceId: string + name: string + iconUrl: string | null + version: number + gameVersion: string + loader: InstanceLoader + gameVersionMismatch: boolean + loaderMismatch: boolean + blocker: SharedInstanceRelinkBlocker | null +} + +/** Resolves a pasted invite link or ID to the shared instance an instance would be relinked to. */ +export function getSharedInstanceRelinkPreview( + instanceId: string, + input: string, +): Promise { + return invoke('plugin:instance|instance_share_get_relink_preview', { instanceId, input }) +} + +/** Links an instance back to a shared instance the current user owns. */ +export function relinkSharedInstance(instanceId: string, sharedInstanceId: string): Promise { + return invoke('plugin:instance|instance_share_relink', { instanceId, sharedInstanceId }) +} + export function unpublishRemoteSharedInstance(sharedInstanceId: string): Promise { return invoke('plugin:instance|instance_share_unpublish_remote', { sharedInstanceId }) } diff --git a/apps/app/build.rs b/apps/app/build.rs index 687fd04122..7692786d7d 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -308,6 +308,8 @@ fn main() { "instance_share_publish", "instance_share_publish_support_details", "instance_share_unlink", + "instance_share_get_relink_preview", + "instance_share_relink", "instance_share_unpublish", "instance_share_unpublish_remote", "instance_share_leave_remote", diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 67f1f411db..f319046e00 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -122,6 +122,8 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_share_publish, instance_share_publish_support_details, instance_share_unlink, + instance_share_get_relink_preview, + instance_share_relink, instance_share_unpublish, instance_share_unpublish_remote, instance_share_leave_remote, @@ -1533,6 +1535,28 @@ pub async fn instance_share_unlink(instance_id: &str) -> Result<()> { Ok(()) } +#[tauri::command] +pub async fn instance_share_get_relink_preview( + instance_id: &str, + input: &str, +) -> Result { + Ok(theseus::instance::get_shared_instance_relink_preview( + instance_id, + input, + ) + .await?) +} + +#[tauri::command] +pub async fn instance_share_relink( + instance_id: &str, + shared_instance_id: &str, +) -> Result<()> { + theseus::instance::relink_shared_instance(instance_id, shared_instance_id) + .await?; + Ok(()) +} + #[tauri::command] pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> { theseus::instance::unpublish_shared_instance(instance_id).await?; diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 0357b2b4f5..ae4baee406 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -90,6 +90,7 @@ pub use self::shared::{ SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, SharedInstanceJoinType, SharedInstanceLockStatus, SharedInstancePublishFile, SharedInstancePublishPreview, + SharedInstanceRelinkBlocker, SharedInstanceRelinkPreview, SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, accept_pending_shared_instance_invite, @@ -101,10 +102,11 @@ pub use self::shared::{ get_shared_instance_invite_install_preview, get_shared_instance_invites, get_shared_instance_publish_preview, get_shared_instance_publish_support_details, - get_shared_instance_update_preview, get_shared_instance_users, - install_shared_instance, invite_shared_instance_users, - leave_remote_shared_instance, publish_shared_instance, - refresh_shared_instance_lock, refresh_shared_instance_locks, + get_shared_instance_relink_preview, get_shared_instance_update_preview, + get_shared_instance_users, install_shared_instance, + invite_shared_instance_users, leave_remote_shared_instance, + publish_shared_instance, refresh_shared_instance_lock, + refresh_shared_instance_locks, relink_shared_instance, remove_shared_instance_users, revoke_shared_instance_invite, unlink_shared_instance, unpublish_remote_shared_instance, unpublish_shared_instance, update_shared_instance, diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs index e4d3cbc996..a44dd574af 100644 --- a/packages/app-lib/src/api/instance/shared/client.rs +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -710,6 +710,24 @@ pub(super) async fn get_shared_instance_invite_info( invite_id: &str, state: &State, ) -> crate::Result { + find_shared_instance_invite_info(invite_id, state) + .await? + .ok_or_else(|| { + shared_instances_status_error( + "get_instance_invite", + &Method::GET, + "/invites/:invite_id", + StatusCode::NOT_FOUND, + None, + ) + }) +} + +/// Fetches an invite's details, or `None` when no invite has this ID. +pub(super) async fn find_shared_instance_invite_info( + invite_id: &str, + state: &State, +) -> crate::Result> { let operation = "get_instance_invite"; let method = Method::GET; let path = format!("/invites/{invite_id}?version=true"); @@ -724,6 +742,9 @@ pub(super) async fn get_shared_instance_invite_info( SharedInstancesRequestAuth::None, ) .await?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } if !response.status().is_success() { return shared_instances_request_error( operation, method, log_path, response, @@ -735,6 +756,7 @@ pub(super) async fn get_shared_instance_invite_info( operation, method, log_path, response, true, ) .await + .map(Some) } pub(super) async fn decline_pending_remote_invite( diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs index a22aba2e87..681562781d 100644 --- a/packages/app-lib/src/api/instance/shared/mod.rs +++ b/packages/app-lib/src/api/instance/shared/mod.rs @@ -181,6 +181,7 @@ mod install; mod invites; mod library; mod publish; +mod relink; mod report; mod types; @@ -215,6 +216,9 @@ pub use self::publish::{ publish_shared_instance, unlink_shared_instance, unpublish_remote_shared_instance, unpublish_shared_instance, }; +pub use self::relink::{ + get_shared_instance_relink_preview, relink_shared_instance, +}; pub use self::report::get_shared_instance_publish_support_details; pub use self::types::{ SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, @@ -222,6 +226,7 @@ pub use self::types::{ SharedInstanceInviteInstallPreview, SharedInstanceInviteLink, SharedInstanceJoinType, SharedInstanceLockStatus, SharedInstancePublishFile, SharedInstancePublishPreview, + SharedInstanceRelinkBlocker, SharedInstanceRelinkPreview, SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, }; diff --git a/packages/app-lib/src/api/instance/shared/relink.rs b/packages/app-lib/src/api/instance/shared/relink.rs new file mode 100644 index 0000000000..07d7f432c1 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/relink.rs @@ -0,0 +1,267 @@ +use super::client::*; +use super::install::*; +use super::publish::*; +use super::types::*; +use super::*; + +/// Resolves a pasted invite link or ID to the shared instance it points to, +/// and previews relinking a local instance to it as its owner. +#[tracing::instrument(skip(input))] +pub async fn get_shared_instance_relink_preview( + instance_id: &str, + input: &str, +) -> crate::Result { + let state = State::get().await?; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or(crate::ErrorKind::InputError("unknown instance".to_string()))?; + let shared_instance_id = resolve_shared_instance_id(input, &state).await?; + + Ok(relink_target(&metadata, &shared_instance_id, &state) + .await? + .preview) +} + +/// Links a local instance back to a shared instance its user owns, such as +/// after the app unlinked it. The local content is kept, and the owner can +/// publish it as the next version. +#[tracing::instrument] +pub async fn relink_shared_instance( + instance_id: &str, + shared_instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let _shared_instance_lock = + state.instance_locks.lock_shared(instance_id).await; + let _relink_lock = + state.instance_locks.lock_relink(shared_instance_id).await; + let metadata = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or(crate::ErrorKind::InputError("unknown instance".to_string()))?; + let target = relink_target(&metadata, shared_instance_id, &state).await?; + if target.preview.blocker.is_some() { + bail!(crate::ErrorKind::InputError( + "this shared instance can't be relinked to this instance" + .to_string(), + )); + } + + crate::state::attach_shared_instance( + instance_id, + crate::state::SharedInstanceAttachmentInput { + id: shared_instance_id.to_string(), + role: SharedInstanceRole::Owner, + restored_from_remote: false, + manager_id: None, + server_manager_name: None, + server_manager_icon_url: None, + linked_user_id: Some(target.user_id), + status: ContentSetSyncStatus::Unknown, + applied_version: Some(target.latest_version.version), + applied_backtrack_version: target + .latest_version + .backtrack_version + .clone(), + latest_version: Some(target.latest_version.version), + }, + &state.pool, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + +/// Where to find the shared instance pasted to relink an instance. +enum RelinkInput { + /// An invite link, holding the invite's ID. + Invite(String), + /// A bare ID, of either a shared instance or an invite. + Id(String), +} + +/// Parses an invite link (`.../share/`), or a bare shared instance +/// or invite ID. +fn parse_relink_input(input: &str) -> crate::Result { + let input = input.trim(); + let (id, is_invite) = match input.split_once("/share/") { + Some((_, rest)) => { + (rest.split(['/', '?', '#']).next().unwrap_or_default(), true) + } + None => (input, false), + }; + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric()) { + bail!(crate::ErrorKind::InputError( + "enter a shared instance ID or invite link".to_string(), + )); + } + + Ok(if is_invite { + RelinkInput::Invite(id.to_string()) + } else { + RelinkInput::Id(id.to_string()) + }) +} + +/// Resolves pasted input to a shared instance ID. Shared instance and invite +/// IDs look alike, so a bare ID is looked up as a shared instance first, then +/// as an invite. +async fn resolve_shared_instance_id( + input: &str, + state: &State, +) -> crate::Result { + let id = match parse_relink_input(input)? { + RelinkInput::Invite(invite_id) => { + return find_shared_instance_invite_info(&invite_id, state) + .await? + .map(|invite| invite.instance_id) + .ok_or( + crate::ErrorKind::InputError( + "this invite link doesn't exist or has expired" + .to_string(), + ) + .into(), + ); + } + RelinkInput::Id(id) => id, + }; + + match get_remote_instance_access(&id, state).await? { + SharedInstanceRemoteResponse::Available(_) => return Ok(id), + SharedInstanceRemoteResponse::Unavailable( + SharedInstanceUnavailableReason::Deleted, + ) => {} + SharedInstanceRemoteResponse::Unavailable(reason) => { + return Err(shared_instance_unavailable_error(reason)); + } + } + + find_shared_instance_invite_info(&id, state) + .await? + .map(|invite| invite.instance_id) + .ok_or( + crate::ErrorKind::InputError( + "no shared instance or invite has this ID".to_string(), + ) + .into(), + ) +} + +/// A shared instance a local instance could be relinked to. +struct RelinkTarget { + preview: SharedInstanceRelinkPreview, + latest_version: InstanceVersionResponse, + user_id: String, +} + +/// Checks that the local instance can be relinked, then fetches the shared +/// instance and what would prevent relinking the instance to it. +async fn relink_target( + metadata: &crate::state::InstanceMetadata, + shared_instance_id: &str, + state: &State, +) -> crate::Result { + ensure_relinkable_instance(metadata)?; + let user_id = linked_modrinth_user_id(state) + .await? + .ok_or(crate::ErrorKind::NotLoggedIn)?; + + let remote = + match get_remote_instance_access(shared_instance_id, state).await? { + SharedInstanceRemoteResponse::Available(remote) => remote, + SharedInstanceRemoteResponse::Unavailable(reason) => { + return Err(shared_instance_unavailable_error(reason)); + } + }; + if remote.quarantine { + return Err(shared_instance_unavailable_error( + SharedInstanceUnavailableReason::Quarantined, + )); + } + let latest_version = + get_latest_remote_version(shared_instance_id, state).await?; + + let blocker = if remote.linked_server.is_some() { + Some(SharedInstanceRelinkBlocker::ServerManaged) + } else if !get_remote_users(shared_instance_id, state) + .await? + .users + .iter() + .any(|user| { + user.id == user_id + && user.join_type == SharedInstanceJoinType::Owner + }) + { + Some(SharedInstanceRelinkBlocker::NotOwner) + } else { + crate::state::list_instances(&state.pool) + .await? + .into_iter() + .find(|instance| { + instance.shared_instance.as_ref().is_some_and(|attachment| { + attachment.id == shared_instance_id + }) + }) + .map(|instance| SharedInstanceRelinkBlocker::AlreadyLinked { + instance_id: instance.instance.id, + instance_name: instance.instance.name, + }) + }; + + let content_set = &metadata.applied_content_set; + Ok(RelinkTarget { + preview: SharedInstanceRelinkPreview { + shared_instance_id: shared_instance_id.to_string(), + name: remote.name, + icon_url: remote.icon, + version: latest_version.version, + game_version: latest_version.game_version.clone(), + loader: latest_version.loader, + game_version_mismatch: content_set.game_version + != latest_version.game_version, + loader_mismatch: content_set.loader != latest_version.loader, + blocker, + }, + latest_version, + user_id, + }) +} + +/// Checks that a local instance is installed, unlinked and could be shared. +fn ensure_relinkable_instance( + metadata: &crate::state::InstanceMetadata, +) -> crate::Result<()> { + if metadata.shared_instance.is_some() + || matches!(metadata.link, InstanceLink::SharedInstance { .. }) + { + bail!(crate::ErrorKind::InputError( + "this instance is already linked to a shared instance".to_string(), + )); + } + if matches!( + metadata.link, + InstanceLink::ServerProject { .. } + | InstanceLink::ServerProjectModpack { .. } + | InstanceLink::ModrinthHosting { .. } + ) { + bail!(crate::ErrorKind::InputError( + "server project instances can't be linked to a shared instance" + .to_string(), + )); + } + if metadata.quarantined { + bail!(crate::ErrorKind::InputError( + "locked instances can't be linked to a shared instance".to_string(), + )); + } + if metadata.instance.install_stage + != crate::state::InstanceInstallStage::Installed + { + bail!(crate::ErrorKind::InputError( + "the instance must finish installing before it can be linked" + .to_string(), + )); + } + + ensure_shareable_link(&metadata.link) +} diff --git a/packages/app-lib/src/api/instance/shared/types.rs b/packages/app-lib/src/api/instance/shared/types.rs index 7670246bb9..1c0d5c1970 100644 --- a/packages/app-lib/src/api/instance/shared/types.rs +++ b/packages/app-lib/src/api/instance/shared/types.rs @@ -193,6 +193,40 @@ pub enum SharedInstanceUpdateDiffType { ConfigFilesUpdated, } +/// What relinking a local instance would link it to, shown before confirming. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharedInstanceRelinkPreview { + pub shared_instance_id: String, + pub name: String, + pub icon_url: Option, + pub version: i32, + pub game_version: String, + pub loader: ModLoader, + pub game_version_mismatch: bool, + pub loader_mismatch: bool, + pub blocker: Option, +} + +/// Why a shared instance can't be relinked to a local instance. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum SharedInstanceRelinkBlocker { + /// Only the owner's instance can be relinked. + NotOwner, + /// Shared instances of a server are managed by the server. + ServerManaged, + /// Another local instance is already linked to the shared instance. + AlreadyLinked { + instance_id: String, + instance_name: String, + }, +} + pub(super) fn is_false(value: &bool) -> bool { !*value } diff --git a/packages/app-lib/src/state/instance_locks.rs b/packages/app-lib/src/state/instance_locks.rs index 3508f5f8d3..c5622c878d 100644 --- a/packages/app-lib/src/state/instance_locks.rs +++ b/packages/app-lib/src/state/instance_locks.rs @@ -10,6 +10,9 @@ pub(crate) struct InstanceLocks { screenshots: DashMap>>, /// Serializes shared instance attachment and recipient mutations per instance. shared: DashMap>>, + /// Serializes relinking local instances to a shared instance, keyed by + /// the shared instance's ID, so it never gets two owner instances. + relink: DashMap>>, } impl InstanceLocks { @@ -34,6 +37,13 @@ impl InstanceLocks { lock(&self.shared, instance_id).await } + pub(crate) async fn lock_relink( + &self, + shared_instance_id: &str, + ) -> OwnedMutexGuard<()> { + lock(&self.relink, shared_instance_id).await + } + pub(crate) fn remove(&self, instance_id: &str) { let _ = self.content.remove(instance_id); let _ = self.screenshots.remove(instance_id);