mirror of
https://github.com/kekkodance/webos-steam-view.git
synced 2026-10-06 11:00:24 +02:00
430 lines
15 KiB
C
430 lines
15 KiB
C
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
/*
|
|
* Steam View - menus are OSD frames (5x7 text to I420) on both platforms.
|
|
* TV encodes them to H.264 for the LGNC plane; host blits them to SDL.
|
|
* Stream video: LGNC plane on TV, OpenH264 decode to SDL on host.
|
|
*/
|
|
#include "app.h"
|
|
#include "media.h"
|
|
#include "osd_fb.h"
|
|
#ifdef TARGET_WEBOS
|
|
#include "webos/osd.h"
|
|
#else
|
|
#include "host/host_video.h"
|
|
#endif
|
|
|
|
#include <ihslib.h>
|
|
#include <SDL.h>
|
|
|
|
#include <signal.h>
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <time.h>
|
|
|
|
#ifdef _WIN32
|
|
#include <winsock2.h>
|
|
#endif
|
|
|
|
void identity_init(uint64_t device_id, const uint8_t secret[32]);
|
|
void app_client_attach(App *app);
|
|
IHS_Session *app_session_start(App *app);
|
|
IHS_ClientConfig *identity_client_config(void);
|
|
|
|
static volatile sig_atomic_t g_running = 1;
|
|
|
|
static void ihs_log(IHS_LogLevel level, const char *tag, const char *message) {
|
|
/* Info and below is per-packet chatter; Warn+ is real signal. */
|
|
if (level > IHS_LogLevelWarn) return;
|
|
fprintf(stderr, "[IHS %d %s] %s\n", level, tag, message);
|
|
fflush(stderr);
|
|
}
|
|
|
|
/* Session logs route here so protocol chatter never touches the UI text. */
|
|
void app_session_log(IHS_LogLevel level, const char *tag, const char *message) {
|
|
ihs_log(level, tag, message);
|
|
}
|
|
|
|
static void on_signal(int sig) {
|
|
(void) sig;
|
|
g_running = 0;
|
|
}
|
|
|
|
#ifdef TARGET_WEBOS
|
|
/* No on-TV UI: the trace file doubles as the status display. PIN and state
|
|
* go here; read with: cat /tmp/steamview-trace.log */
|
|
static FILE *trace_fp = NULL;
|
|
#define TRACE(fmt, ...) do { if (trace_fp) { fprintf(trace_fp, fmt "\n", ##__VA_ARGS__); fflush(trace_fp); } } while (0)
|
|
#else
|
|
#define TRACE(fmt, ...) do {} while (0)
|
|
#endif
|
|
|
|
static App app;
|
|
|
|
void app_set_status(App *a, const char *fmt, ...) {
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(a->status, sizeof(a->status), fmt, ap);
|
|
va_end(ap);
|
|
}
|
|
|
|
/* Stable device identity: random on first run, persisted to a file.
|
|
* The host pairs per deviceId, so this must survive restarts. The
|
|
* negotiated per-host secret follows the identity secret; without it
|
|
* every launch re-pairs (the repeated PIN prompt). */
|
|
static void identity_load(void) {
|
|
extern void app_host_secret_loaded(const uint8_t secret[32]);
|
|
char path[512];
|
|
const char *home = getenv("HOME");
|
|
if (home == NULL) home = ".";
|
|
snprintf(path, sizeof(path), "%s/.steamview-identity", home);
|
|
uint64_t device_id;
|
|
uint8_t secret[32];
|
|
uint8_t host_secret[32];
|
|
FILE *f = fopen(path, "rb");
|
|
if (f != NULL) {
|
|
if (fread(&device_id, sizeof(device_id), 1, f) == 1 &&
|
|
fread(secret, sizeof(secret), 1, f) == 1) {
|
|
identity_init(device_id, secret);
|
|
if (fread(host_secret, sizeof(host_secret), 1, f) == 1) {
|
|
app_host_secret_loaded(host_secret);
|
|
}
|
|
fclose(f);
|
|
return;
|
|
}
|
|
fclose(f);
|
|
}
|
|
srand((unsigned) time(NULL) ^ (unsigned) SDL_GetPerformanceCounter());
|
|
device_id = 0x7f00000000000000ULL | ((uint64_t) rand() << 32) | (uint64_t) rand();
|
|
for (int i = 0; i < 32; i++) secret[i] = (uint8_t) rand();
|
|
f = fopen(path, "wb");
|
|
if (f != NULL) {
|
|
fwrite(&device_id, sizeof(device_id), 1, f);
|
|
fwrite(secret, sizeof(secret), 1, f);
|
|
fclose(f);
|
|
}
|
|
identity_init(device_id, secret);
|
|
}
|
|
|
|
/* Glue callbacks (no app.h leakage of file layout): the negotiated
|
|
* per-host key is appended to .steamview-identity after pairing, and
|
|
* read back on the next launch. */
|
|
static void identity_secret_path(char *out, size_t len) {
|
|
const char *home = getenv("HOME");
|
|
if (home == NULL) home = ".";
|
|
snprintf(out, len, "%s/.steamview-identity", home);
|
|
}
|
|
|
|
void app_host_secret_saved(const uint8_t secret[32]) {
|
|
/* Append after the 8+32 identity bytes; loader reads it back if present. */
|
|
char path[512];
|
|
identity_secret_path(path, sizeof(path));
|
|
FILE *f = fopen(path, "r+b");
|
|
if (f == NULL) return;
|
|
fseek(f, 8 + 32, SEEK_SET);
|
|
fwrite(secret, 1, sizeof(uint8_t) * 32, f);
|
|
fclose(f);
|
|
}
|
|
|
|
void app_host_secret_loaded(const uint8_t secret[32]) {
|
|
memcpy(app.host_secret, secret, sizeof(app.host_secret));
|
|
app.host_secret_valid = true;
|
|
}
|
|
|
|
/* ---------- host picker ---------- */
|
|
static bool g_discovery_running = false;
|
|
|
|
void app_request_stream(App *app);
|
|
|
|
static void hosts_handle_key(SDL_Keycode key) {
|
|
if (app.host_count == 0) return;
|
|
switch (key) {
|
|
case SDLK_UP:
|
|
if (app.host_selected > 0) app.host_selected--;
|
|
break;
|
|
case SDLK_DOWN:
|
|
if (app.host_selected < app.host_count - 1) app.host_selected++;
|
|
break;
|
|
case SDLK_RETURN:
|
|
app_request_stream(&app);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* Tear down the live session (blocks for thread join). Main loop only -
|
|
* never from a session callback (same crash class as the OK-click bug). */
|
|
static void app_teardown_session(void) {
|
|
if (app.session == NULL) return;
|
|
IHS_SessionDisconnect(app.session);
|
|
IHS_SessionThreadedJoin(app.session);
|
|
IHS_SessionDestroy(app.session);
|
|
app.session = NULL;
|
|
}
|
|
|
|
/* Rebuild the client worker (gone since the stream started) and restart
|
|
* discovery with a cleared host list. Shared by Back-out and host-side
|
|
* drops. Safe to call with client == NULL or a live client. */
|
|
static void app_rediscover(void) {
|
|
if (app.client != NULL) {
|
|
IHS_ClientStop(app.client);
|
|
IHS_ClientThreadedJoin(app.client);
|
|
IHS_ClientDestroy(app.client);
|
|
app.client = NULL;
|
|
}
|
|
app.host_count = 0;
|
|
app.host_selected = -1;
|
|
app.rediscover = false;
|
|
app.client = IHS_ClientCreate(identity_client_config());
|
|
if (app.host_secret_valid) {
|
|
IHS_ClientSetSecretKey(app.client, app.host_secret);
|
|
}
|
|
app_client_attach(&app);
|
|
IHS_ClientStartDiscovery(app.client, 5000);
|
|
app.state = APP_STATE_DISCOVERY;
|
|
app_set_status(&app, "Searching for Steam hosts...");
|
|
}
|
|
|
|
/* Worker-thread entry: only sets the flag, the main loop does the work. */
|
|
void app_request_rediscover(App *a) {
|
|
a->rediscover = true;
|
|
}
|
|
|
|
/* OSD menus on both platforms: host picker + PIN screen, Up/Down/OK.
|
|
* TV encodes frames to the LGNC plane; host blits to the SDL window. */
|
|
static int g_ui_state = -1;
|
|
static int g_ui_hosts = -1;
|
|
static char g_ui_pin[16] = {0};
|
|
|
|
static void ui_present(void) {
|
|
char pin_line[32];
|
|
if (app.state == APP_STATE_AUTHORIZING && app.pin_len > 0) {
|
|
snprintf(pin_line, sizeof(pin_line), "PIN: %s", app.pin);
|
|
const char *plines[] = {pin_line, "Type this PIN into Steam",
|
|
"on the host to pair."};
|
|
osd_fb_show("Pair this TV", plines, 3, -1);
|
|
} else if (app.host_count > 0) {
|
|
const char *names[APP_MAX_HOSTS];
|
|
for (int i = 0; i < app.host_count; i++) names[i] = app.hosts[i].name;
|
|
osd_fb_show("Choose a host", names, app.host_count, app.host_selected);
|
|
} else {
|
|
static const char *searching[] = {"Searching for Steam hosts..."};
|
|
osd_fb_show("Steam View", searching, 1, -1);
|
|
}
|
|
#ifdef TARGET_WEBOS
|
|
osd_present();
|
|
#else
|
|
{
|
|
const uint8_t *y, *u, *v;
|
|
osd_fb_planes(&y, &u, &v);
|
|
if (y != NULL && u != NULL && v != NULL) {
|
|
host_video_osd(y, u, v, OSD_W, OSD_H);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void ui_sync(AppState state) {
|
|
(void) state;
|
|
bool changed = ((int) app.state != g_ui_state ||
|
|
app.host_count != g_ui_hosts ||
|
|
strcmp(app.pin, g_ui_pin) != 0);
|
|
if (changed) {
|
|
g_ui_state = (int) app.state;
|
|
g_ui_hosts = app.host_count;
|
|
snprintf(g_ui_pin, sizeof(g_ui_pin), "%s", app.pin);
|
|
ui_present();
|
|
TRACE("state=%d hosts=%d pin=%s", (int) app.state, app.host_count,
|
|
app.pin_len > 0 ? app.pin : "-");
|
|
printf("state=%d hosts=%d pin=%s status=%s\n", (int) app.state,
|
|
app.host_count, app.pin_len > 0 ? app.pin : "-",
|
|
app.status);
|
|
fflush(stdout);
|
|
}
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
/* optional: <ip> unicast-probes a host directly (broadcast filter fallback) */
|
|
const char *probe_ip = (argc > 1 && argv[1][0] != '-') ? argv[1] : NULL;
|
|
#ifdef _WIN32
|
|
WSADATA wsa;
|
|
#endif
|
|
|
|
#ifdef TARGET_WEBOS
|
|
/* TV has no visible stderr under the app manager: trace to the file. */
|
|
trace_fp = fopen("/tmp/steamview-trace.log", "w");
|
|
#endif
|
|
|
|
memset(&app, 0, sizeof(app));
|
|
app.state = APP_STATE_DISCOVERY;
|
|
app.host_selected = -1;
|
|
app.running = true;
|
|
app_set_status(&app, "Starting up...");
|
|
TRACE("startup");
|
|
|
|
identity_load();
|
|
TRACE("identity ok");
|
|
|
|
#ifdef TARGET_WEBOS
|
|
/* Media first (LGNC plane for video + OSD menus). */
|
|
if (!media_init()) {
|
|
TRACE("media_init FAILED");
|
|
}
|
|
TRACE("media ok");
|
|
#endif
|
|
/* Claim remote keys from the compositor (else Back opens the
|
|
* launcher and never reaches us). Same as ihsplay. Literal strings:
|
|
* the upstream SDL branch CI builds may predate the macros. */
|
|
SDL_SetHint("SDL_WEBOS_ACCESS_POLICY_KEYS_BACK", "true");
|
|
SDL_SetHint("SDL_WEBOS_ACCESS_POLICY_KEYS_EXIT", "true");
|
|
/* Transparency hint BEFORE SDL_Init: the fork reads it in VideoInit
|
|
* and registers a callback that clears the surface opaque region on
|
|
* late changes. Early + consistent from the start. */
|
|
SDL_SetHint(SDL_HINT_VIDEO_EGL_ALLOW_TRANSPARENCY, "1");
|
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) {
|
|
fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
|
|
TRACE("SDL_Init FAILED: %s", SDL_GetError());
|
|
return 1;
|
|
}
|
|
TRACE("sdl ok");
|
|
#ifdef TARGET_WEBOS
|
|
/* Transparent present: maps the surface (clears splash) while the
|
|
* LGNC video plane blends through. */
|
|
SDL_Window *win = SDL_CreateWindow("Steam View", 0, 0, 1920, 1080,
|
|
SDL_WINDOW_FULLSCREEN);
|
|
if (win != NULL) {
|
|
SDL_Renderer *ren = SDL_CreateRenderer(win, -1, 0);
|
|
if (ren != NULL) {
|
|
SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND);
|
|
SDL_SetRenderDrawColor(ren, 0, 0, 0, 0);
|
|
SDL_RenderClear(ren);
|
|
SDL_RenderPresent(ren);
|
|
}
|
|
TRACE("window ok");
|
|
} else {
|
|
TRACE("window FAILED: %s", SDL_GetError());
|
|
}
|
|
#else
|
|
if (!host_video_init()) {
|
|
fprintf(stderr, "host video init failed\n");
|
|
return 1;
|
|
}
|
|
TRACE("host video ok");
|
|
#endif
|
|
IHS_Init();
|
|
TRACE("ihs init ok");
|
|
|
|
app.client = IHS_ClientCreate(identity_client_config());
|
|
IHS_ClientSetLogFunction(app.client, ihs_log);
|
|
app_client_attach(&app);
|
|
IHS_ClientStartDiscovery(app.client, 5000);
|
|
if (probe_ip != NULL) {
|
|
IHS_IPAddress ip;
|
|
if (IHS_IPAddressFromString(&ip, probe_ip)) IHS_ClientDiscoverAt(app.client, &ip);
|
|
}
|
|
|
|
Uint32 last_render = 0;
|
|
while (g_running && app.running) {
|
|
SDL_Event ev;
|
|
while (SDL_PollEvent(&ev)) {
|
|
if (ev.type == SDL_QUIT) {
|
|
g_running = 0;
|
|
} else if (ev.type == SDL_KEYDOWN) {
|
|
SDL_Keycode k = ev.key.keysym.sym;
|
|
osd_trace("key sym=%d scancode=%d", (int) k, (int) ev.key.keysym.scancode);
|
|
if (k == SDLK_ESCAPE || k == SDLK_AC_BACK) {
|
|
if (app.state == APP_STATE_STREAMING && app.session != NULL) {
|
|
app_teardown_session();
|
|
app_rediscover();
|
|
} else {
|
|
g_running = 0;
|
|
}
|
|
} else if (app.state == APP_STATE_DISCOVERY || app.state == APP_STATE_HOST_PICK) {
|
|
hosts_handle_key(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* first host seen: move to picker */
|
|
if (app.state == APP_STATE_DISCOVERY && app.host_count > 0) {
|
|
if (app.host_selected < 0) app.host_selected = 0;
|
|
app.state = APP_STATE_HOST_PICK;
|
|
}
|
|
/* stream_ready comes from the worker callback: the client worker is
|
|
* already stopped; join + destroy it here (NOT in the callback -
|
|
* that's the use-after-free that crashed the OK click), then start
|
|
* the session on its own fresh socket. */
|
|
if (app.stream_ready && app.session == NULL) {
|
|
app.stream_ready = false;
|
|
IHS_ClientThreadedJoin(app.client);
|
|
IHS_ClientDestroy(app.client);
|
|
app.client = NULL;
|
|
app.session = app_session_start(&app);
|
|
if (app.session == NULL) {
|
|
app_set_status(&app, "Failed to start session");
|
|
app.state = APP_STATE_ERROR;
|
|
}
|
|
continue;
|
|
}
|
|
/* Post-pairing stream request: the auth task slot reads busy until
|
|
* the timer thread reaps it, so retry from here (never from inside
|
|
* the auth callback - same re-entrancy crash class). */
|
|
if (app.stream_retry && app.client != NULL) {
|
|
AppHost *h = &app.hosts[app.host_selected];
|
|
IHS_HostInfo info;
|
|
memset(&info, 0, sizeof(info));
|
|
info.clientId = h->client_id;
|
|
info.address = h->address;
|
|
info.universe = h->universe;
|
|
snprintf(info.hostname, sizeof(info.hostname), "%s", h->name);
|
|
IHS_StreamingRequest req = {
|
|
.maxResolution = {1920, 1080},
|
|
.streamingEnable = {true, true, false},
|
|
.audioChannelCount = 2,
|
|
};
|
|
if (IHS_ClientStreamingRequest(app.client, &info, &req)) {
|
|
app.stream_retry = false;
|
|
}
|
|
}
|
|
/* OSD menus refresh on state change; console echoes status. */
|
|
ui_sync(app.state);
|
|
#ifdef TARGET_WEBOS
|
|
/* Menus show (not streaming): re-feed the last menu AU at ~5Hz.
|
|
* The VDEC pipeline needs continuous feed to start displaying. */
|
|
if (app.state != APP_STATE_STREAMING && app.session == NULL) {
|
|
static Uint32 last_repeat = 0;
|
|
Uint32 now = SDL_GetTicks();
|
|
if (now - last_repeat >= 200) {
|
|
last_repeat = now;
|
|
osd_repeat();
|
|
}
|
|
}
|
|
#endif
|
|
#ifndef TARGET_WEBOS
|
|
static char last_console[192] = {0};
|
|
if (strcmp(app.status, last_console) != 0) {
|
|
snprintf(last_console, sizeof(last_console), "%s", app.status);
|
|
printf("%s | state=%d frames=%lu\n", app.status, app.state, app.frames);
|
|
fflush(stdout);
|
|
}
|
|
#endif
|
|
SDL_Delay(50);
|
|
}
|
|
if (app.session != NULL) {
|
|
IHS_SessionDisconnect(app.session);
|
|
IHS_SessionThreadedJoin(app.session);
|
|
IHS_SessionDestroy(app.session);
|
|
}
|
|
if (app.client != NULL) {
|
|
IHS_ClientStop(app.client);
|
|
IHS_ClientThreadedJoin(app.client);
|
|
IHS_ClientDestroy(app.client);
|
|
}
|
|
IHS_Quit();
|
|
media_quit();
|
|
/* Window + renderer belong to lv_sdl_window; it cleans up on exit. */
|
|
SDL_Quit();
|
|
return 0;
|
|
}
|