mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-07-24 00:42:51 +10:00
Async downloader, better Proton support (#183)
* feat: async downloader + other fixes * feat: windows command parsing + use library path for install path * feat: better proton support * feat: style fixes and store button now uses in-app * feat: emulator rename + umu emulator fix * feat: bring process creation inline with docs * fix: clippy
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const props = defineProps<{
|
||||
path?: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | undefined>({ required: true });
|
||||
|
||||
const isDefault = computed(() => props.path == model.value);
|
||||
|
||||
async function setDefault() {
|
||||
if (!props.path) return;
|
||||
await invoke("set_default", { path: props.path });
|
||||
model.value = props.path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['p-0.5 rounded-full', isDefault ? 'bg-blue-500' : 'bg-zinc-800']"
|
||||
@click="setDefault"
|
||||
:disabled="!props.path"
|
||||
>
|
||||
<StarIcon
|
||||
:class="['size-[0.7rem]', isDefault ? 'text-zinc-100' : 'text-zinc-100']"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -11,7 +11,7 @@
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
placeholder="{}"
|
||||
aria-describedby="launch-description"
|
||||
v-model="model!!.launchString"
|
||||
v-model="model.launchString"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
|
||||
@@ -21,11 +21,203 @@
|
||||
>Leaving it blank will cause the game not to start.</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<Listbox
|
||||
v-if="props.protonEnabled"
|
||||
as="div"
|
||||
v-model="model.overrideProtonPath"
|
||||
class="mt-6"
|
||||
>
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-white"
|
||||
>Proton override</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="grid w-full cursor-default grid-cols-1 rounded-md bg-white/5 py-1.5 pr-2 pl-3 text-left text-white outline-1 -outline-offset-1 outline-white/10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500 sm:text-sm/6"
|
||||
>
|
||||
<span
|
||||
v-if="currentProtonPath"
|
||||
class="col-start-1 row-start-1 truncate pr-6"
|
||||
>{{ currentProtonPath.name }} ({{ currentProtonPath.path }})</span
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="col-start-1 row-start-1 truncate pr-6 italic text-zinc-400"
|
||||
>No override configured</span
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-zinc-400 sm:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class=""
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-800 py-1 text-base outline-1 -outline-offset-1 outline-white/10 sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
:value="undefined"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-500 text-white outline-hidden'
|
||||
: 'text-white',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate italic',
|
||||
]"
|
||||
>Use global default</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-400',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<h1 class="text-white text-sm font-semibold bg-zinc-900 py-2 px-2">
|
||||
Auto-discovered
|
||||
</h1>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-if="protonPaths.autodiscovered.length > 0"
|
||||
v-for="proton in protonPaths.autodiscovered"
|
||||
:key="proton.path"
|
||||
:value="proton.path"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-500 text-white outline-hidden'
|
||||
: 'text-white',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ proton.name }} ({{ proton.path }})</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-400',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3"
|
||||
>No auto-discovered layers.</li
|
||||
>
|
||||
<h1 class="text-white text-sm font-semibold bg-zinc-900 py-2 px-2">
|
||||
Manually added
|
||||
</h1>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-if="protonPaths.custom.length > 0"
|
||||
v-for="proton in protonPaths.custom"
|
||||
:key="proton.path"
|
||||
:value="proton.path"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-500 text-white outline-hidden'
|
||||
: 'text-white',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ proton.name }} ({{ proton.path }})</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-400',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3"
|
||||
>No manually added layers.</li
|
||||
>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
|
||||
Override the Proton layer used to launch this game. You can add or
|
||||
remove your custom Proton layer paths in
|
||||
<PageWidget to="/settings/compat">
|
||||
<WrenchIcon class="size-3" />
|
||||
Settings </PageWidget
|
||||
>.
|
||||
</p>
|
||||
</Listbox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FrontendGameConfiguration } from "~/composables/game";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FrontendGameConfiguration, ProtonPath } from "~/composables/game";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon } from "@heroicons/vue/20/solid";
|
||||
import { WrenchIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const model = defineModel<FrontendGameConfiguration>();
|
||||
const model = defineModel<FrontendGameConfiguration>({ required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
protonEnabled: boolean;
|
||||
}>();
|
||||
|
||||
const protonPaths = await invoke<{
|
||||
autodiscovered: ProtonPath[];
|
||||
custom: ProtonPath[];
|
||||
default?: string;
|
||||
}>("fetch_proton_paths");
|
||||
const currentProtonPath = computed(
|
||||
() =>
|
||||
protonPaths.autodiscovered.find(
|
||||
(v) => v.path == model.value.overrideProtonPath,
|
||||
) ??
|
||||
protonPaths.custom.find((v) => v.path == model.value.overrideProtonPath),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<component
|
||||
v-model="configuration"
|
||||
:is="tabs[currentTabIndex]?.page"
|
||||
:proton-enabled="protonEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,14 +83,24 @@ import Launch from "./GameOptions/Launch.vue";
|
||||
import type { FrontendGameConfiguration } from "~/composables/game";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const appState = useAppState();
|
||||
|
||||
const open = defineModel<boolean>();
|
||||
const props = defineProps<{ gameId: string }>();
|
||||
const game = await useGame(props.gameId);
|
||||
|
||||
const configuration: Ref<FrontendGameConfiguration> = ref({
|
||||
launchString: game.version!!.launchCommandTemplate,
|
||||
launchString: game.version!.userConfiguration.launchTemplate,
|
||||
overrideProtonPath: game.version!.userConfiguration.overrideProtonPath,
|
||||
});
|
||||
|
||||
const hasWindows = !!(
|
||||
game.version!.setups.find((v) => v.platform === "Windows") ??
|
||||
game.version!.launches.find((v) => v.platform === "Windows")
|
||||
);
|
||||
|
||||
const protonEnabled = !!(appState.value!.umuState !== "NotNeeded" && hasWindows);
|
||||
|
||||
const tabs: Array<{ name: string; icon: Component; page: Component }> = [
|
||||
{
|
||||
name: "Launch",
|
||||
@@ -108,12 +119,14 @@ const saveLoading = ref(false);
|
||||
const saveError = ref<undefined | string>();
|
||||
async function save() {
|
||||
saveLoading.value = true;
|
||||
saveError.value = undefined;
|
||||
try {
|
||||
await invoke("update_game_configuration", {
|
||||
gameId: game.game.id,
|
||||
options: configuration.value,
|
||||
});
|
||||
open.value = false;
|
||||
saveError.value = undefined;
|
||||
} catch (e) {
|
||||
saveError.value = (e as unknown as string).toString();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
/>
|
||||
<div class="inline-flex items-center">
|
||||
<ol class="inline-flex gap-3">
|
||||
<HeaderProtonSupportWidget />
|
||||
<HeaderQueueWidget :object="currentQueueObject" />
|
||||
<li v-for="(item, itemIdx) in quickActions">
|
||||
<HeaderWidget
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<NuxtLink to="/settings/compat">
|
||||
<HeaderWidget
|
||||
v-if="appState && appState.umuState !== 'NotNeeded'"
|
||||
:problem="notInstalled"
|
||||
>
|
||||
<img src="/proton-logo.png" class="relative z-50 size-5 brightness-[30%]" />
|
||||
</HeaderWidget>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const appState = useAppState();
|
||||
|
||||
const notInstalled = appState.value?.umuState === "NotInstalled";
|
||||
</script>
|
||||
@@ -18,9 +18,9 @@ const props = defineProps<{ object?: QueueState["queue"][0] }>();
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="props.object?.progress"
|
||||
v-if="props.object?.dl_progress"
|
||||
class="transition-all absolute left-0 top-0 bottom-0 bg-blue-600 z-10"
|
||||
:style="{ width: `${props.object.progress * 99 + 1}%` }"
|
||||
:style="{ width: `${props.object.dl_progress * 99 + 1}%` }"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
<template>
|
||||
<div class="transition inline-flex items-center cursor-pointer rounded-sm px-4 py-1.5 bg-zinc-900 text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300 relative">
|
||||
<slot />
|
||||
<div v-if="props.notifications !== undefined"
|
||||
class="text-zinc-900 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-xs bg-blue-300 rounded-full w-3.5 h-3.5 text-center">
|
||||
{{ props.notifications }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'transition inline-flex items-center cursor-pointer rounded-sm px-4 py-1.5 text-zinc-600 hover:text-zinc-300 relative',
|
||||
props.notifications !== undefined
|
||||
? 'bg-blue-400'
|
||||
: props.problem !== undefined && props.problem
|
||||
? 'bg-red-400'
|
||||
: 'bg-zinc-900 hover:bg-zinc-800',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="props.notifications !== undefined"
|
||||
class="text-zinc-900 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-xs bg-blue-400 rounded-full w-3.5 h-3.5 text-center"
|
||||
>
|
||||
{{ props.notifications }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="props.problem !== undefined && props.problem"
|
||||
class="text-zinc-100 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] text-sm bg-red-400 rounded-full w-5 h-5 text-center"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
notifications?: number
|
||||
notifications?: number;
|
||||
problem?: boolean;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5 h-full">
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="flex flex-col gap-y-1.5 h-full"
|
||||
>
|
||||
<Disclosure
|
||||
as="div"
|
||||
v-for="(nav, navIndex) in filteredNavigation"
|
||||
@@ -46,7 +50,10 @@
|
||||
<span class="ml-6 relative flex size-4">
|
||||
<MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
|
||||
<MinusIcon
|
||||
:class="[ !open ? 'rotate-90' : 'rotate-0', 'transition-all absolute inset-0 size-4']"
|
||||
:class="[
|
||||
!open ? 'rotate-90' : 'rotate-0',
|
||||
'transition-all absolute inset-0 size-4',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
@@ -196,6 +203,7 @@ type FetchLibraryResponse = {
|
||||
library: Game[];
|
||||
collections: Collection[];
|
||||
other: Game[];
|
||||
missing: Game[];
|
||||
};
|
||||
|
||||
async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
|
||||
@@ -215,6 +223,7 @@ async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
|
||||
.flat()
|
||||
.map((e) => e.game),
|
||||
...library.other,
|
||||
...library.missing,
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
for (const game of allGames) {
|
||||
@@ -237,11 +246,20 @@ async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
|
||||
entries: library.other.map((v) => ({ gameId: v.id, game: v })),
|
||||
} satisfies Collection;
|
||||
|
||||
const missingCollection = {
|
||||
id: "missing",
|
||||
name: "Delisted",
|
||||
isDefault: false,
|
||||
isTools: true,
|
||||
entries: library.missing.map((v) => ({ gameId: v.id, game: v })),
|
||||
};
|
||||
|
||||
loading.value = false;
|
||||
collections.value = [
|
||||
libraryCollection,
|
||||
...library.collections,
|
||||
...(library.other.length > 0 ? [otherCollection] : []),
|
||||
...(library.missing.length > 0 ? [missingCollection] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -259,24 +277,25 @@ await new Promise<void>((r) => {
|
||||
|
||||
const navigation = computed(() =>
|
||||
collections.value.map((collection) => {
|
||||
const items = collection.entries.map(({ game }) => {
|
||||
const status = games[game.id].status;
|
||||
const items = collection.entries
|
||||
.map(({ game }) => {
|
||||
const status = games[game.id].status;
|
||||
|
||||
const isInstalled = computed(
|
||||
() => status.value.type != GameStatusEnum.Remote,
|
||||
);
|
||||
const isInstalled = computed(
|
||||
() => status.value.type != GameStatusEnum.Remote,
|
||||
);
|
||||
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
icon: game.mIconObjectId,
|
||||
isInstalled,
|
||||
id: game.id,
|
||||
type: game.type,
|
||||
};
|
||||
return item;
|
||||
});
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
icon: game.mIconObjectId,
|
||||
isInstalled,
|
||||
id: game.id,
|
||||
type: game.type,
|
||||
};
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
id: collection.id,
|
||||
|
||||
Reference in New Issue
Block a user