refactor(web): sonar fixes (#5740)

This commit is contained in:
Dave Lane authored and GitHub committed 2026-09-17 11:23:23 -04:00
1 parent 7c8926d701
commit e9af4a1aa4
34 files changed
+568 -239

No files matched your search

+13 -5
View File
@@ -6,7 +6,7 @@ function generateExamples(endpoint, method, body = null) {
let psBodyParams = '';
if (body) {
const curlJsonString = JSON.stringify(body).replace(/"/g, '\\"');
const curlJsonString = JSON.stringify(body).replaceAll('"', String.raw`\"`);
curlBodyString = ` -d "${curlJsonString}"`;
curlHeaderString = ' -H "Content-Type: application/json"';
psBodyString = `-Body (ConvertTo-Json ${JSON.stringify(body)})`;
@@ -43,12 +43,20 @@ requests.${method.trim().toLowerCase()}(
};
}
/**
* @brief Create a stable signed 32-bit hash for a string.
*
* @param {string} str String to hash.
* @return {number} Signed 32-bit hash value.
*/
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32bit integer
const signedHash = new Int32Array(1);
for (const character of str) {
const codePoint = character.codePointAt(0);
hash = hash * 31 + codePoint;
signedHash[0] = hash;
hash = signedHash[0];
}
return hash;
}
+2 -2
View File
@@ -9,10 +9,10 @@ document.addEventListener("DOMContentLoaded", function() {
}
let previousElement = table.previousElementSibling;
while (previousElement && previousElement.tagName !== "H2") {
while (previousElement?.tagName && previousElement.tagName !== "H2") {
previousElement = previousElement.previousElementSibling;
}
if (previousElement && previousElement.textContent) {
if (previousElement?.textContent) {
const sectionId = previousElement.textContent.trim().toLowerCase();
const newRow = document.createElement("tr");
+66 -68
View File
@@ -82,6 +82,20 @@ cover-img:
</div>
</div>
</div>
{% capture github_release_package_icons %}
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/windows.svg" alt="Windows"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/macos.svg" alt="macOS"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/freebsd.svg" alt="FreeBSD"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/appimage.svg" alt="AppImage"/> <!-- //NOSONAR(Web:S6851) -->
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/alpinelinux.svg" alt="Alpine Linux"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/archlinux.svg" alt="Arch Linux"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/debian.svg" alt="Debian"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/fedora.svg" alt="Fedora"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/opensuse.svg" alt="openSUSE"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/ubuntu.svg" alt="Ubuntu"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/flatpak.svg" alt="Flatpak"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/homebrew.svg" alt="Homebrew"/>
{% endcapture %}
<div class="card-footer p-3 px-4">
<a class="btn btn-outline-theme me-3 mb-3" href="https://docs.lizardbyte.dev/projects/sunshine" target="_blank">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/readthedocs.svg" alt="ReadTheDocs"/>
@@ -107,22 +121,45 @@ cover-img:
</div>
<div class="card-footer p-3 px-4">
<a class="latest-button btn btn-outline-theme me-3 mb-3 d-none" href="https://github.com/LizardByte/Sunshine/releases/latest" target="_blank">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/github.svg" alt="GitHub"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/windows.svg" alt="Windows"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/debian.svg" alt="Debian"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/ubuntu.svg" alt="Ubuntu"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/flatpak.svg" alt="Flatpak"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/linux.svg" alt="AppImage"/>
<span class="d-flex align-items-center justify-content-center gap-1">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/github.svg" alt="GitHub"/>
Latest: <span id="latest-version" class="crowdin-ignore"></span>
</span>
<span class="d-flex align-items-center justify-content-center gap-1 mt-1">
{{ github_release_package_icons }}
</span>
</a>
<a class="beta-button btn btn-outline-theme me-3 mb-3 d-none" href="#" target="_blank">
<i class="fa-fw fa-lg fas fa-flask"></i>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/windows.svg" alt="Windows"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/debian.svg" alt="Debian"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/ubuntu.svg" alt="Ubuntu"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/flatpak.svg" alt="Flatpak"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/linux.svg" alt="AppImage"/>
<span class="d-flex align-items-center justify-content-center gap-1">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/github.svg" alt="GitHub"/>
<i class="fa-fw fa-lg fas fa-flask"></i>
Beta: <span id="beta-version" class="crowdin-ignore"></span>
</span>
<span class="d-flex align-items-center justify-content-center gap-1 mt-1">
{{ github_release_package_icons }}
</span>
</a>
<a class="btn btn-outline-theme me-3 mb-3" href="https://cloudsmith.io/~lizardbyte/repos/" target="_blank">
<span class="d-flex align-items-center justify-content-center gap-1">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/cloudsmith.svg" alt="Cloudsmith"/>
Cloudsmith
</span>
<span class="d-flex align-items-center justify-content-center gap-1 mt-1">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/alpinelinux.svg" alt="Alpine Linux"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/debian.svg" alt="Debian"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/fedora.svg" alt="Fedora"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/opensuse.svg" alt="openSUSE"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/ubuntu.svg" alt="Ubuntu"/>
</span>
</a>
<a class="btn btn-outline-theme me-3 mb-3" href="https://copr.fedorainfracloud.org/coprs/lizardbyte/" target="_blank">
<span class="d-flex align-items-center justify-content-center">
Copr
</span>
<span class="d-flex align-items-center justify-content-center gap-1 mt-1">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/fedora.svg" alt="Fedora"/>
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/opensuse.svg" alt="openSUSE"/>
</span>
</a>
<a class="btn btn-outline-theme me-3 mb-3" href="https://github.com/LizardByte/pacman-repo" target="_blank">
<img class="icon-sm invert" src="https://cdn.jsdelivr.net/npm/simple-icons@16/icons/archlinux.svg" alt="Arch Linux"/>
@@ -146,63 +183,24 @@ cover-img:
</div>
</div>
<!-- TODO: Move this to website repo, and make it accept arguments for the repo name -->
<script>
// Fetch the releases from the GitHub API
fetch('https://api.github.com/repos/LizardByte/Sunshine/releases')
.then(response => response.json())
.then(data => {
// Filter the releases to get only the pre-releases
const preReleases = data.filter(release => release.prerelease);
// Filter the releases to get only the stable releases
const stableReleases = data.filter(release => !release.prerelease);
const latestButton = document.querySelector('.latest-button');
const latestVersion = document.querySelector('#latest-version');
const betaButton = document.querySelector('.beta-button');
const betaVersion = document.querySelector('#beta-version');
// If there are no stable releases, hide the latest download button
if (stableReleases.length === 0) {
latestButton.classList.add('d-none');
} else {
// Show the latest download button
latestButton.classList.remove('d-none');
// Get the latest stable release
const latestStableRelease = stableReleases[0];
latestVersion.textContent = latestStableRelease.tag_name;
// If there is a pre-release, update the href attribute of the anchor tag
if (preReleases.length > 0) {
const latestPreRelease = preReleases[0];
// Compare the date of the latest pre-release with the date of the latest stable release
const preReleaseDate = new Date(latestPreRelease.published_at);
const stableReleaseDate = new Date(latestStableRelease.published_at);
// If the pre-release is newer, update the href attribute of the anchor tag
if (preReleaseDate > stableReleaseDate) {
betaButton.href = latestPreRelease.html_url;
betaVersion.textContent = latestPreRelease.tag_name;
betaButton.classList.remove('d-none');
} else {
// If the pre-release is older, hide the button
betaButton.classList.add('d-none');
}
} else {
// If there is no pre-release, hide the button
betaButton.classList.add('d-none');
}
}
<script src="{{ '/Sunshine/assets/js/release-versions.js' | relative_url }}"></script>
<script type="module">
try {
await globalThis.updateGitHubReleaseVersions({
repository: 'LizardByte/Sunshine',
stableButtonSelector: '.latest-button',
stableVersionSelector: '#latest-version',
prereleaseButtonSelector: '.beta-button',
prereleaseVersionSelector: '#beta-version',
});
} catch (error) {
console.error('Failed to update release versions', error);
}
// Fetch clients from app directory
fetch('https://app.lizardbyte.dev/app-directory/sunshine.json')
.then(response => response.json())
.then(data => {
const clientsContainer = document.getElementById('clients-container');
if (!clientsContainer) return;
const clientsContainer = document.getElementById('clients-container');
if (clientsContainer) {
const clientsResponse = await fetch('https://app.lizardbyte.dev/app-directory/sunshine.json');
const data = await clientsResponse.json();
const apps = data.apps || [];
@@ -302,5 +300,5 @@ cover-img:
`;
clientsContainer.innerHTML += clientHtml;
});
});
}
</script>
+4 -1
View File
@@ -1,5 +1,8 @@
<script setup>
const model = defineModel({ required: true });
const model = defineModel({
type: [Boolean, Number, String],
required: true,
});
const slots = defineSlots();
const props = defineProps({
class: {
@@ -24,8 +24,3 @@ const props = defineProps({
<slot name="macos"></slot>
</template>
</template>
<style scoped>
</style>
+1 -2
View File
@@ -14,8 +14,7 @@ import { GitHubIcon, DiscordIcon } from 'vue3-simple-icons'
const props = defineProps({
icon: {
type: String,
required: true,
default: 'GitHub'
required: true
},
size: {
type: [Number, String],
@@ -152,6 +152,3 @@ onMounted(() => {
</ul>
</div>
</template>
<style scoped>
</style>
@@ -2,11 +2,11 @@
import { ref } from 'vue'
import PlatformLayout from '../../PlatformLayout.vue'
const props = defineProps([
'platform',
'config',
'global_prep_cmd'
])
const props = defineProps({
platform: String,
config: Object,
global_prep_cmd: Array,
})
const config = ref(props.config)
</script>
@@ -117,7 +117,3 @@ const config = ref(props.config)
</div>
</template>
<style scoped>
</style>
@@ -8,10 +8,10 @@ import DisplayDeviceOptions from "./audiovideo/DisplayDeviceOptions.vue";
import DisplayModesSettings from "./audiovideo/DisplayModesSettings.vue";
import Checkbox from "../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -98,6 +98,3 @@ const config = ref(props.config)
</div>
</template>
<style scoped>
</style>
@@ -8,11 +8,11 @@ import SoftwareEncoder from './encoders/SoftwareEncoder.vue'
import VAAPIEncoder from './encoders/VAAPIEncoder.vue'
import VulkanEncoder from './encoders/VulkanEncoder.vue'
const props = defineProps([
'platform',
'config',
'currentTab'
])
const props = defineProps({
platform: String,
config: Object,
currentTab: String,
})
const config = ref(props.config)
</script>
@@ -69,6 +69,3 @@ const config = ref(props.config)
/>
</template>
<style scoped>
</style>
@@ -1,10 +1,10 @@
<script setup>
import { ref } from 'vue'
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -56,7 +56,3 @@ const config = ref(props.config)
</div>
</template>
<style scoped>
</style>
@@ -89,7 +89,7 @@ function removeCmd(index) {
<!-- Global Prep Commands -->
<div id="global_prep_cmd" class="mb-3 d-flex flex-column">
<label class="form-label">{{ $t('config.global_prep_cmd') }}</label>
<div class="form-label">{{ $t('config.global_prep_cmd') }}</div>
<div class="form-text">{{ $t('config.global_prep_cmd_desc') }}</div>
<table class="table" v-if="config.global_prep_cmd.length > 0">
<thead>
@@ -103,12 +103,14 @@ function removeCmd(index) {
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in config.global_prep_cmd">
<tr v-for="(c, i) in config.global_prep_cmd" :key="i">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
<label :for="`prep-cmd-do-${i}`" class="visually-hidden">{{ $t('_common.do_cmd') }}</label>
<input :id="`prep-cmd-do-${i}`" type="text" class="form-control monospace" v-model="c.do" />
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.undo" />
<label :for="`prep-cmd-undo-${i}`" class="visually-hidden">{{ $t('_common.undo_cmd') }}</label>
<input :id="`prep-cmd-undo-${i}`" type="text" class="form-control monospace" v-model="c.undo" />
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i"
@@ -150,7 +152,3 @@ function removeCmd(index) {
></Checkbox>
</div>
</template>
<style scoped>
</style>
@@ -65,10 +65,10 @@ function serializeKeybindings(pairs) {
return `[${values.join(',')}]`
}
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
const keybindingPairs = ref(parseKeybindings(config.value.keybindings))
@@ -6,15 +6,15 @@ import {
} from '@lucide/vue'
import Checkbox from "../../Checkbox.vue";
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const defaultMoonlightPort = 47989
const config = ref(props.config)
const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort)
const effectivePort = computed(() => Number(config.value?.port ?? defaultMoonlightPort))
</script>
<template>
@@ -102,12 +102,6 @@ const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort
<td>{{+effectivePort + 9}} - {{+effectivePort + 11}}</td>
<td></td>
</tr>
<!-- <tr>-->
<!-- &lt;!&ndash; Mic &ndash;&gt;-->
<!-- <td>UDP</td>-->
<!-- <td>{{+effectivePort + 13}}</td>-->
<!-- <td></td>-->
<!-- </tr>-->
</tbody>
</table>
<!-- add warning about exposing web ui to the internet -->
@@ -182,7 +176,3 @@ const effectivePort = computed(() => +config.value?.port ?? defaultMoonlightPort
</div>
</template>
<style scoped>
</style>
@@ -3,10 +3,10 @@ import { ref } from 'vue'
import { $tp } from '../../../platform-i18n'
import PlatformLayout from '../../../PlatformLayout.vue'
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -94,9 +94,9 @@ function addRemappingEntry() {
<!-- Manual resolution -->
<div class="mt-2 ps-4" v-if="config.dd_resolution_option === 'manual'">
<div class="form-text">
<label for="dd_manual_resolution" class="form-label">
{{ $t('config.dd_manual_resolution') }}
</div>
</label>
<input type="text" class="form-control" id="dd_manual_resolution" placeholder="2560x1440"
v-model="config.dd_manual_resolution" />
</div>
@@ -115,9 +115,9 @@ function addRemappingEntry() {
<!-- Manual refresh rate -->
<div class="mt-2 ps-4" v-if="config.dd_refresh_rate_option === 'manual'">
<div class="form-text">
<label for="dd_manual_refresh_rate" class="form-label">
{{ $t('config.dd_manual_refresh_rate') }}
</div>
</label>
<input type="text" class="form-control" id="dd_manual_refresh_rate" placeholder="59.9558"
v-model="config.dd_manual_refresh_rate" />
</div>
@@ -208,21 +208,33 @@ function addRemappingEntry() {
</tr>
</thead>
<tbody>
<tr v-for="(value, idx) in config.dd_mode_remapping[getRemappingType()]">
<tr v-for="(value, idx) in config.dd_mode_remapping[getRemappingType()]" :key="idx">
<td v-if="getRemappingType() !== REFRESH_RATE_ONLY">
<input type="text" class="form-control monospace" v-model="value.requested_resolution"
<label :for="`dd-requested-resolution-${idx}`" class="visually-hidden">
{{ $t('config.dd_mode_remapping_requested_resolution') }}
</label>
<input :id="`dd-requested-resolution-${idx}`" type="text" class="form-control monospace" v-model="value.requested_resolution"
:placeholder="'1920x1080'" />
</td>
<td v-if="getRemappingType() !== RESOLUTION_ONLY">
<input type="text" class="form-control monospace" v-model="value.requested_fps"
<label :for="`dd-requested-fps-${idx}`" class="visually-hidden">
{{ $t('config.dd_mode_remapping_requested_fps') }}
</label>
<input :id="`dd-requested-fps-${idx}`" type="text" class="form-control monospace" v-model="value.requested_fps"
:placeholder="'60'" />
</td>
<td v-if="getRemappingType() !== REFRESH_RATE_ONLY">
<input type="text" class="form-control monospace" v-model="value.final_resolution"
<label :for="`dd-final-resolution-${idx}`" class="visually-hidden">
{{ $t('config.dd_mode_remapping_final_resolution') }}
</label>
<input :id="`dd-final-resolution-${idx}`" type="text" class="form-control monospace" v-model="value.final_resolution"
:placeholder="'2560x1440'" />
</td>
<td v-if="getRemappingType() !== RESOLUTION_ONLY">
<input type="text" class="form-control monospace" v-model="value.final_refresh_rate"
<label :for="`dd-final-refresh-rate-${idx}`" class="visually-hidden">
{{ $t('config.dd_mode_remapping_final_refresh_rate') }}
</label>
<input :id="`dd-final-refresh-rate-${idx}`" type="text" class="form-control monospace" v-model="value.final_refresh_rate"
:placeholder="'119.95'" />
</td>
<td>
@@ -1,12 +1,9 @@
<script setup>
import { ref } from 'vue'
import { $tp } from '../../../platform-i18n'
import PlatformLayout from '../../../PlatformLayout.vue'
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -3,10 +3,10 @@ import { ref } from 'vue'
import { $tp } from '../../../platform-i18n'
import PlatformLayout from '../../../PlatformLayout.vue'
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
let _outputNamePlaceholder = '0';
@@ -2,10 +2,10 @@
import { ref } from 'vue'
import Checkbox from "../../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -124,7 +124,3 @@ const config = ref(props.config)
</div>
</div>
</template>
<style scoped>
</style>
@@ -2,10 +2,10 @@
import { ref } from 'vue'
import Checkbox from "../../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -45,7 +45,3 @@ const config = ref(props.config)
></Checkbox>
</div>
</template>
<style scoped>
</style>
@@ -2,10 +2,10 @@
import { ref } from 'vue'
import Checkbox from "../../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -125,7 +125,3 @@ const config = ref(props.config)
</div>
</div>
</template>
<style scoped>
</style>
@@ -1,10 +1,10 @@
<script setup>
import { ref } from 'vue'
const props = defineProps([
'platform',
'config'
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -41,7 +41,3 @@ const config = ref(props.config)
</div>
</div>
</template>
<style scoped>
</style>
@@ -2,10 +2,10 @@
import { ref } from 'vue'
import Checkbox from "../../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -88,7 +88,3 @@ const config = ref(props.config)
</div>
</div>
</template>
<style scoped>
</style>
@@ -2,10 +2,10 @@
import { ref } from 'vue'
import Checkbox from "../../../Checkbox.vue";
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
@@ -39,7 +39,3 @@ const config = ref(props.config)
></Checkbox>
</div>
</template>
<style scoped>
</style>
@@ -1,10 +1,10 @@
<script setup>
import { ref } from 'vue'
const props = defineProps([
'platform',
'config',
])
const props = defineProps({
platform: String,
config: Object,
})
const config = ref(props.config)
</script>
+72 -9
View File
@@ -3,21 +3,84 @@ import {createI18n} from "vue-i18n";
// Import only the fallback language files
import en from './public/assets/locale/en.json'
export default async function() {
let r = await (await fetch("./api/configLocale")).json();
let locale = r.locale ?? "en";
document.querySelector('html').setAttribute('lang', locale);
let messages = {
en
};
const localePaths = new Map([
['bg', './assets/locale/bg.json'],
['cs', './assets/locale/cs.json'],
['de', './assets/locale/de.json'],
['en_GB', './assets/locale/en_GB.json'],
['en_US', './assets/locale/en_US.json'],
['es', './assets/locale/es.json'],
['fr', './assets/locale/fr.json'],
['hu', './assets/locale/hu.json'],
['it', './assets/locale/it.json'],
['ja', './assets/locale/ja.json'],
['ko', './assets/locale/ko.json'],
['pl', './assets/locale/pl.json'],
['pt', './assets/locale/pt.json'],
['pt_BR', './assets/locale/pt_BR.json'],
['ru', './assets/locale/ru.json'],
['sv', './assets/locale/sv.json'],
['tr', './assets/locale/tr.json'],
['uk', './assets/locale/uk.json'],
['vi', './assets/locale/vi.json'],
['zh', './assets/locale/zh.json'],
['zh_TW', './assets/locale/zh_TW.json'],
]);
/**
* @brief Resolve a configured locale to a bundled translation file.
*
* @param {unknown} configuredLocale Locale returned by the configuration API.
* @return {string} Supported locale identifier or the English fallback.
*/
function resolveLocale(configuredLocale) {
if (typeof configuredLocale !== 'string') {
return 'en';
}
return configuredLocale === 'en' || localePaths.has(configuredLocale) ? configuredLocale : 'en';
}
/**
* @brief Load messages for a supported locale with an English fallback.
*
* @param {string} locale Supported locale identifier.
* @param {Function} loadTranslation Function that loads a translation path.
* @return {Promise<{locale: string, messages: object}>} Loaded locale and messages.
*/
export async function loadLocaleMessages(
locale,
loadTranslation = async path => (await fetch(path)).json(),
) {
let messages = { en };
try {
if (locale !== 'en') {
let r = await (await fetch(`./assets/locale/${locale}.json`)).json();
messages[locale] = r;
const translation = await loadTranslation(localePaths.get(locale));
messages = Object.fromEntries([
['en', en],
[locale, translation],
]);
}
} catch (e) {
console.error("Failed to download translations", e);
locale = 'en';
}
return { locale, messages };
}
/**
* @brief Create the Vue internationalization instance for the configured locale.
*
* @return {Promise<import('vue-i18n').I18n>} Configured internationalization instance.
*/
export default async function createSunshineI18n() {
const localeConfig = await (await fetch("./api/configLocale")).json();
const configuredLocale = resolveLocale(localeConfig.locale);
const { locale, messages } = await loadLocaleMessages(configuredLocale);
document.documentElement.setAttribute('lang', locale);
const i18n = createI18n({
locale: locale, // set locale
fallbackLocale: 'en', // set fallback locale
@@ -31,12 +31,12 @@ class PlatformMessageI18n {
// We got a message back, return early
return message
}
// If on Windows, we don't fallback to unix, so return early
if (this.platform === 'windows') {
return defaultMsg ? defaultMsg : message
return defaultMsg || message
}
// there's no message for key, check for unix version
const unixKey = this.getPlatformKey(key, 'unix')
message = i18n.t(unixKey)
@@ -59,7 +59,7 @@ export function usePlatformI18n(platform) {
}
if (!platform) {
throw 'platform argument missing'
throw new TypeError('platform argument missing')
}
return inject(
@@ -1,4 +1,13 @@
/**
* @brief Parsed Sunshine release version and comparison helpers.
*/
class SunshineVersion {
/**
* @brief Construct a version from GitHub release data or a version string.
*
* @param {?object} release GitHub release data.
* @param {?string} version Version string when release data is unavailable.
*/
constructor(release = null, version = null) {
if (release) {
this.release = release;
@@ -19,17 +28,29 @@ class SunshineVersion {
this.versionPatch = this.versionParts ? this.versionParts[2] : null;
}
/**
* @brief Split a Sunshine version string into numeric components.
*
* @param {string} version Version string to parse.
* @return {?number[]} Numeric version components.
*/
parseVersion(version) {
if (!version) {
return null;
}
let v = version;
if (v.indexOf("v") === 0) {
if (v.startsWith("v")) {
v = v.substring(1);
}
return v.split('.').map(Number);
}
/**
* @brief Compare this version with another Sunshine version.
*
* @param {SunshineVersion|string} otherVersion Version to compare.
* @return {boolean} Whether this version is greater.
*/
isGreater(otherVersion) {
let otherVersionParts;
if (otherVersion instanceof SunshineVersion) {
@@ -37,7 +58,7 @@ class SunshineVersion {
} else if (typeof otherVersion === 'string') {
otherVersionParts = this.parseVersion(otherVersion);
} else {
throw new Error('Invalid argument: otherVersion must be a SunshineVersion object or a version string');
throw new TypeError('Invalid argument: otherVersion must be a SunshineVersion object or a version string');
}
if (!this.versionParts || !otherVersionParts) {
+1
View File
@@ -198,6 +198,7 @@ set(INTEGRATION_TEST_FILES
"scripts/linux_build.sh"
"src/config.cpp"
"src_assets/common/assets/web/Config.vue"
"src_assets/common/assets/web/locale.js"
"docs/building.md"
"docs/configuration.md"
"docs/getting_started.md"
@@ -75,6 +75,26 @@ protected:
return locales;
}
/**
* @brief Extract locale codes and referenced file names from the web locale loader.
*
* @return Map of configured locale codes to referenced JSON file stems.
*/
static std::map<std::string, std::string, std::less<>> extractLocaleJsPaths() {
std::map<std::string, std::string, std::less<>> locales;
const std::string content = file_handler::read_file("src_assets/common/assets/web/locale.js");
const std::regex localePathPattern(
R"delimiter(\[\s*['"]([^'"]+)['"]\s*,\s*['"]\./assets/locale/([^'"]+)\.json['"]\s*\])delimiter"
);
std::sregex_iterator iter(content.begin(), content.end(), localePathPattern);
for (const std::sregex_iterator end; iter != end; ++iter) {
locales[(*iter)[1].str()] = (*iter)[2].str();
}
return locales;
}
// Get available locale JSON files
static std::set<std::string, std::less<>> getAvailableLocaleFiles() {
std::set<std::string, std::less<>> locales;
@@ -246,6 +266,42 @@ TEST_F(LocaleConsistencyTest, ConfigCppAndGeneralVueLocalesMatch) {
}
}
TEST_F(LocaleConsistencyTest, LocaleJsPathsMatchAvailableFiles) {
const auto localePaths = extractLocaleJsPaths();
auto localeFiles = getAvailableLocaleFiles();
localeFiles.erase("en"); // English is imported directly as the fallback locale.
std::vector<std::string> inconsistencies;
for (const auto &localeFile : localeFiles) {
if (!localePaths.contains(localeFile)) {
inconsistencies.push_back(std::format("{}.json is missing from locale.js", localeFile));
}
}
for (const auto &[localeCode, referencedFile] : localePaths) {
if (localeCode != referencedFile) {
inconsistencies.push_back(
std::format("locale.js maps '{}' to '{}.json'", localeCode, referencedFile)
);
}
if (!localeFiles.contains(referencedFile)) {
inconsistencies.push_back(
std::format("locale.js references missing file: {}.json", referencedFile)
);
}
}
if (!inconsistencies.empty()) {
std::string errorMsg = "locale.js path inconsistencies found:\n";
for (const auto &inconsistency : inconsistencies) {
errorMsg += std::format(" {}\n", inconsistency);
}
FAIL() << errorMsg;
}
}
TEST_F(LocaleConsistencyTest, AllLocaleFilesAreValid) {
const auto localeFiles = getAvailableLocaleFiles();
std::vector<std::string> invalidFiles;
+115
View File
@@ -0,0 +1,115 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DisplayDeviceOptions from '../../src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue'
import General from '../../src_assets/common/assets/web/configs/tabs/General.vue'
import Network from '../../src_assets/common/assets/web/configs/tabs/Network.vue'
const globalOptions = {
mocks: {
$t: key => key,
},
stubs: {
Checkbox: true,
Info: true,
PlatformLayout: {
template: '<div><slot name="windows" /></div>',
},
Play: true,
Plus: true,
Shield: true,
Trash2: true,
TriangleAlert: true,
Undo: true,
},
}
describe('configuration accessibility', () => {
it('gives every global prep command input an accessible label', () => {
const wrapper = mount(General, {
props: {
platform: 'windows',
config: {
global_prep_cmd: [{ do: 'start', elevated: false, undo: 'stop' }],
notify_pre_releases: false,
system_tray: true,
},
},
global: globalOptions,
})
for (const input of wrapper.findAll('#global_prep_cmd tbody input')) {
expect(wrapper.get(`label[for="${input.attributes('id')}"]`).classes()).toContain('visually-hidden')
}
})
it('associates labels with manual display settings and remapping inputs', () => {
const manualWrapper = mount(DisplayDeviceOptions, {
props: {
platform: 'windows',
config: {
dd_configuration_option: 'ensure_active',
dd_config_revert_on_disconnect: false,
dd_hdr_option: 'disabled',
dd_mode_remapping: {
mixed: [],
refresh_rate_only: [],
resolution_only: [],
},
dd_refresh_rate_option: 'manual',
dd_resolution_option: 'manual',
},
},
global: globalOptions,
})
expect(manualWrapper.get('label[for="dd_manual_resolution"]')).toBeDefined()
expect(manualWrapper.get('label[for="dd_manual_refresh_rate"]')).toBeDefined()
const remappingWrapper = mount(DisplayDeviceOptions, {
props: {
platform: 'windows',
config: {
dd_configuration_option: 'ensure_active',
dd_config_revert_on_disconnect: false,
dd_hdr_option: 'disabled',
dd_mode_remapping: {
mixed: [{
final_refresh_rate: '',
final_resolution: '',
requested_fps: '',
requested_resolution: '',
}],
refresh_rate_only: [],
resolution_only: [],
},
dd_refresh_rate_option: 'auto',
dd_resolution_option: 'auto',
},
},
global: globalOptions,
})
for (const input of remappingWrapper.findAll('tbody input')) {
expect(remappingWrapper.get(`label[for="${input.attributes('id')}"]`).classes()).toContain('visually-hidden')
}
})
})
describe('network configuration', () => {
it('uses the default Moonlight port when the configured port is absent', () => {
const wrapper = mount(Network, {
props: {
platform: 'linux',
config: { upnp: false },
},
global: globalOptions,
})
const tableText = wrapper.get('table').text()
expect(tableText).toContain('47984')
expect(tableText).toContain('47989')
expect(tableText).toContain('48010')
expect(tableText).not.toContain('NaN')
})
})
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
createI18n: vi.fn(options => options),
}))
import { createI18n } from 'vue-i18n'
import createSunshineI18n, { loadLocaleMessages } from '../../src_assets/common/assets/web/locale.js'
describe('locale initialization', () => {
beforeEach(() => {
createI18n.mockClear()
document.documentElement.removeAttribute('lang')
})
it('loads a bundled configured locale', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ json: async () => ({ locale: 'de' }) })
.mockResolvedValueOnce({ json: async () => ({ greeting: 'Hallo' }) }))
const i18n = await createSunshineI18n()
expect(fetch).toHaveBeenCalledWith('./api/configLocale')
expect(fetch).toHaveBeenCalledWith('./assets/locale/de.json')
expect(document.documentElement.lang).toBe('de')
expect(i18n.locale).toBe('de')
expect(i18n.fallbackLocale).toBe('en')
expect(i18n.messages.en).toBeDefined()
expect(i18n.messages.de).toEqual({ greeting: 'Hallo' })
})
it.each([
['an unsupported locale', 'unsupported'],
['a prototype property', '__proto__'],
['a non-string value', null],
])('falls back to English for %s', async (_description, locale) => {
vi.stubGlobal('fetch', vi.fn(async () => ({
json: async () => ({ locale }),
})))
const i18n = await createSunshineI18n()
expect(document.documentElement.lang).toBe('en')
expect(i18n.locale).toBe('en')
expect(Object.keys(i18n.messages)).toEqual(['en'])
})
it('falls back to English when a bundled translation fails to load', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const loadTranslation = async () => {
throw new Error('translation unavailable')
}
const result = await loadLocaleMessages('de', loadTranslation)
expect(result.locale).toBe('en')
expect(Object.keys(result.messages)).toEqual(['en'])
expect(error).toHaveBeenCalledWith('Failed to download translations', expect.any(Error))
error.mockRestore()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import SunshineVersion from '../../src_assets/common/assets/web/sunshine_version.js'
describe('SunshineVersion', () => {
it('parses release metadata', () => {
const version = new SunshineVersion({
name: 'Sunshine release',
tag_name: 'v2026.9.16',
tag_tag: 'stable',
})
expect(version.release).toBeDefined()
expect(version.version).toBe('v2026.9.16')
expect(version.versionName).toBe('Sunshine release')
expect(version.versionTag).toBe('stable')
expect(version.versionParts).toEqual([2026, 9, 16])
expect(version.versionMajor).toBe(2026)
expect(version.versionMinor).toBe(9)
expect(version.versionPatch).toBe(16)
})
it('parses a version string without release metadata', () => {
const version = new SunshineVersion(null, '1.2.3')
expect(version.release).toBeNull()
expect(version.versionParts).toEqual([1, 2, 3])
expect(version.versionName).toBeNull()
expect(version.versionTag).toBeNull()
})
it('requires either release metadata or a version string', () => {
expect(() => new SunshineVersion()).toThrow('Either release or version must be provided')
})
it('compares version objects and strings', () => {
const version = new SunshineVersion(null, '2.0.0')
expect(version.isGreater('1.9.9')).toBe(true)
expect(version.isGreater(new SunshineVersion(null, '2.0.0'))).toBe(false)
expect(version.isGreater('3.0.0')).toBe(false)
})
it('handles absent and invalid comparison values', () => {
const version = new SunshineVersion(null, '2.0.0')
const unparsedVersion = new SunshineVersion(null, 'placeholder')
unparsedVersion.versionParts = null
expect(version.parseVersion(null)).toBeNull()
expect(unparsedVersion.isGreater('1.0.0')).toBe(false)
expect(() => version.isGreater(2)).toThrow(TypeError)
})
})
+3 -3
View File
@@ -1,9 +1,9 @@
import fs from 'fs';
import { resolve } from 'path'
import fs from 'node:fs';
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import { codecovVitePlugin } from "@codecov/vite-plugin";
import vue from '@vitejs/plugin-vue'
import process from 'process'
import process from 'node:process'
let assetsSrcPath = 'src_assets/common/assets/web';
let assetsDstPath = 'build/assets/web';