From d1f5f5add3582234ea4ba5d9325c2fb2b9c3dfe2 Mon Sep 17 00:00:00 2001 From: Nico Meister Date: Mon, 26 Oct 2026 20:28:29 +0100 Subject: [PATCH] v3.0.4: eigene Werkzeug-Seite, Logs der MBMNET-Server (HTTPS mit MBMNET-CA), Admin nur mit signiertem Nachweis vom Server Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 +- source/admin.c | 83 ++++---- source/admin.h | 11 +- source/https.c | 64 ++++++ source/https.h | 13 ++ source/logview.c | 511 +++++++++++++++++++++++++++++++++++++++++++++++ source/logview.h | 15 ++ source/main.c | 203 ++++++++++++------- source/ui.h | 26 +++ 9 files changed, 817 insertions(+), 111 deletions(-) create mode 100644 source/https.c create mode 100644 source/https.h create mode 100644 source/logview.c create mode 100644 source/logview.h create mode 100644 source/ui.h diff --git a/Makefile b/Makefile index c315454..bad6857 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ SOURCES := source INCLUDES := source # Bei jedem Release erhöhen - das Auto-Update vergleicht diese Nummer -VERSION := 3.0.3 +VERSION := 3.0.4 APP_TITLE := MBMNET APP_DESCRIPTION := Dein eigenes 3DS-Netzwerk diff --git a/source/admin.c b/source/admin.c index 36d4852..3f1cd0e 100644 --- a/source/admin.c +++ b/source/admin.c @@ -1,56 +1,67 @@ -// Entwickler-Werkzeuge nur für Admins: Die App fragt bei MBMNET, ob die im MBMNET-Platz verknüpfte ID einem -// Admin gehört (GET /mbmnet/app-info?pid=...). Ohne verknüpfte MBMNET-ID gibt es keine Werkzeuge. -// Die Antwort wird mit der Kennung (PID) auf der SD gemerkt: Ohne Verbindung gilt sie nur für dasselbe Konto. +// Entwickler-Werkzeuge nur für Admins - und nur, wenn der Server es gerade bestätigt hat: +// 1. Der Konto-Dienst (act) holt beim MBMNET-Account-Server einen signierten Anmelde-Nachweis (Service-Token) +// für das angemeldete MBMNET-Konto. Den kann nur ausstellen, wer mit diesem Konto angemeldet ist. +// 2. Die App schickt ihn per HTTPS (MBMNET-CA) an /mbmnet/admin-check. Der Server prüft die Signatur und +// antwortet admin=1 nur für Admins. +// Nichts wird gemerkt: Ohne Antwort vom Server (offline, Nintendo aktiv, kein MBMNET-Konto) keine Werkzeuge. #include <3ds.h> #include #include #include "admin.h" +#include "https.h" -#define CACHE_FILE "sdmc:/3ds/mbmnet/admin" +#define OLD_CACHE_FILE "sdmc:/3ds/mbmnet/admin" // gemerkte Antwort älterer Versionen (wird gelöscht) +// Client-ID für den Nachweis (die von Miiverse; der MBMNET-Server nimmt jede) +static char CLIENT_ID[] = "87cd32617f1985439ea608c2746e4610"; static volatile bool isAdmin; static Thread thread; -static char url[128]; -static u32 currentPid; - -static void remember(bool on) -{ - FILE *f = fopen(CACHE_FILE, "w"); - if (f) { fprintf(f, "%lu %d\n", (unsigned long)currentPid, on ? 1 : 0); fclose(f); } -} +static char server[64]; +static char why[96] = "Werkzeuge: kein MBMNET-Konto angemeldet."; // warum (noch) keine Werkzeuge static void worker(void *arg) { (void)arg; - httpcContext ctx; - if (R_FAILED(httpcOpenContext(&ctx, HTTPC_METHOD_GET, url, 1))) return; - httpcAddRequestHeaderField(&ctx, "User-Agent", "MBMNET-Switcher/" APP_VERSION); - u32 status = 0, got = 0; - char body[32] = { 0 }; - Result rc = httpcBeginRequest(&ctx); - if (R_SUCCEEDED(rc)) rc = httpcGetResponseStatusCodeTimeout(&ctx, &status, 8ULL * 1000 * 1000 * 1000); - if (R_SUCCEEDED(rc) && status == 200) httpcDownloadData(&ctx, (u8 *)body, sizeof(body) - 1, &got); - httpcCloseContext(&ctx); - if (status != 200 || strncmp(body, "admin=", 6) != 0) return; // keine Antwort: gemerkte gilt weiter - isAdmin = body[6] == '1'; - remember(isAdmin); + // 1. Anmelde-Nachweis vom Konto-Dienst (für das gerade angemeldete Konto) + Handle done; + snprintf(why, sizeof(why), "Werkzeuge: Server prüft noch ..."); + if (R_FAILED(svcCreateEvent(&done, RESET_ONESHOT))) return; + u32 pid = 0; + svcGetProcessId(&pid, CUR_PROCESS_HANDLE); + Result rc = ACT_AcquireIndependentServiceToken(0xFE, CLIENT_ID, 0, false, false, pid, done); + if (R_SUCCEEDED(rc)) rc = svcWaitSynchronization(done, 15ULL * 1000 * 1000 * 1000); + svcCloseHandle(done); + if (rc != 0) { snprintf(why, sizeof(why), "Werkzeuge: Anmelde-Nachweis nicht erhalten (%08lX).", (unsigned long)rc); return; } + static IndependentServiceTokenV1 tok; + memset(&tok, 0, sizeof(tok)); + if (R_FAILED(rc = ACT_GetIndependentServiceToken(&tok)) || !tok.token[0]) { + snprintf(why, sizeof(why), "Werkzeuge: Anmelde-Nachweis leer (%08lX).", (unsigned long)rc); + return; + } + + // 2. Vom Server prüfen lassen (nur HTTPS) + char body[32]; + u32 len = 0; + Result hrc = 0; + u32 code = https_request(server, "/mbmnet/admin-check", NULL, "X-Service-Token", tok.token, body, sizeof(body), &len, NULL, 0, &hrc); + memset(&tok, 0, sizeof(tok)); + isAdmin = code == 200 && strncmp(body, "admin=1", 7) == 0; + if (isAdmin) why[0] = 0; + else if (code == 200) snprintf(why, sizeof(why), "Werkzeuge gibt es nur für Admins."); + else if (code) snprintf(why, sizeof(why), "Werkzeuge: Server antwortet HTTP %lu.", (unsigned long)code); + else snprintf(why, sizeof(why), "Werkzeuge: keine HTTPS-Verbindung zum Server (%08lX).", (unsigned long)hrc); } -void admin_start(const char *server, u32 pid) +const char *admin_why(void) { return why; } + +void admin_start(const char *srv, bool mbmnetLoggedIn) { - currentPid = pid; isAdmin = false; - if (!pid) { remove(CACHE_FILE); return; } // keine MBMNET-ID verknüpft: keine Werkzeuge - // Gemerkte Antwort nur, wenn sie zu genau diesem Konto gehört (alte Dateien ohne PID zählen nicht) - FILE *f = fopen(CACHE_FILE, "r"); - if (f) { - unsigned long cachedPid = 0; - int on = 0; - if (fscanf(f, "%lu %d", &cachedPid, &on) == 2 && cachedPid == pid) isAdmin = on == 1; - fclose(f); - } - snprintf(url, sizeof(url), "http://%s/mbmnet/app-info?pid=%lu", server, (unsigned long)pid); + snprintf(why, sizeof(why), "Werkzeuge: nur bei aktivem MBMNET mit angemeldetem Admin-Konto."); + remove(OLD_CACHE_FILE); + if (!mbmnetLoggedIn) return; + snprintf(server, sizeof(server), "%s", srv); s32 prio = 0x30; svcGetThreadPriority(&prio, CUR_THREAD_HANDLE); thread = threadCreate(worker, NULL, 16 * 1024, prio + 1, -2, false); diff --git a/source/admin.h b/source/admin.h index f876673..ae3e288 100644 --- a/source/admin.h +++ b/source/admin.h @@ -1,9 +1,12 @@ #pragma once #include <3ds/types.h> +#include -// Fragt im Hintergrund bei MBMNET, ob die verknüpfte NNID (pid) einem Admin gehört. -// pid = 0: nicht fragen, nur die gemerkte Antwort vom letzten Mal benutzen. -void admin_start(const char *server, u32 pid); -// Admin? (gemerkte Antwort, bis der Server geantwortet hat) +// Prüft im Hintergrund, ob das angemeldete MBMNET-Konto Admin ist (signierter Anmelde-Nachweis, per HTTPS +// vom Server bestätigt, siehe admin.c). mbmnetLoggedIn = false: gar nicht fragen, keine Werkzeuge. +void admin_start(const char *server, bool mbmnetLoggedIn); +// Admin? Nur nach Bestätigung durch den Server in dieser Sitzung. bool admin_is(void); +// Warum es (noch) keine Werkzeuge gibt (leer = Admin) +const char *admin_why(void); bool admin_finish(void); // wartet höchstens 0,1 s, false = läuft noch diff --git a/source/https.c b/source/https.c new file mode 100644 index 0000000..6a73c0b --- /dev/null +++ b/source/https.c @@ -0,0 +1,64 @@ +// HTTPS mit der MBMNET-CA, siehe https.h +#include <3ds.h> +#include +#include + +#include "https.h" +#include "olvpatch.h" + +static u8 caDer[1024]; +static u32 caLen; +static LightLock caLock = 1; // 1 = entsperrt (LightLock_Init) + +u32 https_request(const char *server, const char *path, const char *form, const char *header, const char *value, + char *out, u32 cap, u32 *outLen, char *date, size_t dateLen, Result *rcOut) +{ + static u32 formBuf[64]; + Result rc = 0; + *outLen = 0; + if (cap) out[0] = 0; + if (date && dateLen) date[0] = 0; + + LightLock_Lock(&caLock); + bool haveCa = caLen || olvpatch_fetch_ca(server, caDer, &caLen); + LightLock_Unlock(&caLock); + if (!haveCa) { if (rcOut) *rcOut = -1; return 0; } + + char url[224]; + snprintf(url, sizeof(url), "https://%s%s", server, path); + httpcContext ctx; + if (R_FAILED(rc = httpcOpenContext(&ctx, form ? HTTPC_METHOD_POST : HTTPC_METHOD_GET, url, 1))) { + if (rcOut) *rcOut = rc; + return 0; + } + httpcAddRequestHeaderField(&ctx, "User-Agent", "MBMNET-Switcher/" APP_VERSION); + httpcSetKeepAlive(&ctx, HTTPC_KEEPALIVE_DISABLED); + httpcAddTrustedRootCA(&ctx, caDer, caLen); // nur der MBMNET-CA vertrauen wir zusätzlich + if (header && value) httpcAddRequestHeaderField(&ctx, header, value); + if (form) { + u32 n = strlen(form); + if (n >= sizeof(formBuf)) n = sizeof(formBuf) - 1; + memcpy(formBuf, form, n); + httpcAddRequestHeaderField(&ctx, "Content-Type", "application/x-www-form-urlencoded"); + httpcAddPostDataRaw(&ctx, formBuf, n); + } + u32 code = 0; + rc = httpcBeginRequest(&ctx); + if (R_SUCCEEDED(rc)) rc = httpcGetResponseStatusCodeTimeout(&ctx, &code, 10ULL * 1000 * 1000 * 1000); + if (R_SUCCEEDED(rc)) { + if (date && dateLen) httpcGetResponseHeader(&ctx, "Date", date, dateLen); + u32 size = 0, got; + do { + got = 0; + rc = httpcDownloadData(&ctx, (u8 *)out + size, cap - 1 - size, &got); + size += got; + } while (rc == (Result)HTTPC_RESULTCODE_DOWNLOADPENDING && size < cap - 1); + out[size] = 0; + *outLen = size; + if (rc == (Result)HTTPC_RESULTCODE_DOWNLOADPENDING) rc = 0; // Puffer voll: abgeschnitten + } + httpcCloseContext(&ctx); + memset(formBuf, 0, sizeof(formBuf)); // Passwort nicht im Speicher liegen lassen + if (rcOut) *rcOut = rc; + return code; +} diff --git a/source/https.h b/source/https.h new file mode 100644 index 0000000..6aa9866 --- /dev/null +++ b/source/https.h @@ -0,0 +1,13 @@ +#pragma once +#include <3ds/types.h> +#include +#include + +// HTTPS zum MBMNET-Server mit der eigenen MBMNET-CA (dieselbe wie für Miiverse, siehe olvpatch.c). +// Für alles mit Geheimnissen (Log-Passwort, Anmelde-Nachweis): nie im Klartext übers Netz. +// form: POST-Daten (application/x-www-form-urlencoded), NULL = GET +// header: zusätzliche Kopfzeile "Name" mit Wert value (NULL = keine) +// date: bekommt die Date-Kopfzeile der Antwort (NULL = egal) +// Gibt den HTTP-Status zurück, 0 bei Netzwerkfehler (*rcOut = Code). out wird mit 0 abgeschlossen. +u32 https_request(const char *server, const char *path, const char *form, const char *header, const char *value, + char *out, u32 cap, u32 *outLen, char *date, size_t dateLen, Result *rcOut); diff --git a/source/logview.c b/source/logview.c new file mode 100644 index 0000000..e7d126f --- /dev/null +++ b/source/logview.c @@ -0,0 +1,511 @@ +// Werkzeuge > Logs: Log-Zeilen aller MBMNET-Server, wie auf der Log-Webseite (MBMNET-Tools/logserver). +// +// Der Log-Server ist nur übers Accounting erreichbar (/mbmnet/logs/..., logsproxy.js), und zwar nur über +// HTTPS mit der MBMNET-CA (dieselbe wie für Miiverse): Das Log-Passwort geht nie im Klartext übers Netz. +// Oben die Zeilen (neueste unten, Steuerkreuz blättert, ganz oben werden ältere nachgeladen), unten die +// Quellen zum Filtern. Ist man ganz unten, lädt die Ansicht alle 5 Sekunden neu (wie "live" im Browser). +#include <3ds.h> +#include +#include +#include +#include +#include + +#include "logview.h" +#include "https.h" +#include "ui.h" + +#define TOKEN_FILE "sdmc:/3ds/mbmnet/logs-token" +#define MAX_LINES 600 +#define MAX_SOURCES 48 +#define ROW_H 13 +#define ROWS 16 +#define SRC_ROW_H 24 +#define SRC_ROWS 7 +#define RESP_MAX (192 * 1024) + +typedef struct { + u32 id; + u64 t; // ms seit 1970 (UTC, vom Server) + char level; // 'i', 'w', 'e' + char src[28]; // Quelle (ohne Host) + char msg[404]; +} Line; + +typedef struct { + char key[80], name[32], host[32]; + bool online; + u32 errors, warns; +} Source; + +typedef enum { LV_NEED_PASSWORD, LV_LOADING, LV_READY, LV_ERROR } LvState; +enum { JOB_NONE, JOB_LOGIN, JOB_RELOAD, JOB_OLDER }; + +// --- geteilt mit dem Lade-Thread (lock) --- +static LightLock lock; +static LightEvent wake; +static Thread thread; +static volatile bool quit, busy; +static volatile int job; +static char server[64], token[72], password[72]; +static Line *lines; +static int lineCount; +static Source sources[MAX_SOURCES]; +static int sourceCount; +static volatile LvState state = LV_LOADING; +static char status[128]; +static s64 timeOffset; // Sekunden: Konsolenuhr - Serveruhr (für die Uhrzeit der Zeilen) +static u64 lastLoad; // osGetTime() des letzten Ladens + +// --- nur Hauptthread --- +static int cursor; // gewählte Zeile +static int top; // erste sichtbare Zeile +static int srcSel; // 0 = alle, sonst sources[srcSel - 1] +static int srcTop; +static bool errorsOnly, detail, following = true; +static char filterKey[80]; // gewählte Quelle (key), leer = alle + + +// --- HTTP ---------------------------------------------------------------------------- + +static int parse_month(const char *m) +{ + static const char *names[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + for (int i = 0; i < 12; i++) if (!strncmp(m, names[i], 3)) return i + 1; + return 0; +} + +// Tage seit 1970-01-01 (proleptischer gregorianischer Kalender) +static s64 days_from_civil(int y, int m, int d) +{ + y -= m <= 2; + s64 era = (y >= 0 ? y : y - 399) / 400; + s64 yoe = y - era * 400; + s64 doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + s64 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; +} + +// Server-Uhrzeit aus der Date-Kopfzeile ("Mon, 26 Oct 2026 12:34:56 GMT") -> Abstand zur Konsolenuhr +static void sync_clock(const char *date) +{ + int d, y, hh, mm, ss; + char mon[4] = ""; + if (sscanf(date, "%*3s, %d %3s %d %d:%d:%d", &d, mon, &y, &hh, &mm, &ss) != 6) return; + int m = parse_month(mon); + if (!m) return; + s64 serverNow = days_from_civil(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss; + timeOffset = (s64)time(NULL) - serverNow; +} + +// HTTPS-Anfrage ans Accounting (https.c, MBMNET-CA). Status 0 = keine Verbindung (status bekommt den Grund). +static u32 request(const char *path, const char *form, char *out, u32 cap, u32 *outLen) +{ + char date[64]; + Result rc = 0; + u32 code = https_request(server, path, form, token[0] ? "X-Logs-Token" : NULL, token, out, cap, outLen, date, sizeof(date), &rc); + if (date[0]) sync_clock(date); + if (!code) snprintf(status, sizeof(status), rc == -1 ? "MBMNET-Zertifikat fehlt (Server nicht erreichbar?)" + : "Keine Verbindung (%08lX)", (unsigned long)rc); + return code; +} + +static void url_encode(char *out, size_t len, const char *in) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t n = 0; + for (; *in && n + 4 < len; in++) { + unsigned char c = *in; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') out[n++] = c; + else { out[n++] = '%'; out[n++] = hex[c >> 4]; out[n++] = hex[c & 15]; } + } + out[n] = 0; +} + +static void save_token(const char *t) +{ + mkdir("sdmc:/3ds/mbmnet", 0777); + FILE *f = fopen(TOKEN_FILE, "w"); + if (f) { fputs(t, f); fclose(f); } +} + +static void load_token(void) +{ + token[0] = 0; + FILE *f = fopen(TOKEN_FILE, "r"); + if (!f) return; + if (fgets(token, sizeof(token), f)) token[strcspn(token, "\r\n")] = 0; + fclose(f); + if (strlen(token) != 64) token[0] = 0; +} + +static void forget_token(void) +{ + token[0] = 0; + remove(TOKEN_FILE); +} + +// Zeile mit Tabs zerlegen +static int split(char *line, char **f, int max) +{ + int n = 0; + while (n < max) { + f[n++] = line; + char *tab = strchr(line, '\t'); + if (!tab) break; + *tab = 0; + line = tab + 1; + } + return n; +} + +static void parse_line(Line *l, char **f) +{ + l->id = strtoul(f[0], NULL, 10); + l->t = strtoull(f[1], NULL, 10); + l->level = f[2][0] == 'e' ? 'e' : f[2][0] == 'w' ? 'w' : 'i'; + char *bar = strchr(f[3], '|'); // key = "quelle|host" + if (bar) *bar = 0; + snprintf(l->src, sizeof(l->src), "%s", f[3]); + snprintf(l->msg, sizeof(l->msg), "%s", f[4]); +} + +// --- Lade-Thread --------------------------------------------------------------------- + +static bool load_sources(char *buf) +{ + u32 len; + u32 code = request("/mbmnet/logs/sources", NULL, buf, RESP_MAX, &len); + if (code == 401) return false; + if (code != 200) { if (code) snprintf(status, sizeof(status), "Fehler beim Laden (HTTP %lu)", (unsigned long)code); return true; } + Source tmp[MAX_SOURCES]; + int n = 0; + char *save = NULL, *f[8]; + for (char *line = strtok_r(buf, "\n", &save); line && n < MAX_SOURCES; line = strtok_r(NULL, "\n", &save)) { + if (split(line, f, 8) < 6) continue; + Source *s = &tmp[n++]; + snprintf(s->key, sizeof(s->key), "%s", f[0]); + snprintf(s->name, sizeof(s->name), "%s", f[1]); + snprintf(s->host, sizeof(s->host), "%s", f[2]); + s->online = f[3][0] == '1'; + s->errors = strtoul(f[4], NULL, 10); + s->warns = strtoul(f[5], NULL, 10); + } + LightLock_Lock(&lock); + memcpy(sources, tmp, sizeof(Source) * n); + sourceCount = n; + LightLock_Unlock(&lock); + return true; +} + +// older = vor der ältesten Zeile weiterlesen (sonst die neuesten). false = nicht angemeldet. +static bool load_lines(char *buf, bool older) +{ + char path[200], key[96]; + url_encode(key, sizeof(key), filterKey); + LightLock_Lock(&lock); + u32 before = older && lineCount ? lines[0].id : 0; + LightLock_Unlock(&lock); + snprintf(path, sizeof(path), "/mbmnet/logs/lines?limit=%d%s%s%s%s", older ? 100 : 200, key[0] ? "&source=" : "", key, + errorsOnly ? "&level=error" : "", ""); + if (before) snprintf(path + strlen(path), sizeof(path) - strlen(path), "&before=%lu", (unsigned long)before); + u32 len; + u32 code = request(path, NULL, buf, RESP_MAX, &len); + if (code == 401) return false; + if (code != 200) { if (code) snprintf(status, sizeof(status), "Fehler beim Laden (HTTP %lu)", (unsigned long)code); return true; } + + // Neue Zeilen zählen und einlesen + int n = 0; + for (char *p = buf; *p; p++) if (*p == '\n') n++; + if (len && buf[len - 1] != '\n') n++; + Line *fresh = n ? malloc(sizeof(Line) * n) : NULL; + int got = 0; + char *save = NULL, *f[6]; + for (char *line = strtok_r(buf, "\n", &save); line && got < n; line = strtok_r(NULL, "\n", &save)) { + if (split(line, f, 6) < 5) continue; + parse_line(&fresh[got++], f); + } + + LightLock_Lock(&lock); + if (older) { + // vorne anhängen, höchstens MAX_LINES (die neuesten fallen dann weg) + int keep = lineCount; + if (got + keep > MAX_LINES) keep = MAX_LINES - got; + if (keep < 0) keep = 0; + memmove(lines + got, lines, sizeof(Line) * keep); + memcpy(lines, fresh, sizeof(Line) * (got > MAX_LINES ? MAX_LINES : got)); + lineCount = got + keep; + cursor += got; + top += got; + } else { + if (got > MAX_LINES) got = MAX_LINES; + memcpy(lines, fresh, sizeof(Line) * got); + lineCount = got; + } + LightLock_Unlock(&lock); + free(fresh); + snprintf(status, sizeof(status), "%s", ""); + lastLoad = osGetTime(); + return true; +} + +static void worker(void *arg) +{ + (void)arg; + char *buf = malloc(RESP_MAX); + while (!quit) { + LightEvent_Wait(&wake); + if (quit) break; + int j = job; + job = JOB_NONE; + if (j == JOB_NONE || !buf) continue; + busy = true; + if (j == JOB_LOGIN) { + char form[160], enc[140]; + url_encode(enc, sizeof(enc), password); + snprintf(form, sizeof(form), "password=%s", enc); + memset(password, 0, sizeof(password)); + token[0] = 0; + u32 len; + u32 code = request("/mbmnet/logs/login", form, buf, 256, &len); + if (code == 200 && len == 64) { + memcpy(token, buf, 64); + token[64] = 0; + save_token(token); + j = JOB_RELOAD; + } else { + if (code == 401) snprintf(status, sizeof(status), "Falsches Passwort."); + else if (code == 429) snprintf(status, sizeof(status), "Zu viele Versuche, 15 Minuten warten."); + else if (code) snprintf(status, sizeof(status), "Anmeldung fehlgeschlagen (HTTP %lu)", (unsigned long)code); + // code 0: status sagt schon, warum keine Verbindung + state = code == 401 ? LV_NEED_PASSWORD : LV_ERROR; + busy = false; + continue; + } + } + bool ok = j == JOB_OLDER ? load_lines(buf, true) : load_sources(buf) && load_lines(buf, false); + if (!ok) { forget_token(); state = LV_NEED_PASSWORD; snprintf(status, sizeof(status), "Bitte anmelden."); } + else state = LV_READY; + busy = false; + } + free(buf); +} + +static void start_job(int j) +{ + if (busy || job != JOB_NONE) return; + job = j; + if (state != LV_READY) state = LV_LOADING; + LightEvent_Signal(&wake); +} + +void logview_open(const char *srv) +{ + snprintf(server, sizeof(server), "%s", srv); + if (!lines) lines = calloc(MAX_LINES, sizeof(Line)); + if (!thread) { + LightLock_Init(&lock); + LightEvent_Init(&wake, RESET_ONESHOT); + s32 prio = 0x30; + svcGetThreadPriority(&prio, CUR_THREAD_HANDLE); + thread = threadCreate(worker, NULL, 32 * 1024, prio + 1, -2, false); + } + detail = false; + following = true; + load_token(); + status[0] = 0; + if (!token[0]) { state = LV_NEED_PASSWORD; return; } + state = LV_LOADING; + start_job(JOB_RELOAD); +} + +bool logview_finish(void) +{ + if (!thread) return true; + quit = true; + LightEvent_Signal(&wake); + if (R_FAILED(threadJoin(thread, 100ULL * 1000 * 1000))) return false; + threadFree(thread); + thread = NULL; + free(lines); + lines = NULL; + return true; +} + +// --- Eingaben -------------------------------------------------------------------------- + +static void select_source(int sel) +{ + if (sel < 0) sel = sourceCount; + if (sel > sourceCount) sel = 0; + srcSel = sel; + snprintf(filterKey, sizeof(filterKey), "%s", sel ? sources[sel - 1].key : ""); + if (srcSel < srcTop) srcTop = srcSel; + if (srcSel >= srcTop + SRC_ROWS) srcTop = srcSel - SRC_ROWS + 1; + following = true; + start_job(JOB_RELOAD); +} + +static bool ask_password(void) +{ + SwkbdState kb; + char input[64] = ""; + swkbdInit(&kb, SWKBD_TYPE_NORMAL, 2, sizeof(input) - 1); + swkbdSetPasswordMode(&kb, SWKBD_PASSWORD_HIDE_DELAY); + swkbdSetHintText(&kb, "Passwort der Log-Seite"); + swkbdSetButton(&kb, SWKBD_BUTTON_LEFT, "Abbrechen", false); + swkbdSetButton(&kb, SWKBD_BUTTON_RIGHT, "Anmelden", true); + if (swkbdInputText(&kb, input, sizeof(input)) != SWKBD_BUTTON_RIGHT || !input[0]) return false; + snprintf(password, sizeof(password), "%s", input); + memset(input, 0, sizeof(input)); + state = LV_LOADING; + snprintf(status, sizeof(status), "Melde an ..."); + job = JOB_LOGIN; + LightEvent_Signal(&wake); + return true; +} + +typedef struct { float x, y, w, h; } Rect; +static const Rect btnFilter = { 8, 204, 100, 30 }, btnReload = { 112, 204, 96, 30 }, btnBack = { 212, 204, 100, 30 }; +static bool in(Rect r, touchPosition tp) { return tp.px >= r.x && tp.px < r.x + r.w && tp.py >= r.y && tp.py < r.y + r.h; } + +bool logview_update(u32 down, u32 repeat, u32 held) +{ + (void)held; + if (state == LV_NEED_PASSWORD && !busy && job == JOB_NONE) { + if (!ask_password()) return false; // abgebrochen: zurück zu den Werkzeugen + } + touchPosition tp = { 0 }; + if (down & KEY_TOUCH) hidTouchRead(&tp); + + if (down & KEY_B || (down & KEY_TOUCH && in(btnBack, tp))) { + if (detail) detail = false; + else return false; + } + if (down & KEY_A) detail = !detail && lineCount > 0; + if (down & KEY_X || (down & KEY_TOUCH && in(btnFilter, tp))) { errorsOnly = !errorsOnly; following = true; start_job(JOB_RELOAD); } + if (down & KEY_Y || (down & KEY_TOUCH && in(btnReload, tp))) { following = true; start_job(JOB_RELOAD); } + if (down & KEY_R) select_source(srcSel + 1); + if (down & KEY_L) select_source(srcSel - 1); + if (down & KEY_TOUCH && tp.py >= 24 && tp.py < 24 + SRC_ROWS * SRC_ROW_H) { + int row = srcTop + (tp.py - 24) / SRC_ROW_H; + if (row <= sourceCount) select_source(row); + } + + LightLock_Lock(&lock); + int count = lineCount; + if (!detail) { + if (repeat & (KEY_UP | KEY_CPAD_UP)) { + if (cursor > 0) cursor--; + else if (count && state == LV_READY) start_job(JOB_OLDER); // ganz oben: ältere nachladen + } + if (repeat & (KEY_DOWN | KEY_CPAD_DOWN)) cursor++; + if (repeat & KEY_LEFT) cursor -= ROWS; + if (repeat & KEY_RIGHT) cursor += ROWS; + } + if (following) cursor = count - 1; + if (cursor >= count) cursor = count - 1; + if (cursor < 0) cursor = 0; + following = count == 0 || cursor == count - 1; + if (cursor < top) top = cursor; + if (cursor >= top + ROWS) top = cursor - ROWS + 1; + if (top > count - ROWS) top = count - ROWS; + if (top < 0) top = 0; + LightLock_Unlock(&lock); + + // Live: unten angekommen, alle 5 Sekunden neu laden + if (following && state == LV_READY && osGetTime() - lastLoad > 5000) start_job(JOB_RELOAD); + return true; +} + +// --- Zeichnen --------------------------------------------------------------------------- + +static u32 level_color(char level) { return level == 'e' ? COL_RED : level == 'w' ? COL_AMBER : COL_CARD_HI; } + +void logview_draw_top(void) +{ + C2D_DrawRectSolid(0, 0, 0, TOP_W, SCR_H, COL_BG); + C2D_DrawRectSolid(0, 0, 0.5f, TOP_W, 20, COL_BG2); + char head[96], line[160]; + snprintf(head, sizeof(head), "Logs · %s%s", srcSel ? sources[srcSel - 1].name : "alle Quellen", errorsOnly ? " · nur Fehler" : ""); + ui_text(head, 6, 3, 0.45f, COL_WHITE, 0); + const char *right = busy || state == LV_LOADING ? "lädt ..." : status[0] ? status : following ? "live" : "angehalten"; + ui_fit(line, sizeof(line), right, 0.38f, 180); + ui_text(line, TOP_W - 6, 4, 0.38f, status[0] ? COL_AMBER : following ? COL_GREEN : COL_MUTED, C2D_AlignRight); + + if (state == LV_NEED_PASSWORD) { + ui_text_wrap(status[0] ? status : "Anmeldung mit dem Passwort der Log-Seite.", 20, 100, 0.5f, COL_MUTED, TOP_W - 40); + return; + } + LightLock_Lock(&lock); + if (!lineCount) { + ui_text(state == LV_LOADING ? "Einen Moment ..." : "Keine Zeilen.", TOP_W / 2, 110, 0.5f, COL_MUTED, C2D_AlignCenter); + LightLock_Unlock(&lock); + return; + } + for (int r = 0; r < ROWS && top + r < lineCount; r++) { + const Line *l = &lines[top + r]; + float y = 22 + r * ROW_H; + if (top + r == cursor) C2D_DrawRectSolid(0, y, 0.5f, TOP_W, ROW_H, COL_CARD); + time_t when = (time_t)(l->t / 1000 + timeOffset); + struct tm *tm = gmtime(&when); + char hhmm[12]; + snprintf(hhmm, sizeof(hhmm), "%02d:%02d:%02d", tm ? tm->tm_hour : 0, tm ? tm->tm_min : 0, tm ? tm->tm_sec : 0); + ui_text(hhmm, 4, y + 1, 0.34f, COL_MUTED, 0); + C2D_DrawRectSolid(46, y + 1, 0.5f, 3, ROW_H - 2, level_color(l->level)); + ui_fit(line, sizeof(line), l->src, 0.34f, 56); + ui_text(line, 53, y + 1, 0.34f, COL_MUTED, 0); + ui_fit(line, sizeof(line), l->msg, 0.36f, TOP_W - 116); + ui_text(line, 112, y + 1, 0.36f, l->level == 'e' ? C2D_Color32(0xFF, 0x8A, 0x8A, 0xFF) : COL_WHITE, 0); + } + if (detail && cursor < lineCount) { + const Line *l = &lines[cursor]; + ui_round_rect(14, 30, TOP_W - 28, 200, 10, COL_BG2); + C2D_DrawRectSolid(14, 40, 0.5f, 4, 180, level_color(l->level)); + snprintf(head, sizeof(head), "%s · Zeile %lu", l->src, (unsigned long)l->id); + ui_text(head, 26, 36, 0.42f, COL_MUTED, 0); + ui_text_wrap(l->msg, 26, 56, 0.42f, COL_WHITE, TOP_W - 60); + } + LightLock_Unlock(&lock); +} + +static void button(Rect r, const char *label, bool on) +{ + ui_round_rect(r.x, r.y, r.w, r.h, 10, on ? COL_RED : COL_CARD); + ui_text(label, r.x + r.w / 2, r.y + 8, 0.42f, COL_WHITE, C2D_AlignCenter); +} + +void logview_draw_bottom(void) +{ + C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG); + C2D_DrawRectSolid(0, 0, 0.5f, BOT_W, 22, COL_BG2); + ui_text("Quellen", 6, 3, 0.45f, COL_WHITE, 0); + ui_text("L/R wechseln · A Details · X Fehler", BOT_W - 6, 5, 0.34f, COL_MUTED, C2D_AlignRight); + char line[96]; + LightLock_Lock(&lock); + for (int r = 0; r < SRC_ROWS && srcTop + r <= sourceCount; r++) { + int i = srcTop + r; + float y = 24 + r * SRC_ROW_H; + if (i == srcSel) { + C2D_DrawRectSolid(0, y, 0.5f, BOT_W, SRC_ROW_H, COL_CARD_HI); + C2D_DrawRectSolid(0, y + 4, 0.5f, 3, SRC_ROW_H - 8, COL_RED); + } + if (i == 0) { ui_text("Alle Quellen", 14, y + 5, 0.45f, COL_WHITE, 0); continue; } + const Source *s = &sources[i - 1]; + C2D_DrawCircleSolid(14, y + SRC_ROW_H / 2, 0.5f, 4, s->online ? COL_GREEN : COL_MUTED); + snprintf(line, sizeof(line), "%s · %s", s->name, s->host); + char fitted[96]; + ui_fit(fitted, sizeof(fitted), line, 0.42f, s->errors ? 200 : 270); + ui_text(fitted, 24, y + 5, 0.42f, COL_WHITE, 0); + if (s->errors) { + snprintf(line, sizeof(line), "%lu Fehler", (unsigned long)s->errors); + float w = ui_text_width(line, 0.36f) + 12; + ui_round_rect(BOT_W - 8 - w, y + 4, w, 16, 8, COL_RED); + ui_text(line, BOT_W - 8 - w / 2, y + 5, 0.36f, COL_WHITE, C2D_AlignCenter); + } + } + LightLock_Unlock(&lock); + button(btnFilter, errorsOnly ? "Alle Stufen" : "Nur Fehler", errorsOnly); + button(btnReload, "Neu laden", false); + button(btnBack, detail ? "Schließen" : "Zurück", false); +} diff --git a/source/logview.h b/source/logview.h new file mode 100644 index 0000000..db24790 --- /dev/null +++ b/source/logview.h @@ -0,0 +1,15 @@ +#pragma once +#include <3ds.h> +#include + +// Werkzeuge > Logs: die Logs aller MBMNET-Server (Log-Server aus MBMNET-Tools) über das Accounting +// (/mbmnet/logs/..., nur HTTPS mit der MBMNET-CA). Anmeldung mit dem Log-Passwort, das Sitzungs-Token +// liegt danach auf der SD (/3ds/mbmnet/logs-token, 7 Tage gültig). + +void logview_open(const char *server); +// Eingaben verarbeiten. false = Ansicht schließen. +bool logview_update(u32 down, u32 repeat, u32 held); +void logview_draw_top(void); +void logview_draw_bottom(void); +// Hintergrund-Thread beenden (beim Schließen der App). false = läuft noch (siehe main.c) +bool logview_finish(void); diff --git a/source/main.c b/source/main.c index 2d61eb1..7df0ba0 100644 --- a/source/main.c +++ b/source/main.c @@ -1,7 +1,7 @@ // MBMNET Switcher - schaltet den 3DS zwischen MBMNET und Nintendo (Original) um. // // Unten zwei Kacheln: MBMNET und Nintendo. Antippen schaltet um, unter der aktiven steht "Aktiv". -// SELECT blendet die Entwickler-Werkzeuge ein und aus (beim Öffnen immer aus). +// SELECT öffnet für Admins (vom Server bestätigt) eine eigene Werkzeug-Seite, darin u. a. die Logs. #include <3ds.h> #include #include @@ -19,27 +19,12 @@ #include "spotpass.h" #include "shopctl.h" #include "olvpatch.h" +#include "logview.h" +#include "ui.h" -#define TOP_W 400 -#define BOT_W 320 -#define SCR_H 240 - -// Farben (gleiche Palette wie Logo und Webseite) -#define COL_RED C2D_Color32(0xE4, 0x00, 0x2B, 0xFF) -#define COL_RED_DARK C2D_Color32(0x9E, 0x00, 0x1E, 0xFF) -#define COL_BG C2D_Color32(0x16, 0x19, 0x1F, 0xFF) -#define COL_BG2 C2D_Color32(0x22, 0x26, 0x2E, 0xFF) -#define COL_CARD C2D_Color32(0x2A, 0x2F, 0x39, 0xFF) -#define COL_CARD_HI C2D_Color32(0x3A, 0x41, 0x4E, 0xFF) -#define COL_WHITE C2D_Color32(0xFF, 0xFF, 0xFF, 0xFF) -#define COL_MUTED C2D_Color32(0x9A, 0xA3, 0xAF, 0xFF) -#define COL_GREEN C2D_Color32(0x22, 0xC5, 0x5E, 0xFF) -#define COL_AMBER C2D_Color32(0xF5, 0x9E, 0x0B, 0xFF) -#define COL_NINTENDO C2D_Color32(0xE6, 0x00, 0x12, 0xFF) - -// Bedienelemente auf dem unteren Bildschirm. Die Werkzeuge gibt es nur für Admins (mit SELECT), -// die Shop-Leiste nur ohne Werkzeuge. -enum { BTN_ENABLE, BTN_DISABLE, BTN_DIAG, BTN_FTP, BTN_UNLINK, BTN_SPOTPASS, BTN_REBOOT, BTN_SHOP, BTN_SHOP_DEL, BTN_COUNT }; +// Bedienelemente auf dem unteren Bildschirm. Normal: MBMNET / Nintendo und die Shop-Leiste. +// Werkzeug-Seite (nur Admins, SELECT): sechs große Knöpfe statt der Kacheln. +enum { BTN_ENABLE, BTN_DISABLE, BTN_DIAG, BTN_FTP, BTN_UNLINK, BTN_SPOTPASS, BTN_REBOOT, BTN_LOGS, BTN_SHOP, BTN_SHOP_DEL, BTN_COUNT }; typedef struct { float x, y, w, h; } Box; @@ -47,8 +32,13 @@ typedef struct { float x, y, w, h; } Box; #define TILE_W 142 #define TILE_H_FULL 166 #define TILE_H_ADMIN 160 -#define TOOL_COUNT 5 -static const Box toolBoxes[TOOL_COUNT] = { { 12, 184, 56, 42 }, { 72, 184, 56, 42 }, { 132, 184, 56, 42 }, { 192, 184, 56, 42 }, { 252, 184, 56, 42 } }; +#define TOOL_COUNT 6 +// Werkzeug-Seite: 2 Spalten, 3 Reihen, in dieser Reihenfolge +static const int toolOrder[TOOL_COUNT] = { BTN_DIAG, BTN_FTP, BTN_UNLINK, BTN_SPOTPASS, BTN_LOGS, BTN_REBOOT }; +static const Box toolBoxes[TOOL_COUNT] = { { 12, 14, 144, 66 }, { 164, 14, 144, 66 }, { 12, 86, 144, 66 }, + { 164, 86, 144, 66 }, { 12, 158, 144, 66 }, { 164, 158, 144, 66 } }; +static int tool_index(int btn) { for (int i = 0; i < TOOL_COUNT; i++) if (toolOrder[i] == btn) return i; return -1; } +static bool logsOpen; // Werkzeuge > Logs ist offen static const Box shopBox = { 12, 188, 216, 40 }, shopDelBox = { 232, 188, 76, 40 }; static int shopVersion = -1; // installierte Version des MBMNET Shops (-1 = nicht installiert) @@ -56,8 +46,8 @@ static int shopVersion = -1; // installierte Version des MBMNET Shops (-1 = ni // Welche Knöpfe sind gerade da? static bool visible(int btn, bool admin) { - if (btn <= BTN_DISABLE) return true; - if (btn <= BTN_REBOOT) return admin; + if (btn <= BTN_DISABLE) return !admin; // auf der Werkzeug-Seite keine Kacheln + if (btn <= BTN_LOGS) return admin; if (btn == BTN_SHOP) return !admin; return !admin && shopVersion >= 0; // Entfernen nur, wenn installiert } @@ -68,13 +58,9 @@ static Box box_of(int btn, bool admin) switch (btn) { case BTN_ENABLE: return (Box){ 12, TILE_Y, TILE_W, h }; case BTN_DISABLE: return (Box){ 166, TILE_Y, TILE_W, h }; - case BTN_DIAG: return toolBoxes[0]; - case BTN_FTP: return toolBoxes[1]; - case BTN_UNLINK: return toolBoxes[2]; - case BTN_SPOTPASS: return toolBoxes[3]; case BTN_SHOP: return shopBox; case BTN_SHOP_DEL: return shopDelBox; - default: return toolBoxes[4]; + default: return toolBoxes[tool_index(btn) < 0 ? 0 : tool_index(btn)]; } } @@ -92,6 +78,41 @@ static void text(const char *s, float x, float y, float size, u32 color, u32 fla C2D_DrawText(&t, C2D_WithColor | flags, x, y, 0.5f, size, size, color); } +// Für logview.c (ui.h) +void ui_text(const char *s, float x, float y, float size, u32 color, u32 flags) { text(s, x, y, size, color, flags); } + +void ui_text_wrap(const char *s, float x, float y, float size, u32 color, float width) +{ + C2D_Text t; + C2D_TextParse(&t, textBuf, s); + C2D_TextOptimize(&t); + C2D_DrawText(&t, C2D_WithColor | C2D_WordWrap, x, y, 0.5f, size, size, color, width); +} + +static C2D_TextBuf measureBuf; // nur zum Messen, wird jedes Mal geleert +float ui_text_width(const char *s, float size) +{ + C2D_Text t; + float w = 0; + C2D_TextBufClear(measureBuf); + C2D_TextParse(&t, measureBuf, s); + C2D_TextGetDimensions(&t, size, size, &w, NULL); + return w; +} + +void ui_fit(char *out, size_t len, const char *s, float size, float width) +{ + snprintf(out, len, "%s", s); + if (ui_text_width(out, size) <= width) return; + size_t n = strlen(out); + while (n > 1) { + n--; + while (n > 0 && (out[n] & 0xC0) == 0x80) n--; // nicht mitten in einem UTF-8-Zeichen + snprintf(out + n, len - n, "..."); + if (ui_text_width(out, size) <= width) return; + } +} + static void round_rect(float x, float y, float w, float h, float r, u32 color) { C2D_DrawRectSolid(x + r, y, 0.5f, w - 2 * r, h, color); @@ -101,6 +122,7 @@ static void round_rect(float x, float y, float w, float h, float r, u32 color) C2D_DrawCircleSolid(x + r, y + h - r, 0.5f, r, color); C2D_DrawCircleSolid(x + w - r, y + h - r, 0.5f, r, color); } +void ui_round_rect(float x, float y, float w, float h, float r, u32 color) { round_rect(x, y, w, h, r, color); } // Das MBMNET-Logo: rotes abgerundetes Quadrat mit einem "M" aus verbundenen Netzwerk-Knoten. // Geometrie identisch zu assets/logo.svg (100x100-Raster). @@ -172,23 +194,32 @@ static void draw_top(PatchState st, const char *msg, bool admin, bool isAdmin) text(ftp, 10 + w + 12, 4, 0.4f, COL_GREEN, 0); } - float y = msg[0] || admin ? 26 : 62; + // Werkzeug-Seite: Überschrift und was gerade wirklich los ist + if (admin) { + draw_logo(16, 26, 40); + text("Werkzeuge", 66, 28, 0.75f, COL_WHITE, 0); + text("Nur für Admins · B oder SELECT: zurück", 66, 52, 0.4f, COL_MUTED, 0); + char line[112], id[20]; + const char *label = st == STATE_MBMNET ? "Patch: MBMNET" : st == STATE_FOREIGN ? "Patch: fremd (z. B. Pretendo)" : "Patch: keiner"; + snprintf(line, sizeof(line), "%s · angemeldet: %s · v" APP_VERSION, label, nnid_linked(id, sizeof(id)) ? id : "keine ID"); + round_rect(16, 76, TOP_W - 32, 22, 8, COL_CARD); + text(line, 26, 80, 0.4f, COL_MUTED, 0); + if (msg[0]) { + round_rect(16, 104, TOP_W - 32, SCR_H - 112, 10, COL_CARD); + text(msg, 28, 110, 0.46f, COL_WHITE, 0); + } + return; + } + + float y = msg[0] ? 26 : 62; draw_logo(TOP_W / 2 - 32, y, 64); text("MBMNET", TOP_W / 2, y + 70, 0.9f, COL_WHITE, C2D_AlignCenter); y += 108; - // Admins: was gerade wirklich los ist - if (admin) { - char line[96], id[20]; - const char *label = st == STATE_MBMNET ? "Patch: MBMNET" : st == STATE_FOREIGN ? "Patch: fremd (z. B. Pretendo)" : "Patch: keiner"; - snprintf(line, sizeof(line), "%s · NNID: %s%s", label, nnid_linked(id, sizeof(id)) ? id : "keine", isAdmin ? " · Admin" : ""); - text(line, TOP_W / 2, y, 0.42f, COL_MUTED, C2D_AlignCenter); - y += 18; - } if (msg[0]) { round_rect(16, y, TOP_W - 32, SCR_H - y - 8, 10, COL_CARD); text(msg, 28, y + 6, 0.46f, COL_WHITE, 0); - } else if (!admin) { + } else { text("Tippe unten auf MBMNET oder Nintendo.", TOP_W / 2, 200, 0.45f, COL_MUTED, C2D_AlignCenter); } } @@ -247,19 +278,29 @@ static void draw_shop_bar(int selected) static void draw_bottom(int selected, PatchState st, bool admin) { C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG); - draw_tile(BTN_ENABLE, selected == BTN_ENABLE, st == STATE_MBMNET, admin); - draw_tile(BTN_DISABLE, selected == BTN_DISABLE, st == STATE_ORIGINAL, admin); - if (!admin) { draw_shop_bar(selected); return; } - - const char *labels[TOOL_COUNT] = { "Diagnose", ftpOn ? "FTP aus" : "FTP an", "ID lösen", "SpotPass", "Neustart" }; + if (!admin) { + draw_tile(BTN_ENABLE, selected == BTN_ENABLE, st == STATE_MBMNET, admin); + draw_tile(BTN_DISABLE, selected == BTN_DISABLE, st == STATE_ORIGINAL, admin); + draw_shop_bar(selected); + return; + } + // Werkzeug-Seite: sechs große Knöpfe mit kurzer Erklärung for (int i = 0; i < TOOL_COUNT; i++) { + int btn = toolOrder[i]; + const char *label = btn == BTN_DIAG ? "Diagnose" : btn == BTN_FTP ? (ftpOn ? "FTP aus" : "FTP an") + : btn == BTN_UNLINK ? "ID lösen" : btn == BTN_SPOTPASS ? "SpotPass-Test" + : btn == BTN_LOGS ? "Logs" : "Neustart"; + const char *hint = btn == BTN_DIAG ? "Patches und Konten prüfen" : btn == BTN_FTP ? (ftpOn ? "läuft" : "SD-Karte im WLAN") + : btn == BTN_UNLINK ? "nur auf dem 3DS" : btn == BTN_SPOTPASS ? "Mitteilung abholen" + : btn == BTN_LOGS ? "alle MBMNET-Server" : "3DS neu starten"; Box b = toolBoxes[i]; - bool sel = selected == BTN_DIAG + i; - bool on = i == 1 && ftpOn; - round_rect(b.x, b.y, b.w, b.h, 10, sel ? COL_CARD_HI : COL_CARD); - if (sel) C2D_DrawRectSolid(b.x, b.y + 10, 0.5f, 4, b.h - 20, COL_RED); - if (on) C2D_DrawCircleSolid(b.x + 12, b.y + b.h / 2, 0.5f, 4, COL_GREEN); - text(labels[i], b.x + b.w / 2, b.y + 13, 0.45f, on ? COL_GREEN : COL_WHITE, C2D_AlignCenter); + bool sel = selected == btn; + bool on = btn == BTN_FTP && ftpOn; + if (sel) round_rect(b.x - 2, b.y - 2, b.w + 4, b.h + 4, 13, COL_RED); + round_rect(b.x, b.y, b.w, b.h, 11, sel ? COL_CARD_HI : COL_CARD); + if (on) C2D_DrawCircleSolid(b.x + 14, b.y + 21, 0.5f, 4, COL_GREEN); + text(label, b.x + b.w / 2, b.y + 14, 0.58f, on ? COL_GREEN : COL_WHITE, C2D_AlignCenter); + text(hint, b.x + b.w / 2, b.y + 40, 0.38f, COL_MUTED, C2D_AlignCenter); } } @@ -495,7 +536,7 @@ static bool run(int action, char *msg, size_t msglen) bool confirmed = armed == action; armed = -1; // Während ein Update lädt oder installiert wird, nicht umschalten (der 3DS würde mittendrin neu starten) - if (action != BTN_DIAG && action != BTN_FTP && action != BTN_SPOTPASS && action < BTN_SHOP && update_pending()) { + if (action != BTN_DIAG && action != BTN_FTP && action != BTN_SPOTPASS && action != BTN_LOGS && action < BTN_SHOP && update_pending()) { snprintf(msg, msglen, "Moment: Das Update auf v%s wird gerade installiert.\nDanach startet die App neu.", update_new_version()); return false; } @@ -527,6 +568,11 @@ static bool run(int action, char *msg, size_t msglen) if (spotpass_test_start(MBMNET_SERVER, msg, msglen)) spotpassTestFrames = 0; return false; case BTN_REBOOT: return true; + case BTN_LOGS: + logsOpen = true; + logview_open(MBMNET_SERVER); + msg[0] = 0; + return false; case BTN_SHOP: if (shopctl_job() == SHOPJOB_RUNNING) { snprintf(msg, msglen, "Der Shop wird gerade installiert ..."); return false; } if (shopVersion >= 0 && shopVersion >= APP_TITLE_VERSION) { @@ -568,7 +614,8 @@ int main(int argc, char **argv) C2D_Prepare(); C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT); C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT); - textBuf = C2D_TextBufNew(4096); + textBuf = C2D_TextBufNew(8192); // Logs brauchen mehr Platz + measureBuf = C2D_TextBufNew(1024); nnid_init(); frdswitch_init(); @@ -588,14 +635,11 @@ int main(int argc, char **argv) snprintf(msg, sizeof(msg), "Neuer Speicherort für die Patches!\nBitte MBMNET einmal neu antippen."); PatchState st = patch_state(); // Nur im MBMNET-Modus ist die verknüpfte NNID ein MBMNET-Konto - // Werkzeuge nur für Admins: gefragt wird mit der ID im MBMNET-Platz (auch wenn gerade Nintendo aktiv ist) + // Werkzeuge nur für Admins, und nur wenn der Server es per signiertem Anmelde-Nachweis bestätigt. + // Das geht nur, solange das MBMNET-Konto angemeldet ist (MBMNET aktiv, ID verknüpft). { - u8 mslot = frdswitch_slot_of(FRIEND_ACCOUNT_MBMNET); char mid[20]; - u32 mpid = 0; - if (mslot && frdswitch_slot_linked(FRIEND_ACCOUNT_MBMNET, mid, sizeof(mid))) - ACT_GetAccountInfo(&mpid, sizeof(mpid), mslot, INFO_TYPE_PRINCIPAL_ID); - admin_start(server, mpid); + admin_start(server, st == STATE_MBMNET && frdswitch_current() == FRIEND_ACCOUNT_MBMNET && nnid_linked(mid, sizeof(mid))); } // Ein SpotPass-Test von früher (App mitten im Test verlassen) darf nicht weiterlaufen spotpass_test_cancel(); @@ -632,24 +676,39 @@ int main(int argc, char **argv) u32 down = hidKeysDown(); if (--ftpCheckIn <= 0) { ftpCheckIn = 120; ftpOn = ftpctl_running(); } if (down & KEY_START && rebootIn == 0) break; // während des Umschaltens nicht abbrechen + // Logs offen: die haben ihre eigene Bedienung + if (logsOpen) { + if (!admin_is() || !logview_update(down, hidKeysDownRepeat(), hidKeysHeld())) logsOpen = false; + down = 0; + } + bool wasTools = toolsToggled; if (down & KEY_SELECT && admin_is()) toolsToggled = !toolsToggled; + else if (down & KEY_SELECT) snprintf(msg, sizeof(msg), "%s", admin_why()); + if (down & KEY_B && toolsToggled) toolsToggled = false; if (!admin_is()) toolsToggled = false; // Werkzeuge nur für Admins (Server hat es bestätigt) // Werkzeuge nur mit SELECT, beim Öffnen immer aus bool admin = toolsToggled; - if (!visible(selected, admin)) selected = admin || selected <= BTN_REBOOT ? BTN_ENABLE : BTN_SHOP; + if (admin != wasTools) { selected = admin ? BTN_DIAG : BTN_ENABLE; armed = -1; } + if (!visible(selected, admin)) selected = admin ? BTN_DIAG : selected <= BTN_LOGS ? BTN_ENABLE : BTN_SHOP; int action = -1; // Oben die beiden Kacheln, unten (nur mit Werkzeugen) Diagnose / FTP / ID lösen / SpotPass / Neustart bool left = down & (KEY_LEFT | KEY_CPAD_LEFT), right = down & (KEY_RIGHT | KEY_CPAD_RIGHT); bool up = down & (KEY_UP | KEY_CPAD_UP), dn = down & (KEY_DOWN | KEY_CPAD_DOWN); - if (selected < BTN_DIAG) { if (left || right) selected ^= 1; } - else if (selected >= BTN_SHOP) { if ((left || right) && shopVersion >= 0) selected = selected == BTN_SHOP ? BTN_SHOP_DEL : BTN_SHOP; } - else if (left) selected = selected == BTN_DIAG ? BTN_REBOOT : selected - 1; - else if (right) selected = selected == BTN_REBOOT ? BTN_DIAG : selected + 1; - if (dn && selected < BTN_DIAG) selected = admin ? (selected == BTN_ENABLE ? BTN_DIAG : BTN_REBOOT) - : (selected == BTN_DISABLE && shopVersion >= 0 ? BTN_SHOP_DEL : BTN_SHOP); - else if (up && selected >= BTN_SHOP) selected = selected == BTN_SHOP_DEL ? BTN_DISABLE : BTN_ENABLE; - else if (up && selected >= BTN_DIAG) selected = selected == BTN_REBOOT ? BTN_DISABLE : BTN_ENABLE; + if (admin) { + // Werkzeug-Raster: 2 Spalten, 3 Reihen + int i = tool_index(selected); + if (i < 0) i = 0; + if (left || right) i ^= 1; + if (up && i >= 2) i -= 2; + if (dn && i + 2 < TOOL_COUNT) i += 2; + selected = toolOrder[i]; + } else { + if (selected < BTN_DIAG) { if (left || right) selected ^= 1; } + else if (selected >= BTN_SHOP) { if ((left || right) && shopVersion >= 0) selected = selected == BTN_SHOP ? BTN_SHOP_DEL : BTN_SHOP; } + if (dn && selected < BTN_DIAG) selected = selected == BTN_DISABLE && shopVersion >= 0 ? BTN_SHOP_DEL : BTN_SHOP; + else if (up && selected >= BTN_SHOP) selected = selected == BTN_SHOP_DEL ? BTN_DISABLE : BTN_ENABLE; + } if (down & KEY_A) action = selected; // Y: verknüpfte ID lösen (für alle, mit Bestätigung durch ein zweites Y) if (down & KEY_Y) action = BTN_UNLINK; @@ -731,10 +790,12 @@ int main(int argc, char **argv) C3D_FrameBegin(C3D_FRAME_SYNCDRAW); C2D_TargetClear(top, COL_BG); C2D_SceneBegin(top); - draw_top(st, msg, admin, admin_is()); + if (logsOpen) logview_draw_top(); + else draw_top(st, msg, admin, admin_is()); C2D_TargetClear(bot, COL_BG); C2D_SceneBegin(bot); - draw_bottom(selected, st, admin); + if (logsOpen) logview_draw_bottom(); + else draw_bottom(selected, st, admin); C3D_FrameEnd(0); } @@ -754,6 +815,7 @@ int main(int argc, char **argv) else clean = false; // holt nur noch das Zertifikat: hart beenden ist ungefährlich (atomar geschrieben) } clean = admin_finish() && clean; + clean = logview_finish() && clean; clean = update_finish() && clean; if (softReboot) { Result rc = nsInit(); @@ -770,6 +832,7 @@ int main(int argc, char **argv) httpcExit(); if (am) amExit(); C2D_TextBufDelete(textBuf); + C2D_TextBufDelete(measureBuf); C2D_Fini(); C3D_Fini(); gfxExit(); diff --git a/source/ui.h b/source/ui.h new file mode 100644 index 0000000..e9bd594 --- /dev/null +++ b/source/ui.h @@ -0,0 +1,26 @@ +#pragma once +#include + +// Zeichenhilfen und Farben der MBMNET-App (umgesetzt in main.c), auch für logview.c +#define TOP_W 400 +#define BOT_W 320 +#define SCR_H 240 + +#define COL_RED C2D_Color32(0xE4, 0x00, 0x2B, 0xFF) +#define COL_RED_DARK C2D_Color32(0x9E, 0x00, 0x1E, 0xFF) +#define COL_BG C2D_Color32(0x16, 0x19, 0x1F, 0xFF) +#define COL_BG2 C2D_Color32(0x22, 0x26, 0x2E, 0xFF) +#define COL_CARD C2D_Color32(0x2A, 0x2F, 0x39, 0xFF) +#define COL_CARD_HI C2D_Color32(0x3A, 0x41, 0x4E, 0xFF) +#define COL_WHITE C2D_Color32(0xFF, 0xFF, 0xFF, 0xFF) +#define COL_MUTED C2D_Color32(0x9A, 0xA3, 0xAF, 0xFF) +#define COL_GREEN C2D_Color32(0x22, 0xC5, 0x5E, 0xFF) +#define COL_AMBER C2D_Color32(0xF5, 0x9E, 0x0B, 0xFF) +#define COL_NINTENDO C2D_Color32(0xE6, 0x00, 0x12, 0xFF) + +void ui_text(const char *s, float x, float y, float size, u32 color, u32 flags); +void ui_text_wrap(const char *s, float x, float y, float size, u32 color, float width); +float ui_text_width(const char *s, float size); +// Text auf eine Breite kürzen ("...") +void ui_fit(char *out, size_t len, const char *s, float size, float width); +void ui_round_rect(float x, float y, float w, float h, float r, u32 color);