From 4e69ecf11839f0a5a88b57aac910fce50a57595e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:31:27 -0500 Subject: [PATCH 1/4] feat(app): relinking --- .../advanced-installation-settings.vue | 63 +++++ .../settings-modal/installation-settings.vue | 2 + .../relink-shared-instance-modal.vue | 255 ++++++++++++++++++ .../app-frontend/src/locales/en-US/index.json | 54 ++++ .../app-lib/shared-instances/commands.ts | 30 +++ apps/app/build.rs | 2 + apps/app/src/api/instance.rs | 24 ++ packages/app-lib/src/api/instance.rs | 10 +- .../app-lib/src/api/instance/shared/client.rs | 37 +++ .../app-lib/src/api/instance/shared/mod.rs | 5 + .../app-lib/src/api/instance/shared/relink.rs | 254 +++++++++++++++++ .../app-lib/src/api/instance/shared/types.rs | 34 +++ 12 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 apps/app-frontend/src/features/instances/settings-modal/advanced-installation-settings.vue create mode 100644 apps/app-frontend/src/features/instances/settings-modal/relink-shared-instance/relink-shared-instance-modal.vue create mode 100644 packages/app-lib/src/api/instance/shared/relink.rs 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..104444e35a --- /dev/null +++ b/apps/app-frontend/src/features/instances/settings-modal/advanced-installation-settings.vue @@ -0,0 +1,63 @@ + + + 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..9d303a5aa3 --- /dev/null +++ b/apps/app-frontend/src/features/instances/settings-modal/relink-shared-instance/relink-shared-instance-modal.vue @@ -0,0 +1,255 @@ + + + diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 2cd19d9db8..b6cb2a9579 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": "Link this instance back to a shared instance you own using its ID or an invite link." + }, + "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": "After relinking, this instance is the shared instance's owner instance again. People using the shared instance get your changes when you publish." + }, + "installation-settings.relink-shared-instance.modal.description": { + "message": "Link this instance back to a shared instance you own, such as after it was unlinked. Your local content stays as it is, and you can publish it as the next version." + }, + "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": "Shared instance ID or invite link" + }, + "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 of a shared instance can relink it. To receive its updates again, install it 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..477bf37125 100644 --- a/packages/app-lib/src/api/instance/shared/client.rs +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -23,6 +23,21 @@ impl SharedInstancesRequestAuth { impl SharedInstanceUnavailableReason { pub(super) fn from_status(status: StatusCode) -> Option { + // TEMPORARY, don't commit: while `~/.modrinth-inject-401` exists, debug builds treat every + // shared instance availability response as a 401, to reproduce the automatic unlink. + #[cfg(debug_assertions)] + if std::env::var_os("HOME").is_some_and(|home| { + std::path::Path::new(&home) + .join(".modrinth-inject-401") + .exists() + }) { + tracing::warn!( + %status, + "Injecting a 401 for a shared instances response" + ); + return Some(Self::AccessRevoked); + } + match status { StatusCode::NOT_FOUND => Some(Self::Deleted), StatusCode::UNAUTHORIZED => Some(Self::AccessRevoked), @@ -710,6 +725,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 +757,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 +771,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..5be5694584 --- /dev/null +++ b/packages/app-lib/src/api/instance/shared/relink.rs @@ -0,0 +1,254 @@ +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] +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 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 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 } From 9e672d5b3d06455e47b1d2f2fd0cd6f5d9be6495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:17:45 -0500 Subject: [PATCH 2/4] review --- .../relink-shared-instance-modal.vue | 16 ++++++++++++---- .../app-lib/src/api/instance/shared/client.rs | 15 --------------- .../app-lib/src/api/instance/shared/relink.rs | 4 +++- packages/app-lib/src/state/instance_locks.rs | 10 ++++++++++ 4 files changed, 25 insertions(+), 20 deletions(-) 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 index 9d303a5aa3..803e62f8dc 100644 --- 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 @@ -130,14 +130,20 @@ const queryClient = useQueryClient() const modal = ref>() const input = ref('') const preview = ref(null) +/** Increments each time the modal resets, so lookups from an earlier opening are ignored. */ +let session = 0 const previewMutation = useMutation({ - mutationFn: (variables: { instanceId: string; input: string }) => + mutationFn: (variables: { instanceId: string; input: string; session: number }) => getSharedInstanceRelinkPreview(variables.instanceId, variables.input), - onSuccess: (result) => { + onSuccess: (result, variables) => { + if (variables.session !== session) return preview.value = result }, - onError: notifySharedInstanceError, + onError: (error, variables) => { + if (variables.session !== session) return + notifySharedInstanceError(error) + }, }) const relinkMutation = useMutation({ @@ -167,7 +173,7 @@ const blockerMessage = computed(() => { function findSharedInstance() { const value = input.value.trim() if (!value || previewMutation.isPending.value) return - previewMutation.mutate({ instanceId: instance.value.id, input: value }) + previewMutation.mutate({ instanceId: instance.value.id, input: value, session }) } function confirmRelink() { @@ -179,6 +185,8 @@ function confirmRelink() { } function reset() { + session++ + previewMutation.reset() input.value = '' preview.value = null } diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs index 477bf37125..a44dd574af 100644 --- a/packages/app-lib/src/api/instance/shared/client.rs +++ b/packages/app-lib/src/api/instance/shared/client.rs @@ -23,21 +23,6 @@ impl SharedInstancesRequestAuth { impl SharedInstanceUnavailableReason { pub(super) fn from_status(status: StatusCode) -> Option { - // TEMPORARY, don't commit: while `~/.modrinth-inject-401` exists, debug builds treat every - // shared instance availability response as a 401, to reproduce the automatic unlink. - #[cfg(debug_assertions)] - if std::env::var_os("HOME").is_some_and(|home| { - std::path::Path::new(&home) - .join(".modrinth-inject-401") - .exists() - }) { - tracing::warn!( - %status, - "Injecting a 401 for a shared instances response" - ); - return Some(Self::AccessRevoked); - } - match status { StatusCode::NOT_FOUND => Some(Self::Deleted), StatusCode::UNAUTHORIZED => Some(Self::AccessRevoked), diff --git a/packages/app-lib/src/api/instance/shared/relink.rs b/packages/app-lib/src/api/instance/shared/relink.rs index 5be5694584..4b04e0fd6c 100644 --- a/packages/app-lib/src/api/instance/shared/relink.rs +++ b/packages/app-lib/src/api/instance/shared/relink.rs @@ -6,7 +6,7 @@ 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] +#[tracing::instrument(skip(input))] pub async fn get_shared_instance_relink_preview( instance_id: &str, input: &str, @@ -33,6 +33,8 @@ pub async fn relink_shared_instance( 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()))?; 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); From 59ba21eab84ddda97ce361d8d85844e686db1db9 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 9 Nov 2026 15:22:33 +0000 Subject: [PATCH 3/4] chore: cleanup frontend --- .../advanced-installation-settings.vue | 17 ++++++++++++++--- .../relink-shared-instance-modal.vue | 10 +++++----- apps/app-frontend/src/locales/en-US/index.json | 10 +++++----- .../app-lib/src/api/instance/shared/relink.rs | 11 +++++++++++ 4 files changed, 35 insertions(+), 13 deletions(-) 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 index 104444e35a..f4213205cb 100644 --- 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 @@ -24,19 +24,30 @@ import { LinkIcon } from '@modrinth/assets' import { Accordion, Button, defineMessages, injectAuth, useVIntl } from '@modrinth/ui' import { computed, ref } from 'vue' +import { injectSharedInstance } from '@/pages/instance/shared-instance-context' + import { injectInstanceSettings } from './instance-settings-context.ts' import RelinkSharedInstanceModal from './relink-shared-instance/relink-shared-instance-modal.vue' const { instance, offline } = injectInstanceSettings() +const { currentUserCanUseSharedInstances } = injectSharedInstance() const auth = injectAuth() const { formatMessage } = useVIntl() const relinkModal = ref>() +const nonRelinkableLinkTypes = new Set([ + 'shared_instance', + 'imported_modpack', + 'server_project', + 'server_project_modpack', + 'modrinth_hosting', +]) + const canRelink = computed( () => + currentUserCanUseSharedInstances.value && !instance.value.shared_instance && - instance.value.link?.type !== 'shared_instance' && - instance.value.link?.type !== 'imported_modpack' && + !nonRelinkableLinkTypes.has(instance.value.link?.type ?? '') && !instance.value.quarantined && instance.value.install_stage === 'installed', ) @@ -57,7 +68,7 @@ const messages = defineMessages({ relinkDescription: { id: 'installation-settings.relink-shared-instance.description', defaultMessage: - 'Link this instance back to a shared instance you own using its ID or an invite link.', + 'Reconnect this instance to a shared instance it was unlinked from, using an invite link or ID.', }, }) 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 index 803e62f8dc..3718868b98 100644 --- 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 @@ -1,6 +1,6 @@