refactoring
CI / Build NRO (push) Successful in 28s
CI / Format check (push) Successful in 33s
CI / Layering check (push) Successful in 1s

Co-authored-by: n.fedorov <mail@nfedorov.dev>
Co-committed-by: n.fedorov <mail@nfedorov.dev>
This commit was merged in pull request #1.
This commit is contained in:
2026-05-14 23:34:13 +03:00
committed by DragonSpirit
parent 844093e3e7
commit 1111f691c6
81 changed files with 3685 additions and 3036 deletions
+163
View File
@@ -0,0 +1,163 @@
# NXST Architecture
## Layer Diagram
```
┌──────────────────────────────┐
│ ui/ │
│ TitlesLayout, UsersLayout │
│ TransferOverlay, HeaderBar │
└──────────────┬───────────────┘
│ calls
┌──────────────▼───────────────┐
│ service/ │
│ TransferService │
└──────┬───────────────┬───────┘
│ │
┌────────────▼──┐ ┌───────▼────────┐
│ infra/net/ │ │ infra/fs/ │
│ TransferService│ │ io, directory │
│ (sender thread │ │ filesystem │
│ recv threads) │ │ handles │
└────────────┬──┘ └───────┬────────┘
│ │
┌────────────▼───────────────▼────────┐
│ domain/ │
│ Title, Account, TransferState │
│ Result<T>, protocol constants │
└─────────────────────────────────────┘
┌────────────────────▼────────────────┐
│ libnx, Plutonium, SDL2, devkitpro│
└─────────────────────────────────────┘
```
**Hard layering rules:**
- `domain/` depends on nothing in the project.
- `infra/` depends only on `domain/`.
- `service/` depends on `domain/` + `infra/`.
- `ui/` depends only on `service/` + `domain/` — must not include `<arpa/inet.h>`, `<sys/socket.h>`, `<pthread.h>`, or call `recv`/`send` directly.
- `app/` (MainApplication, main.cpp) wires everything together.
---
## Key Types
### `nxst::TransferService` (`include/nxst/service/transfer_service.hpp`)
Owns all network state for both transfer directions. One instance lives in `MainApplication`.
| Method | Description |
|--------|-------------|
| `startSend(index, uid)` | Backup save, discover receiver, stream files |
| `startReceive(index, uid, title_name)` | Listen for sender, receive files, restore save |
| `cancelSend()` / `cancelReceive()` | Interrupt in-flight transfer (socket shutdown) |
| `isSendDone()` / `isReceiveDone()` | Polled by UI render loop |
| `restoreSucceeded()` / `restoreError()` | Restore outcome after receive |
**Threading model:**
```
UI thread (Plutonium event loop)
└─ startSend()
└─ senderEntry [pthread, detached]
├─ findServer() — UDP multicast, 100 ms poll slices
├─ io::backup() — creates local save backup
└─ TCP send loop
└─ startReceive()
├─ broadcastEntry [pthread, detached] — UDP multicast listener
└─ acceptEntry [pthread, detached]
├─ TCP receive loop
└─ io::restore() — mounts save, clears, copies, commits
```
Cancellation: `cancelSend()` / `cancelReceive()` call `shutdown(SHUT_RDWR)` on all open sockets. Blocking `read`/`recvfrom`/`accept` return errors immediately. Atomic flags are checked at loop boundaries.
### `nxst::Result<T, E>` (`include/nxst/domain/result.hpp`)
85-line tagged-union result type. No exceptions, no `std::variant`. Used for `io::backup` and `io::restore` return values.
```cpp
auto result = io::backup(title_index, uid);
if (!result.isOk()) {
failSend("Backup failed: " + result.error());
return;
}
fs::path dir = result.value();
```
`Result<void, E>` specialisation available for operations that succeed without a value.
### `nxst::FsFileSystemHandle` / `nxst::FileHandle` (`include/nxst/infra/fs/handles.hpp`)
RAII wrappers ensuring `fsFsClose` / `fclose` are called on all exit paths.
---
## Threading Invariants
- All `std::atomic` members in `TransferService` use sequential-consistency (default). No explicit `memory_order` needed at this concurrency level.
- `TransferState::status` is a `std::string` protected by `std::mutex status_mutex`. All other `TransferState` fields are atomics.
- `pthread_create` + `pthread_detach` is used throughout (libnx's supported path). Threads are never joined — they signal completion via `state.done = true` and set their active flag to false.
- Cancel is safe to call from any thread.
---
## File Map
```
include/nxst/
├── app/
│ ├── main.hpp — global state (g_currentTime, g_sortMode, …)
│ └── main_application.hpp — MainApplication : pu::ui::Application
├── domain/
│ ├── account.hpp — AccountUid, Account::ids(), Account::username()
│ ├── common.hpp — StringUtils (UTF-8/16, elide, accents)
│ ├── protocol.hpp — wire constants (ports, sentinel, buffer size)
│ ├── result.hpp — Result<T, E> tagged union
│ ├── title.hpp — Title, getTitle(), getTitleCount()
│ ├── transfer_state.hpp — TransferState (atomics + mutex-guarded status)
│ └── util.hpp — StringUtils helpers, blinkLed
├── infra/
│ ├── fs/
│ │ ├── directory.hpp — Directory iterator (libnx FsDir)
│ │ ├── filesystem.hpp — FileSystem::mount/unmount wrappers
│ │ ├── handles.hpp — FsFileSystemHandle, FileHandle (RAII)
│ │ └── io.hpp — io::backup, io::restore, io::copyFile, …
│ ├── net/
│ │ └── socket.hpp — Socket RAII wrapper (int fd)
│ └── sys/
│ └── logger.hpp — nxst::log::info/warn/error (printf-checked)
├── service/
│ └── transfer_service.hpp — TransferService
└── ui/
├── card.hpp — Card UI component
├── const.h — layout/color/font constants
├── header_bar.hpp — HeaderBar (title + user avatar row)
├── hint_bar.hpp — HintBar (button legend)
├── theme.hpp — color, radius, spacing, type tokens
├── titles_layout.hpp — TitlesLayout
├── transfer_overlay.hpp — TransferOverlay (progress modal)
├── ui_context.hpp — UiContext (selected user state)
└── users_layout.hpp — UsersLayout
```
---
## Build
```bash
# Configure (once — toolchain preset reads $DEVKITPRO automatically)
cmake --preset switch
# Build
cmake --build build
# Send to Switch via nxlink
cmake --build build --target send
```
Output: `build/NXST.nro`
See `README.md` for full build prerequisites.
+95
View File
@@ -0,0 +1,95 @@
# NXST Wire Protocol
## Overview
NXST uses two sockets per transfer session:
| Socket | Purpose |
|--------|---------|
| UDP multicast | Receiver advertisement (discovery) |
| TCP | File data stream |
Both sides must be on the same local network segment. The sender (Transfer mode) initiates; the receiver (Receive mode) listens.
---
## Discovery — UDP Multicast
| Parameter | Value |
|-----------|-------|
| Group | `239.0.0.1` |
| Port | `8081` |
| Direction | sender → receiver |
**Flow:**
1. Receiver joins multicast group and binds `0.0.0.0:8081`.
2. Sender sends `"DISCOVER_SERVER"` (15 bytes, no null terminator) to `239.0.0.1:8081`.
3. Receiver replies `"SERVER_HERE"` (11 bytes) to the sender's source address.
4. Sender extracts the receiver's IP from the reply source and closes the UDP socket.
Sender polls in 100 ms slices for up to 3 seconds. Cancel is checked each slice.
---
## File Transfer — TCP
| Parameter | Value |
|-----------|-------|
| Port | `8080` |
| Direction | sender connects → receiver listens |
| Buffer size | 65 536 bytes (`proto::kBufSize`) |
**Connection:**
1. Receiver listens on `0.0.0.0:8080` (started concurrently with multicast listener).
2. Sender connects after receiving `"SERVER_HERE"`.
**Wire layout — one file:**
```
┌─────────────────────────────────┐
│ filename_len │ uint32_t LE │ 4 bytes
├─────────────────────────────────┤
│ filename │ filename_len │ bytes, no null terminator
│ │ bytes │
├─────────────────────────────────┤
│ file_size │ uint64_t LE │ 8 bytes
├─────────────────────────────────┤
│ file_data │ file_size │ bytes
│ │ bytes │
└─────────────────────────────────┘
```
Files are sent sequentially. The stream ends with a sentinel frame:
```
filename_len == 0 (proto::kEofSentinel)
```
No `filename` or `file_size` field follows the sentinel.
**Constraints:**
- `filename_len > proto::kMaxFilename` (4 096) is treated as a protocol error; the receiver aborts.
- Filenames are full paths as produced by `io::backup` (e.g. `/switch/NXST/<title>/<user>/...`).
- On the receiver, the username path component is rewritten to match the local user's nickname before writing to disk.
---
## Post-Transfer
After the TCP stream closes (sentinel received), the receiver calls `io::restore`:
1. Mounts the title's save filesystem.
2. Clears existing save data.
3. Copies received files into `save:/`.
4. Commits via `fsdevCommitDevice("save")`.
The sender creates a local backup via `io::backup` before connecting, so the sender's own save is never at risk.
---
## Cancellation
Either side can cancel at any time by closing the relevant socket (`shutdown` + `close`). The other side's blocking read/write will return an error and the transfer loop exits cleanly. The receiver's accept thread sets `receiver_state.done = true` regardless of how the connection ends, so the UI poll loop always terminates.