mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,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/
|
||||
|
||||
Generated
+296
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
Generated
-248
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
+23
-5
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
Generated
+296
@@ -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"
|
||||
}
|
||||
Generated
+296
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
Generated
-248
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
Generated
-248
@@ -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"
|
||||
}
|
||||
Generated
-12
@@ -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"
|
||||
}
|
||||
Generated
-248
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤t.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(¤t.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()
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
¤t_version_ids,
|
||||
¤t_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,
|
||||
¤t_version_ids,
|
||||
¤t_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(¤t, &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
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user