diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index eeb37273f6..f6333d2a56 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -13,6 +13,7 @@ import { ChevronLeftIcon, ChevronRightIcon, CompassIcon, + ImagesIcon, LogInIcon, LogOutIcon, NewspaperIcon, @@ -531,6 +532,10 @@ const messages = defineMessages({ id: 'app.nav.modrinth-hosting', defaultMessage: 'Modrinth Hosting', }, + screenshots: { + id: 'app.nav.screenshots', + defaultMessage: 'Screenshots', + }, createNewInstance: { id: 'app.nav.create-new-instance', defaultMessage: 'Create new instance', @@ -1712,6 +1717,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload) + + + +import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue' +import { CheckIcon, ClipboardCopyIcon, ExternalIcon, TrashIcon } from '@modrinth/assets' +import { + commonMessages, + defineMessages, + IconButton, + useFormatDateTime, + useVIntl, +} from '@modrinth/ui' +import { computed, ref } from 'vue' + +import type { InstanceScreenshot } from '@/helpers/instance' + +const props = defineProps<{ + screenshot: InstanceScreenshot + selectionKey: string + selected: boolean + selectionActive: boolean + activeDragged: boolean + canDrag: boolean + showInstanceName: boolean +}>() + +const emit = defineEmits<{ + (e: 'activate', event: MouseEvent | KeyboardEvent): void + (e: 'copy' | 'open' | 'delete' | 'toggle-selection'): void +}>() + +const card = ref() +const loaded = ref(false) +const { formatMessage } = useVIntl() +const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' }) +const messages = defineMessages({ + select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' }, + deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' }, + copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' }, + showInFolder: { id: 'app.screenshots.show-in-folder', defaultMessage: 'Show in folder' }, + instanceAndTime: { + id: 'app.screenshots.card.instance-and-time', + defaultMessage: '{instance} · {time}', + }, +}) + +const sensors = [ + PointerSensor.configure({ + preventActivation: () => false, + }), + KeyboardSensor, +] + +useDraggable({ + id: computed(() => `screenshot:${props.selectionKey}`), + element: card, + disabled: computed(() => !props.canDrag), + sensors, + data: computed(() => ({ + selectionKey: props.selectionKey, + instanceId: props.screenshot.instance_id, + })), +}) + +function activate(event: MouseEvent | KeyboardEvent) { + if (event instanceof KeyboardEvent) { + if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) { + return + } + event.preventDefault() + } + emit('activate', event) +} + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue b/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue new file mode 100644 index 0000000000..eedee94806 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue b/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue new file mode 100644 index 0000000000..e33d029f06 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/group.vue b/apps/app-frontend/src/components/ui/screenshots-page/group.vue new file mode 100644 index 0000000000..304bf707a9 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/group.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/index.vue b/apps/app-frontend/src/components/ui/screenshots-page/index.vue new file mode 100644 index 0000000000..4f397c54b4 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/index.vue @@ -0,0 +1,1204 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/preview-modal.vue b/apps/app-frontend/src/components/ui/screenshots-page/preview-modal.vue new file mode 100644 index 0000000000..67642187ad --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/preview-modal.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/section.vue b/apps/app-frontend/src/components/ui/screenshots-page/section.vue new file mode 100644 index 0000000000..bdf67ff4ae --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/section.vue @@ -0,0 +1,137 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue b/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue new file mode 100644 index 0000000000..a6d0086473 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue @@ -0,0 +1,146 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts b/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts new file mode 100644 index 0000000000..9afe8b949a --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts @@ -0,0 +1,130 @@ +import { onBeforeUnmount, type Ref, ref } from 'vue' + +import type { InstanceScreenshot } from '@/helpers/instance' + +type Point = { + x: number + y: number +} + +export type ActiveScreenshotDrag = { + primarySelectionKey: string + selectionKeys: string[] +} + +export const gatherDuration = 500 + +export type ScreenshotDragGatherItem = { + screenshot: InstanceScreenshot + selectionKey: string + rect: { + left: number + top: number + width: number + height: number + } +} + +function getSelectionKey(screenshot: InstanceScreenshot) { + return JSON.stringify([screenshot.instance_id, screenshot.file_name]) +} + +export function useScreenshotDragGather(screenshots: Ref) { + const items = ref([]) + const target = ref({ x: 0, y: 0 }) + const targetOffset = ref({ x: 0, y: 0 }) + const isGathering = ref(false) + let cleanupTimer: number | undefined + + const clearCleanupTimer = () => { + if (cleanupTimer !== undefined) { + clearTimeout(cleanupTimer) + cleanupTimer = undefined + } + } + + const clear = () => { + clearCleanupTimer() + items.value = [] + targetOffset.value = { x: 0, y: 0 } + isGathering.value = false + } + + const updateTarget = (pointer: Point) => { + target.value = { + x: pointer.x + targetOffset.value.x, + y: pointer.y + targetOffset.value.y, + } + } + + const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => { + clear() + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return + + const screenshotCards = Array.from( + document.querySelectorAll('[data-screenshot-card]'), + ) + const sourceCard = screenshotCards.find( + (card) => card.dataset.selectionKey === drag.primarySelectionKey, + ) + + if (sourceCard) { + const sourceRect = sourceCard.getBoundingClientRect() + targetOffset.value = { + x: sourceRect.left + sourceRect.width / 2 - pointer.x, + y: sourceRect.top + sourceRect.height / 2 - pointer.y, + } + } + + const screenshotsByKey = new Map( + screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]), + ) + items.value = drag.selectionKeys.flatMap((selectionKey) => { + const screenshot = screenshotsByKey.get(selectionKey) + const card = screenshotCards.find( + (candidate) => + candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0, + ) + if (!screenshot || !card) return [] + + const rect = card.getBoundingClientRect() + return [ + { + screenshot, + selectionKey, + rect: { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }, + }, + ] + }) + updateTarget(pointer) + isGathering.value = items.value.length > 0 + } + + const finish = () => { + isGathering.value = false + clearCleanupTimer() + cleanupTimer = window.setTimeout(() => { + items.value = [] + cleanupTimer = undefined + }, 400) + } + + onBeforeUnmount(clearCleanupTimer) + + return { + items, + target, + isGathering, + start, + updateTarget, + clear, + finish, + } +} diff --git a/apps/app-frontend/src/generated/app-events/InstancePayload.ts b/apps/app-frontend/src/generated/app-events/InstancePayload.ts index cac7ca562d..53c3a154ba 100644 --- a/apps/app-frontend/src/generated/app-events/InstancePayload.ts +++ b/apps/app-frontend/src/generated/app-events/InstancePayload.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array, } | { "event": "content_install_failed", project_ids: Array, message: string, } | { "event": "removed" }); +export type InstancePayload = { instance_id: string, } & ({ "event": "created" } | { "event": "synced" } | { "event": "servers_updated" } | { "event": "screenshots_updated" } | { "event": "world_updated", world: string, } | { "event": "server_joined", host: string, port: number, timestamp: string, } | { "event": "edited" } | { "event": "content_install_finished", project_ids: Array, } | { "event": "content_install_failed", project_ids: Array, message: string, } | { "event": "removed" }); diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts index 4484308461..66144c0ada 100644 --- a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts +++ b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts @@ -44,7 +44,7 @@ export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { ta export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string } export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" } export type InstancePayload = { instance_id: string, event: InstancePayloadType } -export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" } +export type InstancePayloadType = { tag: "created" } | { tag: "synced" } | { tag: "servers_updated" } | { tag: "screenshots_updated" } | { tag: "world_updated", value: { world: string } } | { tag: "server_joined", value: { host: string, port: u16, timestamp: string } } | { tag: "edited" } | { tag: "content_install_finished", value: { project_ids: string[] } } | { tag: "content_install_failed", value: { project_ids: string[], message: string } } | { tag: "removed" } export type InstanceGroupsChangedPayload = { instance_ids: string[] } export type FriendPayload = { tag: "friend_request", value: { from: string } } | { tag: "user_offline", value: { id: string } } | { tag: "status_update", value: { user_status: FriendStatusPayload } } | { tag: "status_sync" } export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string } diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.js b/apps/app-frontend/src/generated/app-events/postcard/index.js index 8f719d0c15..c8816ffeec 100644 --- a/apps/app-frontend/src/generated/app-events/postcard/index.js +++ b/apps/app-frontend/src/generated/app-events/postcard/index.js @@ -351,13 +351,17 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) { tag: "servers_updated" }; case 3: + return { + tag: "screenshots_updated" + }; + case 4: return { tag: "world_updated", value: { world: d.deserialize_string() } }; - case 4: + case 5: return { tag: "server_joined", value: { @@ -366,18 +370,18 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) { timestamp: d.deserialize_string() } }; - case 5: + case 6: return { tag: "edited" }; - case 6: + case 7: return { tag: "content_install_finished", value: { project_ids: d.deserialize_array(() => d.deserialize_string()) } }; - case 7: + case 8: return { tag: "content_install_failed", value: { @@ -385,7 +389,7 @@ function deserialize_INSTANCE_PAYLOAD_TYPE(d) { message: d.deserialize_string() } }; - case 8: + case 9: return { tag: "removed" }; diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index 3e81ff24a5..e99a179bfe 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -167,6 +167,112 @@ export async function get_mod_full_path(instanceId: string, projectPath: string) return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath }) } +export type ScreenshotKey = { + instance_id: string + file_name: string +} + +export type InstanceScreenshot = ScreenshotKey & { + id: string + instance_name: string + created_at: string + group_id?: string | null + path: string + url: string +} + +export type ScreenshotGroup = { + id: string + name: string +} + +export type ScreenshotGroupMembershipUpdate = { + screenshot_id: string + group_id?: string | null +} + +export type ScreenshotGroupImport = ScreenshotGroup & { + screenshot_ids: string[] +} + +export async function list_instance_screenshots(instanceId: string): Promise { + return await invoke('plugin:instance|instance_list_screenshots', { instanceId }) +} + +export async function list_all_screenshots(): Promise { + return await invoke('plugin:instance|instance_list_all_screenshots') +} + +export async function list_synced_screenshots(): Promise { + return await invoke('plugin:instance|instance_list_synced_screenshots') +} + +export async function list_screenshot_groups(): Promise { + return await invoke('plugin:instance|instance_list_screenshot_groups') +} + +export async function create_screenshot_group( + name: string, + screenshotIds: string[], +): Promise { + return await invoke('plugin:instance|instance_create_screenshot_group', { + name, + screenshotIds, + }) +} + +export async function rename_screenshot_group( + id: string, + newName: string, +): Promise { + return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName }) +} + +export async function delete_screenshot_group(id: string): Promise { + return await invoke('plugin:instance|instance_delete_screenshot_group', { id }) +} + +export async function set_screenshot_group_memberships( + updates: ScreenshotGroupMembershipUpdate[], +): Promise { + return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates }) +} + +export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise { + return await invoke('plugin:instance|instance_import_screenshot_groups', { groups }) +} + +export async function delete_screenshots(keys: ScreenshotKey[]): Promise { + return await invoke('plugin:instance|instance_delete_screenshots', { keys }) +} + +export async function export_screenshots(keys: ScreenshotKey[], exportPath: string): Promise { + return await invoke('plugin:instance|instance_export_screenshots', { keys, exportPath }) +} + +export async function move_screenshots( + keys: ScreenshotKey[], + targetInstanceId: string, +): Promise { + return await invoke('plugin:instance|instance_move_screenshots', { keys, targetInstanceId }) +} + +export async function open_screenshot(key: ScreenshotKey): Promise { + return await invoke('plugin:instance|instance_open_screenshot', { key }) +} + +export async function set_synced_option( + instanceId: string, + option: 'screenshots', + enabled: boolean, +): Promise { + return await invoke('plugin:instance|instance_set_synced_option', { + instanceId, + option, + enabled, + }) +} + export interface JavaVersion { parsed_version: number version: string diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index becbda9208..388a6c939c 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -16,6 +16,9 @@ export type GameInstance = { loader_version?: string group_ids: string[] + synced_options: { + screenshots: boolean + } link?: InstanceLink | null shared_instance?: SharedInstanceAttachment | null diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 839f5d1cce..bc4d94f144 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -401,6 +401,18 @@ "app.icon-editor.notification.view-update": { "message": "View update" }, + "app.image-preview.close": { + "message": "Close" + }, + "app.image-preview.next": { + "message": "Next image" + }, + "app.image-preview.previous": { + "message": "Previous image" + }, + "app.image-preview.toggle-zoom": { + "message": "Toggle zoom" + }, "app.install.phase.downloading_content": { "message": "Downloading content" }, @@ -755,6 +767,9 @@ "app.instance.tab.logs": { "message": "Logs" }, + "app.instance.tab.screenshots": { + "message": "Screenshots" + }, "app.instance.tab.share": { "message": "Share" }, @@ -1232,6 +1247,9 @@ "app.nav.modrinth-hosting": { "message": "Modrinth Hosting" }, + "app.nav.screenshots": { + "message": "Screenshots" + }, "app.nav.sign-in-to-modrinth-account": { "message": "Sign in to a Modrinth account" }, @@ -1316,6 +1334,162 @@ "app.restarting": { "message": "Restarting..." }, + "app.screenshots.card.instance-and-time": { + "message": "{instance} · {time}" + }, + "app.screenshots.copy": { + "message": "Copy image" + }, + "app.screenshots.delete-description": { + "message": "Permanently delete {name}? This action cannot be undone." + }, + "app.screenshots.delete-success": { + "message": "Screenshot deleted" + }, + "app.screenshots.delete-title": { + "message": "Delete screenshot" + }, + "app.screenshots.deselect": { + "message": "Deselect {name}" + }, + "app.screenshots.empty-description": { + "message": "Screenshots you take in-game will appear here." + }, + "app.screenshots.empty-heading": { + "message": "No screenshots yet" + }, + "app.screenshots.error-heading": { + "message": "Failed to load screenshots" + }, + "app.screenshots.filter.add": { + "message": "Add filter" + }, + "app.screenshots.filter.clear": { + "message": "Clear filters" + }, + "app.screenshots.filter.game-version": { + "message": "Game version" + }, + "app.screenshots.filter.label": { + "message": "Filter by" + }, + "app.screenshots.filter.loader": { + "message": "Loader" + }, + "app.screenshots.filter.modpack": { + "message": "Modpack" + }, + "app.screenshots.group-by": { + "message": "Group by" + }, + "app.screenshots.group.all-screenshots": { + "message": "All screenshots" + }, + "app.screenshots.group.collapse": { + "message": "Collapse group" + }, + "app.screenshots.group.custom": { + "message": "Custom grouping" + }, + "app.screenshots.group.date": { + "message": "Date" + }, + "app.screenshots.group.delete": { + "message": "Delete group" + }, + "app.screenshots.group.delete-description": { + "message": "Screenshots in this group will become ungrouped." + }, + "app.screenshots.group.edit": { + "message": "Edit group name" + }, + "app.screenshots.group.expand": { + "message": "Expand group" + }, + "app.screenshots.group.instance": { + "message": "Instance" + }, + "app.screenshots.group.new": { + "message": "New group" + }, + "app.screenshots.group.none": { + "message": "No grouping" + }, + "app.screenshots.group.this-month": { + "message": "This month" + }, + "app.screenshots.group.this-week": { + "message": "This week" + }, + "app.screenshots.group.today": { + "message": "Today" + }, + "app.screenshots.group.ungrouped": { + "message": "Ungrouped" + }, + "app.screenshots.group.yesterday": { + "message": "Yesterday" + }, + "app.screenshots.heading": { + "message": "Screenshots" + }, + "app.screenshots.no-results-description": { + "message": "Try a different search or filter." + }, + "app.screenshots.no-results-heading": { + "message": "No matching screenshots" + }, + "app.screenshots.preview.instance-and-date": { + "message": "{instance} · {date}" + }, + "app.screenshots.search": { + "message": "Search" + }, + "app.screenshots.select": { + "message": "Select {name}" + }, + "app.screenshots.selection.aria-label": { + "message": "Selected screenshots" + }, + "app.screenshots.selection.delete-description": { + "message": "Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone." + }, + "app.screenshots.selection.delete-success": { + "message": "{count, plural, one {# screenshot deleted} other {# screenshots deleted}}" + }, + "app.screenshots.selection.delete-title": { + "message": "Delete selected screenshots" + }, + "app.screenshots.selection.export-zip": { + "message": "Export ZIP" + }, + "app.screenshots.selection.global-export-filename": { + "message": "Modrinth screenshots.zip" + }, + "app.screenshots.selection.instance-export-filename": { + "message": "{instance} screenshots.zip" + }, + "app.screenshots.selection.selected-count": { + "message": "{count} selected" + }, + "app.screenshots.selection.zip-archive": { + "message": "ZIP archive" + }, + "app.screenshots.show-in-folder": { + "message": "Show in folder" + }, + "app.screenshots.sort-by": { + "message": "Sort by" + }, + "app.screenshots.sort.name": { + "message": "Name" + }, + "app.screenshots.sort.newest": { + "message": "Newest" + }, + "app.screenshots.sort.oldest": { + "message": "Oldest" + }, "app.settings.app-version": { "message": "Modrinth App {version}" }, @@ -2411,6 +2585,18 @@ "instance.settings.tabs.sharing": { "message": "Sharing" }, + "instance.settings.tabs.synced-options": { + "message": "Synced options" + }, + "instance.settings.tabs.synced-options.description": { + "message": "Choose which content from this instance appears across the app." + }, + "instance.settings.tabs.synced-options.screenshots": { + "message": "Screenshots" + }, + "instance.settings.tabs.synced-options.screenshots.description": { + "message": "Show screenshots from this instance in the global Screenshots page. Turn this off to use an instance-only Screenshots tab." + }, "instance.settings.tabs.window": { "message": "Window" }, diff --git a/apps/app-frontend/src/pages/Screenshots.vue b/apps/app-frontend/src/pages/Screenshots.vue new file mode 100644 index 0000000000..b7f44e9a1b --- /dev/null +++ b/apps/app-frontend/src/pages/Screenshots.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index 3c218fa833..ad2bd5f4a5 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,7 +1,8 @@ import Browse from './Browse.vue' import Index from './Index.vue' +import Screenshots from './Screenshots.vue' import Servers from './Servers.vue' import Skins from './Skins.vue' import User from './User.vue' -export { Browse, Index, Servers, Skins, User } +export { Browse, Index, Screenshots, Servers, Skins, User } diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue index 4aadcbd485..4890ef0250 100644 --- a/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/index.vue @@ -6,6 +6,7 @@ import { CoffeeIcon, InfoIcon, MonitorIcon, + RefreshCwIcon, UsersIcon, WrenchIcon, } from '@modrinth/assets' @@ -33,6 +34,7 @@ import InstallationSettings from './installation-settings.vue' import { provideInstanceSettings } from './instance-settings-context.ts' import JavaSettings from './java-settings.vue' import SharingSettings from './sharing-settings.vue' +import SyncedOptionsSettings from './synced-options-settings.vue' import WindowSettings from './window-settings.vue' const { formatMessage } = useVIntl() @@ -98,6 +100,14 @@ const tabs = computed(() => [ icon: WrenchIcon, content: InstallationSettings, }, + { + name: defineMessage({ + id: 'instance.settings.tabs.synced-options', + defaultMessage: 'Synced options', + }), + icon: RefreshCwIcon, + content: SyncedOptionsSettings, + }, { name: defineMessage({ id: 'instance.settings.tabs.sharing', diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/synced-options-settings.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/synced-options-settings.vue new file mode 100644 index 0000000000..d73f413fd8 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/synced-options-settings.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/app-frontend/src/pages/instance/index.ts b/apps/app-frontend/src/pages/instance/index.ts index a4cfdec8da..18a6ea53b7 100644 --- a/apps/app-frontend/src/pages/instance/index.ts +++ b/apps/app-frontend/src/pages/instance/index.ts @@ -2,7 +2,8 @@ import Content from './content/index.vue' import Files from './files/index.vue' import Index from './layout.vue' import Logs from './logs/index.vue' +import Screenshots from './screenshots/index.vue' import Share from './share/index.vue' import Worlds from './worlds/index.vue' -export { Content, Files, Index, Logs, Share, Worlds } +export { Content, Files, Index, Logs, Screenshots, Share, Worlds } diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue index 664bcc322a..35cbcba66e 100644 --- a/apps/app-frontend/src/pages/instance/layout.vue +++ b/apps/app-frontend/src/pages/instance/layout.vue @@ -101,6 +101,7 @@ import { EditIcon, FolderOpenIcon, GlobeIcon, + ImagesIcon, PlayIcon, PlusIcon, StopCircleIcon, @@ -192,6 +193,7 @@ const messages = defineMessages({ openFolder: { id: 'app.instance.action.open-folder', defaultMessage: 'Open folder' }, contentTab: { id: 'app.instance.tab.content', defaultMessage: 'Content' }, filesTab: { id: 'app.instance.tab.files', defaultMessage: 'Files' }, + screenshotsTab: { id: 'app.instance.tab.screenshots', defaultMessage: 'Screenshots' }, worldsTab: { id: 'app.instance.tab.worlds', defaultMessage: 'Worlds' }, logsTab: { id: 'app.instance.tab.logs', defaultMessage: 'Logs' }, shareTab: { id: 'app.instance.tab.share', defaultMessage: 'Share' }, @@ -500,6 +502,14 @@ const tabs = computed(() => { }, ] + if (!instance.value?.synced_options.screenshots) { + instanceTabs.splice(2, 0, { + label: formatMessage(messages.screenshotsTab), + href: `${basePath.value}/screenshots`, + icon: ImagesIcon, + }) + } + if (showShareTab.value) { instanceTabs.push({ label: formatMessage(messages.shareTab), diff --git a/apps/app-frontend/src/pages/instance/query-options.ts b/apps/app-frontend/src/pages/instance/query-options.ts index 6848ccb7ac..72d9ddabaa 100644 --- a/apps/app-frontend/src/pages/instance/query-options.ts +++ b/apps/app-frontend/src/pages/instance/query-options.ts @@ -1,13 +1,20 @@ import { queryOptions } from '@tanstack/vue-query' import { get_project_v3 } from '@/helpers/cache.js' -import { get as getInstance } from '@/helpers/instance' +import { + get as getInstance, + list as listInstances, + list_instance_screenshots, + list_screenshot_groups, + list_synced_screenshots, +} from '@/helpers/instance' import { loadInstanceContentData } from '@/helpers/instance-content' import { get_by_instance_id } from '@/helpers/process' import { refreshWorlds } from '@/helpers/worlds' export const instanceKeys = { all: ['instances'] as const, + list: () => [...instanceKeys.all, 'list'] as const, detail: (instanceId: string) => [...instanceKeys.all, 'summary', instanceId] as const, processes: (instanceId: string) => [...instanceKeys.all, 'processes', instanceId] as const, content: (instanceId: string) => [...instanceKeys.all, 'content', instanceId] as const, @@ -30,6 +37,45 @@ export const instanceKeys = { sharedMembers: (instanceId: string) => ['sharedInstanceUsers', instanceId] as const, } +export const screenshotKeys = { + all: ['screenshots'] as const, + global: () => [...screenshotKeys.all, 'global'] as const, + instance: (instanceId: string) => [...screenshotKeys.all, 'instance', instanceId] as const, + groups: () => [...screenshotKeys.all, 'groups'] as const, +} + +export function instanceListQueryOptions() { + return queryOptions({ + queryKey: instanceKeys.list(), + queryFn: listInstances, + staleTime: 30_000, + }) +} + +export function syncedScreenshotsQueryOptions() { + return queryOptions({ + queryKey: screenshotKeys.global(), + queryFn: list_synced_screenshots, + staleTime: 0, + }) +} + +export function instanceScreenshotsQueryOptions(instanceId: string) { + return queryOptions({ + queryKey: screenshotKeys.instance(instanceId), + queryFn: () => list_instance_screenshots(instanceId), + staleTime: 0, + }) +} + +export function screenshotGroupsQueryOptions() { + return queryOptions({ + queryKey: screenshotKeys.groups(), + queryFn: list_screenshot_groups, + staleTime: 0, + }) +} + export function instanceDetailQueryOptions(instanceId: string) { return queryOptions({ queryKey: instanceKeys.detail(instanceId), diff --git a/apps/app-frontend/src/pages/instance/screenshots/index.vue b/apps/app-frontend/src/pages/instance/screenshots/index.vue new file mode 100644 index 0000000000..b2d1a0fbe7 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/screenshots/index.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index ff0bac7bf6..a5327694cb 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -63,6 +63,11 @@ export default new createRouter({ name: 'Skin selector', component: Pages.Skins, }, + { + path: '/screenshots', + name: 'Screenshots', + component: Pages.Screenshots, + }, { path: '/user/:user/:projectType?', name: 'User', @@ -134,6 +139,11 @@ export default new createRouter({ name: 'InstanceFiles', component: Instance.Files, }, + { + path: 'screenshots', + name: 'InstanceScreenshots', + component: Instance.Screenshots, + }, { path: 'logs', name: 'InstanceLogs', diff --git a/apps/app/build.rs b/apps/app/build.rs index 50798f5ca5..2f7ce70457 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -215,6 +215,20 @@ fn main() { "instance_get_optimal_jre_key", "instance_get_full_path", "instance_get_mod_full_path", + "instance_list_screenshots", + "instance_list_all_screenshots", + "instance_list_synced_screenshots", + "instance_list_screenshot_groups", + "instance_create_screenshot_group", + "instance_rename_screenshot_group", + "instance_delete_screenshot_group", + "instance_set_screenshot_group_memberships", + "instance_import_screenshot_groups", + "instance_delete_screenshots", + "instance_export_screenshots", + "instance_move_screenshots", + "instance_open_screenshot", + "instance_set_synced_option", "instance_list", "instance_list_groups", "instance_create_group", diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index f21d82afef..c01aa58931 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -4,6 +4,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager, Runtime}; +use tauri_plugin_fs::FsExt; +use tauri_plugin_opener::OpenerExt; use theseus::DownloadReason; use theseus::data::{ AppliedContentSetPatch, ContentItem, Dependency, @@ -43,6 +46,20 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_get_optimal_jre_key, instance_get_full_path, instance_get_mod_full_path, + instance_list_screenshots, + instance_list_all_screenshots, + instance_list_synced_screenshots, + instance_list_screenshot_groups, + instance_create_screenshot_group, + instance_rename_screenshot_group, + instance_delete_screenshot_group, + instance_set_screenshot_group_memberships, + instance_import_screenshot_groups, + instance_delete_screenshots, + instance_export_screenshots, + instance_move_screenshots, + instance_open_screenshot, + instance_set_synced_option, instance_check_installed, instance_update_all, instance_update_project, @@ -94,6 +111,7 @@ pub struct Instance { pub loader: ModLoader, pub loader_version: Option, pub group_ids: Vec, + pub synced_options: InstanceSyncedOptions, pub link: Option, pub shared_instance: Option, pub quarantined: bool, @@ -112,6 +130,18 @@ pub struct Instance { pub hooks: Hooks, } +#[derive(Serialize, Debug, Clone)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: chrono::DateTime, + pub group_id: Option, + pub path: PathBuf, + pub url: url::Url, +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InstanceLink { @@ -258,6 +288,7 @@ impl From for Instance { loader: metadata.applied_content_set.loader, loader_version: metadata.applied_content_set.loader_version, group_ids: metadata.group_ids, + synced_options: metadata.synced_options, link: InstanceLink::from_core(metadata.link), shared_instance: metadata.shared_instance.map(Into::into), quarantined: metadata.quarantined, @@ -632,6 +663,154 @@ pub async fn instance_get_mod_full_path( Ok(theseus::instance::get_mod_full_path(instance_id, project_path).await?) } +#[tauri::command] +pub async fn instance_list_screenshots( + app_handle: AppHandle, + instance_id: &str, +) -> Result> { + let screenshots = theseus::instance::list_screenshots(instance_id).await?; + serialize_screenshots(&app_handle, screenshots) +} + +#[tauri::command] +pub async fn instance_list_all_screenshots( + app_handle: AppHandle, +) -> Result> { + let screenshots = theseus::instance::list_all_screenshots().await?; + serialize_screenshots(&app_handle, screenshots) +} + +#[tauri::command] +pub async fn instance_list_synced_screenshots( + app_handle: AppHandle, +) -> Result> { + let screenshots = theseus::instance::list_synced_screenshots().await?; + serialize_screenshots(&app_handle, screenshots) +} + +#[tauri::command] +pub async fn instance_list_screenshot_groups() +-> Result> { + Ok(theseus::instance::list_screenshot_groups().await?) +} + +#[tauri::command] +pub async fn instance_create_screenshot_group( + name: String, + screenshot_ids: Vec, +) -> Result { + Ok( + theseus::instance::create_screenshot_group(name, screenshot_ids) + .await?, + ) +} + +#[tauri::command] +pub async fn instance_rename_screenshot_group( + id: String, + new_name: String, +) -> Result { + Ok(theseus::instance::rename_screenshot_group(id, new_name).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshot_group(id: String) -> Result<()> { + Ok(theseus::instance::delete_screenshot_group(id).await?) +} + +#[tauri::command] +pub async fn instance_set_screenshot_group_memberships( + updates: Vec, +) -> Result<()> { + Ok(theseus::instance::set_screenshot_group_memberships(updates).await?) +} + +#[tauri::command] +pub async fn instance_import_screenshot_groups( + groups: Vec, +) -> Result<()> { + Ok(theseus::instance::import_screenshot_groups(groups).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshots( + keys: Vec, +) -> Result<()> { + Ok(theseus::instance::delete_screenshots(&keys).await?) +} + +#[tauri::command] +pub async fn instance_export_screenshots( + keys: Vec, + export_path: PathBuf, +) -> Result<()> { + Ok(theseus::instance::export_screenshots(&keys, export_path).await?) +} + +#[tauri::command] +pub async fn instance_move_screenshots( + keys: Vec, + target_instance_id: &str, +) -> Result> { + Ok(theseus::instance::move_screenshots(&keys, target_instance_id).await?) +} + +#[tauri::command] +pub async fn instance_open_screenshot( + app_handle: AppHandle, + key: theseus::instance::ScreenshotKey, +) -> Result<()> { + let path = theseus::instance::get_screenshot_path(&key).await?; + app_handle + .opener() + .reveal_item_in_dir(path) + .map_err(|error| std::io::Error::other(error.to_string()))?; + Ok(()) +} + +#[tauri::command] +pub async fn instance_set_synced_option( + instance_id: &str, + option: InstanceSyncedOption, + enabled: bool, +) -> Result { + Ok(Instance::from( + theseus::instance::set_synced_option(instance_id, option, enabled) + .await?, + )) +} + +fn serialize_screenshots( + app_handle: &AppHandle, + screenshots: Vec, +) -> Result> { + let mut result = Vec::with_capacity(screenshots.len()); + + for screenshot in screenshots { + app_handle + .asset_protocol_scope() + .allow_file(&screenshot.path) + .map_err(|error| std::io::Error::other(error.to_string()))?; + app_handle + .fs_scope() + .allow_file(&screenshot.path) + .map_err(|error| std::io::Error::other(error.to_string()))?; + let url = super::utils::tauri_convert_file_src(&screenshot.path)?; + result.push(InstanceScreenshot { + id: screenshot.id, + instance_id: screenshot.instance_id, + instance_name: screenshot.instance_name, + file_name: screenshot.file_name, + created_at: screenshot.created_at, + group_id: screenshot.group_id, + path: screenshot.path, + url, + }); + } + + Ok(result) +} + #[tauri::command] pub async fn instance_get_optimal_jre_key( instance_id: &str, diff --git a/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json b/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json new file mode 100644 index 0000000000..70ed01f959 --- /dev/null +++ b/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshot_groups WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e" +} diff --git a/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json b/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json new file mode 100644 index 0000000000..3bafddf112 --- /dev/null +++ b/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27" +} diff --git a/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json b/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json new file mode 100644 index 0000000000..2ff7afea59 --- /dev/null +++ b/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshots WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099" +} diff --git a/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json b/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json new file mode 100644 index 0000000000..c7cb7dbc2d --- /dev/null +++ b/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE screenshots\n\t\tSET instance_id = ?, file_name = ?\n\t\tWHERE instance_id = ? AND file_name = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb" +} diff --git a/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json b/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json new file mode 100644 index 0000000000..61b546127f --- /dev/null +++ b/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, name\n\t\tFROM screenshot_groups\n\t\tORDER BY display_order, name, id\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472" +} diff --git a/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json b/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json new file mode 100644 index 0000000000..182b209b42 --- /dev/null +++ b/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE screenshot_groups SET name = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7" +} diff --git a/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json b/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json new file mode 100644 index 0000000000..c53d1b9160 --- /dev/null +++ b/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa" +} diff --git a/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json b/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json new file mode 100644 index 0000000000..a4ed6ce231 --- /dev/null +++ b/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT DISTINCT screenshots.instance_id\n\t\tFROM screenshot_group_memberships memberships\n\t\tINNER JOIN screenshots ON screenshots.id = memberships.screenshot_id\n\t\tWHERE memberships.group_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "instance_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8" +} diff --git a/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json b/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json new file mode 100644 index 0000000000..4232998a92 --- /dev/null +++ b/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\t\tVALUES (?, ?)\n\t\t\tON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085" +} diff --git a/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json b/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json new file mode 100644 index 0000000000..c828e1998d --- /dev/null +++ b/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE screenshot_groups SET display_order = display_order + 1", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9" +} diff --git a/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json b/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json new file mode 100644 index 0000000000..e97640f36b --- /dev/null +++ b/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO screenshots (\n\t\t\tid,\n\t\t\tinstance_id,\n\t\t\tfile_name,\n\t\t\tcontent_hash,\n\t\t\tfile_size,\n\t\t\tmodified_at,\n\t\t\tcreated_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2" +} diff --git a/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json b/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json new file mode 100644 index 0000000000..792ed44226 --- /dev/null +++ b/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE screenshots\n\t\tSET\n\t\t\tinstance_id = ?,\n\t\t\tfile_name = ?,\n\t\t\tcontent_hash = ?,\n\t\t\tfile_size = ?,\n\t\t\tmodified_at = ?,\n\t\t\tcreated_at = ?\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3" +} diff --git a/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json b/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json new file mode 100644 index 0000000000..78605fb32b --- /dev/null +++ b/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT instance_id FROM screenshots WHERE id = ?", + "describe": { + "columns": [ + { + "name": "instance_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb" +} diff --git a/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json b/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json new file mode 100644 index 0000000000..0cb2b6b16f --- /dev/null +++ b/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce" +} diff --git a/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json b/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json new file mode 100644 index 0000000000..42a1914756 --- /dev/null +++ b/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ?\n\t\tORDER BY screenshots.file_name, screenshots.id\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "file_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "content_hash", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "file_size", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "modified_at", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "group_id?", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770" +} diff --git a/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json b/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json new file mode 100644 index 0000000000..46844e3cfb --- /dev/null +++ b/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\tINSERT INTO screenshot_groups (id, name, display_order)\n\t\t\tVALUES (?, ?, ?)\n\t\t\tON CONFLICT(id) DO UPDATE SET name = excluded.name\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7" +} diff --git a/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json b/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json new file mode 100644 index 0000000000..bbb52a3c66 --- /dev/null +++ b/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f" +} diff --git a/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json b/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json new file mode 100644 index 0000000000..71539ad7c2 --- /dev/null +++ b/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076" +} diff --git a/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json b/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json new file mode 100644 index 0000000000..5bdd62c433 --- /dev/null +++ b/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups", + "describe": { + "columns": [ + { + "name": "display_order!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c" +} diff --git a/packages/app-lib/migrations/20260819120000_instance-synced-options.sql b/packages/app-lib/migrations/20260819120000_instance-synced-options.sql new file mode 100644 index 0000000000..ffb4d3f901 --- /dev/null +++ b/packages/app-lib/migrations/20260819120000_instance-synced-options.sql @@ -0,0 +1,11 @@ +CREATE TABLE instance_synced_options ( + instance_id TEXT NOT NULL, + option TEXT NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + + PRIMARY KEY (instance_id, option), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX instance_synced_options_option_enabled + ON instance_synced_options(option, enabled); diff --git a/packages/app-lib/migrations/20260819130000_screenshot-groups.sql b/packages/app-lib/migrations/20260819130000_screenshot-groups.sql new file mode 100644 index 0000000000..17f640df88 --- /dev/null +++ b/packages/app-lib/migrations/20260819130000_screenshot-groups.sql @@ -0,0 +1,40 @@ +CREATE TABLE screenshots ( + id TEXT NOT NULL, + instance_id TEXT NOT NULL, + file_name TEXT NOT NULL, + content_hash TEXT NOT NULL, + file_size INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + + PRIMARY KEY (id), + UNIQUE (instance_id, file_name), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX screenshots_instance_id ON screenshots(instance_id); +CREATE INDEX screenshots_instance_hash + ON screenshots(instance_id, content_hash, file_size); + +CREATE TABLE screenshot_groups ( + id TEXT NOT NULL, + name TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (id) +); + +CREATE INDEX screenshot_groups_display_order + ON screenshot_groups(display_order); + +CREATE TABLE screenshot_group_memberships ( + screenshot_id TEXT NOT NULL, + group_id TEXT NOT NULL, + + PRIMARY KEY (screenshot_id), + FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE +); + +CREATE INDEX screenshot_group_memberships_group_id + ON screenshot_group_memberships(group_id); diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 54249b0e1e..99b59e97f4 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -11,6 +11,8 @@ mod lifecycle; mod paths; mod projects; mod run; +mod screenshot_groups; +mod screenshots; mod shared; pub use self::content::{ @@ -38,7 +40,7 @@ pub(crate) use self::icon::{ }; pub use self::install::get_optimal_jre_key; pub(crate) use self::lifecycle::create; -pub use self::lifecycle::{edit, remove}; +pub use self::lifecycle::{edit, remove, set_synced_option}; pub use self::paths::{get_full_path, get_mod_full_path}; pub use self::projects::{ InstallProjectWithDependenciesRequest, add_project_from_path, @@ -51,6 +53,18 @@ pub use self::projects::{ pub use self::run::{ QuickPlayType, kill, run, try_update_playtime_by_instance_id, }; +pub use self::screenshot_groups::{ + ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate, + create_screenshot_group, delete_screenshot_group, import_screenshot_groups, + list_screenshot_groups, rename_screenshot_group, + set_screenshot_group_memberships, +}; +pub(crate) use self::screenshots::reconcile_screenshots; +pub use self::screenshots::{ + InstanceScreenshot, ScreenshotKey, delete_screenshots, export_screenshots, + get_screenshot_path, list_all_screenshots, list_screenshots, + list_synced_screenshots, move_screenshots, +}; pub(crate) use self::shared::{ CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS, CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES, diff --git a/packages/app-lib/src/api/instance/lifecycle.rs b/packages/app-lib/src/api/instance/lifecycle.rs index cd5d7f6c26..d5b8813631 100644 --- a/packages/app-lib/src/api/instance/lifecycle.rs +++ b/packages/app-lib/src/api/instance/lifecycle.rs @@ -3,7 +3,7 @@ use crate::event::emit::emit_instance; use crate::state::instances::adapters::sqlite::instance_rows; use crate::state::{ CreateInstance, EditInstance, InstanceIconConfig, InstanceLink, - InstanceMetadata, ModLoader, State, + InstanceMetadata, InstanceSyncedOption, ModLoader, State, }; #[tracing::instrument] @@ -82,6 +82,32 @@ pub async fn edit( Ok(instance) } +pub async fn set_synced_option( + instance_id: &str, + option: InstanceSyncedOption, + enabled: bool, +) -> crate::Result { + let state = State::get().await?; + instance_rows::set_instance_synced_option( + instance_id, + option, + enabled, + &state.pool, + ) + .await?; + + let instance = crate::state::get_instance(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + .as_error() + })?; + + emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?; + + Ok(instance) +} + #[tracing::instrument] pub async fn remove(instance_id: &str) -> crate::Result<()> { let state = State::get().await?; diff --git a/packages/app-lib/src/api/instance/screenshot_groups.rs b/packages/app-lib/src/api/instance/screenshot_groups.rs new file mode 100644 index 0000000000..09aa43ca83 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshot_groups.rs @@ -0,0 +1,356 @@ +use crate::State; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::state::instances::adapters::sqlite::screenshot_rows; +use serde::{Deserialize, Serialize}; +use sqlx::{Sqlite, Transaction}; +use std::collections::HashSet; +use uuid::Uuid; + +const MAX_GROUP_NAME_LENGTH: usize = 256; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroup { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupMembershipUpdate { + pub screenshot_id: String, + pub group_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupImport { + pub id: String, + pub name: String, + pub screenshot_ids: Vec, +} + +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) +} + +pub async fn list_screenshot_groups() -> crate::Result> { + let state = State::get().await?; + Ok(screenshot_rows::list_groups(&state.pool) + .await? + .into_iter() + .map(|group| ScreenshotGroup { + id: group.id, + name: group.name, + }) + .collect()) +} + +pub async fn create_screenshot_group( + name: String, + screenshot_ids: Vec, +) -> crate::Result { + let name = validate_group_name(&name)?; + let state = State::get().await?; + let id = Uuid::new_v4().to_string(); + let mut tx = state.pool.begin().await?; + + sqlx::query!( + "UPDATE screenshot_groups SET display_order = display_order + 1", + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)", + id, + name, + ) + .execute(&mut *tx) + .await?; + + let instance_ids = set_group_members( + &id, + normalize_screenshot_ids(screenshot_ids)?, + &mut tx, + ) + .await?; + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + + Ok(ScreenshotGroup { + id, + name: name.to_string(), + }) +} + +pub async fn rename_screenshot_group( + id: String, + new_name: String, +) -> crate::Result { + let new_name = validate_group_name(&new_name)?; + let state = State::get().await?; + let result = sqlx::query!( + "UPDATE screenshot_groups SET name = ? WHERE id = ?", + new_name, + id, + ) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + + Ok(ScreenshotGroup { + id, + name: new_name.to_string(), + }) +} + +pub async fn delete_screenshot_group(id: String) -> crate::Result<()> { + let state = State::get().await?; + let instance_ids = sqlx::query_scalar!( + " + SELECT DISTINCT screenshots.instance_id + FROM screenshot_group_memberships memberships + INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id + WHERE memberships.group_id = ? + ", + id, + ) + .fetch_all(&state.pool) + .await?; + let result = + sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + emit_screenshot_updates(instance_ids).await; + Ok(()) +} + +pub async fn set_screenshot_group_memberships( + updates: Vec, +) -> crate::Result<()> { + let updates = normalize_membership_updates(updates)?; + if updates.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let group_ids = updates + .iter() + .filter_map(|update| update.group_id.as_deref()) + .collect::>(); + for group_id in group_ids { + ensure_group_exists(group_id, &mut tx).await?; + } + + let mut instance_ids = HashSet::new(); + for update in updates { + let instance_id = + screenshot_instance_id(&update.screenshot_id, &mut tx).await?; + instance_ids.insert(instance_id); + sqlx::query!( + "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?", + update.screenshot_id, + ) + .execute(&mut *tx) + .await?; + if let Some(group_id) = update.group_id { + sqlx::query!( + "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)", + update.screenshot_id, + group_id, + ) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + Ok(()) +} + +pub async fn import_screenshot_groups( + groups: Vec, +) -> crate::Result<()> { + if groups.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let base_order = sqlx::query_scalar!( + "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups", + ) + .fetch_one(&mut *tx) + .await?; + let mut group_ids = HashSet::new(); + let mut affected_instance_ids = HashSet::new(); + + for (index, group) in groups.into_iter().enumerate() { + if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) { + return Err(crate::ErrorKind::InputError( + "Screenshot group import contains an invalid group ID" + .to_string(), + ) + .into()); + } + let name = validate_group_name(&group.name)?; + let display_order = base_order + index as i64; + sqlx::query!( + " + INSERT INTO screenshot_groups (id, name, display_order) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name + ", + group.id, + name, + display_order, + ) + .execute(&mut *tx) + .await?; + affected_instance_ids.extend( + set_group_members( + &group.id, + normalize_screenshot_ids(group.screenshot_ids)?, + &mut tx, + ) + .await?, + ); + } + tx.commit().await?; + emit_screenshot_updates(affected_instance_ids).await; + Ok(()) +} + +fn normalize_screenshot_ids( + screenshot_ids: Vec, +) -> crate::Result> { + let mut unique = HashSet::new(); + if screenshot_ids + .iter() + .all(|screenshot_id| unique.insert(screenshot_id.clone())) + { + return Ok(screenshot_ids); + } + Err(crate::ErrorKind::InputError( + "Screenshot group contains duplicate screenshots".to_string(), + ) + .into()) +} + +fn normalize_membership_updates( + updates: Vec, +) -> crate::Result> { + let mut screenshot_ids = HashSet::new(); + if updates + .iter() + .all(|update| screenshot_ids.insert(update.screenshot_id.clone())) + { + return Ok(updates); + } + Err(crate::ErrorKind::InputError( + "Screenshot membership update contains duplicate screenshots" + .to_string(), + ) + .into()) +} + +async fn set_group_members( + group_id: &str, + screenshot_ids: Vec, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result> { + let mut instance_ids = HashSet::new(); + for screenshot_id in screenshot_ids { + instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?); + sqlx::query!( + " + INSERT INTO screenshot_group_memberships (screenshot_id, group_id) + VALUES (?, ?) + ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id + ", + screenshot_id, + group_id, + ) + .execute(&mut **tx) + .await?; + } + Ok(instance_ids) +} + +async fn ensure_group_exists( + group_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#, + group_id, + ) + .fetch_one(&mut **tx) + .await?; + if !exists { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {group_id}" + )) + .into()); + } + Ok(()) +} + +async fn screenshot_instance_id( + screenshot_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result { + sqlx::query_scalar!( + "SELECT instance_id FROM screenshots WHERE id = ?", + screenshot_id, + ) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown screenshot {screenshot_id}" + )) + }) + .map_err(Into::into) +} + +async fn emit_screenshot_updates( + instance_ids: impl IntoIterator, +) { + for instance_id in instance_ids { + let _ = emit_instance( + &instance_id, + InstancePayloadType::ScreenshotsUpdated, + ) + .await; + } +} diff --git a/packages/app-lib/src/api/instance/screenshots.rs b/packages/app-lib/src/api/instance/screenshots.rs new file mode 100644 index 0000000000..55571bd1d3 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots.rs @@ -0,0 +1,698 @@ +use async_zip::tokio::write::ZipFileWriter; +use async_zip::{Compression, ZipEntryBuilder}; +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; +use tokio::fs::File; +use tokio_util::compat::FuturesAsyncWriteCompatExt; + +use crate::State; +use crate::state::instances::adapters::sqlite::{ + instance_rows::{self, InstanceScreenshotSource}, + screenshot_rows::{self, ScreenshotRow}, +}; +use crate::util::fetch::sha1_file_async; +use crate::util::io::{self, IOError}; +use uuid::Uuid; + +const SCREENSHOTS_DIRECTORY: &str = "screenshots"; +const SCREENSHOT_SCAN_CONCURRENCY: usize = 8; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct ScreenshotKey { + pub instance_id: String, + pub file_name: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: DateTime, + pub group_id: Option, + #[serde(skip)] + pub path: PathBuf, +} + +struct ScannedScreenshot { + file_name: String, + created_at: DateTime, + modified_at: i64, + file_size: i64, + path: PathBuf, +} + +struct ResolvedScreenshot { + scanned: ScannedScreenshot, + row: ScreenshotRow, + is_new: bool, + changed: bool, +} + +pub async fn list_screenshots( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let source = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + + list_source_screenshots(&state, source).await +} + +pub async fn list_synced_screenshots() -> crate::Result> +{ + let state = State::get().await?; + let sources = + instance_rows::list_synced_screenshot_sources(&state.pool).await?; + list_source_screenshot_sets(&state, sources).await +} + +pub async fn list_all_screenshots() -> crate::Result> { + let state = State::get().await?; + let sources = instance_rows::list_screenshot_sources(&state.pool).await?; + list_source_screenshot_sets(&state, sources).await +} + +async fn list_source_screenshot_sets( + state: &State, + sources: Vec, +) -> crate::Result> { + let mut screenshots = + stream::iter(sources.into_iter().map(|source| async move { + list_source_screenshots(state, source).await + })) + .buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY) + .try_collect::>>() + .await? + .into_iter() + .flatten() + .collect::>(); + + sort_screenshots(&mut screenshots); + Ok(screenshots) +} + +pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> { + ensure_unique_keys(keys)?; + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let mut locks = Vec::with_capacity(instance_ids.len()); + for instance_id in instance_ids { + locks.push(state.lock_instance_screenshots(instance_id).await); + } + + for key in keys { + io::remove_file(get_screenshot_path(key).await?).await?; + screenshot_rows::delete_screenshot_by_key( + &key.instance_id, + &key.file_name, + &state.pool, + ) + .await?; + } + + Ok(()) +} + +pub async fn export_screenshots( + keys: &[ScreenshotKey], + export_path: PathBuf, +) -> crate::Result<()> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Err(crate::ErrorKind::InputError( + "At least one screenshot must be selected".to_string(), + ) + .into()); + } + + let state = State::get().await?; + let mut sources = HashMap::new(); + let mut archive_folders = HashMap::::new(); + let mut used_archive_folders = HashSet::new(); + let mut screenshots = Vec::with_capacity(keys.len()); + + for key in keys { + let source = match sources.get(&key.instance_id) { + Some(source) => source, + None => { + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + sources.insert(key.instance_id.clone(), source); + sources.get(&key.instance_id).unwrap() + } + }; + let archive_folder = match archive_folders.get(&key.instance_id) { + Some(folder) => folder.clone(), + None => { + let folder = unique_archive_folder( + &sanitize_archive_component(&source.name), + &mut used_archive_folders, + ); + archive_folders.insert(key.instance_id.clone(), folder.clone()); + folder + } + }; + let archive_name = format!("{archive_folder}/{}", key.file_name); + screenshots.push((archive_name, get_screenshot_path(key).await?)); + } + + let mut file = File::create(&export_path) + .await + .map_err(|error| IOError::with_path(error, &export_path))?; + let mut writer = ZipFileWriter::with_tokio(&mut file); + + for (archive_name, path) in screenshots { + let mut stream = writer + .write_entry_stream( + ZipEntryBuilder::new(archive_name.into(), Compression::Stored) + .build(), + ) + .await? + .compat_write(); + let mut source = File::open(&path) + .await + .map_err(|error| IOError::with_path(error, &path))?; + tokio::io::copy(&mut source, &mut stream) + .await + .map_err(IOError::from)?; + stream.into_inner().close().await?; + } + + writer.close().await?; + Ok(()) +} + +pub async fn move_screenshots( + keys: &[ScreenshotKey], + target_instance_id: &str, +) -> crate::Result> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Ok(Vec::new()); + } + + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .chain(std::iter::once(target_instance_id)) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let mut locks = Vec::with_capacity(instance_ids.len()); + for instance_id in instance_ids { + locks.push(state.lock_instance_screenshots(instance_id).await); + } + let target_source = instance_rows::get_instance_screenshot_source( + target_instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown target instance".to_string()) + })?; + let target_dir = source_screenshots_dir(&state, &target_source).await?; + io::create_dir_all(&target_dir).await?; + ensure_directory_is_not_symlink(&target_dir).await?; + + let mut moved_keys = Vec::with_capacity(keys.len()); + for key in keys { + if key.instance_id == target_instance_id { + continue; + } + + let source_path = get_screenshot_path(key).await?; + let target_path = + available_target_path(&target_dir, &key.file_name).await?; + io::rename_or_move(&source_path, &target_path) + .await + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Could not move screenshot: {error}" + )) + })?; + let file_name = target_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not determine moved screenshot file name" + .to_string(), + ) + })? + .to_string(); + screenshot_rows::move_screenshot( + &key.instance_id, + &key.file_name, + target_instance_id, + &file_name, + &state.pool, + ) + .await?; + moved_keys.push(ScreenshotKey { + instance_id: target_instance_id.to_string(), + file_name, + }); + } + + Ok(moved_keys) +} + +pub async fn get_screenshot_path( + key: &ScreenshotKey, +) -> crate::Result { + validate_file_name(&key.file_name)?; + + let state = State::get().await?; + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let screenshots_dir = source_screenshots_dir(&state, &source).await?; + let canonical_dir = tokio::fs::canonicalize(&screenshots_dir) + .await + .map_err(|error| IOError::with_path(error, &screenshots_dir))?; + let requested_path = screenshots_dir.join(&key.file_name); + let metadata = tokio::fs::symlink_metadata(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(crate::ErrorKind::InputError( + "Screenshot must be a regular file".to_string(), + ) + .into()); + } + + let canonical_path = tokio::fs::canonicalize(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + if !canonical_path.starts_with(&canonical_dir) { + return Err(crate::ErrorKind::InputError( + "Screenshot path is outside the instance screenshots directory" + .to_string(), + ) + .into()); + } + + Ok(canonical_path) +} + +async fn list_source_screenshots( + state: &State, + source: InstanceScreenshotSource, +) -> crate::Result> { + let _lock = state.lock_instance_screenshots(&source.id).await; + let scanned = scan_source_screenshots(state, &source).await?; + reconcile_source_screenshots(state, &source, scanned).await +} + +pub(crate) async fn reconcile_screenshots( + instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let Some(source) = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + else { + return Ok(()); + }; + + list_source_screenshots(&state, source).await?; + Ok(()) +} + +async fn scan_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result> { + let instance_dir = state.directories.instances_dir().join(&source.path); + if !tokio::fs::try_exists(&instance_dir) + .await + .map_err(|error| IOError::with_path(error, &instance_dir))? + { + return Ok(Vec::new()); + } + + let screenshots_dir = source_screenshots_dir(state, &source).await?; + let mut entries = match io::read_dir(&screenshots_dir).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + }; + let mut screenshots = Vec::new(); + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| IOError::with_path(error, &screenshots_dir))? + { + let file_type = entry + .file_type() + .await + .map_err(|error| IOError::with_path(error, entry.path()))?; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + if !has_png_extension(&path) { + continue; + } + + let Some(file_name) = entry.file_name().to_str().map(str::to_owned) + else { + continue; + }; + let metadata = entry + .metadata() + .await + .map_err(|error| IOError::with_path(error, &path))?; + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .map(DateTime::::from) + .map_err(|error| IOError::with_path(error, &path))?; + let modified_at = metadata + .modified() + .map(DateTime::::from) + .map(|value| value.timestamp_millis()) + .map_err(|error| IOError::with_path(error, &path))?; + let file_size = i64::try_from(metadata.len()).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + + screenshots.push(ScannedScreenshot { + file_name, + created_at, + modified_at, + file_size, + path, + }); + } + + Ok(screenshots) +} + +async fn reconcile_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, + scanned: Vec, +) -> crate::Result> { + let existing = + screenshot_rows::list_screenshots(&source.id, &state.pool).await?; + let mut existing_by_name = existing + .into_iter() + .map(|row| (row.file_name.clone(), row)) + .collect::>(); + let mut resolved = Vec::with_capacity(scanned.len()); + let mut unmatched_scanned = Vec::new(); + + for scanned in scanned { + let Some(mut row) = existing_by_name.remove(&scanned.file_name) else { + unmatched_scanned.push(scanned); + continue; + }; + let metadata_changed = row.file_size != scanned.file_size + || row.modified_at != scanned.modified_at + || row.created_at != scanned.created_at.timestamp_millis(); + if metadata_changed { + let (_, hash) = sha1_file_async(&scanned.path).await?; + row.content_hash = hash; + row.file_size = scanned.file_size; + row.modified_at = scanned.modified_at; + row.created_at = scanned.created_at.timestamp_millis(); + } + resolved.push(ResolvedScreenshot { + scanned, + row, + is_new: false, + changed: metadata_changed, + }); + } + + let mut unmatched_by_hash = + HashMap::<(String, i64), Vec>::new(); + for row in existing_by_name.into_values() { + unmatched_by_hash + .entry((row.content_hash.clone(), row.file_size)) + .or_default() + .push(row); + } + + for scanned in unmatched_scanned { + let (file_size, content_hash) = sha1_file_async(&scanned.path).await?; + let file_size = i64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + let hash_key = (content_hash.clone(), file_size); + let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| { + if rows.is_empty() { + return None; + } + let created_at = scanned.created_at.timestamp_millis(); + let index = rows + .iter() + .position(|row| { + row.modified_at == scanned.modified_at + && row.created_at == created_at + }) + .unwrap_or(rows.len() - 1); + Some(rows.swap_remove(index)) + }); + let (mut row, is_new) = match matched { + Some(row) => (row, false), + None => ( + ScreenshotRow { + id: Uuid::new_v4().to_string(), + instance_id: source.id.clone(), + file_name: scanned.file_name.clone(), + content_hash: content_hash.clone(), + file_size, + modified_at: scanned.modified_at, + created_at: scanned.created_at.timestamp_millis(), + group_id: None, + }, + true, + ), + }; + row.instance_id.clone_from(&source.id); + row.file_name.clone_from(&scanned.file_name); + row.content_hash = content_hash; + row.file_size = file_size; + row.modified_at = scanned.modified_at; + row.created_at = scanned.created_at.timestamp_millis(); + resolved.push(ResolvedScreenshot { + scanned, + row, + is_new, + changed: !is_new, + }); + } + + let mut tx = state.pool.begin().await?; + for screenshot in &resolved { + if screenshot.is_new { + screenshot_rows::insert_screenshot(&screenshot.row, &mut tx) + .await?; + } else if screenshot.changed { + screenshot_rows::update_screenshot(&screenshot.row, &mut tx) + .await?; + } + } + for row in unmatched_by_hash.into_values().flatten() { + screenshot_rows::delete_screenshot(&row.id, &mut tx).await?; + } + tx.commit().await?; + + let mut screenshots = resolved + .into_iter() + .map(|screenshot| InstanceScreenshot { + id: screenshot.row.id, + instance_id: source.id.clone(), + instance_name: source.name.clone(), + file_name: screenshot.scanned.file_name, + created_at: screenshot.scanned.created_at, + group_id: screenshot.row.group_id, + path: screenshot.scanned.path, + }) + .collect::>(); + sort_screenshots(&mut screenshots); + Ok(screenshots) +} + +async fn source_screenshots_dir( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result { + let instance_dir = state.directories.instances_dir().join(&source.path); + let canonical_instance_dir = + tokio::fs::canonicalize(&instance_dir) + .await + .map_err(|error| IOError::with_path(error, &instance_dir))?; + let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY); + + ensure_directory_is_not_symlink(&screenshots_dir).await?; + Ok(screenshots_dir) +} + +async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots directory cannot be a symbolic link" + .to_string(), + ) + .into()) + } + Ok(metadata) if !metadata.is_dir() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots path must be a directory".to_string(), + ) + .into()) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(IOError::with_path(error, path).into()), + } +} + +async fn available_target_path( + target_dir: &Path, + file_name: &str, +) -> crate::Result { + validate_file_name(file_name)?; + let original = target_dir.join(file_name); + if !tokio::fs::try_exists(&original) + .await + .map_err(|error| IOError::with_path(error, &original))? + { + return Ok(original); + } + + let path = Path::new(file_name); + let stem = path.file_stem().and_then(|value| value.to_str()).unwrap(); + let extension = path.extension().and_then(|value| value.to_str()).unwrap(); + + for suffix in 1_u32.. { + let candidate = + target_dir.join(format!("{stem} ({suffix}).{extension}")); + if !tokio::fs::try_exists(&candidate) + .await + .map_err(|error| IOError::with_path(error, &candidate))? + { + return Ok(candidate); + } + } + + unreachable!() +} + +fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> { + let mut unique = HashSet::new(); + if keys.iter().all(|key| unique.insert(key)) { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Screenshot selection contains duplicates".to_string(), + ) + .into()) +} + +fn validate_file_name(file_name: &str) -> crate::Result<()> { + let path = Path::new(file_name); + let mut components = path.components(); + let is_single_file = + matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none(); + + if !is_single_file || !has_png_extension(path) { + return Err(crate::ErrorKind::InputError( + "Invalid screenshot file name".to_string(), + ) + .into()); + } + + Ok(()) +} + +fn has_png_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("png")) +} + +fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) { + screenshots.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + .then_with(|| left.file_name.cmp(&right.file_name)) + }); +} + +fn sanitize_archive_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => character, + }) + .collect::(); + + if sanitized.is_empty() || sanitized == "." || sanitized == ".." { + "Instance".to_string() + } else { + sanitized + } +} + +fn unique_archive_folder( + base: &str, + used_folders: &mut HashSet, +) -> String { + if used_folders.insert(base.to_string()) { + return base.to_string(); + } + + for suffix in 2_u32.. { + let candidate = format!("{base} ({suffix})"); + if used_folders.insert(candidate.clone()) { + return candidate; + } + } + + unreachable!() +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 3251429049..992b7d3883 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -27,12 +27,12 @@ pub mod data { Hooks, InstanceIconBackground, InstanceIconConfig, InstanceInstallCandidate, InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata, - JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader, - ModrinthCredentials, OnboardingChecklist, Organization, OwnerType, - ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult, - SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment, - SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version, - WindowSize, + InstanceSyncedOption, InstanceSyncedOptions, JavaVersion, + LinkedModpackInfo, MemorySettings, ModLoader, 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::{ diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index 2fd5d72b71..059bfbfa00 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -472,6 +472,7 @@ pub enum InstancePayloadType { Created, Synced, ServersUpdated, + ScreenshotsUpdated, WorldUpdated { world: String, }, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 1c829d7308..85c5791eef 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -4,8 +4,9 @@ use crate::state::instances::{ ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind, Instance, InstanceIconBackground, InstanceIconConfig, InstanceLaunchContext, InstanceLaunchOverrides, - InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment, - SharedInstanceRole, playtime_to_storage, + InstanceLaunchOverridesData, InstanceLink, InstanceSyncedOption, + InstanceSyncedOptions, SharedInstanceAttachment, SharedInstanceRole, + playtime_to_storage, }; use crate::state::{ InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel, @@ -168,6 +169,7 @@ pub(crate) struct InstanceMetadataRecord { pub link: InstanceLink, pub shared_instance: Option, pub group_ids: Vec, + pub synced_options: InstanceSyncedOptions, pub launch_overrides: InstanceLaunchOverrides, } @@ -177,6 +179,13 @@ pub(crate) struct InstanceDisplayInfo { pub name: String, } +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct InstanceScreenshotSource { + pub id: String, + pub name: String, + pub path: String, +} + #[derive(Debug, sqlx::FromRow)] struct InstanceMetadataRow { id: String, @@ -363,6 +372,7 @@ impl InstanceMetadataRow { link, shared_instance, group_ids, + synced_options: InstanceSyncedOptions::default(), launch_overrides, }) } @@ -466,6 +476,107 @@ where Ok(row) } +pub(crate) async fn get_instance_screenshot_source( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + let source = sqlx::query_as::<_, InstanceScreenshotSource>( + " + SELECT id, name, path + FROM instances + WHERE id = ? + ", + ) + .bind(instance_id) + .fetch_optional(pool) + .await?; + + Ok(source) +} + +pub(crate) async fn list_synced_screenshot_sources( + pool: &SqlitePool, +) -> crate::Result> { + let sources = sqlx::query_as::<_, InstanceScreenshotSource>( + " + SELECT instances.id, instances.name, instances.path + FROM instances + INNER JOIN instance_synced_options synced_options + ON synced_options.instance_id = instances.id + WHERE synced_options.option = 'screenshots' + AND synced_options.enabled = 1 + ORDER BY instances.name, instances.id + ", + ) + .fetch_all(pool) + .await?; + + Ok(sources) +} + +pub(crate) async fn list_screenshot_sources( + pool: &SqlitePool, +) -> crate::Result> { + let sources = sqlx::query_as::<_, InstanceScreenshotSource>( + " + SELECT id, name, path + FROM instances + ORDER BY name, id + ", + ) + .fetch_all(pool) + .await?; + + Ok(sources) +} + +pub(crate) async fn get_instance_synced_options( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result { + let screenshots = sqlx::query_scalar::<_, bool>( + " + SELECT EXISTS( + SELECT 1 + FROM instance_synced_options + WHERE instance_id = ? + AND option = 'screenshots' + AND enabled = 1 + ) + ", + ) + .bind(instance_id) + .fetch_one(pool) + .await?; + + Ok(InstanceSyncedOptions { screenshots }) +} + +async fn attach_synced_options( + records: &mut [InstanceMetadataRecord], + pool: &SqlitePool, +) -> crate::Result<()> { + let screenshot_instance_ids = sqlx::query_scalar::<_, String>( + " + SELECT instance_id + FROM instance_synced_options + WHERE option = 'screenshots' + AND enabled = 1 + ", + ) + .fetch_all(pool) + .await? + .into_iter() + .collect::>(); + + for record in records { + record.synced_options.screenshots = + screenshot_instance_ids.contains(&record.instance.id); + } + + Ok(()) +} + pub(crate) async fn is_instance_quarantined<'e, E>( instance_id: &str, exec: E, @@ -791,7 +902,13 @@ pub(crate) async fn get_instance_metadata_by_id( .fetch_optional(pool) .await?; - row.map(InstanceMetadataRow::into_record).transpose() + let mut record = row.map(InstanceMetadataRow::into_record).transpose()?; + if let Some(record) = record.as_mut() { + record.synced_options = + get_instance_synced_options(&record.instance.id, pool).await?; + } + + Ok(record) } pub(crate) async fn get_instance_metadata_many( @@ -821,9 +938,12 @@ pub(crate) async fn get_instance_metadata_many( .fetch_all(pool) .await?; - rows.into_iter() + let mut records = rows + .into_iter() .map(InstanceMetadataRow::into_record) - .collect() + .collect::>>()?; + attach_synced_options(&mut records, pool).await?; + Ok(records) } pub(crate) async fn list_instance_metadata( @@ -834,9 +954,12 @@ pub(crate) async fn list_instance_metadata( .fetch_all(pool) .await?; - rows.into_iter() + let mut records = rows + .into_iter() .map(InstanceMetadataRow::into_record) - .collect() + .collect::>>()?; + attach_synced_options(&mut records, pool).await?; + Ok(records) } pub(crate) async fn get_instance_launch_context( @@ -1139,6 +1262,60 @@ pub(crate) async fn insert_instance( Ok(()) } +pub(crate) async fn set_instance_synced_option( + instance_id: &str, + option: InstanceSyncedOption, + enabled: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let instance_exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)", + ) + .bind(instance_id) + .fetch_one(pool) + .await?; + + if !instance_exists { + return Err(crate::ErrorKind::InputError( + "Unknown instance".to_string(), + ) + .into()); + } + + sqlx::query( + " + INSERT INTO instance_synced_options (instance_id, option, enabled) + VALUES (?, ?, ?) + ON CONFLICT (instance_id, option) DO UPDATE SET + enabled = excluded.enabled + ", + ) + .bind(instance_id) + .bind(option.as_str()) + .bind(enabled) + .execute(pool) + .await?; + + Ok(()) +} + +pub(crate) async fn insert_default_instance_synced_options( + instance_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query( + " + INSERT INTO instance_synced_options (instance_id, option, enabled) + VALUES (?, 'screenshots', 1) + ", + ) + .bind(instance_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + pub(crate) async fn update_instance( instance: &Instance, tx: &mut Transaction<'_, Sqlite>, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs index d8b478f4da..80ac7f3716 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod content_rows; pub(crate) mod instance_rows; +pub(crate) mod screenshot_rows; diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs new file mode 100644 index 0000000000..0fb1faf91b --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs @@ -0,0 +1,185 @@ +use sqlx::{Sqlite, SqlitePool, Transaction}; + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotRow { + pub id: String, + pub instance_id: String, + pub file_name: String, + pub content_hash: String, + pub file_size: i64, + pub modified_at: i64, + pub created_at: i64, + pub group_id: Option, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotGroupRow { + pub id: String, + pub name: String, +} + +pub(crate) async fn list_screenshots( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotRow, + r#" + SELECT + screenshots.id, + screenshots.instance_id, + screenshots.file_name, + screenshots.content_hash, + screenshots.file_size, + screenshots.modified_at, + screenshots.created_at, + memberships.group_id AS "group_id?" + FROM screenshots + LEFT JOIN screenshot_group_memberships memberships + ON memberships.screenshot_id = screenshots.id + WHERE screenshots.instance_id = ? + ORDER BY screenshots.file_name, screenshots.id + "#, + instance_id, + ) + .fetch_all(pool) + .await?) +} + +pub(crate) async fn insert_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + INSERT INTO screenshots ( + id, + instance_id, + file_name, + content_hash, + file_size, + modified_at, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ", + row.id, + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn update_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + UPDATE screenshots + SET + instance_id = ?, + file_name = ?, + content_hash = ?, + file_size = ?, + modified_at = ?, + created_at = ? + WHERE id = ? + ", + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + row.id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot( + id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!("DELETE FROM screenshots WHERE id = ?", id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn move_screenshot( + source_instance_id: &str, + source_file_name: &str, + target_instance_id: &str, + target_file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + target_instance_id, + target_file_name, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + " + UPDATE screenshots + SET instance_id = ?, file_name = ? + WHERE instance_id = ? AND file_name = ? + ", + target_instance_id, + target_file_name, + source_instance_id, + source_file_name, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot_by_key( + instance_id: &str, + file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + instance_id, + file_name, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub(crate) async fn list_groups( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotGroupRow, + " + SELECT id, name + FROM screenshot_groups + ORDER BY display_order, name, id + ", + ) + .fetch_all(pool) + .await?) +} diff --git a/packages/app-lib/src/state/instances/commands/create_instance.rs b/packages/app-lib/src/state/instances/commands/create_instance.rs index 99aa310c44..204221b745 100644 --- a/packages/app-lib/src/state/instances/commands/create_instance.rs +++ b/packages/app-lib/src/state/instances/commands/create_instance.rs @@ -98,6 +98,11 @@ pub(crate) async fn create_instance( let mut tx = state.pool.begin().await?; instance_rows::insert_instance(&instance, &mut tx).await?; + instance_rows::insert_default_instance_synced_options( + &instance_id, + &mut tx, + ) + .await?; if let Some(icon_config) = &input.icon_config { instance_rows::update_instance_icon_config( &instance_id, diff --git a/packages/app-lib/src/state/instances/commands/get_instance.rs b/packages/app-lib/src/state/instances/commands/get_instance.rs index 4794351740..f3cc90fc50 100644 --- a/packages/app-lib/src/state/instances/commands/get_instance.rs +++ b/packages/app-lib/src/state/instances/commands/get_instance.rs @@ -1,6 +1,7 @@ use crate::state::instances::{ ContentSet, Instance, InstanceIconConfig, InstanceLaunchOverrides, - InstanceLink, SharedInstanceAttachment, adapters::sqlite::instance_rows, + InstanceLink, InstanceSyncedOptions, SharedInstanceAttachment, + adapters::sqlite::instance_rows, }; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; @@ -16,6 +17,7 @@ pub struct InstanceMetadata { #[serde(default)] pub quarantined: bool, pub group_ids: Vec, + pub synced_options: InstanceSyncedOptions, pub launch_overrides: InstanceLaunchOverrides, } @@ -87,6 +89,7 @@ fn instance_metadata( shared_instance: record.shared_instance, quarantined, group_ids: record.group_ids, + synced_options: record.synced_options, launch_overrides: record.launch_overrides, } } diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs index 7e2ed9c9a5..33a4d4204b 100644 --- a/packages/app-lib/src/state/instances/model/instance.rs +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -23,6 +23,27 @@ pub struct InstanceIconConfig { pub symbol: String, } +#[derive( + Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize, +)] +pub struct InstanceSyncedOptions { + pub screenshots: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstanceSyncedOption { + Screenshots, +} + +impl InstanceSyncedOption { + pub fn as_str(self) -> &'static str { + match self { + Self::Screenshots => "screenshots", + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Instance { pub id: String, diff --git a/packages/app-lib/src/state/instances/watcher.rs b/packages/app-lib/src/state/instances/watcher.rs index acd0e5524d..5166e1942b 100644 --- a/packages/app-lib/src/state/instances/watcher.rs +++ b/packages/app-lib/src/state/instances/watcher.rs @@ -40,6 +40,7 @@ pub async fn init_watcher() -> crate::Result { Ok(events) => { let instance_ids = event_instance_ids.read().await; let mut visited_instances = Vec::new(); + let mut visited_screenshot_instances = Vec::new(); for e in &events { let mut instance_path = None; @@ -72,6 +73,9 @@ pub async fn init_watcher() -> crate::Result { .skip_while(|x| x.as_os_str() != instance_path) .nth(1) .map(|x| x.as_os_str()); + let is_screenshot_event = first_file_name + .as_ref() + .is_some_and(|name| *name == "screenshots"); if first_file_name .as_ref() .is_some_and(|x| *x == "crash-reports") @@ -81,13 +85,25 @@ pub async fn init_watcher() -> crate::Result { .is_some_and(|x| *x == "txt") { crash_task(instance_id); - } else if !visited_instances.contains(&instance_id) + } else if (is_screenshot_event + && !visited_screenshot_instances + .contains(&instance_id)) + || (!is_screenshot_event + && !visited_instances + .contains(&instance_id)) { let event = if first_file_name .as_ref() .is_some_and(|x| *x == "servers.dat") { Some(InstancePayloadType::ServersUpdated) + } else if first_file_name + .as_ref() + .is_some_and(|x| *x == "screenshots") + { + Some( + InstancePayloadType::ScreenshotsUpdated, + ) } else if first_file_name.as_ref().is_some_and( |x| { *x == "saves" @@ -138,6 +154,10 @@ pub async fn init_watcher() -> crate::Result { }; if let Some(event) = event { let emit_instance_id = instance_id.clone(); + let reconcile_screenshots = matches!( + &event, + InstancePayloadType::ScreenshotsUpdated + ); let sync_content = first_file_name .as_ref() .is_some_and(|name| { @@ -164,13 +184,29 @@ pub async fn init_watcher() -> crate::Result { "Failed to sync instance content after filesystem change: {error}" ); } + if reconcile_screenshots + && let Err(error) = + crate::api::instance::reconcile_screenshots( + &emit_instance_id, + ) + .await + { + tracing::error!( + "Failed to reconcile screenshots after filesystem change: {error}" + ); + } let _ = emit_instance( &emit_instance_id, event, ) .await; }); - visited_instances.push(instance_id); + if is_screenshot_event { + visited_screenshot_instances + .push(instance_id); + } else { + visited_instances.push(instance_id); + } } } } @@ -199,6 +235,14 @@ pub(crate) async fn watch_instances_init( for instance in instances { watch_instance_folder(&instance.id, &instance.path, watcher, dirs) .await; + if let Err(error) = + crate::api::instance::reconcile_screenshots(&instance.id).await + { + tracing::warn!( + "Failed to reconcile screenshots for {} during watcher initialization: {error}", + instance.id + ); + } } } @@ -222,6 +266,7 @@ pub(crate) async fn watch_instance_folder( for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([ "crash-reports", "saves", + "screenshots", CONFIG_DIRECTORY, ]) { let full_path = full_instance_path.join(sub_path); diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 9b1098abeb..b428d2d036 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -77,6 +77,8 @@ pub struct State { pub(crate) install_db_semaphore: Semaphore, /// Serializes filesystem reconciliation and content mutations per instance. instance_content_locks: DashMap>>, + /// Serializes screenshot filesystem reconciliation per instance. + instance_screenshot_locks: DashMap>>, /// Serializes shared instance attachment and recipient mutations per instance. shared_instance_locks: DashMap>>, @@ -129,8 +131,22 @@ impl State { lock.lock_owned().await } + pub(crate) async fn lock_instance_screenshots( + &self, + instance_id: &str, + ) -> OwnedMutexGuard<()> { + let lock = self + .instance_screenshot_locks + .entry(instance_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + + lock.lock_owned().await + } + pub(crate) fn remove_instance_locks(&self, instance_id: &str) { let _ = self.instance_content_locks.remove(instance_id); + let _ = self.instance_screenshot_locks.remove(instance_id); let _ = self.shared_instance_locks.remove(instance_id); } @@ -254,6 +270,7 @@ impl State { install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS), install_db_semaphore: Semaphore::new(1), instance_content_locks: DashMap::new(), + instance_screenshot_locks: DashMap::new(), shared_instance_locks: DashMap::new(), discord_rpc, process_manager,