951 lines
34 KiB
HTML
951 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="auto">
|
|
|
|
<head>
|
|
<%- header %>
|
|
<style>
|
|
.precmd-head {
|
|
width: 200px;
|
|
}
|
|
|
|
.monospace {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.cover-finder {}
|
|
|
|
.cover-finder .cover-results {
|
|
max-height: 400px;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.cover-finder .cover-results.busy * {
|
|
cursor: wait !important;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.cover-container {
|
|
padding-top: 133.33%;
|
|
position: relative;
|
|
}
|
|
|
|
.cover-container.result {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.spinner-border {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
margin: auto;
|
|
}
|
|
|
|
.cover-container img {
|
|
display: block;
|
|
position: absolute;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.config-page {
|
|
padding: 1em;
|
|
border: 1px solid #dee2e6;
|
|
border-top: none;
|
|
}
|
|
|
|
td {
|
|
padding: 0 0.5em;
|
|
}
|
|
|
|
.env-table td {
|
|
padding: 0.25em;
|
|
border-bottom: rgba(0, 0, 0, 0.25) 1px solid;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.actions-col {
|
|
min-width: 150px;
|
|
}
|
|
|
|
.pre-wrap {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.dragover {
|
|
border-top: 2px solid #ffc400;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body id="app" v-cloak>
|
|
<Navbar></Navbar>
|
|
<div class="container">
|
|
<div class="my-4" v-if="!showEditForm">
|
|
<h1>{{ $t('apps.applications_title') }}</h1>
|
|
<div>{{ $t('apps.applications_desc') }}</div>
|
|
<div>{{ $t('apps.applications_reorder_desc') }}</div>
|
|
<div class="pre-wrap">{{ $t('apps.applications_tips') }}</div>
|
|
</div>
|
|
<div class="my-4" v-else>
|
|
<h1>{{ editForm.name || "< NO NAME >"}}</h1>
|
|
<div>
|
|
<button @click="showEditForm = false" class="btn btn-secondary me-2 mt-2">
|
|
<i class="fas fa-xmark"></i> {{ $t('_common.cancel') }}
|
|
</button>
|
|
<button class="btn btn-primary me-2 mt-2" :disabled="actionDisabled || !editForm.name.trim()" @click="save">
|
|
<i class="fas fa-floppy-disk"></i> {{ $t('_common.save') }}
|
|
</button>
|
|
<button class="btn btn-success mt-2" @click="exportLauncherFile(editForm)" :disabled="!editForm.uuid">
|
|
<i class="fas fa-arrow-up-from-bracket"></i> {{ $t('apps.export_launcher_file') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4" v-if="!showEditForm">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">{{ $t('apps.name') }}</th>
|
|
<th scope="col" class="text-end actions-col">{{ $t('apps.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="(app,i) in apps"
|
|
:key="app.uuid"
|
|
:class="{dragover: app.dragover}"
|
|
draggable="true"
|
|
@dragstart="onDragStart($event, i)"
|
|
@dragenter="onDragEnter($event, app)"
|
|
@dragover="onDragOver($event)"
|
|
@dragleave="onDragLeave(app)"
|
|
@dragend="onDragEnd()"
|
|
@drop="onDrop($event, app, i)"
|
|
>
|
|
<td @dblclick="exportLauncherFile(app)">{{app.name || ' '}}</td>
|
|
<td v-if="app.uuid" class="text-end">
|
|
<button class="btn btn-primary me-1" :disabled="actionDisabled" @click="editApp(app)">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button class="btn btn-danger me-1" :disabled="actionDisabled" @click="showDeleteForm(app)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
<button class="btn btn-warning" :disabled="actionDisabled" @click="closeApp()" v-if="currentApp === app.uuid">
|
|
<i class="fas fa-stop"></i>
|
|
</button>
|
|
<a class="btn btn-success" :disabled="actionDisabled" @click.prevent="launchApp(event, app)" :href="'art://launch?host_uuid=' + hostUUID + '&host_name=' + hostName + '&app_uuid=' + app.uuid + '&app_name=' + app.name" v-else>
|
|
<i class="fas fa-play"></i>
|
|
</a>
|
|
</td>
|
|
<td v-else></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="mt-2">
|
|
<button class="btn btn-primary" @click="newApp" :disabled="actionDisabled">
|
|
<i class="fas fa-plus"></i> {{ $t('apps.add_new') }}
|
|
</button>
|
|
<button class="btn btn-secondary float-end" @click="alphabetizeApps" :disabled="actionDisabled" v-if="!listReordered">
|
|
<i class="fas fa-sort-alpha-up"></i> {{ $t('apps.alphabetize') }}
|
|
</button>
|
|
<button class="btn btn-warning float-end" @click="saveOrder" :disabled="actionDisabled" v-if="listReordered">
|
|
<i class="fas fa-floppy-disk"></i> {{ $t('apps.save_order') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="edit-form card mt-2" v-else>
|
|
<div class="p-4">
|
|
<!-- Application Name -->
|
|
<div class="mb-3">
|
|
<label for="appName" class="form-label">{{ $t('apps.app_name') }}</label>
|
|
<div class="input-group dropup">
|
|
<input type="text" class="form-control" id="appName" aria-describedby="appNameHelp" v-model="editForm.name" />
|
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle"
|
|
aria-expanded="false" @click="showCoverFinder" ref="coverFinderDropdown">
|
|
{{ $t('apps.find_cover') }} (Online)
|
|
</button>
|
|
<div class="dropdown-menu dropdown-menu-end w-50 cover-finder overflow-hidden"
|
|
aria-labelledby="findCoverToggle">
|
|
<div class="modal-header px-2">
|
|
<h4 class="modal-title">{{ $t('apps.covers_found') }}</h4>
|
|
<button type="button" class="btn-close mr-2" aria-label="Close" @click="closeCoverFinder"></button>
|
|
</div>
|
|
<div class="modal-body cover-results px-3 pt-3" :class="{ busy: coverFinderBusy }">
|
|
<div class="row">
|
|
<div v-if="coverSearching" class="col-12 col-sm-6 col-lg-4 mb-3">
|
|
<div class="cover-container">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">{{ $t('apps.loading') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-for="(cover,i) in coverCandidates" :key="'i'" class="col-12 col-sm-6 col-lg-4 mb-3"
|
|
@click="useCover(cover)">
|
|
<div class="cover-container result">
|
|
<img class="rounded" :src="cover.url" />
|
|
</div>
|
|
<label class="d-block text-nowrap text-center text-truncate">
|
|
{{cover.name}}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="appNameHelp" class="form-text">{{ $t('apps.app_name_desc') }}</div>
|
|
</div>
|
|
<!-- Application Image -->
|
|
<div class="mb-3">
|
|
<label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label>
|
|
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
|
|
v-model="editForm['image-path']" />
|
|
<div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
|
|
</div>
|
|
<!-- gamepad override -->
|
|
<div class="mb-3" v-if="platform !== 'macos'">
|
|
<label for="gamepad" class="form-label">{{ $t('config.gamepad') }}</label>
|
|
<select id="gamepad" class="form-select" v-model="editForm.gamepad">
|
|
<option value="">{{ $t('_common.default_global') }}</option>
|
|
<option value="disabled">{{ $t('_common.disabled') }}</option>
|
|
<option value="auto">{{ $t('_common.auto') }}</option>
|
|
|
|
<template v-if="platform === 'linux'">
|
|
<option value="ds5">{{ $t("config.gamepad_ds5") }}</option>
|
|
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
|
|
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
|
|
</template>
|
|
|
|
<template v-if="platform === 'windows'">
|
|
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
|
|
<option value="x360">{{ $t('config.gamepad_x360') }}</option>
|
|
</template>
|
|
|
|
</select>
|
|
<div class="form-text">{{ $t('config.gamepad_desc') }}</div>
|
|
</div>
|
|
<!-- command -->
|
|
<div class="mb-3">
|
|
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
|
|
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
|
|
v-model="editForm.cmd" />
|
|
<div id="appCmdHelp" class="form-text">
|
|
{{ $t('apps.cmd_desc') }}<br>
|
|
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
|
|
</div>
|
|
</div>
|
|
<!-- elevation -->
|
|
<Checkbox v-if="platform === 'windows'"
|
|
class="mb-3"
|
|
id="appElevation"
|
|
label="_common.run_as"
|
|
desc="apps.run_as_desc"
|
|
v-model="editForm.elevated"
|
|
default="false"
|
|
></Checkbox>
|
|
<!-- detached -->
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ $t('apps.detached_cmds') }}</label>
|
|
<div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between my-2">
|
|
<input type="text" v-model="editForm.detached[i]" class="form-control monospace">
|
|
<button class="btn btn-danger mx-2" @click="editForm.detached.splice(i,1)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
<button class="btn btn-success" @click="editForm.detached.splice(i, 0, '')">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
</div>
|
|
<div class="d-flex justify-content-between">
|
|
<button class="btn btn-success" @click="editForm.detached.push('');">
|
|
<i class="fas fa-plus mr-1"></i> {{ $t('apps.detached_cmds_add') }}
|
|
</button>
|
|
</div>
|
|
<div class="form-text">
|
|
{{ $t('apps.detached_cmds_desc') }}<br>
|
|
<b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }}
|
|
</div>
|
|
</div>
|
|
<!-- allow client commands -->
|
|
<Checkbox class="mb-3"
|
|
id="clientCommands"
|
|
label="apps.allow_client_commands"
|
|
desc="apps.allow_client_commands_desc"
|
|
v-model="editForm['allow-client-commands']"
|
|
default="true"
|
|
></Checkbox>
|
|
<!-- prep and state-cmd -->
|
|
<template v-for="type in ['prep', 'state']">
|
|
<Checkbox class="mb-3"
|
|
:id="'excludeGlobal_' + type"
|
|
:label="'apps.global_' + type + '_name'"
|
|
:desc="'apps.global_' + type + '_desc'"
|
|
v-model="editForm['exclude-global-' + type + '-cmd']"
|
|
default="true"
|
|
inverse-values
|
|
></Checkbox>
|
|
<div class="mb-3">
|
|
<label class="form-label">{{ $t('apps.cmd_' + type + '_name') }}</label>
|
|
<div class="form-text pre-wrap">{{ $t('apps.cmd_' + type + '_desc') }}</div>
|
|
<table class="table" v-if="editForm[type + '-cmd'].length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
|
|
<th scope="col"><i class="fas fa-undo"></i> {{ $t('_common.undo_cmd') }}</th>
|
|
<th scope="col" v-if="platform === 'windows'">
|
|
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }}
|
|
</th>
|
|
<th scope="col"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(c, i) in editForm[type + '-cmd']">
|
|
<td>
|
|
<input type="text" class="form-control monospace" v-model="c.do" />
|
|
</td>
|
|
<td>
|
|
<input type="text" class="form-control monospace" v-model="c.undo" />
|
|
</td>
|
|
<td v-if="platform === 'windows'" class="align-middle">
|
|
<Checkbox :id="type + '-cmd-admin-' + i"
|
|
label="_common.elevated"
|
|
desc=""
|
|
v-model="c.elevated"
|
|
></Checkbox>
|
|
</td>
|
|
<td class="text-end">
|
|
<button class="btn btn-danger mx-2" @click="editForm[type + '-cmd'].splice(i,1)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
<button class="btn btn-success" @click="addCmd(editForm[type + '-cmd'], i)">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="d-flex justify-content-start mb-3 mt-3">
|
|
<button class="btn btn-success" @click="addCmd(editForm[type + '-cmd'], -1)">
|
|
<i class="fas fa-plus mr-1"></i> {{ $t('apps.add_cmds') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- working dir -->
|
|
<div class="mb-3">
|
|
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
|
|
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
|
|
v-model="editForm['working-dir']" />
|
|
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
|
|
</div>
|
|
<!-- output -->
|
|
<div class="mb-3">
|
|
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
|
|
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
|
|
v-model="editForm.output" />
|
|
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
|
|
</div>
|
|
<!-- auto-detach -->
|
|
<Checkbox class="mb-3"
|
|
id="autoDetach"
|
|
label="apps.auto_detach"
|
|
desc="apps.auto_detach_desc"
|
|
v-model="editForm['auto-detach']"
|
|
default="true"
|
|
></Checkbox>
|
|
<!-- wait for all processes -->
|
|
<Checkbox class="mb-3"
|
|
id="waitAll"
|
|
label="apps.wait_all"
|
|
desc="apps.wait_all_desc"
|
|
v-model="editForm['wait-all']"
|
|
default="true"
|
|
></Checkbox>
|
|
<!-- terminate on pause -->
|
|
<Checkbox class="mb-3"
|
|
id="terminateOnPause"
|
|
label="apps.terminate_on_pause"
|
|
desc="apps.terminate_on_pause_desc"
|
|
v-model="editForm['terminate-on-pause']"
|
|
default="false"
|
|
></Checkbox>
|
|
<!-- exit timeout -->
|
|
<div class="mb-3">
|
|
<label for="exitTimeout" class="form-label">{{ $t('apps.exit_timeout') }}</label>
|
|
<input type="number" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
|
|
v-model="editForm['exit-timeout']" min="0" placeholder="5" />
|
|
<div id="exitTimeoutHelp" class="form-text">{{ $t('apps.exit_timeout_desc') }}</div>
|
|
</div>
|
|
<!-- use virtual display -->
|
|
<Checkbox class="mb-3"
|
|
id="virtualDisplay"
|
|
label="apps.virtual_display"
|
|
desc="apps.virtual_display_desc"
|
|
v-model="editForm['virtual-display']"
|
|
default="false"
|
|
></Checkbox>
|
|
<!-- use app identity -->
|
|
<Checkbox class="mb-3"
|
|
id="useAppIdentity"
|
|
label="apps.use_app_identity"
|
|
desc="apps.use_app_identity_desc"
|
|
v-model="editForm['use-app-identity']"
|
|
default="false"
|
|
></Checkbox>
|
|
<!-- per-client app identity -->
|
|
<Checkbox class="mb-3"
|
|
v-if="editForm['use-app-identity']"
|
|
id="perClientAppIdentity"
|
|
label="apps.per_client_app_identity"
|
|
desc="apps.per_client_app_identity_desc"
|
|
v-model="editForm['per-client-app-identity']"
|
|
default="false"
|
|
></Checkbox>
|
|
<!-- resolution scale factor -->
|
|
<div class="mb-3" v-if="platform === 'windows'">
|
|
<label for="resolutionScaleFactor" class="form-label">{{ $t('apps.resolution_scale_factor') }}: {{editForm['scale-factor']}}%</label>
|
|
<input type="range" step="1" min="20" max="200" class="form-range" id="resolutionScaleFactor" v-model="editForm['scale-factor']"/>
|
|
<div class="form-text">{{ $t('apps.resolution_scale_factor_desc') }}</div>
|
|
</div>
|
|
<div class="env-hint alert alert-info overflow-auto">
|
|
<div class="form-text">
|
|
<h4>{{ $t('apps.env_vars_about') }}</h4>
|
|
{{ $t('apps.env_vars_desc') }}
|
|
</div>
|
|
<table class="env-table">
|
|
<tr>
|
|
<td><b>{{ $t('apps.env_var_name') }}</b></td>
|
|
<td><b></b></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_APP_ID</td>
|
|
<td>{{ $t('apps.env_app_id') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_APP_NAME</td>
|
|
<td>{{ $t('apps.env_app_name') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_APP_UUID</td>
|
|
<td>{{ $t('apps.env_app_uuid') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_APP_STATUS</td>
|
|
<td>{{ $t('apps.env_app_status') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_UUID</td>
|
|
<td>{{ $t('apps.env_client_uuid') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_NAME</td>
|
|
<td>{{ $t('apps.env_client_name') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_WIDTH</td>
|
|
<td>{{ $t('apps.env_client_width') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_HEIGHT</td>
|
|
<td>{{ $t('apps.env_client_height') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_FPS</td>
|
|
<td>{{ $t('apps.env_client_fps') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_HDR</td>
|
|
<td>{{ $t('apps.env_client_hdr') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_GCMAP</td>
|
|
<td>{{ $t('apps.env_client_gcmap') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_HOST_AUDIO</td>
|
|
<td>{{ $t('apps.env_client_host_audio') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_ENABLE_SOPS</td>
|
|
<td>{{ $t('apps.env_client_enable_sops') }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">APOLLO_CLIENT_AUDIO_CONFIGURATION</td>
|
|
<td>{{ $t('apps.env_client_audio_config') }}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<template v-if="platform === 'windows'">
|
|
<div class="form-text"><b>{{ $t('apps.env_rtss_cli_example') }}</b>
|
|
<pre>cmd /C \path\to\rtss-cli.exe limit:set %APOLLO_CLIENT_FPS%</pre>
|
|
</div>
|
|
</template>
|
|
<template v-if="platform === 'linux'">
|
|
<div class="form-text"><b>{{ $t('apps.env_xrandr_example') }}</b>
|
|
<pre>sh -c "xrandr --output HDMI-1 --mode \"${APOLLO_CLIENT_WIDTH}x${APOLLO_CLIENT_HEIGHT}\" --rate ${APOLLO_CLIENT_FPS}"</pre>
|
|
</div>
|
|
</template>
|
|
<template v-if="platform === 'macos'">
|
|
<div class="form-text"><b>{{ $t('apps.env_displayplacer_example') }}</b>
|
|
<pre>sh -c "displayplacer "id:<screenId> res:${APOLLO_CLIENT_WIDTH}x${APOLLO_CLIENT_HEIGHT} hz:${APOLLO_CLIENT_FPS} scaling:on origin:(0,0) degree:0""</pre>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="form-text">
|
|
<a
|
|
href="https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2app__examples.html"
|
|
target="_blank"
|
|
class="text-decoration-none"
|
|
>
|
|
{{ $t('_common.see_more') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert alert-info">
|
|
<i class="fa-solid fa-xl fa-circle-info"></i> {{ $t('apps.env_sunshine_compatibility') }}
|
|
</div>
|
|
|
|
<!-- Save buttons -->
|
|
<div class="d-flex">
|
|
<button @click="showEditForm = false" class="btn btn-secondary me-2">
|
|
<i class="fas fa-xmark"></i> {{ $t('_common.cancel') }}
|
|
</button>
|
|
<button class="btn btn-primary" :disabled="actionDisabled || !editForm.name.trim()" @click="save">
|
|
<i class="fas fa-floppy-disk"></i> {{ $t('_common.save') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<script type="module">
|
|
import { createApp } from 'vue'
|
|
import { initApp } from './init'
|
|
import Navbar from './Navbar.vue'
|
|
import Checkbox from './Checkbox.vue'
|
|
import { Dropdown } from 'bootstrap/dist/js/bootstrap'
|
|
|
|
const newApp = {
|
|
"name": "New App",
|
|
"output": "",
|
|
"cmd": "",
|
|
"exclude-global-prep-cmd": false,
|
|
"exclude-global-state-cmd": false,
|
|
"elevated": false,
|
|
"auto-detach": true,
|
|
"wait-all": true,
|
|
"exit-timeout": 5,
|
|
"prep-cmd": [],
|
|
"state-cmd": [],
|
|
"detached": [],
|
|
"image-path": "",
|
|
"scale-factor": 100,
|
|
"use-app-identity": false,
|
|
"per-client-app-identity": false,
|
|
"allow-client-commands": true,
|
|
"virtual-display": false,
|
|
"terminate-on-pause": false,
|
|
"gamepad": ""
|
|
}
|
|
|
|
const app = createApp({
|
|
components: {
|
|
Navbar,
|
|
Checkbox
|
|
},
|
|
data() {
|
|
return {
|
|
apps: [],
|
|
showEditForm: false,
|
|
actionDisabled: false,
|
|
editForm: null,
|
|
detachedCmd: "",
|
|
coverSearching: false,
|
|
coverFinderBusy: false,
|
|
coverCandidates: [],
|
|
platform: "",
|
|
currentApp: "",
|
|
draggingApp: -1,
|
|
hostName: "",
|
|
hostUUID: "",
|
|
listReordered: false
|
|
};
|
|
},
|
|
created() {
|
|
this.loadApps();
|
|
|
|
fetch("./api/config", {
|
|
credentials: 'include'
|
|
})
|
|
.then(r => r.json())
|
|
.then(r => this.platform = r.platform);
|
|
},
|
|
methods: {
|
|
onDragStart(e, idx) {
|
|
if (this.showEditForm) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
this.draggingApp = idx;
|
|
this.apps.push({})
|
|
},
|
|
onDragEnter(e, app) {
|
|
if (this.draggingApp < 0) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
app.dragover = true;
|
|
},
|
|
onDragOver(e) {
|
|
if (this.draggingApp < 0) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
},
|
|
onDragLeave(app) {
|
|
app.dragover = false;
|
|
},
|
|
onDragEnd() {
|
|
this.draggingApp = -1;
|
|
this.apps.pop();
|
|
},
|
|
onDrop(e, app, idx) {
|
|
app.dragover = false;
|
|
if (this.draggingApp < 0) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
|
|
if (idx === this.draggingApp || idx - 1 === this.draggingApp) {
|
|
return;
|
|
}
|
|
|
|
const draggedApp = this.apps[this.draggingApp];
|
|
this.apps.splice(this.draggingApp, 1);
|
|
|
|
if (idx > this.draggingApp) {
|
|
idx -= 1;
|
|
}
|
|
|
|
this.apps.splice(idx, 0, draggedApp);
|
|
|
|
this.listReordered = true;
|
|
},
|
|
alphabetizeApps() {
|
|
let orderStat = 0;
|
|
this.apps.sort((a, b) => {
|
|
const result = a.name.localeCompare(b.name);
|
|
orderStat += result;
|
|
return result;
|
|
});
|
|
this.listReordered = orderStat !== this.apps.length - 1;
|
|
if (!this.listReordered) {
|
|
alert(this.$t('apps.already_ordered'));
|
|
}
|
|
},
|
|
saveOrder() {
|
|
this.actionDisabled = true;
|
|
|
|
const reorderedUUIDs = this.apps.reduce((reordered, i) => {
|
|
if (i.uuid) {
|
|
reordered.push(i.uuid)
|
|
}
|
|
return reordered
|
|
}, [])
|
|
|
|
fetch("./api/apps/reorder", {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
method: 'POST',
|
|
body: JSON.stringify({order: reorderedUUIDs})
|
|
})
|
|
.then(r => r.json())
|
|
.then((r) => {
|
|
if (!r.status) {
|
|
alert(this.$t("apps.reorder_failed") + r.error);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
return this.loadApps();
|
|
})
|
|
.finally(() => {
|
|
this.actionDisabled = false;
|
|
})
|
|
|
|
},
|
|
loadApps() {
|
|
return fetch("./api/apps", {
|
|
credentials: 'include'
|
|
})
|
|
.then(r => r.json())
|
|
.then(r => {
|
|
this.apps = r.apps.filter(i => i.uuid).map(i => ({...i, launching: false, dragover: false}));
|
|
this.currentApp = r.current_app;
|
|
this.hostName = r.host_name;
|
|
this.hostUUID = r.host_uuid;
|
|
this.listReordered = false;
|
|
});
|
|
},
|
|
newApp() {
|
|
this.editForm = Object.assign({}, newApp);
|
|
this.showEditForm = true;
|
|
},
|
|
launchApp(event, app) {
|
|
const isLocalHost = ['localhost', '127.0.0.1', '[::1]'].indexOf(location.hostname) >= 0
|
|
|
|
if (!isLocalHost && confirm(this.$t('apps.launch_local_client'))) {
|
|
const link = document.createElement('a');
|
|
link.href = event.target.href;
|
|
link.click();
|
|
return;
|
|
}
|
|
|
|
if (confirm(this.$t('apps.launch_warning'))) {
|
|
this.actionDisabled = true;
|
|
fetch("./api/apps/launch", {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
method: 'POST',
|
|
body: JSON.stringify({ uuid: app.uuid })
|
|
})
|
|
.then(r => r.json())
|
|
.then(r => {
|
|
if (!r.status) {
|
|
alert(this.$t('apps.launch_failed') + r.error);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
this.actionDisabled = false;
|
|
this.loadApps()
|
|
});
|
|
}
|
|
},
|
|
closeApp() {
|
|
if (confirm(this.$t('apps.close_warning'))) {
|
|
this.actionDisabled = true;
|
|
fetch("./api/apps/close", {
|
|
credentials: 'include',
|
|
method: 'POST',
|
|
})
|
|
.then((r) => r.json())
|
|
.then((r) => {
|
|
if (!r.status) {
|
|
alert("apps.close_failed")
|
|
}
|
|
})
|
|
.finally(() => {
|
|
this.actionDisabled = false;
|
|
this.loadApps()
|
|
});
|
|
}
|
|
},
|
|
editApp(app) {
|
|
this.editForm = Object.assign({}, newApp, JSON.parse(JSON.stringify(app)));
|
|
this.showEditForm = true;
|
|
},
|
|
exportLauncherFile(app) {
|
|
const link = document.createElement('a');
|
|
const fileContent = `# Artemis app entry
|
|
# Exported by Apollo
|
|
# https://github.com/ClassicOldSong/Apollo
|
|
|
|
[host_uuid] ${this.hostUUID}
|
|
[host_name] ${this.hostName}
|
|
[app_uuid] ${app.uuid}
|
|
[app_name] ${app.name}
|
|
`;
|
|
link.href = `data:text/plain;charset=utf-8,${encodeURIComponent(fileContent)}`;
|
|
link.download = `${app.name}.art`;
|
|
link.click();
|
|
},
|
|
showDeleteForm(app) {
|
|
const resp = confirm(
|
|
"Are you sure to delete " + app.name + "?"
|
|
);
|
|
if (resp) {
|
|
this.actionDisabled = true;
|
|
fetch("./api/apps/delete", {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
method: 'POST',
|
|
body: JSON.stringify({ uuid: app.uuid })
|
|
}).then((r) => r.json())
|
|
.then((r) => {
|
|
if (!r.status) {
|
|
alert("Delete failed! " + r.error);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
this.actionDisabled = false;
|
|
this.loadApps();
|
|
});
|
|
}
|
|
},
|
|
addCmd(cmdArr, idx) {
|
|
const template = {
|
|
do: "",
|
|
undo: ""
|
|
};
|
|
|
|
if (this.platform === 'windows') {
|
|
template.elevated = false;
|
|
}
|
|
|
|
if (idx < 0) {
|
|
cmdArr.push(template);
|
|
} else {
|
|
cmdArr.splice(idx, 0, template);
|
|
}
|
|
},
|
|
showCoverFinder($event) {
|
|
this.coverCandidates = [];
|
|
this.coverSearching = true;
|
|
const ref = this.$refs.coverFinderDropdown;
|
|
if (!ref) {
|
|
console.error("Ref not found!");
|
|
return;
|
|
}
|
|
this.coverFinderDropdown = Dropdown.getInstance(ref);
|
|
if (!this.coverFinderDropdown) {
|
|
this.coverFinderDropdown = new Dropdown(ref);
|
|
if (!this.coverFinderDropdown) {
|
|
return;
|
|
}
|
|
}
|
|
this.coverFinderDropdown.show();
|
|
function getSearchBucket(name) {
|
|
let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, '');
|
|
if (!bucket) {
|
|
return '@';
|
|
}
|
|
return bucket;
|
|
}
|
|
|
|
function searchCovers(name) {
|
|
if (!name) {
|
|
return Promise.resolve([]);
|
|
}
|
|
let searchName = name.replaceAll(/\s+/g, '.').toLowerCase();
|
|
|
|
// Use raw.githubusercontent.com to avoid CORS issues as we migrate the CNAME
|
|
let dbUrl = "https://raw.githubusercontent.com/LizardByte/GameDB/gh-pages";
|
|
let bucket = getSearchBucket(name);
|
|
return fetch(`${dbUrl}/buckets/${bucket}.json`).then(function (r) {
|
|
if (!r.ok) throw new Error("Failed to search covers");
|
|
return r.json();
|
|
}).then(maps => Promise.all(Object.keys(maps).map(id => {
|
|
let item = maps[id];
|
|
if (item.name.replaceAll(/\s+/g, '.').toLowerCase().startsWith(searchName)) {
|
|
return fetch(`${dbUrl}/games/${id}.json`).then(function (r) {
|
|
return r.json();
|
|
}).catch(() => null);
|
|
}
|
|
return null;
|
|
}).filter(item => item)))
|
|
.then(results => results
|
|
.filter(item => item && item.cover && item.cover.url)
|
|
.map(game => {
|
|
const thumb = game.cover.url;
|
|
const dotIndex = thumb.lastIndexOf('.');
|
|
const slashIndex = thumb.lastIndexOf('/');
|
|
if (dotIndex < 0 || slashIndex < 0) {
|
|
return null;
|
|
}
|
|
const slug = thumb.substring(slashIndex + 1, dotIndex);
|
|
return {
|
|
name: game.name,
|
|
key: `igdb_${game.id}`,
|
|
url: `https://images.igdb.com/igdb/image/upload/t_cover_big/${slug}.jpg`,
|
|
saveUrl: `https://images.igdb.com/igdb/image/upload/t_cover_big_2x/${slug}.png`,
|
|
}
|
|
}).filter(item => item));
|
|
}
|
|
|
|
searchCovers(this.editForm["name"].toString().trim())
|
|
.then(list => this.coverCandidates = list)
|
|
.finally(() => this.coverSearching = false);
|
|
},
|
|
closeCoverFinder() {
|
|
const ref = this.$refs.coverFinderDropdown;
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
const dropdown = this.coverFinderDropdown = Dropdown.getInstance(ref);
|
|
if (!dropdown) {
|
|
return;
|
|
}
|
|
dropdown.hide();
|
|
},
|
|
useCover(cover) {
|
|
this.coverFinderBusy = true;
|
|
fetch("./api/covers/upload", {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
key: cover.key,
|
|
url: cover.saveUrl,
|
|
})
|
|
}).then(r => {
|
|
if (!r.ok) throw new Error("Failed to download covers");
|
|
return r.json();
|
|
}).then(body => this.editForm["image-path"] = body.path)
|
|
.then(() => this.closeCoverFinder())
|
|
.finally(() => this.coverFinderBusy = false);
|
|
},
|
|
save() {
|
|
this.editForm.name = this.editForm.name.trim();
|
|
if (!this.editForm.name) {
|
|
return;
|
|
}
|
|
this.editForm["exit-timeout"] = parseInt(this.editForm["exit-timeout"]) || 5
|
|
this.editForm["scale-factor"] = parseInt(this.editForm["scale-factor"]) || 100
|
|
this.editForm["image-path"] = this.editForm["image-path"].toString().trim().replace(/"/g, '');
|
|
delete this.editForm.id;
|
|
delete this.editForm.launching;
|
|
delete this.editForm.dragover;
|
|
fetch("./api/apps", {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
method: 'POST',
|
|
body: JSON.stringify(this.editForm),
|
|
}).then((r) => r.json())
|
|
.then((r) => {
|
|
if (!r.status) {
|
|
alert(this.$t('apps.save_failed') + r.error);
|
|
throw new Error(`App save failed: ${r.error}`);
|
|
}
|
|
})
|
|
.then(() => {
|
|
this.showEditForm = false;
|
|
this.loadApps();
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
app.directive('dropdown-show', {
|
|
mounted: function (el, binding) {
|
|
el.addEventListener('show.bs.dropdown', binding.value);
|
|
}
|
|
});
|
|
|
|
initApp(app);
|
|
</script>
|