mirror of
https://github.com/LizardByte/Sunshine.git
synced 2025-01-29 09:32:39 +00:00
603 lines
22 KiB
HTML
603 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<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;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body id="app">
|
|
<Navbar></Navbar>
|
|
<div class="container">
|
|
<div class="my-4">
|
|
<h1>Applications</h1>
|
|
<div>Applications are refreshed only when Client is restarted</div>
|
|
</div>
|
|
<div class="card p-4">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Name</th>
|
|
<th scope="col">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(app,i) in apps" :key="i">
|
|
<td>{{app.name}}</td>
|
|
<td>
|
|
<button class="btn btn-primary mx-1" @click="editApp(i)">
|
|
<i class="fas fa-edit"></i> Edit
|
|
</button>
|
|
<button class="btn btn-danger mx-1" @click="showDeleteForm(i)">
|
|
<i class="fas fa-trash"></i> Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="edit-form card mt-2" v-if="showEditForm">
|
|
<div class="p-4">
|
|
<!-- Application Name -->
|
|
<div class="mb-3">
|
|
<label for="appName" class="form-label">Application Name</label>
|
|
<input type="text" class="form-control" id="appName" aria-describedby="appNameHelp" v-model="editForm.name" />
|
|
<div id="appNameHelp" class="form-text">
|
|
Application Name, as shown on Moonlight
|
|
</div>
|
|
</div>
|
|
<!-- output -->
|
|
<div class="mb-3">
|
|
<label for="appOutput" class="form-label">Output</label>
|
|
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
|
|
v-model="editForm.output" />
|
|
<div id="appOutputHelp" class="form-text">
|
|
The file where the output of the command is stored, if it is not
|
|
specified, the output is ignored
|
|
</div>
|
|
</div>
|
|
<!-- prep-cmd -->
|
|
<div class="mb-3">
|
|
<label for="excludeGlobalPrep" class="form-label">Global Prep Commands</label>
|
|
<select id="excludeGlobalPrep" class="form-select" v-model="editForm['exclude-global-prep-cmd']">
|
|
<option v-for="val in [false, true]" :value="val">
|
|
{{ !val ? 'Enabled' : 'Disabled' }}
|
|
</option>
|
|
</select>
|
|
<div class="form-text">
|
|
Enable/Disable the execution of Global Prep Commands for this
|
|
application.
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="appName" class="form-label">Command Preparations</label>
|
|
<div class="form-text">
|
|
A list of commands to be run before/after this application.<br>
|
|
If any of the prep-commands fail, starting the application is aborted.
|
|
</div>
|
|
<div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0">
|
|
<button class="btn btn-success" @click="addPrepCmd">
|
|
<i class="fas fa-plus mr-1"></i> Add Commands
|
|
</button>
|
|
</div>
|
|
<table class="table" v-if="editForm['prep-cmd'].length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col"><i class="fas fa-play"></i> Do Command</th>
|
|
<th scope="col"><i class="fas fa-undo"></i> Undo Command</th>
|
|
<th scope="col" v-if="platform === 'windows'">
|
|
<i class="fas fa-shield-alt"></i> Run as Admin
|
|
</th>
|
|
<th scope="col"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(c, i) in editForm['prep-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'">
|
|
<div class="form-check">
|
|
<input type="checkbox" class="form-check-input" :id="'prep-cmd-admin-' + i" v-model="c.elevated"
|
|
true-value="true" false-value="false" />
|
|
<label :for="'prep-cmd-admin-' + i" class="form-check-label">Elevated</label>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-danger" @click="editForm['prep-cmd'].splice(i,1)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
<button class="btn btn-success" @click="addPrepCmd">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- detached -->
|
|
<div class="mb-3">
|
|
<label for="appName" class="form-label">Detached Commands</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)">
|
|
×
|
|
</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> Add Detached Command
|
|
</button>
|
|
</div>
|
|
<div class="form-text">
|
|
A list of commands to be run and forgotten about
|
|
</div>
|
|
</div>
|
|
<!-- command -->
|
|
<div class="mb-3">
|
|
<label for="appCmd" class="form-label">Command</label>
|
|
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
|
|
v-model="editForm.cmd" />
|
|
<div id="appCmdHelp" class="form-text">
|
|
The main application, if it is not specified, a process is started
|
|
that sleeps indefinitely
|
|
</div>
|
|
</div>
|
|
<!-- working dir -->
|
|
<div class="mb-3">
|
|
<label for="appWorkingDir" class="form-label">Working Directory</label>
|
|
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
|
|
v-model="editForm['working-dir']" />
|
|
<div id="appWorkingDirHelp" class="form-text">
|
|
The working directory that should be passed to the process. For
|
|
example, some applications use the working directory to search for
|
|
configuration files. If not set, Sunshine will default to the parent
|
|
directory of the command
|
|
</div>
|
|
</div>
|
|
<!-- elevation -->
|
|
<div class="mb-3 form-check" v-if="platform === 'windows'">
|
|
<label for="appElevation" class="form-check-label">Run as administrator</label>
|
|
<input type="checkbox" class="form-check-input" id="appElevation" v-model="editForm.elevated"
|
|
true-value="true" false-value="false" />
|
|
<div class="form-text">
|
|
This can be necessary for some applications that require administrator
|
|
permissions to run properly.
|
|
</div>
|
|
</div>
|
|
<!-- auto-detach -->
|
|
<div class="mb-3 form-check">
|
|
<label for="autoDetach" class="form-check-label">Continue streaming if the application exits quickly</label>
|
|
<input type="checkbox" class="form-check-input" id="autoDetach" v-model="editForm['auto-detach']"
|
|
true-value="true" false-value="false" />
|
|
<div class="form-text">
|
|
This will attempt to automatically detect launcher-type apps that close
|
|
quickly after launching another program or instance of themselves. When
|
|
a launcher-type app is detected, it is treated as a detached app.
|
|
</div>
|
|
</div>
|
|
<!-- wait for all processes -->
|
|
<div class="mb-3 form-check">
|
|
<label for="waitAll" class="form-check-label">Continue streaming until all app processes exit</label>
|
|
<input type="checkbox" class="form-check-input" id="waitAll" v-model="editForm['wait-all']"
|
|
true-value="true" false-value="false" />
|
|
<div class="form-text">
|
|
This will continue streaming until all processes started by the app have terminated.
|
|
When unchecked, streaming will stop when the initial app process exits, even if other
|
|
app processes are still running.
|
|
</div>
|
|
</div>
|
|
<!-- exit timeout -->
|
|
<div class="mb-3">
|
|
<label for="exitTimeout" class="form-label">Exit Timeout</label>
|
|
<input type="text" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
|
|
v-model="editForm['exit-timeout']" />
|
|
<div id="exitTimeoutHelp" class="form-text">
|
|
Number of seconds to wait for all app processes to gracefully exit when requested to quit.<br>
|
|
If unset, the default is to wait up to 5 seconds. If set to zero or a negative value,
|
|
the app will be immediately terminated.
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="appImagePath" class="form-label">Image</label>
|
|
<div class="input-group dropup">
|
|
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
|
|
v-model="editForm['image-path']" />
|
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle"
|
|
aria-expanded="false" @click="showCoverFinder" ref="coverFinderDropdown">
|
|
Find Cover
|
|
</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">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">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="appImagePathHelp" class="form-text">
|
|
Application icon/picture/image path that will be sent to client. Image
|
|
must be a PNG file. If not set, Sunshine will send default box image.
|
|
</div>
|
|
</div>
|
|
<div class="env-hint alert alert-info">
|
|
<div class="form-text">
|
|
<h4>About Environment Variables</h4>
|
|
All commands get these environment variables by default:
|
|
</div>
|
|
<table class="env-table">
|
|
<tr>
|
|
<td><b>Var Name</b></td>
|
|
<td><b></b></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_APP_ID</td>
|
|
<td>App ID</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_APP_NAME</td>
|
|
<td>App Name</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_WIDTH</td>
|
|
<td>The Width requested by the client</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_HEIGHT</td>
|
|
<td>The Height requested by the client</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_FPS</td>
|
|
<td>The FPS requested by the client</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_HDR</td>
|
|
<td>(true/false) if HDR is enabled by the client</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_GCMAP</td>
|
|
<td>(int) the requested gamepad mask, in a bitset/bitfield format</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_HOST_AUDIO</td>
|
|
<td>(true/false) if the client has requested host audio</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_ENABLE_SOPS</td>
|
|
<td>(true/false) if the client has requested the option to optimize the game for optimal
|
|
streaming</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-family: monospace">SUNSHINE_CLIENT_AUDIO_CONFIGURATION</td>
|
|
<td>The Audio Configuration requested by the client (2.0/5.1/7.1)</td>
|
|
</tr>
|
|
</table>
|
|
<div class="form-text" v-if="platform === 'windows'"><b>Example - QRes for Resolution
|
|
Automation:</b>
|
|
<pre>cmd /C <qres path>\QRes.exe /X:%SUNSHINE_CLIENT_WIDTH% /Y:%SUNSHINE_CLIENT_HEIGHT%</pre>
|
|
</div>
|
|
<div class="form-text" v-else-if="platform === 'linux'"><b>Example - Xrandr for Resolution
|
|
Automation:</b>
|
|
<pre>sh -c "xrandr --output HDMI-1 --mode \"${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}\" --rate 60"</pre>
|
|
</div>
|
|
<div class="form-text" v-else-if="platform === 'macos'"><b>Example - displayplacer for
|
|
Resolution
|
|
Automation:</b>
|
|
<pre>sh -c "displayplacer "id:<screenId> res:${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT} hz:60 scaling:on origin:(0,0) degree:0""</pre>
|
|
</div>
|
|
<div class="form-text"><a
|
|
href="https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/guides/app_examples.html"
|
|
target="_blank">See More</a></div>
|
|
</div>
|
|
<!-- Save buttons -->
|
|
<div class="d-flex">
|
|
<button @click="showEditForm = false" class="btn btn-secondary m-2">
|
|
Cancel
|
|
</button>
|
|
<button class="btn btn-primary m-2" @click="save">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-2" v-else>
|
|
<button class="btn btn-primary" @click="newApp">
|
|
<i class="fas fa-plus"></i> Add New
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
<script type="module">
|
|
import { createApp } from 'vue';
|
|
import Navbar from './Navbar.vue'
|
|
import {Dropdown} from 'bootstrap'
|
|
const app = createApp({
|
|
components: {
|
|
Navbar
|
|
},
|
|
data() {
|
|
return {
|
|
apps: [],
|
|
showEditForm: false,
|
|
editForm: null,
|
|
detachedCmd: "",
|
|
coverSearching: false,
|
|
coverFinderBusy: false,
|
|
coverCandidates: [],
|
|
platform: "",
|
|
};
|
|
},
|
|
created() {
|
|
fetch("/api/apps")
|
|
.then((r) => r.json())
|
|
.then((r) => {
|
|
console.log(r);
|
|
this.apps = r.apps;
|
|
});
|
|
|
|
fetch("/api/config")
|
|
.then(r => r.json())
|
|
.then(r => this.platform = r.platform);
|
|
},
|
|
methods: {
|
|
newApp() {
|
|
this.editForm = {
|
|
name: "",
|
|
output: "",
|
|
cmd: [],
|
|
index: -1,
|
|
"exclude-global-prep-cmd": false,
|
|
elevated: false,
|
|
"auto-detach": true,
|
|
"wait-all": true,
|
|
"exit-timeout": 5,
|
|
"prep-cmd": [],
|
|
detached: [],
|
|
"image-path": ""
|
|
};
|
|
this.editForm.index = -1;
|
|
this.showEditForm = true;
|
|
},
|
|
editApp(id) {
|
|
this.editForm = JSON.parse(JSON.stringify(this.apps[id]));
|
|
this.editForm.index = id;
|
|
if (this.editForm["prep-cmd"] === undefined)
|
|
this.editForm["prep-cmd"] = [];
|
|
if (this.editForm["detached"] === undefined)
|
|
this.editForm["detached"] = [];
|
|
if (this.editForm["exclude-global-prep-cmd"] === undefined)
|
|
this.editForm["exclude-global-prep-cmd"] = [];
|
|
if (this.editForm["elevated"] === undefined && this.platform === 'windows') {
|
|
this.editForm["elevated"] = [];
|
|
}
|
|
if (this.editForm["auto-detach"] === undefined) {
|
|
this.editForm["auto-detach"] = true;
|
|
}
|
|
if (this.editForm["wait-all"] === undefined) {
|
|
this.editForm["wait-all"] = true;
|
|
}
|
|
if (this.editForm["exit-timeout"] === undefined) {
|
|
this.editForm["exit-timeout"] = 5;
|
|
}
|
|
this.showEditForm = true;
|
|
},
|
|
showDeleteForm(id) {
|
|
let resp = confirm(
|
|
"Are you sure to delete " + this.apps[id].name + "?"
|
|
);
|
|
if (resp) {
|
|
fetch("/api/apps/" + id, { method: "DELETE" }).then((r) => {
|
|
if (r.status == 200) document.location.reload();
|
|
});
|
|
}
|
|
},
|
|
addPrepCmd() {
|
|
let template = {
|
|
do: "",
|
|
undo: ""
|
|
};
|
|
|
|
if (this.platform === 'windows') {
|
|
template = { ...template, elevated: false };
|
|
}
|
|
|
|
this.editForm["prep-cmd"].push(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();
|
|
let bucket = getSearchBucket(name);
|
|
return fetch("https://db.lizardbyte.dev/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("https://db.lizardbyte.dev/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 hash = thumb.substring(slashIndex + 1, dotIndex);
|
|
return {
|
|
name: game.name,
|
|
key: "igdb_" + game.id,
|
|
url: "https://images.igdb.com/igdb/image/upload/t_cover_big/" + hash + ".jpg",
|
|
saveUrl: "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + hash + ".png",
|
|
}
|
|
}).filter(item => item));
|
|
}
|
|
|
|
searchCovers(this.editForm["name"].toString())
|
|
.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", {
|
|
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["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
|
|
fetch("/api/apps", {
|
|
method: "POST",
|
|
body: JSON.stringify(this.editForm),
|
|
}).then((r) => {
|
|
if (r.status == 200) document.location.reload();
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
app.directive('dropdown-show', {
|
|
mounted: function (el, binding) {
|
|
el.addEventListener('show.bs.dropdown', binding.value);
|
|
}
|
|
});
|
|
|
|
app.mount("#app")
|
|
|
|
</script>
|