Merge branch 'main' into boris/dev-1126-v2
@@ -3,6 +3,7 @@ export * from './backups/v1'
|
||||
export * from './backups-queue/v1'
|
||||
export * from './content/v1'
|
||||
export * from './properties/v1'
|
||||
export * from './servers/internal'
|
||||
export * from './servers/v0'
|
||||
export * from './servers/v1'
|
||||
export * from './types'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonServersInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_servers_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server assigned to a subdomain.
|
||||
* GET /_internal/servers/by-subdomain/:subdomain
|
||||
*/
|
||||
public async getBySubdomain(subdomain: string): Promise<Archon.Servers.Internal.Lookup> {
|
||||
return this.client.request<Archon.Servers.Internal.Lookup>(
|
||||
`/servers/by-subdomain/${encodeURIComponent(subdomain)}`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -573,6 +573,12 @@ export namespace Archon {
|
||||
}
|
||||
|
||||
export namespace Servers {
|
||||
export namespace Internal {
|
||||
export type Lookup = {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v0 {
|
||||
export type ServerGetResponse = {
|
||||
servers: Server[]
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ArchonNoticesV0Module } from './archon/notices/v0'
|
||||
import { ArchonOptionsV1Module } from './archon/options/v1'
|
||||
import { ArchonPropertiesV1Module } from './archon/properties/v1'
|
||||
import { ArchonServerUsersV1Module } from './archon/server-users/v1'
|
||||
import { ArchonServersInternalModule } from './archon/servers/internal'
|
||||
import { ArchonServersV0Module } from './archon/servers/v0'
|
||||
import { ArchonServersV1Module } from './archon/servers/v1'
|
||||
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
|
||||
@@ -87,6 +88,7 @@ export const MODULE_REGISTRY = {
|
||||
archon_options_v1: ArchonOptionsV1Module,
|
||||
archon_properties_v1: ArchonPropertiesV1Module,
|
||||
archon_server_users_v1: ArchonServerUsersV1Module,
|
||||
archon_servers_internal: ArchonServersInternalModule,
|
||||
archon_servers_v0: ArchonServersV0Module,
|
||||
archon_servers_v1: ArchonServersV1Module,
|
||||
archon_transfers_internal: ArchonTransfersInternalModule,
|
||||
|
||||
@@ -1355,8 +1355,8 @@ export namespace Labrinth {
|
||||
}
|
||||
|
||||
export type ProjectDisclosureData = ProjectDisclosure & {
|
||||
set_by_moderator: boolean
|
||||
lock_status: DisclosureLockStatus
|
||||
set_by_moderator?: boolean | null
|
||||
lock_status?: DisclosureLockStatus | null
|
||||
updated_at: string
|
||||
updated_by?: string | null
|
||||
deleted_at?: string | null
|
||||
@@ -1713,6 +1713,70 @@ export namespace Labrinth {
|
||||
export type Role = Common.Role
|
||||
export type AuthProvider = Common.AuthProvider
|
||||
export type UserPayoutData = Common.UserPayoutData
|
||||
export type Theme = 'light' | 'dark' | 'oled' | 'retro'
|
||||
export type LayoutOption = 'grid' | 'rows'
|
||||
export type FriendPrivacy = 'none' | 'mutual' | 'everyone'
|
||||
export type InvitePrivacy = 'none' | 'friends' | 'everyone'
|
||||
|
||||
export type AppearancePreferences = {
|
||||
auto: boolean
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export type BehaviorPreferences = {
|
||||
minimize_app: boolean
|
||||
hide_right_sidebar: boolean
|
||||
show_jump_in: boolean
|
||||
compact_instance_cards: boolean
|
||||
show_play_time: boolean
|
||||
hide_nametag: boolean
|
||||
warn_on_unknown_modpacks: boolean
|
||||
skip_non_essential_warnings: boolean
|
||||
}
|
||||
|
||||
export type LocalizationPreferences = {
|
||||
locale: string
|
||||
}
|
||||
|
||||
export type LayoutPreferences = {
|
||||
mods: LayoutOption
|
||||
plugins: LayoutOption
|
||||
datapacks: LayoutOption
|
||||
shaders: LayoutOption
|
||||
resourcepacks: LayoutOption
|
||||
modpacks: LayoutOption
|
||||
servers: LayoutOption
|
||||
users: LayoutOption
|
||||
}
|
||||
|
||||
export type SidebarPreferences = {
|
||||
right_aligned_search: boolean
|
||||
left_aligned_content: boolean
|
||||
}
|
||||
|
||||
export type SocialPreferences = {
|
||||
friend_privacy: FriendPrivacy
|
||||
shared_instances_privacy: InvitePrivacy
|
||||
hosting_access_privacy: InvitePrivacy
|
||||
}
|
||||
|
||||
export type UserPreferences = {
|
||||
appearance: AppearancePreferences
|
||||
behavior: BehaviorPreferences
|
||||
localization: LocalizationPreferences
|
||||
layouts: LayoutPreferences
|
||||
sidebars: SidebarPreferences
|
||||
social: SocialPreferences
|
||||
}
|
||||
|
||||
export type PartialUserPreferences = {
|
||||
appearance?: Partial<AppearancePreferences>
|
||||
behavior?: Partial<BehaviorPreferences>
|
||||
localization?: Partial<LocalizationPreferences>
|
||||
layouts?: Partial<LayoutPreferences>
|
||||
sidebars?: Partial<SidebarPreferences>
|
||||
social?: Partial<SocialPreferences>
|
||||
}
|
||||
|
||||
export type Pride26CampaignDonation = {
|
||||
last_donated_at: string
|
||||
@@ -1887,6 +1951,7 @@ export namespace Labrinth {
|
||||
license: string
|
||||
client_side: string
|
||||
server_side: string
|
||||
disclosure_types: string[]
|
||||
gallery: string[]
|
||||
featured_gallery: string | null
|
||||
color: number | null
|
||||
@@ -1902,7 +1967,7 @@ export namespace Labrinth {
|
||||
|
||||
export namespace v3 {
|
||||
export interface ResultSearchProject {
|
||||
version_id: string
|
||||
version_id?: string
|
||||
project_id: string
|
||||
project_types: string[]
|
||||
all_project_types: string[]
|
||||
@@ -1932,6 +1997,7 @@ export namespace Labrinth {
|
||||
minecraft_java_server?: Projects.v3.MinecraftJavaServer | null
|
||||
minecraft_bedrock_server?: Projects.v3.MinecraftBedrockServer | null
|
||||
minecraft_mod?: unknown | null
|
||||
disclosure_types: string[]
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
|
||||
@@ -37,6 +37,43 @@ export class LabrinthUsersV3Module extends AbstractModule {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's account preferences. The authenticated user may access their
|
||||
* own preferences, while moderators may access another user's preferences.
|
||||
*
|
||||
* GET /v3/user/{id}/preferences
|
||||
*/
|
||||
public async getPreferences(idOrUsername: string): Promise<Labrinth.Users.v3.UserPreferences> {
|
||||
return this.client.request<Labrinth.Users.v3.UserPreferences>(
|
||||
`/user/${encodeURIComponent(idOrUsername)}/preferences`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's account preferences and return the fully resolved result.
|
||||
*
|
||||
* PATCH /v3/user/{id}/preferences
|
||||
*/
|
||||
public async patchPreferences(
|
||||
idOrUsername: string,
|
||||
preferences: Labrinth.Users.v3.PartialUserPreferences,
|
||||
): Promise<Labrinth.Users.v3.UserPreferences> {
|
||||
return this.client.request<Labrinth.Users.v3.UserPreferences>(
|
||||
`/user/${encodeURIComponent(idOrUsername)}/preferences`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'PATCH',
|
||||
body: preferences,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by username prefix.
|
||||
*
|
||||
@@ -56,6 +93,54 @@ export class LabrinthUsersV3Module extends AbstractModule {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's projects.
|
||||
*
|
||||
* @param idOrUsername - The user's ID or username
|
||||
* @returns Promise resolving to an array of the user's projects
|
||||
*
|
||||
* GET /v3/user/{id}/projects
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const projects = await client.labrinth.users_v3.getProjects('my_user')
|
||||
* ```
|
||||
*/
|
||||
public async getProjects(idOrUsername: string): Promise<Labrinth.Projects.v3.Project[]> {
|
||||
return this.client.request<Labrinth.Projects.v3.Project[]>(
|
||||
`/user/${encodeURIComponent(idOrUsername)}/projects`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get projects a user follows.
|
||||
*
|
||||
* @param idOrUsername - The user's ID or username
|
||||
* @returns Promise resolving to an array of followed projects
|
||||
*
|
||||
* GET /v3/user/{id}/follows
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const projects = await client.labrinth.users_v3.getFollowedProjects('my_user')
|
||||
* ```
|
||||
*/
|
||||
public async getFollowedProjects(idOrUsername: string): Promise<Labrinth.Projects.v3.Project[]> {
|
||||
return this.client.request<Labrinth.Projects.v3.Project[]>(
|
||||
`/user/${encodeURIComponent(idOrUsername)}/follows`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects the authenticated user can access directly or through
|
||||
* their organizations.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_created_instance = TRUE\n WHERE id = 0 AND has_created_instance = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "04b62cdfd635acb3d700211a104706fce514b070987a4ff2047afa18ee60d237"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_groups\n\t\tWHERE instance_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "073e4c6fbf434f94c8015076b50c099d57f217b4c1f4a63761b5e07129eb1f84"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT group_name\n\t\tFROM instance_groups\n\t\tWHERE instance_id = ?\n\t\tORDER BY group_name\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "group_name",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0812e019f53d81d527ea15cbf7a6416ad15182131cfb6de3f2dab153c74a553c"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 33
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM instance_groups\n WHERE id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1a1d2e6b837d5f2b43069f4de9f5f60241f1526302015b8098ec8fd68c9743d3"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tUPDATE instance_groups\n\t\tSET name = ?\n\t\tWHERE id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "29d7a7578c6bf4a446eff7f68d1ebe2080733c323ec58f86969d9d1fdb4ba076"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n sync_theme_across_devices, sync_behavior_across_devices,\n version\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -68,105 +68,110 @@
|
||||
"ordinal": 12,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "onboarded",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "extra_launch_args",
|
||||
"ordinal": 14,
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "custom_env_vars",
|
||||
"ordinal": 15,
|
||||
"ordinal": 14,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mc_memory_max",
|
||||
"ordinal": 16,
|
||||
"ordinal": 15,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_force_fullscreen",
|
||||
"ordinal": 17,
|
||||
"ordinal": 16,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_game_resolution_x",
|
||||
"ordinal": 18,
|
||||
"ordinal": 17,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "mc_game_resolution_y",
|
||||
"ordinal": 19,
|
||||
"ordinal": 18,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "hide_on_process_start",
|
||||
"ordinal": 20,
|
||||
"ordinal": 19,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "hook_pre_launch",
|
||||
"ordinal": 21,
|
||||
"ordinal": 20,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hook_wrapper",
|
||||
"ordinal": 22,
|
||||
"ordinal": 21,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hook_post_exit",
|
||||
"ordinal": 23,
|
||||
"ordinal": 22,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "custom_dir",
|
||||
"ordinal": 24,
|
||||
"ordinal": 23,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "prev_custom_dir",
|
||||
"ordinal": 25,
|
||||
"ordinal": 24,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "migrated",
|
||||
"ordinal": 26,
|
||||
"ordinal": 25,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "feature_flags",
|
||||
"ordinal": 27,
|
||||
"ordinal": 26,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 28,
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 29,
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 30,
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "auto_download_updates",
|
||||
"ordinal": 30,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "sync_theme_across_devices",
|
||||
"ordinal": 31,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"name": "sync_behavior_across_devices",
|
||||
"ordinal": 32,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"ordinal": 33,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -186,7 +191,6 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
@@ -205,8 +209,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
|
||||
"hash": "29df6a29d9221b9bbea45e024fb26f5b6a3155788880369af940fcd268d3cb94"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE instances SET modified = unixepoch() WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4b8b302590a9ab956823789dfe9b09b1fe8be840b4a14ad2636d7bb558cc62a1"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET show_checklist = FALSE\n WHERE id = 0\n AND show_checklist = TRUE\n AND has_created_instance = TRUE\n AND has_logged_into_minecraft = TRUE\n AND has_logged_into_modrinth = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5b33d55c05ca5157e50e43ca611f3472d6e2f5349dabef91a203d8a4d938e4aa"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT groups.id\n\t\tFROM instance_group_memberships memberships\n\t\tINNER JOIN instance_groups groups\n\t\t\tON groups.id = memberships.group_id\n\t\tWHERE memberships.instance_id = ?\n\t\tORDER BY groups.name\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73a5b7a4b61a75705f6ed3ea370ec66677b42cdd09b8ae87cee0ef89d3875681"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT OR IGNORE INTO instance_groups (instance_id, group_name)\n VALUES (?, ?)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7790039c6bfeba24c9a0f515b12d06e8ca910f05a73f2b31fe80eef9033873d7"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM instance_groups WHERE id = ?) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7b17b6dc7e42a064a0f6e501d8b45eeeca9734a94f958f43a1605a38235b5fe0"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tDELETE FROM instance_group_memberships\n\t\tWHERE instance_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "85ae4979fb862c60859e2a6694d8288cd69f7defc9f9990b8c18ecf2d4f76ef3"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n sync_theme_across_devices = $32,\n sync_behavior_across_devices = $33,\n\n version = $34\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 34
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "88c6a22aa112f10dce9d42779f06c723b356263ffc83aabb645c9fb5b6d5aa7c"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n has_created_instance,\n has_logged_into_minecraft,\n has_logged_into_modrinth,\n show_checklist\n FROM onboarding_checklist\n WHERE id = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "has_created_instance",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_logged_into_minecraft",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_logged_into_modrinth",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "show_checklist",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "baa15329484896237f57ee6b585c4c7b4a5c56ddb12b7fbe2d8d63936853312e"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT instance_id\n\t\tFROM instance_group_memberships\n\t\tWHERE group_id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "instance_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c264c05f91394756ef724b6cbb0c4ab5534a7f105cf386b53ac44c5527d2e2e7"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_logged_into_minecraft = TRUE\n WHERE id = 0 AND has_logged_into_minecraft = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c715a78bfe15b58f7a14c9eaf3bb59c6d9d83486ccadae7351b1516d3387834f"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\t\tINSERT OR IGNORE INTO instance_groups (instance_id, group_name)\n\t\t\tVALUES (?, ?)\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e26d2ce1fdaaef4ef4648b57faf0ea2940ba9ada7b39f3c2fa973a36ee4cba2a"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE onboarding_checklist\n SET has_logged_into_modrinth = TRUE\n WHERE id = 0 AND has_logged_into_modrinth = FALSE\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa1954f9c9c998fe4c66cbc82bab087901087b5580012e68f96c6836b5a146ca"
|
||||
}
|
||||
@@ -9,10 +9,7 @@ ariadne = { workspace = true }
|
||||
async-minecraft-ping = { workspace = true, features = ["srv"] }
|
||||
async-recursion = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
async-tungstenite = { workspace = true, features = [
|
||||
"tokio-runtime",
|
||||
"tokio-rustls-webpki-roots"
|
||||
] }
|
||||
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
||||
async-walkdir = { workspace = true }
|
||||
async_zip = { workspace = true, features = [
|
||||
"bzip2",
|
||||
@@ -113,11 +110,7 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
||||
ts-rs = { workspace = true, features = [
|
||||
"chrono-impl",
|
||||
"no-serde-warnings",
|
||||
"uuid-impl"
|
||||
], optional = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "no-serde-warnings", "uuid-impl"], optional = true }
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings DROP COLUMN onboarded;
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE onboarding_checklist (
|
||||
id INTEGER NOT NULL CHECK (id = 0),
|
||||
has_created_instance INTEGER NOT NULL DEFAULT FALSE CHECK (has_created_instance IN (FALSE, TRUE)),
|
||||
has_logged_into_minecraft INTEGER NOT NULL DEFAULT FALSE CHECK (
|
||||
has_logged_into_minecraft IN (FALSE, TRUE)
|
||||
),
|
||||
has_logged_into_modrinth INTEGER NOT NULL DEFAULT FALSE CHECK (
|
||||
has_logged_into_modrinth IN (FALSE, TRUE)
|
||||
),
|
||||
show_checklist INTEGER NOT NULL DEFAULT TRUE CHECK (show_checklist IN (FALSE, TRUE)),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
INSERT INTO onboarding_checklist
|
||||
(
|
||||
id,
|
||||
has_created_instance,
|
||||
has_logged_into_minecraft,
|
||||
has_logged_into_modrinth,
|
||||
show_checklist
|
||||
)
|
||||
SELECT
|
||||
0,
|
||||
EXISTS (SELECT 1 FROM instances),
|
||||
EXISTS (SELECT 1 FROM minecraft_users),
|
||||
EXISTS (SELECT 1 FROM modrinth_users),
|
||||
NOT (
|
||||
EXISTS (SELECT 1 FROM instances)
|
||||
AND EXISTS (SELECT 1 FROM minecraft_users)
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
ALTER TABLE instance_groups RENAME TO legacy_instance_group_memberships;
|
||||
|
||||
CREATE TABLE instance_groups (
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE instance_group_memberships (
|
||||
instance_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (instance_id, group_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES instance_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO instance_groups (id, name, display_order)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
group_name,
|
||||
ROW_NUMBER() OVER (ORDER BY group_name) - 1
|
||||
FROM (
|
||||
SELECT DISTINCT group_name
|
||||
FROM legacy_instance_group_memberships
|
||||
);
|
||||
|
||||
INSERT INTO instance_group_memberships (instance_id, group_id)
|
||||
SELECT
|
||||
legacy.instance_id,
|
||||
groups.id
|
||||
FROM legacy_instance_group_memberships legacy
|
||||
INNER JOIN instance_groups groups ON groups.name = legacy.group_name;
|
||||
|
||||
UPDATE install_jobs
|
||||
SET state = json_remove(
|
||||
json_set(
|
||||
state,
|
||||
'$.rollback.instance.group_ids',
|
||||
json(COALESCE((
|
||||
SELECT json_group_array(groups.id)
|
||||
FROM json_each(state, '$.rollback.instance.groups') legacy_groups
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.name = legacy_groups.value
|
||||
), '[]'))
|
||||
),
|
||||
'$.rollback.instance.groups'
|
||||
)
|
||||
WHERE json_type(state, '$.rollback.instance.groups') = 'array';
|
||||
|
||||
DROP TABLE legacy_instance_group_memberships;
|
||||
|
||||
CREATE INDEX instance_groups_name ON instance_groups(name);
|
||||
CREATE INDEX instance_groups_display_order ON instance_groups(display_order);
|
||||
CREATE INDEX instance_group_memberships_group_id
|
||||
ON instance_group_memberships(group_id);
|
||||
|
||||
INSERT OR IGNORE INTO instance_groups (id, name, display_order)
|
||||
VALUES ('group:favorites', 'Favorites', -1);
|
||||
|
||||
UPDATE instance_groups
|
||||
SET name = 'Favorites', display_order = -1
|
||||
WHERE id = 'group:favorites';
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE instance_icon_configs (
|
||||
instance_id TEXT NOT NULL,
|
||||
background TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (instance_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE recent_instance_icon_configs (
|
||||
background TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
used_at INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (background, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX recent_instance_icon_configs_used_at
|
||||
ON recent_instance_icon_configs(used_at DESC);
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN sync_theme_across_devices INTEGER NOT NULL DEFAULT TRUE;
|
||||
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN sync_behavior_across_devices INTEGER NOT NULL DEFAULT TRUE;
|
||||
@@ -4,6 +4,7 @@ mod content;
|
||||
mod content_set_diff;
|
||||
mod export_mrpack;
|
||||
mod get;
|
||||
mod groups;
|
||||
mod icon;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
@@ -23,7 +24,15 @@ pub use self::export_mrpack::{
|
||||
get_pack_export_candidates, get_pack_export_candidates_for_parent,
|
||||
};
|
||||
pub use self::get::{get, get_many, list};
|
||||
pub use self::icon::edit_icon;
|
||||
pub use self::groups::{
|
||||
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
|
||||
create_group, delete_group, list_groups, rename_group,
|
||||
set_group_memberships, set_group_order,
|
||||
};
|
||||
pub use self::icon::{
|
||||
cache_generated_icon, edit_generated_icon, edit_generated_icon_if_empty,
|
||||
edit_icon, get_recent_icon_configs,
|
||||
};
|
||||
pub(crate) use self::icon::{
|
||||
cache_icon, cache_icon_from_path, migrate_legacy_icons,
|
||||
};
|
||||
|
||||
@@ -529,12 +529,12 @@ pub async fn create_mrpack_json(
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|(path, file)| match file.metadata {
|
||||
Some(metadata) => Some((path, metadata.version_id)),
|
||||
_ => None,
|
||||
.filter_map(|(path, file)| {
|
||||
file.metadata
|
||||
.map(|metadata| (path, file.hash, metadata.version_id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let version_ids = projects.iter().map(|x| &*x.1).collect::<Vec<_>>();
|
||||
let version_ids = projects.iter().map(|x| &*x.2).collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_v3_many(
|
||||
&version_ids,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
@@ -544,43 +544,38 @@ pub async fn create_mrpack_json(
|
||||
.await?;
|
||||
let files = projects
|
||||
.into_iter()
|
||||
.filter_map(|(path, version_id)| {
|
||||
if let Some(version) = versions.iter().find(|x| x.id == version_id)
|
||||
{
|
||||
let env = get_mrpack_environment(version.environment);
|
||||
let Some(primary_file) = version.files.first() else {
|
||||
return Some(Err(crate::ErrorKind::OtherError(format!(
|
||||
"No primary file found for mod at: {path}"
|
||||
))
|
||||
.as_error()));
|
||||
};
|
||||
let file_size = primary_file.size;
|
||||
let downloads = vec![primary_file.url.clone()];
|
||||
let hashes = primary_file
|
||||
.hashes
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(h1, h2)| (PackFileHash::from(h1), h2))
|
||||
.collect();
|
||||
.filter_map(|(path, hash, version_id)| {
|
||||
let version = versions.iter().find(|x| x.id == version_id)?;
|
||||
let env = get_mrpack_environment(version.environment);
|
||||
let file = version.files.iter().find(|file| {
|
||||
file.hashes
|
||||
.get("sha1")
|
||||
.is_some_and(|file_hash| file_hash == &hash)
|
||||
})?;
|
||||
let file_size = file.size;
|
||||
let downloads = vec![file.url.clone()];
|
||||
let hashes = file
|
||||
.hashes
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(h1, h2)| (PackFileHash::from(h1), h2))
|
||||
.collect();
|
||||
|
||||
Some(Ok(PackFile {
|
||||
path: match path.try_into() {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
return Some(Err(crate::ErrorKind::OtherError(
|
||||
"Invalid file path in project".into(),
|
||||
)
|
||||
.as_error()));
|
||||
}
|
||||
},
|
||||
hashes,
|
||||
env: Some(env),
|
||||
downloads,
|
||||
file_size,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(Ok(PackFile {
|
||||
path: match path.try_into() {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
return Some(Err(crate::ErrorKind::OtherError(
|
||||
"Invalid file path in project".into(),
|
||||
)
|
||||
.as_error()));
|
||||
}
|
||||
},
|
||||
hashes,
|
||||
env: Some(env),
|
||||
downloads,
|
||||
file_size,
|
||||
}))
|
||||
})
|
||||
.collect::<crate::Result<Vec<PackFile>>>()?;
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
use crate::event::emit::emit_instance_groups_changed;
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH: usize = 256;
|
||||
pub const FAVORITES_GROUP_ID: &str = "group:favorites";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroupMembershipUpdate {
|
||||
pub instance_id: String,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn validate_group_name(name: &str) -> crate::Result<&str> {
|
||||
let name = name.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be empty".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if name.eq_ignore_ascii_case("none") {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Group name cannot be None".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn normalize_membership_updates(
|
||||
mut updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
|
||||
let mut instance_ids = HashSet::new();
|
||||
for update in &mut updates {
|
||||
if !instance_ids.insert(update.instance_id.clone()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance {} in group membership update",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut group_ids = HashSet::new();
|
||||
update
|
||||
.group_ids
|
||||
.retain(|group_id| group_ids.insert(group_id.clone()));
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
|
||||
let state = State::get().await?;
|
||||
Ok(instance_rows::list_instance_groups(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name)| InstanceGroup { id, name })
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
|
||||
let name = validate_group_name(&name)?;
|
||||
let state = State::get().await?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
instance_rows::create_instance_group(&id, name, &state.pool).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
instance_rows::set_instance_group_order(&group_ids, &state.pool).await
|
||||
}
|
||||
|
||||
pub async fn set_group_memberships(
|
||||
updates: Vec<InstanceGroupMembershipUpdate>,
|
||||
) -> crate::Result<()> {
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let updates = normalize_membership_updates(updates)?;
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let unique_group_ids = updates
|
||||
.iter()
|
||||
.flat_map(|update| update.group_ids.iter())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for group_id in unique_group_ids {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM instance_groups WHERE id = ?) AS "exists!: bool""#,
|
||||
group_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
for update in &updates {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE instances SET modified = unixepoch() WHERE id = ?",
|
||||
update.instance_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance {}",
|
||||
update.instance_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
instance_rows::replace_instance_groups(
|
||||
&update.instance_id,
|
||||
&update.group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let instance_ids = updates
|
||||
.into_iter()
|
||||
.map(|update| update.instance_id)
|
||||
.collect::<Vec<_>>();
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> crate::Result<InstanceGroup> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be renamed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_name = validate_group_name(&new_name)?;
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET name = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
new_name,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(InstanceGroup {
|
||||
id,
|
||||
name: new_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be deleted".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let instance_ids = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT instance_id
|
||||
FROM instance_group_memberships
|
||||
WHERE group_id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_groups
|
||||
WHERE id = ?
|
||||
",
|
||||
id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
emit_instance_groups_changed(&instance_ids).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
|
||||
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
|
||||
validate_group_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_trims_valid_names() {
|
||||
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_empty_names() {
|
||||
assert!(validate_group_name(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_reserved_name() {
|
||||
assert!(validate_group_name("NoNe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_allows_favorites() {
|
||||
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_long_names() {
|
||||
assert!(
|
||||
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_group_id_is_stable() {
|
||||
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_reject_duplicate_instances() {
|
||||
let updates = vec![
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["first".to_string()],
|
||||
},
|
||||
InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec!["second".to_string()],
|
||||
},
|
||||
];
|
||||
|
||||
assert!(normalize_membership_updates(updates).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_updates_deduplicate_groups() {
|
||||
let updates =
|
||||
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
|
||||
instance_id: "instance".to_string(),
|
||||
group_ids: vec![
|
||||
"first".to_string(),
|
||||
"second".to_string(),
|
||||
"first".to_string(),
|
||||
],
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
updates[0].group_ids,
|
||||
vec!["first".to_string(), "second".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{EditInstance, State};
|
||||
use crate::state::{
|
||||
EditInstance, InstanceIconBackground, InstanceIconConfig, State,
|
||||
};
|
||||
use crate::util::fetch::{sha1_async, write};
|
||||
use crate::util::io;
|
||||
use bytes::Bytes;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageFormat, ImageReader, Rgba, RgbaImage};
|
||||
use std::fs::File as StdFile;
|
||||
use std::io::{BufRead, BufReader, Cursor, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,6 +17,10 @@ const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
|
||||
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
|
||||
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const GENERATED_ICON_SIZE: u32 = 256;
|
||||
const MAX_ICON_CONFIG_ID_LENGTH: usize = 64;
|
||||
const MAX_SYMBOL_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_SYMBOL_DIMENSION: u32 = 4096;
|
||||
|
||||
enum LegacyIconAction {
|
||||
Keep,
|
||||
@@ -20,6 +28,15 @@ enum LegacyIconAction {
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum ValidatedIconBackground {
|
||||
Color([u8; 3]),
|
||||
LinearTopDownGradient {
|
||||
top_color: [u8; 3],
|
||||
bottom_color: [u8; 3],
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
@@ -36,7 +53,115 @@ pub async fn edit_icon(
|
||||
None
|
||||
};
|
||||
|
||||
apply_instance_icon(instance_id, icon_path, &state).await
|
||||
apply_instance_icon(instance_id, icon_path, None, &state).await
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
apply_instance_icon(
|
||||
instance_id,
|
||||
Some(icon_path.clone()),
|
||||
Some(config),
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn edit_generated_icon_if_empty(
|
||||
instance_id: &str,
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
let applied = instance_rows::update_instance_icon_if_empty(
|
||||
instance_id,
|
||||
&icon_path,
|
||||
&config,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
if !applied {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
Some(&icon_path),
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(Some(icon_path))
|
||||
}
|
||||
|
||||
pub async fn cache_generated_icon(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
add_to_recents: bool,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
let icon_path =
|
||||
cache_generated_icon_with_state(config.clone(), symbol_bytes, &state)
|
||||
.await?;
|
||||
|
||||
if add_to_recents {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::update_recent_instance_icon_config(&config, &mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(icon_path)
|
||||
}
|
||||
|
||||
pub async fn get_recent_icon_configs() -> crate::Result<Vec<InstanceIconConfig>>
|
||||
{
|
||||
let state = State::get().await?;
|
||||
instance_rows::get_recent_instance_icon_configs(&state.pool).await
|
||||
}
|
||||
|
||||
async fn cache_generated_icon_with_state(
|
||||
config: InstanceIconConfig,
|
||||
symbol_bytes: Vec<u8>,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let background = validate_icon_config(&config)?;
|
||||
let icon_bytes = tokio::task::spawn_blocking(move || {
|
||||
render_generated_icon(background, &symbol_bytes)
|
||||
})
|
||||
.await??;
|
||||
let file = write_cached_icon(Bytes::from(icon_bytes), state).await?;
|
||||
|
||||
Ok(file.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon(
|
||||
@@ -125,7 +250,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
}
|
||||
LegacyIconAction::Remove => {
|
||||
if let Err(error) =
|
||||
apply_instance_icon(&instance.id, None, &state).await
|
||||
apply_instance_icon(&instance.id, None, None, &state).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
@@ -144,6 +269,7 @@ pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
async fn apply_instance_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance =
|
||||
@@ -156,6 +282,7 @@ async fn apply_instance_icon(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
icon_config: Some(icon_config),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
@@ -287,6 +414,152 @@ fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
|
||||
Ok(Bytes::from(normalized))
|
||||
}
|
||||
|
||||
fn validate_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<ValidatedIconBackground> {
|
||||
let background = match &config.background {
|
||||
InstanceIconBackground::Color { value } => {
|
||||
ValidatedIconBackground::Color(parse_background_color(value)?)
|
||||
}
|
||||
InstanceIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: parse_background_color(top_color)?,
|
||||
bottom_color: parse_background_color(bottom_color)?,
|
||||
},
|
||||
};
|
||||
validate_icon_config_id("symbol", &config.symbol)?;
|
||||
Ok(background)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_generated_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
) -> crate::Result<()> {
|
||||
validate_icon_config(config).map(drop)
|
||||
}
|
||||
|
||||
fn parse_background_color(value: &str) -> crate::Result<[u8; 3]> {
|
||||
if value.len() != 7 || !value.starts_with('#') {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let color = u32::from_str_radix(&value[1..], 16).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Instance icon background must be a hexadecimal color".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok([
|
||||
((color >> 16) & 0xff) as u8,
|
||||
((color >> 8) & 0xff) as u8,
|
||||
(color & 0xff) as u8,
|
||||
])
|
||||
}
|
||||
|
||||
fn validate_icon_config_id(kind: &str, value: &str) -> crate::Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ICON_CONFIG_ID_LENGTH
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
|
||||
})
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon {kind} ID is invalid"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_generated_icon(
|
||||
background: ValidatedIconBackground,
|
||||
symbol_bytes: &[u8],
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
if symbol_bytes.is_empty() || symbol_bytes.len() > MAX_SYMBOL_BYTES {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon symbol must be a PNG smaller than 4 MiB".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let reader =
|
||||
ImageReader::with_format(Cursor::new(symbol_bytes), ImageFormat::Png);
|
||||
let (width, height) =
|
||||
reader.into_dimensions().map_err(image_input_error)?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_SYMBOL_DIMENSION
|
||||
|| height > MAX_SYMBOL_DIMENSION
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Instance icon symbol dimensions must be between 1 and {MAX_SYMBOL_DIMENSION} pixels"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let symbol =
|
||||
image::load_from_memory_with_format(symbol_bytes, ImageFormat::Png)
|
||||
.map_err(image_input_error)?
|
||||
.resize_exact(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
FilterType::Lanczos3,
|
||||
)
|
||||
.to_rgba8();
|
||||
let mut icon = match background {
|
||||
ValidatedIconBackground::Color(color) => RgbaImage::from_pixel(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
Rgba([color[0], color[1], color[2], 255]),
|
||||
),
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color,
|
||||
bottom_color,
|
||||
} => RgbaImage::from_fn(
|
||||
GENERATED_ICON_SIZE,
|
||||
GENERATED_ICON_SIZE,
|
||||
|_, y| {
|
||||
let interpolate = |top: u8, bottom: u8| {
|
||||
let distance = i32::from(bottom) - i32::from(top);
|
||||
(i32::from(top)
|
||||
+ distance * y as i32
|
||||
/ (GENERATED_ICON_SIZE - 1) as i32)
|
||||
as u8
|
||||
};
|
||||
Rgba([
|
||||
interpolate(top_color[0], bottom_color[0]),
|
||||
interpolate(top_color[1], bottom_color[1]),
|
||||
interpolate(top_color[2], bottom_color[2]),
|
||||
255,
|
||||
])
|
||||
},
|
||||
),
|
||||
};
|
||||
image::imageops::overlay(&mut icon, &symbol, 0, 0);
|
||||
for pixel in icon.pixels_mut() {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(icon)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.map_err(image_input_error)?;
|
||||
|
||||
Ok(encoded.into_inner())
|
||||
}
|
||||
|
||||
fn image_input_error(error: image::ImageError) -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid instance icon symbol: {error}"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn looks_like_svg(bytes: &[u8]) -> bool {
|
||||
if image::guess_format(bytes).is_ok() {
|
||||
return false;
|
||||
@@ -316,3 +589,105 @@ fn svg_not_supported_error() -> crate::Error {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GENERATED_ICON_SIZE, InstanceIconBackground, InstanceIconConfig,
|
||||
ValidatedIconBackground, render_generated_icon, validate_icon_config,
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png(pixel: Rgba<u8>) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_pixel(1, 1, pixel))
|
||||
.write_to(&mut bytes, ImageFormat::Png)
|
||||
.unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_background_and_symbol() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::Color([10, 20, 30]),
|
||||
&png(Rgba([200, 100, 50, 128])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(
|
||||
icon.dimensions(),
|
||||
(GENERATED_ICON_SIZE, GENERATED_ICON_SIZE)
|
||||
);
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([105, 60, 40, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_rejects_invalid_symbol_data() {
|
||||
assert!(
|
||||
render_generated_icon(
|
||||
ValidatedIconBackground::Color([0, 0, 0]),
|
||||
b"not a png",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_renders_linear_top_down_gradient() {
|
||||
let bytes = render_generated_icon(
|
||||
ValidatedIconBackground::LinearTopDownGradient {
|
||||
top_color: [10, 20, 30],
|
||||
bottom_color: [110, 120, 130],
|
||||
},
|
||||
&png(Rgba([0, 0, 0, 0])),
|
||||
)
|
||||
.unwrap();
|
||||
let icon =
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(icon.get_pixel(0, 0), &Rgba([10, 20, 30, 255]));
|
||||
assert_eq!(
|
||||
icon.get_pixel(0, GENERATED_ICON_SIZE - 1),
|
||||
&Rgba([110, 120, 130, 255])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_icon_config_validates_color_and_symbol_id() {
|
||||
assert_eq!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
ValidatedIconBackground::Color([199, 138, 255])
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "purple".to_string(),
|
||||
},
|
||||
symbol: "dusk_block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_icon_config(&InstanceIconConfig {
|
||||
background: InstanceIconBackground::Color {
|
||||
value: "#c78aff".to_string(),
|
||||
},
|
||||
symbol: "dusk-block".to_string(),
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{
|
||||
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
|
||||
State,
|
||||
CreateInstance, EditInstance, InstanceIconConfig, InstanceLink,
|
||||
InstanceMetadata, ModLoader, State,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -14,9 +14,13 @@ pub(crate) async fn create(
|
||||
modloader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstanceMetadata> {
|
||||
let state = State::get().await?;
|
||||
if let Some(icon_config) = &icon_config {
|
||||
super::icon::validate_generated_icon_config(icon_config)?;
|
||||
}
|
||||
let instance = crate::state::create_instance(
|
||||
CreateInstance {
|
||||
name,
|
||||
@@ -25,6 +29,7 @@ pub(crate) async fn create(
|
||||
loader: modloader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
},
|
||||
&state,
|
||||
@@ -47,6 +52,12 @@ pub(crate) async fn create(
|
||||
|
||||
if result.is_err() {
|
||||
let _ = crate::state::remove_instance(&instance.id, &state).await;
|
||||
} else if let Err(error) =
|
||||
crate::onboarding_checklist::mark_created_instance().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark instance creation in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -823,7 +823,8 @@ pub(super) async fn send_bytes_request(
|
||||
) -> crate::Result<reqwest::Response> {
|
||||
let base_url = service_base_url();
|
||||
let url = service_url(base_url, path);
|
||||
send_bytes_request_to_url(operation, method, path, &url, body, state).await
|
||||
send_bytes_request_to_url(operation, method, path, &url, body, None, state)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn send_bytes_request_to_url(
|
||||
@@ -832,6 +833,7 @@ pub(super) async fn send_bytes_request_to_url(
|
||||
path: &str,
|
||||
url: &str,
|
||||
body: Vec<u8>,
|
||||
file_sha512: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<reqwest::Response> {
|
||||
let service_origin = url::Url::parse(service_base_url())
|
||||
@@ -872,18 +874,20 @@ pub(super) async fn send_bytes_request_to_url(
|
||||
"Sending shared instances API request"
|
||||
);
|
||||
|
||||
let response = shared_instances_client(url)
|
||||
let mut request = shared_instances_upload_client(url)
|
||||
.request(method.clone(), url)
|
||||
.bearer_auth(credentials.session)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::SharedInstancesApiError(
|
||||
error.without_url().to_string(),
|
||||
)
|
||||
})?;
|
||||
.body(body);
|
||||
if let Some(file_sha512) = file_sha512 {
|
||||
request = request.header("x-file-sha512", file_sha512);
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
crate::ErrorKind::SharedInstancesApiError(
|
||||
error.without_url().to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let request_id = response_request_id(&response);
|
||||
@@ -1059,3 +1063,13 @@ pub(super) fn shared_instances_client(
|
||||
&INSECURE_REQWEST_CLIENT
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn shared_instances_upload_client(
|
||||
base_url: &str,
|
||||
) -> &'static reqwest::Client {
|
||||
if base_url.starts_with("https://") {
|
||||
&NO_TIMEOUT_REQWEST_CLIENT
|
||||
} else {
|
||||
&INSECURE_NO_TIMEOUT_REQWEST_CLIENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ use crate::state::{
|
||||
ContentSourceKind, EditInstance, ModLoader, ModrinthCredentials,
|
||||
ProjectType, SharedInstanceRole, State,
|
||||
};
|
||||
use crate::util::fetch::{INSECURE_REQWEST_CLIENT, REQWEST_CLIENT};
|
||||
use crate::util::fetch::{
|
||||
INSECURE_NO_TIMEOUT_REQWEST_CLIENT, INSECURE_REQWEST_CLIENT,
|
||||
NO_TIMEOUT_REQWEST_CLIENT, REQWEST_CLIENT,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::*;
|
||||
use async_walkdir::WalkDir;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use futures::StreamExt;
|
||||
use sha2::Digest;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[tracing::instrument]
|
||||
@@ -1018,12 +1019,18 @@ pub(super) async fn upload_external_files(
|
||||
"Invalid shared instance external file upload URL: {error}"
|
||||
))
|
||||
})?;
|
||||
let (bytes, file_sha512) = tokio::task::spawn_blocking(move || {
|
||||
let hash = format!("{:x}", sha2::Sha512::digest(&bytes));
|
||||
(bytes, hash)
|
||||
})
|
||||
.await?;
|
||||
let response = send_bytes_request_to_url(
|
||||
"upload_external_file",
|
||||
Method::PUT,
|
||||
upload_url.path(),
|
||||
&upload.url,
|
||||
bytes,
|
||||
Some(&file_sha512),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -33,7 +33,18 @@ pub async fn finish_login(
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
|
||||
crate::state::login_finish(code, flow, &state.pool).await
|
||||
let credentials =
|
||||
crate::state::login_finish(code, flow, &state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_minecraft().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Minecraft login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
|
||||
@@ -306,9 +306,21 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
let Some(selected_credentials) =
|
||||
Credentials::get_default_credential(&state.pool).await?
|
||||
else {
|
||||
let fallback_default_skin = get_fallback_default_skin()?;
|
||||
|
||||
return Ok(assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.map(|skin| Skin {
|
||||
is_equipped: skin.texture_key
|
||||
== fallback_default_skin.texture_key
|
||||
&& skin.variant == fallback_default_skin.variant,
|
||||
..skin.clone()
|
||||
})
|
||||
.collect());
|
||||
};
|
||||
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod metadata;
|
||||
pub mod minecraft_auth;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod onboarding_checklist;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
pub mod reports;
|
||||
@@ -23,13 +24,15 @@ pub mod data {
|
||||
AppliedContentSetPatch, CacheBehaviour, CacheValueType, ContentFile,
|
||||
ContentItem, ContentItemOwner, ContentItemProject, ContentItemVersion,
|
||||
CreateInstance, Credentials, Dependency, DirectoryInfo, EditInstance,
|
||||
Hooks, InstanceInstallCandidate, InstanceInstallTarget,
|
||||
Hooks, InstanceIconBackground, InstanceIconConfig,
|
||||
InstanceInstallCandidate, InstanceInstallTarget,
|
||||
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
||||
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
|
||||
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
|
||||
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
|
||||
Theme, User, UserFriend, Version, WindowSize,
|
||||
ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
||||
ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
||||
SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
||||
SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
||||
WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
@@ -43,8 +46,8 @@ pub mod prelude {
|
||||
State,
|
||||
data::*,
|
||||
event::CommandPayload,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth, pack,
|
||||
process, settings,
|
||||
install, instance, jre, metadata, minecraft_auth, mr_auth,
|
||||
onboarding_checklist, pack, process, settings,
|
||||
state::{ReleaseChannel, db_backup::app_db_backup_dir},
|
||||
util::{
|
||||
io::{IOError, canonicalize},
|
||||
|
||||
@@ -30,6 +30,15 @@ pub async fn authenticate_finish_flow(
|
||||
.await?;
|
||||
|
||||
creds.upsert(&state.pool).await?;
|
||||
|
||||
if let Err(error) =
|
||||
crate::onboarding_checklist::mark_logged_into_modrinth().await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to mark Modrinth login in onboarding checklist: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
state.friends_socket.disconnect().await?;
|
||||
state
|
||||
.friends_socket
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::State;
|
||||
use crate::event::emit::emit_onboarding_checklist;
|
||||
use crate::state::{
|
||||
OnboardingChecklist, OnboardingChecklistItem, get_onboarding_checklist,
|
||||
mark_onboarding_checklist_item,
|
||||
};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get() -> crate::Result<OnboardingChecklist> {
|
||||
let state = State::get().await?;
|
||||
get_onboarding_checklist(&state.pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_created_instance() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::CreatedInstance).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_minecraft() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoMinecraft).await
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_logged_into_modrinth() -> crate::Result<()> {
|
||||
mark(OnboardingChecklistItem::LoggedIntoModrinth).await
|
||||
}
|
||||
|
||||
async fn mark(item: OnboardingChecklistItem) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
if let Some(checklist) =
|
||||
mark_onboarding_checklist_item(item, &state.pool).await?
|
||||
{
|
||||
emit_onboarding_checklist(checklist).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -309,7 +309,6 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
reporter: InstallProgressReporter,
|
||||
) -> crate::Result<CreatePack> {
|
||||
let state = State::get().await?;
|
||||
let has_icon_url = icon_url.is_some();
|
||||
|
||||
let version = CachedEntry::get_version(
|
||||
&version_id,
|
||||
@@ -443,48 +442,28 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
|
||||
.update(InstallPhaseId::ResolvingPack, None, details.clone())
|
||||
.await?;
|
||||
|
||||
let project = CachedEntry::get_project(
|
||||
&version.project_id,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Invalid project ID specified!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Only fetch the pack icon when icon_url is provided (new profile).
|
||||
// When installing to an existing profile (e.g. server projects),
|
||||
// icon_url is None and we preserve the profile's existing icon.
|
||||
let icon = if has_icon_url {
|
||||
if let Some(icon_url) = project.icon_url {
|
||||
let state = State::get().await?;
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("download modpack icon")
|
||||
.urls(vec![icon_url.clone()])
|
||||
.project_id(project_id.clone())
|
||||
.version_id(version_id.clone())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let icon_bytes = fetch(
|
||||
&icon_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
// When no icon URL is supplied, preserve the instance's existing icon.
|
||||
let icon = if let Some(icon_url) = icon_url {
|
||||
reporter
|
||||
.set_context(
|
||||
InstallErrorContext::new("download modpack icon")
|
||||
.urls(vec![icon_url.clone()])
|
||||
.project_id(project_id.clone())
|
||||
.version_id(version_id.clone())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let icon_bytes = fetch(
|
||||
&icon_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -587,7 +566,7 @@ pub async fn set_instance_information(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = match (&description.project_id, &description.version_id) {
|
||||
let pack_link = match (&description.project_id, &description.version_id) {
|
||||
(Some(project_id), Some(version_id)) => {
|
||||
Some(InstanceLink::ModrinthModpack {
|
||||
project_id: project_id.clone(),
|
||||
@@ -605,13 +584,35 @@ pub async fn set_instance_information(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let existing_link = crate::api::instance::get(&instance_id)
|
||||
.await?
|
||||
.map(|metadata| metadata.link);
|
||||
let link = match existing_link {
|
||||
Some(
|
||||
link @ (InstanceLink::ServerProject { .. }
|
||||
| InstanceLink::ServerProjectModpack { .. }
|
||||
| InstanceLink::ModrinthHosting { .. }
|
||||
| InstanceLink::SharedInstance { .. }),
|
||||
) => Some(link),
|
||||
_ => pack_link,
|
||||
};
|
||||
let source_kind = match &link {
|
||||
Some(InstanceLink::ModrinthModpack { .. }) => {
|
||||
Some(ContentSourceKind::ModrinthModpack)
|
||||
}
|
||||
Some(
|
||||
InstanceLink::ServerProject { .. }
|
||||
| InstanceLink::ServerProjectModpack { .. },
|
||||
) => Some(ContentSourceKind::ServerProject),
|
||||
Some(InstanceLink::ModrinthHosting { .. }) => {
|
||||
Some(ContentSourceKind::ModrinthHosting)
|
||||
}
|
||||
Some(InstanceLink::ImportedModpack { .. }) => {
|
||||
Some(ContentSourceKind::ImportedModpack)
|
||||
}
|
||||
Some(InstanceLink::SharedInstance { .. }) => {
|
||||
Some(ContentSourceKind::SharedInstance)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
crate::api::instance::edit(
|
||||
|
||||
@@ -57,10 +57,10 @@ pub async fn get_user_projects(user_id: &str) -> crate::Result<Value> {
|
||||
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!("{}user/{}/projects", env!("MODRINTH_API_URL"), user_id),
|
||||
&format!("{}user/{}/projects", env!("MODRINTH_API_URL_V3"), user_id),
|
||||
None,
|
||||
None,
|
||||
Some("/v2/user/:id/projects"),
|
||||
Some("/v3/user/:id/projects"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
@@ -109,6 +109,51 @@ pub async fn get_user_collections(user_id: &str) -> crate::Result<Value> {
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_user_preferences(user_id: &str) -> crate::Result<Value> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!(
|
||||
"{}user/{}/preferences",
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
user_id
|
||||
),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/user/:id/preferences"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(preferences))]
|
||||
pub async fn patch_user_preferences(
|
||||
user_id: &str,
|
||||
preferences: Value,
|
||||
) -> crate::Result<Value> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
|
||||
fetch_json(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"{}user/{}/preferences",
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
user_id
|
||||
),
|
||||
None,
|
||||
Some(preferences),
|
||||
Some("/v3/user/:id/preferences"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(patch))]
|
||||
pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use super::{FriendPayload, LoadingBarId};
|
||||
#[cfg(feature = "tauri")]
|
||||
use crate::event::{
|
||||
AppEvent, InstancePayload, LoadingPayload, ProcessPayload, WarningPayload,
|
||||
AppEvent, InstanceGroupsChangedPayload, InstancePayload, LoadingPayload,
|
||||
ProcessPayload, WarningPayload,
|
||||
};
|
||||
use crate::event::{
|
||||
CommandPayload, EventError, InstanceBulkUpdateProgressPayload,
|
||||
InstancePayloadType, LoadingBar, LoadingBarType, ProcessPayloadType,
|
||||
};
|
||||
use crate::state::OnboardingChecklist;
|
||||
use futures::prelude::*;
|
||||
use serde_json::Value;
|
||||
#[cfg(feature = "tauri")]
|
||||
@@ -232,6 +234,34 @@ pub async fn emit_instance(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_instance_groups_changed(
|
||||
instance_ids: &[String],
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get();
|
||||
event_state.send(AppEvent::InstanceGroupsChanged(
|
||||
InstanceGroupsChangedPayload {
|
||||
instance_ids: instance_ids.to_vec(),
|
||||
},
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_onboarding_checklist(
|
||||
checklist: OnboardingChecklist,
|
||||
) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let event_state = crate::EventState::get();
|
||||
event_state.send(AppEvent::OnboardingChecklist(checklist))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
|
||||
@@ -114,6 +114,8 @@ pub enum AppEvent {
|
||||
Loading(LoadingPayload),
|
||||
Process(ProcessPayload),
|
||||
Instance(InstancePayload),
|
||||
InstanceGroupsChanged(InstanceGroupsChangedPayload),
|
||||
OnboardingChecklist(crate::state::OnboardingChecklist),
|
||||
InstanceBulkUpdateProgress(InstanceBulkUpdateProgressPayload),
|
||||
InstallJob(std::sync::Arc<InstallJobSnapshot>),
|
||||
Command(CommandPayload),
|
||||
@@ -161,6 +163,8 @@ pub fn export_app_event_bindings(
|
||||
ProcessPayloadType,
|
||||
InstancePayload,
|
||||
InstancePayloadType,
|
||||
InstanceGroupsChangedPayload,
|
||||
crate::state::OnboardingChecklist,
|
||||
FriendPayload,
|
||||
FriendStatusPayload,
|
||||
LogEvent,
|
||||
@@ -446,6 +450,16 @@ pub struct InstancePayload {
|
||||
pub event: InstancePayloadType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
derive(ts_rs::TS, postcard_bindgen::PostcardBindings)
|
||||
)]
|
||||
#[serde_binhum::serde_binhum]
|
||||
pub struct InstanceGroupsChangedPayload {
|
||||
pub instance_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::api::pack::import::ImportLauncherType;
|
||||
use crate::api::pack::install_from::{CreatePackInstance, CreatePackLocation};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, InstanceLink, InstanceMetadata, ModLoader,
|
||||
InstanceIconConfig, InstanceInstallStage, InstanceLink, InstanceMetadata,
|
||||
ModLoader,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -161,6 +162,8 @@ pub enum InstallRequest {
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
#[serde(default)]
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
},
|
||||
CreateModpackInstance {
|
||||
|
||||
@@ -154,7 +154,7 @@ async fn restore_instance_metadata(
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&metadata.instance.id,
|
||||
&metadata.groups,
|
||||
&metadata.group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -22,8 +22,10 @@ use crate::api::pack::install_mrpack::install_zipped_mrpack_files_with_reporter;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::content_rows;
|
||||
use crate::state::instances::commands::resolve_icon_path;
|
||||
use crate::state::{
|
||||
ContentSourceKind, InstanceInstallStage, InstanceLink, ModLoader, State,
|
||||
ContentSourceKind, InstanceIconConfig, InstanceInstallStage, InstanceLink,
|
||||
ModLoader, State,
|
||||
};
|
||||
use crate::util::fetch::DownloadReason;
|
||||
use std::collections::HashSet;
|
||||
@@ -36,6 +38,7 @@ pub async fn create_instance(
|
||||
loader: ModLoader,
|
||||
loader_version: Option<String>,
|
||||
icon_path: Option<String>,
|
||||
icon_config: Option<InstanceIconConfig>,
|
||||
link: InstanceLink,
|
||||
) -> crate::Result<InstallJobSnapshot> {
|
||||
start(InstallRequest::CreateInstance {
|
||||
@@ -44,6 +47,7 @@ pub async fn create_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
})
|
||||
.await
|
||||
@@ -435,6 +439,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
} => {
|
||||
let metadata = crate::api::instance::create(
|
||||
@@ -443,6 +448,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
icon_config,
|
||||
link,
|
||||
)
|
||||
.await?;
|
||||
@@ -489,6 +495,7 @@ async fn prepare_initial_instance(
|
||||
preview.modloader,
|
||||
preview.loader_version,
|
||||
icon_path,
|
||||
None,
|
||||
link,
|
||||
)
|
||||
.await?;
|
||||
@@ -534,6 +541,7 @@ async fn prepare_initial_instance(
|
||||
loader,
|
||||
loader_version,
|
||||
icon_path,
|
||||
None,
|
||||
shared_link,
|
||||
)
|
||||
.await?;
|
||||
@@ -556,6 +564,7 @@ async fn prepare_initial_instance(
|
||||
ModLoader::Vanilla,
|
||||
Some("latest".to_string()),
|
||||
None,
|
||||
None,
|
||||
InstanceLink::Unmanaged,
|
||||
)
|
||||
.await?;
|
||||
@@ -581,6 +590,7 @@ async fn prepare_initial_instance(
|
||||
metadata.applied_content_set.loader,
|
||||
metadata.applied_content_set.loader_version,
|
||||
metadata.instance.icon_path,
|
||||
None,
|
||||
metadata.link,
|
||||
)
|
||||
.await?;
|
||||
@@ -805,6 +815,7 @@ async fn run_request(
|
||||
loader,
|
||||
loader_version: _,
|
||||
icon_path: _,
|
||||
icon_config: _,
|
||||
link: _,
|
||||
} => {
|
||||
let Some(instance_id) = current_instance_id(job_state) else {
|
||||
@@ -1084,7 +1095,7 @@ async fn apply_post_install_edit(
|
||||
instance_id: &str,
|
||||
edit: Option<InstallPostInstallEdit>,
|
||||
) -> crate::Result<()> {
|
||||
let Some(edit) = edit else {
|
||||
let Some(mut edit) = edit else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -1092,6 +1103,17 @@ async fn apply_post_install_edit(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(icon_path) = edit.icon_path.take() {
|
||||
let icon_path = match icon_path {
|
||||
Some(icon_path) => {
|
||||
let state = State::get().await?;
|
||||
resolve_icon_path(Some(&icon_path), false, &state).await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
edit.icon_path = Some(icon_path);
|
||||
}
|
||||
|
||||
crate::api::instance::edit(
|
||||
instance_id,
|
||||
crate::state::instances::commands::EditInstance {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
|
||||
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
Instance, InstanceIconBackground, InstanceIconConfig,
|
||||
InstanceLaunchContext, InstanceLaunchOverrides,
|
||||
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
|
||||
SharedInstanceRole, playtime_to_storage,
|
||||
};
|
||||
@@ -12,6 +13,7 @@ use crate::state::{
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::de::DeserializeOwned;
|
||||
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
@@ -161,10 +163,11 @@ pub(crate) struct InstanceLaunchOverridesRow {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InstanceMetadataRecord {
|
||||
pub instance: Instance,
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
pub groups: Vec<String>,
|
||||
pub group_ids: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
@@ -184,6 +187,8 @@ struct InstanceMetadataRow {
|
||||
update_channel: String,
|
||||
name: String,
|
||||
icon_path: Option<String>,
|
||||
icon_config_background: Option<String>,
|
||||
icon_config_symbol: Option<String>,
|
||||
created: i64,
|
||||
modified: i64,
|
||||
last_played: Option<i64>,
|
||||
@@ -221,7 +226,7 @@ struct InstanceMetadataRow {
|
||||
imported_name: Option<String>,
|
||||
imported_version_number: Option<String>,
|
||||
imported_filename: Option<String>,
|
||||
groups: String,
|
||||
group_ids: String,
|
||||
launch_overrides: Option<String>,
|
||||
}
|
||||
|
||||
@@ -333,16 +338,31 @@ impl InstanceMetadataRow {
|
||||
self.shared_sync_applied_update_id,
|
||||
self.shared_sync_latest_available_update_id,
|
||||
)?;
|
||||
let groups = parse_groups(self.groups)?;
|
||||
let group_ids = parse_group_ids(self.group_ids)?;
|
||||
let launch_overrides =
|
||||
launch_overrides_from_json(instance_id, self.launch_overrides)?;
|
||||
let icon_config =
|
||||
match (self.icon_config_background, self.icon_config_symbol) {
|
||||
(Some(background), Some(symbol)) => Some(InstanceIconConfig {
|
||||
background: deserialize_icon_background(background)?,
|
||||
symbol,
|
||||
}),
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Instance icon config is incomplete".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InstanceMetadataRecord {
|
||||
instance,
|
||||
icon_config,
|
||||
applied_content_set,
|
||||
link,
|
||||
shared_instance,
|
||||
groups,
|
||||
group_ids,
|
||||
launch_overrides,
|
||||
})
|
||||
}
|
||||
@@ -538,6 +558,8 @@ macro_rules! query_instance_metadata {
|
||||
i.update_channel AS "update_channel!: String",
|
||||
i.name AS "name!: String",
|
||||
i.icon_path AS "icon_path?: String",
|
||||
config.background AS "icon_config_background?: String",
|
||||
config.symbol AS "icon_config_symbol?: String",
|
||||
i.created AS "created!: i64",
|
||||
i.modified AS "modified!: i64",
|
||||
i.last_played AS "last_played?: i64",
|
||||
@@ -576,14 +598,16 @@ macro_rules! query_instance_metadata {
|
||||
link.imported_version_number AS "imported_version_number?: String",
|
||||
link.imported_filename AS "imported_filename?: String",
|
||||
COALESCE((
|
||||
SELECT json_group_array(group_name)
|
||||
SELECT json_group_array(id)
|
||||
FROM (
|
||||
SELECT group_name
|
||||
FROM instance_groups
|
||||
WHERE instance_id = i.id
|
||||
ORDER BY group_name
|
||||
SELECT groups.id
|
||||
FROM instance_group_memberships memberships
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.id = memberships.group_id
|
||||
WHERE memberships.instance_id = i.id
|
||||
ORDER BY groups.name
|
||||
)
|
||||
), '[]') AS "groups!: String",
|
||||
), '[]') AS "group_ids!: String",
|
||||
json(overrides.overrides) AS "launch_overrides?: String"
|
||||
"#
|
||||
+ $from
|
||||
@@ -598,6 +622,8 @@ macro_rules! query_instance_metadata {
|
||||
AND sync.provider = 'shared_instance'
|
||||
LEFT JOIN instance_launch_overrides overrides
|
||||
ON overrides.instance_id = i.id
|
||||
LEFT JOIN instance_icon_configs config
|
||||
ON config.instance_id = i.id
|
||||
"#
|
||||
+ $suffix,
|
||||
$arg,
|
||||
@@ -605,6 +631,157 @@ macro_rules! query_instance_metadata {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn update_instance_icon_config(
|
||||
instance_id: &str,
|
||||
config: Option<&InstanceIconConfig>,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
if let Some(config) = config {
|
||||
let background = serde_json::to_string(&config.background)?;
|
||||
let symbol = &config.symbol;
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_icon_configs (instance_id, background, symbol)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
background = excluded.background,
|
||||
symbol = excluded.symbol
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(&background)
|
||||
.bind(symbol)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
update_recent_instance_icon_config(config, tx).await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM instance_icon_configs
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_instance_icon_if_empty(
|
||||
instance_id: &str,
|
||||
icon_path: &str,
|
||||
config: &InstanceIconConfig,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<bool> {
|
||||
let modified = Utc::now().timestamp();
|
||||
let mut tx = pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"
|
||||
UPDATE instances
|
||||
SET icon_path = ?, modified = ?
|
||||
WHERE id = ? AND (icon_path IS NULL OR icon_path = '')
|
||||
",
|
||||
)
|
||||
.bind(icon_path)
|
||||
.bind(modified)
|
||||
.bind(instance_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
update_instance_icon_config(instance_id, Some(config), &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_recent_instance_icon_config(
|
||||
config: &InstanceIconConfig,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let background = serde_json::to_string(&config.background)?;
|
||||
let used_at = Utc::now().timestamp_millis();
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO recent_instance_icon_configs (background, symbol, used_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (background, symbol) DO UPDATE SET
|
||||
used_at = excluded.used_at
|
||||
",
|
||||
)
|
||||
.bind(&background)
|
||||
.bind(&config.symbol)
|
||||
.bind(used_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM recent_instance_icon_configs
|
||||
WHERE rowid NOT IN (
|
||||
SELECT rowid
|
||||
FROM recent_instance_icon_configs
|
||||
ORDER BY used_at DESC, background, symbol
|
||||
LIMIT 16
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_recent_instance_icon_configs(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceIconConfig>> {
|
||||
let rows = sqlx::query_as::<_, InstanceIconConfigRow>(
|
||||
"
|
||||
SELECT background, symbol
|
||||
FROM recent_instance_icon_configs
|
||||
ORDER BY used_at DESC, background, symbol
|
||||
LIMIT 16
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(InstanceIconConfig {
|
||||
background: deserialize_icon_background(row.background)?,
|
||||
symbol: row.symbol,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct InstanceIconConfigRow {
|
||||
background: String,
|
||||
symbol: String,
|
||||
}
|
||||
|
||||
fn deserialize_icon_background(
|
||||
background: String,
|
||||
) -> crate::Result<InstanceIconBackground> {
|
||||
match serde_json::from_str(&background) {
|
||||
Ok(background) => Ok(background),
|
||||
Err(_) if background.starts_with('#') => {
|
||||
Ok(InstanceIconBackground::Color { value: background })
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_metadata_by_id(
|
||||
id: &str,
|
||||
pool: &SqlitePool,
|
||||
@@ -746,10 +923,12 @@ where
|
||||
{
|
||||
let rows = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT group_name
|
||||
FROM instance_groups
|
||||
WHERE instance_id = ?
|
||||
ORDER BY group_name
|
||||
SELECT groups.id
|
||||
FROM instance_group_memberships memberships
|
||||
INNER JOIN instance_groups groups
|
||||
ON groups.id = memberships.group_id
|
||||
WHERE memberships.instance_id = ?
|
||||
ORDER BY groups.name
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
@@ -759,6 +938,122 @@ where
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instance_groups(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<(String, String)>> {
|
||||
let groups = sqlx::query_as::<_, (String, String)>(
|
||||
"
|
||||
SELECT id, name
|
||||
FROM instance_groups
|
||||
ORDER BY display_order, name, id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance_group(
|
||||
id: &str,
|
||||
name: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET display_order = display_order + 1
|
||||
WHERE id != ?
|
||||
",
|
||||
)
|
||||
.bind(crate::api::instance::FAVORITES_GROUP_ID)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_groups (id, name, display_order)
|
||||
VALUES (?, ?, 0)
|
||||
",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_group_order(
|
||||
group_ids: &[String],
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let existing_group_ids = sqlx::query_scalar::<_, String>(
|
||||
"
|
||||
SELECT id
|
||||
FROM instance_groups
|
||||
WHERE id != ?
|
||||
ORDER BY display_order, name, id
|
||||
",
|
||||
)
|
||||
.bind(crate::api::instance::FAVORITES_GROUP_ID)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let existing_group_id_set = existing_group_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen_group_ids = HashSet::new();
|
||||
let mut ordered_group_ids = Vec::with_capacity(existing_group_ids.len());
|
||||
|
||||
for group_id in group_ids {
|
||||
if !seen_group_ids.insert(group_id.as_str()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Duplicate instance group {group_id} in group order"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if !existing_group_id_set.contains(group_id.as_str()) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unknown instance group {group_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
ordered_group_ids.push(group_id.as_str());
|
||||
}
|
||||
|
||||
for group_id in &existing_group_ids {
|
||||
if seen_group_ids.insert(group_id.as_str()) {
|
||||
ordered_group_ids.push(group_id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (display_order, group_id) in ordered_group_ids.into_iter().enumerate() {
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_groups
|
||||
SET display_order = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
)
|
||||
.bind(display_order as i64)
|
||||
.bind(group_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_launch_overrides<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
@@ -1032,12 +1327,12 @@ pub(crate) async fn set_shared_instance_attachment(
|
||||
|
||||
pub(crate) async fn replace_instance_groups(
|
||||
instance_id: &str,
|
||||
groups: &[String],
|
||||
group_ids: &[String],
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM instance_groups
|
||||
DELETE FROM instance_group_memberships
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
@@ -1045,15 +1340,18 @@ pub(crate) async fn replace_instance_groups(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for group in groups {
|
||||
sqlx::query!(
|
||||
for group_id in group_ids {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_groups (instance_id, group_name)
|
||||
INSERT OR IGNORE INTO instance_group_memberships (
|
||||
instance_id,
|
||||
group_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
instance_id,
|
||||
group,
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(group_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
@@ -1264,7 +1562,7 @@ fn required_i64(value: Option<i64>, column: &str) -> crate::Result<i64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_groups(value: String) -> crate::Result<Vec<String>> {
|
||||
fn parse_group_ids(value: String) -> crate::Result<Vec<String>> {
|
||||
serde_json::from_str(&value).map_err(|err| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid instance groups JSON: {err}"
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::state::instances::{
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
State,
|
||||
InstanceIconConfig, InstanceInstallStage, LauncherFeatureVersion,
|
||||
ModLoader, ReleaseChannel, State,
|
||||
};
|
||||
use crate::util::fetch;
|
||||
use crate::util::io;
|
||||
@@ -23,6 +23,7 @@ pub struct CreateInstance {
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub link: InstanceLink,
|
||||
}
|
||||
|
||||
@@ -97,6 +98,14 @@ pub(crate) async fn create_instance(
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::insert_instance(&instance, &mut tx).await?;
|
||||
if let Some(icon_config) = &input.icon_config {
|
||||
instance_rows::update_instance_icon_config(
|
||||
&instance_id,
|
||||
Some(icon_config),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
content_rows::insert_content_set(&content_set, &mut tx).await?;
|
||||
instance_rows::upsert_instance_link(&instance_id, &input.link, &mut tx)
|
||||
.await?;
|
||||
@@ -170,7 +179,7 @@ async fn path_available(
|
||||
.is_none())
|
||||
}
|
||||
|
||||
async fn resolve_icon_path(
|
||||
pub(crate) async fn resolve_icon_path(
|
||||
icon_path: Option<&str>,
|
||||
ignore_missing_remote_icon: bool,
|
||||
state: &State,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentSourceKind, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
ContentSourceKind, Instance, InstanceIconConfig, InstanceLaunchOverrides,
|
||||
InstanceLink,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
@@ -21,8 +22,14 @@ pub struct EditInstance {
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_path: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_config: Option<Option<InstanceIconConfig>>,
|
||||
pub update_channel: Option<ReleaseChannel>,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub group_ids: Option<Vec<String>>,
|
||||
pub link: Option<InstanceLink>,
|
||||
pub launch_overrides: Option<InstanceLaunchOverridesPatch>,
|
||||
pub content_set_patch: Option<AppliedContentSetPatch>,
|
||||
@@ -174,9 +181,13 @@ pub(crate) async fn edit_instance(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(groups) = &patch.groups {
|
||||
instance_rows::replace_instance_groups(&instance.id, groups, &mut tx)
|
||||
.await?;
|
||||
if let Some(group_ids) = &patch.group_ids {
|
||||
instance_rows::replace_instance_groups(
|
||||
&instance.id,
|
||||
group_ids,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(overrides) = launch_overrides.as_mut() {
|
||||
@@ -184,6 +195,18 @@ pub(crate) async fn edit_instance(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(icon_config) = &patch.icon_config {
|
||||
instance_rows::update_instance_icon_config(
|
||||
&instance.id,
|
||||
icon_config.as_ref(),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
} else if patch.icon_path.is_some() {
|
||||
instance_rows::update_instance_icon_config(&instance.id, None, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if should_mark_shared_instance_stale {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::state::instances::{
|
||||
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
SharedInstanceAttachment, adapters::sqlite::instance_rows,
|
||||
ContentSet, Instance, InstanceIconConfig, InstanceLaunchOverrides,
|
||||
InstanceLink, SharedInstanceAttachment, adapters::sqlite::instance_rows,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -8,12 +8,14 @@ use sqlx::SqlitePool;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceMetadata {
|
||||
pub instance: Instance,
|
||||
#[serde(default)]
|
||||
pub icon_config: Option<InstanceIconConfig>,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub shared_instance: Option<SharedInstanceAttachment>,
|
||||
#[serde(default)]
|
||||
pub quarantined: bool,
|
||||
pub groups: Vec<String>,
|
||||
pub group_ids: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
@@ -79,11 +81,12 @@ fn instance_metadata(
|
||||
) -> InstanceMetadata {
|
||||
InstanceMetadata {
|
||||
instance: record.instance,
|
||||
icon_config: record.icon_config,
|
||||
applied_content_set: record.applied_content_set,
|
||||
link: record.link,
|
||||
shared_instance: record.shared_instance,
|
||||
quarantined,
|
||||
groups: record.groups,
|
||||
group_ids: record.group_ids,
|
||||
launch_overrides: record.launch_overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,10 +929,7 @@ async fn content_files_to_content_items(
|
||||
&meta.versions_v3,
|
||||
),
|
||||
owner,
|
||||
has_update: file.update_version_id.is_some()
|
||||
&& !file.source_kind.is_some_and(
|
||||
ContentSourceKind::is_shared_instance_managed,
|
||||
),
|
||||
has_update: file.update_version_id.is_some(),
|
||||
update_version_id: file.update_version_id.clone(),
|
||||
date_added: modification_times[index].clone(),
|
||||
source_kind: file.source_kind,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod create_instance;
|
||||
pub use self::create_instance::CreateInstance;
|
||||
pub(crate) use self::create_instance::create_instance;
|
||||
pub(crate) use self::create_instance::{create_instance, resolve_icon_path};
|
||||
|
||||
mod edit_instance;
|
||||
pub(crate) use self::edit_instance::edit_instance;
|
||||
|
||||
@@ -4,6 +4,25 @@ use crate::state::{
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InstanceIconBackground {
|
||||
Color {
|
||||
value: String,
|
||||
},
|
||||
#[serde(rename = "linear-top-down-gradient")]
|
||||
LinearTopDownGradient {
|
||||
top_color: String,
|
||||
bottom_color: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InstanceIconConfig {
|
||||
pub background: InstanceIconBackground,
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub id: String,
|
||||
|
||||
@@ -63,7 +63,6 @@ where
|
||||
settings.telemetry = !legacy_settings.opt_out_analytics;
|
||||
settings.discord_rpc = !legacy_settings.disable_discord_rpc;
|
||||
settings.developer_mode = legacy_settings.developer_mode;
|
||||
settings.onboarded = legacy_settings.fully_onboarded;
|
||||
settings.extra_launch_args = legacy_settings.custom_java_args;
|
||||
settings.custom_env_vars = legacy_settings.custom_env_args;
|
||||
settings.memory.maximum = legacy_settings.memory.maximum;
|
||||
@@ -588,14 +587,45 @@ where
|
||||
.await?;
|
||||
|
||||
for group in input.groups {
|
||||
sqlx::query!(
|
||||
let group_id = match sqlx::query_scalar::<_, String>(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_groups (instance_id, group_name)
|
||||
SELECT id
|
||||
FROM instance_groups
|
||||
WHERE name = ?
|
||||
",
|
||||
)
|
||||
.bind(&group)
|
||||
.fetch_optional(exec)
|
||||
.await?
|
||||
{
|
||||
Some(group_id) => group_id,
|
||||
None => {
|
||||
let group_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_groups (id, name)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
)
|
||||
.bind(&group_id)
|
||||
.bind(&group)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
group_id
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT OR IGNORE INTO instance_group_memberships (
|
||||
instance_id,
|
||||
group_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
",
|
||||
instance_id_str,
|
||||
group,
|
||||
)
|
||||
.bind(instance_id_str)
|
||||
.bind(group_id)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
}
|
||||
@@ -659,8 +689,6 @@ struct LegacySettings {
|
||||
pub opt_out_analytics: bool,
|
||||
#[serde(default)]
|
||||
pub advanced_rendering: bool,
|
||||
#[serde(default)]
|
||||
pub fully_onboarded: bool,
|
||||
#[serde(default = "default_settings_dir")]
|
||||
pub loaded_config_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub use self::instances::*;
|
||||
mod settings;
|
||||
pub use self::settings::*;
|
||||
|
||||
mod onboarding_checklist;
|
||||
pub use self::onboarding_checklist::*;
|
||||
|
||||
mod process;
|
||||
pub use self::process::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use sqlx::{Executor, Sqlite};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(
|
||||
feature = "export-ts",
|
||||
derive(ts_rs::TS, postcard_bindgen::PostcardBindings)
|
||||
)]
|
||||
#[serde_binhum::serde_binhum]
|
||||
pub struct OnboardingChecklist {
|
||||
pub has_created_instance: bool,
|
||||
pub has_logged_into_minecraft: bool,
|
||||
pub has_logged_into_modrinth: bool,
|
||||
pub show_checklist: bool,
|
||||
}
|
||||
|
||||
pub(crate) enum OnboardingChecklistItem {
|
||||
CreatedInstance,
|
||||
LoggedIntoMinecraft,
|
||||
LoggedIntoModrinth,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_onboarding_checklist(
|
||||
exec: impl Executor<'_, Database = Sqlite>,
|
||||
) -> crate::Result<OnboardingChecklist> {
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT
|
||||
has_created_instance,
|
||||
has_logged_into_minecraft,
|
||||
has_logged_into_modrinth,
|
||||
show_checklist
|
||||
FROM onboarding_checklist
|
||||
WHERE id = 0
|
||||
",
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
Ok(OnboardingChecklist {
|
||||
has_created_instance: row.has_created_instance == 1,
|
||||
has_logged_into_minecraft: row.has_logged_into_minecraft == 1,
|
||||
has_logged_into_modrinth: row.has_logged_into_modrinth == 1,
|
||||
show_checklist: row.show_checklist == 1,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_onboarding_checklist_item(
|
||||
item: OnboardingChecklistItem,
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> crate::Result<Option<OnboardingChecklist>> {
|
||||
let result = match item {
|
||||
OnboardingChecklistItem::CreatedInstance => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_created_instance = TRUE
|
||||
WHERE id = 0 AND has_created_instance = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
OnboardingChecklistItem::LoggedIntoMinecraft => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_logged_into_minecraft = TRUE
|
||||
WHERE id = 0 AND has_logged_into_minecraft = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
OnboardingChecklistItem::LoggedIntoModrinth => {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET has_logged_into_modrinth = TRUE
|
||||
WHERE id = 0 AND has_logged_into_modrinth = FALSE
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE onboarding_checklist
|
||||
SET show_checklist = FALSE
|
||||
WHERE id = 0
|
||||
AND show_checklist = TRUE
|
||||
AND has_created_instance = TRUE
|
||||
AND has_logged_into_minecraft = TRUE
|
||||
AND has_logged_into_modrinth = TRUE
|
||||
"
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Some(get_onboarding_checklist(pool).await?))
|
||||
}
|
||||
@@ -19,13 +19,13 @@ pub struct Settings {
|
||||
pub advanced_rendering: bool,
|
||||
pub native_decorations: bool,
|
||||
pub toggle_sidebar: bool,
|
||||
pub sync_theme_across_devices: bool,
|
||||
pub sync_behavior_across_devices: bool,
|
||||
|
||||
pub telemetry: bool,
|
||||
pub discord_rpc: bool,
|
||||
pub personalized_ads: bool,
|
||||
|
||||
pub onboarded: bool,
|
||||
|
||||
pub extra_launch_args: Vec<String>,
|
||||
pub custom_env_vars: Vec<(String, String)>,
|
||||
pub memory: MemorySettings,
|
||||
@@ -62,6 +62,7 @@ pub enum FeatureFlag {
|
||||
ServerProjectQa,
|
||||
I18nDebug,
|
||||
ShowInstancePlayTime,
|
||||
CompactInstanceCards,
|
||||
SkipNonEssentialWarnings,
|
||||
AdvancedFiltersCollapsed,
|
||||
AlwaysShowCopyDetails,
|
||||
@@ -85,12 +86,12 @@ impl Settings {
|
||||
max_concurrent_writes, max_concurrent_downloads,
|
||||
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
|
||||
discord_rpc, developer_mode, telemetry, personalized_ads,
|
||||
onboarded,
|
||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
||||
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
|
||||
skipped_update, pending_update_toast_for_version, auto_download_updates,
|
||||
sync_theme_across_devices, sync_behavior_across_devices,
|
||||
version
|
||||
FROM settings
|
||||
"
|
||||
@@ -113,7 +114,6 @@ impl Settings {
|
||||
discord_rpc: res.discord_rpc == 1,
|
||||
developer_mode: res.developer_mode == 1,
|
||||
personalized_ads: res.personalized_ads == 1,
|
||||
onboarded: res.onboarded == 1,
|
||||
extra_launch_args: res
|
||||
.extra_launch_args
|
||||
.as_ref()
|
||||
@@ -150,6 +150,8 @@ impl Settings {
|
||||
pending_update_toast_for_version: res
|
||||
.pending_update_toast_for_version,
|
||||
auto_download_updates: res.auto_download_updates.map(|x| x == 1),
|
||||
sync_theme_across_devices: res.sync_theme_across_devices == 1,
|
||||
sync_behavior_across_devices: res.sync_behavior_across_devices == 1,
|
||||
version: res.version as usize,
|
||||
})
|
||||
}
|
||||
@@ -186,33 +188,34 @@ impl Settings {
|
||||
telemetry = $11,
|
||||
personalized_ads = $12,
|
||||
|
||||
onboarded = $13,
|
||||
extra_launch_args = jsonb($13),
|
||||
custom_env_vars = jsonb($14),
|
||||
mc_memory_max = $15,
|
||||
mc_force_fullscreen = $16,
|
||||
mc_game_resolution_x = $17,
|
||||
mc_game_resolution_y = $18,
|
||||
hide_on_process_start = $19,
|
||||
|
||||
extra_launch_args = jsonb($14),
|
||||
custom_env_vars = jsonb($15),
|
||||
mc_memory_max = $16,
|
||||
mc_force_fullscreen = $17,
|
||||
mc_game_resolution_x = $18,
|
||||
mc_game_resolution_y = $19,
|
||||
hide_on_process_start = $20,
|
||||
hook_pre_launch = $20,
|
||||
hook_wrapper = $21,
|
||||
hook_post_exit = $22,
|
||||
|
||||
hook_pre_launch = $21,
|
||||
hook_wrapper = $22,
|
||||
hook_post_exit = $23,
|
||||
custom_dir = $23,
|
||||
prev_custom_dir = $24,
|
||||
migrated = $25,
|
||||
|
||||
custom_dir = $24,
|
||||
prev_custom_dir = $25,
|
||||
migrated = $26,
|
||||
toggle_sidebar = $26,
|
||||
feature_flags = $27,
|
||||
hide_nametag_skins_page = $28,
|
||||
|
||||
toggle_sidebar = $27,
|
||||
feature_flags = $28,
|
||||
hide_nametag_skins_page = $29,
|
||||
skipped_update = $29,
|
||||
pending_update_toast_for_version = $30,
|
||||
auto_download_updates = $31,
|
||||
|
||||
skipped_update = $30,
|
||||
pending_update_toast_for_version = $31,
|
||||
auto_download_updates = $32,
|
||||
sync_theme_across_devices = $32,
|
||||
sync_behavior_across_devices = $33,
|
||||
|
||||
version = $33
|
||||
version = $34
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -226,7 +229,6 @@ impl Settings {
|
||||
self.developer_mode,
|
||||
self.telemetry,
|
||||
self.personalized_ads,
|
||||
self.onboarded,
|
||||
extra_launch_args,
|
||||
custom_env_vars,
|
||||
self.memory.maximum,
|
||||
@@ -246,6 +248,8 @@ impl Settings {
|
||||
self.skipped_update,
|
||||
self.pending_update_toast_for_version,
|
||||
self.auto_download_updates,
|
||||
self.sync_theme_across_devices,
|
||||
self.sync_behavior_across_devices,
|
||||
version,
|
||||
)
|
||||
.execute(exec)
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{self, Duration, Instant, SystemTime};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::{fs::File, io::AsyncReadExt, io::AsyncWriteExt};
|
||||
use tracing::{debug, info};
|
||||
@@ -330,27 +330,45 @@ fn duration_seconds_ceil(duration: Duration) -> u64 {
|
||||
.saturating_add(u64::from(duration.subsec_nanos() > 0))
|
||||
}
|
||||
|
||||
fn reqwest_client_builder_with_timeout() -> reqwest::ClientBuilder {
|
||||
reqwest_client_builder().read_timeout(Duration::from_secs(30))
|
||||
}
|
||||
|
||||
fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(time::Duration::from_secs(15))
|
||||
.read_timeout(time::Duration::from_secs(30))
|
||||
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.tcp_keepalive(Some(Duration::from_secs(10)))
|
||||
.user_agent(crate::launcher_user_agent())
|
||||
}
|
||||
|
||||
pub static INSECURE_REQWEST_CLIENT: LazyLock<reqwest::Client> =
|
||||
LazyLock::new(|| {
|
||||
reqwest_client_builder_with_timeout()
|
||||
.build()
|
||||
.expect("client configuration should be valid")
|
||||
});
|
||||
|
||||
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest_client_builder_with_timeout()
|
||||
.https_only(true)
|
||||
.build()
|
||||
.expect("client configuration should be valid")
|
||||
});
|
||||
|
||||
pub static INSECURE_NO_TIMEOUT_REQWEST_CLIENT: LazyLock<reqwest::Client> =
|
||||
LazyLock::new(|| {
|
||||
reqwest_client_builder()
|
||||
.build()
|
||||
.expect("client configuration should be valid")
|
||||
});
|
||||
|
||||
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest_client_builder()
|
||||
.https_only(true)
|
||||
.build()
|
||||
.expect("client configuration should be valid")
|
||||
});
|
||||
pub static NO_TIMEOUT_REQWEST_CLIENT: LazyLock<reqwest::Client> =
|
||||
LazyLock::new(|| {
|
||||
reqwest_client_builder()
|
||||
.https_only(true)
|
||||
.build()
|
||||
.expect("client configuration should be valid")
|
||||
});
|
||||
|
||||
const FETCH_ATTEMPTS: usize = 2;
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ import _KeyboardIcon from './icons/keyboard.svg?component'
|
||||
import _LandmarkIcon from './icons/landmark.svg?component'
|
||||
import _LanguagesIcon from './icons/languages.svg?component'
|
||||
import _LayersIcon from './icons/layers.svg?component'
|
||||
import _LayoutGridIcon from './icons/layout-grid.svg?component'
|
||||
import _LayoutTemplateIcon from './icons/layout-template.svg?component'
|
||||
import _LeftArrowIcon from './icons/left-arrow.svg?component'
|
||||
import _LibraryIcon from './icons/library.svg?component'
|
||||
@@ -212,6 +213,8 @@ import _PackageIcon from './icons/package.svg?component'
|
||||
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||
import _PackagePlusIcon from './icons/package-plus.svg?component'
|
||||
import _PackageSearchIcon from './icons/package-search.svg?component'
|
||||
import _PageRoundIcon from './icons/page-round.svg?component'
|
||||
import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
||||
import _PaletteIcon from './icons/palette.svg?component'
|
||||
import _PauseIcon from './icons/pause.svg?component'
|
||||
@@ -261,6 +264,7 @@ import _SortDescIcon from './icons/sort-desc.svg?component'
|
||||
import _SparklesIcon from './icons/sparkles.svg?component'
|
||||
import _SpinnerIcon from './icons/spinner.svg?component'
|
||||
import _SplitIcon from './icons/split.svg?component'
|
||||
import _SquarePlusIcon from './icons/square-plus.svg?component'
|
||||
import _StarIcon from './icons/star.svg?component'
|
||||
import _StopCircleIcon from './icons/stop-circle.svg?component'
|
||||
import _StoreIcon from './icons/store.svg?component'
|
||||
@@ -608,6 +612,7 @@ export const KeyboardIcon = _KeyboardIcon
|
||||
export const LandmarkIcon = _LandmarkIcon
|
||||
export const LanguagesIcon = _LanguagesIcon
|
||||
export const LayersIcon = _LayersIcon
|
||||
export const LayoutGridIcon = _LayoutGridIcon
|
||||
export const LayoutTemplateIcon = _LayoutTemplateIcon
|
||||
export const LeftArrowIcon = _LeftArrowIcon
|
||||
export const LibraryIcon = _LibraryIcon
|
||||
@@ -652,6 +657,8 @@ export const PackageIcon = _PackageIcon
|
||||
export const PackageClosedIcon = _PackageClosedIcon
|
||||
export const PackageOpenIcon = _PackageOpenIcon
|
||||
export const PackagePlusIcon = _PackagePlusIcon
|
||||
export const PackageSearchIcon = _PackageSearchIcon
|
||||
export const PageRoundIcon = _PageRoundIcon
|
||||
export const PaintbrushIcon = _PaintbrushIcon
|
||||
export const PaletteIcon = _PaletteIcon
|
||||
export const PauseIcon = _PauseIcon
|
||||
@@ -701,6 +708,7 @@ export const SortDescIcon = _SortDescIcon
|
||||
export const SparklesIcon = _SparklesIcon
|
||||
export const SpinnerIcon = _SpinnerIcon
|
||||
export const SplitIcon = _SplitIcon
|
||||
export const SquarePlusIcon = _SquarePlusIcon
|
||||
export const StarIcon = _StarIcon
|
||||
export const StopCircleIcon = _StopCircleIcon
|
||||
export const StoreIcon = _StoreIcon
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-layout-grid"
|
||||
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"
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-package-search"
|
||||
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="M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14" />
|
||||
<path d="m7.5 4.27 9 5.15" />
|
||||
<polyline points="3.29 7 12 12 20.71 7" />
|
||||
<line x1="12" x2="12" y1="22" y2="12" />
|
||||
<circle cx="18.5" cy="15.5" r="2.5" />
|
||||
<path d="M20.27 17.27 22 19" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="110" height="110" viewBox="0 0 110 110" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M93.9596 55.0001C93.9596 63.4375 87.1195 70.2777 78.682 70.2777C75.6304 70.2777 72.033 69.7433 69.0657 70.538C66.4298 71.2443 64.3705 73.3036 63.6643 75.9395C62.8695 78.9067 63.4039 82.5042 63.4039 85.5558C63.4039 93.9932 56.5638 100.833 48.1263 100.833M36.668 32.0834H68.7513M36.668 50.4168H50.418M16.043 45.8334C16.043 28.5486 16.043 19.9062 21.7483 14.5364C27.4536 9.16675 36.6362 9.16675 55.0013 9.16675H58.5428C73.49 9.16675 80.9636 9.16675 86.1538 12.8235C87.6411 13.8712 88.9611 15.1137 90.0743 16.5133C93.9596 21.3982 93.9596 28.4321 93.9596 42.5001V54.1668C93.9596 67.7482 93.9596 74.5384 91.8105 79.9618C88.3551 88.6812 81.0479 95.5584 71.7836 98.8103C66.0215 100.833 58.8064 100.833 44.3763 100.833C36.1306 100.833 32.0077 100.833 28.7149 99.6775C23.4212 97.8194 19.2456 93.8892 17.2711 88.9071C16.043 85.8079 16.043 81.9276 16.043 74.1667V45.8334Z"
|
||||
stroke="white"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-square-plus"
|
||||
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"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M8 12h8" />
|
||||
<path d="M12 8v8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: 'More ways to personalize your library'
|
||||
summary: The new Play page, overhauled instance groups, custom icons, and a smoother onboarding experience.
|
||||
date: 2026-08-17T17:30:00-07:00
|
||||
authors: ['AJfd8YH6', '6EjnV9Uf']
|
||||
---
|
||||
|
||||
Hey everyone,
|
||||
|
||||
Creating, organizing, and finding instances has always been one of the most important parts of the Modrinth App, but we weren’t happy with how this worked.
|
||||
|
||||
With this update, we’ve brought the Home and Library together into a new Play page, rebuilt instance organization, added an icon editor, and improved onboarding across the whole app.
|
||||
|
||||
### TL;DR
|
||||
|
||||
- New Play page combining Home and Library
|
||||
- Improved instance creation flow
|
||||
- New icon editor for instance icons
|
||||
- Improved instance groups and filtering
|
||||
- New onboarding checklist for first-time users
|
||||
- Updated empty states and sign-in modals
|
||||
|
||||
## Play page
|
||||
|
||||
We’ve introduced a new Play page that merges the old Home and Library pages. We had a few goals for this page:
|
||||
|
||||
- Bring everything related to playing into one place
|
||||
- Make it clearer how to actually launch the game
|
||||
- Improve how instances are organized
|
||||
|
||||

|
||||
|
||||
### Jump in section
|
||||
|
||||
This is basically an evolution of the Jump back in section. Newly created instances can now show up here as well. Previously, you had to launch an instance and join a world or server before anything would populate, which wasn’t great for new users.
|
||||
|
||||
We also made a few other changes:
|
||||
|
||||
- An instance and world will no longer both show up. Once you join a world or server, it replaces the instance entry
|
||||
- There is a hard limit of five slots instead of the previous three or six based on recency
|
||||
- Iterated on the overall design
|
||||
|
||||
### Library section
|
||||
|
||||
The instance library got a complete overhaul. You can now create groups and instances directly from the Library section.
|
||||
|
||||
Groups can be created by clicking the “New group” button on the page, or by selecting multiple instances and clicking “New group” in the action bar. The action bar also includes other actions like removing instances from a group or deleting them.
|
||||
|
||||
Once a group is created, you can edit its name, change the instances within it, and reorder your groups from the controls in the top right. Instances can also be selected or multi-selected and dragged between groups.
|
||||
|
||||
<div class="video-wrapper mb-8">
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="./groups-demo.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
|
||||
The instance creation flow also got some improvements. You can now search for content directly if you already know what you want to play and we’ve added a new option for uploading a modpack.
|
||||
|
||||

|
||||
|
||||
## Icon editor
|
||||
|
||||
A long standing problem with the instance library is that it’s always been hard to spot instances at a glance. Custom instances all use a default icon and choosing your own image isn’t easy.
|
||||
|
||||
To fix this, we’ve introduced an icon editor where you can create your own icon for each instance, or we’ll randomize one for you. When making an icon, you can mix and match different backgrounds and symbols until you land on something that feels right.
|
||||
|
||||
We’ve released an initial set of backgrounds and symbols, with more to come over time. A big thanks to the creators of these mods who let us feature items from their content in this first drop!
|
||||
|
||||
- Cobblemon
|
||||
- Create
|
||||
- Create Aeronautics
|
||||
- Farmer's Delight
|
||||
- Supplementaries
|
||||
- Sophisticated Backpacks
|
||||
- Ad Astra
|
||||
- Handcrafted
|
||||
- Origins
|
||||
- Botania
|
||||
- Blåhaj
|
||||
|
||||

|
||||
|
||||
## Onboarding
|
||||
|
||||
Along with this update, we wanted to make the app experience better for new users. We’ve introduced a getting started checklist that covers creating your first instance, signing into Minecraft, and signing into Modrinth. The natural flow of the app already takes you through most of this, but it works as a helpful reminder.
|
||||
|
||||
### Empty states
|
||||
|
||||
We’ve also updated several of the more important empty states in the app. This includes the new Play page, Hosting page, and letting you play around with the skin editor without being logged in.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Sign-in modals
|
||||
|
||||
Along with the empty states, the Microsoft and Modrinth account sign-in modals also got a new look as part of the onboarding work.
|
||||
|
||||

|
||||
@@ -10,6 +10,110 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-08-20T22:10:10+00:00`,
|
||||
product: 'app',
|
||||
version: '0.18.2',
|
||||
body: `## Security
|
||||
- Fixed .mrpack exports replacing the original file with the primary file of a version previously detected on hash lookup.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-20T21:46:49+00:00`,
|
||||
product: 'app',
|
||||
version: '0.18.1',
|
||||
body: `## Added
|
||||
- Added ability to click on the ellipses in pagination controls to go to a specific page.
|
||||
- Added compact mode for instances in library.
|
||||
- Added drag handle to resize the amount of items in the "Jump in" section.
|
||||
- Added icon editing actions for instance context menu.
|
||||
- Added back sort by loader and game version.
|
||||
- Added new loader symbols for icon editor.
|
||||
- Depends on search filter, which filters all projects that depends on the project you've selected.
|
||||
- Modpack included content filter, which filters projects that includes a set of projects you've selected.
|
||||
- Enabled friend request controls in Social settings - you can now limit who can send you friend requests on Modrinth.
|
||||
- Enabled shared instance invite controls in Social settings, letting you limit who can send you shared instance invites.
|
||||
- Enabled Modrinth Hosting access invite controls in Social settings - you can now limit who can invite you to manage a Modrinth Hosting server.
|
||||
- Added a new option to sync your behaviour & appearance settings across Modrinth App sessions on multiple devices.
|
||||
- Added language coverage to the Language settings page.
|
||||
|
||||
## Changed
|
||||
- Expanded quick instance area now that there is more vertical height in the navbar.
|
||||
- Tab bars now display with smaller text and without icons on mobile.
|
||||
|
||||
## Fixed
|
||||
- Fixed issue where projects not installed display as installed.
|
||||
- Fixed tab bars displaying weirdly on mobile.
|
||||
- Fixed tab bar shadows being cut off.
|
||||
- Fixed tab bars showing the highlight as the wrong size or position sometimes.
|
||||
- Fixed missing signal icon and players online in "Jump in" cards.
|
||||
- Stopped applying conditional icon padding for user avatars.
|
||||
- Fixed memory leak on Discover page`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-20T21:46:49+00:00`,
|
||||
product: 'web',
|
||||
body: `## Added
|
||||
- Added ability to click on the ellipses in pagination controls to go to a specific page.
|
||||
- Depends on search filter, which filters all projects that depends on the project you've selected.
|
||||
- Modpack included content filter, which filters projects that includes a set of projects you've selected.
|
||||
- Enabled friend request controls in Social settings - you can now limit who can send you friend requests on Modrinth.
|
||||
- Enabled shared instance invite controls in Social settings, letting you limit who can send you shared instance invites.
|
||||
- Enabled Modrinth Hosting access invite controls in Social settings - you can now limit who can invite you to manage a Modrinth Hosting server.
|
||||
- Added a new option to sync your appearance settings across website sessions on multiple devices.
|
||||
- Added language coverage to the Language settings page.
|
||||
|
||||
## Fixed
|
||||
- Fixed server projects not displaying properly in orgs and collections.
|
||||
- Fixed tab bars displaying weirdly on mobile.
|
||||
- Fixed tab bar shadows being cut off.
|
||||
- Fixed tab bars showing the highlight as the wrong size or position sometimes.
|
||||
- Stopped applying conditional icon padding for user avatars.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-18T03:40:19+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Improved loader detection logic for Fabric derivatives when uploading versions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-18T01:11:08+00:00`,
|
||||
product: 'app',
|
||||
version: '0.18.0',
|
||||
body: `## Added
|
||||
- Added new Play page, replacing the old Home and Library pages.
|
||||
- Added an icon creator so you can create icons for your instances if you don't have your own art for it.
|
||||
- Added a "Getting started" checklist to help you finish setting up the app.
|
||||
|
||||
## Changed
|
||||
- Improved the create instance menu to make the options clearer and let you search for projects right from the menu.
|
||||
- Improved the empty states to help guide you to creating an instance.
|
||||
- Improved the sign-in pop-up for when your Minecraft account is required.
|
||||
- Skin selector can now be previewed before signing in with your Minecraft account.
|
||||
- Reintroduced the modpack icon in the content tab's managed content card.
|
||||
- Renamed "Managed content" -> "Provided content"
|
||||
|
||||
## Fixed
|
||||
- Fixed hooks and environment variables not saving.
|
||||
- Fixed hover state on the "Reload to update" button
|
||||
- Fixed header alignment issue in the browse content page
|
||||
- Fixed instance icon being deleted after a installing a server project's shared content.
|
||||
- Fixed flickering on the content tab at specific app window widths.
|
||||
- Fixed issue where some instances when unlinking a modpack or shared instance, the newly unlinked content would not have updates checked
|
||||
- Fixed broken gallery image viewer on project pages.
|
||||
- Attempted to fix bug on macOS that prevented LAN play in some cases.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-18T01:11:08+00:00`,
|
||||
product: 'web',
|
||||
body: `## Added
|
||||
- Added placeholder report reasons for AI images and fully AI-generated content.
|
||||
|
||||
## Changed
|
||||
- Improved site embeds on content without icons and projects/users that cannot be found.
|
||||
|
||||
## Fixed
|
||||
- Fixed report page failing to load when user is not logged in.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-15T02:19:39+00:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const article = {
|
||||
html: () => import(`./app_personalization.content`).then(m => m.html),
|
||||
title: "More ways to personalize your library",
|
||||
summary: "The new Play page, overhauled instance groups, custom icons, and a smoother onboarding experience.",
|
||||
date: "2026-08-18T00:30:00.000Z",
|
||||
slug: "app-personalization",
|
||||
authors: ["AJfd8YH6","6EjnV9Uf"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_f
|
||||
import { article as accelerating_development } from "./accelerating_development";
|
||||
import { article as ai_policy_and_disclosures } from "./ai_policy_and_disclosures";
|
||||
import { article as analytics_overhaul } from "./analytics_overhaul";
|
||||
import { article as app_personalization } from "./app_personalization";
|
||||
import { article as becoming_sustainable } from "./becoming_sustainable";
|
||||
import { article as capital_return } from "./capital_return";
|
||||
import { article as carbon_ads } from "./carbon_ads";
|
||||
@@ -50,6 +51,7 @@ export const articles = [
|
||||
accelerating_development,
|
||||
ai_policy_and_disclosures,
|
||||
analytics_overhaul,
|
||||
app_personalization,
|
||||
becoming_sustainable,
|
||||
capital_return,
|
||||
carbon_ads,
|
||||
|
||||
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 705 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "labrinth-derive"
|
||||
name = "component-derive"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
@@ -0,0 +1,279 @@
|
||||
use darling::{FromDeriveInput, FromField};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{Attribute, DeriveInput, Error, Ident, Result, Type, Visibility};
|
||||
|
||||
#[derive(Debug, FromDeriveInput)]
|
||||
#[darling(supports(struct_named))]
|
||||
struct Component {
|
||||
ident: Ident,
|
||||
vis: Visibility,
|
||||
data: darling::ast::Data<(), ComponentField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromField)]
|
||||
#[darling(attributes(component), forward_attrs)]
|
||||
struct ComponentField {
|
||||
ident: Option<Ident>,
|
||||
vis: Visibility,
|
||||
ty: Type,
|
||||
attrs: Vec<Attribute>,
|
||||
#[darling(default)]
|
||||
synthetic: bool,
|
||||
#[darling(default)]
|
||||
nested: bool,
|
||||
}
|
||||
|
||||
pub fn derive(input: &DeriveInput) -> Result<TokenStream> {
|
||||
let Component { ident, vis, data } = Component::from_derive_input(input)?;
|
||||
let fields = data
|
||||
.take_struct()
|
||||
.expect("macro only works on structs with named fields");
|
||||
|
||||
let fields = &fields.fields;
|
||||
let struct_serial = struct_serial(&vis, &ident, fields)?;
|
||||
let struct_partial = struct_partial(&vis, &ident, fields)?;
|
||||
let impl_apply_to = impl_apply_to(&ident, fields);
|
||||
let impl_into_diff_from = impl_into_diff_from(&ident, fields);
|
||||
|
||||
// `#[validate(nested)]` needs `Validate` in scope; `as _` avoids a name clash
|
||||
let validate_import = if fields.iter().any(|field| field.nested) {
|
||||
quote! { use validator::Validate as _; }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
Ok(quote! {
|
||||
#validate_import
|
||||
|
||||
#struct_serial
|
||||
#struct_partial
|
||||
|
||||
const _: () = {
|
||||
#impl_apply_to
|
||||
#impl_into_diff_from
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn struct_serial(
|
||||
vis: &Visibility,
|
||||
ident: &Ident,
|
||||
fields: &[ComponentField],
|
||||
) -> Result<TokenStream> {
|
||||
let ident_serial = format_ident!("Serial{ident}");
|
||||
|
||||
let fields = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = &field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let vis = &field.vis;
|
||||
let ty = &field.ty;
|
||||
let attrs = &field.attrs;
|
||||
|
||||
let (field_ty, validate_attr) = if field.nested {
|
||||
let field_ty = match nested_type(ty, "Serial") {
|
||||
Ok(field_ty) => field_ty,
|
||||
Err(err) => return Some(Err(err)),
|
||||
};
|
||||
(field_ty, quote! { #[validate(nested)] })
|
||||
} else {
|
||||
(quote! { #ty }, quote! {})
|
||||
};
|
||||
|
||||
Some(Ok(quote! {
|
||||
#(#attrs)*
|
||||
#validate_attr
|
||||
#vis #ident: #field_ty
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(quote! {
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
::serde::Serialize,
|
||||
::serde::Deserialize,
|
||||
::validator::Validate,
|
||||
::utoipa::ToSchema,
|
||||
)]
|
||||
#vis struct #ident_serial {
|
||||
#(#fields),*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn struct_partial(
|
||||
vis: &Visibility,
|
||||
ident: &Ident,
|
||||
fields: &[ComponentField],
|
||||
) -> Result<TokenStream> {
|
||||
let ident_partial = format_ident!("Partial{ident}");
|
||||
|
||||
let fields = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = &field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let vis = &field.vis;
|
||||
let ty = &field.ty;
|
||||
let attrs = &field.attrs;
|
||||
|
||||
let (inner_ty, validate_attr) = if field.nested {
|
||||
let inner_ty = match nested_type(ty, "Partial") {
|
||||
Ok(inner_ty) => inner_ty,
|
||||
Err(err) => return Some(Err(err)),
|
||||
};
|
||||
(inner_ty, quote! { #[validate(nested)] })
|
||||
} else {
|
||||
(quote! { #ty }, quote! {})
|
||||
};
|
||||
|
||||
let serde_attr = if !field.nested
|
||||
&& let Type::Path(path) = ty
|
||||
&& path
|
||||
.path
|
||||
.segments
|
||||
.first()
|
||||
.is_some_and(|segment| segment.ident == "Option")
|
||||
{
|
||||
quote! {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "::core::option::Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
)]
|
||||
}
|
||||
} else {
|
||||
quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] }
|
||||
};
|
||||
|
||||
Some(Ok(quote! {
|
||||
#(#attrs)*
|
||||
#validate_attr
|
||||
#serde_attr
|
||||
#vis #ident: ::core::option::Option<#inner_ty>
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(quote! {
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
::serde::Serialize,
|
||||
::serde::Deserialize,
|
||||
::validator::Validate,
|
||||
::utoipa::ToSchema,
|
||||
)]
|
||||
#vis struct #ident_partial {
|
||||
#(#fields),*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn impl_apply_to(ident: &Ident, fields: &[ComponentField]) -> TokenStream {
|
||||
let ident_partial = format_ident!("Partial{ident}");
|
||||
|
||||
let apply_fields = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let apply_value = if field.nested {
|
||||
quote! { t.apply_to(&mut component.#ident) }
|
||||
} else {
|
||||
quote! { component.#ident = t }
|
||||
};
|
||||
|
||||
Some(quote! {
|
||||
if let Some(t) = self.#ident {
|
||||
#apply_value;
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
quote! {
|
||||
impl #ident_partial {
|
||||
pub fn apply_to(self, component: &mut #ident) {
|
||||
#(#apply_fields)*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn impl_into_diff_from(
|
||||
ident: &Ident,
|
||||
fields: &[ComponentField],
|
||||
) -> TokenStream {
|
||||
let ident_partial = format_ident!("Partial{ident}");
|
||||
|
||||
let diff_fields = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let diff_value = if field.nested {
|
||||
quote! { self.#ident.into_diff_from(&base.#ident) }
|
||||
} else {
|
||||
quote! { self.#ident }
|
||||
};
|
||||
|
||||
Some(quote! {
|
||||
#ident: (self.#ident != base.#ident).then(|| #diff_value)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
quote! {
|
||||
impl #ident {
|
||||
pub fn into_diff_from(self, base: &Self) -> #ident_partial {
|
||||
#ident_partial {
|
||||
#(#diff_fields),*
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nested_type(ty: &Type, prefix: &str) -> Result<TokenStream> {
|
||||
if let Type::Path(path) = ty
|
||||
&& let Some(segment) = path.path.segments.last()
|
||||
{
|
||||
// FIXME: Validate that nested type also derives component, prob by checking for component impl
|
||||
let nested = format_ident!("{}{}", prefix, segment.ident);
|
||||
Ok(quote! { #nested })
|
||||
} else {
|
||||
Err(Error::new_spanned(
|
||||
ty,
|
||||
"nested component fields must be a named path type",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
use darling::{FromDeriveInput, FromField};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{Attribute, DeriveInput, Ident, Result, Type, Visibility};
|
||||
|
||||
#[derive(Debug, FromDeriveInput)]
|
||||
#[darling(supports(struct_named))]
|
||||
struct Component {
|
||||
ident: Ident,
|
||||
vis: Visibility,
|
||||
data: darling::ast::Data<(), ComponentField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromField)]
|
||||
#[darling(attributes(component), forward_attrs)]
|
||||
struct ComponentField {
|
||||
ident: Option<Ident>,
|
||||
vis: Visibility,
|
||||
ty: Type,
|
||||
attrs: Vec<Attribute>,
|
||||
#[darling(default)]
|
||||
synthetic: bool,
|
||||
}
|
||||
|
||||
pub fn derive(input: &DeriveInput) -> Result<TokenStream> {
|
||||
let Component { ident, vis, data } = Component::from_derive_input(input)?;
|
||||
let fields = data
|
||||
.take_struct()
|
||||
.expect("macro only works on structs with named fields");
|
||||
|
||||
let fields = &fields.fields;
|
||||
let struct_serial = struct_serial(&vis, &ident, fields)?;
|
||||
let struct_edit = struct_edit(&vis, &ident, fields)?;
|
||||
|
||||
Ok(quote! {
|
||||
#struct_serial
|
||||
#struct_edit
|
||||
})
|
||||
}
|
||||
|
||||
fn struct_serial(
|
||||
vis: &Visibility,
|
||||
ident: &Ident,
|
||||
fields: &[ComponentField],
|
||||
) -> Result<TokenStream> {
|
||||
let ident_serial = format_ident!("{ident}Serial");
|
||||
|
||||
let fields = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = &field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let vis = &field.vis;
|
||||
let ty = &field.ty;
|
||||
let attrs = &field.attrs;
|
||||
|
||||
Some(quote! {
|
||||
#(#attrs)*
|
||||
#vis #ident: #ty
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(quote! {
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
::serde::Serialize,
|
||||
::serde::Deserialize,
|
||||
::validator::Validate,
|
||||
::utoipa::ToSchema,
|
||||
)]
|
||||
#vis struct #ident_serial {
|
||||
#(#fields),*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn struct_edit(
|
||||
vis: &Visibility,
|
||||
ident: &Ident,
|
||||
fields: &[ComponentField],
|
||||
) -> Result<TokenStream> {
|
||||
let ident_edit = format_ident!("{ident}Edit");
|
||||
|
||||
let (fields, apply_fields): (Vec<_>, Vec<_>) = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.synthetic {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ident = &field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("macro only works on structs with named fields");
|
||||
let vis = &field.vis;
|
||||
let ty = &field.ty;
|
||||
let attrs = &field.attrs;
|
||||
|
||||
let serde_attr = if let Type::Path(path) = ty
|
||||
&& let Some(root_ident) = path.path.segments.first()
|
||||
&& root_ident.ident == "Option"
|
||||
{
|
||||
quote! {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "::core::option::Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
)]
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[serde(default)]
|
||||
}
|
||||
};
|
||||
|
||||
Some((
|
||||
quote! {
|
||||
#(#attrs)*
|
||||
#serde_attr
|
||||
#vis #ident: ::core::option::Option<#ty>
|
||||
},
|
||||
quote! {
|
||||
if let Some(t) = self.#ident {
|
||||
component.#ident = t;
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
.unzip();
|
||||
|
||||
Ok(quote! {
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
::serde::Serialize,
|
||||
::serde::Deserialize,
|
||||
::validator::Validate,
|
||||
::utoipa::ToSchema,
|
||||
)]
|
||||
#vis struct #ident_edit {
|
||||
#(#fields),*
|
||||
}
|
||||
|
||||
impl #ident_edit {
|
||||
pub fn apply_to(
|
||||
self,
|
||||
component: &mut #ident,
|
||||
) {
|
||||
#(#apply_fields)*
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,17 @@ const copyProjectLink = async (
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url)
|
||||
return url
|
||||
}
|
||||
|
||||
function isOfficialModrinthHost(): boolean {
|
||||
const host = globalThis.location?.hostname
|
||||
return host === 'modrinth.com' || host === 'www.modrinth.com' || host === 'staging.modrinth.com'
|
||||
}
|
||||
|
||||
function isLocalhost(): boolean {
|
||||
const host = globalThis.location?.hostname
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
|
||||
}
|
||||
|
||||
const keybinds: { [id: string]: KeybindListener } = {
|
||||
@@ -71,31 +82,68 @@ const keybinds: { [id: string]: KeybindListener } = {
|
||||
keybind: 'Ctrl+Alt+C',
|
||||
description: 'Copy permalink',
|
||||
scope: 'project',
|
||||
action: async (ctx) => copyProjectLink(ctx.project, true, false, false),
|
||||
action: async (ctx) => {
|
||||
const url = await copyProjectLink(ctx.project, true, false, false)
|
||||
ctx.notifyCopied(url, 'Copied permalink to clipboard')
|
||||
},
|
||||
},
|
||||
'copy-relative-permalink': {
|
||||
keybind: 'Ctrl+Alt+R',
|
||||
description: 'Copy relative permalink',
|
||||
scope: 'project',
|
||||
action: async (ctx) => copyProjectLink(ctx.project, true, true, false),
|
||||
action: async (ctx) => {
|
||||
const url = await copyProjectLink(ctx.project, true, true, false)
|
||||
ctx.notifyCopied(url, 'Copied relative permalink to clipboard')
|
||||
},
|
||||
},
|
||||
'copy-page-permalink': {
|
||||
keybind: 'Shift+Ctrl+Alt+C',
|
||||
description: 'Copy permalink with page',
|
||||
scope: 'project',
|
||||
action: async (ctx) => copyProjectLink(ctx.project, true, false, true),
|
||||
action: async (ctx) => {
|
||||
const url = await copyProjectLink(ctx.project, true, false, true)
|
||||
ctx.notifyCopied(url, 'Copied permalink with page to clipboard')
|
||||
},
|
||||
},
|
||||
'copy-page-relative-permalink': {
|
||||
keybind: 'Shift+Ctrl+Alt+R',
|
||||
description: 'Copy relative permalink with page',
|
||||
scope: 'project',
|
||||
action: async (ctx) => copyProjectLink(ctx.project, true, true, true),
|
||||
action: async (ctx) => {
|
||||
const url = await copyProjectLink(ctx.project, true, true, true)
|
||||
ctx.notifyCopied(url, 'Copied relative permalink with page to clipboard')
|
||||
},
|
||||
},
|
||||
'copy-id': {
|
||||
keybind: 'Ctrl+Alt+D',
|
||||
description: 'Copy Project ID',
|
||||
scope: 'project',
|
||||
action: async (ctx) => await navigator.clipboard.writeText(ctx.project.id),
|
||||
action: async (ctx) => {
|
||||
await navigator.clipboard.writeText(ctx.project.id)
|
||||
ctx.notifyCopied(ctx.project.id, 'Copied Project ID to clipboard')
|
||||
},
|
||||
},
|
||||
'open-official-site': {
|
||||
keybind: 'Ctrl+Shift+P',
|
||||
description: 'Open current page on production/staging',
|
||||
scope: 'global',
|
||||
enabled: () => !isOfficialModrinthHost(),
|
||||
action: (ctx) => {
|
||||
globalThis.open(ctx.officialUrl, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
},
|
||||
'copy-official-site': {
|
||||
keybind: 'Ctrl+Shift+O',
|
||||
description: 'Copy production/staging URL (localhost only)',
|
||||
scope: 'global',
|
||||
enabled: () => isLocalhost(),
|
||||
action: async (ctx) => {
|
||||
await navigator.clipboard.writeText(ctx.officialUrl)
|
||||
const environment = ctx.officialUrl.startsWith('https://staging.modrinth.com')
|
||||
? 'staging'
|
||||
: 'production'
|
||||
ctx.notifyCopied(ctx.officialUrl, `Copied ${environment} URL to clipboard`)
|
||||
},
|
||||
},
|
||||
'approve-project': {
|
||||
keybind: 'Shift+Alt+A',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
## Missing Content Disclosures
|
||||
|
||||
%R5.9%, we ask that you ensure all applicable %PROJECT_CONTENT_DISCLOSURES_FLINK% are filled out with accurate information.
|
||||
%R5.9%, we ask that you ensure all applicable %PROJECT_CONTENT_DISCLOSURES_FLINK% are filled out with accurate information. \
|
||||
For additional information and guidance, please refer to our %DISCLOSURES_FAQ_FLINK%.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
## Misused Disclosures
|
||||
|
||||
%R5.9%, we ask that you ensure all %PROJECT_CONTENT_DISCLOSURES_FLINK% are filled out accurately and appropriately with information directly relevant to the Disclosures purpose and your project's content.
|
||||
%R5.9%, we ask that you ensure all %PROJECT_CONTENT_DISCLOSURES_FLINK% are filled out accurately and appropriately with information directly relevant to the disclosure's purpose and your project's content.
|
||||
|
||||
Please refer to our %DISCLOSURES_FAQ_FLINK% FAQ for more info.
|
||||
|
||||
@@ -44,11 +44,12 @@ export class Keybinds {
|
||||
|
||||
handle(event: KeyboardEvent, ctx: ModerationContext): boolean {
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target as HTMLElement)?.closest('.cm-editor') ||
|
||||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
|
||||
(event.target as HTMLElement)?.classList?.contains('cm-line')
|
||||
ctx.scope !== 'global' &&
|
||||
(event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target as HTMLElement)?.closest('.cm-editor') ||
|
||||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
|
||||
(event.target as HTMLElement)?.classList?.contains('cm-line'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "اختر علامة دقيقة"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "تأكد من اطلاع المستخدمين على أي تفاصيل مهمة من خلال تعبئة إفصاحات المحتوى التي تنطبق على {type}."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "تحقق من إفصاحات المحتوى"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "وصفك هو {length, plural, one {# readable character} other {# readable characters}}. يُوصى بأن يكون على الأقل {minChars, plural, one {# character}other{#characters}} لإنشاء وصف واضح وغني بالمعلومات."
|
||||
},
|
||||
@@ -87,7 +93,7 @@
|
||||
"defaultMessage": "بعض روابطك الإضافية تبدو متطابقة. كل رابط يجب أن يكون فريدا بنوع رابط مناسب."
|
||||
},
|
||||
"nags.identical-links.title": {
|
||||
"defaultMessage": "Clean up identical links"
|
||||
"defaultMessage": "تنظيف الروابط المتطابقة"
|
||||
},
|
||||
"nags.image-heavy-description.description": {
|
||||
"defaultMessage": "وصفك يجب أن يحتوي على نص عادي أو نص بديل للصورة، لتكون متاحة لمستخدمي قارئ الشاشة أو من لا توجد عنده اتصال إنترنت جيد."
|
||||
@@ -114,7 +120,7 @@
|
||||
"defaultMessage": "لا تستخدم مقصر روابط"
|
||||
},
|
||||
"nags.long-headers.description": {
|
||||
"defaultMessage": "{count, plural, one {# header} other {# headers}} في وصفك {count, plural, one {طويل جدًا} other {طويلة جدًا}}.يجب أن تكون العناوين مختصرة وتعمل كعناوين للأقسام،وليست جملًا كاملة.\n"
|
||||
"defaultMessage": "{count, plural, one {# header} other {# headers}} في وصفك {count, plural, one {طويل جدًا} other {طويلة جدًا}}.يجب أن تكون العناوين مختصرة وتعمل كعناوين للأقسام،وليست جملًا كاملة. "
|
||||
},
|
||||
"nags.long-headers.title": {
|
||||
"defaultMessage": "قصر العناوين"
|
||||
@@ -147,19 +153,19 @@
|
||||
"defaultMessage": "مراجعة التعليقات."
|
||||
},
|
||||
"nags.multiple-resolution-tags.description": {
|
||||
"defaultMessage": "لقد اخترت {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}) حزمة الموارد يجب ان تمتلك فقط علامة دقة واحدة تتطابق مع دقتهم الأساسية."
|
||||
"defaultMessage": "لقد اخترت {count, plural, one {وسم دقة واحد} two {وسمي دقة} few {# وسوم دقة} many {# وسم دقة} other {# وسم دقة}} ({tags}). يُفضّل أن يحتوي ريسورسباك عادةً على وسم دقة واحد فقط، بحيث يتوافق مع دقته الأساسية."
|
||||
},
|
||||
"nags.multiple-resolution-tags.title": {
|
||||
"defaultMessage": "أختر قرار صحيح"
|
||||
},
|
||||
"nags.review-permissions.description": {
|
||||
"defaultMessage": "الرجاء التأكد من توفير إثبات لحصولك على أُذون لنشر أي محتوى خارجي في حزمة التعديلات الخاصة بك."
|
||||
"defaultMessage": "تأكد من تقديم ما يثبت حصولك على إذن لتوزيع أي محتوى خارجي ضمن المودباك الخاص بك."
|
||||
},
|
||||
"nags.review-permissions.title": {
|
||||
"defaultMessage": "مراجعة الأُذون الخارجية"
|
||||
},
|
||||
"nags.select-compatibility.description": {
|
||||
"defaultMessage": "ماهي الإصدارات التي يدعمها خادمك. اختر مودباك, أو اضف من عندك."
|
||||
"defaultMessage": "حدد إصدارات اللعبة التي يدعمها خادمك، واختر مودباك أو ارفع مودباكك الخاص."
|
||||
},
|
||||
"nags.select-compatibility.title": {
|
||||
"defaultMessage": "اختر التوافق."
|
||||
@@ -194,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "زيارة إعدادات الوصف"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "انتقل إلى إعدادات الإفصاحات"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "زيارة إعدادات الحقوق"
|
||||
},
|
||||
@@ -209,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "زيارة الإعدادات العامة"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "زيارة إعدادات الإصدارات"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "لا يمكن أن يكون ملخص مشروعك نفس اسمه. من المهم أن تصنع ملخصا واضحا وغني بالمعلومات."
|
||||
},
|
||||
@@ -254,6 +266,12 @@
|
||||
"nags.upload-gallery-image.description": {
|
||||
"defaultMessage": "يُطلب صورة واحدة على الأقل في المَعْرِض لعرض محتوى {type} الخاص بك."
|
||||
},
|
||||
"nags.upload-gallery-image.description-resourcepack": {
|
||||
"defaultMessage": "يجب إضافة صورة واحدة على الأقل إلى المعرض لعرض محتوى الريسورسباك الخاص بك، باستثناء ريسورساكات الصوت أو الترجمة. إذا كان هذا ينطبق على ريسورسباكك، يُرجى اختيار الوسم المناسب."
|
||||
},
|
||||
"nags.upload-gallery-image.description-shader": {
|
||||
"defaultMessage": "يلزم وجود ثلاث صور معرض على الأقل لعرض محتوى المظلل (Shader) الخاص بك في مجموعة متنوعة من الحالات والظروف."
|
||||
},
|
||||
"nags.upload-gallery-image.title": {
|
||||
"defaultMessage": "رفع صورة من المَعْرِض"
|
||||
},
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"nags.add-links.title": {
|
||||
"defaultMessage": "Přidejte externí odkazy"
|
||||
},
|
||||
"nags.all-languages.description": {
|
||||
"defaultMessage": "Vybrali jste všechny dostupné možnosti jazyka. Prosím uveďte pouze jazyky, které váš server aktivně podporuje."
|
||||
},
|
||||
"nags.all-languages.title": {
|
||||
"defaultMessage": "Vyberte si přesné jazyky"
|
||||
},
|
||||
@@ -44,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Vyberte odpovídající štítky"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Ujistěte se, že uživatelé jsou si vědomi všech důležitých detailů vyplněním informací o obsahu, které se vztahují na váš {type}."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Zkontrolovat zveřejnění obsahu"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Váš popis má {length, plural, one {# čitelný znak} few {# čitelné znaky} other {# čitelných znaků}}. Pro vytvoření jasného a informativního popisu doporučujeme alespoň {minChars, plural, one {# znak} few {# znaky} other {# znaků}}."
|
||||
},
|
||||
@@ -191,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Přejděte do nastavení popisu"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Přejděte do nastavení zveřejnění"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Přejděte do nastavení licence"
|
||||
},
|
||||
@@ -206,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Přejděte do obecného nastavení"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Přejděte do nastavení verze"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Souhrn se nesmí shodovat s názvem projektu. Je důležité vytvořit informativní a poutavý souhrn."
|
||||
},
|
||||
@@ -273,4 +288,3 @@
|
||||
"defaultMessage": "Přejděte do nastavení odkazů"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Wähle akkurate Tags aus"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Stelle sicher, dass Nutzer über alle wichtigen Details informiert sind, indem du die für dein {type} geltenden Angaben zum Inhalt ausfüllst."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Angaben zum Inhalt prüfen"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Deine Beschreibung ist {length, plural, one {# lesbares Zeichen} other {# lesbare Zeichen}} lang. Mindestens {minChars, plural, one {1 Zeichen ist} other {# Zeichen sind}} empfohlen, um eine klare und informative Beschreibung zu erstellen."
|
||||
},
|
||||
@@ -194,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Beschreibungseinstellungen ansehen"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Angabeneinstellungen öffnen"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Lizenzeinstellungen ansehen"
|
||||
},
|
||||
@@ -209,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Allgemeine Einstellungen ansehen"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Versionseinstellungen ansehen"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Deine Zusammenfassung kann nicht identisch mit deinem Projektnamen sein. Es ist wichtig, eine informative und spannende Zusammenfassung zu erstellen."
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"defaultMessage": "Füge die IP-Adresse und den Port hinzu, über die Java Edition-Spieler deinem Server beitreten können."
|
||||
},
|
||||
"nags.add-java-address.title": {
|
||||
"defaultMessage": "Javaadresse hinzufügen"
|
||||
"defaultMessage": "Java-Adresse hinzufügen"
|
||||
},
|
||||
"nags.add-license-details.description": {
|
||||
"defaultMessage": "Gib eine gültige URL mit Namen oder eine SPDX-Kennung für deine benutzerdefinierte Lizenz ein."
|
||||
@@ -47,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Wähle passende Tags"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Stelle sicher, dass Nutzer über alle wichtigen Details informiert sind, indem du die für dein {type} geltenden Angaben zum Inhalt ausfüllst."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Angaben zum Inhalt prüfen"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Deine Beschreibung enthält {length, plural, one {# lesbares Zeichen} other {# lesbare Zeichen}}. Es wird empfohlen, mindestens {minChars, plural, one {# Zeichen} other {# Zeichen}} zu verwenden, um eine klare und informative Beschreibung zu erstellen."
|
||||
},
|
||||
@@ -194,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Beschreibungseinstellungen ansehen"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Angabeneinstellungen öffnen"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Lizenzeinstellungen ansehen"
|
||||
},
|
||||
@@ -209,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Allgemeine Einstellungen aufrufen"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Versionseinstellungen ansehen"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Deine Zusammenfassung darf nicht identisch mit dem Namen deines Projekts sein. Es ist wichtig, eine informative und ansprechende Zusammenfassung zu erstellen."
|
||||
},
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Selecciona las etiquetas adecuadas"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Asegúrate de que los usuarios estén al tanto de cualquier detalle importante rellenando las divulgaciones de contenido que se aplican a tu {type}."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Comprobar el contenido de divulgación"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Tu descripción tiene {length, plural, one {# carácter legible} other {# caracteres legibles}}. Se recomienda al menos {minChars, plural, one {# carácter} other {# caracteres}} para crear una descripción clara e informativa."
|
||||
},
|
||||
@@ -194,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Ver configuración de la descripción"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Visitar la configuración de divulgación"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Ver configuración de la licencia"
|
||||
},
|
||||
@@ -209,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Ver configuración general"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Visitar la configuración de versiones"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Tu resumen no puede ser igual al nombre de tu proyecto. Es importante crear un resumen informativo y llamativo."
|
||||
},
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"nags.add-java-address.title": {
|
||||
"defaultMessage": "Añade una dirección Java"
|
||||
},
|
||||
"nags.add-license-details.description": {
|
||||
"defaultMessage": "Añade una URL válida y un nombre o identificador SPDX para su licencia personalizada."
|
||||
},
|
||||
"nags.add-license-details.title": {
|
||||
"defaultMessage": "Agregar detalles de la licencia"
|
||||
},
|
||||
@@ -44,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Selecciona etiquetas precisas"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Asegúrate de que los usuarios estén al tanto de cualquier detalle importante rellenando las divulgaciones de contenido que se aplican a tu {type}."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Comprobar divulgaciones de contenido"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Tu descripción tiene {length, plural, one {# carácter legible} other {# caracteres legibles}}. Se recomienda un mínimo de {minChars, plural, one {# carácter} other {# caracteres}} para crear una descripción clara e informativa."
|
||||
},
|
||||
@@ -191,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Visitar la configuración de la descripción"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Visita la configuración de divulgación"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Visitar la configuración de la licencia"
|
||||
},
|
||||
@@ -206,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Visitar la configuración general"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Visita los ajustes de versiones"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Tu resumen no puede ser igual al Nombre de tu proyecto. Es importante crear un Resumen informativo y atractivo."
|
||||
},
|
||||
@@ -251,6 +266,12 @@
|
||||
"nags.upload-gallery-image.description": {
|
||||
"defaultMessage": "Se requiere al menos una imagen en la galería para mostrar el contenido de tu {type}."
|
||||
},
|
||||
"nags.upload-gallery-image.description-resourcepack": {
|
||||
"defaultMessage": "Se requiere al menos una imagen en la galería para mostrar el contenido de tu paquete de recursos, excepto para los paquetes de audio o localización. Si este es el caso de tu paquete, selecciona la etiqueta correspondiente."
|
||||
},
|
||||
"nags.upload-gallery-image.description-shader": {
|
||||
"defaultMessage": "Se requieren al menos tres imágenes en la galería para mostrar el contenido de su shader en diversas situaciones y condiciones."
|
||||
},
|
||||
"nags.upload-gallery-image.title": {
|
||||
"defaultMessage": "Subir una imagen a la galería"
|
||||
},
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Sélectionnez des tags précis"
|
||||
},
|
||||
"nags.check-disclosures.description": {
|
||||
"defaultMessage": "Soyez sûr que tous les utilisateurs sont avertis de tout détail importent en remplissant les avertissements sur le contenu de votre {type}."
|
||||
},
|
||||
"nags.check-disclosures.title": {
|
||||
"defaultMessage": "Regarder les avertissements sur le contenu"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "Votre description contient {length, plural, one {# caractère lisible} other {# caractères lisibles}}. Il est recommandé d’en avoir au moins {minChars, plural, one {# caractère} other {# caractères}} pour obtenir une description claire et informative."
|
||||
},
|
||||
@@ -194,6 +200,9 @@
|
||||
"nags.settings.description.title": {
|
||||
"defaultMessage": "Accéder aux paramètres de la description"
|
||||
},
|
||||
"nags.settings.disclosures.title": {
|
||||
"defaultMessage": "Visiter les paramètres d'avertissement sur le contenu"
|
||||
},
|
||||
"nags.settings.license.title": {
|
||||
"defaultMessage": "Accéder aux paramètres de la licence"
|
||||
},
|
||||
@@ -209,6 +218,9 @@
|
||||
"nags.settings.title": {
|
||||
"defaultMessage": "Accéder aux paramètres généraux"
|
||||
},
|
||||
"nags.settings.versions.title": {
|
||||
"defaultMessage": "Accéder aux paramètres des versions"
|
||||
},
|
||||
"nags.summary-same-as-title.description": {
|
||||
"defaultMessage": "Votre résumé ne peut pas être identique au nom de votre projet. Il est important de créer un résumé informatif et attrayant."
|
||||
},
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
"nags.add-java-address.title": {
|
||||
"defaultMessage": "הוסף כתובת ג'אווה"
|
||||
},
|
||||
"nags.add-license-details.description": {
|
||||
"defaultMessage": "תוסיף לינק ושם או SPDX איידי של ליצנסיה שלך."
|
||||
},
|
||||
"nags.add-license-details.title": {
|
||||
"defaultMessage": "תוסיף פרטים של ליצנסיה"
|
||||
},
|
||||
"nags.add-links-server.description": {
|
||||
"defaultMessage": "הוסיפו קישורים רלוונטיים מחוץ ל-Modrinth, כגון אתר אינטרנט, חנות או הזמנה לדיסקורד."
|
||||
},
|
||||
@@ -29,6 +35,12 @@
|
||||
"nags.add-links.title": {
|
||||
"defaultMessage": "הוסף קישורים חיצוניים"
|
||||
},
|
||||
"nags.all-languages.description": {
|
||||
"defaultMessage": "בחרת כל שפות זמינות. תבחר רק שפות שהסרור תומך בהן."
|
||||
},
|
||||
"nags.all-languages.title": {
|
||||
"defaultMessage": "בחר שפות מדויקות"
|
||||
},
|
||||
"nags.all-tags-selected.description": {
|
||||
"defaultMessage": "בחרת את כל {totalAvailableTags, plural, one {# תג זמין} other {# תגיות זמינות}}. זה סותר את המטרה של תגים, שנועדו לעזור למשתמשים למצוא פרויקטים רלוונטיים. אנא בחר רק את התגים הרלוונטיים לפרויקט שלך."
|
||||
},
|
||||
@@ -65,12 +77,18 @@
|
||||
"nags.gallery.title": {
|
||||
"defaultMessage": "בקר בדף הגלריה"
|
||||
},
|
||||
"nags.gpl-license-source-required.description": {
|
||||
"defaultMessage": "ה-{type} שלך משתמש ברישיון הדורש קוד מקור זמין. אנא ספק קישור לקוד המקור או קובץ מקורות עבור כל גרסה נוספת, או שקול להשתמש ברישיון אחר."
|
||||
},
|
||||
"nags.gpl-license-source-required.title": {
|
||||
"defaultMessage": "ספק קוד מקור"
|
||||
},
|
||||
"nags.identical-links.description": {
|
||||
"defaultMessage": "חלק מהקישורים החיצוניים שלך נראים זהים. יש להזין כל קישור פעם אחת בלבד ועם סוג הקישור המתאים."
|
||||
},
|
||||
"nags.identical-links.title": {
|
||||
"defaultMessage": "תמחק קישורים זהים"
|
||||
},
|
||||
"nags.image-heavy-description.description": {
|
||||
"defaultMessage": "התיאור שלך צריך להכיל מספיק טקסט פשוט או טקסט חלופי לתמונה, כדי שיישאר נגיש למשתמשים עם הקראות מסך או חיבורי אינטרנט איטיים."
|
||||
},
|
||||
@@ -200,6 +218,9 @@
|
||||
"nags.too-many-tags.title": {
|
||||
"defaultMessage": "בחר תגים מדוייקים"
|
||||
},
|
||||
"nags.upload-gallery-image.description-shader": {
|
||||
"defaultMessage": "צריך לעלות אפילו שלוש תמונות כדי לרואת את השיידר בסיטואציות שונות."
|
||||
},
|
||||
"nags.upload-gallery-image.title": {
|
||||
"defaultMessage": "העלה תמונת גלריה"
|
||||
},
|
||||
|
||||