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
@@ -43,6 +43,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
public readonly launchermeta!: InferredClientModules['launchermeta']
public readonly paper!: InferredClientModules['paper']
public readonly purpur!: InferredClientModules['purpur']
public readonly sharedinstances!: InferredClientModules['sharedinstances']
constructor(config: ClientConfig) {
super()
@@ -50,6 +51,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
archonBaseUrl: 'https://archon.modrinth.com',
sharedInstancesBaseUrl: 'https://shared-instances.modrinth.com',
...config,
}
this.features = config.features ?? []
@@ -123,6 +125,8 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else if (options.api === 'sharedinstances') {
baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!)
} else {
baseUrl = options.api
}
@@ -170,6 +174,8 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else if (options.api === 'sharedinstances') {
baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!)
} else {
baseUrl = options.api
}
+8
View File
@@ -57,6 +57,10 @@ import { MclogsInsightsV1Module } from './mclogs/insights/v1'
import { MclogsLogsV1Module } from './mclogs/logs/v1'
import { PaperVersionsV3Module } from './paper/v3'
import { PurpurVersionsV2Module } from './purpur/v2'
import { SharedInstancesInstancesV1Module } from './shared-instances/instances/v1'
import { SharedInstancesInvitesV1Module } from './shared-instances/invites/v1'
import { SharedInstancesModerationV1Module } from './shared-instances/moderation/v1'
import { SharedInstancesUsersV1Module } from './shared-instances/users/v1'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -128,6 +132,10 @@ export const MODULE_REGISTRY = {
labrinth_versions_v3: LabrinthVersionsV3Module,
paper_versions_v3: PaperVersionsV3Module,
purpur_versions_v2: PurpurVersionsV2Module,
sharedinstances_invites_v1: SharedInstancesInvitesV1Module,
sharedinstances_instances_v1: SharedInstancesInstancesV1Module,
sharedinstances_moderation_v1: SharedInstancesModerationV1Module,
sharedinstances_users_v1: SharedInstancesUsersV1Module,
} as const satisfies Record<string, ModuleConstructor>
export type ModuleID = keyof typeof MODULE_REGISTRY
@@ -21,7 +21,7 @@ function buildImageQueryParams(
params.thread_message_id = target.thread_message_id
break
case 'report':
params.report_id = target.report_id
if (target.report_id) params.report_id = target.report_id
break
}
return params
@@ -434,7 +434,7 @@ export namespace Labrinth {
| { context: 'project'; project_id: string }
| { context: 'version'; version_id: string }
| { context: 'thread_message'; thread_message_id: string }
| { context: 'report'; report_id: string }
| { context: 'report'; report_id: string | null }
)
export type UploadedImageFor<C extends ImageUploadContext> = Extract<
@@ -450,7 +450,7 @@ export namespace Labrinth {
| { context: 'project'; project_id: string }
| { context: 'version'; version_id: string }
| { context: 'thread_message'; thread_message_id: string }
| { context: 'report'; report_id: string }
| { context: 'report'; report_id?: string }
}
}
@@ -1917,13 +1917,14 @@ export namespace Labrinth {
export namespace Reports {
export namespace v3 {
export type ItemType = 'project' | 'version' | 'user' | 'unknown'
export type ItemType = 'project' | 'version' | 'user' | 'shared-instance' | 'unknown'
export type Report = {
id: string
report_type: string
item_id: string
item_type: ItemType
shared_instance_version_id?: number
reporter: string
body: string
created: string
@@ -0,0 +1,66 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { SharedInstances } from '../types'
export class SharedInstancesInstancesV1Module extends AbstractModule {
public getModuleID(): string {
return 'sharedinstances_instances_v1'
}
public async get(instanceId: string): Promise<SharedInstances.Instances.v1.Instance> {
return this.client.request<SharedInstances.Instances.v1.Instance>(
`/instances/${encodeURIComponent(instanceId)}`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
},
)
}
public async getForUser(userId: string): Promise<string[]> {
return this.client.request<string[]>('/instances', {
api: 'sharedinstances',
version: 1,
method: 'GET',
params: { user: userId },
})
}
public async getUsers(instanceId: string): Promise<SharedInstances.Instances.v1.InstanceUsers> {
return this.client.request<SharedInstances.Instances.v1.InstanceUsers>(
`/instances/${encodeURIComponent(instanceId)}/users`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
},
)
}
public async getLatestVersion(
instanceId: string,
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
return this.client.request<SharedInstances.Instances.v1.InstanceVersion>(
`/instances/${encodeURIComponent(instanceId)}/versions`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
},
)
}
public async getVersion(
instanceId: string,
version: number,
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
return this.client.request<SharedInstances.Instances.v1.InstanceVersion>(
`/instances/${encodeURIComponent(instanceId)}/versions/${version}`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
},
)
}
}
@@ -0,0 +1,21 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { SharedInstances } from '../types'
export class SharedInstancesInvitesV1Module extends AbstractModule {
public getModuleID(): string {
return 'sharedinstances_invites_v1'
}
public async get(inviteId: string): Promise<SharedInstances.Invites.v1.Invite> {
return this.client.request<SharedInstances.Invites.v1.Invite>(
`/invites/${encodeURIComponent(inviteId)}`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
skipAuth: true,
retry: false,
},
)
}
}
@@ -0,0 +1,41 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { SharedInstances } from '../types'
export class SharedInstancesModerationV1Module extends AbstractModule {
public getModuleID(): string {
return 'sharedinstances_moderation_v1'
}
public async blacklistUsers(
request: SharedInstances.Moderation.v1.BlacklistUserRequest,
): Promise<void> {
return this.client.request('/moderation/blacklist', {
api: 'sharedinstances',
version: 1,
method: 'POST',
body: request,
})
}
public async unblacklistUsers(
request: SharedInstances.Moderation.v1.BlacklistUserRequest,
): Promise<void> {
return this.client.request('/moderation/blacklist', {
api: 'sharedinstances',
version: 1,
method: 'DELETE',
body: request,
})
}
public async deleteFile(instanceId: string, version: number, fileName: string): Promise<void> {
return this.client.request(
`/moderation/instances/${encodeURIComponent(instanceId)}/versions/${version}/files/${encodeURIComponent(fileName)}`,
{
api: 'sharedinstances',
version: 1,
method: 'DELETE',
},
)
}
}
@@ -0,0 +1,101 @@
export namespace SharedInstances {
export namespace Invites {
export namespace v1 {
export type UserManager = {
id: string
name: string
type: 'user'
avatar?: string | null
}
export type ServerManager = {
name: string
type: 'server'
icon?: string | null
}
export type Manager = UserManager | ServerManager
export type InviteUser = {
id: string
name: string
avatar?: string | null
joined_at: string | null
}
export type Invite = {
instance_id: string
instance_name: string
instance_icon?: string | null
game_version: string
loader_version: string
managers: Manager[]
instance_users?: InviteUser[]
}
}
}
export namespace Instances {
export namespace v1 {
export type Instance = {
name: string
icon: string | null
quarantine: boolean
}
export type JoinType = 'owner' | 'invite' | 'link'
export type InstanceUser = {
id: string
joined_at: string | null
join_type: JoinType
last_played: string | null
}
export type InstanceUsers = {
users: InstanceUser[]
tokens: number
}
export type FileMetadata = {
path: string
hash: string
}
export type ExternalFile = {
file_name: string
file_type: string
url: string
file_size?: number
metadata?: FileMetadata[]
}
export type InstanceVersion = {
version: number
modrinth_ids?: string[]
ready: boolean
external_files: ExternalFile[]
modpack_id: string | null
game_version: string
loader: string
loader_version: string
}
}
}
export namespace Moderation {
export namespace v1 {
export type BlacklistUserRequest = {
user_ids: string[]
}
}
}
export namespace Users {
export namespace v1 {
export type BlacklistStatus = {
blacklisted: boolean
}
}
}
}
@@ -0,0 +1,21 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { SharedInstances } from '../types'
export class SharedInstancesUsersV1Module extends AbstractModule {
public getModuleID(): string {
return 'sharedinstances_users_v1'
}
public async getBlacklistStatus(
userId: string,
): Promise<SharedInstances.Users.v1.BlacklistStatus> {
return this.client.request<SharedInstances.Users.v1.BlacklistStatus>(
`/blacklist/${encodeURIComponent(userId)}`,
{
api: 'sharedinstances',
version: 1,
method: 'GET',
},
)
}
}
+1
View File
@@ -6,3 +6,4 @@ export * from './launcher-meta/types'
export * from './mclogs/types'
export * from './paper/types'
export * from './purpur/types'
export * from './shared-instances/types'
@@ -21,6 +21,8 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else if (options.api === 'sharedinstances') {
baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!)
} else {
baseUrl = options.api
}
+6
View File
@@ -50,6 +50,12 @@ export interface ClientConfig {
*/
archonBaseUrl?: BaseUrlConfig
/**
* Base URL for the Shared Instances API
* @default 'https://shared-instances.modrinth.com'
*/
sharedInstancesBaseUrl?: BaseUrlConfig
/**
* Default request timeout in milliseconds
* @default 10000
+1
View File
@@ -11,6 +11,7 @@ export type RequestOptions = {
* API to use for this request
* - 'labrinth': Main Modrinth API (resolves to labrinthBaseUrl)
* - 'archon': Modrinth Hosting API (resolves to archonBaseUrl)
* - 'sharedinstances': Shared Instances API (resolves to sharedInstancesBaseUrl)
* - string: Custom base URL (e.g., 'https://custom-api.com')
*/
api: 'labrinth' | 'archon' | string
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_BASE_URL=http://localhost:8000/
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_BASE_URL=https://api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
@@ -1,5 +1,6 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_BASE_URL=https://api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_content_set_remote_refs (\n\t\t\tcontent_set_id,\n\t\t\tref_type,\n\t\t\tref_id\n\t\t)\n\t\tVALUES (?, ?, ?)\n\t\tON CONFLICT (content_set_id, ref_type) DO UPDATE SET\n\t\t\tref_id = excluded.ref_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_content_set_sync_state (\n\t\t\tcontent_set_id,\n\t\t\tprovider,\n\t\t\tapplied_update_id,\n\t\t\tlatest_available_update_id,\n\t\t\tchecked_at,\n\t\t\tstatus\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (content_set_id) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tapplied_update_id = excluded.applied_update_id,\n\t\t\tlatest_available_update_id = excluded.latest_available_update_id,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tstatus = excluded.status\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n shared_instance_role,\n shared_instance_manager_id,\n shared_instance_linked_user_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
"describe": {
"columns": [
{
@@ -59,19 +59,34 @@
"type_info": "Text"
},
{
"name": "imported_name",
"name": "shared_instance_role",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "imported_version_number",
"name": "shared_instance_manager_id",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "imported_filename",
"name": "shared_instance_linked_user_id",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "imported_name",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "imported_version_number",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "imported_filename",
"ordinal": 16,
"type_info": "Text"
}
],
"parameters": {
@@ -91,8 +106,11 @@
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281"
"hash": "6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tDELETE FROM instance_content_set_remote_refs\n\t\tWHERE content_set_id = ? AND ref_type = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652"
}
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n \n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n \n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n \n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a"
}
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE 1 = ?",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tshared_instance_id,\n\t\t\tshared_instance_role,\n\t\t\tshared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id\n\t\t)\n\t\tVALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = CASE\n\t\t\t\tWHEN excluded.shared_instance_id IS NULL\n\t\t\t\t\tAND instance_links.link_kind = 'shared_instance'\n\t\t\t\t\tTHEN 'unmanaged'\n\t\t\t\tELSE instance_links.link_kind\n\t\t\tEND,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\tshared_instance_role = excluded.shared_instance_role,\n\t\t\tshared_instance_manager_id = excluded.shared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name = excluded.shared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tDELETE FROM instance_content_set_sync_state\n\t\tWHERE content_set_id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\tshared_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b"
}
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_role TEXT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_manager_id TEXT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_linked_user_id TEXT NULL;
@@ -0,0 +1,5 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_server_manager_name TEXT NULL;
ALTER TABLE instance_links
ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL;
@@ -0,0 +1,4 @@
CREATE TABLE instance_quarantines (
instance_id TEXT PRIMARY KEY NOT NULL,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);
+21 -1
View File
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
// /share/{invite_id}
Some(("share", raw)) => {
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
match decode(raw) {
Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
invite_id: decoded.to_string(),
},
Err(e) => {
emit_warning(&format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.into());
}
}
}
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
@@ -93,7 +113,7 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
pub async fn parse_command(
command_string: &str,
) -> crate::Result<CommandPayload> {
tracing::debug!("Parsing command: {}", &command_string);
tracing::debug!("Parsing external command");
// modrinth://some-command
// This occurs when following a web redirect link
+23
View File
@@ -1,6 +1,7 @@
//! Theseus instance management interface
mod content;
mod content_set_diff;
mod export_mrpack;
mod get;
mod install;
@@ -8,6 +9,7 @@ mod lifecycle;
mod paths;
mod projects;
mod run;
mod shared;
pub use self::content::{
get_content_items, get_dependencies_as_content_items,
@@ -33,3 +35,24 @@ pub use self::projects::{
pub use self::run::{
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
};
pub(crate) use self::shared::{
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
read_bounded_config_bundle_entry,
};
pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite,
accept_shared_instance_invite_for_install,
can_active_user_use_shared_instances, create_shared_instance_invite_link,
decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users,
install_shared_instance, invite_shared_instance_users,
publish_shared_instance, remove_shared_instance_users,
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
};
@@ -0,0 +1,152 @@
use crate::state::Version;
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ContentSetDiffKind {
Added,
Removed,
Updated,
}
#[derive(Clone, Debug)]
pub(crate) enum ContentSetDiffEntry {
Project {
kind: ContentSetDiffKind,
project_id: String,
current_version_name: Option<String>,
new_version_name: Option<String>,
disabled: bool,
},
ExternalFile {
kind: ContentSetDiffKind,
file_name: String,
disabled: bool,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetDiffOptions {
pub removed_disabled_project_ids: HashSet<String>,
pub removed_disabled_external_files: HashSet<String>,
pub common_external_files_are_updated: bool,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetSnapshot {
pub versions: Vec<ContentSetSnapshotVersion>,
pub external_files: HashSet<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ContentSetSnapshotVersion {
pub project_id: String,
pub version_id: String,
pub version_name: String,
}
impl From<Version> for ContentSetSnapshotVersion {
fn from(version: Version) -> Self {
Self {
project_id: version.project_id,
version_id: version.id,
version_name: version.version_number,
}
}
}
pub(crate) fn diff_content_sets(
current: &ContentSetSnapshot,
latest: &ContentSetSnapshot,
options: &ContentSetDiffOptions,
) -> Vec<ContentSetDiffEntry> {
let current_versions = versions_by_project(current);
let latest_versions = versions_by_project(latest);
let project_ids = current_versions
.keys()
.chain(latest_versions.keys())
.copied()
.collect::<HashSet<_>>();
let mut diffs = Vec::new();
for project_id in project_ids {
let current = current_versions.get(project_id);
let latest = latest_versions.get(project_id);
match (current, latest) {
(None, Some(latest)) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Added,
project_id: project_id.to_string(),
current_version_name: None,
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
(Some(current), None) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Removed,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: None,
disabled: options
.removed_disabled_project_ids
.contains(project_id),
});
}
(Some(current), Some(latest))
if current.version_id != latest.version_id =>
{
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Updated,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
_ => {}
}
}
for file_name in latest.external_files.difference(&current.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Added,
file_name: file_name.clone(),
disabled: false,
});
}
if options.common_external_files_are_updated {
for file_name in
latest.external_files.intersection(&current.external_files)
{
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Updated,
file_name: file_name.clone(),
disabled: false,
});
}
}
for file_name in current.external_files.difference(&latest.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Removed,
file_name: file_name.clone(),
disabled: options
.removed_disabled_external_files
.contains(file_name),
});
}
diffs
}
fn versions_by_project(
snapshot: &ContentSetSnapshot,
) -> HashMap<&str, &ContentSetSnapshotVersion> {
snapshot
.versions
.iter()
.map(|version| (version.project_id.as_str(), version))
.collect()
}
+16 -1
View File
@@ -101,12 +101,27 @@ pub async fn edit_icon(
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path),
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
&state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
+102 -9
View File
@@ -1,7 +1,9 @@
use crate::event::emit::{emit_instance, emit_loading, init_loading};
use crate::event::{InstancePayloadType, LoadingBarType};
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State};
use crate::state::{
CacheBehaviour, CachedEntry, ContentSourceKind, ProjectType, State,
};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
@@ -23,6 +25,7 @@ pub async fn update_all_projects(
instance_id: &str,
) -> crate::Result<HashMap<String, String>> {
let state = State::get().await?;
ensure_instance_content_unlocked(instance_id, &state).await?;
let instance = get_instance_display_info(instance_id, &state).await?;
let loading_bar = init_loading(
LoadingBarType::InstanceUpdate {
@@ -51,13 +54,18 @@ pub async fn update_project(
skip_send_event: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let path = crate::state::instances::commands::update_project(
instance_id,
project_path,
&state,
)
.await?;
if !skip_send_event.unwrap_or(false) {
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
@@ -73,6 +81,7 @@ pub async fn add_project_from_version(
dependent_on_version_id: Option<String>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_instance_content_unlocked(instance_id, &state).await?;
let project_path =
crate::state::instances::commands::add_project_from_version(
instance_id,
@@ -97,6 +106,7 @@ pub async fn install_project_with_dependencies(
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let plan = crate::state::instances::commands::resolve_install_plan(
instance_id,
crate::state::instances::commands::InstanceInstallProjectRequest {
@@ -181,6 +191,12 @@ pub async fn switch_project_version_with_dependencies(
version_id: &str,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
@@ -204,13 +220,18 @@ pub async fn add_project_from_path(
project_type: Option<ProjectType>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await
ensure_instance_content_unlocked(instance_id, &state).await?;
let project_path =
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(project_path)
}
#[tracing::instrument]
@@ -235,6 +256,8 @@ pub async fn toggle_disable_project(
desired_enabled: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
let res = crate::state::instances::commands::toggle_disable_project(
instance_id,
project,
@@ -253,6 +276,8 @@ pub async fn remove_project(
project: &str,
) -> crate::Result<()> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
crate::state::instances::commands::remove_project(
instance_id,
project,
@@ -264,6 +289,45 @@ pub async fn remove_project(
Ok(())
}
async fn ensure_shared_instance_can_modify_project(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
if !metadata
.shared_instance
.is_some_and(|attachment| attachment.role.is_member())
{
return Ok(());
}
let source_kind =
crate::state::instances::commands::content_source_kind_for_project_path(
instance_id,
project_path,
state,
)
.await?;
if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) {
return Err(crate::ErrorKind::InputError(
"Shared instance managed content cannot be changed directly."
.to_string(),
)
.into());
}
Ok(())
}
#[tracing::instrument]
pub async fn update_managed_modrinth_version(
instance_id: &str,
@@ -278,6 +342,7 @@ pub async fn update_managed_modrinth_version(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack {
@@ -335,6 +400,7 @@ pub async fn repair_managed_modrinth(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
ensure_metadata_content_unlocked(&metadata)?;
let post_install_edit = match &metadata.link {
crate::state::InstanceLink::ServerProjectModpack { .. } => {
@@ -381,6 +447,33 @@ fn unmanaged_pack_error(instance_id: &str) -> crate::ErrorKind {
))
}
async fn ensure_instance_content_unlocked(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
if instance_rows::is_instance_quarantined(instance_id, &state.pool).await? {
return Err(quarantined_content_error().into());
}
Ok(())
}
fn ensure_metadata_content_unlocked(
metadata: &crate::state::InstanceMetadata,
) -> crate::Result<()> {
if metadata.quarantined {
return Err(quarantined_content_error().into());
}
Ok(())
}
fn quarantined_content_error() -> crate::ErrorKind {
crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
}
async fn get_instance_display_info(
instance_id: &str,
state: &State,
+28
View File
@@ -24,6 +24,23 @@ pub async fn run(
quick_play_type: QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
instance_id,
&state.pool,
)
.await?
{
return Err(crate::ErrorKind::InputError(
"This instance has been quarantined".to_string(),
)
.into());
}
super::shared::check_shared_instance_availability_before_launch(
instance_id,
&state,
)
.await?;
let default_account = Credentials::get_default_credential(&state.pool)
.await?
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
@@ -50,6 +67,17 @@ async fn run_credentials(
"Tried to run a nonexistent instance {instance_id}!"
))
})?;
if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined(
instance_id,
&state.pool,
)
.await?
{
return Err(crate::ErrorKind::InputError(
"This instance has been quarantined".to_string(),
)
.into());
}
let pre_launch_hooks = context
.launch_overrides
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
use super::client::*;
use super::publish::*;
use super::types::*;
use super::*;
pub(super) async fn shared_instance_update_diffs(
metadata: &crate::state::InstanceMetadata,
version: &InstanceVersionResponse,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let remote_modpack_id =
version.modpack_id.as_deref().filter(|id| !id.is_empty());
let current_modpack_id = shared_modpack_id(&metadata.link);
let modpack_unlinked =
current_modpack_id.is_some() && remote_modpack_id.is_none();
let (current_version_ids, current_external_files) =
current_shared_content(metadata, modpack_unlinked, state).await?;
let (latest_version_ids, latest_external_files) =
remote_shared_content(version);
let removed_disabled_project_ids = HashSet::new();
let removed_disabled_external_files = HashSet::new();
let mut diffs = shared_content_diffs(
&current_version_ids,
&current_external_files,
&latest_version_ids,
&latest_external_files,
&removed_disabled_project_ids,
&removed_disabled_external_files,
true,
state,
)
.await?;
let mut configuration_diffs = shared_instance_configuration_diffs(
current_modpack_id.as_deref(),
remote_modpack_id,
&metadata.applied_content_set.game_version,
&version.game_version,
metadata.applied_content_set.loader,
version.loader,
metadata.applied_content_set.loader_version.as_deref(),
Some(version.loader_version.as_str()),
state,
)
.await?;
configuration_diffs.append(&mut diffs);
Ok(configuration_diffs)
}
pub(super) async fn shared_instance_publish_diffs(
metadata: &crate::state::InstanceMetadata,
version: &InstanceVersionResponse,
snapshot: &CurrentPublishSnapshot,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let remote_modpack_id =
version.modpack_id.as_deref().filter(|id| !id.is_empty());
let current_modpack_id = shared_modpack_id(&metadata.link);
let modpack_unlinked =
remote_modpack_id.is_some() && current_modpack_id.is_none();
let disabled_versions = async {
if snapshot.disabled_version_ids.is_empty() {
Ok(HashMap::new())
} else {
shared_versions_by_project(&snapshot.disabled_version_ids, state)
.await
}
};
let ((latest_version_ids, latest_external_files), disabled_versions) = tokio::try_join!(
remote_publish_content(version, modpack_unlinked, state),
disabled_versions,
)?;
let current_external_files = snapshot
.external_files
.iter()
.map(|file| file.file_name.clone())
.collect::<HashSet<_>>();
let current_version_ids = snapshot
.version_ids
.iter()
.filter(|id| current_modpack_id.as_deref() != Some(id.as_str()))
.cloned()
.collect::<Vec<_>>();
let mut removed_disabled_project_ids =
snapshot.disabled_project_ids.clone();
removed_disabled_project_ids.extend(disabled_versions.into_keys());
let mut diffs = shared_content_diffs(
&latest_version_ids,
&latest_external_files,
&current_version_ids,
&current_external_files,
&removed_disabled_project_ids,
&snapshot.disabled_external_files,
false,
state,
)
.await?;
let mut configuration_diffs = shared_instance_configuration_diffs(
remote_modpack_id,
current_modpack_id.as_deref(),
&version.game_version,
&metadata.applied_content_set.game_version,
version.loader,
metadata.applied_content_set.loader,
Some(version.loader_version.as_str()),
metadata.applied_content_set.loader_version.as_deref(),
state,
)
.await?;
configuration_diffs.append(&mut diffs);
Ok(configuration_diffs)
}
pub(super) async fn shared_instance_configuration_diffs(
current_modpack_id: Option<&str>,
new_modpack_id: Option<&str>,
current_game_version: &str,
new_game_version: &str,
current_loader: ModLoader,
new_loader: ModLoader,
current_loader_version: Option<&str>,
new_loader_version: Option<&str>,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let mut diffs = Vec::new();
if current_modpack_id != new_modpack_id {
match (current_modpack_id, new_modpack_id) {
(None, Some(_)) => diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::ModpackLinked,
None,
shared_modpack_version_label(new_modpack_id, state).await,
)),
(Some(_), None) => diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::ModpackUnlinked,
shared_modpack_version_label(current_modpack_id, state).await,
None,
)),
(Some(current_modpack_id), Some(new_modpack_id)) => {
let current =
shared_modpack_version_details(current_modpack_id, state)
.await;
let new =
shared_modpack_version_details(new_modpack_id, state).await;
let project_name = new
.as_ref()
.and_then(|details| details.project_name.clone())
.or_else(|| {
current
.as_ref()
.and_then(|details| details.project_name.clone())
});
diffs.push(SharedInstanceUpdateDiff {
type_: SharedInstanceUpdateDiffType::ModpackUpdated,
project_id: None,
project_name,
file_name: None,
current_version_name: current
.map(|details| details.version_name),
new_version_name: new.map(|details| details.version_name),
config_file_count: None,
disabled: false,
});
}
(None, None) => unreachable!(),
}
}
if current_game_version != new_game_version {
diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::GameVersionUpdated,
Some(current_game_version.to_string()),
Some(new_game_version.to_string()),
));
}
let current_loader_version =
normalized_loader_version(current_loader_version);
let new_loader_version = normalized_loader_version(new_loader_version);
if current_loader != new_loader
|| current_loader_version != new_loader_version
{
diffs.push(configuration_diff(
SharedInstanceUpdateDiffType::LoaderUpdated,
Some(shared_loader_label(current_loader, current_loader_version)),
Some(shared_loader_label(new_loader, new_loader_version)),
));
}
Ok(diffs)
}
pub(super) fn configuration_diff(
type_: SharedInstanceUpdateDiffType,
current_version_name: Option<String>,
new_version_name: Option<String>,
) -> SharedInstanceUpdateDiff {
SharedInstanceUpdateDiff {
type_,
project_id: None,
project_name: None,
file_name: None,
current_version_name,
new_version_name,
config_file_count: None,
disabled: false,
}
}
pub(super) async fn shared_modpack_version_label(
version_id: Option<&str>,
state: &State,
) -> Option<String> {
let version_id = version_id?;
let details = shared_modpack_version_details(version_id, state).await?;
Some(match details.project_name {
Some(project_name) => {
format!("{project_name} {}", details.version_name)
}
None => details.version_name,
})
}
struct SharedModpackVersionDetails {
project_name: Option<String>,
version_name: String,
}
async fn shared_modpack_version_details(
version_id: &str,
state: &State,
) -> Option<SharedModpackVersionDetails> {
let Some(version) = CachedEntry::get_version(
version_id,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await
.ok()
.flatten() else {
return Some(SharedModpackVersionDetails {
project_name: None,
version_name: version_id.to_string(),
});
};
let project = CachedEntry::get_project(
&version.project_id,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await
.ok()
.flatten();
Some(SharedModpackVersionDetails {
project_name: Some(
project.map(|project| project.title).unwrap_or(version.name),
),
version_name: version.version_number,
})
}
pub(super) fn normalized_loader_version(
loader_version: Option<&str>,
) -> Option<&str> {
loader_version.filter(|version| !version.is_empty())
}
pub(super) fn shared_loader_label(
loader: ModLoader,
loader_version: Option<&str>,
) -> String {
let loader_name = match loader {
ModLoader::Vanilla => "Vanilla",
ModLoader::Forge => "Forge",
ModLoader::Fabric => "Fabric",
ModLoader::Quilt => "Quilt",
ModLoader::NeoForge => "NeoForge",
};
match loader_version {
Some(version) => format!("{loader_name} {version}"),
None => loader_name.to_string(),
}
}
pub(super) async fn shared_content_diffs(
current_version_ids: &[String],
current_external_files: &HashSet<String>,
latest_version_ids: &[String],
latest_external_files: &HashSet<String>,
removed_disabled_project_ids: &HashSet<String>,
removed_disabled_external_files: &HashSet<String>,
common_external_files_are_updated: bool,
state: &State,
) -> crate::Result<Vec<SharedInstanceUpdateDiff>> {
let current = shared_content_snapshot(
current_version_ids,
current_external_files,
state,
)
.await?;
let latest = shared_content_snapshot(
latest_version_ids,
latest_external_files,
state,
)
.await?;
let options = ContentSetDiffOptions {
removed_disabled_project_ids: removed_disabled_project_ids.clone(),
removed_disabled_external_files: removed_disabled_external_files
.clone(),
common_external_files_are_updated,
};
let content_diffs = diff_content_sets(&current, &latest, &options);
let project_ids = content_diffs
.iter()
.filter_map(|diff| match diff {
ContentSetDiffEntry::Project { project_id, .. } => {
Some(project_id.clone())
}
ContentSetDiffEntry::ExternalFile { .. } => None,
})
.collect::<HashSet<_>>();
let project_names = shared_project_names(&project_ids, state).await?;
let mut diffs = Vec::new();
for diff in content_diffs {
match diff {
ContentSetDiffEntry::Project {
kind,
project_id,
current_version_name,
new_version_name,
disabled,
} => {
let project_name = Some(
project_names
.get(&project_id)
.cloned()
.unwrap_or_else(|| project_id.clone()),
);
diffs.push(SharedInstanceUpdateDiff {
type_: shared_update_diff_type(kind),
project_id: Some(project_id),
project_name,
file_name: None,
current_version_name,
new_version_name,
config_file_count: None,
disabled,
});
}
ContentSetDiffEntry::ExternalFile {
kind,
file_name,
disabled,
} => {
diffs.push(SharedInstanceUpdateDiff {
type_: shared_update_diff_type(kind),
project_id: None,
project_name: None,
file_name: Some(file_name),
current_version_name: None,
new_version_name: None,
config_file_count: None,
disabled,
});
}
}
}
diffs.sort_by(|a, b| {
a.project_name
.as_deref()
.or(a.file_name.as_deref())
.cmp(&b.project_name.as_deref().or(b.file_name.as_deref()))
});
Ok(diffs)
}
pub(super) fn shared_update_diff_type(
kind: ContentSetDiffKind,
) -> SharedInstanceUpdateDiffType {
match kind {
ContentSetDiffKind::Added => SharedInstanceUpdateDiffType::Added,
ContentSetDiffKind::Removed => SharedInstanceUpdateDiffType::Removed,
ContentSetDiffKind::Updated => SharedInstanceUpdateDiffType::Updated,
}
}
pub(super) async fn shared_content_snapshot(
version_ids: &[String],
external_files: &HashSet<String>,
state: &State,
) -> crate::Result<ContentSetSnapshot> {
let versions = shared_versions_by_project(version_ids, state)
.await?
.into_values()
.map(ContentSetSnapshotVersion::from)
.collect();
Ok(ContentSetSnapshot {
versions,
external_files: external_files.clone(),
})
}
pub(super) fn remote_shared_content(
version: &InstanceVersionResponse,
) -> (Vec<String>, HashSet<String>) {
let mut version_ids = version.modrinth_ids.clone();
if let Some(modpack_id) = version.modpack_id.as_deref() {
version_ids.retain(|id| id != modpack_id);
}
dedupe_strings(&mut version_ids);
(
version_ids,
version
.external_files
.iter()
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
.map(|file| file.file_name.clone())
.collect(),
)
}
@@ -0,0 +1,574 @@
use super::client::*;
use super::diff::*;
use super::publish::*;
use super::types::*;
use super::*;
#[tracing::instrument]
pub async fn install_shared_instance(
shared_instance_id: &str,
name: String,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
let data = shared_instance_install_data(
shared_instance_id,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
name,
version,
&state,
)
.await?;
crate::install::create_shared_instance(data).await
}
pub(super) fn shared_instance_invite_install_name(
shared_instance_id: &str,
name: String,
) -> String {
let name = name.trim();
if name.is_empty() || name == "Shared instance" {
return format!("Shared instance {shared_instance_id}");
}
name.to_string()
}
#[tracing::instrument]
pub async fn get_shared_instance_install_preview(
shared_instance_id: &str,
name: String,
) -> crate::Result<SharedInstanceInstallPreview> {
let state = State::get().await?;
let version = get_latest_remote_version(shared_instance_id, &state).await?;
shared_instance_install_preview_from_version(
shared_instance_id.to_string(),
name,
version,
&state,
)
.await
}
#[tracing::instrument(skip(invite_id))]
pub async fn accept_shared_instance_invite_for_install(
invite_id: &str,
) -> crate::Result<SharedInstanceInviteInstallPreview> {
let state = State::get().await?;
let invite = get_shared_instance_invite_info(invite_id, &state).await?;
let shared_instance_id = invite.instance_id;
let instance_icon_url = invite.instance_icon;
let (manager_id, server_manager_name, server_manager_icon_url) =
shared_instance_invite_manager(invite.managers);
let name = shared_instance_invite_install_name(
&shared_instance_id,
invite.instance_name,
);
accept_shared_instance_invite(&shared_instance_id, invite_id, &state)
.await?;
let version =
get_latest_remote_version(&shared_instance_id, &state).await?;
let mut preview = shared_instance_install_preview_from_version(
shared_instance_id.clone(),
name,
version,
&state,
)
.await?;
if instance_icon_url.is_some() {
preview.icon_url.clone_from(&instance_icon_url);
}
Ok(SharedInstanceInviteInstallPreview {
shared_instance_id,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
preview,
})
}
pub(super) fn shared_instance_invite_manager(
managers: Vec<InstanceInviteManagerResponse>,
) -> (Option<String>, Option<String>, Option<String>) {
match managers.into_iter().next() {
Some(InstanceInviteManagerResponse::User { id }) => {
(Some(id), None, None)
}
Some(InstanceInviteManagerResponse::Server { name, icon }) => {
(None, Some(name), icon)
}
None => (None, None, None),
}
}
pub(super) async fn shared_instance_install_preview_from_version(
shared_instance_id: String,
name: String,
version: InstanceVersionResponse,
state: &State,
) -> crate::Result<SharedInstanceInstallPreview> {
let name = match name.trim() {
"" => "Shared instance".to_string(),
name => name.to_string(),
};
let mut content_version_ids = version.modrinth_ids.clone();
let mut seen_content_version_ids = HashSet::new();
content_version_ids
.retain(|id| seen_content_version_ids.insert(id.clone()));
let modpack = shared_instance_install_modpack(&version, state).await?;
let modpack_dependency_count = modpack
.as_ref()
.map(|modpack| modpack.dependency_count)
.unwrap_or_default();
let modpack_version_id =
modpack.as_ref().map(|modpack| modpack.version_id.clone());
if let Some(modpack_version_id) = modpack_version_id.as_deref() {
content_version_ids.retain(|id| id != modpack_version_id);
}
let icon_url = modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone());
let external_files = version
.external_files
.iter()
.filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
.map(|file| SharedInstanceExternalFilePreview {
file_name: file.file_name.clone(),
file_type: file.file_type.clone(),
})
.collect::<Vec<_>>();
let external_file_count = external_files.len();
Ok(SharedInstanceInstallPreview {
shared_instance_id,
version: version.version,
name,
icon_url,
game_version: version.game_version,
loader: version.loader,
mod_count: modpack_dependency_count
+ content_version_ids.len()
+ external_file_count,
external_file_count,
modpack_version_id,
content_version_ids,
external_files,
})
}
#[tracing::instrument]
pub async fn get_shared_instance_update_preview(
instance_id: &str,
) -> crate::Result<Option<SharedInstanceUpdatePreview>> {
let state = State::get().await?;
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(attachment) = metadata.shared_instance.clone() else {
return Ok(None);
};
if !attachment.role.is_member() {
if let SharedInstanceRemoteResponse::Unavailable(reason) =
get_remote_instance_access(&attachment.id, &state).await?
{
handle_unavailable_shared_instance_if_current_user(
instance_id,
&attachment,
reason,
&state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Ok(None);
}
let version =
get_latest_remote_member_version(instance_id, &attachment, &state)
.await?;
if !version.ready {
return Ok(Some(SharedInstanceUpdatePreview {
shared_instance_id: attachment.id,
current_version: attachment.applied_version,
latest_version: version.version,
update_available: false,
diffs: Vec::new(),
}));
}
let update_available = attachment
.applied_version
.is_none_or(|current| current < version.version);
let diffs = if update_available {
shared_instance_update_diffs(&metadata, &version, &state).await?
} else {
Vec::new()
};
Ok(Some(SharedInstanceUpdatePreview {
shared_instance_id: attachment.id,
current_version: attachment.applied_version,
latest_version: version.version,
update_available,
diffs,
}))
}
pub(crate) async fn check_shared_instance_availability_before_launch(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(attachment) = metadata.shared_instance else {
return Ok(());
};
let availability =
match get_remote_instance_access(&attachment.id, state).await {
Ok(availability) => availability,
Err(error)
if matches!(
error.raw.as_ref(),
crate::ErrorKind::NoCredentialsError
| crate::ErrorKind::FetchError(_)
) =>
{
tracing::warn!(
instance_id,
shared_instance_id = %attachment.id,
error = %error,
"Could not check shared instance availability before launch"
);
return Ok(());
}
Err(error) => return Err(error),
};
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
handle_unavailable_shared_instance_if_current_user(
instance_id,
&attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
Ok(())
}
#[tracing::instrument]
pub async fn update_shared_instance(
instance_id: &str,
) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let metadata = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let attachment = metadata.shared_instance.clone().ok_or_else(|| {
crate::ErrorKind::InputError(
"Instance is not attached to a shared instance".to_string(),
)
})?;
if !attachment.role.is_member() {
return Err(crate::ErrorKind::InputError(
"Only shared instance members can update from shared instances"
.to_string(),
)
.into());
}
let version =
get_latest_remote_member_version(instance_id, &attachment, &state)
.await?;
let data = shared_instance_install_data(
&attachment.id,
attachment.manager_id.clone(),
attachment.server_manager_name.clone(),
attachment.server_manager_icon_url.clone(),
None,
metadata.instance.name,
version,
&state,
)
.await?;
crate::install::update_shared_instance(instance_id.to_string(), data).await
}
pub(super) async fn ensure_ready_remote_version_for_invite(
instance_id: &str,
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<()> {
match get_latest_remote_version_optional_unavailable(&attachment.id, state)
.await?
{
SharedInstanceRemoteResponse::Available(_) => Ok(()),
SharedInstanceRemoteResponse::Unavailable(
SharedInstanceUnavailableReason::Deleted,
) => {
tracing::info!(
instance_id,
shared_instance_id = %attachment.id,
"Shared instance has no ready version before invite; publishing current content"
);
publish_shared_instance_inner(instance_id, &[], state).await
}
SharedInstanceRemoteResponse::Unavailable(reason) => {
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
Err(shared_instance_unavailable_error(reason))
}
}
}
pub(super) async fn get_latest_remote_member_version(
instance_id: &str,
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<InstanceVersionResponse> {
if let SharedInstanceRemoteResponse::Unavailable(reason) =
get_remote_instance_access(&attachment.id, state).await?
{
if shared_attachment_matches_current_user(attachment, state).await? {
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Err(crate::ErrorKind::OtherError(format!(
"{}: {}",
shared_instance_unavailable_message(reason),
attachment.id
))
.into());
}
let version = match get_latest_remote_version_optional_unavailable(
&attachment.id,
state,
)
.await?
{
SharedInstanceRemoteResponse::Available(version) => version,
SharedInstanceRemoteResponse::Unavailable(reason) => {
if shared_attachment_matches_current_user(attachment, state).await?
{
handle_unavailable_shared_instance_if_current_user(
instance_id,
attachment,
reason,
state,
)
.await?;
return Err(shared_instance_unavailable_error(reason));
}
return Err(crate::ErrorKind::OtherError(format!(
"{}: {}",
shared_instance_unavailable_message(reason),
attachment.id
))
.into());
}
};
Ok(version)
}
pub(super) async fn shared_attachment_matches_current_user(
attachment: &SharedInstanceAttachment,
state: &State,
) -> crate::Result<bool> {
let Some(linked_user_id) = attachment.linked_user_id.as_deref() else {
return Ok(false);
};
Ok(linked_modrinth_user_id(state)
.await?
.as_deref()
.is_some_and(|current_user_id| current_user_id == linked_user_id))
}
pub(super) async fn has_shared_instance_recipients(
users: &SharedInstanceUsers,
attachment: &SharedInstanceAttachment,
has_pending_recipients: bool,
state: &State,
) -> crate::Result<bool> {
if has_pending_recipients || users.tokens > 0 {
return Ok(true);
}
let current_user_id = linked_modrinth_user_id(state).await?;
Ok(users.user_ids.iter().any(|user_id| {
Some(user_id.as_str()) != attachment.linked_user_id.as_deref()
&& Some(user_id.as_str()) != attachment.manager_id.as_deref()
&& Some(user_id.as_str()) != current_user_id.as_deref()
}))
}
pub(super) async fn handle_unavailable_shared_instance_if_current_user(
instance_id: &str,
attachment: &SharedInstanceAttachment,
reason: SharedInstanceUnavailableReason,
state: &State,
) -> crate::Result<()> {
if reason != SharedInstanceUnavailableReason::Quarantined
&& !shared_attachment_matches_current_user(attachment, state).await?
{
return Ok(());
}
match reason {
SharedInstanceUnavailableReason::Quarantined => {
crate::state::quarantine_shared_instance(instance_id, &state.pool)
.await?;
}
_ => {
crate::state::clear_shared_instance(instance_id, &state.pool)
.await?;
}
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(())
}
pub(super) fn shared_instance_unavailable_error(
reason: SharedInstanceUnavailableReason,
) -> crate::Error {
crate::ErrorKind::SharedInstanceUnavailable(reason).into()
}
pub(super) fn shared_instance_unavailable_message(
reason: SharedInstanceUnavailableReason,
) -> &'static str {
match reason {
SharedInstanceUnavailableReason::Deleted => {
"Shared instance was deleted"
}
SharedInstanceUnavailableReason::AccessRevoked => {
"Shared instance access was revoked"
}
SharedInstanceUnavailableReason::Quarantined => {
"Shared instance was quarantined"
}
}
}
fn shared_instance_external_file_data(
file: ExternalFileResponse,
) -> crate::Result<SharedInstanceExternalFileData> {
let file_size = file.file_size.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Shared instance external file {} is missing its size",
file.file_name
))
})?;
let file_size = u64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(format!(
"Shared instance external file {} has an invalid size",
file.file_name
))
})?;
Ok(SharedInstanceExternalFileData {
file_name: file.file_name,
file_type: file.file_type,
url: file.url,
file_size,
})
}
pub(super) async fn shared_instance_install_data(
shared_instance_id: &str,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
name: String,
version: InstanceVersionResponse,
state: &State,
) -> crate::Result<SharedInstanceInstallData> {
if !version.ready {
return Err(crate::ErrorKind::InputError(
"Shared instance version is not ready to install".to_string(),
)
.into());
}
let name = shared_instance_name(name);
let linked_user_id = linked_modrinth_user_id(state).await?;
let modpack = shared_instance_install_modpack(&version, state).await?;
let modpack_version_id =
modpack.as_ref().map(|modpack| modpack.version_id.as_str());
let modrinth_ids = version
.modrinth_ids
.into_iter()
.filter(|id| Some(id.as_str()) != modpack_version_id)
.collect();
Ok(SharedInstanceInstallData {
shared_instance_id: shared_instance_id.to_string(),
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
linked_user_id,
name,
version: version.version,
modrinth_ids,
external_files: version
.external_files
.into_iter()
.map(shared_instance_external_file_data)
.collect::<crate::Result<Vec<_>>>()?,
modpack,
game_version: version.game_version,
loader: version.loader,
loader_version: shared_instance_loader_version(version.loader_version),
})
}
pub(super) async fn linked_modrinth_user_id(
state: &State,
) -> crate::Result<Option<String>> {
Ok(
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
.await?
.map(|credentials| credentials.user_id),
)
}
@@ -0,0 +1,184 @@
const SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS: i32 = 604800;
const SHARED_INSTANCE_INVITE_MAX_USES: i32 = 10;
use super::client::*;
use super::install::*;
use super::publish::*;
use super::types::*;
use super::*;
#[tracing::instrument]
pub async fn get_shared_instance_users(
instance_id: &str,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(SharedInstanceUsers::empty());
};
get_remote_users(&attachment.id, &state).await
}
#[tracing::instrument]
pub async fn invite_shared_instance_users(
instance_id: &str,
user_ids: Vec<String>,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let (metadata, attachment) =
shared_instance_for_invites(instance_id, user_ids.len(), &state)
.await?;
ensure_owner(&attachment)?;
if !user_ids.is_empty() {
ensure_ready_remote_version_for_invite(
instance_id,
&attachment,
&state,
)
.await?;
update_remote_instance(
&attachment.id,
shared_instance_name(metadata.instance.name.clone()),
&state,
)
.await?;
tracing::info!(
instance_id,
shared_instance_id = %attachment.id,
user_count = user_ids.len(),
"Adding users to shared instance"
);
add_remote_users(&attachment.id, user_ids.clone(), &state).await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
get_remote_users(&attachment.id, &state).await
}
#[tracing::instrument(skip(replace_invite_id))]
pub async fn create_shared_instance_invite_link(
instance_id: &str,
max_age_seconds: Option<i32>,
max_uses: Option<i32>,
replace_invite_id: Option<String>,
) -> crate::Result<SharedInstanceInviteLink> {
let state = State::get().await?;
let (metadata, attachment) =
shared_instance_for_invites(instance_id, 0, &state).await?;
ensure_owner(&attachment)?;
ensure_ready_remote_version_for_invite(instance_id, &attachment, &state)
.await?;
update_remote_instance(
&attachment.id,
shared_instance_name(metadata.instance.name),
&state,
)
.await?;
let max_age_seconds =
max_age_seconds.unwrap_or(SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS);
let max_uses = max_uses.unwrap_or(SHARED_INSTANCE_INVITE_MAX_USES);
if max_age_seconds <= 0
|| max_age_seconds > SHARED_INSTANCE_INVITE_MAX_AGE_SECONDS
{
return Err(crate::ErrorKind::InputError(
"Invite expiry must be between now and seven days".to_string(),
)
.into());
}
if max_uses <= 0 {
return Err(crate::ErrorKind::InputError(
"Invite max uses must be greater than zero".to_string(),
)
.into());
}
if let Some(invite_id) = replace_invite_id {
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
}
let created_at = Utc::now();
let response = request_json::<CreateInstanceInviteResponse>(
"create_instance_invite",
Method::POST,
&format!("/instances/{}/invites", attachment.id),
Some(json!({
"max_age": max_age_seconds,
"max_uses": max_uses,
})),
&state,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(SharedInstanceInviteLink {
invite_id: response.id,
expires_at: created_at
+ chrono::Duration::seconds(i64::from(max_age_seconds)),
max_uses,
})
}
#[tracing::instrument]
pub async fn remove_shared_instance_users(
instance_id: &str,
user_ids: Vec<String>,
has_pending_recipients: bool,
) -> crate::Result<SharedInstanceUsers> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(SharedInstanceUsers::empty());
};
ensure_owner(&attachment)?;
if !user_ids.is_empty() {
remove_remote_users(&attachment.id, user_ids, &state).await?;
}
let remaining_users = get_remote_users(&attachment.id, &state).await?;
if !has_shared_instance_recipients(
&remaining_users,
&attachment,
has_pending_recipients,
&state,
)
.await?
{
delete_remote_instance(&attachment.id, &state).await?;
crate::state::clear_shared_instance(instance_id, &state.pool).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
return Ok(SharedInstanceUsers::empty());
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(remaining_users)
}
#[tracing::instrument]
pub async fn accept_pending_shared_instance_invite(
shared_instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
if accept_pending_remote_invite(shared_instance_id, &state).await? {
return Ok(());
}
Err(crate::ErrorKind::InputError(
"No pending invite found for shared instance".to_string(),
)
.into())
}
#[tracing::instrument]
pub async fn decline_pending_shared_instance_invite(
shared_instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
decline_pending_remote_invite(shared_instance_id, &state).await
}
@@ -0,0 +1,140 @@
use super::content_set_diff::{
ContentSetDiffEntry, ContentSetDiffKind, ContentSetDiffOptions,
ContentSetSnapshot, ContentSetSnapshotVersion, diff_content_sets,
};
use crate::SharedInstanceUnavailableReason;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::install::{
InstallJobSnapshot, SharedInstanceExternalFileData,
SharedInstanceInstallData,
};
use crate::state::instances::{InstanceLink, SharedInstanceAttachment};
use crate::state::{
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSetSyncStatus,
ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials,
ProjectType, SharedInstanceRole, State,
};
use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT};
use chrono::{DateTime, Utc};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::io::Read;
pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip";
pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs";
pub(crate) const CONFIG_SYNC_ENABLED: bool = true;
pub(crate) const CONFIG_DIRECTORY: &str = "config";
pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096;
pub(crate) const MAX_CONFIG_BUNDLE_FILE_SIZE: u64 = 16 * 1024 * 1024;
pub(crate) const MAX_CONFIG_BUNDLE_TOTAL_SIZE: u64 = 128 * 1024 * 1024;
pub(crate) const CONFIG_FILE_EXTENSIONS: [&str; 13] = [
"json",
"json5",
"jsonc",
"yml",
"yaml",
"css",
"toml",
"txt",
"ini",
"cfg",
"conf",
"properties",
"xml",
];
pub(crate) fn read_bounded_config_bundle_entry(
reader: impl Read,
declared_size: u64,
total_size: &mut u64,
) -> crate::Result<Vec<u8>> {
let remaining = MAX_CONFIG_BUNDLE_TOTAL_SIZE.saturating_sub(*total_size);
let entry_limit = MAX_CONFIG_BUNDLE_FILE_SIZE.min(remaining);
if declared_size > entry_limit {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle exceeds the uncompressed size limit"
.to_string(),
)
.into());
}
let capacity = usize::try_from(declared_size).map_err(|_| {
crate::ErrorKind::InputError(
"Shared instance config bundle entry is too large".to_string(),
)
})?;
let mut bytes = Vec::with_capacity(capacity);
reader
.take(entry_limit.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to read shared instance config bundle: {error}"
))
})?;
let actual_size = bytes.len() as u64;
if actual_size > entry_limit {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle exceeds the uncompressed size limit"
.to_string(),
)
.into());
}
*total_size = total_size.checked_add(actual_size).ok_or_else(|| {
crate::ErrorKind::InputError(
"Shared instance config bundle size overflowed".to_string(),
)
})?;
Ok(bytes)
}
mod client;
mod diff;
mod install;
mod invites;
mod publish;
mod types;
pub(crate) use self::install::check_shared_instance_availability_before_launch;
pub(crate) use self::publish::sync_shared_instance_icon;
pub use self::install::{
accept_shared_instance_invite_for_install,
get_shared_instance_install_preview, get_shared_instance_update_preview,
install_shared_instance, update_shared_instance,
};
pub use self::invites::{
accept_pending_shared_instance_invite, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, get_shared_instance_users,
invite_shared_instance_users, remove_shared_instance_users,
};
pub use self::publish::{
get_shared_instance_publish_preview, publish_shared_instance,
unlink_shared_instance, unpublish_shared_instance,
};
pub use self::types::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
};
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {
let state = State::get().await?;
let Some(credentials) =
ModrinthCredentials::get_and_refresh(&state.pool, &state.api_semaphore)
.await?
else {
return Ok(true);
};
let status =
client::get_user_blacklist_status(&credentials.user_id, &state).await?;
Ok(!status.blacklisted)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
use super::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedInstanceUsers {
pub user_ids: Vec<String>,
#[serde(default)]
pub users: Vec<SharedInstanceUser>,
#[serde(default)]
pub tokens: i32,
}
impl SharedInstanceUsers {
pub(super) fn empty() -> Self {
Self {
user_ids: Vec::new(),
users: Vec::new(),
tokens: 0,
}
}
pub(super) fn from_users(
users: Vec<SharedInstanceUser>,
tokens: i32,
) -> Self {
let user_ids = users.iter().map(|user| user.id.clone()).collect();
Self {
user_ids,
users,
tokens,
}
}
pub(super) fn from_user_ids(user_ids: Vec<String>) -> Self {
let users = user_ids
.iter()
.map(|user_id| SharedInstanceUser {
id: user_id.clone(),
joined_at: None,
join_type: SharedInstanceJoinType::Invite,
last_played: None,
})
.collect();
Self {
user_ids,
users,
tokens: 0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedInstanceUser {
pub id: String,
pub joined_at: Option<DateTime<Utc>>,
pub join_type: SharedInstanceJoinType,
pub last_played: Option<DateTime<Utc>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SharedInstanceJoinType {
Owner,
Invite,
Link,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInstallPreview {
pub shared_instance_id: String,
pub version: i32,
pub name: String,
pub icon_url: Option<String>,
pub game_version: String,
pub loader: ModLoader,
pub mod_count: usize,
pub external_file_count: usize,
pub modpack_version_id: Option<String>,
pub content_version_ids: Vec<String>,
pub external_files: Vec<SharedInstanceExternalFilePreview>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceExternalFilePreview {
pub file_name: String,
pub file_type: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceUpdatePreview {
pub shared_instance_id: String,
pub current_version: Option<i32>,
pub latest_version: i32,
pub update_available: bool,
pub diffs: Vec<SharedInstanceUpdateDiff>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstancePublishPreview {
pub shared_instance_id: String,
pub latest_version: i32,
pub diffs: Vec<SharedInstanceUpdateDiff>,
pub config_files: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteLink {
pub invite_id: String,
pub expires_at: DateTime<Utc>,
pub max_uses: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteInstallPreview {
pub shared_instance_id: String,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub instance_icon_url: Option<String>,
pub preview: SharedInstanceInstallPreview,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceUpdateDiff {
#[serde(rename = "type")]
pub type_: SharedInstanceUpdateDiffType,
pub project_id: Option<String>,
pub project_name: Option<String>,
pub file_name: Option<String>,
pub current_version_name: Option<String>,
pub new_version_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_file_count: Option<usize>,
#[serde(default, skip_serializing_if = "is_false")]
pub disabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceUpdateDiffType {
Added,
Removed,
Updated,
ModpackLinked,
ModpackUpdated,
ModpackUnlinked,
GameVersionUpdated,
LoaderUpdated,
ConfigFilesUpdated,
}
pub(super) fn is_false(value: &bool) -> bool {
!*value
}
+4 -1
View File
@@ -11,9 +11,11 @@ pub mod minecraft_skins;
pub mod mr_auth;
pub mod pack;
pub mod process;
pub mod reports;
pub mod server_address;
pub mod settings;
pub mod tags;
pub mod users;
pub mod worlds;
pub mod data {
@@ -26,7 +28,8 @@ pub mod data {
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize,
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
+13 -2
View File
@@ -1,8 +1,19 @@
use crate::state::ModrinthCredentials;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModrinthAuthFlow {
SignIn,
SignUp,
}
#[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
match flow {
ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
}
}
#[tracing::instrument]
+46
View File
@@ -0,0 +1,46 @@
use crate::State;
use crate::util::fetch::fetch_json;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReportItemType {
Project,
Version,
User,
SharedInstance,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateReportRequest {
pub report_type: String,
pub item_id: String,
pub item_type: ReportItemType,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uploaded_images: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateReportResponse {
pub id: String,
}
#[tracing::instrument(skip(request))]
pub async fn create_report(
request: CreateReportRequest,
) -> crate::Result<CreateReportResponse> {
let state = State::get().await?;
fetch_json(
Method::POST,
&format!("{}report", env!("MODRINTH_API_URL_V3")),
None,
Some(serde_json::to_value(request)?),
Some("/v3/report"),
&state.api_semaphore,
&state.pool,
)
.await
}
+32
View File
@@ -0,0 +1,32 @@
use crate::State;
use crate::util::fetch::fetch_json;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
}
#[tracing::instrument]
pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
let state = State::get().await?;
let query = urlencoding::encode(query);
fetch_json(
Method::GET,
&format!(
"{}users/search?query={}",
env!("MODRINTH_API_URL_V3"),
query
),
None,
None,
Some("/v3/users/search"),
&state.api_semaphore,
&state.pool,
)
.await
}
+21
View File
@@ -22,6 +22,24 @@ pub struct LabrinthError {
pub route: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceUnavailableReason {
Deleted,
AccessRevoked,
Quarantined,
}
impl std::fmt::Display for SharedInstanceUnavailableReason {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deleted => write!(fmt, "deleted"),
Self::AccessRevoked => write!(fmt, "access_revoked"),
Self::Quarantined => write!(fmt, "quarantined"),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
#[error("{0:?}")]
@@ -103,6 +121,9 @@ pub enum ErrorKind {
#[error("Invalid input: {0}")]
InputError(String),
#[error("Shared instance unavailable: {0}")]
SharedInstanceUnavailable(SharedInstanceUnavailableReason),
#[error("Join handle error: {0}")]
JoinError(#[from] tokio::task::JoinError),
+3
View File
@@ -234,6 +234,9 @@ pub enum CommandPayload {
server: Option<String>,
singleplayer_world: Option<String>,
},
InstallSharedInstanceInvite {
invite_id: String,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,
+7 -4
View File
@@ -3,6 +3,7 @@ pub mod events;
pub mod model;
pub mod recovery;
pub mod runner;
mod shared_instance;
pub mod store;
pub use events::InstallProgressReporter;
@@ -11,11 +12,13 @@ pub use model::{
InstallJobEventKind, InstallJobKind, InstallJobSnapshot, InstallJobStatus,
InstallModpackPreview, InstallPhaseDetails, InstallPhaseId,
InstallPostInstallEdit, InstallProgress, InstallProgressSecondary,
InstallRequest,
InstallRequest, SharedInstanceExternalFileData, SharedInstanceInstallData,
SharedInstanceInstallModpack,
};
pub use runner::{
cancel_job, create_instance, create_modpack_instance, dismiss_job,
duplicate_instance, get_job, import_instance, install_existing_instance,
cancel_job, create_instance, create_modpack_instance,
create_shared_instance, dismiss_job, duplicate_instance, get_job,
import_instance, install_existing_instance,
install_pack_to_existing_instance, job_support_details, list_jobs,
retry_job,
retry_job, update_shared_instance,
};
+67 -2
View File
@@ -169,6 +169,9 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
CreateSharedInstance {
data: SharedInstanceInstallData,
},
ImportInstance {
launcher_type: ImportLauncherType,
base_path: PathBuf,
@@ -187,6 +190,10 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
UpdateSharedInstance {
instance_id: String,
data: SharedInstanceInstallData,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
@@ -201,6 +208,46 @@ pub struct InstallPostInstallEdit {
pub link: Option<InstanceLink>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallData {
pub shared_instance_id: String,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
#[serde(default)]
pub instance_icon_url: Option<String>,
#[serde(default)]
pub linked_user_id: Option<String>,
pub name: String,
pub version: i32,
pub modrinth_ids: Vec<String>,
#[serde(default)]
pub external_files: Vec<SharedInstanceExternalFileData>,
pub modpack: Option<SharedInstanceInstallModpack>,
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceExternalFileData {
pub file_name: String,
pub file_type: String,
pub url: String,
pub file_size: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallModpack {
pub project_id: String,
pub version_id: String,
pub title: String,
pub icon_url: Option<String>,
pub dependency_count: usize,
}
impl InstallRequest {
pub fn kind(&self) -> InstallJobKind {
match self {
@@ -208,6 +255,9 @@ impl InstallRequest {
Self::CreateModpackInstance { .. } => {
InstallJobKind::CreateModpackInstance
}
Self::CreateSharedInstance { .. } => {
InstallJobKind::CreateSharedInstance
}
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
Self::InstallExistingInstance { .. } => {
@@ -216,13 +266,17 @@ impl InstallRequest {
Self::InstallPackToExistingInstance { .. } => {
InstallJobKind::InstallPackToExistingInstance
}
Self::UpdateSharedInstance { .. } => {
InstallJobKind::UpdateSharedInstance
}
}
}
pub fn target(&self) -> InstallTarget {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallTarget::ExistingInstance {
instance_id: instance_id.clone(),
}
@@ -234,7 +288,8 @@ impl InstallRequest {
pub fn cleanup(&self) -> InstallCleanup {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallCleanup::RestoreExistingInstance {
instance_id: instance_id.clone(),
}
@@ -249,10 +304,12 @@ impl InstallRequest {
pub enum InstallJobKind {
CreateInstance,
CreateModpackInstance,
CreateSharedInstance,
ImportInstance,
DuplicateInstance,
InstallExistingInstance,
InstallPackToExistingInstance,
UpdateSharedInstance,
}
impl InstallJobKind {
@@ -260,24 +317,28 @@ impl InstallJobKind {
match self {
Self::CreateInstance => "create_instance",
Self::CreateModpackInstance => "create_modpack_instance",
Self::CreateSharedInstance => "create_shared_instance",
Self::ImportInstance => "import_instance",
Self::DuplicateInstance => "duplicate_instance",
Self::InstallExistingInstance => "install_existing_instance",
Self::InstallPackToExistingInstance => {
"install_pack_to_existing_instance"
}
Self::UpdateSharedInstance => "update_shared_instance",
}
}
pub fn from_stored_str(value: &str) -> Self {
match value {
"create_modpack_instance" => Self::CreateModpackInstance,
"create_shared_instance" => Self::CreateSharedInstance,
"import_instance" => Self::ImportInstance,
"duplicate_instance" => Self::DuplicateInstance,
"install_existing_instance" => Self::InstallExistingInstance,
"install_pack_to_existing_instance" => {
Self::InstallPackToExistingInstance
}
"update_shared_instance" => Self::UpdateSharedInstance,
_ => Self::CreateInstance,
}
}
@@ -484,6 +545,8 @@ pub struct InstallErrorView {
pub phase: Option<InstallPhaseId>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<crate::SharedInstanceUnavailableReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<InstallApiErrorDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<InstallErrorContext>,
@@ -513,6 +576,7 @@ impl InstallErrorView {
code: code.to_string(),
phase: Some(phase),
message: error.to_string(),
reason: None,
api: match error.raw.as_ref() {
crate::ErrorKind::LabrinthError(error) => {
Some(InstallApiErrorDetails {
@@ -538,6 +602,7 @@ impl InstallErrorView {
code: code.to_string(),
phase: Some(phase),
message: message.into(),
reason: None,
api: None,
context: None,
}
+284 -8
View File
@@ -7,7 +7,257 @@ use super::model::{
use super::store;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::State;
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::{
ContentEntry, ContentSetRemoteRef, ContentSetRemoteRefType,
ContentSetSyncProvider, ContentSetSyncState, InstanceFile,
InstanceMetadata, State,
};
use async_walkdir::WalkDir;
use chrono::Utc;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SHARED_INSTANCE_ROLLBACK_FILE: &str = "rollback.json";
const SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR: &str = "instance";
#[derive(Deserialize, Serialize)]
struct SharedInstanceUpdateRollback {
files: Vec<InstanceFile>,
entries: Vec<ContentEntry>,
}
pub(super) async fn prepare_shared_instance_update_backup(
job_id: Uuid,
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<PathBuf> {
let staging_dir = state
.directories
.metadata_dir()
.join("install_job_backups")
.join(job_id.to_string());
if tokio::fs::try_exists(&staging_dir).await? {
crate::util::io::remove_dir_all(&staging_dir).await?;
}
crate::util::io::create_dir_all(&staging_dir).await?;
let result = async {
let files = content_rows::get_instance_files(
&metadata.instance.id,
&state.pool,
)
.await?;
let entries = content_rows::get_content_entries(
&metadata.applied_content_set.id,
&state.pool,
)
.await?;
let snapshot = SharedInstanceUpdateRollback { files, entries };
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
copy_directory(
&instance_path,
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
state,
)
.await?;
crate::util::io::write(
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
serde_json::to_vec(&snapshot)?,
)
.await?;
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
let _ = crate::util::io::remove_dir_all(&staging_dir).await;
}
result?;
Ok(staging_dir)
}
pub(super) async fn clear_staging_dir(job_state: &InstallJobState) {
let Some(staging_dir) = &job_state.paths.staging_dir else {
return;
};
if let Err(error) = crate::util::io::remove_dir_all(staging_dir).await
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
path = %staging_dir.display(),
"Failed to remove install rollback backup: {error}"
);
}
}
async fn restore_shared_instance_update(
staging_dir: &Path,
rollback: &super::model::InstallRollbackState,
state: &State,
) -> crate::Result<()> {
let snapshot = serde_json::from_slice::<SharedInstanceUpdateRollback>(
&crate::util::io::read(staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE))
.await?,
)?;
let instance_path = state
.directories
.instances_dir()
.join(&rollback.instance.instance.path);
if tokio::fs::try_exists(&instance_path).await? {
crate::util::io::remove_dir_all(&instance_path).await?;
}
copy_directory(
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
&instance_path,
state,
)
.await?;
content_rows::restore_instance_content_snapshot(
&rollback.instance.instance.id,
&snapshot.files,
&snapshot.entries,
&state.pool,
)
.await?;
restore_instance_metadata(&rollback.instance, state).await?;
Ok(())
}
async fn restore_instance_metadata(
metadata: &InstanceMetadata,
state: &State,
) -> crate::Result<()> {
let content_set_id = metadata.applied_content_set.id.as_str();
let mut tx = state.pool.begin().await?;
instance_rows::update_instance(&metadata.instance, &mut tx).await?;
content_rows::update_content_set(&metadata.applied_content_set, &mut tx)
.await?;
instance_rows::upsert_instance_link(
&metadata.instance.id,
&metadata.link,
&mut tx,
)
.await?;
instance_rows::set_shared_instance_attachment(
&metadata.instance.id,
metadata.shared_instance.as_ref(),
&mut tx,
)
.await?;
instance_rows::replace_instance_groups(
&metadata.instance.id,
&metadata.groups,
&mut tx,
)
.await?;
instance_rows::upsert_instance_launch_overrides(
&metadata.launch_overrides,
&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?;
if let Some(attachment) = &metadata.shared_instance {
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.to_string(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: attachment.id.clone(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(
&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: Some(Utc::now()),
status: attachment.status,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn copy_directory(
source: &Path,
target: &Path,
state: &State,
) -> crate::Result<()> {
crate::util::io::create_dir_all(target).await?;
let mut walker = WalkDir::new(source);
while let Some(entry) = walker.next().await {
let entry = entry.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to read instance backup path: {error}"
))
})?;
let entry_path = entry.path();
let relative_path = entry_path.strip_prefix(source)?;
let target_path = target.join(relative_path);
let file_type = entry.file_type().await?;
if file_type.is_dir() {
crate::util::io::create_dir_all(&target_path).await?;
} else if file_type.is_file() {
crate::util::fetch::copy(
&entry_path,
&target_path,
&state.io_semaphore,
)
.await?;
} else if file_type.is_symlink() {
copy_symlink(&entry_path, &target_path).await?;
}
}
Ok(())
}
async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
if let Some(parent) = target.parent() {
crate::util::io::create_dir_all(parent).await?;
}
let link_target = tokio::fs::read_link(source).await?;
#[cfg(unix)]
tokio::fs::symlink(link_target, target).await?;
#[cfg(windows)]
{
let metadata = tokio::fs::metadata(source).await?;
if metadata.is_dir() {
tokio::fs::symlink_dir(link_target, target).await?;
} else {
tokio::fs::symlink_file(link_target, target).await?;
}
}
Ok(())
}
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
@@ -61,6 +311,9 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
state,
)
.await?;
if job.state.rollback_error.is_none() {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
}
@@ -96,6 +349,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
..
} => None,
},
InstallRequest::CreateSharedInstance { data } => {
Some(InstallJobDisplay {
title: data.name.clone(),
icon: data
.modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone()),
})
}
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
@@ -104,7 +366,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
| InstallRequest::InstallPackToExistingInstance { .. }
| InstallRequest::UpdateSharedInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
@@ -128,12 +391,25 @@ pub async fn apply_cleanup(
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
if let Some(rollback) = &job_state.rollback {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
if matches!(
&job_state.request,
InstallRequest::UpdateSharedInstance { .. }
) && let Some(staging_dir) = &job_state.paths.staging_dir
{
restore_shared_instance_update(
staging_dir,
rollback,
state,
)
.await?;
} else {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
rollback.install_stage,
&state.pool,
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
}
+262 -16
View File
@@ -3,7 +3,13 @@ use super::model::{
InstallCleanup, InstallErrorContext, InstallErrorView, InstallJobDisplay,
InstallJobEventKind, InstallJobSnapshot, InstallJobState, InstallJobStatus,
InstallPhaseDetails, InstallPhaseId, InstallPostInstallEdit,
InstallRequest, InstallRollbackState, InstallTarget,
InstallProgress, InstallRequest, InstallRollbackState, InstallTarget,
SharedInstanceInstallData,
};
use super::shared_instance::{
apply_shared_instance_content, apply_shared_instance_update,
attach_pending_shared_instance, finalize_shared_instance_attachment,
shared_instance_link, shared_instance_pack_location,
};
use super::{diagnostics, recovery, store};
use crate::ErrorKind;
@@ -53,6 +59,19 @@ pub async fn create_modpack_instance(
.await
}
pub async fn create_shared_instance(
data: SharedInstanceInstallData,
) -> crate::Result<InstallJobSnapshot> {
start(InstallRequest::CreateSharedInstance { data }).await
}
pub async fn update_shared_instance(
instance_id: String,
data: SharedInstanceInstallData,
) -> crate::Result<InstallJobSnapshot> {
start(InstallRequest::UpdateSharedInstance { instance_id, data }).await
}
pub async fn import_instance(
launcher_type: crate::api::pack::import::ImportLauncherType,
base_path: PathBuf,
@@ -129,9 +148,15 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.into());
}
if job.state.rollback_error.is_some() {
recovery::apply_cleanup(&job.state, &state).await?;
recovery::clear_staging_dir(&job.state).await;
}
job.state.target = job.state.request.target();
job.state.cleanup = job.state.request.cleanup();
job.state.rollback = None;
job.state.paths.staging_dir = None;
job.state.error = None;
job.state.rollback_error = None;
job.state.context = None;
@@ -150,6 +175,7 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -204,6 +230,9 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
&state,
)
.await?;
if job.state.rollback_error.is_none() {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
Ok(record.snapshot())
@@ -221,6 +250,7 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -296,6 +326,54 @@ async fn prepare_initial_instance(
);
set_instance_id(job_state, metadata.instance.id);
}
InstallRequest::CreateSharedInstance { data } => {
let shared_link = shared_instance_link(data.modpack.as_ref());
let (game_version, loader, loader_version, icon_path) =
if let Some(modpack) = data.modpack.clone() {
let preview = get_instance_from_pack(
shared_instance_pack_location(modpack),
)
.await?;
(
preview.game_version,
preview.modloader,
preview.loader_version,
data.instance_icon_url
.clone()
.or_else(|| {
preview.icon.as_ref().map(|path| {
path.to_string_lossy().to_string()
})
})
.or_else(|| preview.icon_url.clone()),
)
} else {
(
data.game_version.clone(),
data.loader,
data.loader_version.clone(),
data.instance_icon_url.clone(),
)
};
let metadata = crate::api::instance::create(
data.name.clone(),
game_version,
loader,
loader_version,
icon_path,
shared_link,
)
.await?;
set_display(
job_state,
metadata.instance.name,
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
} => {
@@ -343,7 +421,8 @@ async fn prepare_initial_instance(
InstallRequest::InstallExistingInstance { instance_id, .. }
| InstallRequest::InstallPackToExistingInstance {
instance_id, ..
} => {
}
| InstallRequest::UpdateSharedInstance { instance_id, .. } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
}
}
@@ -353,7 +432,7 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = run_job(job_id).await {
if let Err(error) = Box::pin(run_job(job_id)).await {
tracing::error!(
"Install job {job_id} failed to update state: {error}"
);
@@ -387,16 +466,23 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
.await?;
emit_install_job(&record.snapshot()).await?;
let result = run_request(job_id, &mut job_state, &state).await;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
if let Ok(record) = store::get_required(job_id, &state).await {
job_state = record.state;
}
match result {
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
}
Err(error) => Err(error),
};
match result {
Ok(()) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
});
@@ -413,6 +499,7 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
&state,
)
.await?;
recovery::clear_staging_dir(&job_state).await;
emit_install_job(&record.snapshot()).await?;
}
Err(error) => {
@@ -459,6 +546,9 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
&state,
)
.await?;
if job_state.rollback_error.is_none() {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
}
@@ -541,17 +631,39 @@ async fn run_request(
modpack_details(&location),
)
.await?;
install_pack(
Box::pin(install_pack(
job_id,
job_state,
location,
instance_id.clone(),
DownloadReason::Modpack,
)
))
.await?;
apply_post_install_edit(&instance_id, post_install_edit).await?;
Ok(Some(instance_id))
}
InstallRequest::CreateSharedInstance { data } => {
let Some(instance_id) = current_instance_id(job_state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
Box::pin(apply_shared_instance_content(
job_id,
job_state,
state,
&instance_id,
&data,
))
.await?;
finalize_shared_instance_attachment(&instance_id, &data, state)
.await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
Ok(Some(instance_id))
}
InstallRequest::ImportInstance {
launcher_type,
base_path,
@@ -629,6 +741,7 @@ async fn run_request(
}
InstallRequest::InstallExistingInstance { instance_id, force } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
update_progress(
job_id,
job_state,
@@ -660,6 +773,7 @@ async fn run_request(
post_install_edit,
} => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
let disabled_project_ids = remove_existing_pack_content(
job_id,
job_state,
@@ -667,13 +781,13 @@ async fn run_request(
&instance_id,
)
.await?;
install_pack(
Box::pin(install_pack(
job_id,
job_state,
location,
instance_id.clone(),
DownloadReason::Modpack,
)
))
.await?;
restore_disabled_projects(
&instance_id,
@@ -684,6 +798,49 @@ async fn run_request(
apply_post_install_edit(&instance_id, post_install_edit).await?;
Ok(Some(instance_id))
}
InstallRequest::UpdateSharedInstance { instance_id, data } => {
prepare_existing_rollback(job_state, state, &instance_id).await?;
lock_existing_instance(&instance_id, state).await?;
let rollback_instance = job_state
.rollback
.as_ref()
.map(|rollback| rollback.instance.clone())
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Shared instance update rollback state is missing"
.to_string(),
)
})?;
let staging_dir = recovery::prepare_shared_instance_update_backup(
job_id,
&rollback_instance,
state,
)
.await?;
job_state.paths.staging_dir = Some(staging_dir);
let record = store::update_state(job_id, job_state, state).await?;
emit_install_job(&record.snapshot()).await?;
let disabled_project_ids =
disabled_project_ids(&instance_id, state).await?;
Box::pin(apply_shared_instance_update(
job_id,
job_state,
state,
&instance_id,
&data,
))
.await?;
restore_disabled_projects(
&instance_id,
disabled_project_ids,
state,
)
.await?;
finalize_shared_instance_attachment(&instance_id, &data, state)
.await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
Ok(Some(instance_id))
}
}
}
@@ -714,6 +871,20 @@ async fn apply_post_install_edit(
Ok(())
}
async fn disabled_project_ids(
instance_id: &str,
state: &State,
) -> crate::Result<HashSet<String>> {
Ok(crate::state::instances::commands::list_project_files(
instance_id,
state,
)
.await?
.into_iter()
.filter_map(|file| (!file.enabled).then_some(file.project_id?))
.collect())
}
async fn remove_existing_pack_content(
job_id: Uuid,
job_state: &InstallJobState,
@@ -873,7 +1044,7 @@ async fn restore_disabled_projects(
Ok(())
}
async fn install_pack(
pub(super) async fn install_pack(
job_id: Uuid,
job_state: &mut InstallJobState,
location: CreatePackLocation,
@@ -927,12 +1098,12 @@ async fn install_pack(
}
};
install_zipped_mrpack_files_with_reporter(
Box::pin(install_zipped_mrpack_files_with_reporter(
create_pack,
false,
reason,
reporter,
)
))
.await?;
Ok(())
@@ -954,6 +1125,12 @@ async fn prepare_existing_rollback(
"Unknown instance {instance_id}"
))
})?;
if instance.quarantined {
return Err(crate::ErrorKind::InputError(
"Content in quarantined instances cannot be changed.".to_string(),
)
.into());
}
let install_stage = instance.instance.install_stage;
set_display(
job_state,
@@ -968,6 +1145,26 @@ async fn prepare_existing_rollback(
instance_id: instance_id.to_string(),
};
Ok(())
}
async fn lock_existing_instance_if_needed(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
lock_existing_instance(instance_id, state).await?;
}
Ok(())
}
async fn lock_existing_instance(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::MinecraftInstalling,
@@ -979,7 +1176,26 @@ async fn prepare_existing_rollback(
Ok(())
}
async fn update_progress(
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
state: &State,
@@ -992,6 +1208,25 @@ async fn update_progress(
Ok(())
}
pub(super) async fn update_content_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
state: &State,
current: u64,
total: u64,
) -> crate::Result<()> {
job_state.progress.phase = InstallPhaseId::DownloadingContent;
job_state.progress.progress = Some(InstallProgress {
current,
total,
secondary: None,
});
job_state.progress.details = InstallPhaseDetails::Empty;
let record = store::update_state(job_id, job_state, state).await?;
emit_install_job(&record.snapshot()).await?;
Ok(())
}
fn set_instance_id(job_state: &mut InstallJobState, instance_id: String) {
job_state.target = match &job_state.target {
InstallTarget::ExistingInstance { .. } => {
@@ -1036,12 +1271,16 @@ fn install_error_view(
error: &crate::Error,
context: Option<InstallErrorContext>,
) -> InstallErrorView {
InstallErrorView::from_error(
let mut view = InstallErrorView::from_error(
install_error_code(phase, error),
phase,
error,
context,
)
);
if let ErrorKind::SharedInstanceUnavailable(reason) = error.raw.as_ref() {
view.reason = Some(*reason);
}
view
}
fn install_error_code(
@@ -1051,6 +1290,9 @@ fn install_error_code(
use InstallPhaseId::*;
match error.raw.as_ref() {
ErrorKind::SharedInstanceUnavailable(_) => {
"shared_instance_unavailable"
}
ErrorKind::InputError(_) => match phase {
PreparingInstance | Finalizing => "instance_error",
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
@@ -1079,6 +1321,8 @@ fn install_error_code(
},
ErrorKind::FetchError(_)
| ErrorKind::ApiIsDownError(_)
| ErrorKind::WSError(_)
| ErrorKind::WSClosedError(_)
| ErrorKind::Ratelimited { .. } => "network_error",
ErrorKind::Any(_)
if matches!(
@@ -1123,7 +1367,9 @@ fn current_instance_id(job_state: &InstallJobState) -> Option<String> {
}
}
fn modpack_details(location: &CreatePackLocation) -> InstallPhaseDetails {
pub(super) fn modpack_details(
location: &CreatePackLocation,
) -> InstallPhaseDetails {
match location {
CreatePackLocation::FromVersionId {
project_id,
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -290,6 +290,7 @@ pub async fn install_minecraft_with_reporter(
};
let state = State::get().await?;
let previous_install_stage = instance.install_stage;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
@@ -299,6 +300,7 @@ pub async fn install_minecraft_with_reporter(
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
let result = async {
let instance_path = get_instance_full_path(&instance.path).await?;
if let Some(reporter) = &reporter {
reporter
@@ -618,7 +620,34 @@ pub async fn install_minecraft_with_reporter(
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
Ok(())
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
if let Err(error) =
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
previous_install_stage,
&state.pool,
)
.await
{
tracing::error!(
"Failed to restore install stage for instance {}: {error}",
instance.id
);
} else if let Err(error) =
emit_instance(&instance.id, InstancePayloadType::Edited).await
{
tracing::error!(
"Failed to emit restored install stage for instance {}: {error}",
instance.id
);
}
}
result
}
pub async fn install_minecraft_for_instance_id_with_reporter(
+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,
+11 -6
View File
@@ -915,13 +915,10 @@ pub async fn write_cached_icon(
bytes: Bytes,
semaphore: &IoSemaphore,
) -> crate::Result<PathBuf> {
let extension = Path::new(&icon_path).extension().and_then(OsStr::to_str);
let hash = sha1_async(bytes.clone()).await?;
let path = cache_dir.join("icons").join(if let Some(ext) = extension {
format!("{hash}.{ext}")
} else {
hash
});
let path = cache_dir
.join("icons")
.join(cached_icon_file_name(icon_path, &hash));
write(&path, &bytes, semaphore).await?;
@@ -929,6 +926,14 @@ pub async fn write_cached_icon(
Ok(path)
}
fn cached_icon_file_name(icon_path: &str, hash: &str) -> String {
let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path);
match Path::new(path).extension().and_then(OsStr::to_str) {
Some(extension) => format!("{hash}.{extension}"),
None => hash.to_string(),
}
}
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
let hash = tokio::task::spawn_blocking(move || {
sha1_smol::Sha1::from(bytes).hexdigest()
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+2
View File
@@ -118,6 +118,7 @@ import _EyeOffIcon from './icons/eye-off.svg?component'
import _FileIcon from './icons/file.svg?component'
import _FileArchiveIcon from './icons/file-archive.svg?component'
import _FileCodeIcon from './icons/file-code.svg?component'
import _FileCogIcon from './icons/file-cog.svg?component'
import _FileImageIcon from './icons/file-image.svg?component'
import _FilePlusIcon from './icons/file-plus.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
@@ -549,6 +550,7 @@ export const EyeOffIcon = _EyeOffIcon
export const FileIcon = _FileIcon
export const FileArchiveIcon = _FileArchiveIcon
export const FileCodeIcon = _FileCodeIcon
export const FileCogIcon = _FileCogIcon
export const FileImageIcon = _FileImageIcon
export const FilePlusIcon = _FilePlusIcon
export const FileTextIcon = _FileTextIcon
+25
View File
@@ -0,0 +1,25 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-file-cog"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.85 22H18a2 2 0 0 0 2-2V8a2 2 0 0 0-.586-1.414l-4-4A2 2 0 0 0 14 2H6a2 2 0 0 0-2 2v6.6" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="m3.305 19.53.923-.382" />
<path d="m4.228 16.852-.924-.383" />
<path d="m5.852 15.228-.383-.923" />
<path d="m5.852 20.772-.383.924" />
<path d="m8.148 15.228.383-.923" />
<path d="m8.53 21.696-.382-.924" />
<path d="m9.773 16.852.922-.383" />
<path d="m9.773 19.148.922.383" />
<circle cx="7" cy="18" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 770 B

+2
View File
@@ -11,6 +11,7 @@ import './omorphia.scss'
import _FourOhFourNotFound from './branding/404.svg?component'
// Branding
import _BrowserWindowSuccessIllustration from './branding/illustrations/browser-window-success.svg?component'
import _InviteBackgroundIllustration from './branding/illustrations/invite-bg.webp?url'
import _ModrinthIcon from './branding/logo.svg?component'
import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component'
import _AngryRinthbot from './branding/rinthbot/angry.webp'
@@ -84,6 +85,7 @@ import _NoTasksIllustration from './illustrations/no-tasks.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
export const InviteBackgroundIllustration = _InviteBackgroundIllustration
export const FourOhFourNotFound = _FourOhFourNotFound
export const ModrinthPlusIcon = _ModrinthPlusIcon
export const AngryRinthbot = _AngryRinthbot
+4 -3
View File
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Report, Thread, User, Version } from '@modrinth/utils'
import type { Labrinth, SharedInstances } from '@modrinth/api-client'
import type { Thread, User, Version } from '@modrinth/utils'
export interface OwnershipTarget {
name: string
@@ -8,11 +8,12 @@ export interface OwnershipTarget {
type: 'user' | 'organization'
}
export interface ExtendedReport extends Report {
export interface ExtendedReport extends Labrinth.Reports.v3.Report {
thread: Thread
reporter_user: User
project?: Labrinth.Projects.v2.Project
user?: User
version?: Version
target?: OwnershipTarget
shared_instance?: SharedInstances.Instances.v1.Instance
}
+43
View File
@@ -13,6 +13,20 @@ use typed_path::{
#[repr(transparent)]
pub struct SafeRelativeUtf8UnixPathBuf(Utf8UnixPathBuf);
pub fn is_safe_file_name(file_name: &str) -> bool {
if file_name.contains('/') || file_name.contains('\\') {
return false;
}
SafeRelativeUtf8UnixPathBuf::try_from(file_name.to_string()).is_ok_and(
|path| {
path.components()
.exactly_one()
.is_ok_and(|component| component.is_normal())
},
)
}
impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf {
fn deserialize<D: Deserializer<'de>>(
deserializer: D,
@@ -146,3 +160,32 @@ fn safe_relative_path_deserialization_contract() {
.expect_err("Path should be considered invalid");
}
}
#[test]
fn safe_file_name_contract() {
let valid_file_names = [
"file.txt",
"file.name.with.dots.tar.gz",
"file..name.jar",
"123_456-789.file",
];
for file_name in valid_file_names {
assert!(is_safe_file_name(file_name));
}
let invalid_file_names = [
"",
".",
"..",
"../file.txt",
"directory/file.txt",
r"..\file.txt",
r"directory\file.txt",
"C:file.txt",
"C:/file.txt",
"NUL.txt",
];
for file_name in invalid_file_names {
assert!(!is_safe_file_name(file_name));
}
}
+48 -15
View File
@@ -9,25 +9,47 @@
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
<div
class="col-start-2 min-w-0"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
: 'flex flex-1 flex-col gap-2'
"
>
<div
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
class="flex min-w-0 flex-1 flex-col gap-2"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'admonition-inline-content'
: ''
"
>
<slot name="header">{{ header }}</slot>
<span
v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
<div
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
>
<ClockIcon class="size-4" />
{{ relativeTimeLabel }}
</span>
<slot name="header">{{ header }}</slot>
<span
v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
>
<ClockIcon class="size-4" />
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
</div>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
<div
v-if="inlineActions && !showActionsUnderneath && $slots.actions"
class="ml-auto flex shrink-0 items-center justify-end self-center"
>
<slot name="actions" />
</div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
<div v-else-if="showActionsUnderneath || $slots.actions" class="mt-2">
<slot name="actions" />
</div>
</div>
@@ -80,9 +102,10 @@ import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning' | 'neutral'
header?: string
body?: string
inlineActions?: boolean
showActionsUnderneath?: boolean
dismissible?: boolean
progress?: number
@@ -95,6 +118,7 @@ const props = withDefaults(
type: 'info',
header: '',
body: '',
inlineActions: false,
showActionsUnderneath: false,
dismissible: false,
progress: undefined,
@@ -143,6 +167,7 @@ const typeClasses = {
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
moderation: 'border-brand-orange bg-bg-orange',
neutral: 'border-surface-4 bg-surface-3',
}
const iconClasses = {
@@ -152,6 +177,7 @@ const iconClasses = {
critical: 'text-brand-red',
success: 'text-brand-green',
moderation: 'text-brand-orange',
neutral: 'text-secondary',
}
const buttonColors = {
@@ -161,6 +187,7 @@ const buttonColors = {
critical: 'red',
success: 'green',
moderation: 'orange',
neutral: 'standard',
} as const
const progressTrackClasses = {
@@ -170,6 +197,7 @@ const progressTrackClasses = {
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
moderation: 'bg-brand-orange/20',
neutral: 'bg-surface-4',
}
const progressFillClasses = {
@@ -181,10 +209,15 @@ const progressFillClasses = {
blue: 'bg-brand-blue',
green: 'bg-brand-green',
red: 'bg-brand-red',
neutral: 'bg-surface-5',
}
</script>
<style scoped>
.admonition-inline-content {
min-width: min(100%, 16rem);
}
.admonition-progress--waiting {
animation: admonition-progress-waiting 1s linear infinite;
position: relative;
@@ -34,6 +34,7 @@
</div>
<div class="ml-2 flex shrink-0 items-center gap-4">
<button
v-if="showSize"
type="button"
class="hidden w-[92px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
@@ -52,6 +53,7 @@
/>
</button>
<button
v-if="showModified"
type="button"
class="hidden w-[132px] appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left font-semibold hover:text-primary sm:flex"
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
@@ -90,8 +92,11 @@
{{ formatMessage(messages.parentFolder) }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
<span class="hidden w-[132px] text-left text-sm text-secondary sm:block" />
<span v-if="showSize" class="hidden w-[92px] text-left text-sm text-secondary sm:block" />
<span
v-if="showModified"
class="hidden w-[132px] text-left text-sm text-secondary sm:block"
/>
<span class="size-4 shrink-0" aria-hidden="true" />
</div>
</div>
@@ -140,10 +145,16 @@
{{ entry.name }}
</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block">
<span
v-if="showSize"
class="hidden w-[92px] truncate text-left text-sm text-secondary sm:block"
>
{{ formatSize(entry) }}
</span>
<span class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block">
<span
v-if="showModified"
class="hidden w-[132px] truncate text-left text-sm text-secondary sm:block"
>
{{ formatModified(entry) }}
</span>
<ChevronRightIcon
@@ -164,8 +175,8 @@
<span class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm font-medium opacity-0">.</span>
<div class="ml-2 flex shrink-0 items-center gap-4">
<span class="hidden w-[92px] text-left text-sm sm:block" />
<span class="hidden w-[132px] text-left text-sm sm:block" />
<span v-if="showSize" class="hidden w-[92px] text-left text-sm sm:block" />
<span v-if="showModified" class="hidden w-[132px] text-left text-sm sm:block" />
<span class="size-4 shrink-0" />
</div>
</div>
@@ -262,10 +273,14 @@ const props = withDefaults(
defineProps<{
items: FileTreeSelectItem[]
modelValue: string[]
showSize?: boolean
showModified?: boolean
}>(),
{
items: () => [],
modelValue: () => [],
showSize: true,
showModified: true,
},
)
@@ -1,7 +1,7 @@
<template>
<div
ref="metadata"
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2"
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-[1.625rem] gap-y-2"
>
<slot />
</div>
@@ -1,6 +1,10 @@
<template>
<div :class="rootClass" data-page-header-metadata-item v-bind="$attrs">
<BulletDivider class="page-header-metadata-item-divider shrink-0" />
<span
class="page-header-metadata-item-divider absolute right-full flex h-full w-[1.625rem] items-center justify-center"
>
<BulletDivider class="shrink-0" />
</span>
<AutoLink
v-if="to && !disabled"
v-tooltip="tooltip"
@@ -72,7 +76,7 @@ const props = withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
const defaultIconClass = 'block size-5 shrink-0 text-current'
const baseClass =
'flex min-w-0 items-center gap-2 font-medium leading-none text-secondary text-nowrap'
'relative flex min-w-0 items-center font-medium leading-none text-secondary text-nowrap'
const contentBaseClass = 'inline-flex min-w-0 items-center gap-2 text-inherit'
const interactiveClass = 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline'
+1
View File
@@ -14,6 +14,7 @@ export * from './project'
export * from './search'
export * from './servers'
export * from './settings'
export * from './sharing'
export * from './skin'
export * from './user'
export * from './version'
@@ -1,111 +0,0 @@
<template>
<NewModal ref="modal" header="Install to play" :closable="true">
<div class="flex flex-col gap-4 max-w-[500px]">
<Admonition type="info" header="Shared server instance">
This server requires modded content to play. Accept to install the needed files from
Modrinth.
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
shared this instance with you today.
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Shared instance</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
<template v-if="modCount"> · {{ modCount }} mods </template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
Decline
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<CheckIcon />
Accept
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import type { Project } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useVIntl } from '../../composables'
import { formatLoader } from '../../utils'
import Admonition from '../base/Admonition.vue'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import NewModal from './NewModal.vue'
const props = defineProps<{
project: Project
sharedBy?: {
name: string
icon_url?: string
}
modCount?: number
}>()
const emit = defineEmits<{
accept: []
decline: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
function handleAccept() {
// TODO: Implement accept logic
emit('accept')
modal.value?.hide()
}
function handleDecline() {
emit('decline')
modal.value?.hide()
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
+14 -4
View File
@@ -89,11 +89,11 @@
ref="scrollContainer"
data-modal-content
:class="[
'flex-1 min-h-0',
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
'flex-1 min-h-0 overflow-y-auto',
props.noPadding ? '' : 'p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
:style="{ maxHeight: maxContentHeight }"
@scroll="checkScrollState"
>
<slot> You just lost the game.</slot>
@@ -231,6 +231,7 @@ const visible = ref(false)
const stackDepth = ref(0)
const modalBodyRef = ref<HTMLElement | null>(null)
let previousFocusEl: Element | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
@@ -244,6 +245,10 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
props.onShow?.()
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
@@ -292,8 +297,9 @@ function hide() {
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
hideTimeout = setTimeout(() => {
open.value = false
hideTimeout = null
nextTick(() => props.onAfterHide?.())
}, 300)
}
@@ -340,6 +346,10 @@ function resetMousePosition() {
}
onUnmounted(() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -1,6 +1,5 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
@@ -18,6 +18,7 @@
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:action-loading="toastActionLoading(item.id)"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
@@ -29,7 +30,7 @@
:progress-type="item.toast.progressType"
:progress-current="item.toast.progressCurrent"
:progress-total="item.toast.progressTotal"
@accept="handleToastAction(item, item.toast.onAccept)"
@accept="handleToastAccept(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@@ -167,7 +168,7 @@ import {
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import {
@@ -189,11 +190,13 @@ const hasModalActive = computed(() => stackCount.value > 0)
const notificationGroupStyle = computed(() => ({
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
}))
const activeToastActions = ref<Record<string, 'accept'>>({})
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
function isDownloadNotification(item: PopupNotification) {
return (
@@ -265,6 +268,26 @@ async function handleToastAction(item: PopupNotification, action?: () => void |
await action?.()
}
async function handleToastAccept(item: PopupNotification, action?: () => void | Promise<void>) {
if (toastActionLoading(item.id) != null) return
const actionId = String(item.id)
popupNotificationManager.stopNotificationTimer(item)
activeToastActions.value = {
...activeToastActions.value,
[actionId]: 'accept',
}
try {
await action?.()
} finally {
activeToastActions.value = Object.fromEntries(
Object.entries(activeToastActions.value).filter(([key]) => key !== actionId),
)
popupNotificationManager.removeNotification(item.id)
}
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
@@ -4,11 +4,11 @@
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
:src="inviteAvatarUrl"
:alt="inviteAvatarLabel"
:tint-by="inviteAvatarLabel"
size="44px"
circle
:circle="inviteAvatarCircle"
no-shadow
class="border border-solid border-surface-5"
/>
@@ -35,20 +35,17 @@
>.
</template>
<template v-else>
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="24px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-[7px]"
/>
<span class="min-w-0 truncate font-semibold text-contrast">{{
entityLabel
}}</span> </span
>.
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
:tint-by="entityLabel"
size="28px"
no-shadow
raised
class="inline-block !rounded-lg align-middle"
/>
<span class="ml-1 font-semibold text-contrast">{{ entityLabel }}</span>
<span> instance.</span>
</template>
</template>
</p>
@@ -65,10 +62,17 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
<button :disabled="actionLoading != null" @click="$emit('accept')">
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
<CheckIcon v-else />
Accept
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
<button :disabled="actionLoading != null" @click="$emit('decline')">
<XIcon />
Decline
</button>
</ButtonStyled>
</div>
</div>
@@ -175,7 +179,7 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { useFormatBytes, useFormatNumber } from '../../composables'
@@ -190,10 +194,12 @@ type NotificationToastType =
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
type NotificationToastAction = 'accept'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actionLoading?: NotificationToastAction | null
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
@@ -209,6 +215,7 @@ const props = withDefaults(
actions?: PopupNotificationButton[]
}>(),
{
actionLoading: null,
actorName: null,
actorAvatarUrl: null,
entityName: '',
@@ -239,6 +246,9 @@ const isInviteNotification = computed(
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
const inviteAvatarUrl = computed(() => props.actorAvatarUrl)
const inviteAvatarLabel = computed(() => actorLabel.value)
const inviteAvatarCircle = computed(() => true)
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
const progressPercent = computed(() => Math.round(progressValue.value * 100))
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
@@ -250,7 +260,7 @@ const inviteActionText = computed(() => {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
return 'invited you to'
})
const resolvedStatusText = computed(() => {
@@ -0,0 +1,2 @@
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
export * from './invite-players-modal/types'
@@ -0,0 +1,393 @@
<template>
<NewModal
ref="modal"
:header="header"
width="min(34rem, calc(100vw - 2rem))"
max-width="34rem"
no-padding
noblur
>
<div class="flex max-h-[calc(100vh-8rem)] min-h-0 flex-col">
<div class="border-0 border-b border-solid border-surface-5 p-6">
<div class="flex items-start gap-2">
<Combobox
:key="searchInputKey"
:model-value="undefined"
:options="searchOptions"
:search-value="searchTarget"
:search-placeholder="searchPlaceholderLabel"
:placeholder="searchPlaceholderLabel"
:no-options-message="searchLookupMessage"
:min-search-length-to-open="searchMinimumLength"
:disable-search-filter="usesRemoteSearch"
class="min-w-0 flex-1"
searchable
show-search-icon
:show-chevron="false"
search-type="search"
search-name="modrinth-player-invite-search"
search-inputmode="search"
search-autocomplete="new-password"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
:search-input-attrs="passwordManagerIgnoreAttrs"
@search-input="handleSearchInput"
@select="handleSearchSelect"
>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="findSearchUser(item.value)?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username: item.label })"
:tint-by="item.label"
size="1.5rem"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-semibold"
:class="isSelected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
<ButtonStyled color="brand">
<button
v-tooltip="searchInviteTooltip"
class="shrink-0"
:disabled="!canInviteSearchTarget"
@click="inviteSearchTarget"
>
<PlusIcon aria-hidden="true" />
{{ addButtonLabel }}
</button>
</ButtonStyled>
</div>
</div>
<div class="min-h-[11rem] overflow-y-auto bg-surface-2 px-6 py-4">
<div class="mb-2 text-base font-semibold text-primary">
{{ friendsHeading }}
</div>
<div
v-if="friends.length === 0"
class="flex min-h-32 items-center justify-center text-secondary"
>
{{ emptyFriendsLabel }}
</div>
<div v-else class="-mx-6 flex flex-col">
<InvitePlayersModalUserRow
v-for="friend in sortedFriends"
:key="friend.id"
:user="friend"
:avatar-alt="formatMessage(messages.avatarAlt, { username: friend.username })"
:added-label="addedButtonLabel"
:cancel-label="cancelButtonLabel"
:invite-label="inviteButtonLabel"
:requested-label="requestedButtonLabel"
:requested-tooltip="requestedTooltip(friend.username)"
:user-profile-link="userProfileLink"
@invite="inviteFriend"
@cancel="cancelInvite"
/>
</div>
</div>
<div v-if="link" class="border-0 border-t border-solid border-surface-5 p-6">
<div class="flex flex-col gap-2">
<div class="text-base font-semibold text-contrast">
{{ inviteLinkHeading }}
</div>
<ButtonStyled>
<button
type="button"
class="!h-10 w-full !justify-between !px-4 text-left !shadow-none"
@click="copyInviteLink"
>
<span class="min-w-0 truncate text-base font-semibold text-primary">
{{ link }}
</span>
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
</ButtonStyled>
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
{{ linkExpiryDescription }}
<button
v-if="updateInviteLink"
type="button"
class="cursor-pointer border-0 bg-transparent p-0 text-base font-medium text-blue hover:underline"
@click="inviteLinkEditor?.show()"
>
{{ formatMessage(messages.editInviteLink) }}
</button>
</p>
</div>
</div>
</div>
</NewModal>
<InvitePlayersModalInviteLinkEditor
v-if="updateInviteLink"
ref="inviteLinkEditor"
:link-expires-at="linkExpiresAt"
:link-max-uses="linkMaxUses"
:update-invite-link="updateInviteLink"
/>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, PlusIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox from '../../base/Combobox.vue'
import NewModal from '../../modal/NewModal.vue'
import InvitePlayersModalInviteLinkEditor from './invite-players-modal-invite-link-editor.vue'
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
import type {
InviteLinkSettings,
InvitePlayersInvitePayload,
InvitePlayersSearchUser,
InvitePlayersUser,
InvitePlayersUserProfileLink,
} from './types'
import { useInvitePlayersSearch } from './use-invite-players-search'
const props = withDefaults(
defineProps<{
header?: string
friends?: InvitePlayersUser[]
suggestions?: InvitePlayersSearchUser[]
searchUsers?: (query: string) => Promise<InvitePlayersSearchUser[]>
link?: string
linkExpiresAt?: string | Date | null
linkMaxUses?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
friendsLabel?: string
searchPlaceholder?: string
addLabel?: string
inviteLabel?: string
addedLabel?: string
cancelLabel?: string
requestedLabel?: string
emptyFriendsLabel?: string
canInvite?: boolean
inviteDisabledMessage?: string
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
}>(),
{
header: 'Share instance',
friends: () => [],
suggestions: () => [],
canInvite: true,
linkMaxUses: 10,
},
)
const emit = defineEmits<{
invite: [payload: InvitePlayersInvitePayload]
cancel: [user: InvitePlayersUser]
'copy-link': [link: string]
}>()
const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const inviteLinkEditor = ref<InstanceType<typeof InvitePlayersModalInviteLinkEditor> | null>(null)
const messages = defineMessages({
friendsHeading: {
id: 'sharing.invite-players-modal.friends-heading',
defaultMessage: 'Your friends - {count}',
},
searchPlaceholder: {
id: 'sharing.invite-players-modal.search-placeholder',
defaultMessage: 'Enter Modrinth username',
},
addButton: {
id: 'sharing.invite-players-modal.add',
defaultMessage: 'Add',
},
inviteButton: {
id: 'sharing.invite-players-modal.invite',
defaultMessage: 'Invite',
},
addedButton: {
id: 'sharing.invite-players-modal.added',
defaultMessage: 'Added',
},
cancelButton: {
id: 'sharing.invite-players-modal.cancel',
defaultMessage: 'Cancel',
},
requestedButton: {
id: 'sharing.invite-players-modal.requested',
defaultMessage: 'Request sent',
},
requestedTooltip: {
id: 'sharing.invite-players-modal.requested-tooltip',
defaultMessage: '{username} needs to accept your friend request first',
},
noFriends: {
id: 'sharing.invite-players-modal.no-friends',
defaultMessage: 'No friends found.',
},
noSearchResults: {
id: 'sharing.invite-players-modal.no-search-results',
defaultMessage: 'No matching users found.',
},
searching: {
id: 'sharing.invite-players-modal.searching',
defaultMessage: 'Searching...',
},
alreadyInvited: {
id: 'sharing.invite-players-modal.already-invited',
defaultMessage: 'This user has already been invited.',
},
inviteLinkHeading: {
id: 'sharing.invite-players-modal.invite-link-heading',
defaultMessage: 'Or use an invite link',
},
inviteExpiryDescription: {
id: 'sharing.invite-players-modal.invite-expiry-description',
defaultMessage: 'Your invite link expires in {duration}.',
},
editInviteLink: {
id: 'sharing.invite-players-modal.edit-invite-link',
defaultMessage: 'Edit invite link.',
},
linkCopiedTitle: {
id: 'sharing.invite-players-modal.link-copied-title',
defaultMessage: 'Link copied',
},
linkCopiedText: {
id: 'sharing.invite-players-modal.link-copied-text',
defaultMessage: 'The invite link has been copied to your clipboard.',
},
linkCopyFailedTitle: {
id: 'sharing.invite-players-modal.link-copy-failed-title',
defaultMessage: 'Failed to copy link',
},
avatarAlt: {
id: 'sharing.invite-players-modal.avatar-alt',
defaultMessage: "{username}'s avatar",
},
})
const friendsHeading = computed(
() =>
props.friendsLabel ??
formatMessage(messages.friendsHeading, {
count: props.friends.length,
}),
)
const searchPlaceholderLabel = computed(
() => props.searchPlaceholder ?? formatMessage(messages.searchPlaceholder),
)
const addButtonLabel = computed(() => props.addLabel ?? formatMessage(messages.addButton))
const inviteButtonLabel = computed(() => props.inviteLabel ?? formatMessage(messages.inviteButton))
const addedButtonLabel = computed(() => props.addedLabel ?? formatMessage(messages.addedButton))
const cancelButtonLabel = computed(() => props.cancelLabel ?? formatMessage(messages.cancelButton))
const requestedButtonLabel = computed(
() => props.requestedLabel ?? formatMessage(messages.requestedButton),
)
const requestedTooltip = (username: string) =>
formatMessage(messages.requestedTooltip, {
username,
})
const emptyFriendsLabel = computed(
() => props.emptyFriendsLabel ?? formatMessage(messages.noFriends),
)
const inviteLinkHeading = computed(() => formatMessage(messages.inviteLinkHeading))
const linkExpiryDescription = computed(() => {
if (!props.linkExpiresAt) return ''
const expiresAt = new Date(props.linkExpiresAt)
if (Number.isNaN(expiresAt.getTime())) return ''
const hours = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 3_600_000))
const duration =
hours < 48 ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${Math.ceil(hours / 24)} days`
return formatMessage(messages.inviteExpiryDescription, { duration })
})
const inviteDisabledMessage = computed(
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
)
const {
searchTarget,
searchInputKey,
searchOptions,
searchLookupMessage,
searchMinimumLength,
usesRemoteSearch,
passwordManagerIgnoreAttrs,
sortedFriends,
canInviteSearchTarget,
searchInviteTooltip,
findSearchUser,
handleSearchInput,
handleSearchSelect,
inviteSearchTarget,
resetSearch,
} = useInvitePlayersSearch({
friends: () => props.friends,
suggestions: () => props.suggestions,
searchUsers: () => props.searchUsers,
canInvite: () => props.canInvite,
inviteDisabledMessage,
alreadyInvitedMessage: () => formatMessage(messages.alreadyInvited),
searchingMessage: () => formatMessage(messages.searching),
noResultsMessage: () => formatMessage(messages.noSearchResults),
onInvite: (payload) => emit('invite', payload),
})
function inviteFriend(friend: InvitePlayersUser) {
emit('invite', {
user: friend,
source: 'friend',
})
}
function cancelInvite(friend: InvitePlayersUser) {
emit('cancel', friend)
}
async function copyInviteLink() {
if (!props.link) return
emit('copy-link', props.link)
try {
await navigator.clipboard.writeText(props.link)
notificationManager?.addNotification({
type: 'success',
title: formatMessage(messages.linkCopiedTitle),
text: formatMessage(messages.linkCopiedText),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
notificationManager?.addNotification({
type: 'error',
title: formatMessage(messages.linkCopyFailedTitle),
text: message,
})
}
}
function show(event?: MouseEvent) {
resetSearch()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

Some files were not shown because too many files have changed in this diff Show More