From f6d0ceeae58a33990346009686f1ee6a9a13b993 Mon Sep 17 00:00:00 2001 From: Gordon Date: Sat, 23 May 2026 11:36:33 -0400 Subject: [PATCH 01/11] feat: Add custom games management and improve UI - Add ability to paste and add custom Nintendo Switch games - Add ability to remove games from the list - Games and removals are now persisted to ~/NS-RPC/custom_games.json - Custom games automatically deduplicated on import - Improved button layout with flexbox alignment - Enhanced README with complete documentation - New backend functions: AddCustomGames, RemoveGame, LoadCustomGames, SaveCustomGames - Updated frontend with custom games panel and remove game panel --- README.md | 179 +++++++++++++++++++++++----- app.go | 159 +++++++++++++++++++++++++ frontend/src/App.tsx | 275 ++++++++++++++++++++++++++++++++----------- 3 files changed, 516 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 8aa17f3..089b60f 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,171 @@ -# NS-RPC +# NS-RPC Enhanced The definitive way to display your Nintendo Switch games in Discord. 🎮 -## Introduction +## Features -NS-RPC (Nintendo Switch Rich Presence) is a Wails app for Windows and macOS. -It makes it easy for anyone to share what they are playing on the Switch to Discord in a fancy Rich Presence, like a PC game. +### Core Features +- Display that you are using your Switch across all of Discord +- Select from an extensive list of games to show off +- Set a custom status message to let everyone know exactly what you're doing +- Pin your favourite games into a quick list +- Experience a clean and organized user interface -This app was built using [Wails](https://wails.io) (🏴󠁧󠁢󠁷󠁬󠁳󠁿 pride) and [SolidJS](https://solidjs.com). - -![NS-RPC's design](https://i.imgur.com/FRbQwzC.png) - -### With NS-RPC you can.. - -- Display that you are using your Switch across all of Discord. -- Select from an extensive list of games to show off. -- Set a custom status message to let everyone know exactly what you're doing. -- Pin your favourite games into a quick list. -- Experience my _questionable_ user interface. +### Enhanced Features ✨ +- **Add Custom Games**: Paste your own Nintendo Switch games (one per line) directly into the app +- **Remove Games**: Delete any game from the list with a single click +- **Persistent Storage**: All custom games and removals are automatically saved and restored when you restart the app +- **Auto Deduplication**: Duplicate games are automatically removed when adding new titles +- **Improved UI**: Better organized buttons with flexbox layout ## Prerequisites -All you need to get going is some common sense and the [Discord App](https://discordapp.com) installed to the same machine. +All you need to get going is some common sense and the [Discord App](https://discordapp.com) installed on the same machine. Users running Windows 10 or earlier _may_ encounter issues running NS-RPC due to Wails' use of **Microsoft WebView2** on Windows. **If** you do encounter problems, ensure this is installed. ## Installing -If you're looking for convenience, you'll find already built copies of NS-RPC for -both Windows and macOS [here](https://github.com/Da532/NS-RPC/releases). +Pre-built binaries for Windows, macOS, and Linux are available in the [Releases](https://github.com/druidsareus/NS-RPC/releases) section. + +### macOS +- Download `NS-RPC.dmg` +- Extract and drag `NS-RPC.app` to your Applications folder + +### Windows +- Download `NS-RPC.exe` +- Run the executable directly + +### Linux +- Build from source (see Development section below) + +## How to Use + +### Adding Custom Games +1. Click the **"Add Custom Games"** button +2. In the dialog, paste your Nintendo Switch game titles (one per line) +3. Click **"Add"** - duplicates are automatically removed +4. Custom games are instantly saved to `~/NS-RPC/custom_games.json` +5. Click **"Back"** to return to the main screen + +Example: +``` +The Legend of Zelda: Breath of the Wild +Mario Kart 8 Deluxe +Super Smash Bros. Ultimate +``` + +### Removing Games +1. Click the **"Remove Game"** button +2. Select the game you want to remove from the dropdown +3. Click the **trash icon** to delete it +4. Changes are automatically saved +5. Click **"Back"** to return to the main screen + +### Setting Your Status +1. Select a game from the dropdown +2. Enter a custom status (e.g., "Online", "Playing with Friends", "Speed Running") +3. Click **"Play"** to update your Discord status +4. Click **"Idle"** to reset to the home screen + +### Managing Pins +1. Select a game and click the **pin icon** to add it to your quick list +2. Click **"Switch Pins"** to view your pinned games +3. Click the pin icon again on a pinned game to remove it + +## Data Persistence + +### Default Games +- Loaded from the online repository on startup +- Located at: `https://raw.githubusercontent.com/druidsareus/NS-RPC/master/games.json` + +### Custom Games & Removals +- Saved to: `~/NS-RPC/custom_games.json` (on macOS/Linux) or `%USERPROFILE%\NS-RPC\custom_games.json` (on Windows) +- Automatically loaded when the app starts +- Persists across app sessions + +### Pinned Games +- Saved to: `~/NS-RPC/pinned.json` (on macOS/Linux) or `%USERPROFILE%\NS-RPC\pinned.json` (on Windows) + +## Development + +This app is built using [Wails](https://wails.io) (🏴󠁧󠁢󠁷󠁬󠁳󠁿 with pride) and [SolidJS](https://solidjs.com). + +### Prerequisites +- Go 1.18 or higher +- Node.js and Yarn +- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` + +### Building + +Install dependencies: +```bash +cd frontend +yarn install +cd .. +``` + +Build for your platform: + +**macOS (Universal - Intel + Apple Silicon)** +```bash +wails build --platform darwin/universal +``` + +**macOS (Intel only)** +```bash +wails build --platform darwin/amd64 +``` + +**macOS (Apple Silicon only)** +```bash +wails build --platform darwin/arm64 +``` + +**Windows (64-bit)** +```bash +wails build --platform windows/amd64 +``` + +**Linux (64-bit)** +```bash +wails build --platform linux/amd64 +``` + +The built app will be in `build/bin/`. + +## Technical Details + +### Backend (Go) +Key functions added for custom games management: +- `AddCustomGames(gameInput string)` - Parse and add new games from user input +- `RemoveGame(title string)` - Remove a game from the list +- `LoadCustomGames()` - Load persisted custom games on startup +- `SaveCustomGames()` - Save custom games to disk + +### Frontend (SolidJS/Tailwind) +- Custom games input panel with textarea +- Remove game selection panel with trash icon +- Improved button layout using Tailwind flexbox utilities +- Better state management for UI panels + +## What's Rewritten? + +The original NS-RPC used Electron and was codebase that the original author wasn't happy maintaining. This version uses Wails instead, which the author prefers for its lighter footprint and simpler development experience. The frontend now uses SolidJS for its speed and lack of jank compared to React. + +## Contributing + +Found a bug or have a suggestion? Feel free to open an issue or submit a pull request! -## Rewrite +## Support -Long time users may realise this is a brand new app! -NS-RPC's original codebase was not something I wanted to maintain. -It was the first project I wrote in JavaScript and I utilised Electron for this. +Need help? Join the [Discord server](https://discord.gg/StDcdMu) for support and to chat with other users. -The new version uses Wails rather than Electron which I much prefer working in. -The frontend uses SolidJS. I much prefer using this to React for its sheer speed and removal of jank, while still using JSX. +## License -## Anything else? +See LICENSE file for details. -Not as of yet. If you have feature suggestions or need support, head over to this handy [Discord server](https://discord.gg/StDcdMu) and talk to us. +--- -Have a good one! +**Original Project**: [Da532/NS-RPC](https://github.com/Da532/NS-RPC) by AlmightyCX +**Enhanced Fork**: Custom games management and improved UI diff --git a/app.go b/app.go index 9f785d3..6753daf 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "path/filepath" "runtime" "sort" + "strings" "github.com/hugolgst/rich-go/client" "golang.org/x/exp/slices" @@ -48,6 +49,8 @@ func (a *App) startup(ctx context.Context) { if err != nil { panic(err) } + // Load custom games that were saved + a.LoadCustomGames() err = client.Login(clientID) if err != nil { connErr = true @@ -87,6 +90,19 @@ func (a *App) Reconnect() bool { return true } +func getConfigDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + panic(err) + } + configDir := filepath.Join(homeDir, "NS-RPC") + _, err = os.Stat(configDir) + if err != nil { + os.Mkdir(configDir, os.ModePerm) + } + return configDir +} + func (a *App) GetGamesData() error { resp, err := http.Get(gamesURL) if err != nil { @@ -107,6 +123,71 @@ func (a *App) GetGamesData() error { return nil } +func (a *App) LoadCustomGames() { + configDir := getConfigDir() + customGamesPath := filepath.Join(configDir, "custom_games.json") + + file, err := os.Open(customGamesPath) + if err != nil { + return + } + defer file.Close() + + var customGames Games + bytes, _ := io.ReadAll(file) + err = json.Unmarshal(bytes, &customGames) + if err != nil { + return + } + + // Add custom games to the list + seen := make(map[string]bool) + for _, game := range gamesList { + seen[game.Title] = true + } + + for _, customGame := range customGames { + if !seen[customGame.Title] { + gamesList = append(gamesList, customGame) + seen[customGame.Title] = true + } + } + + sort.Slice(gamesList, func(i, j int) bool { + return gamesList[i].Title < gamesList[j].Title + }) +} + +func (a *App) SaveCustomGames() { + configDir := getConfigDir() + customGamesPath := filepath.Join(configDir, "custom_games.json") + + // Get list of default games (from the URL) + var defaultGames Games + resp, err := http.Get(gamesURL) + if err == nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + json.Unmarshal(body, &defaultGames) + } + + defaultTitles := make(map[string]bool) + for _, game := range defaultGames { + defaultTitles[game.Title] = true + } + + // Save only custom games (not in default list) + var customGames Games + for _, game := range gamesList { + if !defaultTitles[game.Title] { + customGames = append(customGames, game) + } + } + + data, _ := json.Marshal(customGames) + os.WriteFile(customGamesPath, data, os.ModePerm) +} + func (a *App) GetGamesList() string { data, err := json.Marshal(gamesList) if err != nil { @@ -199,3 +280,81 @@ func (a *App) GetPins() string { func (a *App) IsMac() bool { return runtime.GOOS != "windows" } + +func (a *App) AddCustomGames(gameInput string) string { + lines := strings.Split(gameInput, "\n") + var customGames Games + seen := make(map[string]bool) + + // Add existing games to seen map + for _, game := range gamesList { + seen[game.Title] = true + } + + // Parse new games + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Remove leading/trailing quotes if present + line = strings.Trim(line, "\"'") + if !seen[line] { + // Generate image ID from title (lowercase, replace spaces/special chars) + imgID := strings.ToLower(line) + imgID = strings.ReplaceAll(imgID, " ", "") + imgID = strings.ReplaceAll(imgID, "®", "") + imgID = strings.ReplaceAll(imgID, ":", "") + imgID = strings.ReplaceAll(imgID, "!", "") + imgID = strings.ReplaceAll(imgID, "'", "") + imgID = strings.ReplaceAll(imgID, "–", "") + imgID = strings.ReplaceAll(imgID, "-", "") + imgID = strings.ReplaceAll(imgID, "(", "") + imgID = strings.ReplaceAll(imgID, ")", "") + imgID = strings.ReplaceAll(imgID, ".", "") + + customGames = append(customGames, Game{Title: line, Img: imgID}) + gamesList = append(gamesList, Game{Title: line, Img: imgID}) + seen[line] = true + } + } + + // Sort games by title + sort.Slice(gamesList, func(i, j int) bool { + return gamesList[i].Title < gamesList[j].Title + }) + + // Save custom games to disk + a.SaveCustomGames() + + response := map[string]interface{}{ + "added": len(customGames), + "message": "Custom games added successfully!", + } + data, _ := json.Marshal(response) + return string(data) +} + +func (a *App) RemoveGame(title string) string { + for i, game := range gamesList { + if game.Title == title { + gamesList = append(gamesList[:i], gamesList[i+1:]...) + + // Save custom games to disk + a.SaveCustomGames() + + response := map[string]interface{}{ + "removed": true, + "message": "Game removed successfully!", + } + data, _ := json.Marshal(response) + return string(data) + } + } + response := map[string]interface{}{ + "removed": false, + "message": "Game not found!", + } + data, _ := json.Marshal(response) + return string(data) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65e4776..cc26ef8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,10 @@ import { PinGame, GetPins, IsMac, + AddCustomGames, + RemoveGame, } from "../wailsjs/go/main/App"; -import { faToggleOn, faThumbTack } from "@fortawesome/free-solid-svg-icons"; +import { faToggleOn, faThumbTack, faTrash } from "@fortawesome/free-solid-svg-icons"; import Fa, { FaLayers } from "solid-fa"; const App: Component = () => { @@ -16,10 +18,14 @@ const App: Component = () => { { title: "Home", img: "home" }, ]); const [pinsShow, setPinsShow] = createSignal(false); + const [customShow, setCustomShow] = createSignal(false); + const [removeShow, setRemoveShow] = createSignal(false); const [selection, setSelection] = createSignal("Home"); const [status, setStatus] = createSignal("Online"); const [connErr, setConnErr] = createSignal(false); const [isMac, setIsMac] = createSignal(false); + const [customInput, setCustomInput] = createSignal(""); + const [customMsg, setCustomMsg] = createSignal(""); IsMac().then((result: boolean) => setIsMac(result)); @@ -29,6 +35,39 @@ const App: Component = () => { }); }; + const handleAddCustomGames = () => { + const input = customInput(); + if (input.trim() === "") { + setCustomMsg("Please enter game titles"); + return; + } + AddCustomGames(input).then((result: string) => { + const parsed = JSON.parse(result); + setCustomMsg(`Added ${parsed.added} new games!`); + setCustomInput(""); + setTimeout(() => setCustomMsg(""), 3000); + GetGamesList().then((result: string) => + setGamesList(JSON.parse(result)) + ); + }); + }; + + const handleRemoveGame = (title: string) => { + RemoveGame(title).then((result: string) => { + const parsed = JSON.parse(result); + if (parsed.removed) { + setCustomMsg("Game removed!"); + setTimeout(() => setCustomMsg(""), 2000); + GetGamesList().then((result: string) => + setGamesList(JSON.parse(result)) + ); + if (selection() === title) { + setSelection("Home"); + } + } + }); + }; + createEffect(() => { selection(); status(); @@ -43,75 +82,171 @@ const App: Component = () => { -
- - - - setStatus(e.currentTarget.value)} - placeholder="Online, Karting with Friends, etc..." - /> -
- - -
- - + +
+ + + + setStatus(e.currentTarget.value)} + placeholder="Online, Karting with Friends, etc..." + /> +
+
+ + +
+
+ + + + +
+
+ + +
+ +