From 1111f691c6acfd6f00a4ccac691c876be0ab9953 Mon Sep 17 00:00:00 2001 From: "n.fedorov" Date: Thu, 14 May 2026 23:34:13 +0300 Subject: [PATCH] refactoring Co-authored-by: n.fedorov Co-committed-by: n.fedorov --- .clang-format | 29 + .clang-tidy | 47 ++ .editorconfig | 22 + .gitattributes | 10 + .gitea/workflows/build.yml | 79 +++ .github/workflows/build.yml | 76 +++ .gitignore | 21 +- .gitmodules | 3 - CHANGELOG.md | 34 ++ CMakeLists.txt | 121 ++++ CMakePresets.json | 17 + LICENSE | 30 + Makefile | 204 ------- PLAN.md | 434 ++++++++++++++ README.md | 88 +++ deps/asprintf/asprintf.c | 37 -- deps/asprintf/asprintf.h | 10 - deps/asprintf/clib.json | 9 - docs/ARCHITECTURE.md | 163 ++++++ docs/PROTOCOL.md | 95 +++ include/MainApplication.hpp | 21 - include/account.hpp | 76 --- include/client.hpp | 13 - include/common.hpp | 65 --- include/const.h | 6 - include/directory.hpp | 58 -- include/filesystem.hpp | 39 -- include/io.hpp | 55 -- include/logger.hpp | 85 --- include/main.hpp | 26 - include/net/Socket.hpp | 18 - include/nxst/app/main_application.hpp | 23 + include/nxst/domain/account.hpp | 36 ++ include/nxst/domain/protocol.hpp | 16 + include/nxst/domain/result.hpp | 108 ++++ include/nxst/domain/title.hpp | 58 ++ .../domain/transfer_state.hpp} | 18 +- include/nxst/domain/util.hpp | 22 + include/nxst/infra/fs/directory.hpp | 32 ++ include/nxst/infra/fs/filesystem.hpp | 9 + include/nxst/infra/fs/handles.hpp | 71 +++ include/nxst/infra/fs/io.hpp | 19 + include/nxst/infra/net/socket.hpp | 29 + include/nxst/infra/sys/logger.hpp | 14 + include/nxst/service/transfer_service.hpp | 113 ++++ .../HeaderBar.hpp => nxst/ui/header_bar.hpp} | 24 +- .../{ui/HintBar.hpp => nxst/ui/hint_bar.hpp} | 27 +- include/{Theme.hpp => nxst/ui/theme.hpp} | 19 +- .../ui/titles_layout.hpp} | 35 +- .../ui/transfer_overlay.hpp} | 46 +- .../ui/users_layout.hpp} | 18 +- include/protocol.hpp | 17 - include/server.hpp | 8 - include/title.hpp | 91 --- include/ui/Card.hpp | 18 - include/ui/UiContext.hpp | 12 - include/util.hpp | 53 -- lib | 1 - source/MainApplication.cpp | 21 - source/account.cpp | 142 ----- source/client.cpp | 249 -------- source/common.cpp | 124 ---- source/directory.cpp | 76 --- source/filesystem.cpp | 42 -- source/io.cpp | 333 ----------- source/server.cpp | 345 ----------- source/title.cpp | 323 ----------- source/util.cpp | 175 ------ source/Main.cpp => src/app/main.cpp | 162 +++--- src/app/main_application.cpp | 20 + src/domain/account.cpp | 95 +++ src/domain/title.cpp | 243 ++++++++ src/domain/util.cpp | 250 ++++++++ src/infra/fs/directory.cpp | 27 + src/infra/fs/filesystem.cpp | 14 + src/infra/fs/io.cpp | 319 +++++++++++ src/infra/sys/logger.cpp | 90 +++ src/service/transfer_service.cpp | 540 ++++++++++++++++++ .../ui/titles_layout.cpp | 160 +++--- .../ui/users_layout.cpp | 37 +- tools/format.sh | 6 + 81 files changed, 3685 insertions(+), 3036 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitea/workflows/build.yml create mode 100644 .github/workflows/build.yml delete mode 100644 .gitmodules create mode 100644 CHANGELOG.md create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 LICENSE delete mode 100644 Makefile create mode 100644 PLAN.md create mode 100644 README.md delete mode 100644 deps/asprintf/asprintf.c delete mode 100644 deps/asprintf/asprintf.h delete mode 100644 deps/asprintf/clib.json create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PROTOCOL.md delete mode 100644 include/MainApplication.hpp delete mode 100644 include/account.hpp delete mode 100644 include/client.hpp delete mode 100644 include/common.hpp delete mode 100644 include/const.h delete mode 100644 include/directory.hpp delete mode 100644 include/filesystem.hpp delete mode 100644 include/io.hpp delete mode 100644 include/logger.hpp delete mode 100644 include/main.hpp delete mode 100644 include/net/Socket.hpp create mode 100644 include/nxst/app/main_application.hpp create mode 100644 include/nxst/domain/account.hpp create mode 100644 include/nxst/domain/protocol.hpp create mode 100644 include/nxst/domain/result.hpp create mode 100644 include/nxst/domain/title.hpp rename include/{TransferState.hpp => nxst/domain/transfer_state.hpp} (70%) create mode 100644 include/nxst/domain/util.hpp create mode 100644 include/nxst/infra/fs/directory.hpp create mode 100644 include/nxst/infra/fs/filesystem.hpp create mode 100644 include/nxst/infra/fs/handles.hpp create mode 100644 include/nxst/infra/fs/io.hpp create mode 100644 include/nxst/infra/net/socket.hpp create mode 100644 include/nxst/infra/sys/logger.hpp create mode 100644 include/nxst/service/transfer_service.hpp rename include/{ui/HeaderBar.hpp => nxst/ui/header_bar.hpp} (81%) rename include/{ui/HintBar.hpp => nxst/ui/hint_bar.hpp} (72%) rename include/{Theme.hpp => nxst/ui/theme.hpp} (91%) rename include/{TitlesLayout.hpp => nxst/ui/titles_layout.hpp} (77%) rename include/{TransferOverlay.hpp => nxst/ui/transfer_overlay.hpp} (64%) rename include/{UsersLayout.hpp => nxst/ui/users_layout.hpp} (81%) delete mode 100644 include/protocol.hpp delete mode 100644 include/server.hpp delete mode 100644 include/title.hpp delete mode 100644 include/ui/Card.hpp delete mode 100644 include/ui/UiContext.hpp delete mode 100644 include/util.hpp delete mode 160000 lib delete mode 100644 source/MainApplication.cpp delete mode 100644 source/account.cpp delete mode 100644 source/client.cpp delete mode 100644 source/common.cpp delete mode 100644 source/directory.cpp delete mode 100644 source/filesystem.cpp delete mode 100644 source/io.cpp delete mode 100644 source/server.cpp delete mode 100644 source/title.cpp delete mode 100644 source/util.cpp rename source/Main.cpp => src/app/main.cpp (69%) create mode 100644 src/app/main_application.cpp create mode 100644 src/domain/account.cpp create mode 100644 src/domain/title.cpp create mode 100644 src/domain/util.cpp create mode 100644 src/infra/fs/directory.cpp create mode 100644 src/infra/fs/filesystem.cpp create mode 100644 src/infra/fs/io.cpp create mode 100644 src/infra/sys/logger.cpp create mode 100644 src/service/transfer_service.cpp rename source/TitlesLayout.cpp => src/ui/titles_layout.cpp (65%) rename source/UsersLayout.cpp => src/ui/users_layout.cpp (63%) create mode 100755 tools/format.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..9fcdcf4 --- /dev/null +++ b/.clang-format @@ -0,0 +1,29 @@ +--- +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 110 +PointerAlignment: Left +AlignAfterOpenBracket: Align +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: Empty +BreakBeforeBraces: Attach +SortIncludes: true +IncludeBlocks: Regroup +IncludeCategories: + # Project headers: nxst/ + - Regex: '^(<|")(nxst/)' + Priority: 3 + SortPriority: 3 + # Third-party: Plutonium, libnx, SDL, switch.h + - Regex: '^(<|")(pu/|switch\.h|libnx|SDL|freetype|harfbuzz|zlib)' + Priority: 2 + SortPriority: 2 + # System / C++ standard library + - Regex: '^<' + Priority: 1 + SortPriority: 1 +SpacesBeforeTrailingComments: 2 +Cpp11BracedListStyle: true +Standard: c++17 +NamespaceIndentation: All diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..d397413 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,47 @@ +--- +Checks: > + bugprone-*, + readability-*, + modernize-*, + cppcoreguidelines-pro-type-cstyle-cast, + -fuchsia-*, + -google-*, + -llvm-*, + -readability-magic-numbers, + -readability-named-parameter, + -modernize-use-trailing-return-type, + -modernize-use-nodiscard, + -modernize-avoid-c-arrays, + -bugprone-easily-swappable-parameters + +WarningsAsErrors: '' + +HeaderFilterRegex: 'include/nxst/.*' + +CheckOptions: + - key: readability-identifier-naming.ClassCase + value: CamelCase + - key: readability-identifier-naming.StructCase + value: CamelCase + - key: readability-identifier-naming.FunctionCase + value: camelBack + - key: readability-identifier-naming.MethodCase + value: camelBack + - key: readability-identifier-naming.VariableCase + value: lower_case + - key: readability-identifier-naming.ParameterCase + value: lower_case + - key: readability-identifier-naming.MemberCase + value: lower_case + - key: readability-identifier-naming.NamespaceCase + value: lower_case + - key: readability-identifier-naming.ConstexprVariablePrefix + value: 'k' + - key: readability-identifier-naming.ConstexprVariableCase + value: CamelCase + - key: readability-identifier-naming.EnumCase + value: CamelCase + - key: readability-identifier-naming.EnumConstantCase + value: CamelCase + - key: readability-braces-around-statements.ShortStatementLines + value: '2' diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c1bae59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cpp,hpp,h,c}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.{json,md}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d41ff5e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize line endings +* text=auto eol=lf + +# Binary assets +*.png binary +*.jpg binary +*.nro binary +*.nso binary +*.pfs0 binary +*.elf binary diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e6ae9f5 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + pull_request: + +jobs: + nro: + name: Build NRO + runs-on: ubuntu-latest + + steps: + - name: Build + run: | + AUTH_URL=$(echo "$GITHUB_SERVER_URL" | sed "s|://|://oauth2:${GITHUB_TOKEN}@|") + docker rm -f nxst-build 2>/dev/null || true + docker run --name nxst-build devkitpro/devkita64:latest \ + bash -c " + git clone --recurse-submodules '${AUTH_URL}/${GITHUB_REPOSITORY}' /src && + cd /src && git checkout '${GITHUB_SHA}' && + cmake --preset switch && + cmake --build build -j\$(nproc) + " + docker cp nxst-build:/src/build/NXST.nro ./NXST.nro + docker rm nxst-build + + - name: Publish release + if: startsWith(github.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + release_id=$(curl -sf -X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${GITHUB_REF_NAME}\", \"name\": \"${GITHUB_REF_NAME}\"}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + + curl -sf -X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=NXST.nro" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @./NXST.nro + + format: + name: Format check + runs-on: ubuntu-latest + + steps: + - name: Checkout + run: | + AUTH_URL=$(echo "$GITHUB_SERVER_URL" | sed "s|://|://oauth2:${GITHUB_TOKEN}@|") + git clone "${AUTH_URL}/${GITHUB_REPOSITORY}" . + git checkout "$GITHUB_SHA" + + - name: Install clang-format + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm.list + sudo apt-get update && sudo apt-get install -y clang-format-22 + sudo ln -sf /usr/bin/clang-format-22 /usr/local/bin/clang-format + + - name: Check formatting + run: | + find src include \( -name '*.cpp' -o -name '*.hpp' \) \ + | xargs clang-format --dry-run --Werror + + layering: + name: Layering check + runs-on: ubuntu-latest + + steps: + - name: Checkout + run: | + AUTH_URL=$(echo "$GITHUB_SERVER_URL" | sed "s|://|://oauth2:${GITHUB_TOKEN}@|") + git clone "${AUTH_URL}/${GITHUB_REPOSITORY}" . + git checkout "$GITHUB_SHA" + + - name: UI must not include net/sys headers + run: | + ! grep -rE '^#include\s*[<"](arpa/inet|sys/socket|pthread)' src/ui/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d7123e2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + pull_request: + +jobs: + nro: + name: Build NRO + runs-on: ubuntu-latest + container: + image: devkitpro/devkita64:latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Configure + run: cmake --preset switch + + - name: Build + run: cmake --build build -j$(nproc) + + - uses: actions/upload-artifact@v4 + with: + name: NXST-${{ github.sha }} + path: build/NXST.nro + + release: + name: Upload release asset + runs-on: ubuntu-latest + needs: nro + if: startsWith(github.ref, 'refs/tags/v') + + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v4 + with: + name: NXST-${{ github.sha }} + + - uses: softprops/action-gh-release@v2 + with: + files: NXST.nro + + format: + name: Format check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install clang-format + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm.list + sudo apt-get update && sudo apt-get install -y clang-format-22 + sudo ln -sf /usr/bin/clang-format-22 /usr/local/bin/clang-format + + - name: Check formatting + run: | + find src include \( -name '*.cpp' -o -name '*.hpp' \) \ + | xargs clang-format --dry-run --Werror + + layering: + name: Layering check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: UI must not include net/sys headers + run: | + ! grep -rE '^#include\s*[<"](arpa/inet|sys/socket|pthread)' src/ui/ diff --git a/.gitignore b/.gitignore index 4d54a32..5c2b8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .DS_Store +.serena +.idea/ # Prerequisites *.d @@ -32,5 +34,20 @@ *.exe *.out *.app -server -client + +# Switch build artifacts +*.nro +*.nso +*.pfs0 +*.nacp +*.elf +*.lst +*.map + +# CMake +build/ +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +compile_commands.json +.cache/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 4312018..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "lib"] - path = lib - url = https://github.com/XorTroll/Plutonium.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e12dae0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to NXST follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Version numbers are calendar-based (`MM.DD.YYYY`) until the protocol stabilises. + +--- + +## [Unreleased] + +### Changed +- Full architectural refactor (8 phases): + - Layered directory structure: `src/` + `include/nxst/` mirroring domain / infra / service / ui + - CMake build system replacing Makefile + - `nxst::TransferService` — all network state and threads moved out of UI layer + - `nxst::Result` tagged-union type replacing `std::tuple` + - RAII handles: `FsFileSystemHandle`, `FileHandle` + - Logger rewritten with `nxst::log` namespace and compile-time format checking + - snake_case filenames and identifiers throughout + +### Fixed +- `mkdir(path, 777)` was decimal (octal `01411`) — corrected to `0777` +- Logger format args were silently dropped (double-substitution bug) +- `new u8[]` / `malloc` in IO paths replaced with `std::vector` + +--- + +## [04.26.2026] + +### Added +- Initial public release: save transfer between two Nintendo Switch consoles over local Wi-Fi +- Multicast discovery (UDP `239.0.0.1:8081`) + TCP file transfer (`:8080`) +- Send and Receive modes with progress overlay and cancel support +- Automatic save backup before transfer +- Restore replaces live save data and commits to the save device diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..43f8420 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,121 @@ +cmake_minimum_required(VERSION 3.20) +project(NXST + LANGUAGES CXX + VERSION 0.1.0 +) + +# ── C++ standard and flags ──────────────────────────────────────────────────── +# Arch/linker/libnx flags are already injected by the Switch.cmake toolchain. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS ON) # gnu++17 + +add_compile_options( + -fno-rtti + -fno-exceptions + -O2 + -g + -D_GNU_SOURCE=1 +) + +# Export compilation database (enables clangd / clang-tidy on the host) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ── Sources ─────────────────────────────────────────────────────────────────── +file(GLOB_RECURSE NXST_SOURCES + src/app/*.cpp + src/domain/*.cpp + src/infra/net/*.cpp + src/infra/fs/*.cpp + src/infra/sys/*.cpp + src/service/*.cpp + src/ui/*.cpp +) + +add_executable(NXST ${NXST_SOURCES}) + +# ── Include paths ───────────────────────────────────────────────────────────── +target_include_directories(NXST PRIVATE include) + +# ── pkg-config (uses aarch64-none-elf-pkg-config set by Switch.cmake) ───────── +find_package(PkgConfig REQUIRED) + +set(NXST_PKG_MODULES + SDL2_ttf SDL2_gfx SDL2_image SDL2_mixer + freetype2 harfbuzz minizip libpng libjpeg libwebp + glesv2 egl glapi zlib +) +pkg_check_modules(PORTLIBS REQUIRED IMPORTED_TARGET ${NXST_PKG_MODULES}) + +target_include_directories(NXST PRIVATE ${PORTLIBS_INCLUDE_DIRS}) + +# ── Plutonium ───────────────────────────────────────────────────────────────── +include(ExternalProject) + +set(PLUTONIUM_SOURCE_DIR ${CMAKE_BINARY_DIR}/plutonium) +set(LIBPU_A ${PLUTONIUM_SOURCE_DIR}/Plutonium/lib/libpu.a) + +ExternalProject_Add(plutonium_ep + GIT_REPOSITORY https://github.com/XorTroll/Plutonium.git + GIT_TAG b56564b70d038c59eef875f2c3cf436859c827f2 + GIT_PROGRESS ON + SOURCE_DIR ${PLUTONIUM_SOURCE_DIR} + CONFIGURE_COMMAND "" + BUILD_COMMAND make -j -C ${PLUTONIUM_SOURCE_DIR}/Plutonium + INSTALL_COMMAND "" + BUILD_IN_SOURCE 1 + BUILD_BYPRODUCTS ${LIBPU_A} +) + +add_library(plutonium STATIC IMPORTED GLOBAL) +add_dependencies(plutonium plutonium_ep) +set_target_properties(plutonium PROPERTIES + IMPORTED_LOCATION ${LIBPU_A} +) +target_include_directories(NXST PRIVATE ${PLUTONIUM_SOURCE_DIR}/Plutonium/include) + +# ── Link libraries ──────────────────────────────────────────────────────────── +# Order matters for static linking: put most dependent libs first. +# drm_nouveau, harfbuzz, freetype, z appended explicitly after pkg-config output +# to fix the freetype→harfbuzz static link order (see build notes from libnx update). +target_link_libraries(NXST PRIVATE + plutonium + PkgConfig::PORTLIBS + drm_nouveau + harfbuzz + freetype + z +) + +# ── NACP + NRO ──────────────────────────────────────────────────────────────── +set(NXST_NACP ${CMAKE_CURRENT_BINARY_DIR}/NXST.nacp) + +nx_generate_nacp( + OUTPUT ${NXST_NACP} + NAME "NXST" + AUTHOR "DragonSpirit" + VERSION "04.26.2026" +) + +nx_create_nro(NXST + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/NXST.nro + ICON ${CMAKE_SOURCE_DIR}/icon.png + NACP ${NXST_NACP} +) + +# ── Convenience targets ──────────────────────────────────────────────────────── +find_program(NXLINK_EXE nxlink HINTS ${DEVKITPRO}/tools/bin) + +if(NXLINK_EXE) + add_custom_target(send + COMMAND ${NXLINK_EXE} ${CMAKE_CURRENT_BINARY_DIR}/NXST.nro + DEPENDS NXST_nro + COMMENT "Sending NXST.nro via nxlink" + ) + + add_custom_target(debug + COMMAND ${NXLINK_EXE} -s ${CMAKE_CURRENT_BINARY_DIR}/NXST.nro + DEPENDS NXST_nro + COMMENT "Sending NXST.nro with stdio bridge" + ) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..7a816f8 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,17 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "switch", + "displayName": "Nintendo Switch", + "toolchainFile": "$env{DEVKITPRO}/cmake/Switch.cmake", + "binaryDir": "${sourceDir}/build" + } + ], + "buildPresets": [ + { + "name": "switch", + "configurePreset": "switch" + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fa6ac14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + For the full license text see . + +------------------------------------------------------------------------------- + +NXST includes code derived from Checkpoint by Bernardo Giordano / FlagBrew, +licensed under GPLv3. The derived files are: + + src/infra/fs/io.cpp + src/domain/account.cpp + src/domain/title.cpp + include/nxst/infra/fs/filesystem.hpp + src/infra/fs/filesystem.cpp + src/infra/fs/directory.cpp + +All original NXST code is also released under the GNU General Public +License v3 or later, to satisfy the license inheritance requirement. + +Original Checkpoint repository: https://github.com/BernardoGiordano/Checkpoint diff --git a/Makefile b/Makefile deleted file mode 100644 index 356daa7..0000000 --- a/Makefile +++ /dev/null @@ -1,204 +0,0 @@ -#--------------------------------------------------------------------------------- -.SUFFIXES: -#--------------------------------------------------------------------------------- - -ifeq ($(strip $(DEVKITPRO)),) -$(error "Please set DEVKITPRO in your environment. export DEVKITPRO=/devkitpro") -endif - -TOPDIR ?= $(CURDIR) -include $(DEVKITPRO)/libnx/switch_rules - -#--------------------------------------------------------------------------------- -# TARGET is the name of the output -# BUILD is the directory where object files & intermediate files will be placed -# SOURCES is a list of directories containing source code -# DATA is a list of directories containing data files -# INCLUDES is a list of directories containing header files -# EXEFS_SRC is the optional input directory containing data copied into exefs, if anything this normally should only contain "main.npdm". -# ROMFS is the directory containing data to be added to RomFS, relative to the Makefile (Optional) -# -# NO_ICON: if set to anything, do not use icon. -# NO_NACP: if set to anything, no .nacp file is generated. -# APP_TITLE is the name of the app stored in the .nacp file (Optional) -# APP_AUTHOR is the author of the app stored in the .nacp file (Optional) -# APP_VERSION is the version of the app stored in the .nacp file (Optional) -# APP_TITLEID is the titleID of the app stored in the .nacp file (Optional) -# ICON is the filename of the icon (.jpg), relative to the project folder. -# If not set, it attempts to use one of the following (in this order): -# - .jpg -# - icon.jpg -# - /default_icon.jpg -#--------------------------------------------------------------------------------- -TARGET := NXST -BUILD := build -SOURCES := source lib/Plutonium/source -INCLUDES := include include/net lib/Plutonium/include -EXEFS_SRC := exefs_src -APP_TITLE := NXST -APP_AUTHOR := DragonSpirit -APP_VERSION := 04.26.2026 -ICON := icon.png - -#--------------------------------------------------------------------------------- -# options for code generation -#--------------------------------------------------------------------------------- -ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE - -CFLAGS += -g -O2 -ffunction-sections $(ARCH) $(DEFINES) - -CFLAGS += $(INCLUDE) -D__SWITCH__ -D_GNU_SOURCE=1 - -CXXFLAGS:= $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++17 -g - -ASFLAGS := -g $(ARCH) -LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) - -PKGCONF := $(DEVKITPRO)/portlibs/switch/bin/aarch64-none-elf-pkg-config -PKG_LIBS := SDL2_ttf SDL2_gfx SDL2_image SDL2_mixer freetype2 harfbuzz minizip libpng libjpeg libwebp glesv2 egl glapi zlib -LIBS := -lpu $(shell $(PKGCONF) --libs $(PKG_LIBS)) -ldrm_nouveau -lharfbuzz -lfreetype -lz - -#--------------------------------------------------------------------------------- -# list of directories containing libraries, this must be the top level containing -# include and lib -#--------------------------------------------------------------------------------- -LIBDIRS := $(PORTLIBS) $(LIBNX) $(CURDIR)/lib/Plutonium - - -#--------------------------------------------------------------------------------- -# no real need to edit anything past this point unless you need to add additional -# rules for different file extensions -#--------------------------------------------------------------------------------- -ifneq ($(BUILD),$(notdir $(CURDIR))) -#--------------------------------------------------------------------------------- - -export OUTPUT := $(CURDIR)/$(TARGET) -export TOPDIR := $(CURDIR) - -export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \ - $(foreach dir,$(DATA),$(CURDIR)/$(dir)) - -export DEPSDIR := $(CURDIR)/$(BUILD) - -CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) -CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) -SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) -BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*))) - -#--------------------------------------------------------------------------------- -# use CXX for linking C++ projects, CC for standard C -#--------------------------------------------------------------------------------- -ifeq ($(strip $(CPPFILES)),) -#--------------------------------------------------------------------------------- - export LD := $(CC) -#--------------------------------------------------------------------------------- -else -#--------------------------------------------------------------------------------- - export LD := $(CXX) -#--------------------------------------------------------------------------------- -endif -#--------------------------------------------------------------------------------- - -export OFILES_BIN := $(addsuffix .o,$(BINFILES)) -export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) -export OFILES := $(OFILES_BIN) $(OFILES_SRC) -export HFILES_BIN := $(addsuffix .h,$(subst .,_,$(BINFILES))) - -export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ - $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ - -I$(CURDIR)/$(BUILD) - -export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) - -export BUILD_EXEFS_SRC := $(TOPDIR)/$(EXEFS_SRC) - -ifeq ($(strip $(ICON)),) - icons := $(wildcard *.jpg) - ifneq (,$(findstring $(TARGET).jpg,$(icons))) - export APP_ICON := $(TOPDIR)/$(TARGET).jpg - else - ifneq (,$(findstring icon.jpg,$(icons))) - export APP_ICON := $(TOPDIR)/icon.jpg - endif - endif -else - export APP_ICON := $(TOPDIR)/$(ICON) -endif - -ifeq ($(strip $(NO_ICON)),) - export NROFLAGS += --icon=$(APP_ICON) -endif - -ifeq ($(strip $(NO_NACP)),) - export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp -endif - -ifneq ($(APP_TITLEID),) - export NACPFLAGS += --titleid=$(APP_TITLEID) -endif - -ifneq ($(ROMFS),) - export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS) -endif - -.PHONY: $(BUILD) clean all - -#--------------------------------------------------------------------------------- -all: $(BUILD) - -$(BUILD): - @[ -d $@ ] || mkdir -p $@ - @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile - -#--------------------------------------------------------------------------------- -clean: - @echo clean ... - @rm -fr $(BUILD) $(TARGET).pfs0 $(TARGET).nso $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst - -cleanbuild: clean all - -#--------------------------------------------------------------------------------- -send: $(BUILD) - @nxlink $(TARGET).nro - -debug: $(BUILD) - @nxlink -s $(TARGET).nro - - -else -.PHONY: all - -DEPENDS := $(OFILES:.o=.d) - -#--------------------------------------------------------------------------------- -# main targets -#--------------------------------------------------------------------------------- -all : $(OUTPUT).pfs0 $(OUTPUT).nro - -$(OUTPUT).pfs0 : $(OUTPUT).nso - -$(OUTPUT).nso : $(OUTPUT).elf - -ifeq ($(strip $(NO_NACP)),) -$(OUTPUT).nro : $(OUTPUT).elf $(OUTPUT).nacp -else -$(OUTPUT).nro : $(OUTPUT).elf -endif - -$(OUTPUT).elf : $(OFILES) - -$(OFILES_SRC) : $(HFILES_BIN) - -#--------------------------------------------------------------------------------- -# you need a rule like this for each extension you use as binary data -#--------------------------------------------------------------------------------- -%.bin.o %_bin.h : %.bin -#--------------------------------------------------------------------------------- - @echo $(notdir $<) - @$(bin2o) - --include $(DEPENDS) - -#--------------------------------------------------------------------------------------- -endif -#--------------------------------------------------------------------------------------- diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e93e5e7 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,434 @@ +# NXST Architectural Refactor Plan + +## Current Phase Tracker + +| Phase | Title | Status | Effort | Notes | +|-------|-------|--------|--------|-------| +| 0 | Tooling & ground rules | ✅ Done | S (~2h) | `.clang-format`, `.clang-tidy`, `.editorconfig`, `.gitattributes` | +| 1 | Bug fixes & dead code | ✅ Done | S (~3h) | logger rewrite, `mkdir 0777`, RU comment, dead code | +| 2 | File renames + `#pragma once` | ✅ Done | S (~2h) | snake_case filenames, unify guards | +| 3 | Directory restructure | ✅ Done | M (~1d) | `src/` + `include/nxst/` layered tree | +| 4 | Make → CMake migration | ✅ Done | M (~1d) | devkitpro `Switch.cmake` toolchain | +| 5 | TransferService extraction | ✅ Done | L (~2d) | kill globals, sever UI ↔ net coupling | +| 6 | `Result` + RAII | ✅ Done | M (~1d) | tagged union, OS handle wrappers, fix raw memory | +| 7 | Documentation + license | ✅ Done | S (~half-day) | README, ARCHITECTURE, PROTOCOL, CHANGELOG, GPLv3 LICENSE | +| 8 | CI | ✅ Done | S (~2h) | GitHub Actions, `.nro` artifact, format check, layering check | + +**Active phase:** — All phases complete. +**Last updated:** 2026-04-27. + +Mark a phase `🟡 In progress` when starting and `✅ Done` when verified on hardware. Keep this table the source of truth. + +--- + +## Context + +NXST is a Nintendo Switch homebrew save-transfer app (~3.2K LOC across 13 .cpp + 24 .hpp files, plus the vendored Plutonium UI submodule). The current codebase works but reads as a prototype: + +- Flat `source/` + mostly flat `include/` with mixed `PascalCase` and `lowercase` filenames. +- Mixed identifier style (`recv_all` next to `isServerTransferDone`). +- 11 headers use `#pragma once`, 9 use legacy `#ifndef X_HPP` guards. +- **Severe layering violation**: UI layouts (`TitlesLayout::runTransfer`, `runReceive`) call into raw socket code and `io::restore` directly. +- Globals: 6 in `server.cpp`, 4 in `client.cpp`, 2 in `io.cpp`, plus `g_currentUId` everywhere. +- **Logger is silently broken** (`include/logger.hpp:54`): `printf(StringUtils::format(...).c_str(), args...)` already substitutes the format, then passes leftover variadic args to a string with no remaining `%` specifiers — every error log loses its arguments. +- **Permission bug** in `source/io.cpp:119`: `mkdir(path.c_str(), 777)` — decimal 777 is octal 1411 (no read for owner). Has been wrong since day one. +- One monster function: `io::restore` is 112 lines mixing mount, create, clear, copy, commit. +- Russian comment in `io.cpp:231` (mixed languages). +- Dead commented-out code in `util.cpp:50-58` and `logger.hpp:51-54`. +- No `.clang-format`, no `.clang-tidy`, no `.editorconfig`, no `.gitattributes`, no CI, no `README.md`, no `ARCHITECTURE.md`, no `LICENSE` file (despite `io.cpp` carrying GPLv3 from Checkpoint). + +Goal: transform NXST into a layered, conventionally-named, tooled-up project that builds on Switch via CMake and is readable by a stranger in under ten minutes. Solo developer; bar is "professional indie", not enterprise. + +User decisions (already made): +- **Full 8-phase plan**. +- **Migrate Make → CMake** with devkitpro toolchain file. +- **camelCase for functions, PascalCase for classes**, snake_case for files and locals, `kPascalCase` for constants. +- **No tests** (Switch homebrew rarely has them; pure-logic surface is small). + +--- + +## Target Directory Tree + +``` +NXST/ +├── .clang-format # LLVM-derived, 4-space, 110 col +├── .clang-tidy # bugprone-*, readability-*, modernize-* +├── .editorconfig # LF, UTF-8, trim WS, final newline +├── .gitattributes # eol=lf; lib/Plutonium linguist-vendored +├── .github/workflows/build.yml # CI: devkitpro container, cmake, .nro upload +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── PROTOCOL.md +│ └── screenshots/ +├── cmake/ +│ └── modules/ # FindPlutonium.cmake (if needed) +├── include/nxst/ +│ ├── app/ # MainApplication, AppContext +│ ├── domain/ # Title, Account, TransferState, Result +│ ├── infra/ +│ │ ├── fs/ # filesystem, directory, RAII handles +│ │ ├── net/ # socket, sendAll/recvAll +│ │ └── sys/ # logger, threading, switch services +│ ├── service/ # TransferService +│ └── ui/ # layouts, widgets, theme +├── src/ # mirrors include/nxst/ +│ ├── app/ +│ ├── domain/ +│ ├── infra/{fs,net,sys}/ +│ ├── service/ +│ ├── ui/ +│ └── main.cpp +├── tools/format.sh # one-line clang-format helper +├── lib/Plutonium/ # submodule, untouched +├── deps/asprintf/ # untouched +├── CMakeLists.txt # top-level +├── README.md +├── ARCHITECTURE.md # → docs/ARCHITECTURE.md +├── CHANGELOG.md +├── LICENSE # GPLv3 (forced by Checkpoint inheritance) +└── icon.png +``` + +--- + +## Naming Convention Spec + +| Axis | Rule | Examples | +|---|---|---| +| Files | `snake_case.{cpp,hpp}` | `transfer_service.cpp`, `socket.hpp` | +| Classes / structs | `PascalCase` | `TransferService`, `AccountProfile`, `Socket` | +| Functions / methods | `camelCase` | `sendAll()`, `recvAll()`, `replaceUsername()` | +| Locals / params | `snake_case` | `int sock_fd`, `std::string file_name` | +| Members | `snake_case`, no prefix | `bytes_done`, `client_sock` | +| Constants / constexpr | `kPascalCase` | `kTcpPort`, `kBufSize`, `kMulticastGroup` | +| Enums | `enum class Foo { Bar, Baz }` | `enum class TransferKind { Send, Receive }` | +| Macros (avoid) | `NXST_UPPER_SNAKE` | `NXST_UNREACHABLE` | +| Namespaces | lowercase, short | `nxst`, `nxst::net`, `nxst::ui` | + +--- + +## Layering (dependency direction) + +``` + ┌────────────────────────┐ + │ Presentation (ui/) │ + │ layouts, widgets │ + └───────────┬────────────┘ + │ + ┌───────────▼────────────┐ + │ Service (svc/) │ + │ TransferService │ + └───────────┬────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ +┌──────▼──────┐ ┌────────▼──────┐ ┌────────▼──────┐ +│ Domain │ │ Infra/net │ │ Infra/fs │ Infra/sys +│ Title, │ │ Socket, │ │ Directory, │ Logger, +│ Account, │ │ sendAll, │ │ FsHandle, │ Thread, +│ Result │ │ recvAll │ │ FileHandle │ Account svc +└─────────────┘ └───────────────┘ └───────────────┘ + │ + libnx, SDL, Plutonium +``` + +**Hard 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 ``, ``, `pthread.h`, or call `recv`/`send` directly**. `app/` wires everything. + +CI enforcement: a 20-line shell step greps `src/ui/**` for forbidden headers. + +--- + +## Phase 0 — Tooling and ground rules (S, ~2h) + +**Goal.** Lay down conventions before touching code. + +**Files created.** +- `.clang-format`: BasedOnStyle LLVM, IndentWidth 4, ColumnLimit 110, PointerAlignment Left, IncludeBlocks Regroup with three categories (system, third-party, project). +- `.clang-tidy`: enable `bugprone-*`, `readability-*` (minus `magic-numbers`), `modernize-*` (minus `use-trailing-return-type`, `use-nodiscard`), `cppcoreguidelines-pro-type-cstyle-cast`. Disable `fuchsia-*`, `google-*`, `llvm-header-guard`. +- `.editorconfig`: LF, UTF-8, 4-space, trim trailing whitespace, final newline. Tab indent for `Makefile` (only while it still exists). +- `.gitattributes`: `* text=auto eol=lf`, `lib/Plutonium/** linguist-vendored=true`, `deps/** linguist-vendored=true`. Stops GitHub mis-classifying the repo language. +- `tools/format.sh`: `clang-format -i` over `src/` + `include/`. + +**Risk.** None — no compiled changes. + +**Buildable?** Unaffected. + +--- + +## Phase 1 — Bug fixes and dead code (S, ~3h) + +**Goal.** Fix concrete bugs before any structural change can mask them. + +**Tasks.** +1. Replace broken `Logger` (`include/logger.hpp`) with free-function API in `include/nxst/infra/sys/log.hpp`: + ```cpp + namespace nxst::log { + enum class Level { Debug, Info, Warn, Error }; + void write(Level, const char* fmt, ...) __attribute__((format(printf, 2, 3))); + void debug(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void info (const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void warn (const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void error(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + } + ``` + Implementation: `vsnprintf` into stack buffer, prefix with `[YYYY-MM-DD HH:MM:SS][LEVEL]`, append to `/switch/NXST/log.log` and stderr. TU-local `std::mutex`. No singleton class. Drop the GPLv3 Checkpoint header (the new file is original). + - `format(printf, ...)` attribute gives compile-time format-string checking — that is the actual reason this rewrite matters. + - Leave thin shim `LOG_INFO(...)`/`LOG_ERROR(...)` macros so existing call sites keep compiling during phase migration. +2. Fix `source/io.cpp:119` — `mkdir(path.c_str(), 777)` → `mkdir(path.c_str(), 0777)`. +3. Translate Russian comment at `source/io.cpp:231` to English. +4. Delete dead commented-out code in `source/util.cpp:50-58` and old `include/logger.hpp:51-54` block. + +**Risk.** Logger API change ripples to <30 call sites. Macros bridge the gap. + +**Buildable?** Yes after each file. + +--- + +## Phase 2 — File renames + `#pragma once` (S, ~2h) + +**Goal.** Land the filename convention before the directory move so git history sees rename + move as separate commits. + +**Tasks.** +- Rename to `snake_case` via `git mv` (use two-step on case-insensitive macOS): `Main.cpp`→`main.cpp`, `MainApplication.{cpp,hpp}`→`main_application.{cpp,hpp}`, `TitlesLayout.{cpp,hpp}`→`titles_layout.{cpp,hpp}`, `UsersLayout.{cpp,hpp}`→`users_layout.{cpp,hpp}`, plus matching headers under `include/`. +- Convert all `#ifndef X_HPP / #define X_HPP / #endif` guards to `#pragma once`. Affected: `logger.hpp`, `account.hpp`, and the 7 other headers using legacy guards. +- Update affected `#include` lines. +- `Makefile` `SOURCES` globs `*.cpp`, so no Makefile change needed for renames. + +**Risk.** macOS case-insensitive FS hides case-only renames — use `git mv Foo.cpp tmp.cpp && git mv tmp.cpp foo.cpp`. + +**Buildable?** Yes after each pair. + +--- + +## Phase 3 — Directory restructure (M, ~1 day) + +**Goal.** Move flat `source/` and `include/` into the layered tree. **No code changes**, just relocation + include-path updates. + +**Tasks.** +1. Create the `src/{app,domain,infra/{net,fs,sys},service,ui}` and `include/nxst/{...}` skeleton. +2. Move files: + - `protocol.hpp`, `transfer_state.hpp`, `account.hpp` (the `AccountUid` struct + ordering only), `title.hpp/cpp`, `common.hpp/cpp`, `util.hpp/cpp` → `domain/`. + - `client.{hpp,cpp}` → `infra/net/transfer_sender.{hpp,cpp}`, `server.{hpp,cpp}` → `infra/net/transfer_receiver.{hpp,cpp}`. Renaming is intentional: `CLAUDE.md` already documents that "server = receiver, client = sender" is inverted from typical usage; renaming kills the confusion permanently. + - `net/socket.hpp` → `infra/net/socket.hpp`. + - `io.hpp/cpp`, `directory.hpp/cpp`, `filesystem.hpp/cpp` → `infra/fs/`. + - new `log.hpp/cpp`, switch-service init pieces from `account.cpp` → `infra/sys/`. + - `main_application.{hpp,cpp}`, `main.{hpp,cpp}` → `app/`. + - `titles_layout.*`, `users_layout.*`, `transfer_overlay.hpp`, `theme.hpp`, `ui/*.hpp`, `header_bar.hpp` → `ui/`. +3. Update `Makefile` (still in use — CMake migration is Phase 4): + ```make + SOURCES := src src/app src/domain src/infra/net src/infra/fs src/infra/sys src/service src/ui lib/Plutonium/source + INCLUDES := include lib/Plutonium/include + ``` + Remove the now-defunct `include/net` from `INCLUDES`. +4. Update **every** `#include` to `#include ` form: `#include `, `#include `. The single root `include/` keeps the prefix mandatory and grepable. + +**Risk.** Big disruptive phase. Do on a feature branch, build after every directory's worth of moves. `service/` directory stays empty for now — scaffolding only. + +**Buildable?** Yes if you move-and-rebuild incrementally. + +--- + +## Phase 4 — Migrate Make → CMake (M, ~1 day) + +**Goal.** Replace `Makefile` with `CMakeLists.txt` using devkitpro's bundled `Switch.cmake` toolchain file. Project compiles via `cmake -B build -DCMAKE_TOOLCHAIN_FILE=$DEVKITPRO/cmake/Switch.cmake && cmake --build build`. + +**Tasks.** +1. Create top-level `CMakeLists.txt`: + - `cmake_minimum_required(VERSION 3.20)`, `project(NXST CXX)`. + - C++17, `-fno-rtti -fno-exceptions` (matches current `CXXFLAGS`). + - ARM flags `-march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE` mirror current `ARCH`. + - `find_package(PkgConfig REQUIRED)` + `pkg_check_modules(SWITCH_LIBS REQUIRED SDL2_ttf SDL2_gfx SDL2_image SDL2_mixer freetype2 harfbuzz minizip libpng libjpeg libwebp glesv2 egl glapi zlib)` against `aarch64-none-elf-pkg-config` (already wired up per memory ID 101). + - Manual link: `pu` (Plutonium), `drm_nouveau`, plus the trailing `-lharfbuzz -lfreetype -lz` static-link-order workaround (memory IDs 97, 98, 100 — required after recent libnx update). + - Add Plutonium as `add_subdirectory(lib/Plutonium)` if its CMake exists, else as an INTERFACE library wrapping its prebuilt `.a`. Inspection during this phase decides. + - Use devkitpro's `nx_create_nro` / `nx_generate_nacp` helpers from the toolchain file to emit `NXST.nro` with icon + NACP metadata (`APP_TITLE=NXST`, `APP_AUTHOR=DragonSpirit`, `APP_VERSION=04.26.2026`). +2. Create `cmake/modules/` if any custom Find module proves necessary (likely not). +3. Add convenience targets: `cmake --build build --target send` to invoke `nxlink NXST.nro`. +4. Delete old `Makefile` only after CMake build produces a working `.nro` matching the Make output. +5. Update `.gitignore`: replace `build/` Make artifacts with CMake's `build/` directory rules and `CMakeCache.txt`, `CMakeFiles/`, `compile_commands.json`. + +**Risk.** Highest in the plan. Mitigation: +- Keep `Makefile` alongside CMake during the phase. Verify `.nro` from both is identical (or at minimum boots and runs on hardware). +- If the devkitpro `Switch.cmake` toolchain proves brittle for Plutonium's transitive headers, fall back to keeping Make and document why CMake was deferred. +- The pkg-config tweaks captured in memory IDs 96–101 are non-obvious — re-apply them in the CMake config explicitly, not via `pkg_check_modules` defaults. + +**Buildable?** Yes — both build systems coexist until CMake is verified. + +--- + +## Phase 5 — TransferService extraction, kill globals (L, ~2 days) + +**Goal.** Sever the UI → net coupling. Single biggest "this looks like real software" change. + +**Tasks.** +1. Create `src/service/transfer_service.{hpp,cpp}`. Class owns: + - one `TransferState` per direction (sender, receiver), + - all listen / accept / broadcast threads (currently 6 globals in `server.cpp`, 4 in `client.cpp`), + - public API: `start(TransferKind, AccountUid, std::vector)`, `cancel()`, `progress()`, `statusText()`, `isDone()`, `failureReason()`, `setOnComplete(std::function)`. +2. Move file globals (`g_server_state`, `g_client_state`, all socket/pthread atomics) into `TransferService` private members. pthread C trampolines: `static void* threadEntry(void* self)` immediately calls `*static_cast(self)`. +3. Refactor `titles_layout.cpp` `runTransfer`/`runReceive` to call `app_ctx.transfer.start(...)` / `cancel()` instead of free `transfer_files()` / `start_listening()`. Inject via `AppContext&` (already partially present per `include/ui/UiContext.hpp`). +4. Move `g_currentUId` out of global scope into `AppContext::current_user`. Pass `AppContext&` down to layouts at construction. There is exactly one `AppContext` and its lifetime equals the app — safe and cheap. +5. UI's `runReceive` currently calls `io::restore` directly after server work completes. Move into the service's completion callback so UI never touches FS directly. + +**Strategy.** Keep old `transfer_files()` / `start_listening()` free functions as thin wrappers around `TransferService` for the duration of the phase. Convert UI call sites one at a time. Delete wrappers only after both sites are on the service. + +**Buildable?** Yes via the wrappers. + +--- + +## Phase 6 — `Result` and RAII (M, ~1 day) + +**Goal.** Replace bool/out-param/silent-failure patterns with `Result`; wrap raw OS handles in RAII. + +**Tasks.** +1. `include/nxst/domain/result.hpp` — minimal `Result` with tagged union (no `std::variant` because `-fno-exceptions` rules out monostate-on-throw): + ```cpp + template + class Result { + bool ok_; + union { T val_; E err_; }; + public: + static Result success(T v); + static Result failure(E e); + bool isOk() const noexcept; + const T& value() const; // UB if !isOk + const E& error() const; // UB if isOk + template auto map(F&&) const; + }; + ``` + Resist importing `tl::expected`. 60 lines of in-house code beats a vendored dep at this scale. +2. RAII wrappers in `include/nxst/infra/`: + - `FdHandle` — owns `int fd`, closes on dtor, move-only. + - `FsFileSystemHandle` — owns `FsFileSystem`, calls `fsFsClose`. Plugs the "save"-mount leak from memory ID 59. + - `AccountProfileHandle` — owns `AccountProfile`, calls `accountProfileClose`. Plugs the bug from memory ID 64. + - `FileHandle` — owns `FILE*`, `fclose` on dtor. +3. Replace `io.cpp:57` `new u8[BUFFER_SIZE] / delete[]` and `io.cpp:234` `malloc/free` with `std::vector` (already the pattern in `client.cpp`/`server.cpp`). +4. Split `io::restore` (the 112-line monster, `io.cpp:221-332`) into `restoreSaveForTitle`, `extractAndCommit`, `clearStaleSaveFiles`. Each returns `Result`. +5. Convert `recvAll` / `sendAll` / socket setup paths in `infra/net` to return `Result` so error paths compose under `-fno-exceptions`. + +**Strategy.** Adopt incrementally — start at IO boundaries, leave `bool` returns untouched in modules not yet refactored. + +**Buildable?** Yes per file. + +--- + +## Phase 7 — Documentation + license (S, ~half day) + +**Goal.** A stranger can build, run, and understand this in under 15 minutes. + +**Files created.** +- `README.md`: what NXST does, screenshot, install (drop `.nro` into `/switch/`), build (devkitpro + `cmake --build`), credit Plutonium and Checkpoint, link to `docs/`. +- `docs/ARCHITECTURE.md`: layering diagram from this plan, threading model (one accept thread, one broadcast thread, one transfer thread per direction; cancellation via `shutdown(2)` + atomic flag), pointers to key files. +- `docs/PROTOCOL.md`: promote the 9-line wire-format comment from `protocol.hpp` into a real spec — byte-level diagrams, multicast discovery on `239.0.0.1:8081`, TCP transfer on `:8080`, EOF sentinel = `filename_len == 0`, `kBufSize=65536`, `kMaxFilename=4096`. +- `CHANGELOG.md`: Keep-a-Changelog format. Version `0.x` until protocol stabilizes. +- `LICENSE`: GPLv3 — forced by `io.cpp` carrying Checkpoint's GPLv3 header. Document credit + license inheritance in README. + +**Risk.** None. + +**Buildable?** Unaffected. + +--- + +## Phase 8 — CI (S, ~2h) + +**Goal.** Every push verifies `.nro` builds. One green badge tells visitors the repo is alive. + +**Tasks.** +- `.github/workflows/build.yml`: + ```yaml + on: [push, pull_request] + jobs: + nro: + runs-on: ubuntu-latest + container: devkitpro/devkita64:latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive } + - run: | + cmake -B build \ + -DCMAKE_TOOLCHAIN_FILE=$DEVKITPRO/cmake/Switch.cmake \ + -DCMAKE_BUILD_TYPE=Release + cmake --build build -j$(nproc) + - uses: actions/upload-artifact@v4 + with: + name: NXST-${{ github.sha }} + path: build/NXST.nro + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + sudo apt-get install -y clang-format + find src include -name '*.cpp' -o -name '*.hpp' \ + | xargs clang-format --dry-run --Werror + layering: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + ! grep -rE '^#include\s*[<"](arpa/|sys/socket|pthread\.h)' src/ui/ + ``` +- Optional release job on tag `v*`: same CMake build, attach `.nro` to GitHub Release. + +**Risk.** First run typically fails on submodule init or container path — iterate on a branch. + +**Buildable?** Pass or fix. + +--- + +## Critical files to modify + +- `Makefile` — Phase 3 SOURCES/INCLUDES update; deleted in Phase 4 after CMake parity. +- `include/logger.hpp` — Phase 1 replace with `nxst::log` free-function API. +- `source/io.cpp` — Phase 1 mkdir + comment fix; Phase 6 split `restore()` + RAII; Phase 5 stop being called from UI. +- `source/server.cpp` — Phase 3 rename to `transfer_receiver.cpp`; Phase 5 globals migrate into `TransferService`. +- `source/client.cpp` — Phase 3 rename to `transfer_sender.cpp`; Phase 5 globals migrate into `TransferService`. +- `source/TitlesLayout.cpp` — Phase 2 rename; Phase 5 sever direct calls into network and `io::restore`. +- `source/Main.cpp` — Phase 2 rename; Phase 5 construct `AppContext` with `TransferService`. +- `source/util.cpp` — Phase 1 delete dead block. + +New files (not yet present): +- `.clang-format`, `.clang-tidy`, `.editorconfig`, `.gitattributes` — Phase 0. +- `CMakeLists.txt` — Phase 4. +- `include/nxst/domain/result.hpp` — Phase 6. +- `include/nxst/service/transfer_service.hpp` + `src/service/transfer_service.cpp` — Phase 5. +- `README.md`, `docs/ARCHITECTURE.md`, `docs/PROTOCOL.md`, `CHANGELOG.md`, `LICENSE` — Phase 7. +- `.github/workflows/build.yml` — Phase 8. + +--- + +## What is deliberately NOT in this plan + +1. **C++20.** Devkitpro's `gnu++17` is fine; nothing in the codebase wants concepts/ranges/coroutines, and coroutines under `-fno-exceptions` are a maze. Stay on `gnu++17`. +2. **Abstracting Plutonium.** It is *the* UI framework; you will not port it. UI calls Plutonium directly. +3. **`tl::expected` / `std::expected`.** 60-line in-house `Result` is enough. +4. **DI containers, factories, abstract logger interfaces.** Solo developer, one service, one logger sink. Add complexity only when a second sink actually appears. +5. **Integration tests against the network.** No bug class is meaningfully prevented; cost is high; value comes from running on real hardware. +6. **Migrating from pthread to `std::thread`.** Libnx's pthread is the supported path. A single `Thread` RAII wrapper inside `infra/sys/` is the most you should add. +7. **PImpl broadly.** At 2.3 KLOC compile time is sub-second; PImpl just adds indirection. +8. **Re-vendoring or modifying Plutonium.** Submodule, untouched. `linguist-vendored` keeps it out of GitHub language stats. +9. **UPPER_SNAKE constants.** Visually clash with macros; `kPascalCase` is the modern norm. + +--- + +## Recommended phasing for a solo developer + +- **Weekend 1:** Phase 0 + 1 + 2. Tooled, bugs fixed, files renamed, builds. Already feels different. +- **Weekend 2:** Phase 3. Big move, one sitting on a branch. +- **Weekend 3:** Phase 4 (CMake). Build system swap. Highest individual phase risk — schedule when fresh. +- **Weekend 4 (longest):** Phase 5 (TransferService). UI no longer touches sockets. The win. +- **Weekend 5:** Phase 6. `Result`, RAII, `restore()` split. +- **Evening:** Phase 7. README + ARCHITECTURE + LICENSE. +- **Evening:** Phase 8. CI. + +Stopping after Weekend 4 still leaves the project visibly more serious than it started. Each weekend produces a defensible "ship it" state. + +--- + +## Verification (end-to-end test plan) + +After each phase: +1. `cmake --build build` (Phase 4+) or `make` produces `NXST.nro` with no warnings. +2. Copy `NXST.nro` to Switch SD card `/switch/NXST/`, launch via hbmenu, verify the title list and user list render (regression tests for memory IDs 73, 78). +3. Run a save transfer between two Switches: select a title on the sender, "Receive" on the receiver, verify the file lands and `io::restore` replaces it on the receiver. Verify cancel works mid-transfer (regression for memory IDs 60, 103, 104). +4. Verify the log file at `/switch/NXST/log.log` contains formatted entries with timestamps and levels (regression for the broken-Logger bug). +5. Phase 8 only: confirm a green CI run on a PR; download the `NXST.nro` artifact; install it on Switch; run the same flow. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b28446 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# NXST + +Transfer save data between two Nintendo Switch consoles over local Wi-Fi. + +Pick a game, pick a user. One Switch sends; the other receives. The sender backs up its own save first, so you can never lose data in transit. + +--- + +## Install + +1. Download `NXST.nro` from [Releases](../../releases). +2. Copy it to `/switch/NXST/NXST.nro` on your SD card. +3. Launch via hbmenu (hold R while starting any game, or Album). + +Both Switches must be on the same Wi-Fi network. No router configuration needed — discovery uses UDP multicast. + +--- + +## Usage + +**Sender** (the Switch whose save you want to copy): + +1. Open NXST → select a title → press **A** → **Transfer**. +2. Wait for "Waiting for receiver…" to change to "Transferring…". + +**Receiver** (the Switch that will receive the save): + +1. Open NXST → select the same title → press **A** → **Receive**. +2. Wait. The save is restored automatically when the transfer finishes. + +Press **B** on either side to cancel mid-transfer. + +Logs are written to `/switch/NXST/log.log`. + +--- + +## Build + +**Prerequisites:** [devkitPro](https://devkitpro.org/wiki/Getting_Started) with `switch-dev` and `switch-portlibs` packages, plus `cmake ≥ 3.20`. + +```bash +# Clone (Plutonium UI fetched automatically by CMake) +git clone https://github.com/your-username/NXST.git +cd NXST + +# Configure (once) +cmake --preset switch + +# Build +cmake --build build + +# Send to Switch via nxlink (Switch must be on same network, nxlink running) +cmake --build build --target send +``` + +Output: `build/NXST.nro` + +--- + +## Architecture + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the layer diagram, threading model, and key types. + +See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the wire protocol (UDP multicast discovery + TCP file stream). + +``` +ui/ — TitlesLayout, UsersLayout, TransferOverlay, HeaderBar +service/ — TransferService (all network threads and state) +infra/net/ — Socket RAII, sendAll/recvAll +infra/fs/ — io::backup, io::restore, directory iterator, RAII handles +infra/sys/ — nxst::log (printf-checked, timestamped) +domain/ — Title, Account, Result, TransferState, protocol constants +``` + +--- + +## Credits + +- **[Plutonium](https://github.com/XorTroll/Plutonium)** — Switch UI framework by XorTroll +- **[Checkpoint](https://github.com/BernardoGiordano/Checkpoint)** — save management library by Bernardo Giordano / FlagBrew; several files in `src/infra/fs/` and `src/domain/` are derived from Checkpoint + +--- + +## License + +GPLv3 — see [`LICENSE`](LICENSE). + +NXST includes code derived from Checkpoint (GPLv3). All original NXST code is released under the same license to satisfy the GPL inheritance requirement. diff --git a/deps/asprintf/asprintf.c b/deps/asprintf/asprintf.c deleted file mode 100644 index 1d25035..0000000 --- a/deps/asprintf/asprintf.c +++ /dev/null @@ -1,37 +0,0 @@ -#include -#include - -#include "asprintf.h" - -int vasprintf(char **strp, const char *fmt, va_list ap) { - int size, res; - - va_list cp; - va_copy(cp, ap); - size = vsnprintf(NULL, 0, fmt, cp); - va_end(cp); - if (size < 0) - return -1; - - *strp = (char *)malloc(size + 1); - if (*strp == NULL) - return -1; - - res = vsnprintf(*strp, size + 1, fmt, ap); - if (res < 0) { - free(*strp); - return -1; - } - - return res; -} - -int asprintf(char **s, const char *fmt, ...) { - int ret; - - va_list ap; - va_start(ap, fmt); - ret = vasprintf(s, fmt, ap); - va_end(ap); - return ret; -} diff --git a/deps/asprintf/asprintf.h b/deps/asprintf/asprintf.h deleted file mode 100644 index 3b40a04..0000000 --- a/deps/asprintf/asprintf.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef HAVE_ASPRINTF -#define HAVE_ASPRINTF 1 - -#include - -int vasprintf(char **strp, const char *fmt, va_list ap); - -int asprintf(char **s, const char *fmt, ...); - -#endif diff --git a/deps/asprintf/clib.json b/deps/asprintf/clib.json deleted file mode 100644 index 4e2b86d..0000000 --- a/deps/asprintf/clib.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "asprintf", - "version": "1.0.0", - "repo": "Neved4/asprintf", - "description": "asprintf, vasprintf - print to allocated string", - "license": "MIT", - "keywords": ["asprintf", "sprintf", "alloc", "string"], - "src": ["asprintf.c", "asprintf.h"] -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..0d0f748 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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, 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 ``, ``, ``, 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` (`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` 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 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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..83e737c --- /dev/null +++ b/docs/PROTOCOL.md @@ -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//<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. diff --git a/include/MainApplication.hpp b/include/MainApplication.hpp deleted file mode 100644 index c510dea..0000000 --- a/include/MainApplication.hpp +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include <pu/Plutonium> -#include <UsersLayout.hpp> -#include <TitlesLayout.hpp> - -namespace ui { - - class MainApplication : public pu::ui::Application { - - public: - using Application::Application; - PU_SMART_CTOR(MainApplication) - - void OnLoad() override; - - // Layout instance - UsersLayout::Ref usersLayout; - TitlesLayout::Ref titlesLayout; - }; -} \ No newline at end of file diff --git a/include/account.hpp b/include/account.hpp deleted file mode 100644 index ec43644..0000000 --- a/include/account.hpp +++ /dev/null @@ -1,76 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef ACCOUNT_HPP -#define ACCOUNT_HPP - -#include <map> -#include <string.h> -#include <string> -#include <switch.h> -#include <vector> - -#define USER_ICON_SIZE 64 - -namespace std { - template <> - struct hash<AccountUid> { - size_t operator()(const AccountUid& a) const { return ((hash<u64>()(a.uid[0]) ^ (hash<u64>()(a.uid[1]) << 1)) >> 1); } - }; -} - -inline bool operator==(const AccountUid& x, const AccountUid& y) -{ - return x.uid[0] == y.uid[0] && x.uid[1] == y.uid[1]; -} - -inline bool operator==(const AccountUid& x, u64 y) -{ - return x.uid[0] == y && x.uid[1] == y; -} - -inline bool operator<(const AccountUid& x, const AccountUid& y) -{ - if (x.uid[0] != y.uid[0]) return x.uid[0] < y.uid[0]; - return x.uid[1] < y.uid[1]; -} - -struct User { - AccountUid id; - std::string name; -}; - -namespace Account { - Result init(void); - void exit(void); - - std::vector<AccountUid> ids(void); - AccountUid selectAccount(void); - std::string username(AccountUid id); - std::string iconPath(AccountUid id); -} - -#endif \ No newline at end of file diff --git a/include/client.hpp b/include/client.hpp deleted file mode 100644 index 582b8a6..0000000 --- a/include/client.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#include <string> -#include <switch.h> - -int transfer_files(size_t index, AccountUid uid); -bool isClientTransferDone(); -bool isClientTransferCancelled(); -bool isClientConnectionFailed(); -bool isClientProgressKnown(); -bool isClientWorkersIdle(); -void cancelClientTransfer(); -double getClientProgress(); -std::string getClientStatusText(); -std::string getClientFailReason(); diff --git a/include/common.hpp b/include/common.hpp deleted file mode 100644 index b80a8f6..0000000 --- a/include/common.hpp +++ /dev/null @@ -1,65 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef COMMON_HPP -#define COMMON_HPP - -#include <algorithm> -#include <arpa/inet.h> -#include <codecvt> -#include <cstdio> -#include <locale> -#include <memory> -#include <netinet/in.h> -#include <stdarg.h> -#include <string.h> -#include <string> -#include <sys/socket.h> -#include <time.h> -#include <unistd.h> - -#define ATEXIT(func) atexit((void (*)())func) - -namespace DateTime { - std::string timeStr(void); - std::string dateTimeStr(void); - std::string logDateTime(void); -} - -namespace StringUtils { - bool containsInvalidChar(const std::string& str); - std::string escapeJson(const std::string& s); - std::string format(const std::string fmt_str, ...); - std::string removeForbiddenCharacters(std::string src); - std::string UTF16toUTF8(const std::u16string& src); - void ltrim(std::string& s); - void rtrim(std::string& s); - void trim(std::string& s); -} - -char* getConsoleIP(void); - -#endif \ No newline at end of file diff --git a/include/const.h b/include/const.h deleted file mode 100644 index 0cf33f4..0000000 --- a/include/const.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include <Theme.hpp> - -#define COLOR(hex) pu::ui::Color::FromHex(hex) -#define BACKGROUND_COLOR theme::color::BgBase diff --git a/include/directory.hpp b/include/directory.hpp deleted file mode 100644 index eefe165..0000000 --- a/include/directory.hpp +++ /dev/null @@ -1,58 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef DIRECTORY_HPP -#define DIRECTORY_HPP - -#include <dirent.h> -#include <errno.h> -#include <string> -#include <switch.h> -#include <vector> - -struct DirectoryEntry { - std::string name; - bool directory; -}; - -class Directory { -public: - Directory(const std::string& root); - ~Directory() = default; - - Result error(void); - std::string entry(size_t index); - bool folder(size_t index); - bool good(void); - size_t size(void); - -private: - std::vector<struct DirectoryEntry> mList; - Result mError; - bool mGood; -}; - -#endif \ No newline at end of file diff --git a/include/filesystem.hpp b/include/filesystem.hpp deleted file mode 100644 index a940a3e..0000000 --- a/include/filesystem.hpp +++ /dev/null @@ -1,39 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef FILESYSTEM_HPP -#define FILESYSTEM_HPP - -#include "account.hpp" -#include <switch.h> - -namespace FileSystem { - Result mount(FsFileSystem* fileSystem, u64 titleID, AccountUid userID); - int mount(FsFileSystem fs); - void unmount(void); -} - -#endif \ No newline at end of file diff --git a/include/io.hpp b/include/io.hpp deleted file mode 100644 index 3693f5b..0000000 --- a/include/io.hpp +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef IO_HPP -#define IO_HPP - -#include "account.hpp" -#include "directory.hpp" -#include "title.hpp" -#include "util.hpp" -#include <dirent.h> -#include <switch.h> -#include <sys/stat.h> -#include <tuple> -#include <unistd.h> -#include <utility> - -#define BUFFER_SIZE 0x80000 - -namespace io { - std::tuple<bool, Result, std::string> backup(size_t index, AccountUid uid); - std::tuple<bool, Result, std::string> restore(size_t index, AccountUid uid, size_t cellIndex, const std::string& nameFromCell); - - Result copyDirectory(const std::string& srcPath, const std::string& dstPath); - void copyFile(const std::string& srcPath, const std::string& dstPath); - Result createDirectory(const std::string& path); - Result deleteFolderRecursively(const std::string& path); - bool directoryExists(const std::string& path); - bool fileExists(const std::string& path); -} - -#endif \ No newline at end of file diff --git a/include/logger.hpp b/include/logger.hpp deleted file mode 100644 index 177694e..0000000 --- a/include/logger.hpp +++ /dev/null @@ -1,85 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef LOGGER_HPP -#define LOGGER_HPP - -#include "common.hpp" - -#include <stdio.h> -#include <string> - -class Logger { -public: - static Logger& getInstance(void) - { - static Logger mLogger; - return mLogger; - } - - inline static const std::string INFO = "[INFO]"; - inline static const std::string DEBUG = "[DEBUG]"; - inline static const std::string ERROR = "[ERROR]"; - inline static const std::string WARN = "[WARN]"; - - template <typename... Args> - void log(const std::string& level, const std::string& format = {}, Args... args) - { - // buffer += StringUtils::format(("[" + DateTime::logDateTime() + "] " + level + " " + format + "\n").c_str(), args...); - // buffer += StringUtils::format("%s\n", format.c_str()); - // buffer += StringUtils::format("%s\n",StringUtils::format("[" + DateTime::logDateTime() + "] " + level + " " + format + "\n").c_str(), args...); - printf(StringUtils::format("[" + DateTime::logDateTime() + "] " + level + " " + format + "\n").c_str(), args...); - } - - void flush(void) - { - mFile = fopen(mPath.c_str(), "a"); - if (mFile != NULL) { - fprintf(mFile, buffer.c_str()); - fprintf(stderr, buffer.c_str()); - fclose(mFile); - } - } - -private: - Logger(void) { buffer = ""; } - ~Logger(void) {} - - Logger(Logger const&) = delete; - void operator=(Logger const&) = delete; - -#if defined(__SWITCH__) - const std::string mPath = "/switch/NXST/log.log"; -#else - const std::string mPath = "log.log"; -#endif - - FILE* mFile; - - std::string buffer; -}; - -#endif \ No newline at end of file diff --git a/include/main.hpp b/include/main.hpp deleted file mode 100644 index a46e5d2..0000000 --- a/include/main.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef MAIN_HPP -#define MAIN_HPP -#include <const.h> -#include "account.hpp" -#include "title.hpp" -#include "util.hpp" -#include <memory> -#include <switch.h> -#include "logger.hpp" - -typedef enum { SORT_ALPHA, SORT_LAST_PLAYED, SORT_PLAY_TIME, SORT_MODES_COUNT } sort_t; - -inline float g_currentTime = 0; -inline AccountUid g_currentUId; -inline bool g_backupScrollEnabled = 0; -inline bool g_notificationLedAvailable = false; -inline bool g_shouldExitNetworkLoop = false; -inline std::string g_selectedCheatKey; -inline std::vector<std::string> g_selectedCheatCodes; -inline u32 g_username_dotsize; -inline sort_t g_sortMode = SORT_ALPHA; -inline std::string g_currentFile = ""; -inline bool g_isTransferringFile = false; -inline const std::string g_emptySave = "New..."; - -#endif \ No newline at end of file diff --git a/include/net/Socket.hpp b/include/net/Socket.hpp deleted file mode 100644 index 0dc2371..0000000 --- a/include/net/Socket.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once -#include <unistd.h> - -struct Socket { - int fd = -1; - - Socket() = default; - explicit Socket(int fd) : fd(fd) {} - ~Socket() { if (fd >= 0) close(fd); } - - Socket(const Socket&) = delete; - Socket& operator=(const Socket&) = delete; - Socket(Socket&& o) : fd(o.fd) { o.fd = -1; } - - operator int() const { return fd; } - bool valid() const { return fd >= 0; } - void release() { fd = -1; } -}; diff --git a/include/nxst/app/main_application.hpp b/include/nxst/app/main_application.hpp new file mode 100644 index 0000000..e3ebdce --- /dev/null +++ b/include/nxst/app/main_application.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include <pu/Plutonium> + +#include <nxst/service/transfer_service.hpp> +#include <nxst/ui/titles_layout.hpp> +#include <nxst/ui/users_layout.hpp> + +namespace ui { + + class MainApplication : public pu::ui::Application { + + public: + using Application::Application; + PU_SMART_CTOR(MainApplication) + + void OnLoad() override; + + UsersLayout::Ref users_layout; + TitlesLayout::Ref titles_layout; + nxst::TransferService transfer; + }; +} // namespace ui \ No newline at end of file diff --git a/include/nxst/domain/account.hpp b/include/nxst/domain/account.hpp new file mode 100644 index 0000000..ca7200a --- /dev/null +++ b/include/nxst/domain/account.hpp @@ -0,0 +1,36 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once +#include <string> +#include <vector> + +#include <switch.h> + +// Hash and comparison support for AccountUid as map/unordered_map key. +namespace std { + template <> struct hash<AccountUid> { + size_t operator()(const AccountUid& a) const noexcept { + return (hash<u64>()(a.uid[0]) ^ (hash<u64>()(a.uid[1]) << 1)) >> 1; + } + }; +} // namespace std + +inline bool operator==(const AccountUid& x, const AccountUid& y) { + return x.uid[0] == y.uid[0] && x.uid[1] == y.uid[1]; +} + +inline bool operator<(const AccountUid& x, const AccountUid& y) { + return x.uid[0] != y.uid[0] ? x.uid[0] < y.uid[0] : x.uid[1] < y.uid[1]; +} + +struct User { + AccountUid id; + std::string name; +}; + +namespace account { + Result init(); + void exit(); + std::vector<AccountUid> ids(); + std::string username(AccountUid id); + std::string iconPath(AccountUid id); +} // namespace account diff --git a/include/nxst/domain/protocol.hpp b/include/nxst/domain/protocol.hpp new file mode 100644 index 0000000..adafb5d --- /dev/null +++ b/include/nxst/domain/protocol.hpp @@ -0,0 +1,16 @@ +#pragma once + +namespace proto { + constexpr uint16_t kTcpPort = 8080; + constexpr uint16_t kMulticastPort = 8081; + constexpr char kMulticastGroup[] = "239.0.0.1"; + constexpr size_t kBufSize = 65536; + constexpr uint32_t kMaxFilename = 4096; + constexpr uint32_t kEofSentinel = 0; + + // Wire layout per file: + // [filename_len : uint32_t LE] — 0 == end-of-stream + // [filename : filename_len bytes] + // [file_size : uint64_t LE] + // [file_data : file_size bytes] +} // namespace proto diff --git a/include/nxst/domain/result.hpp b/include/nxst/domain/result.hpp new file mode 100644 index 0000000..f92279d --- /dev/null +++ b/include/nxst/domain/result.hpp @@ -0,0 +1,108 @@ +#pragma once +#include <string> +#include <utility> + +namespace nxst { + + template <class T, class E = std::string> class Result { + bool ok; + alignas(T) alignas(E) unsigned char storage[sizeof(T) > sizeof(E) ? sizeof(T) : sizeof(E)]; + + Result() = default; + + public: + static Result success(T val) { + Result res; + res.ok = true; + new (res.storage) T(std::move(val)); + return res; + } + + static Result failure(E err) { + Result res; + res.ok = false; + new (res.storage) E(std::move(err)); + return res; + } + + ~Result() { + if (ok) + reinterpret_cast<T*>(storage)->~T(); + else + reinterpret_cast<E*>(storage)->~E(); + } + + Result(const Result& other) : ok(other.ok) { + if (ok) + new (storage) T(*reinterpret_cast<const T*>(other.storage)); + else + new (storage) E(*reinterpret_cast<const E*>(other.storage)); + } + + Result(Result&& other) : ok(other.ok) { + if (ok) + new (storage) T(std::move(*reinterpret_cast<T*>(other.storage))); + else + new (storage) E(std::move(*reinterpret_cast<E*>(other.storage))); + } + + Result& operator=(const Result&) = delete; + + bool isOk() const noexcept { + return ok; + } + const T& value() const { + return *reinterpret_cast<const T*>(storage); + } + const E& error() const { + return *reinterpret_cast<const E*>(storage); + } + }; + + // Specialisation for Result<void> + template <class E> class Result<void, E> { + bool ok; + alignas(E) unsigned char storage[sizeof(E)]; + + Result() = default; + + public: + static Result success() { + Result res; + res.ok = true; + return res; + } + + static Result failure(E err) { + Result res; + res.ok = false; + new (res.storage) E(std::move(err)); + return res; + } + + ~Result() { + if (!ok) + reinterpret_cast<E*>(storage)->~E(); + } + + Result(const Result& other) : ok(other.ok) { + if (!ok) + new (storage) E(*reinterpret_cast<const E*>(other.storage)); + } + + Result(Result&& other) : ok(other.ok) { + if (!ok) + new (storage) E(std::move(*reinterpret_cast<E*>(other.storage))); + } + + Result& operator=(const Result&) = delete; + + bool isOk() const noexcept { + return ok; + } + const E& error() const { + return *reinterpret_cast<const E*>(storage); + } + }; + +} // namespace nxst diff --git a/include/nxst/domain/title.hpp b/include/nxst/domain/title.hpp new file mode 100644 index 0000000..7363451 --- /dev/null +++ b/include/nxst/domain/title.hpp @@ -0,0 +1,58 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once +#include <string> +#include <unordered_map> +#include <utility> +#include <vector> + +#include <switch.h> + +class Title { + public: + void init(u8 save_data_type, u64 title_id, AccountUid uid, const std::string& name, + const std::string& author); + + std::string author() const; + std::pair<std::string, std::string> displayName() const; + u64 id() const; + std::string name() const; + std::string path() const; + u64 playTimeNanoseconds() const; + std::string playTime() const; + void playTimeNanoseconds(u64 ns); + u32 lastPlayedTimestamp() const; + void lastPlayedTimestamp(u32 ts); + std::string fullPath(size_t index) const; + void refreshDirectories(); + u64 saveId() const; + void saveId(u64 id); + std::vector<std::string> saves() const; + u8 saveDataType() const; + AccountUid userId() const; + std::string userName() const; + + private: + u64 m_id{0}; + u64 m_save_id{0}; + AccountUid m_uid{}; + std::string m_user_name; + std::string m_name; + std::string m_safe_name; + std::string m_author; + std::string m_path; + std::vector<std::string> m_saves; + std::vector<std::string> m_full_save_paths; + u8 m_save_data_type{0}; + std::pair<std::string, std::string> m_display_name; + u64 m_play_time_ns{0}; + u32 m_last_played_ts{0}; +}; + +bool areTitlesLoaded(); +void loadTitles(); +void sortTitles(); +void rotateSortMode(); +void getTitle(Title& dst, AccountUid uid, size_t i); +size_t getTitleCount(AccountUid uid); +void refreshDirectories(u64 id); +std::unordered_map<std::string, std::string> getCompleteTitleList(); diff --git a/include/TransferState.hpp b/include/nxst/domain/transfer_state.hpp similarity index 70% rename from include/TransferState.hpp rename to include/nxst/domain/transfer_state.hpp index d3adc61..5255891 100644 --- a/include/TransferState.hpp +++ b/include/nxst/domain/transfer_state.hpp @@ -4,22 +4,22 @@ #include <string> struct TransferState { - std::atomic<bool> done{false}; - std::atomic<bool> cancelled{false}; - std::atomic<bool> connection_failed{false}; + std::atomic<bool> done{false}; + std::atomic<bool> cancelled{false}; + std::atomic<bool> connection_failed{false}; std::atomic<uint64_t> bytes_done{0}; std::atomic<uint64_t> bytes_total{0}; - std::string status; - std::string fail_reason; + std::string status; + std::string fail_reason; mutable std::mutex status_mutex; void reset() { - done = false; - cancelled = false; + done = false; + cancelled = false; connection_failed = false; - bytes_done = 0; - bytes_total = 0; + bytes_done = 0; + bytes_total = 0; fail_reason.clear(); std::lock_guard<std::mutex> lock(status_mutex); status.clear(); diff --git a/include/nxst/domain/util.hpp b/include/nxst/domain/util.hpp new file mode 100644 index 0000000..6642618 --- /dev/null +++ b/include/nxst/domain/util.hpp @@ -0,0 +1,22 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once + +#include <switch.h> + +void servicesExit(); +Result servicesInit(); +void blinkLed(u8 times); + +namespace string_utils { + bool containsInvalidChar(const std::string& str); + std::string format(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + std::string removeForbiddenCharacters(std::string src); + std::string UTF16toUTF8(const std::u16string& src); + void ltrim(std::string& s); + void rtrim(std::string& s); + void trim(std::string& s); + std::string removeAccents(std::string str); + std::string removeNotAscii(std::string str); + std::u16string UTF8toUTF16(const char* src); + std::string elide(const std::string& s, size_t max_chars); +} // namespace string_utils diff --git a/include/nxst/infra/fs/directory.hpp b/include/nxst/infra/fs/directory.hpp new file mode 100644 index 0000000..a51d404 --- /dev/null +++ b/include/nxst/infra/fs/directory.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once +#include <string> +#include <vector> + +#include <switch.h> + +class Directory { + public: + explicit Directory(const std::string& path); + + bool good() const { + return m_good; + } + Result error() const { + return m_error; + } + size_t size() const { + return m_entries.size(); + } + std::string entry(size_t i) const; + bool folder(size_t i) const; + + private: + struct Entry { + std::string name; + bool is_dir; + }; + std::vector<Entry> m_entries; + Result m_error{0}; + bool m_good{false}; +}; diff --git a/include/nxst/infra/fs/filesystem.hpp b/include/nxst/infra/fs/filesystem.hpp new file mode 100644 index 0000000..22eb421 --- /dev/null +++ b/include/nxst/infra/fs/filesystem.hpp @@ -0,0 +1,9 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once +#include <switch.h> + +namespace file_system { + Result mount(FsFileSystem* fs, u64 title_id, AccountUid uid); + int mount(FsFileSystem fs); + void unmount(); +} // namespace file_system diff --git a/include/nxst/infra/fs/handles.hpp b/include/nxst/infra/fs/handles.hpp new file mode 100644 index 0000000..ae41240 --- /dev/null +++ b/include/nxst/infra/fs/handles.hpp @@ -0,0 +1,71 @@ +#pragma once +#include <cstdio> + +#include <switch.h> + +namespace nxst { + + // RAII wrapper for FsFileSystem — auto-closes on destruction. + struct FsFileSystemHandle { + FsFileSystem fs{}; + bool valid{false}; + + FsFileSystemHandle() = default; + ~FsFileSystemHandle() { + if (valid) + fsFsClose(&fs); + } // NOLINT(modernize-use-equals-default) + + FsFileSystemHandle(const FsFileSystemHandle&) = delete; + FsFileSystemHandle& operator=(const FsFileSystemHandle&) = delete; + + FsFileSystem* get() { + return &fs; + } + + void release() { + valid = false; + } // transfer ownership to devfs + }; + + // RAII wrapper for FILE* — auto-fclose on destruction. + struct FileHandle { + FILE* ptr{nullptr}; + + explicit FileHandle(FILE* file) : ptr(file) {} + ~FileHandle() { + if (ptr != nullptr) + fclose(ptr); + } // NOLINT(modernize-use-equals-default) + + FileHandle(const FileHandle&) = delete; + FileHandle& operator=(const FileHandle&) = delete; + + explicit operator bool() const { + return ptr != nullptr; + } + FILE* get() const { + return ptr; + } + }; + + // RAII wrapper for AccountProfile — auto-closes on destruction. + struct AccountProfileHandle { + AccountProfile profile{}; + bool valid{false}; + + AccountProfileHandle() = default; + ~AccountProfileHandle() { + if (valid) + accountProfileClose(&profile); + } // NOLINT(modernize-use-equals-default) + + AccountProfileHandle(const AccountProfileHandle&) = delete; + AccountProfileHandle& operator=(const AccountProfileHandle&) = delete; + + AccountProfile* get() { + return &profile; + } + }; + +} // namespace nxst diff --git a/include/nxst/infra/fs/io.hpp b/include/nxst/infra/fs/io.hpp new file mode 100644 index 0000000..418f52d --- /dev/null +++ b/include/nxst/infra/fs/io.hpp @@ -0,0 +1,19 @@ +// Copyright (C) 2024-2026 NXST contributors +#pragma once +#include <string> + +#include <switch.h> + +#include <nxst/domain/result.hpp> + +namespace io { + nxst::Result<std::string> backup(size_t index, AccountUid uid); + nxst::Result<std::string> restore(size_t index, AccountUid uid, const std::string& title_name); + + Result copyDirectory(const std::string& src, const std::string& dst); + void copyFile(const std::string& src, const std::string& dst); + Result createDirectory(const std::string& path); + Result deleteFolderRecursively(const std::string& path); + bool directoryExists(const std::string& path); + bool fileExists(const std::string& path); +} // namespace io diff --git a/include/nxst/infra/net/socket.hpp b/include/nxst/infra/net/socket.hpp new file mode 100644 index 0000000..5fdf054 --- /dev/null +++ b/include/nxst/infra/net/socket.hpp @@ -0,0 +1,29 @@ +#pragma once +#include <unistd.h> + +struct Socket { + int fd = -1; + + Socket() = default; + explicit Socket(int fd) : fd(fd) {} + ~Socket() { + if (fd >= 0) + close(fd); + } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + Socket(Socket&& o) : fd(o.fd) { + o.fd = -1; + } + + operator int() const { + return fd; + } + bool valid() const { + return fd >= 0; + } + void release() { + fd = -1; + } +}; diff --git a/include/nxst/infra/sys/logger.hpp b/include/nxst/infra/sys/logger.hpp new file mode 100644 index 0000000..7fca98b --- /dev/null +++ b/include/nxst/infra/sys/logger.hpp @@ -0,0 +1,14 @@ +#pragma once + +// New API — use these going forward. +namespace nxst::log { + + enum class Level { Debug, Info, Warn, Error }; + + void write(Level level, const char* fmt, ...) __attribute__((format(printf, 2, 3))); + void debug(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void info(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void warn(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + void error(const char* fmt, ...) __attribute__((format(printf, 1, 2))); + +} // namespace nxst::log \ No newline at end of file diff --git a/include/nxst/service/transfer_service.hpp b/include/nxst/service/transfer_service.hpp new file mode 100644 index 0000000..886adce --- /dev/null +++ b/include/nxst/service/transfer_service.hpp @@ -0,0 +1,113 @@ +#pragma once +#include <atomic> +#include <pthread.h> +#include <string> + +#include <switch.h> + +#include <nxst/domain/transfer_state.hpp> + +namespace nxst { + + class TransferService { + public: + int startSend(size_t title_index, AccountUid uid); + void cancelSend(); + + bool isSendDone() const { + return sender_state.done.load(); + } + bool isSendCancelled() const { + return sender_state.cancelled.load(); + } + bool isSendConnectionFailed() const { + return sender_state.connection_failed.load(); + } + bool isSendProgressKnown() const { + return sender_state.bytes_total.load() > 0; + } + bool isSendWorkersIdle() const { + return !sender_active.load(); + } + double sendProgress() const { + return sender_state.progress(); + } + std::string sendStatusText() const { + return sender_state.getStatus(); + } + std::string sendFailReason() const { + return sender_state.fail_reason; + } + + int startReceive(size_t title_index, AccountUid uid, std::string title_name); + void cancelReceive(); + + bool isReceiveDone() const { + return receiver_state.done.load(); + } + bool isReceiveCancelled() const { + return receiver_state.cancelled.load(); + } + bool isReceiveWorkersIdle() const { + return !receiver_accept_active.load() && !receiver_broadcast_active.load(); + } + double receiveProgress() const { + return receiver_state.progress(); + } + std::string receiveStatusText() const { + return receiver_state.getStatus(); + } + bool restoreSucceeded() const { + return restore_ok; + } + std::string restoreError() const { + return restore_error; + } + + private: + // Sender + TransferState sender_state; + std::atomic<int> sender_udp_sock{-1}; + std::atomic<int> sender_tcp_sock{-1}; + std::atomic<bool> sender_active{false}; + + // Receiver + TransferState receiver_state; + std::atomic<int> receiver_client_sock{-1}; + std::atomic<int> receiver_listen_sock{-1}; + std::atomic<int> receiver_bcast_sock{-1}; + std::atomic<bool> receiver_accept_active{false}; + std::atomic<bool> receiver_broadcast_active{false}; + pthread_t receiver_bcast_thread{}; + + // Stored at startReceive, read after network transfer completes + size_t restore_title_index{0}; + AccountUid restore_uid{}; + std::string restore_title_name; + bool restore_ok{false}; + std::string restore_error; + + // Sender thread + struct SenderArgs { + TransferService* svc; + size_t title_index; + AccountUid uid; + }; + static void* senderEntry(void* arg); + void runSender(size_t title_index, AccountUid uid); + void failSend(const std::string& reason); + int findServer(char* out_ip); + + // Receiver threads + struct AcceptArgs { + TransferService* svc; + int server_fd; + }; + static void* broadcastEntry(void* arg); + static void* acceptEntry(void* arg); + void runBroadcast(); + void runAccept(int server_fd); + std::string replaceUsername(const std::string& file_path) const; + }; + +} // namespace nxst diff --git a/include/ui/HeaderBar.hpp b/include/nxst/ui/header_bar.hpp similarity index 81% rename from include/ui/HeaderBar.hpp rename to include/nxst/ui/header_bar.hpp index 59b1a20..40ce919 100644 --- a/include/ui/HeaderBar.hpp +++ b/include/nxst/ui/header_bar.hpp @@ -1,13 +1,13 @@ #pragma once #include <pu/Plutonium> -#include <Theme.hpp> -#include <ui/UiContext.hpp> -#include <account.hpp> + +#include <nxst/domain/account.hpp> +#include <nxst/ui/theme.hpp> namespace ui { class HeaderBar { - private: + private: pu::ui::elm::Rectangle::Ref bg; pu::ui::elm::Rectangle::Ref divider; pu::ui::elm::TextBlock::Ref appName; @@ -16,14 +16,12 @@ namespace ui { pu::ui::elm::Image::Ref avatar; pu::ui::elm::TextBlock::Ref userName; - public: + public: HeaderBar(pu::ui::Layout* parent, const std::string& sub = "Save Transfer") { using namespace theme; - bg = pu::ui::elm::Rectangle::New( - 0, 0, layout::ScreenW, layout::HeaderH, color::BgSurface); - divider = pu::ui::elm::Rectangle::New( - 0, layout::HeaderH - 1, layout::ScreenW, 1, color::Divider); + bg = pu::ui::elm::Rectangle::New(0, 0, layout::ScreenW, layout::HeaderH, color::BgSurface); + divider = pu::ui::elm::Rectangle::New(0, layout::HeaderH - 1, layout::ScreenW, 1, color::Divider); appName = pu::ui::elm::TextBlock::New(space::lg, 8, "NXST"); appName->SetFont(type::font(type::Title)); @@ -35,9 +33,7 @@ namespace ui { const int chipW = 280; const int chipX = layout::ScreenW - chipW - space::lg; - chipBg = pu::ui::elm::Rectangle::New( - chipX, 16, chipW, 40, - color::BgSurface2, radius::pill); + chipBg = pu::ui::elm::Rectangle::New(chipX, 16, chipW, 40, color::BgSurface2, radius::pill); chipBg->SetVisible(false); avatar = pu::ui::elm::Image::New(chipX + 4, 20, ""); @@ -65,7 +61,7 @@ namespace ui { userName->SetVisible(show); if (show) { userName->SetText(name); - std::string path = Account::iconPath(*uid); + std::string path = account::iconPath(*uid); if (!path.empty()) { avatar->SetImage(path); avatar->SetWidth(32); @@ -83,4 +79,4 @@ namespace ui { subtitle->SetText(text); } }; -} +} // namespace ui diff --git a/include/ui/HintBar.hpp b/include/nxst/ui/hint_bar.hpp similarity index 72% rename from include/ui/HintBar.hpp rename to include/nxst/ui/hint_bar.hpp index 76107ed..322ecd3 100644 --- a/include/ui/HintBar.hpp +++ b/include/nxst/ui/hint_bar.hpp @@ -1,8 +1,10 @@ #pragma once -#include <pu/Plutonium> -#include <Theme.hpp> -#include <vector> #include <string> +#include <vector> + +#include <pu/Plutonium> + +#include <nxst/ui/theme.hpp> namespace ui { @@ -12,28 +14,27 @@ namespace ui { }; class HintBar { - private: + private: pu::ui::Layout* parent; pu::ui::elm::Rectangle::Ref bg; pu::ui::elm::Rectangle::Ref divider; std::vector<pu::ui::elm::TextBlock::Ref> labels; - public: + public: HintBar(pu::ui::Layout* p) : parent(p) { using namespace theme; - bg = pu::ui::elm::Rectangle::New( - 0, layout::ScreenH - layout::HintH, - layout::ScreenW, layout::HintH, color::BgSurface); - divider = pu::ui::elm::Rectangle::New( - 0, layout::ScreenH - layout::HintH, - layout::ScreenW, 1, color::Divider); + bg = pu::ui::elm::Rectangle::New(0, layout::ScreenH - layout::HintH, layout::ScreenW, + layout::HintH, color::BgSurface); + divider = pu::ui::elm::Rectangle::New(0, layout::ScreenH - layout::HintH, layout::ScreenW, 1, + color::Divider); parent->Add(bg); parent->Add(divider); } void SetHints(const std::vector<Hint>& hints) { using namespace theme; - for (auto& l : labels) l->SetVisible(false); + for (auto& l : labels) + l->SetVisible(false); labels.clear(); int x = layout::ScreenW - space::lg; @@ -52,4 +53,4 @@ namespace ui { } } }; -} +} // namespace ui diff --git a/include/Theme.hpp b/include/nxst/ui/theme.hpp similarity index 91% rename from include/Theme.hpp rename to include/nxst/ui/theme.hpp index f32f31a..a974c8a 100644 --- a/include/Theme.hpp +++ b/include/nxst/ui/theme.hpp @@ -1,8 +1,9 @@ #pragma once -#include <pu/Plutonium> #include <string> +#include <pu/Plutonium> + namespace theme { using pu::ui::Color; @@ -26,7 +27,7 @@ namespace theme { constexpr Color Divider{0x2A, 0x33, 0x42, 0xFF}; constexpr Color FocusRing{0xE2, 0x4B, 0x55, 0xFF}; - } + } // namespace color namespace space { constexpr int xs = 4; @@ -35,14 +36,14 @@ namespace theme { constexpr int lg = 24; constexpr int xl = 32; constexpr int xxl = 48; - } + } // namespace space namespace radius { constexpr int sm = 6; constexpr int md = 12; constexpr int lg = 20; constexpr int pill = 9999; - } + } // namespace radius namespace type { constexpr int Display = 38; @@ -54,7 +55,7 @@ namespace theme { inline std::string font(int size) { return "DefaultFont@" + std::to_string(size); } - } + } // namespace type namespace layout { constexpr int ScreenW = 1280; @@ -63,16 +64,16 @@ namespace theme { constexpr int HintH = 56; constexpr int ContentTop = HeaderH; constexpr int ContentH = ScreenH - HeaderH - HintH; - } + } // namespace layout namespace motion { constexpr int FadeFrames = 20; constexpr int SlideFrames = 14; constexpr int SpinnerFrames = 72; - } + } // namespace motion namespace font { constexpr const char* Default = "Inter"; constexpr const char* Medium = "InterMedium"; - } -} + } // namespace font +} // namespace theme diff --git a/include/TitlesLayout.hpp b/include/nxst/ui/titles_layout.hpp similarity index 77% rename from include/TitlesLayout.hpp rename to include/nxst/ui/titles_layout.hpp index b8b6c39..b7b507c 100644 --- a/include/TitlesLayout.hpp +++ b/include/nxst/ui/titles_layout.hpp @@ -1,12 +1,14 @@ -#include <pu/Plutonium> -#include <const.h> -#include <title.hpp> -#include <account.hpp> +#pragma once +#include <memory> #include <unordered_map> #include <vector> -#include <memory> -#include <ui/HeaderBar.hpp> -#include <ui/HintBar.hpp> + +#include <pu/Plutonium> + +#include <nxst/domain/account.hpp> +#include <nxst/domain/title.hpp> +#include <nxst/ui/header_bar.hpp> +#include <nxst/ui/hint_bar.hpp> namespace ui { @@ -14,8 +16,7 @@ namespace ui { enum class TitlesAction { Transfer, Receive }; class TitlesLayout : public pu::ui::Layout { - private: - + private: pu::ui::elm::Menu::Ref titlesMenu; std::unordered_map<AccountUid, std::vector<pu::ui::elm::MenuItem::Ref>> menuCache; bool m_inputLocked = false; @@ -33,6 +34,7 @@ namespace ui { pu::ui::elm::TextBlock::Ref emptyText; pu::ui::elm::TextBlock::Ref emptySub; + AccountUid current_uid{}; TitlesFocus focus = TitlesFocus::List; TitlesAction action = TitlesAction::Transfer; int lockedListIndex = 0; @@ -43,15 +45,18 @@ namespace ui { void runTransfer(int index, Title& title); void runReceive(int index, Title& title); - public: - + public: TitlesLayout(); - void InitTitles(); - void LockInput() { m_inputLocked = true; } - void UnlockInput() { m_inputLocked = false; } + void InitTitles(AccountUid uid); + void LockInput() { + m_inputLocked = true; + } + void UnlockInput() { + m_inputLocked = false; + } void onInput(u64 Down, u64 Up, u64 Held, pu::ui::TouchPoint Pos); PU_SMART_CTOR(TitlesLayout) }; -} +} // namespace ui diff --git a/include/TransferOverlay.hpp b/include/nxst/ui/transfer_overlay.hpp similarity index 64% rename from include/TransferOverlay.hpp rename to include/nxst/ui/transfer_overlay.hpp index ab9d5a6..1d17b33 100644 --- a/include/TransferOverlay.hpp +++ b/include/nxst/ui/transfer_overlay.hpp @@ -1,12 +1,13 @@ #pragma once #include <pu/Plutonium> -#include <Theme.hpp> -#include <util.hpp> + +#include <nxst/domain/util.hpp> +#include <nxst/ui/theme.hpp> namespace ui { class TransferOverlay : public pu::ui::Overlay { - private: + private: pu::ui::elm::Rectangle::Ref card; pu::ui::elm::TextBlock::Ref titleText; pu::ui::elm::TextBlock::Ref statusText; @@ -20,24 +21,18 @@ namespace ui { static constexpr int CardX = (theme::layout::ScreenW - CardW) / 2; static constexpr int CardY = (theme::layout::ScreenH - CardH) / 2; - public: - TransferOverlay(const std::string &title) - : Overlay(0, 0, theme::layout::ScreenW, theme::layout::ScreenH, theme::color::Scrim) - { + public: + TransferOverlay(const std::string& title) + : Overlay(0, 0, theme::layout::ScreenW, theme::layout::ScreenH, theme::color::Scrim) { using namespace theme; - card = pu::ui::elm::Rectangle::New( - CardX, CardY, CardW, CardH, color::BgSurface, radius::lg); + card = pu::ui::elm::Rectangle::New(CardX, CardY, CardW, CardH, color::BgSurface, radius::lg); - titleText = pu::ui::elm::TextBlock::New( - CardX + space::lg, CardY + space::lg, title); + titleText = pu::ui::elm::TextBlock::New(CardX + space::lg, CardY + space::lg, title); titleText->SetFont(type::font(type::Title)); titleText->SetColor(color::TextPrimary); - statusText = pu::ui::elm::TextBlock::New( - CardX + space::lg, - CardY + space::lg + 56, - ""); + statusText = pu::ui::elm::TextBlock::New(CardX + space::lg, CardY + space::lg + 56, ""); statusText->SetFont(type::font(type::Body)); statusText->SetColor(color::TextSecondary); @@ -45,24 +40,19 @@ namespace ui { int barY = CardY + space::lg + 56 + 56; int barW = CardW - 2 * space::lg; - progressTrack = pu::ui::elm::Rectangle::New( - barX, barY, barW, 8, color::Divider, radius::sm); + progressTrack = pu::ui::elm::Rectangle::New(barX, barY, barW, 8, color::Divider, radius::sm); - progressBar = pu::ui::elm::ProgressBar::New( - barX, barY, barW, 8, 100.0); + progressBar = pu::ui::elm::ProgressBar::New(barX, barY, barW, 8, 100.0); progressBar->SetProgressColor(color::Primary); progressBar->SetBackgroundColor(color::Divider); - indeterminateText = pu::ui::elm::TextBlock::New( - barX, barY - 4, "Preparing transfer..."); + indeterminateText = pu::ui::elm::TextBlock::New(barX, barY - 4, "Preparing transfer..."); indeterminateText->SetFont(type::font(type::Body)); indeterminateText->SetColor(color::TextMuted); indeterminateText->SetVisible(false); - hintText = pu::ui::elm::TextBlock::New( - CardX + space::lg, - CardY + CardH - space::lg - 18, - "B to cancel"); + hintText = + pu::ui::elm::TextBlock::New(CardX + space::lg, CardY + CardH - space::lg - 18, "B to cancel"); hintText->SetFont(type::font(type::Caption)); hintText->SetColor(color::TextMuted); @@ -76,8 +66,8 @@ namespace ui { } PU_SMART_CTOR(TransferOverlay) - void SetStatus(const std::string &status) { - statusText->SetText(StringUtils::elide(status, 56)); + void SetStatus(const std::string& status) { + statusText->SetText(string_utils::elide(status, 56)); } void SetProgress(double val) { @@ -91,4 +81,4 @@ namespace ui { } }; -} +} // namespace ui diff --git a/include/UsersLayout.hpp b/include/nxst/ui/users_layout.hpp similarity index 81% rename from include/UsersLayout.hpp rename to include/nxst/ui/users_layout.hpp index e61f191..c037621 100644 --- a/include/UsersLayout.hpp +++ b/include/nxst/ui/users_layout.hpp @@ -1,22 +1,21 @@ -#include <pu/Plutonium> -#include <const.h> -#include <ui/HeaderBar.hpp> -#include <ui/HintBar.hpp> #include <memory> +#include <pu/Plutonium> + +#include <nxst/ui/header_bar.hpp> +#include <nxst/ui/hint_bar.hpp> + namespace ui { class UsersLayout : public pu::ui::Layout { - private: - + private: pu::ui::elm::Menu::Ref usersMenu; pu::ui::elm::Rectangle::Ref loadingBg; pu::ui::elm::TextBlock::Ref loadingText; std::unique_ptr<HeaderBar> header; std::unique_ptr<HintBar> hints; - public: - + public: UsersLayout(); void onInput(u64 Down, u64 Up, u64 Held, pu::ui::TouchPoint Pos); @@ -24,6 +23,5 @@ namespace ui { int32_t GetCurrentIndex(); PU_SMART_CTOR(UsersLayout) - }; -} +} // namespace ui diff --git a/include/protocol.hpp b/include/protocol.hpp deleted file mode 100644 index 48cd021..0000000 --- a/include/protocol.hpp +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include <cstdint> - -namespace proto { - constexpr uint16_t TCP_PORT = 8080; - constexpr uint16_t MULTICAST_PORT = 8081; - constexpr char MULTICAST_GROUP[] = "239.0.0.1"; - constexpr size_t BUF_SIZE = 65536; - constexpr uint32_t MAX_FILENAME = 4096; - constexpr uint32_t EOF_SENTINEL = 0; - - // Wire layout per file: - // [filename_len : uint32_t LE] — 0 == end-of-stream - // [filename : filename_len bytes] - // [file_size : uint64_t LE] - // [file_data : file_size bytes] -} diff --git a/include/server.hpp b/include/server.hpp deleted file mode 100644 index 5e917e0..0000000 --- a/include/server.hpp +++ /dev/null @@ -1,8 +0,0 @@ -#include <string> -int startSendingThread(); -bool isServerTransferDone(); -bool isServerTransferCancelled(); -bool isServerWorkersIdle(); -void cancelServerTransfer(); -double getServerProgress(); -std::string getServerStatusText(); \ No newline at end of file diff --git a/include/title.hpp b/include/title.hpp deleted file mode 100644 index 31e88b2..0000000 --- a/include/title.hpp +++ /dev/null @@ -1,91 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef TITLE_HPP -#define TITLE_HPP - -#include "account.hpp" -#include "filesystem.hpp" -#include "io.hpp" -#include <algorithm> -#include <stdlib.h> -#include <string> -#include <switch.h> -#include <unordered_map> -#include <utility> -#include <vector> - -class Title { -public: - void init(u8 saveDataType, u64 titleid, AccountUid userID, const std::string& name, const std::string& author); - ~Title() = default; - - std::string author(void); - std::pair<std::string, std::string> displayName(void); - u64 id(void); - std::string name(void); - std::string path(void); - u64 playTimeNanoseconds(void); - std::string playTime(void); - void playTimeNanoseconds(u64 playTimeNanoseconds); - u32 lastPlayedTimestamp(void); - void lastPlayedTimestamp(u32 lastPlayedTimestamp); - std::string fullPath(size_t index); - void refreshDirectories(void); - u64 saveId(); - void saveId(u64 id); - std::vector<std::string> saves(void); - u8 saveDataType(void); - AccountUid userId(void); - std::string userName(void); - -private: - u64 mId; - u64 mSaveId; - AccountUid mUserId; - std::string mUserName; - std::string mName; - std::string mSafeName; - std::string mAuthor; - std::string mPath; - std::vector<std::string> mSaves; - std::vector<std::string> mFullSavePaths; - u8 mSaveDataType; - std::pair<std::string, std::string> mDisplayName; - u64 mPlayTimeNanoseconds; - u32 mLastPlayedTimestamp; -}; - -void getTitle(Title& dst, AccountUid uid, size_t i); -size_t getTitleCount(AccountUid uid); -void loadTitles(void); -bool areTitlesLoaded(void); -void sortTitles(void); -void rotateSortMode(void); -void refreshDirectories(u64 id); -std::unordered_map<std::string, std::string> getCompleteTitleList(void); - -#endif \ No newline at end of file diff --git a/include/ui/Card.hpp b/include/ui/Card.hpp deleted file mode 100644 index c923f4d..0000000 --- a/include/ui/Card.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once -#include <pu/Plutonium> -#include <Theme.hpp> - -namespace ui { - - class Card { - public: - pu::ui::elm::Rectangle::Ref bg; - - Card(pu::ui::Layout* parent, int x, int y, int w, int h, - pu::ui::Color color = theme::color::BgSurface, - int rad = theme::radius::lg) { - bg = pu::ui::elm::Rectangle::New(x, y, w, h, color, rad); - parent->Add(bg); - } - }; -} diff --git a/include/ui/UiContext.hpp b/include/ui/UiContext.hpp deleted file mode 100644 index 3d444e8..0000000 --- a/include/ui/UiContext.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include <string> -#include <optional> -#include <switch.h> -#include <account.hpp> - -namespace ui { - struct UiContext { - std::optional<AccountUid> selectedUser; - std::string selectedUserName; - }; -} diff --git a/include/util.hpp b/include/util.hpp deleted file mode 100644 index 5fafd97..0000000 --- a/include/util.hpp +++ /dev/null @@ -1,53 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#ifndef UTIL_HPP -#define UTIL_HPP - -#include "account.hpp" -#include "common.hpp" -#include "io.hpp" -#include <switch.h> -#include <sys/stat.h> - -// debug -#include <arpa/inet.h> -#include <sys/errno.h> -#include <sys/socket.h> - -void servicesExit(void); -Result servicesInit(void); -HidsysNotificationLedPattern blinkLedPattern(u8 times); -void blinkLed(u8 times); - -namespace StringUtils { - std::string removeAccents(std::string str); - std::string removeNotAscii(std::string str); - std::u16string UTF8toUTF16(const char* src); - std::string elide(const std::string& s, size_t maxChars); -} - -#endif diff --git a/lib b/lib deleted file mode 160000 index b56564b..0000000 --- a/lib +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b56564b70d038c59eef875f2c3cf436859c827f2 diff --git a/source/MainApplication.cpp b/source/MainApplication.cpp deleted file mode 100644 index c2fa9de..0000000 --- a/source/MainApplication.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include <string> -#include <switch.h> -#include <switch/services/hid.h> -#include <vector> -#include <MainApplication.hpp> - -namespace ui { - MainApplication *mainApp; - - void MainApplication::OnLoad() { - mainApp = this; - this->usersLayout = UsersLayout::New(); - this->titlesLayout = TitlesLayout::New(); - this->usersLayout->SetOnInput( - std::bind(&UsersLayout::onInput, this->usersLayout, std::placeholders::_1, std::placeholders::_2, - std::placeholders::_3, std::placeholders::_4)); - this->titlesLayout->SetOnInput(std::bind(&TitlesLayout::onInput, this->titlesLayout,std::placeholders::_1, std::placeholders::_2, - std::placeholders::_3, std::placeholders::_4)); - this->LoadLayout(this->usersLayout); - } -} \ No newline at end of file diff --git a/source/account.cpp b/source/account.cpp deleted file mode 100644 index 12d3967..0000000 --- a/source/account.cpp +++ /dev/null @@ -1,142 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "account.hpp" -#include <main.hpp> -#include <sys/stat.h> -#include <cstdio> - -static std::map<AccountUid, User> mUsers; - -Result Account::init(void) -{ - Result res = accountInitialize(AccountServiceType_Application); - if (R_FAILED(res)) return res; - - AccountUid uids[8]; - s32 count = 0; - accountListAllUsers(uids, 8, &count); - for (s32 i = 0; i < count; i++) { - Account::username(uids[i]); // populates mUsers as side effect - } - return 0; -} - -void Account::exit(void) -{ - accountExit(); -} - -std::vector<AccountUid> Account::ids(void) -{ - std::vector<AccountUid> v; - for (auto& value : mUsers) { - v.push_back(value.second.id); - } - return v; -} - -static User getUser(AccountUid id) -{ - User user{id, ""}; - AccountProfile profile; - AccountProfileBase profilebase; - memset(&profilebase, 0, sizeof(profilebase)); - - if (R_SUCCEEDED(accountGetProfile(&profile, id))) { - if (R_SUCCEEDED(accountProfileGet(&profile, NULL, &profilebase))) { - user.name = std::string(profilebase.nickname); - } - accountProfileClose(&profile); - } - return user; -} - -std::string Account::username(AccountUid id) -{ - std::map<AccountUid, User>::const_iterator got = mUsers.find(id); - if (got == mUsers.end()) { - User user = getUser(id); - mUsers.insert({id, user}); - return user.name; - } - - return got->second.name; -} - -std::string Account::iconPath(AccountUid id) -{ - char path[128]; - snprintf(path, sizeof(path), "sdmc:/switch/NXST/cache/%016lX%016lX.jpg", - id.uid[0], id.uid[1]); - - struct stat st; - if (stat(path, &st) == 0 && st.st_size > 0) return std::string(path); - - mkdir("sdmc:/switch", 0755); - mkdir("sdmc:/switch/NXST", 0755); - mkdir("sdmc:/switch/NXST/cache", 0755); - - AccountProfile profile; - if (R_FAILED(accountGetProfile(&profile, id))) return ""; - - u32 imgSize = 0; - if (R_FAILED(accountProfileGetImageSize(&profile, &imgSize)) || imgSize == 0) { - accountProfileClose(&profile); - return ""; - } - - std::vector<u8> buf(imgSize); - u32 outSize = 0; - Result r = accountProfileLoadImage(&profile, buf.data(), imgSize, &outSize); - accountProfileClose(&profile); - if (R_FAILED(r) || outSize == 0) return ""; - - FILE* f = fopen(path, "wb"); - if (!f) return ""; - fwrite(buf.data(), 1, outSize, f); - fclose(f); - return std::string(path); -} - -AccountUid Account::selectAccount(void) -{ - LibAppletArgs args; - libappletArgsCreate(&args, 0x10000); - u8 st_in[0xA0] = {0}; - u8 st_out[0x18] = {0}; - size_t repsz; - - Result res = libappletLaunch(AppletId_LibraryAppletPlayerSelect, &args, st_in, 0xA0, st_out, 0x18, &repsz); - if (R_SUCCEEDED(res)) { - u64 lres = *(u64*)st_out; - AccountUid uid = *(AccountUid*)&st_out[8]; - if (lres == 0) - return uid; - } - - return g_currentUId; -} \ No newline at end of file diff --git a/source/client.cpp b/source/client.cpp deleted file mode 100644 index 0ea5350..0000000 --- a/source/client.cpp +++ /dev/null @@ -1,249 +0,0 @@ -#include <arpa/inet.h> -#include <chrono> -#include <cstring> -#include <filesystem> -#include <sys/select.h> -#include <fstream> -#include <iostream> -#include <netinet/in.h> -#include <pthread.h> -#include <sys/socket.h> -#include <sys/types.h> -#include <unistd.h> -#include <vector> - -#ifdef __SWITCH__ -#include <client.hpp> -#include <io.hpp> -#include <switch.h> -#endif - -#include <protocol.hpp> -#include <TransferState.hpp> - -namespace fs = std::filesystem; -using path = fs::path; - -static TransferState g_client_state; -static std::atomic<int> g_client_udp_sock{-1}; -static std::atomic<int> g_client_tcp_sock{-1}; -static std::atomic<bool> g_client_thread_active{false}; - -bool isClientTransferDone() { return g_client_state.done.load(); } -bool isClientTransferCancelled() { return g_client_state.cancelled.load(); } -bool isClientConnectionFailed() { return g_client_state.connection_failed.load(); } -bool isClientProgressKnown() { return g_client_state.bytes_total.load() > 0; } -bool isClientWorkersIdle() { return !g_client_thread_active.load(); } -double getClientProgress() { return g_client_state.progress(); } -std::string getClientStatusText() { return g_client_state.getStatus(); } -std::string getClientFailReason() { return g_client_state.fail_reason; } - -void cancelClientTransfer() { - g_client_state.cancelled.store(true); - int udp = g_client_udp_sock.exchange(-1); - if (udp >= 0) { - shutdown(udp, SHUT_RDWR); - close(udp); - } - int tcp = g_client_tcp_sock.exchange(-1); - if (tcp >= 0) { - shutdown(tcp, SHUT_RDWR); - close(tcp); - } -} - -static bool send_all(int sock, const void* buf, size_t len) { - size_t sent = 0; - while (sent < len) { - ssize_t n = send(sock, static_cast<const char*>(buf) + sent, len - sent, 0); - if (n <= 0) return false; - sent += n; - } - return true; -} - -static bool sendFile(int sock, const fs::path& filepath) { - std::ifstream infile(filepath, std::ios::binary | std::ios::ate); - if (!infile.is_open()) { - std::cerr << "File not found: " << filepath << std::endl; - return false; - } - - uint32_t filename_len = (uint32_t)filepath.string().size(); - uint64_t file_size = (uint64_t)infile.tellg(); - infile.seekg(0, std::ios::beg); - - std::cout << "Sending: " << filepath << " (" << file_size << " bytes)" << std::endl; - - if (!send_all(sock, &filename_len, sizeof(filename_len))) return false; - if (!send_all(sock, filepath.c_str(), filename_len)) return false; - if (!send_all(sock, &file_size, sizeof(file_size))) return false; - - std::vector<char> buffer(proto::BUF_SIZE); - uint64_t remaining = file_size; - while (remaining > 0) { - size_t to_read = (size_t)std::min(remaining, (uint64_t)proto::BUF_SIZE); - infile.read(buffer.data(), (std::streamsize)to_read); - std::streamsize count = infile.gcount(); - if (count <= 0) break; - if (!send_all(sock, buffer.data(), (size_t)count)) { - std::cerr << "Failed to send data for: " << filepath << std::endl; - return false; - } - g_client_state.bytes_done.fetch_add((uint64_t)count); - remaining -= (uint64_t)count; - } - - return true; -} - -struct ThreadArgs { size_t index; AccountUid uid; }; - -static int find_server(char* server_ip); - -static void fail_connect(const std::string& reason) { - g_client_state.fail_reason = reason; - g_client_state.connection_failed.store(true); - g_client_state.done.store(true); -} - -static void* discovery_and_send_thread(void* arg) { - g_client_thread_active.store(true); - ThreadArgs* targs = static_cast<ThreadArgs*>(arg); - size_t index = targs->index; - AccountUid uid = targs->uid; - delete targs; - - auto finish = [](void*) { - g_client_state.done.store(true); - g_client_thread_active.store(false); - return (void*)nullptr; - }; - - char server_ip[INET_ADDRSTRLEN]; - if (find_server(server_ip) != 0) { - if (!g_client_state.cancelled.load()) - fail_connect("No receiver found.\nMake sure the other Switch is in Receive mode."); - return finish(nullptr); - } - if (g_client_state.cancelled.load()) return finish(nullptr); - - g_client_state.setStatus("Creating backup..."); - auto backupResult = io::backup(index, uid); - if (!std::get<0>(backupResult)) { - fail_connect("Failed to create backup:\n" + std::get<2>(backupResult)); - return finish(nullptr); - } - fs::path directory = std::get<2>(backupResult); - - if (g_client_state.cancelled.load()) return finish(nullptr); - - g_client_state.setStatus("Connecting..."); - int tcp_fd = socket(AF_INET, SOCK_STREAM, 0); - if (tcp_fd < 0) { fail_connect("Failed to open socket."); return finish(nullptr); } - g_client_tcp_sock.store(tcp_fd); - - auto release_tcp = [&]() { - int owned = g_client_tcp_sock.exchange(-1); - if (owned == tcp_fd) close(tcp_fd); - }; - - sockaddr_in serv{}; - serv.sin_family = AF_INET; - serv.sin_port = htons(proto::TCP_PORT); - if (inet_pton(AF_INET, server_ip, &serv.sin_addr) <= 0 || - connect(tcp_fd, (sockaddr*)&serv, sizeof(serv)) < 0) { - if (!g_client_state.cancelled.load()) - fail_connect("Failed to connect to receiver."); - release_tcp(); - return finish(nullptr); - } - - uint64_t total = 0; - for (const auto& entry : fs::recursive_directory_iterator(directory)) - if (fs::is_regular_file(entry.path())) - total += fs::file_size(entry.path()); - g_client_state.bytes_total.store(total); - - for (const auto& entry : fs::recursive_directory_iterator(directory)) { - if (g_client_state.cancelled.load()) break; - const path& p = entry.path(); - if (fs::is_regular_file(p)) { - g_client_state.setStatus(p.filename().string()); - if (!sendFile(tcp_fd, p)) break; - } - } - - uint32_t sentinel = proto::EOF_SENTINEL; - send_all(tcp_fd, &sentinel, sizeof(sentinel)); - - release_tcp(); - g_client_state.setStatus(""); - return finish(nullptr); -} - -static int find_server(char* server_ip) { - int udp_fd = socket(AF_INET, SOCK_DGRAM, 0); - if (udp_fd < 0) return -1; - g_client_udp_sock.store(udp_fd); - - auto release_udp = [&]() { - int owned = g_client_udp_sock.exchange(-1); - if (owned == udp_fd) close(udp_fd); - }; - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(proto::MULTICAST_PORT); - addr.sin_addr.s_addr = inet_addr(proto::MULTICAST_GROUP); - - const char* msg = "DISCOVER_SERVER"; - if (sendto(udp_fd, msg, strlen(msg), 0, (sockaddr*)&addr, sizeof(addr)) < 0) { - release_udp(); - return -1; - } - - // Poll in 100ms slices so we can react to cancel within 100ms - auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); - while (std::chrono::steady_clock::now() < deadline) { - if (g_client_state.cancelled.load()) { - release_udp(); - return -1; - } - struct timeval tv{0, 100000}; - fd_set fds; - FD_ZERO(&fds); - FD_SET(udp_fd, &fds); - if (select(udp_fd + 1, &fds, nullptr, nullptr, &tv) > 0) { - sockaddr_in from{}; - socklen_t fromlen = sizeof(from); - char buf[256]; - ssize_t n = recvfrom(udp_fd, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); - if (n > 0) { - buf[n] = '\0'; - if (strcmp(buf, "SERVER_HERE") == 0) { - inet_ntop(AF_INET, &from.sin_addr, server_ip, INET_ADDRSTRLEN); - release_udp(); - return 0; - } - } - } - } - - release_udp(); - return -1; -} - -int transfer_files(size_t index, AccountUid uid) { - g_client_state.reset(); - g_client_state.setStatus("Searching for receiver..."); - - ThreadArgs* arg = new ThreadArgs{index, uid}; - pthread_t thread; - if (pthread_create(&thread, nullptr, discovery_and_send_thread, arg) != 0) { - delete arg; - return -1; - } - pthread_detach(thread); - return 0; -} diff --git a/source/common.cpp b/source/common.cpp deleted file mode 100644 index dbd2cea..0000000 --- a/source/common.cpp +++ /dev/null @@ -1,124 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "common.hpp" - -std::string DateTime::timeStr(void) -{ - time_t unixTime; - struct tm timeStruct; - time(&unixTime); - localtime_r(&unixTime, &timeStruct); - return StringUtils::format("%02i:%02i:%02i", timeStruct.tm_hour, timeStruct.tm_min, timeStruct.tm_sec); -} - -std::string DateTime::dateTimeStr(void) -{ - time_t unixTime; - struct tm timeStruct; - time(&unixTime); - localtime_r(&unixTime, &timeStruct); - return StringUtils::format("%04i%02i%02i-%02i%02i%02i", timeStruct.tm_year + 1900, timeStruct.tm_mon + 1, timeStruct.tm_mday, timeStruct.tm_hour, - timeStruct.tm_min, timeStruct.tm_sec); -} - -std::string DateTime::logDateTime(void) -{ - time_t unixTime; - struct tm timeStruct; - time(&unixTime); - localtime_r(&unixTime, &timeStruct); - return StringUtils::format("%04i-%02i-%02i %02i:%02i:%02i", timeStruct.tm_year + 1900, timeStruct.tm_mon + 1, timeStruct.tm_mday, - timeStruct.tm_hour, timeStruct.tm_min, timeStruct.tm_sec); -} - -std::string StringUtils::UTF16toUTF8(const std::u16string& src) -{ - static std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t> convert; - std::string dst = convert.to_bytes(src); - return dst; -} - -std::string StringUtils::removeForbiddenCharacters(std::string src) -{ - static const std::string illegalChars = ".,!\\/:?*\"<>|"; - for (size_t i = 0, sz = src.length(); i < sz; i++) { - if (illegalChars.find(src[i]) != std::string::npos) { - src[i] = ' '; - } - } - - size_t i; - for (i = src.length() - 1; i > 0 && src[i] == ' '; i--) - ; - src.erase(i + 1, src.length() - i); - - return src; -} - -std::string StringUtils::format(const std::string fmt_str, ...) -{ - va_list ap; - char* fp = NULL; - va_start(ap, fmt_str); - vasprintf(&fp, fmt_str.c_str(), ap); - va_end(ap); - std::unique_ptr<char[]> formatted(fp); - return std::string(formatted.get()); -} - -bool StringUtils::containsInvalidChar(const std::string& str) -{ - for (size_t i = 0, sz = str.length(); i < sz; i++) { - if (!isascii(str[i])) { - return true; - } - } - return false; -} - -void StringUtils::ltrim(std::string& s) -{ - s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](int ch) { return !std::isspace(ch); })); -} - -void StringUtils::rtrim(std::string& s) -{ - s.erase(std::find_if(s.rbegin(), s.rend(), [](int ch) { return !std::isspace(ch); }).base(), s.end()); -} - -void StringUtils::trim(std::string& s) -{ - ltrim(s); - rtrim(s); -} - -char* getConsoleIP(void) -{ - struct in_addr in; - in.s_addr = gethostid(); - return inet_ntoa(in); -} \ No newline at end of file diff --git a/source/directory.cpp b/source/directory.cpp deleted file mode 100644 index b6acbe3..0000000 --- a/source/directory.cpp +++ /dev/null @@ -1,76 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "directory.hpp" - -Directory::Directory(const std::string& root) -{ - mGood = false; - mError = 0; - mList.clear(); - - DIR* dir = opendir(root.c_str()); - struct dirent* ent; - - if (dir == NULL) { - mError = (Result)errno; - } - else { - while ((ent = readdir(dir))) { - std::string name = std::string(ent->d_name); - bool directory = ent->d_type == DT_DIR; - struct DirectoryEntry de = {name, directory}; - mList.push_back(de); - } - closedir(dir); - mGood = true; - } -} - -Result Directory::error(void) -{ - return mError; -} - -bool Directory::good(void) -{ - return mGood; -} - -std::string Directory::entry(size_t index) -{ - return index < mList.size() ? mList.at(index).name : ""; -} - -bool Directory::folder(size_t index) -{ - return index < mList.size() ? mList.at(index).directory : false; -} - -size_t Directory::size(void) -{ - return mList.size(); -} \ No newline at end of file diff --git a/source/filesystem.cpp b/source/filesystem.cpp deleted file mode 100644 index 7df86a1..0000000 --- a/source/filesystem.cpp +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "filesystem.hpp" - -Result FileSystem::mount(FsFileSystem* fileSystem, u64 titleID, AccountUid userID) -{ - return fsOpen_SaveData(fileSystem, titleID, userID); -} - -int FileSystem::mount(FsFileSystem fs) -{ - return fsdevMountDevice("save", fs); -} - -void FileSystem::unmount(void) -{ - fsdevUnmountDevice("save"); -} \ No newline at end of file diff --git a/source/io.cpp b/source/io.cpp deleted file mode 100644 index 4364d8c..0000000 --- a/source/io.cpp +++ /dev/null @@ -1,333 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "io.hpp" -#include "main.hpp" -#include <logger.hpp> - -bool io::fileExists(const std::string& path) -{ - struct stat buffer; - return (stat(path.c_str(), &buffer) == 0); -} - -void io::copyFile(const std::string& srcPath, const std::string& dstPath) -{ - g_isTransferringFile = true; - - FILE* src = fopen(srcPath.c_str(), "rb"); - if (src == NULL) { - Logger::getInstance().log(Logger::ERROR, "Failed to open source file " + srcPath + " during copy with errno %d. Skipping...", errno); - return; - } - FILE* dst = fopen(dstPath.c_str(), "wb"); - if (dst == NULL) { - Logger::getInstance().log(Logger::ERROR, "Failed to open destination file " + dstPath + " during copy with errno " + std::to_string(errno) + ". Skipping..."); - fclose(src); - return; - } - - fseek(src, 0, SEEK_END); - u64 sz = ftell(src); - rewind(src); - - u8* buf = new u8[BUFFER_SIZE]; - u64 offset = 0; - - size_t slashpos = srcPath.rfind("/"); - g_currentFile = srcPath.substr(slashpos + 1, srcPath.length() - slashpos - 1); - - while (offset < sz) { - u32 count = fread((char*)buf, 1, BUFFER_SIZE, src); - if (count == 0) { - Logger::getInstance().log(Logger::ERROR, "fread returned 0 for file {} at offset {}/{} with errno {}. Aborting copy.", srcPath, offset, sz, errno); - break; - } - fwrite((char*)buf, 1, count, dst); - offset += count; - } - - delete[] buf; - fclose(src); - fclose(dst); - - if (dstPath.rfind("save:/", 0) == 0) { - fsdevCommitDevice("save"); - } - - g_isTransferringFile = false; -} - -Result io::copyDirectory(const std::string& srcPath, const std::string& dstPath) -{ - Result res = 0; - bool quit = false; - Directory items(srcPath); - - if (!items.good()) { - return items.error(); - } - - for (size_t i = 0, sz = items.size(); i < sz && !quit; i++) { - std::string newsrc = srcPath + items.entry(i); - std::string newdst = dstPath + items.entry(i); - - if (items.folder(i)) { - res = io::createDirectory(newdst); - if (R_SUCCEEDED(res)) { - newsrc += "/"; - newdst += "/"; - res = io::copyDirectory(newsrc, newdst); - } - else { - quit = true; - } - } - else { - io::copyFile(newsrc, newdst); - } - } - - return 0; -} - -Result io::createDirectory(const std::string& path) -{ - mkdir(path.c_str(), 777); - return 0; -} - -bool io::directoryExists(const std::string& path) -{ - struct stat sb; - return (stat(path.c_str(), &sb) == 0 && S_ISDIR(sb.st_mode)); -} - -Result io::deleteFolderRecursively(const std::string& path) -{ - Directory dir(path); - if (!dir.good()) { - return dir.error(); - } - - for (size_t i = 0, sz = dir.size(); i < sz; i++) { - if (dir.folder(i)) { - std::string newpath = path + "/" + dir.entry(i) + "/"; - deleteFolderRecursively(newpath); - newpath = path + dir.entry(i); - rmdir(newpath.c_str()); - } - else { - std::string newpath = path + dir.entry(i); - std::remove(newpath.c_str()); - } - } - - rmdir(path.c_str()); - return 0; -} - -std::tuple<bool, Result, std::string> io::backup(size_t index, AccountUid uid) -{ - Result res = 0; - std::tuple<bool, Result, std::string> ret = std::make_tuple(false, -1, ""); - Title title; - getTitle(title, uid, index); - - Logger::getInstance().log(Logger::INFO, "Started backup of %s. Title id: 0x%016lX; User id: 0x%lX%lX.", title.name().c_str(), title.id(), - title.userId().uid[1], title.userId().uid[0]); - - FsFileSystem fileSystem; - res = FileSystem::mount(&fileSystem, title.id(), title.userId()); - if (R_SUCCEEDED(res)) { - int rc = FileSystem::mount(fileSystem); - if (rc == -1) { - fsFsClose(&fileSystem); - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Failed to mount filesystem during backup. Title id: 0x%016lX; User id: 0x%lX%lX.", title.id(), - title.userId().uid[1], title.userId().uid[0]); - return std::make_tuple(false, -2, "Failed to mount save."); - } - } - else { - Logger::getInstance().log(Logger::ERROR, - "Failed to mount filesystem during backup with result 0x%08lX. Title id: 0x%016lX; User id: 0x%lX%lX.", res, title.id(), - title.userId().uid[1], title.userId().uid[0]); - return std::make_tuple(false, res, "Failed to mount save."); - } - - std::string suggestion = StringUtils::removeNotAscii(StringUtils::removeAccents(Account::username(title.userId()))); - - io::createDirectory(title.path()); - std::string dstPath = title.path() + "/" + suggestion; - - // Write to a temp dir first; rename on success so the existing backup - // is never destroyed if the copy is interrupted mid-way. - std::string tmpPath = dstPath + ".tmp"; - if (io::directoryExists(tmpPath)) { - io::deleteFolderRecursively((tmpPath + "/").c_str()); - } - io::createDirectory(tmpPath); - res = io::copyDirectory("save:/", tmpPath + "/"); - if (R_FAILED(res)) { - FileSystem::unmount(); - io::deleteFolderRecursively((tmpPath + "/").c_str()); - Logger::getInstance().log(Logger::ERROR, "Failed to copy directory to " + tmpPath + " with result 0x%08lX.", res); - return std::make_tuple(false, res, "Failed to backup save."); - } - - // Swap: delete old backup only after new one is fully written. - if (io::directoryExists(dstPath)) { - io::deleteFolderRecursively((dstPath + "/").c_str()); - } - if (rename(tmpPath.c_str(), dstPath.c_str()) != 0) { - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Failed to rename temp backup to " + dstPath); - return std::make_tuple(false, (Result)-1, "Failed to finalise backup."); - } - - refreshDirectories(title.id()); - - FileSystem::unmount(); - - ret = std::make_tuple(true, 0, dstPath); - Logger::getInstance().log(Logger::INFO, "Backup succeeded."); - return ret; -} - -std::tuple<bool, Result, std::string> io::restore(size_t index, AccountUid uid, size_t cellIndex, const std::string& nameFromCell) -{ - Result res = 0; - std::tuple<bool, Result, std::string> ret = std::make_tuple(false, -1, ""); - Title title; - getTitle(title, uid, index); - - Logger::getInstance().log(Logger::INFO, "Started restore of %s. Title id: 0x%016lX; User id: 0x%lX%lX.", title.name().c_str(), title.id(), - title.userId().uid[1], title.userId().uid[0]); - - // Если сейв ещё не существует (игра не запускалась) — создаём его через NACP. - // fsCreateSaveDataFileSystem возвращает ошибку если сейв уже есть — это нормально. - { - NsApplicationControlData* nsacd = (NsApplicationControlData*)malloc(sizeof(NsApplicationControlData)); - if (nsacd != NULL) { - memset(nsacd, 0, sizeof(NsApplicationControlData)); - size_t outsize = 0; - if (R_SUCCEEDED(nsGetApplicationControlData(NsApplicationControlSource_Storage, title.id(), nsacd, sizeof(NsApplicationControlData), &outsize))) { - static const FsSaveDataMetaInfo meta = {.size = 0x40060, .type = FsSaveDataMetaType_Thumbnail}; - - FsSaveDataAttribute attr = {}; - attr.application_id = title.id(); - attr.uid = uid; - attr.save_data_type = FsSaveDataType_Account; - attr.save_data_rank = FsSaveDataRank_Primary; - - FsSaveDataCreationInfo createInfo = {}; - createInfo.save_data_size = (s64)nsacd->nacp.user_account_save_data_size; - createInfo.journal_size = (s64)nsacd->nacp.user_account_save_data_journal_size; - createInfo.available_size = 0x4000; - createInfo.owner_id = nsacd->nacp.save_data_owner_id; - createInfo.save_data_space_id = FsSaveDataSpaceId_User; - - fsCreateSaveDataFileSystem(&attr, &createInfo, &meta); - } - free(nsacd); - } - } - - FsFileSystem fileSystem; - res = FileSystem::mount(&fileSystem, title.id(), uid); - if (R_SUCCEEDED(res)) { - int rc = FileSystem::mount(fileSystem); - if (rc == -1) { - fsFsClose(&fileSystem); - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Failed to mount filesystem during restore. Title id: 0x%016lX; User id: 0x%lX%lX.", title.id(), - uid.uid[1], uid.uid[0]); - return std::make_tuple(false, -2, "Failed to mount save."); - } - } - else { - Logger::getInstance().log(Logger::ERROR, - "Failed to mount filesystem during restore with result 0x%08lX. Title id: 0x%016lX; User id: 0x%lX%lX.", res, title.id(), - uid.uid[1], uid.uid[0]); - return std::make_tuple(false, res, "Failed to mount save."); - } - - std::string suggestion = StringUtils::removeNotAscii(StringUtils::removeAccents(Account::username(uid))); - std::string srcPath = title.path() + "/" + suggestion + "/"; - std::string dstPath = "save:/"; - - // Validate source exists and is non-empty before touching live save data. - { - Directory srcCheck(srcPath); - if (!srcCheck.good() || srcCheck.size() == 0) { - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Restore source is empty or missing: " + srcPath); - return std::make_tuple(false, (Result)-1, "Restore source is empty or missing."); - } - } - - { - Directory saveRoot(dstPath); - for (size_t i = 0, sz = saveRoot.size(); i < sz; i++) { - if (saveRoot.folder(i)) { - io::deleteFolderRecursively((dstPath + saveRoot.entry(i) + "/").c_str()); - rmdir((dstPath + saveRoot.entry(i)).c_str()); - } else { - std::remove((dstPath + saveRoot.entry(i)).c_str()); - } - } - } - - res = fsdevCommitDevice("save"); - if (R_FAILED(res)) { - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Failed to commit save after clearing with result 0x%08lX.", res); - return std::make_tuple(false, res, "Failed to commit save after delete."); - } - - res = io::copyDirectory(srcPath, dstPath); - if (R_FAILED(res)) { - FileSystem::unmount(); - Logger::getInstance().log(Logger::ERROR, "Failed to copy directory " + srcPath + " to " + dstPath + " with result 0x%08lX. Skipping...", res); - return std::make_tuple(false, res, "Failed to restore save."); - } - - res = fsdevCommitDevice("save"); - if (R_FAILED(res)) { - Logger::getInstance().log(Logger::ERROR, "Failed to commit save with result 0x%08lX.", res); - return std::make_tuple(false, res, "Failed to commit to save device."); - } - else { - blinkLed(4); - ret = std::make_tuple(true, 0, nameFromCell + "\nhas been restored successfully."); - } - - FileSystem::unmount(); - - Logger::getInstance().log(Logger::INFO, "Restore succeeded."); - return ret; -} \ No newline at end of file diff --git a/source/server.cpp b/source/server.cpp deleted file mode 100644 index a864df3..0000000 --- a/source/server.cpp +++ /dev/null @@ -1,345 +0,0 @@ -#include <arpa/inet.h> -#include <atomic> -#include <cstring> -#include <iomanip> -#include <iostream> -#include <netinet/in.h> -#include <pthread.h> -#include <string.h> -#include <sys/socket.h> -#include <sys/stat.h> -#include <sys/types.h> -#include <unistd.h> -#include <vector> - -#ifdef __SWITCH__ -#include <server.hpp> -#include <switch.h> -#include <main.hpp> -#endif - -#include <protocol.hpp> -#include <TransferState.hpp> -#include <net/Socket.hpp> - -static TransferState g_server_state; -static std::atomic<int> g_server_client_sock{-1}; -static std::atomic<int> g_server_listen_sock{-1}; -static std::atomic<int> g_broadcast_sock{-1}; -static std::atomic<bool> g_accept_thread_active{false}; -static std::atomic<bool> g_broadcast_thread_active{false}; -static pthread_t g_broadcast_thread{}; - -bool isServerTransferDone() { return g_server_state.done.load(); } -bool isServerTransferCancelled() { return g_server_state.cancelled.load(); } -bool isServerWorkersIdle() { return !g_accept_thread_active.load() && !g_broadcast_thread_active.load(); } -double getServerProgress() { return g_server_state.progress(); } -std::string getServerStatusText() { return g_server_state.getStatus(); } - -void cancelServerTransfer() { - g_server_state.cancelled.store(true); - int sock = g_server_client_sock.exchange(-1); - if (sock >= 0) { - shutdown(sock, SHUT_RDWR); - close(sock); - } - int lsock = g_server_listen_sock.exchange(-1); - if (lsock >= 0) { - shutdown(lsock, SHUT_RDWR); - close(lsock); - } - int bsock = g_broadcast_sock.exchange(-1); - if (bsock >= 0) { - shutdown(bsock, SHUT_RDWR); - close(bsock); - } - if (g_broadcast_thread_active.load()) { - pthread_cancel(g_broadcast_thread); - } -} - -#ifdef __SWITCH__ -static std::string replaceUsername(const std::string& path) { - std::string username = StringUtils::removeNotAscii( - StringUtils::removeAccents(Account::username(g_currentUId))); - size_t lastSlash = path.rfind('/'); - if (lastSlash == std::string::npos) return path; - size_t prevSlash = path.rfind('/', lastSlash - 1); - if (prevSlash == std::string::npos) - return username + path.substr(lastSlash); - return path.substr(0, prevSlash + 1) + username + path.substr(lastSlash); -} -#endif - -static bool recv_all(int sock, void* buf, size_t len) { - size_t received = 0; - while (received < len) { - ssize_t n = read(sock, static_cast<char*>(buf) + received, len - received); - if (n <= 0) return false; - received += n; - } - return true; -} - -static void mkdirs(const std::string& path) { - for (size_t i = 1; i < path.size(); i++) { - if (path[i] == '/') { - std::string component = path.substr(0, i); - mkdir(component.c_str(), 0777); - } - } - mkdir(path.c_str(), 0777); -} - -static void receive_file(int sock, const std::string& relative_path, uint64_t file_size) { - std::cout << "Receiving: " << relative_path << " (" << file_size << " bytes)" << std::endl; - - size_t last_slash = relative_path.rfind('/'); - if (last_slash != std::string::npos) { - std::string dir = relative_path.substr(0, last_slash); - if (!dir.empty()) mkdirs(dir); - } - - FILE* outfile = fopen(relative_path.c_str(), "wb"); - if (!outfile) { - std::cerr << "Failed to open for writing: " << relative_path - << " errno=" << errno << std::endl; - // Drain so sender doesn't hang - std::vector<char> drain(proto::BUF_SIZE); - uint64_t remaining = file_size; - while (remaining > 0) { - size_t to_read = (size_t)std::min(remaining, (uint64_t)proto::BUF_SIZE); - ssize_t n = read(sock, drain.data(), to_read); - if (n <= 0) break; - remaining -= (uint64_t)n; - } - return; - } - - g_server_state.bytes_total.store(file_size); - g_server_state.bytes_done.store(0); - - std::vector<char> buffer(proto::BUF_SIZE); - uint64_t total_received = 0; - while (total_received < file_size) { - size_t to_read = (size_t)std::min(file_size - total_received, (uint64_t)proto::BUF_SIZE); - ssize_t n = read(sock, buffer.data(), to_read); - if (n <= 0) { - std::cerr << "Read error receiving: " << relative_path << std::endl; - break; - } - fwrite(buffer.data(), 1, (size_t)n, outfile); - total_received += (uint64_t)n; - g_server_state.bytes_done.store(total_received); - } - - fclose(outfile); - std::cout << "Received: " << relative_path << std::endl; -} - -static void* handle_client(void* socket_desc) { - int client_socket = *(int*)socket_desc; - delete static_cast<int*>(socket_desc); - - while (true) { - uint32_t filename_len = 0; - if (!recv_all(client_socket, &filename_len, sizeof(filename_len))) - break; - - if (filename_len == proto::EOF_SENTINEL) { - std::cout << "End of transfer." << std::endl; - break; - } - - if (filename_len > proto::MAX_FILENAME) { - std::cerr << "filename_len=" << filename_len << " exceeds MAX_FILENAME, aborting." << std::endl; - break; - } - - std::vector<char> filename(filename_len + 1, '\0'); - if (!recv_all(client_socket, filename.data(), filename_len)) { - std::cerr << "Short read on filename, aborting." << std::endl; - break; - } - std::string filename_str(filename.data(), filename_len); - -#ifdef __SWITCH__ - filename_str = replaceUsername(filename_str); -#endif - - { - size_t sl = filename_str.rfind('/'); - g_server_state.setStatus( - sl != std::string::npos ? filename_str.substr(sl + 1) : filename_str); - } - - uint64_t file_size = 0; - if (!recv_all(client_socket, &file_size, sizeof(file_size))) { - std::cerr << "Short read on file_size, aborting." << std::endl; - break; - } - - receive_file(client_socket, filename_str, file_size); - } - - int owned_client = g_server_client_sock.exchange(-1); - if (owned_client == client_socket) { - close(client_socket); - } - return nullptr; -} - -struct AcceptArgs { int server_fd; }; - -static void* accept_and_handle(void* arg) { - g_accept_thread_active.store(true); - int server_fd = static_cast<AcceptArgs*>(arg)->server_fd; - delete static_cast<AcceptArgs*>(arg); - - g_server_listen_sock.store(server_fd); - sockaddr_in client_addr{}; - socklen_t client_len = sizeof(client_addr); - int client_socket = accept(server_fd, (sockaddr*)&client_addr, &client_len); - int owned_listen = g_server_listen_sock.exchange(-1); - if (owned_listen == server_fd) { - close(server_fd); - } - - if (client_socket >= 0) { - g_server_client_sock.store(client_socket); - int* pclient = new (std::nothrow) int(client_socket); - if (pclient) { - handle_client(pclient); - } else { - close(client_socket); - } - } - - g_server_state.done.store(true); - g_accept_thread_active.store(false); - return nullptr; -} - -static void* broadcast_listener(void* arg) { - g_broadcast_thread_active.store(true); - pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, nullptr); - pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, nullptr); - int udp = socket(AF_INET, SOCK_DGRAM, 0); - if (udp < 0) { - perror("broadcast_listener: socket"); - g_broadcast_thread_active.store(false); - return nullptr; - } - - g_broadcast_sock.store(udp); - - struct timeval tv{0, 20000}; // 20ms poll so cancel/exit wins race with socketExit - setsockopt(udp, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_port = htons(proto::MULTICAST_PORT); - - if (bind(udp, (sockaddr*)&addr, sizeof(addr)) < 0) { - perror("broadcast_listener: bind"); - int owned = g_broadcast_sock.exchange(-1); - if (owned == udp) close(udp); - g_broadcast_thread_active.store(false); - return nullptr; - } - - ip_mreq group{}; - group.imr_multiaddr.s_addr = inet_addr(proto::MULTICAST_GROUP); - group.imr_interface.s_addr = htonl(INADDR_ANY); - if (setsockopt(udp, IPPROTO_IP, IP_ADD_MEMBERSHIP, &group, sizeof(group)) < 0) { - perror("broadcast_listener: setsockopt"); - int owned = g_broadcast_sock.exchange(-1); - if (owned == udp) close(udp); - g_broadcast_thread_active.store(false); - return nullptr; - } - - std::cout << "Broadcast listener started" << std::endl; - char buf[256]; - sockaddr_in from{}; - socklen_t fromlen = sizeof(from); - - while (true) { - ssize_t n = recvfrom(udp, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); - if (n < 0) { - if (g_server_state.cancelled.load()) break; - continue; - } - buf[n] = '\0'; - if (strcmp(buf, "DISCOVER_SERVER") == 0) { - const char* reply = "SERVER_HERE"; - sendto(udp, reply, strlen(reply), 0, (sockaddr*)&from, fromlen); - std::cout << "Discovery replied." << std::endl; - break; - } - } - - int owned = g_broadcast_sock.exchange(-1); - if (owned == udp) close(udp); - g_broadcast_thread_active.store(false); - return nullptr; -} - -int startSendingThread() { - g_server_state.reset(); - g_server_state.setStatus("Waiting for connection..."); - - pthread_t broadcast_thread; - if (pthread_create(&broadcast_thread, nullptr, broadcast_listener, nullptr) != 0) { - perror("startSendingThread: broadcast thread"); - return 1; - } - g_broadcast_thread = broadcast_thread; - pthread_detach(broadcast_thread); - - Socket server(socket(AF_INET, SOCK_STREAM, 0)); - if (!server.valid()) { - perror("startSendingThread: socket"); - cancelServerTransfer(); - return 1; - } - - int yes = 1; - setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = INADDR_ANY; - addr.sin_port = htons(proto::TCP_PORT); - - if (bind(server, (sockaddr*)&addr, sizeof(addr)) < 0) { - perror("startSendingThread: bind"); - cancelServerTransfer(); - return 1; - } - if (listen(server, 3) < 0) { - perror("startSendingThread: listen"); - cancelServerTransfer(); - return 1; - } - - AcceptArgs* acc_args = new AcceptArgs{server.fd}; - pthread_t accept_thread; - if (pthread_create(&accept_thread, nullptr, accept_and_handle, acc_args) != 0) { - delete acc_args; - cancelServerTransfer(); - return 1; - } - pthread_detach(accept_thread); - server.release(); // accepted by accept_and_handle - return 0; -} - -#ifndef __SWITCH__ -int main() { - if (startSendingThread() != 0) return 1; - while (!isServerTransferDone()) usleep(16000); - return 0; -} -#endif diff --git a/source/title.cpp b/source/title.cpp deleted file mode 100644 index 190cb1c..0000000 --- a/source/title.cpp +++ /dev/null @@ -1,323 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "title.hpp" -#include "main.hpp" - -static std::unordered_map<AccountUid, std::vector<Title>> titles; -static bool s_titlesLoaded = false; - -bool areTitlesLoaded(void) -{ - return s_titlesLoaded; -} - -void Title::init(u8 saveDataType, u64 id, AccountUid userID, const std::string& name, const std::string& author) -{ - mId = id; - mUserId = userID; - mSaveDataType = saveDataType; - mUserName = Account::username(userID); - mAuthor = author; - mName = name; - mSafeName = StringUtils::containsInvalidChar(name) ? StringUtils::format("0x%016llX", mId) : StringUtils::removeForbiddenCharacters(name); - mPath = "sdmc:/switch/NXST/saves/" + StringUtils::format("0x%016llX", mId) + " " + mSafeName; - - std::string aname = StringUtils::removeAccents(mName); - size_t pos = aname.rfind(":"); - mDisplayName = std::make_pair(aname, ""); - if (pos != std::string::npos) { - std::string name1 = aname.substr(0, pos); - std::string name2 = aname.substr(pos + 1); - StringUtils::trim(name1); - StringUtils::trim(name2); - mDisplayName.first = name1; - mDisplayName.second = name2; - } - else { - // check for parenthesis - size_t pos1 = aname.rfind("("); - size_t pos2 = aname.rfind(")"); - if (pos1 != std::string::npos && pos2 != std::string::npos) { - std::string name1 = aname.substr(0, pos1); - std::string name2 = aname.substr(pos1 + 1, pos2 - 1 - pos1); - StringUtils::trim(name1); - StringUtils::trim(name2); - mDisplayName.first = name1; - mDisplayName.second = name2; - } - } - - refreshDirectories(); -} - -u8 Title::saveDataType(void) -{ - return mSaveDataType; -} - -u64 Title::id(void) -{ - return mId; -} - -u64 Title::saveId(void) -{ - return mSaveId; -} - -void Title::saveId(u64 saveId) -{ - mSaveId = saveId; -} - -AccountUid Title::userId(void) -{ - return mUserId; -} - -std::string Title::userName(void) -{ - return mUserName; -} - -std::string Title::author(void) -{ - return mAuthor; -} - -std::string Title::name(void) -{ - return mName; -} - -std::pair<std::string, std::string> Title::displayName(void) -{ - return mDisplayName; -} - -std::string Title::path(void) -{ - return mPath; -} - -std::string Title::fullPath(size_t index) -{ - return mFullSavePaths.at(index); -} - -std::vector<std::string> Title::saves() -{ - return mSaves; -} - -u64 Title::playTimeNanoseconds(void) -{ - return mPlayTimeNanoseconds; -} - -std::string Title::playTime(void) -{ - const u64 playTimeMinutes = mPlayTimeNanoseconds / 60000000000; - return StringUtils::format("%d", playTimeMinutes / 60) + ":" + StringUtils::format("%02d", playTimeMinutes % 60) + " hours"; -} - -void Title::playTimeNanoseconds(u64 playTimeNanoseconds) -{ - mPlayTimeNanoseconds = playTimeNanoseconds; -} - -u32 Title::lastPlayedTimestamp(void) -{ - return mLastPlayedTimestamp; -} - -void Title::lastPlayedTimestamp(u32 lastPlayedTimestamp) -{ - mLastPlayedTimestamp = lastPlayedTimestamp; -} - -void Title::refreshDirectories(void) -{ - mSaves.clear(); - mFullSavePaths.clear(); - - Directory savelist(mPath); - if (savelist.good()) { - for (size_t i = 0, sz = savelist.size(); i < sz; i++) { - if (savelist.folder(i)) { - mSaves.push_back(savelist.entry(i)); - mFullSavePaths.push_back(mPath + "/" + savelist.entry(i)); - } - } - - std::sort(mSaves.rbegin(), mSaves.rend()); - std::sort(mFullSavePaths.rbegin(), mFullSavePaths.rend()); - mSaves.insert(mSaves.begin(), g_emptySave); - mFullSavePaths.insert(mFullSavePaths.begin(), g_emptySave); - } - else { - Logger::getInstance().log(Logger::ERROR, "Couldn't retrieve the extdata directory list for the title " + name()); - } -} - -void loadTitles(void) -{ - if (s_titlesLoaded) return; - s_titlesLoaded = true; - - titles.clear(); - - FsSaveDataInfoReader reader; - FsSaveDataInfo info; - s64 total_entries = 0; - size_t outsize = 0; - - NacpLanguageEntry* nle = NULL; - NsApplicationControlData* nsacd = (NsApplicationControlData*)malloc(sizeof(NsApplicationControlData)); - if (nsacd == NULL) { - return; - } - memset(nsacd, 0, sizeof(NsApplicationControlData)); - - Result res = fsOpenSaveDataInfoReader(&reader, FsSaveDataSpaceId_User); - if (R_FAILED(res)) { - free(nsacd); - return; - } - - while (1) { - res = fsSaveDataInfoReaderRead(&reader, &info, 1, &total_entries); - if (R_FAILED(res) || total_entries == 0) { - break; - } - - if (info.save_data_type == FsSaveDataType_Account) { - u64 tid = info.application_id; - u64 sid = info.save_data_id; - AccountUid uid = info.uid; - // if (mFilterIds.find(tid) == mFilterIds.end()) { - res = nsGetApplicationControlData(NsApplicationControlSource_Storage, tid, nsacd, sizeof(NsApplicationControlData), &outsize); - if (R_SUCCEEDED(res) && !(outsize < sizeof(nsacd->nacp))) { - res = nacpGetLanguageEntry(&nsacd->nacp, &nle); - if (R_SUCCEEDED(res) && nle != NULL) { - Title title; - title.init(info.save_data_type, tid, uid, std::string(nle->name), std::string(nle->author)); - title.saveId(sid); - - // load play statistics - PdmPlayStatistics stats; - res = pdmqryQueryPlayStatisticsByApplicationIdAndUserAccountId(tid, uid, false, &stats); - if (R_SUCCEEDED(res)) { - title.playTimeNanoseconds(stats.playtime); - title.lastPlayedTimestamp(stats.last_timestamp_user); - } - - // loadIcon(tid, nsacd, outsize - sizeof(nsacd->nacp)); - - // check if the vector is already created - std::unordered_map<AccountUid, std::vector<Title>>::iterator it = titles.find(uid); - if (it != titles.end()) { - // found - it->second.push_back(title); - } - else { - // not found, insert into map - std::vector<Title> v; - v.push_back(title); - titles.emplace(uid, v); - } - } - } - nle = NULL; - // } - } - } - - free(nsacd); - fsSaveDataInfoReaderClose(&reader); - - sortTitles(); -} - -void sortTitles(void) -{ - for (auto& vect : titles) { - std::sort(vect.second.begin(), vect.second.end(), [](Title& l, Title& r) { - switch (g_sortMode) { - case SORT_LAST_PLAYED: - return l.lastPlayedTimestamp() > r.lastPlayedTimestamp(); - case SORT_PLAY_TIME: - return l.playTimeNanoseconds() > r.playTimeNanoseconds(); - case SORT_ALPHA: - default: - return l.name() < r.name(); - } - }); - } -} - -void rotateSortMode(void) -{ - g_sortMode = static_cast<sort_t>((g_sortMode + 1) % SORT_MODES_COUNT); - sortTitles(); -} - -void getTitle(Title& dst, AccountUid uid, size_t i) -{ - std::unordered_map<AccountUid, std::vector<Title>>::iterator it = titles.find(uid); - if (it != titles.end() && i < getTitleCount(uid)) { - dst = it->second.at(i); - } -} - -size_t getTitleCount(AccountUid uid) -{ - std::unordered_map<AccountUid, std::vector<Title>>::iterator it = titles.find(uid); - return it != titles.end() ? it->second.size() : 0; -} - -void refreshDirectories(u64 id) -{ - for (auto& pair : titles) { - for (size_t i = 0; i < pair.second.size(); i++) { - if (pair.second.at(i).id() == id) { - pair.second.at(i).refreshDirectories(); - } - } - } -} - -std::unordered_map<std::string, std::string> getCompleteTitleList(void) -{ - std::unordered_map<std::string, std::string> map; - for (const auto& pair : titles) { - for (auto value : pair.second) { - map.insert({StringUtils::format("0x%016llX", value.id()), value.name()}); - } - } - return map; -} diff --git a/source/util.cpp b/source/util.cpp deleted file mode 100644 index 81f4219..0000000 --- a/source/util.cpp +++ /dev/null @@ -1,175 +0,0 @@ -/* - * This file is part of Checkpoint - * Copyright (C) 2017-2021 Bernardo Giordano, FlagBrew - * - * 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 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. If not, see <http://www.gnu.org/licenses/>. - * - * Additional Terms 7.b and 7.c of GPLv3 apply to this file: - * * Requiring preservation of specified reasonable legal notices or - * author attributions in that material or in the Appropriate Legal - * Notices displayed by works containing it. - * * Prohibiting misrepresentation of the origin of that material, - * or requiring that modified versions of such material be marked in - * reasonable ways as different from the original version. - */ - -#include "util.hpp" -#include <logger.hpp> -#include <MainApplication.hpp> -#include "main.hpp" - -void servicesExit(void) -{ - Logger::getInstance().flush(); - Account::exit(); - plExit(); - romfsExit(); -} - -Result servicesInit(void) -{ - io::createDirectory("sdmc:/switch"); - io::createDirectory("sdmc:/switch/NXST"); - io::createDirectory("sdmc:/switch/NXST/saves"); - - if (appletGetAppletType() != AppletType_Application) { - Logger::getInstance().log(Logger::WARN, "Please do not run NXST in applet mode."); - } - - // Result socinit = 0; - // if ((socinit = socketInitializeDefault()) == 0) { - // nxlinkStdio(); - // } - // else { - // Logger::getInstance().log(Logger::INFO, "Unable to initialize socket. Result code 0x%08lX.", socinit); - // } - - // g_shouldExitNetworkLoop = R_FAILED(socinit); - - Result res = 0; - - romfsInit(); - - padConfigureInput(1, HidNpadStyleSet_NpadStandard); - hidInitializeTouchScreen(); - - if (R_FAILED(res = plInitialize(PlServiceType_User))) { - Logger::getInstance().log(Logger::ERROR, "plInitialize failed. Result code 0x%08lX.", res); - return res; - } - - if (R_FAILED(res = Account::init())) { - Logger::getInstance().log(Logger::ERROR, "Account::init failed. Result code 0x%08lX.", res); - return res; - } - - if (R_FAILED(res = nsInitialize())) { - Logger::getInstance().log(Logger::ERROR, "nsInitialize failed. Result code 0x{:08X}.", res); - return res; - } - - if (R_SUCCEEDED(res = hidsysInitialize())) { - g_notificationLedAvailable = true; - } - else { - Logger::getInstance().log(Logger::INFO, "Notification led not available. Result code 0x{:08X}.", res); - } - - - Logger::getInstance().log(Logger::INFO, "NXST loading completed!"); - - return 0; -} - -std::u16string StringUtils::UTF8toUTF16(const char* src) -{ - char16_t tmp[256] = {0}; - utf8_to_utf16((uint16_t*)tmp, (uint8_t*)src, 256); - return std::u16string(tmp); -} - -// https://stackoverflow.com/questions/14094621/change-all-accented-letters-to-normal-letters-in-c -std::string StringUtils::removeAccents(std::string str) -{ - std::u16string src = UTF8toUTF16(str.c_str()); - const std::u16string illegal = UTF8toUTF16("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüūýþÿ"); - const std::u16string fixed = UTF8toUTF16("AAAAAAECEEEEIIIIDNOOOOOx0UUUUYPsaaaaaaeceeeeiiiiOnooooo/0uuuuuypy"); - - for (size_t i = 0, sz = src.length(); i < sz; i++) { - size_t index = illegal.find(src[i]); - if (index != std::string::npos) { - src[i] = fixed[index]; - } - } - - return UTF16toUTF8(src); -} - -std::string StringUtils::removeNotAscii(std::string str) -{ - for (size_t i = 0, sz = str.length(); i < sz; i++) { - if (!isascii(str[i])) { - str[i] = ' '; - } - } - return str; -} - -std::string StringUtils::elide(const std::string& s, size_t maxChars) -{ - if (s.size() <= maxChars || maxChars < 6) return s; - constexpr const char* dots = "..."; - size_t budget = maxChars - 3; - size_t head = (budget + 1) / 2; - size_t tail = budget - head; - return s.substr(0, head) + dots + s.substr(s.size() - tail); -} - -HidsysNotificationLedPattern blinkLedPattern(u8 times) -{ - HidsysNotificationLedPattern pattern; - memset(&pattern, 0, sizeof(pattern)); - - pattern.baseMiniCycleDuration = 0x1; // 12.5ms. - pattern.totalMiniCycles = 0x2; // 2 mini cycles. - pattern.totalFullCycles = times; // Repeat n times. - pattern.startIntensity = 0x0; // 0%. - - pattern.miniCycles[0].ledIntensity = 0xF; // 100%. - pattern.miniCycles[0].transitionSteps = 0xF; // 15 steps. Total 187.5ms. - pattern.miniCycles[0].finalStepDuration = 0x0; // Forced 12.5ms. - pattern.miniCycles[1].ledIntensity = 0x0; // 0%. - pattern.miniCycles[1].transitionSteps = 0xF; // 15 steps. Total 187.5ms. - pattern.miniCycles[1].finalStepDuration = 0x0; // Forced 12.5ms. - - return pattern; -} - -void blinkLed(u8 times) -{ - if (g_notificationLedAvailable) { - PadState pad; - padInitializeDefault(&pad); - s32 n; - HidsysUniquePadId uniquePadIds[2] = {0}; - HidsysNotificationLedPattern pattern = blinkLedPattern(times); - memset(uniquePadIds, 0, sizeof(uniquePadIds)); - Result res = hidsysGetUniquePadsFromNpad(padIsHandheld(&pad) ? HidNpadIdType_Handheld : HidNpadIdType_No1, uniquePadIds, 2, &n); - if (R_SUCCEEDED(res)) { - for (s32 i = 0; i < n; i++) { - hidsysSetNotificationLedPattern(&pattern, uniquePadIds[i]); - } - } - } -} diff --git a/source/Main.cpp b/src/app/main.cpp similarity index 69% rename from source/Main.cpp rename to src/app/main.cpp index 47748d1..d95d70b 100644 --- a/source/Main.cpp +++ b/src/app/main.cpp @@ -1,78 +1,84 @@ -#include <MainApplication.hpp> -#include "util.hpp" -#include "main.hpp" -#include <server.hpp> -#include <client.hpp> -#include <unistd.h> - -static int nxlink_sock = -1; - -extern "C" void userAppInit() { - appletInitialize(); - hidInitialize(); - nsInitialize(); - setsysInitialize(); - setInitialize(); - accountInitialize(AccountServiceType_Administrator); - pmshellInitialize(); - socketInitializeDefault(); - pdmqryInitialize(); - nxlink_sock = nxlinkStdio(); - printf("userAppInit\n"); -} - -extern "C" void userAppExit() { - cancelServerTransfer(); - cancelClientTransfer(); - for (int i = 0; i < 150 && (!isServerWorkersIdle() || !isClientWorkersIdle()); i++) { - usleep(10000); - } - if (nxlink_sock != -1) { - close(nxlink_sock); - } - appletExit(); - hidExit(); - nsExit(); - setsysExit(); - setExit(); - accountExit(); - pmshellExit(); - socketExit(); - pdmqryExit(); -} - -// Main entrypoint -int main() { - Result res = servicesInit(); - if (R_FAILED(res)) { - servicesExit(); - exit(res); - } - - printf("main"); - - // First create our renderer, where one can customize SDL or other stuff's - // initialization. - auto renderer_opts = pu::ui::render::RendererInitOptions( - SDL_INIT_EVERYTHING, pu::ui::render::RendererHardwareFlags); - renderer_opts.UseImage(pu::ui::render::IMGAllFlags); - renderer_opts.UseAudio(pu::ui::render::MixerAllFlags); - renderer_opts.UseTTF(); - renderer_opts.SetExtraDefaultFontSize(theme::type::Caption); - renderer_opts.SetExtraDefaultFontSize(theme::type::Label); - renderer_opts.SetExtraDefaultFontSize(theme::type::Body); - renderer_opts.SetExtraDefaultFontSize(theme::type::Title); - renderer_opts.SetExtraDefaultFontSize(theme::type::Display); - - auto renderer = pu::ui::render::Renderer::New(renderer_opts); - - // Create our main application from the renderer - auto main = ui::MainApplication::New(renderer); - - main->Prepare(); - - main->Show(); - - servicesExit(); - return 0; -} +#include <unistd.h> + +#include <nxst/app/main_application.hpp> +#include <nxst/domain/util.hpp> + +namespace ui { + extern MainApplication* mainApp; +} + +static int nxlink_sock = -1; + +extern "C" void userAppInit() { + appletInitialize(); + hidInitialize(); + nsInitialize(); + setsysInitialize(); + setInitialize(); + accountInitialize(AccountServiceType_Administrator); + pmshellInitialize(); + socketInitializeDefault(); + pdmqryInitialize(); + nxlink_sock = nxlinkStdio(); + printf("userAppInit\n"); +} + +extern "C" void userAppExit() { + if (ui::mainApp) { + ui::mainApp->transfer.cancelReceive(); + ui::mainApp->transfer.cancelSend(); + for (int i = 0; i < 150 && (!ui::mainApp->transfer.isReceiveWorkersIdle() || + !ui::mainApp->transfer.isSendWorkersIdle()); + i++) { + usleep(10000); + } + } + if (nxlink_sock != -1) { + close(nxlink_sock); + } + appletExit(); + hidExit(); + nsExit(); + setsysExit(); + setExit(); + accountExit(); + pmshellExit(); + socketExit(); + pdmqryExit(); +} + +// Main entrypoint +int main() { + Result res = servicesInit(); + if (R_FAILED(res)) { + servicesExit(); + exit(res); + } + + printf("main"); + + // First create our renderer, where one can customize SDL or other stuff's + // initialization. + auto renderer_opts = + pu::ui::render::RendererInitOptions(SDL_INIT_EVERYTHING, pu::ui::render::RendererHardwareFlags); + renderer_opts.UseImage(pu::ui::render::IMGAllFlags); + renderer_opts.UseAudio(pu::ui::render::MixerAllFlags); + renderer_opts.UseTTF(); + renderer_opts.SetExtraDefaultFontSize(theme::type::Caption); + renderer_opts.SetExtraDefaultFontSize(theme::type::Label); + renderer_opts.SetExtraDefaultFontSize(theme::type::Body); + renderer_opts.SetExtraDefaultFontSize(theme::type::Title); + renderer_opts.SetExtraDefaultFontSize(theme::type::Display); + + auto renderer = pu::ui::render::Renderer::New(renderer_opts); + + // Create our main application from the renderer + auto main = ui::MainApplication::New(renderer); + + main->Prepare(); + + main->Show(); + + servicesExit(); + return 0; +} diff --git a/src/app/main_application.cpp b/src/app/main_application.cpp new file mode 100644 index 0000000..593a214 --- /dev/null +++ b/src/app/main_application.cpp @@ -0,0 +1,20 @@ +#include <switch.h> + +#include <nxst/app/main_application.hpp> + +namespace ui { + MainApplication* mainApp; + + void MainApplication::OnLoad() { + mainApp = this; + this->users_layout = UsersLayout::New(); + this->titles_layout = TitlesLayout::New(); + this->users_layout->SetOnInput(std::bind(&UsersLayout::onInput, this->users_layout, + std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4)); + this->titles_layout->SetOnInput(std::bind(&TitlesLayout::onInput, this->titles_layout, + std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4)); + this->LoadLayout(this->users_layout); + } +} // namespace ui \ No newline at end of file diff --git a/src/domain/account.cpp b/src/domain/account.cpp new file mode 100644 index 0000000..adcb23b --- /dev/null +++ b/src/domain/account.cpp @@ -0,0 +1,95 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <cstdio> +#include <cstring> +#include <map> +#include <sys/stat.h> +#include <vector> + +#include <nxst/domain/account.hpp> +#include <nxst/infra/fs/handles.hpp> + +static std::map<AccountUid, User> s_users; + +Result account::init() { + Result res = accountInitialize(AccountServiceType_Application); + if (R_FAILED(res)) + return res; + + AccountUid uids[8]; + s32 count = 0; + accountListAllUsers(uids, 8, &count); + for (s32 i = 0; i < count; ++i) { + username(uids[i]); // populate cache + } + return 0; +} + +void account::exit() { + accountExit(); +} + +std::vector<AccountUid> account::ids() { + std::vector<AccountUid> result; + result.reserve(s_users.size()); + for (const auto& pair : s_users) { + result.push_back(pair.second.id); + } + return result; +} + +static User fetchUser(AccountUid id) { + User user{id, ""}; + nxst::AccountProfileHandle profile; + AccountProfileBase base{}; + + if (R_SUCCEEDED(accountGetProfile(profile.get(), id))) { + profile.valid = true; + if (R_SUCCEEDED(accountProfileGet(profile.get(), nullptr, &base))) { + user.name = std::string(base.nickname); + } + } + return user; +} + +std::string account::username(AccountUid id) { + auto it = s_users.find(id); + if (it == s_users.end()) { + User user = fetchUser(id); + s_users.emplace(id, user); + return user.name; + } + return it->second.name; +} + +std::string account::iconPath(AccountUid id) { + char path[128]; + snprintf(path, sizeof(path), "sdmc:/switch/NXST/cache/%016lX%016lX.jpg", id.uid[0], id.uid[1]); + + struct stat st; + if (stat(path, &st) == 0 && st.st_size > 0) + return std::string(path); + + mkdir("sdmc:/switch", 0755); + mkdir("sdmc:/switch/NXST", 0755); + mkdir("sdmc:/switch/NXST/cache", 0755); + + nxst::AccountProfileHandle profile; + if (R_FAILED(accountGetProfile(profile.get(), id))) + return ""; + profile.valid = true; + + u32 img_size = 0; + if (R_FAILED(accountProfileGetImageSize(profile.get(), &img_size)) || img_size == 0) + return ""; + + std::vector<u8> buf(img_size); + u32 out_size = 0; + if (R_FAILED(accountProfileLoadImage(profile.get(), buf.data(), img_size, &out_size)) || out_size == 0) + return ""; + + nxst::FileHandle f(fopen(path, "wb")); + if (!f) + return ""; + fwrite(buf.data(), 1, out_size, f.get()); + return std::string(path); +} diff --git a/src/domain/title.cpp b/src/domain/title.cpp new file mode 100644 index 0000000..c86616a --- /dev/null +++ b/src/domain/title.cpp @@ -0,0 +1,243 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <algorithm> +#include <cstring> +#include <vector> + +#include "nxst/domain/account.hpp" +#include <nxst/domain/title.hpp> +#include <nxst/domain/util.hpp> +#include <nxst/infra/fs/directory.hpp> +#include <nxst/infra/sys/logger.hpp> + +using sort_t = enum { SortAlpha, SortLastPlayed, SortPlayTime, SortModesCount }; +static constexpr const char* kEmptySave = "New..."; +static sort_t s_sort_mode = SortAlpha; + +static std::unordered_map<AccountUid, std::vector<Title>> titles; +static bool s_titles_loaded = false; + +void Title::init(u8 save_data_type, u64 title_id, AccountUid uid, const std::string& name, + const std::string& author) { + m_id = title_id; + m_uid = uid; + m_save_data_type = save_data_type; + m_user_name = account::username(uid); + m_author = author; + m_name = name; + m_safe_name = string_utils::containsInvalidChar(name) ? string_utils::format("0x%016llX", m_id) + : string_utils::removeForbiddenCharacters(name); + m_path = "sdmc:/switch/NXST/saves/" + string_utils::format("0x%016llX", m_id) + " " + m_safe_name; + + std::string aname = string_utils::removeAccents(m_name); + m_display_name = {aname, ""}; + + size_t colon = aname.rfind(':'); + if (colon != std::string::npos) { + std::string head = aname.substr(0, colon); + std::string tail = aname.substr(colon + 1); + string_utils::trim(head); + string_utils::trim(tail); + m_display_name = {head, tail}; + } else { + size_t open = aname.rfind('('); + size_t close = aname.rfind(')'); + if (open != std::string::npos && close != std::string::npos && close > open) { + std::string head = aname.substr(0, open); + std::string paren = aname.substr(open + 1, close - open - 1); + string_utils::trim(head); + string_utils::trim(paren); + m_display_name = {head, paren}; + } + } + + refreshDirectories(); +} + +u8 Title::saveDataType() const { + return m_save_data_type; +} +u64 Title::id() const { + return m_id; +} +u64 Title::saveId() const { + return m_save_id; +} +void Title::saveId(u64 id) { + m_save_id = id; +} +AccountUid Title::userId() const { + return m_uid; +} +std::string Title::userName() const { + return m_user_name; +} +std::string Title::author() const { + return m_author; +} +std::string Title::name() const { + return m_name; +} +std::pair<std::string, std::string> Title::displayName() const { + return m_display_name; +} +std::string Title::path() const { + return m_path; +} +std::string Title::fullPath(size_t index) const { + return m_full_save_paths.at(index); +} +std::vector<std::string> Title::saves() const { + return m_saves; +} +u64 Title::playTimeNanoseconds() const { + return m_play_time_ns; +} +void Title::playTimeNanoseconds(u64 ns) { + m_play_time_ns = ns; +} +u32 Title::lastPlayedTimestamp() const { + return m_last_played_ts; +} +void Title::lastPlayedTimestamp(u32 ts) { + m_last_played_ts = ts; +} + +std::string Title::playTime() const { + const u64 minutes = m_play_time_ns / 60000000000ULL; + return string_utils::format("%d", minutes / 60) + ":" + string_utils::format("%02d", minutes % 60) + + " hours"; +} + +void Title::refreshDirectories() { + m_saves.clear(); + m_full_save_paths.clear(); + + Directory savelist(m_path); + if (savelist.good()) { + for (size_t i = 0; i < savelist.size(); ++i) { + if (savelist.folder(i)) { + m_saves.push_back(savelist.entry(i)); + m_full_save_paths.push_back(m_path + "/" + savelist.entry(i)); + } + } + std::sort(m_saves.rbegin(), m_saves.rend()); + std::sort(m_full_save_paths.rbegin(), m_full_save_paths.rend()); + m_saves.insert(m_saves.begin(), kEmptySave); + m_full_save_paths.insert(m_full_save_paths.begin(), kEmptySave); + } else { + nxst::log::error("Could not read save directory for title %s", m_name.c_str()); + } +} + +bool areTitlesLoaded() { + return s_titles_loaded; +} + +void loadTitles() { + if (s_titles_loaded) + return; + s_titles_loaded = true; + titles.clear(); + + FsSaveDataInfoReader reader; + Result res = fsOpenSaveDataInfoReader(&reader, FsSaveDataSpaceId_User); + if (R_FAILED(res)) + return; + + std::vector<u8> nacp_buf(sizeof(NsApplicationControlData), 0); + auto* nsacd = reinterpret_cast<NsApplicationControlData*>(nacp_buf.data()); + + FsSaveDataInfo info{}; + s64 count = 0; + while (true) { + res = fsSaveDataInfoReaderRead(&reader, &info, 1, &count); + if (R_FAILED(res) || count == 0) + break; + if (info.save_data_type != FsSaveDataType_Account) + continue; + + u64 tid = info.application_id; + AccountUid uid = info.uid; + size_t outsize = 0; + NacpLanguageEntry* nle = nullptr; + + memset(nsacd, 0, sizeof(NsApplicationControlData)); + res = nsGetApplicationControlData(NsApplicationControlSource_Storage, tid, nsacd, + sizeof(NsApplicationControlData), &outsize); + if (R_FAILED(res) || outsize < sizeof(nsacd->nacp)) + continue; + if (R_FAILED(nacpGetLanguageEntry(&nsacd->nacp, &nle)) || !nle) + continue; + + Title title; + title.init(info.save_data_type, tid, uid, std::string(nle->name), std::string(nle->author)); + title.saveId(info.save_data_id); + + PdmPlayStatistics stats{}; + if (R_SUCCEEDED(pdmqryQueryPlayStatisticsByApplicationIdAndUserAccountId(tid, uid, false, &stats))) { + title.playTimeNanoseconds(stats.playtime); + title.lastPlayedTimestamp(stats.last_timestamp_user); + } + + auto it = titles.find(uid); + if (it != titles.end()) { + it->second.push_back(title); + } else { + titles.emplace(uid, std::vector<Title>{title}); + } + } + + fsSaveDataInfoReaderClose(&reader); + sortTitles(); +} + +void sortTitles() { + for (auto& pair : titles) { + std::sort(pair.second.begin(), pair.second.end(), [](const Title& l, const Title& r) { + switch (s_sort_mode) { + case SortLastPlayed: + return l.lastPlayedTimestamp() > r.lastPlayedTimestamp(); + case SortPlayTime: + return l.playTimeNanoseconds() > r.playTimeNanoseconds(); + case SortAlpha: + default: + return l.name() < r.name(); + } + }); + } +} + +void rotateSortMode() { + s_sort_mode = static_cast<sort_t>((s_sort_mode + 1) % SortModesCount); + sortTitles(); +} + +void getTitle(Title& dst, AccountUid uid, size_t i) { + auto it = titles.find(uid); + if (it != titles.end() && i < it->second.size()) + dst = it->second[i]; +} + +size_t getTitleCount(AccountUid uid) { + auto it = titles.find(uid); + return it != titles.end() ? it->second.size() : 0; +} + +void refreshDirectories(u64 id) { + for (auto& pair : titles) { + for (auto& title : pair.second) { + if (title.id() == id) + title.refreshDirectories(); + } + } +} + +std::unordered_map<std::string, std::string> getCompleteTitleList() { + std::unordered_map<std::string, std::string> map; + for (const auto& pair : titles) { + for (const auto& title : pair.second) { + map.emplace(string_utils::format("0x%016llX", title.id()), title.name()); + } + } + return map; +} diff --git a/src/domain/util.cpp b/src/domain/util.cpp new file mode 100644 index 0000000..80d4f30 --- /dev/null +++ b/src/domain/util.cpp @@ -0,0 +1,250 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <algorithm> +#include <cctype> +#include <cstdarg> +#include <cstdio> +#include <string> +#include <unordered_map> + +#include <nxst/domain/account.hpp> +#include <nxst/domain/util.hpp> +#include <nxst/infra/fs/io.hpp> +#include <nxst/infra/sys/logger.hpp> + +static bool s_notification_led_available = false; + +void servicesExit() { + account::exit(); + plExit(); + romfsExit(); +} + +Result servicesInit() { + io::createDirectory("sdmc:/switch"); + io::createDirectory("sdmc:/switch/NXST"); + io::createDirectory("sdmc:/switch/NXST/saves"); + + if (appletGetAppletType() != AppletType_Application) { + nxst::log::warn("Please do not run NXST in applet mode."); + } + + Result res = 0; + romfsInit(); + padConfigureInput(1, HidNpadStyleSet_NpadStandard); + hidInitializeTouchScreen(); + + if (R_FAILED(res = plInitialize(PlServiceType_User))) { + nxst::log::error("plInitialize failed. Result code 0x%08X.", res); + return res; + } + if (R_FAILED(res = account::init())) { + nxst::log::error("account::init failed. Result code 0x%08X.", res); + return res; + } + if (R_FAILED(res = nsInitialize())) { + nxst::log::error("nsInitialize failed. Result code 0x%08X.", res); + return res; + } + if (R_SUCCEEDED(hidsysInitialize())) { + s_notification_led_available = true; + } else { + nxst::log::info("Notification LED not available."); + } + + nxst::log::info("NXST loading completed."); + return 0; +} + +bool string_utils::containsInvalidChar(const std::string& str) { + for (unsigned char c : str) { + if (!isascii(c)) + return true; + } + return false; +} + +std::string string_utils::format(const char* fmt, ...) { + va_list a1, a2; + va_start(a1, fmt); + va_copy(a2, a1); + int n = vsnprintf(nullptr, 0, fmt, a1); + va_end(a1); + if (n < 0) { + va_end(a2); + return {}; + } + std::string buf(static_cast<size_t>(n), '\0'); + vsnprintf(buf.data(), static_cast<size_t>(n) + 1, fmt, a2); + va_end(a2); + return buf; +} + +std::string string_utils::removeForbiddenCharacters(std::string src) { + static constexpr std::string_view kForbidden = ".,!\\/:?*\"<>|"; + for (char& c : src) { + if (kForbidden.find(c) != std::string_view::npos) + c = ' '; + } + auto last = src.find_last_not_of(' '); + if (last != std::string::npos) + src.erase(last + 1); + return src; +} + +static size_t encodeUtf8(char* out, char32_t cp) { + if (cp < 0x80) { + out[0] = static_cast<char>(cp); + return 1; + } + if (cp < 0x800) { + out[0] = static_cast<char>(0xC0 | (cp >> 6)); + out[1] = static_cast<char>(0x80 | (cp & 0x3F)); + return 2; + } + if (cp < 0x10000) { + out[0] = static_cast<char>(0xE0 | (cp >> 12)); + out[1] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); + out[2] = static_cast<char>(0x80 | (cp & 0x3F)); + return 3; + } + out[0] = static_cast<char>(0xF0 | (cp >> 18)); + out[1] = static_cast<char>(0x80 | ((cp >> 12) & 0x3F)); + out[2] = static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); + out[3] = static_cast<char>(0x80 | (cp & 0x3F)); + return 4; +} + +std::string string_utils::UTF16toUTF8(const std::u16string& src) { + std::string result; + result.reserve(src.size() * 2); + for (size_t i = 0; i < src.size(); ++i) { + char32_t cp = src[i]; + if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < src.size()) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (src[++i] - 0xDC00); + } + char buf[4]; + result.append(buf, encodeUtf8(buf, cp)); + } + return result; +} + +void string_utils::ltrim(std::string& s) { + s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char c) { + return !std::isspace(c); + })); +} + +void string_utils::rtrim(std::string& s) { + s.erase(std::find_if(s.rbegin(), s.rend(), + [](unsigned char c) { + return !std::isspace(c); + }) + .base(), + s.end()); +} + +void string_utils::trim(std::string& s) { + ltrim(s); + rtrim(s); +} + +// Decodes a UTF-8 string to UTF-16, handling surrogate pairs for codepoints > U+FFFF. +std::u16string string_utils::UTF8toUTF16(const char* src) { + std::u16string result; + while (*src != '\0') { + char32_t cp = 0; + unsigned char c = static_cast<unsigned char>(*src); + if (c < 0x80) { + cp = c; + ++src; + } else if (c < 0xE0) { + cp = static_cast<char32_t>(c & 0x1F) << 6 | (src[1] & 0x3F); + src += 2; + } else if (c < 0xF0) { + cp = static_cast<char32_t>(c & 0x0F) << 12 | static_cast<char32_t>(src[1] & 0x3F) << 6 | + (src[2] & 0x3F); + src += 3; + } else { + cp = static_cast<char32_t>(c & 0x07) << 18 | static_cast<char32_t>(src[1] & 0x3F) << 12 | + static_cast<char32_t>(src[2] & 0x3F) << 6 | (src[3] & 0x3F); + src += 4; + } + if (cp < 0x10000) { + result += static_cast<char16_t>(cp); + } else { + cp -= 0x10000; + result += static_cast<char16_t>(0xD800 | (cp >> 10)); + result += static_cast<char16_t>(0xDC00 | (cp & 0x3FF)); + } + } + return result; +} + +// Replaces Latin characters with diacritics with their ASCII base equivalents. +std::string string_utils::removeAccents(std::string str) { + static const std::unordered_map<char16_t, char16_t> kMap = { + {u'À', u'A'}, {u'Á', u'A'}, {u'Â', u'A'}, {u'Ã', u'A'}, {u'Ä', u'A'}, {u'Å', u'A'}, {u'Æ', u'E'}, + {u'Ç', u'C'}, {u'È', u'E'}, {u'É', u'E'}, {u'Ê', u'E'}, {u'Ë', u'E'}, {u'Ì', u'I'}, {u'Í', u'I'}, + {u'Î', u'I'}, {u'Ï', u'I'}, {u'Ð', u'D'}, {u'Ñ', u'N'}, {u'Ò', u'O'}, {u'Ó', u'O'}, {u'Ô', u'O'}, + {u'Õ', u'O'}, {u'Ö', u'O'}, {u'Ø', u'O'}, {u'Ù', u'U'}, {u'Ú', u'U'}, {u'Û', u'U'}, {u'Ü', u'U'}, + {u'Ý', u'Y'}, {u'ß', u's'}, {u'à', u'a'}, {u'á', u'a'}, {u'â', u'a'}, {u'ã', u'a'}, {u'ä', u'a'}, + {u'å', u'a'}, {u'æ', u'e'}, {u'ç', u'c'}, {u'è', u'e'}, {u'é', u'e'}, {u'ê', u'e'}, {u'ë', u'e'}, + {u'ì', u'i'}, {u'í', u'i'}, {u'î', u'i'}, {u'ï', u'i'}, {u'ñ', u'n'}, {u'ò', u'o'}, {u'ó', u'o'}, + {u'ô', u'o'}, {u'õ', u'o'}, {u'ö', u'o'}, {u'ø', u'o'}, {u'ù', u'u'}, {u'ú', u'u'}, {u'û', u'u'}, + {u'ü', u'u'}, {u'ū', u'u'}, {u'ý', u'y'}, {u'ÿ', u'y'}, + }; + std::u16string wide = UTF8toUTF16(str.c_str()); + for (char16_t& ch : wide) { + auto it = kMap.find(ch); + if (it != kMap.end()) + ch = it->second; + } + return string_utils::UTF16toUTF8(wide); +} + +std::string string_utils::removeNotAscii(std::string str) { + for (char& c : str) { + if (!isascii(static_cast<unsigned char>(c))) + c = ' '; + } + return str; +} + +std::string string_utils::elide(const std::string& s, size_t max_chars) { + if (s.size() <= max_chars || max_chars < 6) + return s; + size_t budget = max_chars - 3; + size_t head = (budget + 1) / 2; + size_t tail = budget - head; + return s.substr(0, head) + "..." + s.substr(s.size() - tail); +} + +static HidsysNotificationLedPattern makeLedPattern(u8 times) { + HidsysNotificationLedPattern p{}; + p.baseMiniCycleDuration = 0x1; + p.totalMiniCycles = 0x2; + p.totalFullCycles = times; + p.startIntensity = 0x0; + p.miniCycles[0] = {0xF, 0xF, 0x0}; + p.miniCycles[1] = {0x0, 0xF, 0x0}; + return p; +} + +void blinkLed(u8 times) { + if (!s_notification_led_available) + return; + + PadState pad; + padInitializeDefault(&pad); + s32 n = 0; + HidsysUniquePadId pads[2]{}; + HidsysNotificationLedPattern pattern = makeLedPattern(times); + + Result res = hidsysGetUniquePadsFromNpad(padIsHandheld(&pad) ? HidNpadIdType_Handheld : HidNpadIdType_No1, + pads, 2, &n); + if (R_SUCCEEDED(res)) { + for (s32 i = 0; i < n; ++i) { + hidsysSetNotificationLedPattern(&pattern, pads[i]); + } + } +} diff --git a/src/infra/fs/directory.cpp b/src/infra/fs/directory.cpp new file mode 100644 index 0000000..d551179 --- /dev/null +++ b/src/infra/fs/directory.cpp @@ -0,0 +1,27 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <cerrno> +#include <dirent.h> + +#include <nxst/infra/fs/directory.hpp> + +Directory::Directory(const std::string& path) { + DIR* d = opendir(path.c_str()); + if (!d) { + m_error = static_cast<Result>(errno); + return; + } + struct dirent* ent; + while ((ent = readdir(d)) != nullptr) { + m_entries.push_back({ent->d_name, ent->d_type == DT_DIR}); + } + closedir(d); + m_good = true; +} + +std::string Directory::entry(size_t i) const { + return i < m_entries.size() ? m_entries[i].name : ""; +} + +bool Directory::folder(size_t i) const { + return i < m_entries.size() && m_entries[i].is_dir; +} diff --git a/src/infra/fs/filesystem.cpp b/src/infra/fs/filesystem.cpp new file mode 100644 index 0000000..9abd9ea --- /dev/null +++ b/src/infra/fs/filesystem.cpp @@ -0,0 +1,14 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <nxst/infra/fs/filesystem.hpp> + +Result file_system::mount(FsFileSystem* fs, u64 title_id, AccountUid uid) { + return fsOpen_SaveData(fs, title_id, uid); +} + +int file_system::mount(FsFileSystem fs) { + return fsdevMountDevice("save", fs); +} + +void file_system::unmount() { + fsdevUnmountDevice("save"); +} diff --git a/src/infra/fs/io.cpp b/src/infra/fs/io.cpp new file mode 100644 index 0000000..721ea24 --- /dev/null +++ b/src/infra/fs/io.cpp @@ -0,0 +1,319 @@ +// Copyright (C) 2024-2026 NXST contributors +#include <cstdio> +#include <sys/stat.h> +#include <unistd.h> +#include <vector> + +#include <nxst/domain/account.hpp> +#include <nxst/domain/title.hpp> +#include <nxst/domain/util.hpp> +#include <nxst/infra/fs/directory.hpp> +#include <nxst/infra/fs/filesystem.hpp> +#include <nxst/infra/fs/handles.hpp> +#include <nxst/infra/fs/io.hpp> +#include <nxst/infra/sys/logger.hpp> + +static constexpr size_t kBufSize = 0x80000; + +bool io::fileExists(const std::string& path) { + struct stat buffer; + return (stat(path.c_str(), &buffer) == 0); +} + +void io::copyFile(const std::string& srcPath, const std::string& dstPath) { + nxst::FileHandle src(fopen(srcPath.c_str(), "rb")); + if (!src) { + nxst::log::error("Failed to open source file %s during copy with errno %d. Skipping...", + srcPath.c_str(), errno); + return; + } + nxst::FileHandle dst(fopen(dstPath.c_str(), "wb")); + if (!dst) { + nxst::log::error("Failed to open destination file %s during copy with errno %d. Skipping...", + dstPath.c_str(), errno); + return; + } + + fseek(src.get(), 0, SEEK_END); + u64 sz = (u64)ftell(src.get()); + rewind(src.get()); + + std::vector<u8> buf(kBufSize); + u64 offset = 0; + + while (offset < sz) { + u32 count = (u32)fread(buf.data(), 1, kBufSize, src.get()); + if (count == 0) { + nxst::log::error("fread returned 0 for %s at offset %llu/%llu (errno %d). Aborting.", + srcPath.c_str(), (unsigned long long)offset, (unsigned long long)sz, errno); + break; + } + size_t written = fwrite(buf.data(), 1, count, dst.get()); + if (written != count) { + nxst::log::error("fwrite incomplete for %s (%zu/%u bytes). Aborting.", dstPath.c_str(), written, + count); + break; + } + offset += count; + } + + if (dstPath.rfind("save:/", 0) == 0) { + fsdevCommitDevice("save"); + } +} + +Result io::copyDirectory(const std::string& srcPath, const std::string& dstPath) { + Result res = 0; + bool quit = false; + Directory items(srcPath); + + if (!items.good()) { + return items.error(); + } + + for (size_t i = 0, sz = items.size(); i < sz && !quit; i++) { + std::string newsrc = srcPath + items.entry(i); + std::string newdst = dstPath + items.entry(i); + + if (items.folder(i)) { + res = createDirectory(newdst); + if (R_SUCCEEDED(res)) { + newsrc += "/"; + newdst += "/"; + res = copyDirectory(newsrc, newdst); + } else { + quit = true; + } + } else { + copyFile(newsrc, newdst); + } + } + + return res; +} + +Result io::createDirectory(const std::string& path) { + if (mkdir(path.c_str(), 0777) != 0 && errno != EEXIST) { + nxst::log::error("mkdir failed for %s (errno %d).", path.c_str(), errno); + return 1; + } + return 0; +} + +bool io::directoryExists(const std::string& path) { + struct stat sb; + return (stat(path.c_str(), &sb) == 0 && S_ISDIR(sb.st_mode)); +} + +Result io::deleteFolderRecursively(const std::string& path) { + Directory dir(path); + if (!dir.good()) { + return dir.error(); + } + + Result res = 0; + for (size_t i = 0, sz = dir.size(); i < sz; i++) { + if (dir.folder(i)) { + std::string sub = path + "/" + dir.entry(i); + Result sub_res = deleteFolderRecursively(sub + "/"); + if (R_FAILED(sub_res)) + res = sub_res; + rmdir(sub.c_str()); + } else { + std::remove((path + dir.entry(i)).c_str()); + } + } + + rmdir(path.c_str()); + return res; +} + +nxst::Result<std::string> io::backup(size_t index, AccountUid uid) { + Title title; + getTitle(title, uid, index); + + nxst::log::info("Started backup of %s. Title id: 0x%016lX; User id: 0x%lX%lX.", title.name().c_str(), + title.id(), title.userId().uid[1], title.userId().uid[0]); + + nxst::FsFileSystemHandle fs_handle; + Result res = file_system::mount(fs_handle.get(), title.id(), title.userId()); + if (R_FAILED(res)) { + nxst::log::error("Failed to mount filesystem during backup with result 0x%08X. " + "Title id: 0x%016lX; User id: 0x%lX%lX.", + res, title.id(), title.userId().uid[1], title.userId().uid[0]); + return nxst::Result<std::string>::failure("Failed to mount save."); + } + fs_handle.valid = true; + + if (file_system::mount(*fs_handle.get()) == -1) { + nxst::log::error("Failed to mount devfs during backup. Title id: 0x%016lX; User id: 0x%lX%lX.", + title.id(), title.userId().uid[1], title.userId().uid[0]); + file_system::unmount(); + return nxst::Result<std::string>::failure("Failed to mount save."); + } + fs_handle.release(); // devfs now owns the kernel handle + + std::string suggestion = + string_utils::removeNotAscii(string_utils::removeAccents(account::username(title.userId()))); + + io::createDirectory(title.path()); + std::string dst_path = title.path() + "/" + suggestion; + std::string tmp_path = dst_path + ".tmp"; + + if (io::directoryExists(tmp_path)) { + if (R_FAILED(io::deleteFolderRecursively(tmp_path + "/"))) + nxst::log::warn("Failed to clean up stale tmp dir %s.", tmp_path.c_str()); + } + res = io::createDirectory(tmp_path); + if (R_FAILED(res)) { + file_system::unmount(); + nxst::log::error("Failed to create tmp dir %s.", tmp_path.c_str()); + return nxst::Result<std::string>::failure("Failed to create tmp directory."); + } + res = copyDirectory("save:/", tmp_path + "/"); + if (R_FAILED(res)) { + file_system::unmount(); + io::deleteFolderRecursively(tmp_path + "/"); + nxst::log::error("Failed to copy directory to %s with result 0x%08X.", tmp_path.c_str(), res); + return nxst::Result<std::string>::failure("Failed to backup save."); + } + + if (io::directoryExists(dst_path)) { + if (R_FAILED(io::deleteFolderRecursively(dst_path + "/"))) + nxst::log::warn("Failed to remove old backup at %s.", dst_path.c_str()); + } + if (rename(tmp_path.c_str(), dst_path.c_str()) != 0) { + file_system::unmount(); + nxst::log::error("Failed to rename temp backup to %s.", dst_path.c_str()); + return nxst::Result<std::string>::failure("Failed to finalise backup."); + } + + refreshDirectories(title.id()); + file_system::unmount(); + + nxst::log::info("Backup succeeded."); + return nxst::Result<std::string>::success(dst_path); +} + +// Creates the save data filesystem for a title if it doesn't exist yet. +static void createSaveIfNeeded(u64 title_id, AccountUid uid) { + std::vector<u8> nsacd_buf(sizeof(NsApplicationControlData), 0); + auto* nsacd = reinterpret_cast<NsApplicationControlData*>(nsacd_buf.data()); + + size_t outsize = 0; + if (!R_SUCCEEDED(nsGetApplicationControlData(NsApplicationControlSource_Storage, title_id, nsacd, + sizeof(NsApplicationControlData), &outsize))) { + return; + } + + static const FsSaveDataMetaInfo meta = {.size = 0x40060, .type = FsSaveDataMetaType_Thumbnail}; + + FsSaveDataAttribute attr = {}; + attr.application_id = title_id; + attr.uid = uid; + attr.save_data_type = FsSaveDataType_Account; + attr.save_data_rank = FsSaveDataRank_Primary; + + FsSaveDataCreationInfo create_info = {}; + create_info.save_data_size = (s64)nsacd->nacp.user_account_save_data_size; + create_info.journal_size = (s64)nsacd->nacp.user_account_save_data_journal_size; + create_info.available_size = 0x4000; + create_info.owner_id = nsacd->nacp.save_data_owner_id; + create_info.save_data_space_id = FsSaveDataSpaceId_User; + + fsCreateSaveDataFileSystem(&attr, &create_info, &meta); +} + +static nxst::Result<void> clearSaveRoot(const std::string& dst_path) { + Directory save_root(dst_path); + for (size_t i = 0, sz = save_root.size(); i < sz; i++) { + if (save_root.folder(i)) { + io::deleteFolderRecursively(dst_path + save_root.entry(i) + "/"); + rmdir((dst_path + save_root.entry(i)).c_str()); + } else { + std::remove((dst_path + save_root.entry(i)).c_str()); + } + } + + Result res = fsdevCommitDevice("save"); + if (R_FAILED(res)) { + nxst::log::error("Failed to commit save after clearing with result 0x%08X.", res); + return nxst::Result<void>::failure("Failed to commit save after delete."); + } + return nxst::Result<void>::success(); +} + +static nxst::Result<void> extractAndCommit(const std::string& src_path, const std::string& dst_path) { + Result res = io::copyDirectory(src_path, dst_path); + if (R_FAILED(res)) { + nxst::log::error("Failed to copy %s to save:/ with result 0x%08X.", src_path.c_str(), res); + return nxst::Result<void>::failure("Failed to restore save."); + } + + res = fsdevCommitDevice("save"); + if (R_FAILED(res)) { + nxst::log::error("Failed to commit save with result 0x%08X.", res); + return nxst::Result<void>::failure("Failed to commit to save device."); + } + return nxst::Result<void>::success(); +} + +nxst::Result<std::string> io::restore(size_t index, AccountUid uid, const std::string& title_name) { + Title title; + getTitle(title, uid, index); + + nxst::log::info("Started restore of %s. Title id: 0x%016lX; User id: 0x%lX%lX.", title.name().c_str(), + title.id(), title.userId().uid[1], title.userId().uid[0]); + + createSaveIfNeeded(title.id(), uid); + + nxst::FsFileSystemHandle fs_handle; + Result res = file_system::mount(fs_handle.get(), title.id(), uid); + if (R_FAILED(res)) { + nxst::log::error("Failed to mount filesystem during restore with result 0x%08X. " + "Title id: 0x%016lX; User id: 0x%lX%lX.", + res, title.id(), uid.uid[1], uid.uid[0]); + return nxst::Result<std::string>::failure("Failed to mount save."); + } + fs_handle.valid = true; + + if (file_system::mount(*fs_handle.get()) == -1) { + nxst::log::error("Failed to mount devfs during restore. Title id: 0x%016lX; User id: 0x%lX%lX.", + title.id(), uid.uid[1], uid.uid[0]); + file_system::unmount(); + return nxst::Result<std::string>::failure("Failed to mount save."); + } + fs_handle.release(); // devfs now owns the kernel handle + + std::string suggestion = + string_utils::removeNotAscii(string_utils::removeAccents(account::username(uid))); + std::string src_path = title.path() + "/" + suggestion + "/"; + const std::string dst_path = "save:/"; + + { + Directory src_check(src_path); + if (!src_check.good() || src_check.size() == 0) { + file_system::unmount(); + nxst::log::error("Restore source is empty or missing: %s", src_path.c_str()); + return nxst::Result<std::string>::failure("Restore source is empty or missing."); + } + } + + auto clear_res = clearSaveRoot(dst_path); + if (!clear_res.isOk()) { + file_system::unmount(); + return nxst::Result<std::string>::failure(clear_res.error()); + } + + auto extract_res = extractAndCommit(src_path, dst_path); + if (!extract_res.isOk()) { + file_system::unmount(); + return nxst::Result<std::string>::failure(extract_res.error()); + } + + blinkLed(4); + file_system::unmount(); + + nxst::log::info("Restore succeeded."); + return nxst::Result<std::string>::success(title_name + "\nhas been restored successfully."); +} diff --git a/src/infra/sys/logger.cpp b/src/infra/sys/logger.cpp new file mode 100644 index 0000000..985dea8 --- /dev/null +++ b/src/infra/sys/logger.cpp @@ -0,0 +1,90 @@ +#include <cstdarg> +#include <cstdio> +#include <ctime> +#include <mutex> + +#include <nxst/infra/sys/logger.hpp> + +namespace { + + std::mutex g_log_mutex; + +#if defined(__SWITCH__) + constexpr const char* kLogPath = "/switch/NXST/log.log"; +#else + constexpr const char* kLogPath = "nxst.log"; +#endif + + void writeEntry(const char* tag, const char* fmt, va_list args) { + char msg[2048]; + vsnprintf(msg, sizeof(msg), fmt, args); + + time_t now = time(nullptr); + struct tm tm_buf; + localtime_r(&now, &tm_buf); + char time_str[16]; + strftime(time_str, sizeof(time_str), "%H:%M:%S", &tm_buf); + + std::lock_guard<std::mutex> lock(g_log_mutex); + + fprintf(stderr, "[%s]%s %s\n", time_str, tag, msg); + + FILE* log_file = fopen(kLogPath, "a"); + if (log_file != nullptr) { + fprintf(log_file, "[%s]%s %s\n", time_str, tag, msg); + fclose(log_file); + } + } + +} // namespace + +namespace nxst::log { + + void write(Level level, const char* fmt, ...) { + const char* tag = "[INFO] "; + switch (level) { + case Level::Debug: + tag = "[DEBUG]"; + break; + case Level::Info: + tag = "[INFO] "; + break; + case Level::Warn: + tag = "[WARN] "; + break; + case Level::Error: + tag = "[ERROR]"; + break; + } + va_list args; + va_start(args, fmt); + writeEntry(tag, fmt, args); + va_end(args); + } + + void debug(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + writeEntry("[DEBUG]", fmt, args); + va_end(args); + } + void info(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + writeEntry("[INFO] ", fmt, args); + va_end(args); + } + void warn(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + writeEntry("[WARN] ", fmt, args); + va_end(args); + } + void error(const char* fmt, ...) { + va_list args; + va_start(args, fmt); + writeEntry("[ERROR]", fmt, args); + va_end(args); + } + +} // namespace nxst::log diff --git a/src/service/transfer_service.cpp b/src/service/transfer_service.cpp new file mode 100644 index 0000000..440c529 --- /dev/null +++ b/src/service/transfer_service.cpp @@ -0,0 +1,540 @@ +#include <arpa/inet.h> +#include <chrono> +#include <cstring> +#include <filesystem> +#include <fstream> +#include <netinet/in.h> +#include <sys/select.h> +#include <sys/socket.h> +#include <sys/stat.h> +#include <unistd.h> +#include <vector> + +#include <nxst/service/transfer_service.hpp> + +#ifdef __SWITCH__ +#include <switch.h> + +#include <nxst/domain/account.hpp> +#include <nxst/domain/util.hpp> +#include <nxst/infra/fs/io.hpp> +#endif + +#include <nxst/domain/protocol.hpp> +#include <nxst/infra/net/socket.hpp> + +namespace fs = std::filesystem; + +namespace nxst { + + // ─── File-transfer helpers ──────────────────────────────────────────────────── + + static bool sendAll(int sock, const void* buf, size_t len) { + size_t sent = 0; + while (sent < len) { + ssize_t n = send(sock, static_cast<const char*>(buf) + sent, len - sent, 0); + if (n <= 0) + return false; + sent += n; + } + return true; + } + + static bool recvAll(int sock, void* buf, size_t len) { + size_t got = 0; + while (got < len) { + ssize_t n = read(sock, static_cast<char*>(buf) + got, len - got); + if (n <= 0) + return false; + got += n; + } + return true; + } + + static bool sendFile(int sock, const fs::path& filepath, TransferState& state) { + std::ifstream infile(filepath, std::ios::binary | std::ios::ate); + if (!infile.is_open()) + return false; + + uint32_t filename_len = (uint32_t)filepath.string().size(); + uint64_t file_size = (uint64_t)infile.tellg(); + infile.seekg(0, std::ios::beg); + + if (!sendAll(sock, &filename_len, sizeof(filename_len))) + return false; + if (!sendAll(sock, filepath.c_str(), filename_len)) + return false; + if (!sendAll(sock, &file_size, sizeof(file_size))) + return false; + + std::vector<char> buffer(proto::kBufSize); + uint64_t remaining = file_size; + while (remaining > 0) { + size_t to_read = (size_t)std::min(remaining, (uint64_t)proto::kBufSize); + infile.read(buffer.data(), (std::streamsize)to_read); + std::streamsize count = infile.gcount(); + if (count <= 0) + break; + if (!sendAll(sock, buffer.data(), (size_t)count)) + return false; + state.bytes_done.fetch_add((uint64_t)count); + remaining -= (uint64_t)count; + } + return true; + } + + static void mkdirs(const std::string& path) { + for (size_t i = 1; i < path.size(); i++) { + if (path[i] == '/') { + std::string component = path.substr(0, i); + mkdir(component.c_str(), 0777); + } + } + mkdir(path.c_str(), 0777); + } + + static void receiveFile(int sock, const std::string& rel_path, uint64_t file_size, TransferState& state) { + size_t last_slash = rel_path.rfind('/'); + if (last_slash != std::string::npos) { + std::string dir = rel_path.substr(0, last_slash); + if (!dir.empty()) + mkdirs(dir); + } + + FILE* outfile = fopen(rel_path.c_str(), "wb"); + if (!outfile) { + std::vector<char> drain(proto::kBufSize); + uint64_t remaining = file_size; + while (remaining > 0) { + size_t to_read = (size_t)std::min(remaining, (uint64_t)proto::kBufSize); + ssize_t n = read(sock, drain.data(), to_read); + if (n <= 0) + break; + remaining -= (uint64_t)n; + } + return; + } + + state.bytes_total.store(file_size); + state.bytes_done.store(0); + + std::vector<char> buffer(proto::kBufSize); + uint64_t total = 0; + while (total < file_size) { + size_t to_read = (size_t)std::min(file_size - total, (uint64_t)proto::kBufSize); + ssize_t n = read(sock, buffer.data(), to_read); + if (n <= 0) + break; + fwrite(buffer.data(), 1, (size_t)n, outfile); + total += (uint64_t)n; + state.bytes_done.store(total); + } + fclose(outfile); + } + + // ─── Sender ────────────────────────────────────────────────────────────────── + + void TransferService::failSend(const std::string& reason) { + sender_state.fail_reason = reason; + sender_state.connection_failed.store(true); + sender_state.done.store(true); + } + + int TransferService::findServer(char* out_ip) { + int udp_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (udp_fd < 0) + return -1; + sender_udp_sock.store(udp_fd); + + auto releaseUdp = [&]() { + int owned = sender_udp_sock.exchange(-1); + if (owned == udp_fd) + close(udp_fd); + }; + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(proto::kMulticastPort); + addr.sin_addr.s_addr = inet_addr(proto::kMulticastGroup); + + if (sendto(udp_fd, "DISCOVER_SERVER", 15, 0, (sockaddr*)&addr, sizeof(addr)) < 0) { + releaseUdp(); + return -1; + } + + // Poll in 100ms slices so cancel races within 100ms + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (std::chrono::steady_clock::now() < deadline) { + if (sender_state.cancelled.load()) { + releaseUdp(); + return -1; + } + struct timeval tv{0, 100000}; + fd_set fds; + FD_ZERO(&fds); + FD_SET(udp_fd, &fds); + if (select(udp_fd + 1, &fds, nullptr, nullptr, &tv) > 0) { + sockaddr_in from{}; + socklen_t fromlen = sizeof(from); + char buf[256]; + ssize_t n = recvfrom(udp_fd, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); + if (n > 0) { + buf[n] = '\0'; + if (strcmp(buf, "SERVER_HERE") == 0) { + inet_ntop(AF_INET, &from.sin_addr, out_ip, INET_ADDRSTRLEN); + releaseUdp(); + return 0; + } + } + } + } + releaseUdp(); + return -1; + } + + void* TransferService::senderEntry(void* arg) { + auto* a = static_cast<SenderArgs*>(arg); + TransferService* svc = a->svc; + size_t idx = a->title_index; + AccountUid uid = a->uid; + delete a; + svc->runSender(idx, uid); + return nullptr; + } + + void TransferService::runSender(size_t title_index, AccountUid uid) { + sender_active.store(true); + + auto finish = [this]() { + sender_state.done.store(true); + sender_active.store(false); + }; + + char server_ip[INET_ADDRSTRLEN]; + if (findServer(server_ip) != 0) { + if (!sender_state.cancelled.load()) + failSend("No receiver found.\nMake sure the other Switch is in Receive mode."); + return finish(); + } + if (sender_state.cancelled.load()) + return finish(); + + sender_state.setStatus("Creating backup..."); +#ifdef __SWITCH__ + auto backup_result = io::backup(title_index, uid); + if (!backup_result.isOk()) { + failSend("Failed to create backup:\n" + backup_result.error()); + return finish(); + } + fs::path directory = backup_result.value(); +#else + fs::path directory = "."; + (void)title_index; + (void)uid; +#endif + + if (sender_state.cancelled.load()) + return finish(); + + sender_state.setStatus("Connecting..."); + int tcp_fd = socket(AF_INET, SOCK_STREAM, 0); + if (tcp_fd < 0) { + failSend("Failed to open socket."); + return finish(); + } + sender_tcp_sock.store(tcp_fd); + + auto releaseTcp = [&]() { + int owned = sender_tcp_sock.exchange(-1); + if (owned == tcp_fd) + close(tcp_fd); + }; + + sockaddr_in serv{}; + serv.sin_family = AF_INET; + serv.sin_port = htons(proto::kTcpPort); + if (inet_pton(AF_INET, server_ip, &serv.sin_addr) <= 0 || + connect(tcp_fd, (sockaddr*)&serv, sizeof(serv)) < 0) { + if (!sender_state.cancelled.load()) + failSend("Failed to connect to receiver."); + releaseTcp(); + return finish(); + } + + uint64_t total = 0; + for (const auto& entry : fs::recursive_directory_iterator(directory)) + if (fs::is_regular_file(entry.path())) + total += fs::file_size(entry.path()); + sender_state.bytes_total.store(total); + + for (const auto& entry : fs::recursive_directory_iterator(directory)) { + if (sender_state.cancelled.load()) + break; + const fs::path& p = entry.path(); + if (fs::is_regular_file(p)) { + sender_state.setStatus(p.filename().string()); + if (!sendFile(tcp_fd, p, sender_state)) + break; + } + } + + uint32_t sentinel = proto::kEofSentinel; + sendAll(tcp_fd, &sentinel, sizeof(sentinel)); + + releaseTcp(); + sender_state.setStatus(""); + return finish(); + } + + int TransferService::startSend(size_t title_index, AccountUid uid) { + sender_state.reset(); + sender_state.setStatus("Searching for receiver..."); + + auto* arg = new SenderArgs{this, title_index, uid}; + pthread_t thread; + if (pthread_create(&thread, nullptr, senderEntry, arg) != 0) { + delete arg; + return -1; + } + pthread_detach(thread); + return 0; + } + + void TransferService::cancelSend() { + sender_state.cancelled.store(true); + int udp = sender_udp_sock.exchange(-1); + if (udp >= 0) { + shutdown(udp, SHUT_RDWR); + close(udp); + } + int tcp = sender_tcp_sock.exchange(-1); + if (tcp >= 0) { + shutdown(tcp, SHUT_RDWR); + close(tcp); + } + } + + // ─── Receiver ──────────────────────────────────────────────────────────────── + + std::string TransferService::replaceUsername(const std::string& file_path) const { +#ifdef __SWITCH__ + std::string username = + string_utils::removeNotAscii(string_utils::removeAccents(account::username(restore_uid))); + size_t last_slash = file_path.rfind('/'); + if (last_slash == std::string::npos) + return file_path; + size_t prev_slash = file_path.rfind('/', last_slash - 1); + if (prev_slash == std::string::npos) + return username + file_path.substr(last_slash); + return file_path.substr(0, prev_slash + 1) + username + file_path.substr(last_slash); +#else + return file_path; +#endif + } + + void* TransferService::broadcastEntry(void* arg) { + static_cast<TransferService*>(arg)->runBroadcast(); + return nullptr; + } + + void TransferService::runBroadcast() { + receiver_broadcast_active.store(true); + pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, nullptr); + pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, nullptr); + + int udp = socket(AF_INET, SOCK_DGRAM, 0); + if (udp < 0) { + receiver_broadcast_active.store(false); + return; + } + receiver_bcast_sock.store(udp); + + auto releaseUdp = [&]() { + int owned = receiver_bcast_sock.exchange(-1); + if (owned == udp) + close(udp); + }; + + struct timeval tv{0, 20000}; // 20ms poll so cancel/exit wins race with socketExit + setsockopt(udp, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(proto::kMulticastPort); + + if (bind(udp, (sockaddr*)&addr, sizeof(addr)) < 0) { + releaseUdp(); + receiver_broadcast_active.store(false); + return; + } + + ip_mreq group{}; + group.imr_multiaddr.s_addr = inet_addr(proto::kMulticastGroup); + group.imr_interface.s_addr = htonl(INADDR_ANY); + if (setsockopt(udp, IPPROTO_IP, IP_ADD_MEMBERSHIP, &group, sizeof(group)) < 0) { + releaseUdp(); + receiver_broadcast_active.store(false); + return; + } + + char buf[256]; + sockaddr_in from{}; + socklen_t fromlen = sizeof(from); + while (true) { + ssize_t n = recvfrom(udp, buf, sizeof(buf) - 1, 0, (sockaddr*)&from, &fromlen); + if (n < 0) { + if (receiver_state.cancelled.load()) + break; + continue; + } + buf[n] = '\0'; + if (strcmp(buf, "DISCOVER_SERVER") == 0) { + sendto(udp, "SERVER_HERE", 11, 0, (sockaddr*)&from, fromlen); + break; + } + } + + releaseUdp(); + receiver_broadcast_active.store(false); + } + + void* TransferService::acceptEntry(void* arg) { + auto* a = static_cast<AcceptArgs*>(arg); + TransferService* svc = a->svc; + int server_fd = a->server_fd; + delete a; + svc->runAccept(server_fd); + return nullptr; + } + + void TransferService::runAccept(int server_fd) { + receiver_accept_active.store(true); + receiver_listen_sock.store(server_fd); + + sockaddr_in client_addr{}; + socklen_t client_len = sizeof(client_addr); + int client_sock = accept(server_fd, (sockaddr*)&client_addr, &client_len); + + int owned_listen = receiver_listen_sock.exchange(-1); + if (owned_listen == server_fd) + close(server_fd); + + if (client_sock >= 0) { + receiver_client_sock.store(client_sock); + + while (true) { + uint32_t filename_len = 0; + if (!recvAll(client_sock, &filename_len, sizeof(filename_len))) + break; + if (filename_len == proto::kEofSentinel) + break; + if (filename_len > proto::kMaxFilename) + break; + + std::vector<char> filename_buf(filename_len + 1, '\0'); + if (!recvAll(client_sock, filename_buf.data(), filename_len)) + break; + std::string filename_str(filename_buf.data(), filename_len); + filename_str = replaceUsername(filename_str); + + { + size_t sl = filename_str.rfind('/'); + receiver_state.setStatus(sl != std::string::npos ? filename_str.substr(sl + 1) + : filename_str); + } + + uint64_t file_size = 0; + if (!recvAll(client_sock, &file_size, sizeof(file_size))) + break; + receiveFile(client_sock, filename_str, file_size, receiver_state); + } + + int owned = receiver_client_sock.exchange(-1); + if (owned == client_sock) + close(client_sock); + + if (!receiver_state.cancelled.load()) { +#ifdef __SWITCH__ + receiver_state.setStatus("Restoring..."); + auto result = io::restore(restore_title_index, restore_uid, restore_title_name); + restore_ok = result.isOk(); + restore_error = result.isOk() ? "" : result.error(); +#else + restore_ok = true; +#endif + } + } + + receiver_state.done.store(true); + receiver_accept_active.store(false); + } + + int TransferService::startReceive(size_t title_index, AccountUid uid, std::string title_name) { + receiver_state.reset(); + receiver_state.setStatus("Waiting for connection..."); + restore_title_index = title_index; + restore_uid = uid; + restore_title_name = std::move(title_name); + restore_ok = false; + restore_error.clear(); + + pthread_t bcast_thread; + if (pthread_create(&bcast_thread, nullptr, broadcastEntry, this) != 0) + return 1; + receiver_bcast_thread = bcast_thread; + pthread_detach(bcast_thread); + + Socket server(socket(AF_INET, SOCK_STREAM, 0)); + if (!server.valid()) { + cancelReceive(); + return 1; + } + + int yes = 1; + setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(proto::kTcpPort); + + if (bind(server, (sockaddr*)&addr, sizeof(addr)) < 0 || listen(server, 3) < 0) { + cancelReceive(); + return 1; + } + + auto* acc_args = new AcceptArgs{this, server.fd}; + pthread_t accept_thread; + if (pthread_create(&accept_thread, nullptr, acceptEntry, acc_args) != 0) { + delete acc_args; + cancelReceive(); + return 1; + } + pthread_detach(accept_thread); + server.release(); + return 0; + } + + void TransferService::cancelReceive() { + receiver_state.cancelled.store(true); + int sock = receiver_client_sock.exchange(-1); + if (sock >= 0) { + shutdown(sock, SHUT_RDWR); + close(sock); + } + int lsock = receiver_listen_sock.exchange(-1); + if (lsock >= 0) { + shutdown(lsock, SHUT_RDWR); + close(lsock); + } + int bsock = receiver_bcast_sock.exchange(-1); + if (bsock >= 0) { + shutdown(bsock, SHUT_RDWR); + close(bsock); + } + if (receiver_broadcast_active.load()) + pthread_cancel(receiver_bcast_thread); + } + +} // namespace nxst diff --git a/source/TitlesLayout.cpp b/src/ui/titles_layout.cpp similarity index 65% rename from source/TitlesLayout.cpp rename to src/ui/titles_layout.cpp index 3af2d07..6b8c11f 100644 --- a/source/TitlesLayout.cpp +++ b/src/ui/titles_layout.cpp @@ -1,13 +1,9 @@ -#include <MainApplication.hpp> -#include <stdio.h> -#include <main.hpp> -#include <const.h> -#include <client.hpp> -#include <server.hpp> -#include <TransferOverlay.hpp> +#include <nxst/app/main_application.hpp> +#include <nxst/domain/util.hpp> +#include <nxst/ui/transfer_overlay.hpp> namespace ui { - extern MainApplication *mainApp; + extern MainApplication* mainApp; namespace { constexpr int ListX = theme::space::lg; @@ -18,76 +14,71 @@ namespace ui { constexpr int ContentH = theme::layout::ContentH - 2 * theme::space::md; constexpr int BtnH = 56; constexpr int BtnW = PanelW - 2 * theme::space::lg; - } + } // namespace - TitlesLayout::TitlesLayout() : Layout::Layout() { + TitlesLayout::TitlesLayout() { using namespace theme; - this->titlesMenu = pu::ui::elm::Menu::New( - ListX, ContentY, ListW, - color::BgBase, color::BgSurface2, - 88, 6); + this->titlesMenu = + pu::ui::elm::Menu::New(ListX, ContentY, ListW, color::BgBase, color::BgSurface2, 88, 6); this->titlesMenu->SetScrollbarColor(color::Primary); this->titlesMenu->SetItemsFocusColor(color::BgSurface2); - this->titlesMenu->SetOnSelectionChanged([this]() { this->refreshPanel(); }); + this->titlesMenu->SetOnSelectionChanged([this]() { + this->refreshPanel(); + }); this->SetBackgroundColor(color::BgBase); this->Add(this->titlesMenu); - this->panelBg = pu::ui::elm::Rectangle::New( - PanelX, ContentY, PanelW, ContentH, color::BgSurface, radius::lg); + this->panelBg = + pu::ui::elm::Rectangle::New(PanelX, ContentY, PanelW, ContentH, color::BgSurface, radius::lg); this->Add(this->panelBg); - this->panelTitle = pu::ui::elm::TextBlock::New( - PanelX + space::lg, ContentY + space::lg, ""); + this->panelTitle = pu::ui::elm::TextBlock::New(PanelX + space::lg, ContentY + space::lg, ""); this->panelTitle->SetFont(type::font(type::Title)); this->panelTitle->SetColor(color::TextPrimary); this->Add(this->panelTitle); - this->panelHint = pu::ui::elm::TextBlock::New( - PanelX + space::lg, ContentY + space::lg + 48, "Pick an action:"); + this->panelHint = + pu::ui::elm::TextBlock::New(PanelX + space::lg, ContentY + space::lg + 48, "Pick an action:"); this->panelHint->SetFont(type::font(type::Body)); this->panelHint->SetColor(color::TextSecondary); this->Add(this->panelHint); int btnY = ContentY + 200; - this->btnTransferBg = pu::ui::elm::Rectangle::New( - PanelX + space::lg, btnY, BtnW, BtnH, color::BgSurface2, radius::md); + this->btnTransferBg = + pu::ui::elm::Rectangle::New(PanelX + space::lg, btnY, BtnW, BtnH, color::BgSurface2, radius::md); this->Add(this->btnTransferBg); - this->btnTransferText = pu::ui::elm::TextBlock::New( - PanelX + space::lg + space::md, btnY + 14, "Transfer to another device"); + this->btnTransferText = pu::ui::elm::TextBlock::New(PanelX + space::lg + space::md, btnY + 14, + "Transfer to another device"); this->btnTransferText->SetFont(type::font(type::Body)); this->btnTransferText->SetColor(color::TextSecondary); this->Add(this->btnTransferText); int btnY2 = btnY + BtnH + space::md; - this->btnReceiveBg = pu::ui::elm::Rectangle::New( - PanelX + space::lg, btnY2, BtnW, BtnH, color::BgSurface2, radius::md); + this->btnReceiveBg = + pu::ui::elm::Rectangle::New(PanelX + space::lg, btnY2, BtnW, BtnH, color::BgSurface2, radius::md); this->Add(this->btnReceiveBg); - this->btnReceiveText = pu::ui::elm::TextBlock::New( - PanelX + space::lg + space::md, btnY2 + 14, "Receive from another device"); + this->btnReceiveText = pu::ui::elm::TextBlock::New(PanelX + space::lg + space::md, btnY2 + 14, + "Receive from another device"); this->btnReceiveText->SetFont(type::font(type::Body)); this->btnReceiveText->SetColor(color::TextSecondary); this->Add(this->btnReceiveText); this->panelFooter = pu::ui::elm::TextBlock::New( - PanelX + space::lg, - ContentY + ContentH - space::lg - 18, - "Save data only"); + PanelX + space::lg, ContentY + ContentH - space::lg - 18, "Save data only"); this->panelFooter->SetFont(type::font(type::Caption)); this->panelFooter->SetColor(color::TextMuted); this->Add(this->panelFooter); - this->emptyText = pu::ui::elm::TextBlock::New( - ListX + ListW / 2 - 280, ContentY + ContentH / 2 - 40, - "No save data on this profile"); + this->emptyText = pu::ui::elm::TextBlock::New(ListX + ListW / 2 - 280, ContentY + ContentH / 2 - 40, + "No save data on this profile"); this->emptyText->SetFont(type::font(type::Display)); this->emptyText->SetColor(color::TextPrimary); this->emptyText->SetVisible(false); this->Add(this->emptyText); - this->emptySub = pu::ui::elm::TextBlock::New( - ListX + ListW / 2 - 220, ContentY + ContentH / 2 + 16, - "Play something first, then come back."); + this->emptySub = pu::ui::elm::TextBlock::New(ListX + ListW / 2 - 220, ContentY + ContentH / 2 + 16, + "Play something first, then come back."); this->emptySub->SetFont(type::font(type::Body)); this->emptySub->SetColor(color::TextMuted); this->emptySub->SetVisible(false); @@ -98,24 +89,24 @@ namespace ui { this->updateHints(); } - void TitlesLayout::InitTitles() { + void TitlesLayout::InitTitles(AccountUid uid) { using namespace theme; - Logger::getInstance().log(Logger::INFO, "InitTitles"); + this->current_uid = uid; - auto it = this->menuCache.find(g_currentUId); + auto it = this->menuCache.find(uid); std::vector<pu::ui::elm::MenuItem::Ref>* items; if (it != this->menuCache.end()) { items = &it->second; } else { std::vector<pu::ui::elm::MenuItem::Ref> built; - for (size_t i = 0; i < getTitleCount(g_currentUId); i++) { + for (size_t i = 0; i < getTitleCount(uid); i++) { Title title; - getTitle(title, g_currentUId, i); + getTitle(title, uid, i); auto titleItem = pu::ui::elm::MenuItem::New(title.name().c_str()); titleItem->SetColor(color::TextPrimary); built.push_back(titleItem); } - auto inserted = this->menuCache.emplace(g_currentUId, std::move(built)); + auto inserted = this->menuCache.emplace(uid, std::move(built)); items = &inserted.first->second; } @@ -144,15 +135,16 @@ namespace ui { this->refreshButtons(); this->updateHints(); - this->header->SetUser(g_currentUId, Account::username(g_currentUId)); + this->header->SetUser(uid, account::username(uid)); } void TitlesLayout::refreshPanel() { - if (this->titlesMenu->GetItems().empty()) return; + if (this->titlesMenu->GetItems().empty()) + return; int idx = this->titlesMenu->GetSelectedIndex(); Title title; - getTitle(title, g_currentUId, idx); - this->panelTitle->SetText(StringUtils::elide(title.name(), 24)); + getTitle(title, this->current_uid, idx); + this->panelTitle->SetText(string_utils::elide(title.name(), 24)); } void TitlesLayout::refreshButtons() { @@ -185,24 +177,25 @@ namespace ui { } void TitlesLayout::runTransfer(int index, Title& title) { + (void)title; auto ovl = TransferOverlay::New("Transferring save data..."); this->titlesMenu->SetVisible(false); mainApp->StartOverlay(ovl); this->LockInput(); - if (transfer_files(index, g_currentUId) != 0) { + if (mainApp->transfer.startSend((size_t)index, this->current_uid) != 0) { mainApp->EndOverlay(); this->titlesMenu->SetVisible(true); this->UnlockInput(); mainApp->CreateShowDialog("Transfer", "Failed to start transfer.", {"OK"}, true); return; } - while (!isClientTransferDone()) { - ovl->SetStatus(getClientStatusText()); - ovl->SetProgressVisible(isClientProgressKnown()); - ovl->SetProgress(getClientProgress()); + while (!mainApp->transfer.isSendDone()) { + ovl->SetStatus(mainApp->transfer.sendStatusText()); + ovl->SetProgressVisible(mainApp->transfer.isSendProgressKnown()); + ovl->SetProgress(mainApp->transfer.sendProgress()); mainApp->CallForRender(); if (mainApp->GetButtonsDown() & HidNpadButton_B) { - cancelClientTransfer(); + mainApp->transfer.cancelSend(); } svcSleepThread(16666666LL); } @@ -210,9 +203,9 @@ namespace ui { this->titlesMenu->SetVisible(true); this->UnlockInput(); - if (isClientConnectionFailed()) { - mainApp->CreateShowDialog("Transfer", getClientFailReason(), {"OK"}, true); - } else if (isClientTransferCancelled()) { + if (mainApp->transfer.isSendConnectionFailed()) { + mainApp->CreateShowDialog("Transfer", mainApp->transfer.sendFailReason(), {"OK"}, true); + } else if (mainApp->transfer.isSendCancelled()) { mainApp->CreateShowDialog("Transfer", "Transfer cancelled.", {"OK"}, true); } else { mainApp->CreateShowDialog("Transfer", "Save data sent successfully!", {"OK"}, true); @@ -220,20 +213,21 @@ namespace ui { } void TitlesLayout::runReceive(int index, Title& title) { - if (startSendingThread() != 0) { - mainApp->CreateShowDialog("Receive", "Failed to start receiver.\nCheck network connection.", {"OK"}, true); + if (mainApp->transfer.startReceive((size_t)index, this->current_uid, title.name()) != 0) { + mainApp->CreateShowDialog("Receive", "Failed to start receiver.\nCheck network connection.", + {"OK"}, true); return; } auto ovl = TransferOverlay::New("Receiving save data..."); this->titlesMenu->SetVisible(false); mainApp->StartOverlay(ovl); this->LockInput(); - while (!isServerTransferDone()) { - ovl->SetStatus(getServerStatusText()); - ovl->SetProgress(getServerProgress()); + while (!mainApp->transfer.isReceiveDone()) { + ovl->SetStatus(mainApp->transfer.receiveStatusText()); + ovl->SetProgress(mainApp->transfer.receiveProgress()); mainApp->CallForRender(); if (mainApp->GetButtonsDown() & HidNpadButton_B) { - cancelServerTransfer(); + mainApp->transfer.cancelReceive(); } svcSleepThread(16666666LL); } @@ -241,24 +235,27 @@ namespace ui { this->titlesMenu->SetVisible(true); this->UnlockInput(); - if (isServerTransferCancelled()) { + if (mainApp->transfer.isReceiveCancelled()) { mainApp->CreateShowDialog("Receive", "Transfer cancelled.", {"OK"}, true); - return; - } - auto restoreResult = io::restore(index, g_currentUId, 0, title.name()); - if (std::get<0>(restoreResult)) { - mainApp->CreateShowDialog("Receive", "Save data received and restored successfully!", {"OK"}, true); + } else if (mainApp->transfer.restoreSucceeded()) { + mainApp->CreateShowDialog("Receive", "Save data received and restored successfully!", {"OK"}, + true); } else { - mainApp->CreateShowDialog("Receive", "Restore failed:\n" + std::get<2>(restoreResult), {"OK"}, true); + mainApp->CreateShowDialog("Receive", "Restore failed:\n" + mainApp->transfer.restoreError(), + {"OK"}, true); } } void TitlesLayout::onInput(u64 Down, u64 Up, u64 Held, pu::ui::TouchPoint Pos) { - if (m_inputLocked) return; + (void)Up; + (void)Held; + (void)Pos; + if (m_inputLocked) + return; if (Down & HidNpadButton_Plus) { - cancelClientTransfer(); - cancelServerTransfer(); + mainApp->transfer.cancelSend(); + mainApp->transfer.cancelReceive(); mainApp->Close(); return; } @@ -266,11 +263,12 @@ namespace ui { if (focus == TitlesFocus::List) { if (Down & HidNpadButton_B) { this->header->SetUser(std::nullopt, ""); - mainApp->LoadLayout(mainApp->usersLayout); + mainApp->LoadLayout(mainApp->users_layout); return; } if (Down & HidNpadButton_A) { - if (this->titlesMenu->GetItems().empty()) return; + if (this->titlesMenu->GetItems().empty()) + return; this->lockedListIndex = this->titlesMenu->GetSelectedIndex(); this->focus = TitlesFocus::Actions; this->action = TitlesAction::Transfer; @@ -288,17 +286,17 @@ namespace ui { this->updateHints(); return; } - if (Down & (HidNpadButton_Up | HidNpadButton_Down | - HidNpadButton_StickLUp | HidNpadButton_StickLDown)) { - this->action = (action == TitlesAction::Transfer) - ? TitlesAction::Receive : TitlesAction::Transfer; + if (Down & + (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_StickLUp | HidNpadButton_StickLDown)) { + this->action = + (action == TitlesAction::Transfer) ? TitlesAction::Receive : TitlesAction::Transfer; this->refreshButtons(); return; } if (Down & HidNpadButton_A) { int idx = this->titlesMenu->GetSelectedIndex(); Title title; - getTitle(title, g_currentUId, idx); + getTitle(title, this->current_uid, idx); TitlesAction chosen = action; this->focus = TitlesFocus::List; this->refreshButtons(); @@ -311,4 +309,4 @@ namespace ui { } } } -} +} // namespace ui diff --git a/source/UsersLayout.cpp b/src/ui/users_layout.cpp similarity index 63% rename from source/UsersLayout.cpp rename to src/ui/users_layout.cpp index 6231d70..22a315e 100644 --- a/source/UsersLayout.cpp +++ b/src/ui/users_layout.cpp @@ -1,35 +1,27 @@ -#include <cstdio> -#include <MainApplication.hpp> -#include "main.hpp" +#include <nxst/app/main_application.hpp> namespace ui { - extern MainApplication *mainApp; + extern MainApplication* mainApp; UsersLayout::UsersLayout() : Layout::Layout() { using namespace theme; - this->usersMenu = pu::ui::elm::Menu::New( - 0, layout::ContentTop + space::md, - layout::ScreenW, - color::BgBase, color::BgSurface2, - 88, 6); + this->usersMenu = pu::ui::elm::Menu::New(0, layout::ContentTop + space::md, layout::ScreenW, + color::BgBase, color::BgSurface2, 88, 6); this->usersMenu->SetScrollbarColor(color::Primary); this->usersMenu->SetItemsFocusColor(color::BgSurface2); - for (AccountUid const& uid : Account::ids()) { - auto item = pu::ui::elm::MenuItem::New(Account::username(uid)); + for (AccountUid const& uid : account::ids()) { + auto item = pu::ui::elm::MenuItem::New(account::username(uid)); item->SetColor(color::TextPrimary); this->usersMenu->AddItem(item); } - this->loadingBg = pu::ui::elm::Rectangle::New( - 0, 0, layout::ScreenW, layout::ScreenH, color::Scrim); + this->loadingBg = pu::ui::elm::Rectangle::New(0, 0, layout::ScreenW, layout::ScreenH, color::Scrim); this->loadingBg->SetVisible(false); - this->loadingText = pu::ui::elm::TextBlock::New( - layout::ScreenW / 2 - 120, - layout::ScreenH / 2 - 12, - "Loading saves..."); + this->loadingText = pu::ui::elm::TextBlock::New(layout::ScreenW / 2 - 120, layout::ScreenH / 2 - 12, + "Loading saves..."); this->loadingText->SetFont(type::font(type::Body)); this->loadingText->SetColor(color::TextSecondary); this->loadingText->SetVisible(false); @@ -50,11 +42,12 @@ namespace ui { void UsersLayout::onInput(u64 Down, u64 Up, u64 Held, pu::ui::TouchPoint Pos) { if (Down & HidNpadButton_Plus) { - svcExitProcess(); + mainApp->Close(); + return; } if (Down & HidNpadButton_A) { - g_currentUId = Account::ids().at(this->usersMenu->GetSelectedIndex()); + AccountUid uid = account::ids().at(this->usersMenu->GetSelectedIndex()); if (!areTitlesLoaded()) { this->usersMenu->SetVisible(false); @@ -69,8 +62,8 @@ namespace ui { this->usersMenu->SetVisible(true); } - mainApp->titlesLayout->InitTitles(); - mainApp->LoadLayout(mainApp->titlesLayout); + mainApp->titles_layout->InitTitles(uid); + mainApp->LoadLayout(mainApp->titles_layout); } } -} +} // namespace ui diff --git a/tools/format.sh b/tools/format.sh new file mode 100755 index 0000000..2717ef7 --- /dev/null +++ b/tools/format.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +find src include -name '*.cpp' -o -name '*.hpp' | xargs clang-format -i +echo "Formatted."