From bdd7b52e2d1a1c40a76ad4fa76e4f50858126c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caio=20C=C3=A9sar?= Date: Fri, 13 Nov 2026 15:54:30 -0300 Subject: [PATCH] fix(web-ui): resolve the base path at runtime for reverse proxies The single page application refactor switched Vite to `base: '/'` and added ``, so assets, API requests and routes always resolve against the domain root. Behind a reverse proxy that serves the UI under a sub-path (e.g. /sunshine/), the browser requests /assets/... and the UI fails to load. Resolve the base path from the current URL in a small inline script, build assets relative to it, and make the remaining root-absolute API URLs relative. vue-router picks the base up from the element. Fixes #6048 Co-Authored-By: Claude Opus 5.5 --- src_assets/common/assets/web/Apps.vue | 2 +- src_assets/common/assets/web/Navbar.vue | 4 +- .../common/assets/web/Troubleshooting.vue | 10 +- src_assets/common/assets/web/index.html | 19 +++- src_assets/common/assets/web/router.js | 2 +- tests/web/Troubleshooting.test.js | 8 +- tests/web/base_path.test.js | 91 +++++++++++++++++++ vite.config.js | 2 +- 8 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tests/web/base_path.test.js diff --git a/src_assets/common/assets/web/Apps.vue b/src_assets/common/assets/web/Apps.vue index 8aaa73db252..c34fca0d017 100644 --- a/src_assets/common/assets/web/Apps.vue +++ b/src_assets/common/assets/web/Apps.vue @@ -49,7 +49,7 @@
{ globalThis.location.replace(logoutPageUrl.toString()) } - request.open('GET', '/', true, 'sunshine-logout', cacheBuster) + request.open('GET', './', true, 'sunshine-logout', cacheBuster) request.setRequestHeader('Cache-Control', 'no-store') request.onload = finish request.onerror = finish diff --git a/src_assets/common/assets/web/Troubleshooting.vue b/src_assets/common/assets/web/Troubleshooting.vue index b1ae7ba0b7e..93174f005c1 100644 --- a/src_assets/common/assets/web/Troubleshooting.vue +++ b/src_assets/common/assets/web/Troubleshooting.vue @@ -805,7 +805,7 @@ }, created() { this._logsCopyTimeout = null; - fetch("/api/config") + fetch("./api/config") .then((r) => r.json()) .then((r) => { this.platform = r.platform; @@ -1003,7 +1003,7 @@ }, ddResetPersistence() { this.ddResetPressed = true; - apiFetch("/api/reset-display-device-persistence", { + apiFetch("./api/reset-display-device-persistence", { method: "POST", headers: { "Content-Type": "application/json" @@ -1084,7 +1084,7 @@ }); }, refreshVirtualInputStatus() { - fetch("/api/virtual-input/status") + fetch("./api/virtual-input/status") .then((r) => r.json()) .then((r) => { const virtualhid = r.virtualhid || {}; @@ -1216,7 +1216,7 @@ refreshLicenseStatus() { this.licenseBusy = true; this.licenseError = ''; - fetch("/api/virtual-input/license") + fetch("./api/virtual-input/license") .then(async (response) => { const status = await response.json(); if (!response.ok) { @@ -1243,7 +1243,7 @@ body.license_key = this.licenseKey.trim(); } - apiFetch("/api/virtual-input/license", { + apiFetch("./api/virtual-input/license", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src_assets/common/assets/web/index.html b/src_assets/common/assets/web/index.html index 71f8bd9abbf..b4d6a3caf0d 100644 --- a/src_assets/common/assets/web/index.html +++ b/src_assets/common/assets/web/index.html @@ -4,7 +4,24 @@ - + Sunshine diff --git a/src_assets/common/assets/web/router.js b/src_assets/common/assets/web/router.js index 96873f24fb6..2c36a7de692 100644 --- a/src_assets/common/assets/web/router.js +++ b/src_assets/common/assets/web/router.js @@ -6,7 +6,7 @@ import { createRouter, createWebHistory } from 'vue-router' * Lazy page imports keep the source split into focused components while the * server serves the same index document for every browser route. */ -const routes = [ +export const routes = [ { path: '/', component: () => import('./Home.vue') }, { path: '/apps', component: () => import('./Apps.vue') }, { path: '/clients', redirect: '/' }, diff --git a/tests/web/Troubleshooting.test.js b/tests/web/Troubleshooting.test.js index 98f0ff2c4c8..c9c14794b9c 100644 --- a/tests/web/Troubleshooting.test.js +++ b/tests/web/Troubleshooting.test.js @@ -21,10 +21,10 @@ import en from '../../src_assets/common/assets/web/public/assets/locale/en.json' */ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, permissions = [], i18n = null) { vi.stubGlobal('fetch', vi.fn(async (url) => { - if (url === '/api/config') { + if (url === './api/config') { return { json: async () => ({ platform, gamepad_driver: gamepadDriver }) } } - if (url === '/api/virtual-input/status') { + if (url === './api/virtual-input/status') { return { json: async () => ({ virtualhid: { @@ -37,7 +37,7 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, }), } } - if (url === '/api/virtual-input/license') { + if (url === './api/virtual-input/license') { return { ok: true, json: async () => ({ service_available: true, state: 'licensed', licensed: true, ...licenseStatus }) } } if (url === './api/permissions') { @@ -243,7 +243,7 @@ describe('virtual input troubleshooting', () => { expect(status.attributes('title')).toBe('troubleshooting.driver_status_compatible') expect(status.attributes('role')).toBeUndefined() expect(status.attributes('tabindex')).toBeUndefined() - expect(fetch).toHaveBeenCalledWith('/api/virtual-input/status') + expect(fetch).toHaveBeenCalledWith('./api/virtual-input/status') wrapper.unmount() }) diff --git a/tests/web/base_path.test.js b/tests/web/base_path.test.js new file mode 100644 index 00000000000..84982f78848 --- /dev/null +++ b/tests/web/base_path.test.js @@ -0,0 +1,91 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { routes } from '../../src_assets/common/assets/web/router.js' + +const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../src_assets/common/assets/web') +const indexHtml = readFileSync(join(webRoot, 'index.html'), 'utf8') + +/** + * @brief Extract the classic inline script that resolves the base path from index.html. + * + * @return {string} Inline script source. + */ +function baseScriptSource() { + const match = /