diff --git a/package.json b/package.json
index 1043790409d..eba5add9ee6 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,15 @@
"scripts": {
"build": "vite build --debug",
"build-clean": "vite build --debug --emptyOutDir",
- "dev": "vite build --watch",
+ "dev": "vite",
"serve": "serve ./tests/fixtures/http --no-port-switching"
},
"dependencies": {
"@lizardbyte/shared-web": "2025.326.11214",
+ "@popperjs/core": "2.11.8",
"vue": "3.5.13",
- "vue-i18n": "11.1.3"
+ "vue-i18n": "11.1.3",
+ "vue-router": "4.5.1"
},
"devDependencies": {
"@codecov/vite-plugin": "1.9.0",
diff --git a/src/confighttp.cpp b/src/confighttp.cpp
index 4ba09b88a5a..7bb8732b4ea 100644
--- a/src/confighttp.cpp
+++ b/src/confighttp.cpp
@@ -142,10 +142,19 @@ namespace confighttp {
// If credentials are shown, redirect the user to a /welcome page
if (config::sunshine.username.empty()) {
+ if (request->path == "/welcome") {
+ return true;
+ }
send_redirect(response, request, "/welcome");
return false;
}
+ // Redirect after /welcome to /
+ if (request->path == "/welcome") {
+ send_redirect(response, request, "/");
+ return false;
+ }
+
auto fg = util::fail_guard([&]() {
send_unauthorized(response, request);
});
@@ -225,191 +234,76 @@ namespace confighttp {
}
print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "index.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
-
- /**
- * @brief Get the PIN page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- */
- void getPinPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
- return;
- }
-
- print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "pin.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
-
- /**
- * @brief Get the apps page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- */
- void getAppsPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
- return;
+ if (request->path.starts_with("/api")) {
+ return not_found(response, request);
}
-
- print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "apps.html");
+ std::string content = file_handler::read_file(WEB_DIR "index.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
- headers.emplace("Access-Control-Allow-Origin", "https://images.igdb.com/");
response->write(content, headers);
}
/**
- * @brief Get the clients page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
+ * @brief Check if a path is a child of another path.
+ * @param base The base path.
+ * @param query The path to check.
+ * @return True if the path is a child of the base path, false otherwise.
*/
- void getClientsPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
- return;
- }
-
- print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "clients.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
+ bool isChildPath(fs::path const &base, fs::path const &query) {
+ auto relPath = fs::relative(base, query);
+ return *(relPath.begin()) != fs::path("..");
}
/**
- * @brief Get the configuration page.
+ * @brief Get an asset from the node_modules directory.
* @param response The HTTP response object.
* @param request The HTTP request object.
*/
- void getConfigPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
- return;
- }
-
+ void getNodeModules(resp_https_t response, req_https_t request) {
print_req(request);
+ fs::path webDirPath(WEB_DIR);
+ fs::path nodeModulesPath(webDirPath / "assets");
- std::string content = file_handler::read_file(WEB_DIR "config.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
+ // .relative_path is needed to shed any leading slash that might exist in the request path
+ auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path());
- /**
- * @brief Get the password page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- */
- void getPasswordPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
+ // Don't do anything if file does not exist or is outside the assets directory
+ if (!isChildPath(filePath, nodeModulesPath)) {
+ BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder";
+ bad_request(response, request);
return;
}
-
- print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "password.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
-
- /**
- * @brief Get the welcome page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- */
- void getWelcomePage(resp_https_t response, req_https_t request) {
- print_req(request);
- if (!config::sunshine.username.empty()) {
- send_redirect(response, request, "/");
+ if (!fs::exists(filePath)) {
+ not_found(response, request);
return;
}
- std::string content = file_handler::read_file(WEB_DIR "welcome.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
- /**
- * @brief Get the troubleshooting page.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- */
- void getTroubleshootingPage(resp_https_t response, req_https_t request) {
- if (!authenticate(response, request)) {
+ auto relPath = fs::relative(filePath, webDirPath);
+ // get the mime type from the file extension mime_types map
+ // remove the leading period from the extension
+ auto mimeType = mime_types.find(relPath.extension().string().substr(1));
+ // check if the extension is in the map at the x position
+ if (mimeType == mime_types.end()) {
+ bad_request(response, request);
return;
}
- print_req(request);
-
- std::string content = file_handler::read_file(WEB_DIR "troubleshooting.html");
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "text/html; charset=utf-8");
- response->write(content, headers);
- }
-
- /**
- * @brief Get the favicon image.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- * @todo combine function with getSunshineLogoImage and possibly getNodeModules
- * @todo use mime_types map
- */
- void getFaviconImage(resp_https_t response, req_https_t request) {
- print_req(request);
-
- std::ifstream in(WEB_DIR "images/sunshine.ico", std::ios::binary);
- SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "image/x-icon");
- response->write(SimpleWeb::StatusCode::success_ok, in, headers);
- }
-
- /**
- * @brief Get the Sunshine logo image.
- * @param response The HTTP response object.
- * @param request The HTTP request object.
- * @todo combine function with getFaviconImage and possibly getNodeModules
- * @todo use mime_types map
- */
- void getSunshineLogoImage(resp_https_t response, req_https_t request) {
- print_req(request);
-
- std::ifstream in(WEB_DIR "images/logo-sunshine-45.png", std::ios::binary);
+ // if it is, set the content type to the mime type
SimpleWeb::CaseInsensitiveMultimap headers;
- headers.emplace("Content-Type", "image/png");
+ headers.emplace("Content-Type", mimeType->second);
+ std::ifstream in(filePath.string(), std::ios::binary);
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
}
- /**
- * @brief Check if a path is a child of another path.
- * @param base The base path.
- * @param query The path to check.
- * @return True if the path is a child of the base path, false otherwise.
- */
- bool isChildPath(fs::path const &base, fs::path const &query) {
- auto relPath = fs::relative(base, query);
- return *(relPath.begin()) != fs::path("..");
- }
-
/**
* @brief Get an asset from the node_modules directory.
* @param response The HTTP response object.
* @param request The HTTP request object.
*/
- void getNodeModules(resp_https_t response, req_https_t request) {
+ void getImages(resp_https_t response, req_https_t request) {
print_req(request);
fs::path webDirPath(WEB_DIR);
- fs::path nodeModulesPath(webDirPath / "assets");
+ fs::path nodeModulesPath(webDirPath / "images");
// .relative_path is needed to shed any leading slash that might exist in the request path
auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path());
@@ -1092,15 +986,8 @@ namespace confighttp {
server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) {
bad_request(response, request);
};
- server.default_resource["GET"] = not_found;
+ server.default_resource["GET"] = getIndexPage;
server.resource["^/$"]["GET"] = getIndexPage;
- server.resource["^/pin/?$"]["GET"] = getPinPage;
- server.resource["^/apps/?$"]["GET"] = getAppsPage;
- server.resource["^/clients/?$"]["GET"] = getClientsPage;
- server.resource["^/config/?$"]["GET"] = getConfigPage;
- server.resource["^/password/?$"]["GET"] = getPasswordPage;
- server.resource["^/welcome/?$"]["GET"] = getWelcomePage;
- server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage;
server.resource["^/api/pin$"]["POST"] = savePin;
server.resource["^/api/apps$"]["GET"] = getApps;
server.resource["^/api/logs$"]["GET"] = getLogs;
@@ -1117,9 +1004,8 @@ namespace confighttp {
server.resource["^/api/clients/unpair$"]["POST"] = unpair;
server.resource["^/api/apps/close$"]["POST"] = closeApp;
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
- server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage;
- server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
server.resource["^/assets\\/.+$"]["GET"] = getNodeModules;
+ server.resource["^/images\\/.+$"]["GET"] = getImages;
server.config.reuse_address = true;
server.config.address = net::af_to_any_address_string(address_family);
server.config.port = port_https;
diff --git a/src_assets/common/assets/web/Navbar.vue b/src_assets/common/assets/web/Navbar.vue
deleted file mode 100644
index 166398b9fa7..00000000000
--- a/src_assets/common/assets/web/Navbar.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src_assets/common/assets/web/index.html b/src_assets/common/assets/web/index.html
index 4a8660af376..2876e01a29c 100644
--- a/src_assets/common/assets/web/index.html
+++ b/src_assets/common/assets/web/index.html
@@ -1,157 +1,15 @@
-
-
- <%- header %>
-
-
-
-
-
-
{{ $t('index.welcome') }}
-
{{ $t('index.description') }}
-
-
-
-
-
Version {{version.version}}
-
-
- {{ $t('index.loading_latest') }}
-
-
- {{ $t('index.version_dirty') }} 🌇
-
-
- {{ $t('index.installed_version_not_stable') }}
-
-
-
- {{ $t('index.version_latest') }}
-
-
-
-
-
-
-
{{ $t('index.new_pre_release') }}
-
-
{{ $t('index.download') }}
-
-
{{preReleaseVersion.release.name}}
-
{{preReleaseVersion.release.body}}
-
-
-
-
-
-
-
{{ $t('index.new_stable') }}
-
-
{{ $t('index.download') }}
-
-
{{githubVersion.release.name}}
-
{{githubVersion.release.body}}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ Sunshine
+
+
+
+
+
+
+
+
diff --git a/src_assets/common/assets/web/public/assets/css/sunshine.css b/src_assets/common/assets/web/public/assets/css/sunshine.css
deleted file mode 100644
index 843600feebd..00000000000
--- a/src_assets/common/assets/web/public/assets/css/sunshine.css
+++ /dev/null
@@ -1,16 +0,0 @@
-/* Hide pages while localization is loading */
-[v-cloak] {
- display: none;
-}
-
-[data-bs-theme=dark] .element {
- color: var(--bs-primary-text-emphasis);
- background-color: var(--bs-primary-bg-subtle);
-}
-
-@media (prefers-color-scheme: dark) {
- .element {
- color: var(--bs-primary-text-emphasis);
- background-color: var(--bs-primary-bg-subtle);
- }
-}
diff --git a/src_assets/common/assets/web/src/App.vue b/src_assets/common/assets/web/src/App.vue
new file mode 100644
index 00000000000..0a463ef9346
--- /dev/null
+++ b/src_assets/common/assets/web/src/App.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/src_assets/common/assets/web/src/assets/css/sunshine.css b/src_assets/common/assets/web/src/assets/css/sunshine.css
new file mode 100644
index 00000000000..1cebeda4bf2
--- /dev/null
+++ b/src_assets/common/assets/web/src/assets/css/sunshine.css
@@ -0,0 +1,107 @@
+/* Hide pages while localization is loading */
+[v-cloak] {
+ display: none;
+}
+
+[data-bs-theme=dark] .element {
+ color: var(--bs-primary-text-emphasis);
+ background-color: var(--bs-primary-bg-subtle);
+}
+
+@media (prefers-color-scheme: dark) {
+ .element {
+ color: var(--bs-primary-text-emphasis);
+ background-color: var(--bs-primary-bg-subtle);
+ }
+}
+
+.navbar-background {
+ background-color: #ffc400
+}
+
+.header .nav-link {
+ color: rgba(0, 0, 0, .65) !important;
+}
+
+.header .nav-link.active {
+ color: rgb(0, 0, 0) !important;
+}
+
+.header .nav-link:hover {
+ color: rgb(0, 0, 0) !important;
+}
+
+.header .navbar-toggler {
+ color: rgba(var(--bs-dark-rgb), .65) !important;
+ border: var(--bs-border-width) solid rgba(var(--bs-dark-rgb), 0.15) !important;
+}
+
+.header .navbar-toggler-icon {
+ --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
+}
+
+.form-control::placeholder {
+ opacity: 0.5;
+}
+
+.precmd-head {
+ width: 200px;
+}
+
+.monospace {
+ font-family: monospace;
+}
+
+.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;
+}
\ No newline at end of file
diff --git a/src_assets/common/assets/web/Checkbox.vue b/src_assets/common/assets/web/src/components/Checkbox.vue
similarity index 100%
rename from src_assets/common/assets/web/Checkbox.vue
rename to src_assets/common/assets/web/src/components/Checkbox.vue
diff --git a/src_assets/common/assets/web/src/components/Navbar.vue b/src_assets/common/assets/web/src/components/Navbar.vue
new file mode 100644
index 00000000000..0b4fd7e69a7
--- /dev/null
+++ b/src_assets/common/assets/web/src/components/Navbar.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
diff --git a/src_assets/common/assets/web/PlatformLayout.vue b/src_assets/common/assets/web/src/components/PlatformLayout.vue
similarity index 100%
rename from src_assets/common/assets/web/PlatformLayout.vue
rename to src_assets/common/assets/web/src/components/PlatformLayout.vue
diff --git a/src_assets/common/assets/web/ResourceCard.vue b/src_assets/common/assets/web/src/components/ResourceCard.vue
similarity index 100%
rename from src_assets/common/assets/web/ResourceCard.vue
rename to src_assets/common/assets/web/src/components/ResourceCard.vue
diff --git a/src_assets/common/assets/web/ThemeToggle.vue b/src_assets/common/assets/web/src/components/ThemeToggle.vue
similarity index 95%
rename from src_assets/common/assets/web/ThemeToggle.vue
rename to src_assets/common/assets/web/src/components/ThemeToggle.vue
index 7c34916adc9..13d71b5fe44 100644
--- a/src_assets/common/assets/web/ThemeToggle.vue
+++ b/src_assets/common/assets/web/src/components/ThemeToggle.vue
@@ -1,5 +1,5 @@
+ }
+
\ No newline at end of file
diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/src/views/ConfigView.vue
similarity index 94%
rename from src_assets/common/assets/web/config.html
rename to src_assets/common/assets/web/src/views/ConfigView.vue
index e6a85b874bb..1f7602a149b 100644
--- a/src_assets/common/assets/web/config.html
+++ b/src_assets/common/assets/web/src/views/ConfigView.vue
@@ -1,23 +1,4 @@
-
-
-
-
- <%- header %>
-
-
-
-
-
+
{{ $t('config.configuration') }}
@@ -91,25 +72,20 @@
{{ $t('config.configuration') }}
-
+
+
+
\ No newline at end of file
diff --git a/src_assets/common/assets/web/src/views/IndexView.vue b/src_assets/common/assets/web/src/views/IndexView.vue
new file mode 100644
index 00000000000..45042b15018
--- /dev/null
+++ b/src_assets/common/assets/web/src/views/IndexView.vue
@@ -0,0 +1,166 @@
+
+
+
{{ $t('index.welcome') }}
+
{{ $t('index.description') }}
+
+
+
+
+
Version {{ version.version }}
+
+
{{ $t('index.loading_latest') }}
+
+ {{ $t('index.version_dirty') }} 🌇
+
+
+ {{ $t('index.installed_version_not_stable') }}
+
+
+
+ {{ $t('index.version_latest') }}
+
+
+
+
+
+
+
{{ $t('index.new_pre_release') }}
+
+
{{
+ $t('index.download') }}
+
+
{{ preReleaseVersion.release.name }}
+
{{ preReleaseVersion.release.body }}
+
+
+
+
+
+
+
{{ $t('index.new_stable') }}
+
+
{{
+ $t('index.download') }}
+
+
{{ githubVersion.release.name }}
+
{{ githubVersion.release.body }}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src_assets/common/assets/web/password.html b/src_assets/common/assets/web/src/views/PasswordView.vue
similarity index 86%
rename from src_assets/common/assets/web/password.html
rename to src_assets/common/assets/web/src/views/PasswordView.vue
index 854ee596271..5c8b7338b3b 100644
--- a/src_assets/common/assets/web/password.html
+++ b/src_assets/common/assets/web/src/views/PasswordView.vue
@@ -1,23 +1,4 @@
-
-
-
-
- <%- header %>
-
-
-
-
-
+
{{ $t('password.password_change') }}
-
-
+
+
\ No newline at end of file
diff --git a/src_assets/common/assets/web/pin.html b/src_assets/common/assets/web/src/views/PinView.vue
similarity index 56%
rename from src_assets/common/assets/web/pin.html
rename to src_assets/common/assets/web/src/views/PinView.vue
index a5dcdb5dd6b..34ff7c65b67 100644
--- a/src_assets/common/assets/web/pin.html
+++ b/src_assets/common/assets/web/src/views/PinView.vue
@@ -1,18 +1,28 @@
-
-
-
-
- <%- header %>
-
-
-
-
+
{{ $t('pin.pin_pairing') }}
-