diff --git a/CONTRIBUTING_ICONS.md b/CONTRIBUTING_ICONS.md
new file mode 100644
index 0000000..6d16b09
--- /dev/null
+++ b/CONTRIBUTING_ICONS.md
@@ -0,0 +1,53 @@
+# Contributing Game Icons to NS-RPC
+
+## How to Add Your Game Icons
+
+### Step 1: Upload Icon to Discord Developer Portal
+1. Go to https://discord.com/developers/applications/1507780464306425866
+2. Click "Rich Presence" → "Art Assets"
+3. Click "Upload Asset"
+4. Upload a PNG image (512x512px recommended)
+5. Name it with a simple slug format (lowercase, underscores, no special chars)
+ - Example: `mario_kart_8_deluxe` or `zelda_botw`
+6. Note the asset name
+
+### Step 2: Add to community_assets.json
+1. Edit `community_assets.json`
+2. Add your game to the `games` object:
+ ```json
+ "Your Game Title": "your_asset_name"
+ ```
+3. Keep games alphabetically sorted by title
+4. Use exact game titles as they appear in the app
+
+### Step 3: Submit Pull Request
+1. Fork the repository
+2. Commit your changes
+3. Submit a pull request
+4. Include which game(s) you added in the description
+
+## Example Contribution
+
+```json
+"Balatro": "balatro",
+"Bayonetta": "bayonetta",
+"Bayonetta 2": "bayonetta_2",
+"Bayonetta 3": "bayonetta_3",
+"Bayonetta Origins: Cereza and the Lost Demon": "bayonetta_origins"
+```
+
+## Guidelines
+
+- **Asset Names**: Use lowercase, underscores for spaces, no special characters
+- **Game Titles**: Must match exactly as they appear in the game list
+- **Icons**: Should be clear, recognizable game artwork
+- **Format**: PNG, 512x512px minimum
+- **Originality**: Use official game art or fan art you have rights to share
+
+## Supported Games
+
+Any Nintendo Switch game can be added! Check if your game is already in the list before submitting.
+
+## Questions?
+
+Open an issue if you have questions about contributing!
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).
-
-
-
-### 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..85befc9 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"
@@ -35,7 +36,7 @@ type Pins []string
var gamesList Games
var connErr bool = false
-const clientID string = "1114647533562646700"
+const clientID string = "1507780464306425866"
const gamesURL string = "https://raw.githubusercontent.com/Da532/NS-RPC/master/games.json"
func NewApp() *App {
@@ -48,6 +49,10 @@ func (a *App) startup(ctx context.Context) {
if err != nil {
panic(err)
}
+ // Load community assets
+ a.LoadCommunityAssets()
+ // Load custom games that were saved
+ a.LoadCustomGames()
err = client.Login(clientID)
if err != nil {
connErr = true
@@ -87,6 +92,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 +125,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 {
@@ -116,7 +199,7 @@ func (a *App) GetGamesList() string {
return string(data)
}
-func (a *App) SetGame(title string, status string) {
+func (a *App) SetGame(title string, status string, console string) {
var selectedGame Game
for _, game := range gamesList {
if game.Title == title {
@@ -125,10 +208,15 @@ func (a *App) SetGame(title string, status string) {
}
}
if selectedGame.Title != "" {
+ details := selectedGame.Title
+ state := status
+ if console == "Nintendo Switch 2" {
+ details = selectedGame.Title + " | Switch 2"
+ }
err := client.SetActivity(client.Activity{
LargeImage: selectedGame.Img,
- Details: selectedGame.Title,
- State: cases.Title(language.English).String(status),
+ Details: details,
+ State: cases.Title(language.English).String(state),
})
if err != nil {
panic(err)
@@ -199,3 +287,165 @@ 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] {
+ // Check if community has an asset for this game
+ imgID := a.GetCommunityAssetName(line)
+
+ 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 {
+ // Prevent deleting Home
+ if title == "Home" {
+ response := map[string]interface{}{
+ "removed": false,
+ "message": "Cannot delete Home!",
+ }
+ data, _ := json.Marshal(response)
+ return string(data)
+ }
+
+ 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)
+}
+
+type CommunityAssets struct {
+ Assets map[string]interface{} `json:"assetMappings"`
+}
+
+var communityAssets CommunityAssets
+
+func (a *App) LoadCommunityAssets() {
+ configDir := getConfigDir()
+ assetsPath := filepath.Join(configDir, "community_assets.json")
+
+ file, err := os.Open(assetsPath)
+ if err != nil {
+ // Try loading from app directory
+ file, err = os.Open("community_assets.json")
+ if err != nil {
+ communityAssets.Assets = make(map[string]interface{})
+ return
+ }
+ }
+ defer file.Close()
+
+ bytes, _ := io.ReadAll(file)
+ json.Unmarshal(bytes, &communityAssets)
+}
+
+func (a *App) GetCommunityAssetName(gameTitle string) string {
+ // Check community assets first
+ if assets, ok := communityAssets.Assets["games"].(map[string]interface{}); ok {
+ if assetName, exists := assets[gameTitle].(string); exists {
+ return assetName
+ }
+ }
+
+ // Fall back to "home" if not found
+ return "home"
+}
+
+type VersionInfo struct {
+ Version string `json:"version"`
+ LatestURL string `json:"latestUrl"`
+}
+
+const currentVersion string = "1.3.2"
+const releaseCheckURL string = "https://api.github.com/repos/druidsareus/NS-RPC/releases/latest"
+
+func (a *App) CheckForUpdates() string {
+ resp, err := http.Get(releaseCheckURL)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return ""
+ }
+
+ var release map[string]interface{}
+ err = json.Unmarshal(body, &release)
+ if err != nil {
+ return ""
+ }
+
+ // Get tag_name (version)
+ if tagName, ok := release["tag_name"].(string); ok {
+ // Remove 'v' prefix if present
+ latestVersion := strings.TrimPrefix(tagName, "v")
+
+ // Compare versions
+ if latestVersion != currentVersion {
+ // Return GitHub releases page link instead of direct download
+ if htmlURL, ok := release["html_url"].(string); ok {
+ return htmlURL
+ }
+ }
+ }
+
+ return ""
+}
+
+func (a *App) GetCurrentVersion() string {
+ return currentVersion
+}
diff --git a/community_assets.json b/community_assets.json
new file mode 100644
index 0000000..cc2b0c4
--- /dev/null
+++ b/community_assets.json
@@ -0,0 +1,22 @@
+{
+ "assetMappings": {
+ "description": "Community-contributed game icon mappings for Discord Rich Presence",
+ "instructions": "To contribute: 1) Upload your icon to Discord Developer Portal, 2) Add the mapping below with game title as key and asset name as value",
+ "games": {
+ "Mario Kart 8 Deluxe": "mario_kart_8_deluxe",
+ "The Legend of Zelda: Breath of the Wild": "zelda_botw",
+ "The Legend of Zelda: Tears of the Kingdom": "zelda_totk",
+ "Super Smash Bros. Ultimate": "smash_bros_ultimate",
+ "Splatoon 3": "splatoon_3",
+ "Animal Crossing: New Horizons": "animal_crossing_nh",
+ "Pokémon Violet": "pokemon_violet",
+ "Pokémon Shield": "pokemon_shield",
+ "Hades": "hades",
+ "Minecraft": "minecraft",
+ "Fortnite": "fortnite",
+ "Metroid Dread": "metroid_dread",
+ "Fire Emblem: Three Houses": "fe_three_houses",
+ "Xenoblade Chronicles 3": "xenoblade_chronicles_3"
+ }
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 65e4776..5e32f39 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,8 +7,12 @@ import {
PinGame,
GetPins,
IsMac,
+ AddCustomGames,
+ RemoveGame,
+ CheckForUpdates,
+ GetCurrentVersion,
} 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,12 +20,26 @@ 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 [console, setConsole] = createSignal("Nintendo Switch");
const [connErr, setConnErr] = createSignal(false);
const [isMac, setIsMac] = createSignal(false);
+ const [customInput, setCustomInput] = createSignal("");
+ const [customMsg, setCustomMsg] = createSignal("");
+ const [updateUrl, setUpdateUrl] = createSignal("");
+ const [currentVersion, setCurrentVersion] = createSignal("");
IsMac().then((result: boolean) => setIsMac(result));
+
+ GetCurrentVersion().then((version: string) => {
+ setCurrentVersion(version);
+ CheckForUpdates().then((url: string) => {
+ if (url) setUpdateUrl(url);
+ });
+ });
const connCheck = () => {
CheckConn().then((result: boolean) => {
@@ -29,9 +47,43 @@ 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();
+ console();
connCheck();
});
@@ -43,75 +95,183 @@ const App: Component = () => {
{customMsg()}
+@@ -126,6 +286,23 @@ const App: Component = () => { Ensure Discord is started, then click this message to retry.