diff --git a/.github/workflows/thextech-smbx.yml b/.github/workflows/thextech-smbx.yml
new file mode 100644
index 0000000..00bb92c
--- /dev/null
+++ b/.github/workflows/thextech-smbx.yml
@@ -0,0 +1,69 @@
+name: TheXTech-SMBX
+
+on:
+ push:
+ paths:
+ - 'homebrew/TheXTech-SMBX/**'
+ tags:
+ - '*'
+ pull_request:
+ paths:
+ - 'homebrew/TheXTech-SMBX/**'
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install dependencies
+ run: |
+ sudo apt update
+ sudo apt install bash clang-18 lld-18
+ sudo apt install build-essential
+
+ - name: Install toolchain
+ run: |
+ wget https://github.com/ps5-payload-dev/pacbrew-repo/releases/latest/download/ps5-payload-dev.tar.gz
+ sudo tar xf ps5-payload-dev.tar.gz -C /
+
+ - name: Build
+ run: |
+ PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk ./homebrew/TheXTech-SMBX/build.sh
+
+ - name: Zip build output
+ run: |
+ cd homebrew
+ zip -r ../TheXTech-SMBX.zip TheXTech-SMBX
+
+ - name: Upload zipped artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: TheXTech-SMBX
+ path: TheXTech-SMBX.zip
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: TheXTech-SMBX
+ path: .
+
+ - name: Create release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ prerelease: true
+ files: TheXTech-SMBX.zip
+
+
diff --git a/Makefile.pc b/Makefile.pc
index 681f58b..03f0375 100644
--- a/Makefile.pc
+++ b/Makefile.pc
@@ -18,9 +18,11 @@ VERSION_TAG := $(shell git describe --abbrev=10 --dirty --always --tags)
PYTHON ?= python3
-BIN := websrv.pc
-SRCS := src/main.c src/websrv.c src/asset.c src/fs.c src/mime.c
-SRCS += src/pc/sys.c
+BIN := websrv.pc
+
+SRCS := src/main.c src/websrv.c src/asset.c src/fs.c src/mime.c
+SRCS += src/ssdp.c
+SRCS += src/pc/sys.c
CFLAGS := -Wall -DVERSION_TAG=\"$(VERSION_TAG)\"
LDADD += `pkg-config libmicrohttpd --libs`
diff --git a/Makefile.ps5 b/Makefile.ps5
index 8fc2bcb..7c57220 100644
--- a/Makefile.ps5
+++ b/Makefile.ps5
@@ -27,20 +27,21 @@ VERSION_TAG := $(shell git describe --abbrev=10 --dirty --always --tags)
PYTHON ?= python3
-BIN := websrv-ps5.elf
+BIN := websrv-ps5.elf
-SRCS := src/main.c src/websrv.c src/asset.c src/fs.c src/mime.c
-SRCS += src/mdns.c src/smb.c
-SRCS += src/ps5/sys.c src/ps5/pt.c src/ps5/elfldr.c src/ps5/hbldr.c
-SRCS += src/ps5/notify.c src/ps5/http.c
+SRCS := src/main.c src/websrv.c src/asset.c src/fs.c src/mime.c
+SRCS += src/mdns.c src/ssdp.c src/smb.c
+SRCS += src/ps5/sys.c src/ps5/pt.c src/ps5/elfldr.c src/ps5/hbldr.c
+SRCS += src/ps5/notify.c src/ps5/http.c
-CFLAGS := -g -O0 -Wall -Werror -Isrc -DVERSION_TAG=\"$(VERSION_TAG)\"
+CFLAGS := -g -O2 -Wall -Werror -Isrc -DVERSION_TAG=\"$(VERSION_TAG)\"
+CFLAGS += -DHAVE_MDNS -DHAVE_SMB
-LDADD := -lkernel_sys -lSceSystemService -lSceUserService -lSceAppInstUtil
-LDADD += -lSceSsl -lSceHttp
-LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config libmicrohttpd --libs`
-LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config microdns --libs`
-LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config libsmb2 --libs`
+LDADD := -lkernel_sys -lSceSystemService -lSceUserService -lSceAppInstUtil
+LDADD += -lSceSsl -lSceHttp
+LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config libmicrohttpd --libs`
+LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config microdns --libs`
+LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config libsmb2 --libs`
ASSETS := $(wildcard assets/*)
GEN_SRCS := $(patsubst assets/%,gen/%, $(ASSETS:=.c))
diff --git a/README.md b/README.md
index a8e0cfb..2a79cd0 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ Examples:
- http://ps5:8080/fs/?fmt=json - Browser the local filesystem (json)
- http://ps5:8080/fs/system_ex/app/NPXS40028/redis.conf - Download a local file
- http://ps5:8080/mdns - List mDNS services discovered by websrv (json)
+- http://ps5:8080/ssdp - List SSDP (UPnP) services discovered by websrv (json)
+- http://ps5:8080/ssdp?st=urn:schemas-upnp-org:device:MediaServer:1 - List discovered DLNA media servers (json)
- http://ps5:8080/smb?addr=192.168.1.1 - List shares on a remote SMB host (json)
- http://ps5:8080/smb/share?addr=192.168.1.1 - List files and folders shared by a remote SMB host (json)
- http://ps5:8080/smb/share/file?addr=192.168.1.1 - Download a remote SMB file via websrv
diff --git a/homebrew/TheXTech-SMBX/build.sh b/homebrew/TheXTech-SMBX/build.sh
new file mode 100755
index 0000000..2a469df
--- /dev/null
+++ b/homebrew/TheXTech-SMBX/build.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Copyright (C) 2026 John Törnblom
+#
+# This file is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; see the file COPYING. If not see
+# .
+
+VER="v1.3.7.3"
+URL="https://wohlsoft.ru/projects/TheXTech/_downloads/releases/thextech-full-src-$VER.tar.gz"
+ASSETS="https://wohlsoft.ru/projects/TheXTech/_downloads/assets/thextech-smbx13-assets-full.7z"
+
+SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
+SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")"
+
+if [[ -z "$PS5_PAYLOAD_SDK" ]]; then
+ echo "error: PS5_PAYLOAD_SDK is not set"
+ exit 1
+fi
+
+set -e
+
+source "${PS5_PAYLOAD_SDK}/toolchain/prospero.sh"
+
+TEMPDIR=$(mktemp -d)
+trap 'rm -rf -- "$TEMPDIR"' EXIT
+
+wget -O $TEMPDIR/smbx13.7z "${ASSETS}"
+
+wget -O $TEMPDIR/thextech.tar.gz "${URL}"
+tar xf $TEMPDIR/thextech.tar.gz -C $TEMPDIR
+
+export SDL_CONFIG="${PS5_PAYLOAD_SDK}/bin/prospero-sdl2-config"
+
+${CMAKE} -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON \
+ -DPGE_ENABLE_VIDEO_REC=OFF \
+ -DPGE_SHARED_SDLMIXER=OFF \
+ -DTHEXTECH_BUILD_GL_DESKTOP_MODERN=OFF \
+ -DTHEXTECH_BUILD_GL_ES_LEGACY=OFF \
+ -DTHEXTECH_BUILD_GL_ES_MODERN=OFF \
+ -DTHEXTECH_FORCE_FULLSCREEN=ON \
+ -DTHEXTECH_NO_BUILD_DATE=ON \
+ -DUSE_STATIC_LIBC=ON \
+ -DUSE_SYSTEM_SDL2=ON \
+ -B $TEMPDIR/build \
+ $TEMPDIR/thextech-full-src
+
+# must set DESTDIR or it prefixes ${PS5_PAYLOAD_SDK}/target to the path and breaks building
+${MAKE} -j$(nproc) -C $TEMPDIR/build DESTDIR="/"
+
+7z x $TEMPDIR/smbx13.7z -o$TEMPDIR/assets/
+
+# extract icon
+mkdir -p "${SCRIPT_DIR}/sce_sys"
+cp $TEMPDIR/assets/graphics/ui/icon/thextech_256.png "${SCRIPT_DIR}/sce_sys/icon0.png"
+
+mv $TEMPDIR/build/output/bin/thextech "${SCRIPT_DIR}/thextech-smbx.elf"
+cp "${PS5_SYSROOT}/${PS5_HBROOT}/lib/libOSMesa.so.8" "${SCRIPT_DIR}/libOSMesa.so.8"
+
+mv $TEMPDIR/assets/* "${SCRIPT_DIR}/"
+mv $TEMPDIR/thextech-full-src/LICENSE "${SCRIPT_DIR}/License.TheXTech.txt"
+mv $TEMPDIR/thextech-full-src/README.md "${SCRIPT_DIR}/ReadMe.txt"
+mv $TEMPDIR/thextech-full-src/changelog.txt "${SCRIPT_DIR}/"
\ No newline at end of file
diff --git a/homebrew/TheXTech-SMBX/homebrew.js b/homebrew/TheXTech-SMBX/homebrew.js
new file mode 100644
index 0000000..fc561e3
--- /dev/null
+++ b/homebrew/TheXTech-SMBX/homebrew.js
@@ -0,0 +1,85 @@
+/* Copyright (C) 2026 John Törnblom
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation; either version 3, or (at your option) any
+later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; see the file COPYING. If not, see
+. */
+
+async function main() {
+ const WORKDIR = window.workingDir;
+ const PAYLOAD = WORKDIR + '/thextech-smbx.elf';
+ const ENV = {HOME: window.workingDir,
+ LD_LIBRARY_PATH: window.workingDir};
+
+ return {
+ mainText: "Super Mario Bros. X",
+ secondaryText: 'Fan-made Mario game engine',
+ onclick: async() => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV
+ };
+ },
+ options: [{
+ text: "Speedrun Mode 1 (Native)",
+ onclick: () => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV,
+ args: '--speed-run-mode 1'
+ };
+ }
+ }, {
+ text: "Speedrun Mode 2 (SMBX2)",
+ onclick: () => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV,
+ args: '--speed-run-mode 2'
+ };
+ }
+ }, {
+ text: "Speedrun Mode 3 (Strict SMBX 1.3)",
+ onclick: () => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV,
+ args: '--speed-run-mode 3'
+ };
+ }
+ }, {
+ text: "SMBX2 compatibiltiy mode",
+ onclick: () => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV,
+ args: '--compat-level smbx2'
+ };
+ }
+ }, {
+ text: "SMBX 1.3 strict compatibiltiy mode",
+ onclick: () => {
+ return {
+ path: PAYLOAD,
+ cwd: WORKDIR,
+ env: ENV,
+ args: '--compat-level smbx13'
+ };
+ }
+ }]
+ };
+}
diff --git a/homebrew/TheXTech-SMBX/settings/thextech.ini b/homebrew/TheXTech-SMBX/settings/thextech.ini
new file mode 100644
index 0000000..2c8adad
--- /dev/null
+++ b/homebrew/TheXTech-SMBX/settings/thextech.ini
@@ -0,0 +1,6 @@
+[advanced]
+render = "opengl11"
+
+[video]
+internal-res = "smbx-wide"
+
diff --git a/src/main.c b/src/main.c
index 7039088..76361a4 100644
--- a/src/main.c
+++ b/src/main.c
@@ -18,6 +18,7 @@ along with this program; see the file COPYING. If not, see
#include
#include "mdns.h"
+#include "ssdp.h"
#include "websrv.h"
@@ -39,10 +40,18 @@ main(int argc, char** argv) {
signal(SIGCHLD, SIG_IGN);
while(1) {
-#ifdef __SCE__
+#ifdef HAVE_MDNS
mdns_discovery_start();
#endif
+ ssdp_discovery_start();
+
websrv_listen(port);
+
+#ifdef HAVE_MDNS
+ mdns_discovery_stop();
+#endif
+ ssdp_discovery_stop();
+
sleep(3);
}
diff --git a/src/ssdp.c b/src/ssdp.c
new file mode 100644
index 0000000..3a829d4
--- /dev/null
+++ b/src/ssdp.c
@@ -0,0 +1,640 @@
+/* Copyright (C) 2026 John Törnblom
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation; either version 3, or (at your option) any
+later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; see the file COPYING. If not, see
+. */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include "ssdp.h"
+#include "websrv.h"
+
+
+/**
+ * SSDP constants, see the UPnP Device Architecture specification.
+ **/
+#define SSDP_ADDR "239.255.255.250"
+#define SSDP_PORT 1900
+#define SSDP_TTL 4
+#define SSDP_MX 3
+
+/**
+ * Search target used when probing the network. Devices respond once per
+ * service they provide, e.g., upnp:rootdevice, uuid:, and
+ * urn:schemas-upnp-org:device:MediaServer:1.
+ **/
+#define SSDP_ST "ssdp:all"
+
+/**
+ * Number of seconds between two rounds of M-SEARCH requests.
+ **/
+#define SSDP_INTERVAL 30
+
+/**
+ * TTL used for responses that omit a CACHE-CONTROL header.
+ **/
+#define SSDP_DEFAULT_TTL 180
+
+
+/**
+ * Data structure used to keep track of services.
+ **/
+typedef struct service_seq {
+ char usn[256];
+ char st[256];
+ char server[256];
+ char location[512];
+ char addr[INET_ADDRSTRLEN];
+ uint16_t port;
+ time_t ttl;
+ struct service_seq* next;
+} service_seq_t;
+
+
+/**
+ * Global state variables.
+ **/
+static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
+static pthread_t g_thread;
+static bool g_running = false;
+static service_seq_t* g_service_seq = 0;
+
+
+/**
+ * Check if the discovery thread should keep running.
+ **/
+static bool
+ssdp_is_running(void) {
+ bool running;
+
+ pthread_mutex_lock(&g_lock);
+ running = g_running;
+ pthread_mutex_unlock(&g_lock);
+
+ return running;
+}
+
+
+/**
+ * Remove unresponsive services.
+ **/
+static void
+ssdp_purge_services(void) {
+ service_seq_t* prev = 0;
+ service_seq_t* curr = 0;
+
+ pthread_mutex_lock(&g_lock);
+
+ curr = g_service_seq;
+ while(curr) {
+ if(curr->ttl > time(0)) {
+ prev = curr;
+ curr = curr->next;
+ } else if(prev) {
+ prev->next = curr->next;
+ free(curr);
+ curr = prev->next;
+ } else {
+ g_service_seq = curr->next;
+ free(curr);
+ curr = g_service_seq;
+ }
+ }
+
+ pthread_mutex_unlock(&g_lock);
+}
+
+
+/**
+ * Remove all known services.
+ **/
+static void
+ssdp_flush_services(void) {
+ service_seq_t* curr;
+ service_seq_t* next;
+
+ pthread_mutex_lock(&g_lock);
+
+ curr = g_service_seq;
+ g_service_seq = 0;
+
+ pthread_mutex_unlock(&g_lock);
+
+ while(curr) {
+ next = curr->next;
+ free(curr);
+ curr = next;
+ }
+}
+
+
+/**
+ * Copy the value of an HTTP-style header into a buffer.
+ **/
+static int
+ssdp_header_value(const char* resp, const char* name, char* buf, size_t size) {
+ size_t namelen = strlen(name);
+ const char* line;
+ const char* end;
+ const char* val;
+ size_t linelen;
+ size_t len;
+
+ buf[0] = 0;
+
+ for(line=resp; line && *line;) {
+ end = strpbrk(line, "\r\n");
+ linelen = end ? (size_t)(end - line) : strlen(line);
+
+ if(linelen > namelen && !strncasecmp(line, name, namelen)) {
+ val = line + namelen;
+
+ while(*val == ' ' || *val == '\t') {
+ val++;
+ }
+
+ if(*val == ':') {
+ val++;
+ while(*val == ' ' || *val == '\t') {
+ val++;
+ }
+
+ if((size_t)(val - line) >= linelen) {
+ return 0;
+ }
+
+ len = linelen - (size_t)(val - line);
+ while(len && (val[len-1] == ' ' || val[len-1] == '\t')) {
+ len--;
+ }
+ if(len >= size) {
+ len = size - 1;
+ }
+
+ memcpy(buf, val, len);
+ buf[len] = 0;
+
+ return 0;
+ }
+ }
+
+ if(!end) {
+ break;
+ }
+ line = end + strspn(end, "\r\n");
+ }
+
+ return -1;
+}
+
+
+/**
+ * Parse the max-age directive of a CACHE-CONTROL header.
+ **/
+static time_t
+ssdp_cache_ttl(const char* resp) {
+ char buf[128] = {0};
+ const char* p;
+
+ if(ssdp_header_value(resp, "CACHE-CONTROL", buf, sizeof(buf))) {
+ return SSDP_DEFAULT_TTL;
+ }
+
+ for(p=buf; *p; p++) {
+ if(strncasecmp(p, "max-age", 7)) {
+ continue;
+ }
+
+ p += 7;
+ while(*p == ' ' || *p == '\t') {
+ p++;
+ }
+ if(*p != '=') {
+ continue;
+ }
+
+ p++;
+ while(*p == ' ' || *p == '\t') {
+ p++;
+ }
+
+ if(atoi(p) > 0) {
+ return (time_t)atoi(p);
+ }
+ break;
+ }
+
+ return SSDP_DEFAULT_TTL;
+}
+
+
+/**
+ * Parse the port number of a URL
+ **/
+static uint16_t
+ssdp_location_port(const char* url) {
+ const char* p;
+
+ if(!(p=strstr(url, "://"))) {
+ return 0;
+ }
+
+ for(p+=3; *p && *p != '/' && *p != ':'; p++);
+
+ if(*p == ':') {
+ return (uint16_t)atoi(p + 1);
+ }
+
+ if(!strncmp("https://", url, 8)) {
+ return 443;
+ }
+
+ return 80;
+}
+
+
+/**
+ * Forget a service that announced its departure.
+ **/
+static void
+ssdp_service_lost(const char* usn) {
+ service_seq_t* ss;
+
+ pthread_mutex_lock(&g_lock);
+
+ for(ss=g_service_seq; ss; ss=ss->next) {
+ if(!strcmp(ss->usn, usn)) {
+ ss->ttl = 0;
+ break;
+ }
+ }
+
+ pthread_mutex_unlock(&g_lock);
+
+ ssdp_purge_services();
+}
+
+
+/**
+ * Remember a service announced via SSDP.
+ **/
+static void
+ssdp_service_found(const char* resp, const char* addr) {
+ service_seq_t* ss = 0;
+ char location[512];
+ char server[256];
+ char usn[256];
+ char nts[64];
+ char st[256];
+ time_t ttl;
+
+ if(ssdp_header_value(resp, "USN", usn, sizeof(usn)) || !usn[0]) {
+ return;
+ }
+
+ // NOTIFY messages use NT instead of ST, and NTS to signal their intent
+ if(ssdp_header_value(resp, "ST", st, sizeof(st))) {
+ ssdp_header_value(resp, "NT", st, sizeof(st));
+ }
+ if(!ssdp_header_value(resp, "NTS", nts, sizeof(nts))) {
+ if(!strncasecmp(nts, "ssdp:byebye", 11)) {
+ ssdp_service_lost(usn);
+ return;
+ }
+ }
+
+ if(ssdp_header_value(resp, "LOCATION", location, sizeof(location)) ||
+ !location[0]) {
+ return;
+ }
+ ssdp_header_value(resp, "SERVER", server, sizeof(server));
+
+ ttl = ssdp_cache_ttl(resp);
+
+ pthread_mutex_lock(&g_lock);
+
+ for(ss=g_service_seq; ss; ss=ss->next) {
+ if(!strcmp(ss->usn, usn)) {
+ break;
+ }
+ }
+
+ if(!ss) {
+ if(!(ss=malloc(sizeof(service_seq_t)))) {
+ pthread_mutex_unlock(&g_lock);
+ return;
+ }
+ snprintf(ss->usn, sizeof(ss->usn), "%s", usn);
+ ss->next = g_service_seq;
+ g_service_seq = ss;
+ }
+
+ snprintf(ss->st, sizeof(ss->st), "%s", st);
+ snprintf(ss->server, sizeof(ss->server), "%s", server);
+ snprintf(ss->location, sizeof(ss->location), "%s", location);
+ snprintf(ss->addr, sizeof(ss->addr), "%s", addr);
+ ss->port = ssdp_location_port(location);
+ ss->ttl = time(0) + ttl;
+
+ pthread_mutex_unlock(&g_lock);
+}
+
+
+/**
+ * Open a socket for SSDP traffic.
+ **/
+static int
+ssdp_socket_open(void) {
+ struct sockaddr_in sin;
+ struct ip_mreq mreq;
+ int ttl = SSDP_TTL;
+ struct timeval tv;
+ int reuse = 1;
+ int fd;
+
+ if((fd=socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
+ perror("socket");
+ return -1;
+ }
+
+ if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
+ perror("setsockopt");
+ }
+ if(setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)) < 0) {
+ perror("setsockopt");
+ }
+
+ memset(&sin, 0, sizeof(sin));
+ sin.sin_family = AF_INET;
+ sin.sin_port = htons(SSDP_PORT);
+ sin.sin_addr.s_addr = htonl(INADDR_ANY);
+
+ if(bind(fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
+ perror("bind");
+ } else {
+ memset(&mreq, 0, sizeof(mreq));
+ mreq.imr_multiaddr.s_addr = inet_addr(SSDP_ADDR);
+ mreq.imr_interface.s_addr = htonl(INADDR_ANY);
+
+ if(setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
+ perror("setsockopt");
+ }
+ }
+
+ if(setsockopt(fd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)) < 0) {
+ perror("setsockopt");
+ close(fd);
+ return -1;
+ }
+
+ tv.tv_sec = 1;
+ tv.tv_usec = 0;
+ if(setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) {
+ perror("setsockopt");
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+
+/**
+ * Multicast an M-SEARCH request.
+ **/
+static int
+ssdp_send_msearch(int fd) {
+ struct sockaddr_in sin;
+ char req[512];
+ int len;
+
+ len = snprintf(req, sizeof(req),
+ "M-SEARCH * HTTP/1.1\r\n"
+ "HOST: " SSDP_ADDR ":%d\r\n"
+ "MAN: \"ssdp:discover\"\r\n"
+ "MX: %d\r\n"
+ "ST: " SSDP_ST "\r\n"
+ "\r\n", SSDP_PORT, SSDP_MX);
+
+ memset(&sin, 0, sizeof(sin));
+ sin.sin_family = AF_INET;
+ sin.sin_port = htons(SSDP_PORT);
+ sin.sin_addr.s_addr = inet_addr(SSDP_ADDR);
+
+ if(sendto(fd, req, len, 0, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
+ perror("sendto");
+ return -1;
+ }
+
+ return 0;
+}
+
+
+/**
+ * Thread for running SSDP service discovery.
+ **/
+static void*
+ssdp_discovery_thread(void* args) {
+ char addr[INET_ADDRSTRLEN];
+ struct sockaddr_in sin;
+ time_t deadline;
+ char buf[4096];
+ socklen_t len;
+ ssize_t size;
+ int fd;
+
+ if((fd=ssdp_socket_open()) < 0) {
+ return 0;
+ }
+
+ while(ssdp_is_running()) {
+ ssdp_send_msearch(fd);
+
+ deadline = time(0) + SSDP_INTERVAL;
+ while(ssdp_is_running() && time(0) < deadline) {
+ len = sizeof(sin);
+ size = recvfrom(fd, buf, sizeof(buf) - 1, 0,
+ (struct sockaddr*)&sin, &len);
+ if(size < 0) {
+ if(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
+ continue;
+ }
+ perror("recvfrom");
+ break;
+ }
+
+ buf[size] = 0;
+ if(!inet_ntop(AF_INET, &sin.sin_addr, addr, sizeof(addr))) {
+ continue;
+ }
+
+ ssdp_service_found(buf, addr);
+ }
+
+ ssdp_purge_services();
+ }
+
+ close(fd);
+
+ ssdp_flush_services();
+
+ pthread_mutex_lock(&g_lock);
+ g_running = false;
+ pthread_mutex_unlock(&g_lock);
+
+ return 0;
+}
+
+
+int
+ssdp_discovery_stop(void) {
+ bool stop;
+
+ pthread_mutex_lock(&g_lock);
+ stop = g_running;
+ g_running = false;
+ pthread_mutex_unlock(&g_lock);
+
+ if(!stop) {
+ return -1;
+ }
+
+ return pthread_join(g_thread, 0);
+}
+
+
+int
+ssdp_discovery_start(void) {
+ bool start;
+
+ pthread_mutex_lock(&g_lock);
+ start = !g_running;
+ g_running = true;
+ pthread_mutex_unlock(&g_lock);
+
+ if(!start) {
+ return -1;
+ }
+
+ return pthread_create(&g_thread, 0, ssdp_discovery_thread, 0);
+}
+
+
+/**
+ * Append a string to a JSON buffer.
+ **/
+static char*
+ssdp_json_string(char* ptr, const char* str) {
+ for(; *str; str++) {
+ switch(*str) {
+ case '"':
+ ptr += sprintf(ptr, "\\\"");
+ break;
+
+ case '\\':
+ ptr += sprintf(ptr, "\\\\");
+ break;
+
+ default:
+ if((unsigned char)*str < 0x20) {
+ ptr += sprintf(ptr, "\\u%04x", *str);
+ } else {
+ *ptr++ = *str;
+ }
+ break;
+ }
+ }
+
+ *ptr = 0;
+
+ return ptr;
+}
+
+
+enum MHD_Result
+ssdp_request(struct MHD_Connection *conn, const char* url) {
+ enum MHD_Result ret = MHD_NO;
+ struct MHD_Response *resp;
+ service_seq_t* ss;
+ bool first = true;
+ size_t size = 0;
+ const char* st;
+ char *buf;
+ char *ptr;
+
+ st = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "st");
+
+ pthread_mutex_lock(&g_lock);
+
+ // estimate needed memory, assume worst-case escaping
+ for(ss=g_service_seq; ss; ss=ss->next) {
+ size += (strlen(ss->usn) + strlen(ss->st) + strlen(ss->server) +
+ strlen(ss->location) + strlen(ss->addr)) * 6;
+ size += 128;
+ }
+ size += 16;
+
+ if(!(buf=ptr=malloc(size))) {
+ pthread_mutex_unlock(&g_lock);
+ return ret;
+ }
+
+ ptr += sprintf(ptr, "[\n");
+ for(ss=g_service_seq; ss; ss=ss->next) {
+ if(st && strcmp(st, ss->st)) {
+ continue;
+ }
+ if(!first) {
+ ptr += sprintf(ptr, ",\n");
+ }
+ first = false;
+
+ ptr += sprintf(ptr, " {\"usn\":\"");
+ ptr = ssdp_json_string(ptr, ss->usn);
+ ptr += sprintf(ptr, "\",\"st\":\"");
+ ptr = ssdp_json_string(ptr, ss->st);
+ ptr += sprintf(ptr, "\",\"server\":\"");
+ ptr = ssdp_json_string(ptr, ss->server);
+ ptr += sprintf(ptr, "\",\"location\":\"");
+ ptr = ssdp_json_string(ptr, ss->location);
+ ptr += sprintf(ptr, "\",\"address\":\"");
+ ptr = ssdp_json_string(ptr, ss->addr);
+ ptr += sprintf(ptr, "\",\"port\": %d}", ss->port);
+ }
+ ptr += sprintf(ptr, "\n]\n");
+
+ pthread_mutex_unlock(&g_lock);
+
+ size = ptr - buf;
+ if((resp=MHD_create_response_from_buffer(size, buf,
+ MHD_RESPMEM_MUST_FREE))) {
+ MHD_add_response_header(resp, MHD_HTTP_HEADER_CONTENT_TYPE,
+ "application/json");
+ ret = websrv_queue_response(conn, MHD_HTTP_OK, resp);
+ MHD_destroy_response(resp);
+ }
+
+ return ret;
+}
diff --git a/src/ssdp.h b/src/ssdp.h
new file mode 100644
index 0000000..9c8e3fd
--- /dev/null
+++ b/src/ssdp.h
@@ -0,0 +1,38 @@
+/* Copyright (C) 2026 John Törnblom
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation; either version 3, or (at your option) any
+later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; see the file COPYING. If not, see
+. */
+
+#pragma once
+
+#include
+
+
+/**
+ * Start the SSDP service discovery.
+ **/
+int ssdp_discovery_start(void);
+
+
+/**
+ * Stop the SSDP service discovery.
+ **/
+int ssdp_discovery_stop(void);
+
+
+/**
+ * Respond to a SSDP discovery request.
+ **/
+enum MHD_Result ssdp_request(struct MHD_Connection *conn,
+ const char* url);
diff --git a/src/websrv.c b/src/websrv.c
index 1fcd368..e1a01d9 100644
--- a/src/websrv.c
+++ b/src/websrv.c
@@ -29,6 +29,7 @@ along with this program; see the file COPYING. If not, see
#include "fs.h"
#include "mdns.h"
#include "smb.h"
+#include "ssdp.h"
#include "sys.h"
#include "version.h"
#include "websrv.h"
@@ -312,14 +313,9 @@ websrv_on_request(void *cls, struct MHD_Connection *conn,
if(!strncmp("/fs/", url, 4)) {
return fs_request(conn, url);
}
-#ifdef __SCE__
- if(!strcmp("/mdns", url)) {
- return mdns_request(conn, url);
+ if(!strcmp("/ssdp", url)) {
+ return ssdp_request(conn, url);
}
- if(!strncmp("/smb", url, 4)) {
- return smb_request(conn, url);
- }
-#endif
if(!strcmp("/launch", url)) {
return launch_request(conn);
}
@@ -335,6 +331,16 @@ websrv_on_request(void *cls, struct MHD_Connection *conn,
if(!strcmp("/", url) || !url[0]) {
return asset_request(conn, "/index.html");
}
+#ifdef HAVE_MDNS
+ if(!strcmp("/mdns", url)) {
+ return mdns_request(conn, url);
+ }
+#endif
+#ifdef HAVE_SMB
+ if(!strncmp("/smb", url, 4)) {
+ return smb_request(conn, url);
+ }
+#endif
return asset_request(conn, url);
}