diff --git a/.github/workflows/wails-build.yaml b/.github/workflows/wails-build.yaml index 06e0121..f47e94f 100644 --- a/.github/workflows/wails-build.yaml +++ b/.github/workflows/wails-build.yaml @@ -34,7 +34,7 @@ jobs: id: extract_version shell: bash run: | - version=$(jq -r '.info.productVersion' ./wails.json) + version=$(jq -r '.info.productVersion' ./app/wails.json) echo "Version extracted from wails.json: $version" echo "CURRENT_VERSION=$version" >> "$GITHUB_OUTPUT" @@ -53,12 +53,16 @@ jobs: echo "Bumped version to: $new_version" echo "BUILD_VERSION=$new_version" >> "$GITHUB_OUTPUT" - jq --arg new_version "$new_version" '.info.productVersion = $new_version' ./wails.json > temp_wails.json - mv temp_wails.json ./wails.json + jq --arg new_version "$new_version" '.info.productVersion = $new_version' ./app/wails.json > temp_wails.json + mv temp_wails.json ./app/wails.json + + jq --arg new_version "$new_version" '.productVersion = $new_version' ./server/server.json > temp_wails.json + mv temp_wails.json ./server/server.json - name: Commit and Push bump run: | - git add wails.json + git add ./app/wails.json + git add ./server/server.json git commit -m "Bump version to v${{ steps.bump_version.outputs.BUILD_VERSION }}" git push @@ -73,23 +77,38 @@ jobs: shell: bash - name: Build Windows App - working-directory: . + working-directory: ./app run: wails build --clean --platform windows/amd64 -o PalworldDSGui.exe shell: bash + - name: Build Windows Server + working-directory: ./server + run: go build -o PalworldDSGUI_WinServer.exe + shell: bash + + # - name: Build Linux Server + # working-directory: ./server + # run: GOOS=linux GOARCH=amd64 go build -o PalworldDSGUI_LinuxServer + # shell: bash + - name: Upload Artifact uses: actions/upload-artifact@v3 with: name: PalworldDSGui.exe - path: ./build/bin/PalworldDSGui.exe + path: | + ./app/build/bin/PalworldDSGui.exe + ./server/PalworldDSGUI_WinServer.exe - name: Create Release id: create_release uses: softprops/action-gh-release@v1 with: - files: ./build/bin/PalworldDSGui.exe + files: | + ./app/build/bin/PalworldDSGui.exe + ./server/PalworldDSGUI_WinServer.exe tag_name: v${{ steps.bump_version.outputs.BUILD_VERSION }} name: Release v${{ steps.bump_version.outputs.BUILD_VERSION }} + draft: true body: | ## Changelog No changelog available yet. diff --git a/HOW_TO_USE.md b/HOW_TO_USE.md new file mode 100644 index 0000000..ee41bbc --- /dev/null +++ b/HOW_TO_USE.md @@ -0,0 +1,42 @@ +## Quick Start Guide + +1. Download the **GUI Server** `PalworldDSGUI_Server.exe` from the [releases page](https://github.com/diogomartino/palworld-ds-gui/releases). +2. Place the executable in a folder of your choice. +3. Run the executable. +4. Wait until the server is downloaded from the Steam servers. +5. An API key will be shown on the console. Save it, you will need it to connect to the server. +6. Download the **GUI Client** `PalworldDSGui.exe` from the [releases page](https://github.com/diogomartino/palworld-ds-gui/releases). +7. Open the client and use the API key to connect to the server. + +## Remote Server + +If you want to run the server on a remote machine (eg: a VPS), the steps are the same as above, you just need to download and execute the server on the remote machine, and then use the client to connect to it. You may need to open the port **21577** on the remote machine to be able to access it from the client. + +## Using an existing server + +If you already have a Palworld server, you can still use this tool, you just need to make sure that the folder structure is correct, which should look like this: + +```plaintext +SomeRandomFolder/ +├── server/ <------- This is your existing server folder (the one with the **PalServer.exe** executable) +├── PalworldDSGUI_Server.ex +``` + +The `server` folder should contain the `PalServer.exe` executable and all the other files that come with the server. The `PalworldDSGUI_Server.exe` should be in the same folder as the `server` folder. Then you can run the `PalworldDSGUI_Server.exe` and it will detect the existing server. + +## GUI server parameters + +| Param | Description | Default | +| -------- | ------------------------- | ------- | +| -newkey | Generate a new API key | +| -showkey | Show the current API key | +| -help | Show help | +| -port | Port to run the server on | 21577 | + +## Running the server on Linux + +Coming soon. + +## Running it in Docker + +Coming soon. diff --git a/README.md b/README.md index a9461dd..5303431 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,24 @@ This is a GUI for the Palworld Dedicated Server. Configure and manage your serve ## Download -You can download the latest version from the [releases page](https://github.com/diogomartino/palworld-ds-gui/releases). +You can downloads the latest versions from the [releases page](https://github.com/diogomartino/palworld-ds-gui/releases). [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) is required. You most likely already have it installed, so you don't need to worry. +### [How To Use - Click here](HOW_TO_USE.md) + > [!WARNING] > Be aware that this software is still in early development and may contain bugs. Please report any issues you find. ## Screenshots +![Connecting](https://i.imgur.com/e5rSvBE.png) ![Home](https://i.imgur.com/157panY.png) ![Settings](https://i.imgur.com/gu0x0PS.png) ![Admin](https://i.imgur.com/49giAIK.png) ![Backups](https://i.imgur.com/3IboT0o.png) -## Instructions - -1. Open the app -2. Wait for the dedicated server to be downloaded from Steam -3. Configure your server -4. Hit the "Start" button - -The default settings mirror the official configurations, but users have the flexibility to customize them according to their preferences. - -## Development - -### Future plans - -- Add support for Linux and macOS -- Create RCON management interface -- Automated backups (both local and remote) -- Automatic server updates -- Multiple map management -- User profiles management -- Automated imports of local worlds -- Resource usage graphs (CPU, RAM, etc) +## Developmen ### Requirements @@ -51,8 +34,6 @@ The default settings mirror the official configurations, but users have the flex wails dev -s ``` -This will launch the application in development mode. The interface will also run on http://localhost:34115 in case you want to run it in a browser. - ## Building ``` diff --git a/.gitignore b/app/.gitignore similarity index 100% rename from .gitignore rename to app/.gitignore diff --git a/app.go b/app/app.go similarity index 55% rename from app.go rename to app/app.go index 4b02d4f..d291ed0 100644 --- a/app.go +++ b/app/app.go @@ -3,31 +3,26 @@ package main import ( "context" "fmt" - backupsmanager "palword-ds-gui/backups-manager" - dedicatedserver "palword-ds-gui/dedicated-server" + "io" + "net/http" + "os" rconclient "palword-ds-gui/rcon-client" - "palword-ds-gui/steamcmd" "palword-ds-gui/utils" + "path" "github.com/gocolly/colly/v2" "github.com/wailsapp/wails/v2/pkg/runtime" ) type App struct { - ctx context.Context - steamCmd *steamcmd.SteamCMD - dedicatedServer *dedicatedserver.DedicatedServer - backupsManager *backupsmanager.BackupManager - rconClient *rconclient.RconClient - reactReady bool + ctx context.Context + rconClient *rconclient.RconClient + reactReady bool } -func NewApp(server *dedicatedserver.DedicatedServer, cmd *steamcmd.SteamCMD, backupManager *backupsmanager.BackupManager, rconClient *rconclient.RconClient) *App { +func NewApp(rconClient *rconclient.RconClient) *App { return &App{ - steamCmd: cmd, - dedicatedServer: server, - backupsManager: backupManager, - rconClient: rconClient, + rconClient: rconClient, } } @@ -48,19 +43,11 @@ func (a *App) InitApp() { return } - a.steamCmd.Init(a.ctx) - a.dedicatedServer.Init(a.ctx) - a.backupsManager.Init(a.ctx) a.reactReady = true - - runtime.EventsEmit(a.ctx, "SET_LOADING_STATUS", "DONE") utils.LogToFile("app.go: initApp()") } func (a *App) beforeClose(ctx context.Context) (prevent bool) { - a.dedicatedServer.Dispose() - a.backupsManager.Dispose() - utils.LogToFile("app.go: beforeClose()") return false } @@ -90,3 +77,48 @@ func (a *App) GetSteamProfileURL(steamid string) { return } } + +func (a *App) SaveLog(log string) { + utils.LogToFile(log) +} + +func (a *App) OpenLogs() { + utils.OpenExplorerWithFile(utils.Config.AppDataDir, "logs.txt") +} + +func (a *App) DownloadFile(url string, filename string, authToken string) error { + path := path.Join(utils.GetCurrentDir(), filename) + utils.LogToFile(fmt.Sprintf("Downloading %s to %s", url, path)) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned non-200 status: %d %s", resp.StatusCode, resp.Status) + } + + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + utils.OpenExplorerWithFile(utils.GetCurrentDir(), filename) + + return nil +} diff --git a/build/appicon.png b/app/build/appicon.png similarity index 100% rename from build/appicon.png rename to app/build/appicon.png diff --git a/frontend/.eslintrc.cjs b/app/frontend/.eslintrc.cjs similarity index 100% rename from frontend/.eslintrc.cjs rename to app/frontend/.eslintrc.cjs diff --git a/frontend/.gitignore b/app/frontend/.gitignore similarity index 100% rename from frontend/.gitignore rename to app/frontend/.gitignore diff --git a/frontend/.npmrc b/app/frontend/.npmrc similarity index 100% rename from frontend/.npmrc rename to app/frontend/.npmrc diff --git a/frontend/.prettierignore b/app/frontend/.prettierignore similarity index 100% rename from frontend/.prettierignore rename to app/frontend/.prettierignore diff --git a/frontend/.prettierrc b/app/frontend/.prettierrc similarity index 100% rename from frontend/.prettierrc rename to app/frontend/.prettierrc diff --git a/frontend/index.html b/app/frontend/index.html similarity index 100% rename from frontend/index.html rename to app/frontend/index.html diff --git a/frontend/package.json b/app/frontend/package.json similarity index 91% rename from frontend/package.json rename to app/frontend/package.json index e00eb76..86952fc 100644 --- a/frontend/package.json +++ b/app/frontend/package.json @@ -11,12 +11,16 @@ "framer-motion": "^10.16.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.12", "react-redux": "^8.1.3", "react-router": "^6.21.3", "react-router-dom": "^6.21.3", - "redux": "^4.2.1" + "react-toastify": "^10.0.4", + "redux": "^4.2.1", + "ws": "^8.16.0" }, "devDependencies": { + "@types/node": "^20.11.17", "@types/react": "^18.2.34", "@types/react-dom": "^18.2.14", "@typescript-eslint/eslint-plugin": "^6.9.1", diff --git a/app/frontend/package.json.md5 b/app/frontend/package.json.md5 new file mode 100644 index 0000000..fa9726d --- /dev/null +++ b/app/frontend/package.json.md5 @@ -0,0 +1 @@ +5a84e7b692d7b7a616c13967a1f63bfe \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/app/frontend/pnpm-lock.yaml similarity index 98% rename from frontend/pnpm-lock.yaml rename to app/frontend/pnpm-lock.yaml index 75e3f73..998efd6 100644 --- a/frontend/pnpm-lock.yaml +++ b/app/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.12 + version: 4.0.12(react@18.2.0) react-redux: specifier: ^8.1.3 version: 8.1.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) @@ -35,11 +38,20 @@ dependencies: react-router-dom: specifier: ^6.21.3 version: 6.21.3(react-dom@18.2.0)(react@18.2.0) + react-toastify: + specifier: ^10.0.4 + version: 10.0.4(react-dom@18.2.0)(react@18.2.0) redux: specifier: ^4.2.1 version: 4.2.1 + ws: + specifier: ^8.16.0 + version: 8.16.0 devDependencies: + '@types/node': + specifier: ^20.11.17 + version: 20.11.17 '@types/react': specifier: ^18.2.34 version: 18.2.34 @@ -90,7 +102,7 @@ devDependencies: version: 5.2.2 vite: specifier: ^4.5.1 - version: 4.5.1 + version: 4.5.1(@types/node@20.11.17) packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -3513,6 +3525,15 @@ packages: } dev: true + /@types/node@20.11.17: + resolution: + { + integrity: sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw== + } + dependencies: + undici-types: 5.26.5 + dev: true + /@types/prop-types@15.7.9: resolution: { @@ -3728,7 +3749,7 @@ packages: vite: ^4 dependencies: '@swc/core': 1.3.95 - vite: 4.5.1 + vite: 4.5.1(@types/node@20.11.17) transitivePeerDependencies: - '@swc/helpers' dev: true @@ -3977,6 +3998,14 @@ packages: engines: { node: '>=6' } dev: false + /clsx@2.1.0: + resolution: + { + integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg== + } + engines: { node: '>=6' } + dev: false + /color-convert@2.0.1: resolution: { @@ -5532,6 +5561,18 @@ packages: scheduler: 0.23.0 dev: false + /react-error-boundary@4.0.12(react@18.2.0): + resolution: + { + integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA== + } + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.23.2 + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: { @@ -5690,6 +5731,20 @@ packages: - '@types/react' dev: false + /react-toastify@10.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: + { + integrity: sha512-etR3RgueY8pe88SA67wLm8rJmL1h+CLqUGHuAoNsseW35oTGJEri6eBTyaXnFKNQ80v/eO10hBYLgz036XRGgA== + } + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + clsx: 2.1.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: { @@ -6117,6 +6172,13 @@ packages: hasBin: true dev: true + /undici-types@5.26.5: + resolution: + { + integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + } + dev: true + /untildify@4.0.0: resolution: { @@ -6246,7 +6308,7 @@ packages: integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== } - /vite@4.5.1: + /vite@4.5.1(@types/node@20.11.17): resolution: { integrity: sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA== @@ -6277,6 +6339,7 @@ packages: terser: optional: true dependencies: + '@types/node': 20.11.17 esbuild: 0.18.20 postcss: 8.4.31 rollup: 3.29.4 @@ -6301,6 +6364,22 @@ packages: integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== } + /ws@8.16.0: + resolution: + { + integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + } + engines: { node: '>=10.0.0' } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /yallist@4.0.0: resolution: { diff --git a/frontend/postcss.config.js b/app/frontend/postcss.config.js similarity index 100% rename from frontend/postcss.config.js rename to app/frontend/postcss.config.js diff --git a/frontend/public/vite.svg b/app/frontend/public/vite.svg similarity index 100% rename from frontend/public/vite.svg rename to app/frontend/public/vite.svg diff --git a/frontend/src/actions/app.ts b/app/frontend/src/actions/app.ts similarity index 71% rename from frontend/src/actions/app.ts rename to app/frontend/src/actions/app.ts index 6ea749d..e705998 100644 --- a/frontend/src/actions/app.ts +++ b/app/frontend/src/actions/app.ts @@ -1,12 +1,11 @@ import { appSliceActions } from '../store/app-slice'; import { store } from '../store'; -import { LoadingStatus, TSettings } from '../types'; -import { DesktopApi } from '../desktop'; +import { TSettings } from '../types'; +import { DesktopAPI } from '../desktop'; import { settingsSelector, steamImagesCacheSelector } from '../selectors/app'; - -export const setLoadingStatus = (loadingStatus: LoadingStatus) => { - store.dispatch(appSliceActions.setLoadingStatus(loadingStatus)); -}; +import { ServerAPI } from '../server'; +import { serverSliceActions } from '../store/server-slice'; +import { toast } from 'react-toastify'; export const toggleTheme = () => { store.dispatch(appSliceActions.toggleTheme()); @@ -20,6 +19,10 @@ export const setRconCredentials = (host: string, password: string) => { store.dispatch(appSliceActions.setRconCredentials({ host, password })); }; +export const setServerCredentials = (host: string, apiKey: string) => { + store.dispatch(appSliceActions.setServerCredentials({ host, apiKey })); +}; + export const saveSettings = (settings?: TSettings) => { let targetSettings; @@ -34,31 +37,19 @@ export const saveSettings = (settings?: TSettings) => { localStorage.setItem('settings', JSON.stringify(targetSettings)); }; -export const initApp = async () => { - const state = store.getState(); - const { backup } = settingsSelector(state); - - await DesktopApi.server.readConfig(); - await DesktopApi.server.readSaveName(); - - if (backup.enabled) { - await DesktopApi.backups.start(backup.intervalHours, backup.keepCount); - } -}; - -export const changeBackupSettings = ( +export const changeBackupSettings = async ( enabled: boolean, intervalHours: number, keepCount: number ) => { if (enabled) { - DesktopApi.backups.start(+intervalHours, +keepCount); + await ServerAPI.backups.start(+intervalHours, +keepCount); } else { - DesktopApi.backups.stop(); + await ServerAPI.backups.stop(); } store.dispatch( - appSliceActions.setBackupSettings({ + serverSliceActions.setBackupSettings({ enabled, intervalHours, keepCount @@ -96,5 +87,13 @@ export const cacheSteamImage = async (steamId: string) => { return; } - DesktopApi.getProfileImageURL(steamId); + DesktopAPI.getProfileImageURL(steamId); +}; + +export const notifyError = (message: string) => { + toast(message, { type: 'error' }); +}; + +export const notifySuccess = (message: string) => { + toast(message, { type: 'success', autoClose: 2000 }); }; diff --git a/app/frontend/src/actions/console.ts b/app/frontend/src/actions/console.ts new file mode 100644 index 0000000..babc084 --- /dev/null +++ b/app/frontend/src/actions/console.ts @@ -0,0 +1,7 @@ +import { consolesSliceActions } from '../store/console-slice'; +import { store } from '../store'; +import { TConsoleEntry } from '../types'; + +export const addConsoleEntry = (entry: TConsoleEntry) => { + store.dispatch(consolesSliceActions.addConsoleEntry(entry)); +}; diff --git a/frontend/src/actions/modal.ts b/app/frontend/src/actions/modal.ts similarity index 100% rename from frontend/src/actions/modal.ts rename to app/frontend/src/actions/modal.ts diff --git a/app/frontend/src/actions/server.ts b/app/frontend/src/actions/server.ts new file mode 100644 index 0000000..d9d4e50 --- /dev/null +++ b/app/frontend/src/actions/server.ts @@ -0,0 +1,43 @@ +import { serverSliceActions } from '../store/server-slice'; +import { store } from '../store'; +import { ConfigKey, TConfig } from '../types/server-config'; +import { ServerStatus } from '../types'; +import { consolesSliceActions } from '../store/console-slice'; +import { rconCredentialsSelector } from '../selectors/app'; +import { setRconCredentials } from './app'; + +export const setConfig = (config: TConfig) => { + const state = store.getState(); + const rconCredentials = rconCredentialsSelector(state); + + // auto fill rcon credentials if they are not set + if (!rconCredentials.host && !rconCredentials.password) { + setRconCredentials( + `127.0.0.1:${config[ConfigKey.RCONPort]}`, + config[ConfigKey.AdminPassword] + ); + } + + store.dispatch(serverSliceActions.setConfig(config)); +}; + +export const setSaveName = (saveName: string) => { + store.dispatch(serverSliceActions.setSaveName(saveName)); +}; + +export const setStatus = (status: ServerStatus) => { + store.dispatch(serverSliceActions.setStatus(status)); +}; + +export const clearServerState = () => { + store.dispatch(serverSliceActions.clearServerState()); + store.dispatch(consolesSliceActions.clearConsole()); +}; + +export const setBackupsList = (backupsList: any) => { + store.dispatch(serverSliceActions.setBackupsList(backupsList)); +}; + +export const setServerVersion = (version: string) => { + store.dispatch(serverSliceActions.setVersion(version)); +}; diff --git a/app/frontend/src/actions/socket.ts b/app/frontend/src/actions/socket.ts new file mode 100644 index 0000000..314251a --- /dev/null +++ b/app/frontend/src/actions/socket.ts @@ -0,0 +1,118 @@ +import { socketSliceActions } from '../store/socket-slice'; +import { serverSliceActions } from '../store/server-slice'; +import { store } from '../store'; +import { + Modal, + ServerStatus, + TBackup, + TBackupSettings, + TClientInitedData, + TConsoleEntry +} from '../types'; +import { setLaunchParams } from './app'; +import { + setBackupsList, + setConfig, + setSaveName, + setServerVersion +} from './server'; +import { parseConfig } from '../helpers/config-parser'; +import { addConsoleEntry } from './console'; +import { openModal } from './modal'; + +export const setSocket = (socket: WebSocket) => { + store.dispatch(socketSliceActions.setSocket(socket)); +}; + +export const setSocketConnecting = (connecting: boolean) => { + store.dispatch(socketSliceActions.setConnecting(connecting)); +}; + +export const setSocketError = (error: string | boolean) => { + store.dispatch(socketSliceActions.setError(error)); +}; + +export const setSocketInited = (inited: boolean) => { + store.dispatch(socketSliceActions.setInited(inited)); +}; + +export const clearSocket = (clearError = false) => { + store.dispatch(socketSliceActions.clear()); + + if (clearError) { + store.dispatch(socketSliceActions.setError(undefined)); + } +}; + +export const onErrorReceived = (error: string) => { + store.dispatch(socketSliceActions.setError(error)); +}; + +export const onServerStatusChanged = (data: ServerStatus) => { + store.dispatch(serverSliceActions.setStatus(data)); +}; + +export const onAddConsoleEntry = (message: string) => { + const entry: TConsoleEntry = { + message, + msgType: 'stdout', + timestamp: Date.now() + }; + + addConsoleEntry(entry); +}; + +export const onClientInited = (data: TClientInitedData) => { + onServerStatusChanged(data.currentServerStatus); + onLaunchParamsChanged(data.currentLaunchParams); + onServerConfigChanged(data.currentConfig); + onServerSaveNameChanged(data.currentSaveName); + onBackupSettingsUpdated(data.currentBackupsSettings); + onBackupListUpdated(data.currentBackupsList); + + setServerVersion(data.serverVersion); + setSocketInited(true); + setSocketConnecting(false); + + if (data.serverVersion !== APP_VERSION) { + openModal(Modal.ACTION_CONFIRMATION, { + clientVersion: APP_VERSION, + serverVersion: data.serverVersion + }); + } +}; + +export const onBackupListUpdated = (data) => { + const backups: TBackup[] = data.map((backup) => ({ + fileName: backup.Filename, + saveName: backup.SaveName, + size: backup.Size, + timestamp: backup.Timestamp + })); + + setBackupsList(backups); +}; + +export const onBackupSettingsUpdated = (data) => { + const backupsSettings: TBackupSettings = { + enabled: data.Enabled, + intervalHours: data.Interval, + keepCount: data.KeepCount + }; + + store.dispatch(serverSliceActions.setBackupSettings(backupsSettings)); +}; + +export const onLaunchParamsChanged = (launchParams: string) => { + setLaunchParams(launchParams); +}; + +export const onServerConfigChanged = (configStr: string) => { + const config = parseConfig(configStr); + + setConfig(config); +}; + +export const onServerSaveNameChanged = (saveName: string) => { + setSaveName(saveName); +}; diff --git a/frontend/src/assets/palworld-logo.webp b/app/frontend/src/assets/palworld-logo.webp similarity index 100% rename from frontend/src/assets/palworld-logo.webp rename to app/frontend/src/assets/palworld-logo.webp diff --git a/frontend/src/components/app/index.tsx b/app/frontend/src/components/app/index.tsx similarity index 62% rename from frontend/src/components/app/index.tsx rename to app/frontend/src/components/app/index.tsx index 19cccd1..93951b7 100644 --- a/frontend/src/components/app/index.tsx +++ b/app/frontend/src/components/app/index.tsx @@ -1,17 +1,16 @@ import Routing from '../routing'; import useEventsInit from '../../hooks/use-events-init'; -import useLoadingStatus from '../../hooks/use-loading-status'; -import { LoadingStatus } from '../../types'; import Initializing from '../../screens/initializing'; import useTheme from '../../hooks/use-theme'; +import useSocket from '../../hooks/use-socket'; const App = () => { useEventsInit(); useTheme(); - const loadingStatus = useLoadingStatus(); + const { socket, inited } = useSocket(); - if (loadingStatus !== LoadingStatus.DONE) { + if (!socket || socket.readyState !== WebSocket.OPEN || !inited) { return ; } diff --git a/app/frontend/src/components/custom-error-boundary/index.tsx b/app/frontend/src/components/custom-error-boundary/index.tsx new file mode 100644 index 0000000..18607d1 --- /dev/null +++ b/app/frontend/src/components/custom-error-boundary/index.tsx @@ -0,0 +1,63 @@ +import { Button } from '@nextui-org/react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { DesktopAPI } from '../../desktop'; +import { useCallback } from 'react'; + +type TErrorBoundaryProps = { + children: React.ReactNode; +}; + +type TErrorFallbackProps = { + error: Error; + resetErrorBoundary: () => void; +}; + +const ErrorFallback = ({ error }: TErrorFallbackProps) => { + return ( +
+

ERROR

+

+ Something went wrong and the application could not recover :( +

+

Error: {error.message}

+ + + +

+ If the error persists, please open an issue on GitHub{' '} + + DesktopAPI.openUrl( + 'https://github.com/diogomartino/palworld-ds-gui/issues' + ) + } + > + here. + +

+
+ ); +}; + +const CustomErrorBoundary = ({ children }: TErrorBoundaryProps) => { + const logError = useCallback((error: Error) => { + DesktopAPI.logToFile(error.stack ?? error.message); + }, []); + + return ( + + {children} + + ); +}; + +export default CustomErrorBoundary; diff --git a/app/frontend/src/components/custom-toast-container/index.tsx b/app/frontend/src/components/custom-toast-container/index.tsx new file mode 100644 index 0000000..10f32bf --- /dev/null +++ b/app/frontend/src/components/custom-toast-container/index.tsx @@ -0,0 +1,24 @@ +import { ToastContainer, Slide } from 'react-toastify'; +import useSelectedTheme from '../../hooks/use-selected-theme'; + +const CustomToastContainer = () => { + const theme = useSelectedTheme(); + + return ( + + ); +}; + +export default CustomToastContainer; diff --git a/frontend/src/components/layout/index.tsx b/app/frontend/src/components/layout/index.tsx similarity index 100% rename from frontend/src/components/layout/index.tsx rename to app/frontend/src/components/layout/index.tsx diff --git a/app/frontend/src/components/modals/confirm-action/index.tsx b/app/frontend/src/components/modals/confirm-action/index.tsx new file mode 100644 index 0000000..90769e3 --- /dev/null +++ b/app/frontend/src/components/modals/confirm-action/index.tsx @@ -0,0 +1,75 @@ +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader +} from '@nextui-org/react'; +import { closeModals } from '../../../actions/modal'; +import useModalsInfo from '../../../hooks/use-modals-info'; +import { DesktopAPI } from '../../../desktop'; + +type TVersionMismatchModalProps = { + clientVersion: string; + serverVersion: string; +}; + +const VersionMismatchModal = ({ + clientVersion, + serverVersion +}: TVersionMismatchModalProps) => { + const { isModalOpen } = useModalsInfo(); + + return ( + { + closeModals(); + }} + scrollBehavior="inside" + > + + +

Version Mismatch

+
+ +

+ The server version is different from the client version. This may + cause issues. Please make sure the server and this app are up to + date. +

+
+
+ Server version: + {serverVersion} +
+
+ Client version: + {clientVersion} +
+
+ + + DesktopAPI.openUrl( + 'https://github.com/diogomartino/palworld-ds-gui/releases/latest' + ) + } + > + Download latest versions here + +
+ + + +
+
+ ); +}; + +export default VersionMismatchModal; diff --git a/frontend/src/components/modals/index.tsx b/app/frontend/src/components/modals/index.tsx similarity index 64% rename from frontend/src/components/modals/index.tsx rename to app/frontend/src/components/modals/index.tsx index 246c331..b80b2ed 100644 --- a/frontend/src/components/modals/index.tsx +++ b/app/frontend/src/components/modals/index.tsx @@ -2,9 +2,11 @@ import { createElement } from 'react'; import useModalsInfo from '../../hooks/use-modals-info'; import { Modal } from '../../types'; import ConfirmActionModal from './confirm-action'; +import VersionMismatchModal from './confirm-action'; const ModalsMap = { - [Modal.ACTION_CONFIRMATION]: ConfirmActionModal + [Modal.ACTION_CONFIRMATION]: ConfirmActionModal, + [Modal.VERSION_MISMATCH]: VersionMismatchModal }; const ModalsProvider = () => { @@ -12,7 +14,7 @@ const ModalsProvider = () => { if (!openModal || !ModalsMap[openModal]) return null; - return createElement(ModalsMap[openModal], modalProps); + return createElement(ModalsMap[openModal], modalProps); }; export default ModalsProvider; diff --git a/frontend/src/components/modals/confirm-action/index.tsx b/app/frontend/src/components/modals/version-mismatch/index.tsx similarity index 96% rename from frontend/src/components/modals/confirm-action/index.tsx rename to app/frontend/src/components/modals/version-mismatch/index.tsx index 5142bfb..58555c4 100644 --- a/frontend/src/components/modals/confirm-action/index.tsx +++ b/app/frontend/src/components/modals/version-mismatch/index.tsx @@ -47,7 +47,7 @@ const ConfirmActionModal = ({ > - {title ?? 'Please confirm your action.'} +

{title ?? 'Please confirm your action.'}

{message ?? 'Are you sure?'} diff --git a/frontend/src/components/routing/index.tsx b/app/frontend/src/components/routing/index.tsx similarity index 100% rename from frontend/src/components/routing/index.tsx rename to app/frontend/src/components/routing/index.tsx diff --git a/app/frontend/src/components/sidebar/index.tsx b/app/frontend/src/components/sidebar/index.tsx new file mode 100644 index 0000000..c357caf --- /dev/null +++ b/app/frontend/src/components/sidebar/index.tsx @@ -0,0 +1,58 @@ +import { + IconBolt, + IconDoorExit, + IconFiles, + IconInfoHexagon, + IconServer, + IconSettings, + IconSettingsUp +} from '@tabler/icons-react'; +import NavItem from './nav-item'; +import useHasUpdates from '../../hooks/use-latest-version'; +import useSocket from '../../hooks/use-socket'; + +const Sidebar = () => { + const { hasUpdates } = useHasUpdates(); + const { disconnect } = useSocket(); + + return ( +
+
+
+ + + + + + +
+ +
+ +
+
+
+ ); +}; + +export default Sidebar; diff --git a/frontend/src/components/sidebar/nav-item.tsx b/app/frontend/src/components/sidebar/nav-item.tsx similarity index 96% rename from frontend/src/components/sidebar/nav-item.tsx rename to app/frontend/src/components/sidebar/nav-item.tsx index 2e4f8ad..8c8881a 100644 --- a/frontend/src/components/sidebar/nav-item.tsx +++ b/app/frontend/src/components/sidebar/nav-item.tsx @@ -63,9 +63,10 @@ const NavItem = ({ >

{label}

diff --git a/app/frontend/src/components/socket-provider/index.tsx b/app/frontend/src/components/socket-provider/index.tsx new file mode 100644 index 0000000..94a9b56 --- /dev/null +++ b/app/frontend/src/components/socket-provider/index.tsx @@ -0,0 +1,134 @@ +import { createContext } from 'react'; +import { + clearSocket, + onAddConsoleEntry, + onBackupListUpdated, + onBackupSettingsUpdated, + onLaunchParamsChanged, + onServerConfigChanged, + onServerSaveNameChanged, + onServerStatusChanged, + setSocket, + setSocketConnecting, + setSocketError +} from '../../actions/socket'; +import { SocketEvent, TSocketMessage } from '../../types'; +import { ServerAPI } from '../../server'; +import { clearServerState } from '../../actions/server'; +import { useNavigate } from 'react-router'; +import { DesktopAPI } from '../../desktop'; + +type TSocketProviderProps = { + children: React.ReactNode; +}; + +type TContext = { + connect: (address: string, apiKey: string) => void; + disconnect: () => void; +}; + +export const WebSocketContext = createContext({ + connect: () => {}, + disconnect: () => {} +}); + +const SocketProvider = ({ children }: TSocketProviderProps) => { + const navigate = useNavigate(); + + const connect = (address: string = 'localhost:21577', apiKey: string) => { + clearServerState(); + clearSocket(true); + setSocketConnecting(true); + setSocketError(false); + DesktopAPI.logToFile(`Connecting to ${address}`); + + const socketUrl = `ws://${address}/ws?auth=${apiKey}`; + const socket = new WebSocket(socketUrl); + + const onOpen = () => { + DesktopAPI.logToFile(`Connected to ${address}`); + navigate('/'); + setSocket(socket); + ServerAPI.init(); + }; + + const onClose = () => { + clearSocket(); + DesktopAPI.logToFile(`Disconnected from ${address}`); + }; + + const onError = (event: Event) => { + setSocketError(true); + setSocketConnecting(false); + DesktopAPI.logToFile(`Websocket error: ${JSON.stringify(event)}`); + }; + + const onMessage = (event: MessageEvent) => { + if (event.type === 'message') { + const message: TSocketMessage = JSON.parse( + event.data || '{success: false}' + ); + + if (!message.success) { + console.error('Something went wrong', message); + } + + switch (message.event) { + case SocketEvent.SERVER_STATUS_CHANGED: + onServerStatusChanged(message.data); + break; + case SocketEvent.SERVER_CONFIG_CHANGED: + onServerConfigChanged(message.data); + break; + case SocketEvent.SERVER_SAVE_NAME_CHANGED: + onServerSaveNameChanged(message.data); + break; + case SocketEvent.ADD_CONSOLE_ENTRY: + onAddConsoleEntry(message.data); + break; + case SocketEvent.BACKUP_LIST_CHANGED: + onBackupListUpdated(message.data); + break; + case SocketEvent.BACKUP_SETTINGS_CHANGED: + onBackupSettingsUpdated(message.data); + break; + case SocketEvent.LAUNCH_PARAMS_CHANGED: + onLaunchParamsChanged(message.data); + break; + default: + break; + } + } + }; + + socket.addEventListener('open', onOpen); + socket.addEventListener('close', onClose); + socket.addEventListener('error', onError); + socket.addEventListener('message', onMessage); + + return () => { + socket.removeEventListener('open', onOpen); + socket.removeEventListener('close', onClose); + socket.removeEventListener('error', onError); + socket.removeEventListener('message', onMessage); + socket.close(); + }; + }; + + const disconnect = () => { + clearSocket(); + }; + + return ( + + {children} + + ); +}; + +export default SocketProvider; diff --git a/frontend/src/components/terminal-output/index.tsx b/app/frontend/src/components/terminal-output/index.tsx similarity index 94% rename from frontend/src/components/terminal-output/index.tsx rename to app/frontend/src/components/terminal-output/index.tsx index 0d892ea..5ea9388 100644 --- a/frontend/src/components/terminal-output/index.tsx +++ b/app/frontend/src/components/terminal-output/index.tsx @@ -16,7 +16,7 @@ const Entry = ({ entry }: TEntryProps) => { return (
- {new Date(entry.timestamp * 1000).toLocaleTimeString()} + {new Date(entry.timestamp).toLocaleTimeString()} {entry.message}
diff --git a/app/frontend/src/desktop.ts b/app/frontend/src/desktop.ts new file mode 100644 index 0000000..a50a08e --- /dev/null +++ b/app/frontend/src/desktop.ts @@ -0,0 +1,135 @@ +import { AppEvent, TGenericFunction } from './types'; +import { EventsOff, EventsOn } from './wailsjs/runtime/runtime'; +import * as RconClient from './wailsjs/go/rconclient/RconClient'; +import * as App from './wailsjs/go/main/App'; +import { store } from './store'; +import { rconCredentialsSelector } from './selectors/app'; +import { RconCommand, TRconInfo, TRconPlayer } from './types/rcon'; + +export const DesktopAPI = { + onAppEvent: ( + event: AppEvent, + callback, + unsubscribes?: TGenericFunction[] + ) => { + EventsOn(event, callback); + + const unsubscribe = () => { + EventsOff(event, callback); + }; + + if (unsubscribes) { + unsubscribes.push(unsubscribe); + } + + return unsubscribe; + }, + openUrl: async (url: string) => { + await App.OpenInBrowser(url); + }, + initApp: async () => { + await App.InitApp(); + }, + getProfileImageURL: async (steamID64: string) => { + await App.GetSteamProfileURL(steamID64); + }, + logToFile: async (message: string) => { + await App.SaveLog(message); + }, + openLogFile: async () => { + await App.OpenLogs(); + }, + downloadFile: async (url: string, fileName: string, apiKey: string) => { + await App.DownloadFile(url, fileName, apiKey); + }, + rcon: { + execute: async (command: string) => { + const state = store.getState(); + const rconCredentials = rconCredentialsSelector(state); + + const result = await RconClient.Execute( + rconCredentials.host, + rconCredentials.password, + command + ); + + return result.trim(); + }, + getInfo: async (): Promise => { + try { + const result = ( + (await DesktopAPI.rcon.execute(RconCommand.INFO)) || '' + ).trim(); + + const regex = /Welcome to Pal Server\[(.*?)\]\s*(.*)/; + const match = result.match(regex); + const [, version, name] = match || []; + + return { + version, + name + }; + } catch { + // + } + + return undefined; + }, + getPlayers: async (): Promise => { + try { + const result = ( + (await DesktopAPI.rcon.execute(RconCommand.SHOW_PLAYERS)) || '' + ).trim(); + + const lines = result.split('\n'); + + lines.shift(); // remove the first line which is the header + + const players = lines.map((line) => { + const [name, uid, steamId] = line.split(',').map((s) => s.trim()); + const player: TRconPlayer = { + name, + uid, + steamId + }; + + DesktopAPI.getProfileImageURL(steamId); // crawl steam profile image and cache the url in the store so we don't have to do it again for the same player + + return player; + }); + + return players; + } catch { + // + } + + return []; + }, + save: async () => { + await DesktopAPI.rcon.execute(RconCommand.SAVE); + }, + shutdown: async ( + message: string = 'Server is being shutdown', + seconds?: number + ) => { + const command = `${RconCommand.SHUTDOWN} ${seconds ?? 1} ${message}`; + + await DesktopAPI.rcon.execute(command); + }, + sendMessage: async (messages: string) => { + const command = `${RconCommand.BROADCAST} ${messages}`; + + await DesktopAPI.rcon.execute(command); + }, + ban: async (uid: string) => { + const command = `${RconCommand.BAN} ${uid}`; + + await DesktopAPI.rcon.execute(command); + }, + kick: async (uid: string) => { + const command = `${RconCommand.KICK} ${uid}`; + + await DesktopAPI.rcon.execute(command); + } + } +}; diff --git a/frontend/src/helpers/bytes-to-mb.ts b/app/frontend/src/helpers/bytes-to-mb.ts similarity index 100% rename from frontend/src/helpers/bytes-to-mb.ts rename to app/frontend/src/helpers/bytes-to-mb.ts diff --git a/frontend/src/helpers/config-parser.ts b/app/frontend/src/helpers/config-parser.ts similarity index 100% rename from frontend/src/helpers/config-parser.ts rename to app/frontend/src/helpers/config-parser.ts diff --git a/frontend/src/helpers/sleep.ts b/app/frontend/src/helpers/sleep.ts similarity index 100% rename from frontend/src/helpers/sleep.ts rename to app/frontend/src/helpers/sleep.ts diff --git a/app/frontend/src/hooks/use-backup-settings.ts b/app/frontend/src/hooks/use-backup-settings.ts new file mode 100644 index 0000000..ee40972 --- /dev/null +++ b/app/frontend/src/hooks/use-backup-settings.ts @@ -0,0 +1,6 @@ +import { useSelector } from 'react-redux'; +import { backupSetingsSelector } from '../selectors/server'; + +export const useBackupSettings = () => useSelector(backupSetingsSelector); + +export default useBackupSettings; diff --git a/app/frontend/src/hooks/use-backups-list.ts b/app/frontend/src/hooks/use-backups-list.ts new file mode 100644 index 0000000..ffb5d3b --- /dev/null +++ b/app/frontend/src/hooks/use-backups-list.ts @@ -0,0 +1,6 @@ +import { useSelector } from 'react-redux'; +import { serverBackupsListSelector } from '../selectors/server'; + +const useBackupsList = () => useSelector(serverBackupsListSelector); + +export default useBackupsList; diff --git a/app/frontend/src/hooks/use-console-entries.ts b/app/frontend/src/hooks/use-console-entries.ts new file mode 100644 index 0000000..785cc52 --- /dev/null +++ b/app/frontend/src/hooks/use-console-entries.ts @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; +import { consolesEntriesSelector } from '../selectors/console'; + +const useConsoleEntries = () => + useSelector((state: any) => consolesEntriesSelector(state)); + +export default useConsoleEntries; diff --git a/app/frontend/src/hooks/use-events-init.ts b/app/frontend/src/hooks/use-events-init.ts new file mode 100644 index 0000000..5b82122 --- /dev/null +++ b/app/frontend/src/hooks/use-events-init.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; +import { DesktopAPI } from '../desktop'; +import { AppEvent, TGenericFunction } from '../types'; +import { addSteamImage, checkForUpdates } from '../actions/app'; + +const CHECK_FOR_UPDATES_INTERVAL = 1000 * 60 * 60 * 24; // 1 day + +const useEventsInit = () => { + const hasInit = useRef(false); + + useEffect(() => { + if (!hasInit.current) { + DesktopAPI.initApp(); + checkForUpdates(); + + setInterval(() => { + checkForUpdates(); + }, CHECK_FOR_UPDATES_INTERVAL); + + hasInit.current = true; + } + }, []); + + useEffect(() => { + const unsubscribes: TGenericFunction[] = []; + + DesktopAPI.onAppEvent( + AppEvent.RETURN_STEAM_IMAGE, + (resultString: string) => { + const [steamId, imageUrl] = resultString.split('|'); + + addSteamImage(steamId, imageUrl); + }, + unsubscribes + ); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }, []); +}; + +export default useEventsInit; diff --git a/frontend/src/hooks/use-latest-version.ts b/app/frontend/src/hooks/use-latest-version.ts similarity index 100% rename from frontend/src/hooks/use-latest-version.ts rename to app/frontend/src/hooks/use-latest-version.ts diff --git a/frontend/src/hooks/use-launch-params.ts b/app/frontend/src/hooks/use-launch-params.ts similarity index 100% rename from frontend/src/hooks/use-launch-params.ts rename to app/frontend/src/hooks/use-launch-params.ts diff --git a/frontend/src/hooks/use-modals-info.ts b/app/frontend/src/hooks/use-modals-info.ts similarity index 100% rename from frontend/src/hooks/use-modals-info.ts rename to app/frontend/src/hooks/use-modals-info.ts diff --git a/frontend/src/hooks/use-rcon-credentials.ts b/app/frontend/src/hooks/use-rcon-credentials.ts similarity index 100% rename from frontend/src/hooks/use-rcon-credentials.ts rename to app/frontend/src/hooks/use-rcon-credentials.ts diff --git a/frontend/src/hooks/use-selected-theme.ts b/app/frontend/src/hooks/use-selected-theme.ts similarity index 100% rename from frontend/src/hooks/use-selected-theme.ts rename to app/frontend/src/hooks/use-selected-theme.ts diff --git a/frontend/src/hooks/use-server-config.ts b/app/frontend/src/hooks/use-server-config.ts similarity index 100% rename from frontend/src/hooks/use-server-config.ts rename to app/frontend/src/hooks/use-server-config.ts diff --git a/app/frontend/src/hooks/use-server-credentials.ts b/app/frontend/src/hooks/use-server-credentials.ts new file mode 100644 index 0000000..7f37078 --- /dev/null +++ b/app/frontend/src/hooks/use-server-credentials.ts @@ -0,0 +1,6 @@ +import { useSelector } from 'react-redux'; +import { serverCredentialsSelector } from '../selectors/app'; + +const useServerCredentials = () => useSelector(serverCredentialsSelector); + +export default useServerCredentials; diff --git a/frontend/src/hooks/use-server-save-name.ts.ts b/app/frontend/src/hooks/use-server-save-name.ts.ts similarity index 100% rename from frontend/src/hooks/use-server-save-name.ts.ts rename to app/frontend/src/hooks/use-server-save-name.ts.ts diff --git a/frontend/src/hooks/use-server-status.ts b/app/frontend/src/hooks/use-server-status.ts similarity index 100% rename from frontend/src/hooks/use-server-status.ts rename to app/frontend/src/hooks/use-server-status.ts diff --git a/frontend/src/hooks/use-settings.ts b/app/frontend/src/hooks/use-settings.ts similarity index 100% rename from frontend/src/hooks/use-settings.ts rename to app/frontend/src/hooks/use-settings.ts diff --git a/app/frontend/src/hooks/use-socket.ts b/app/frontend/src/hooks/use-socket.ts new file mode 100644 index 0000000..615d986 --- /dev/null +++ b/app/frontend/src/hooks/use-socket.ts @@ -0,0 +1,12 @@ +import { useSelector } from 'react-redux'; +import { socketStateSelector } from '../selectors/socket'; +import useWebSocketContext from './use-websocket-context'; + +const useSocket = () => { + const { connect, disconnect } = useWebSocketContext(); + const socketData = useSelector(socketStateSelector); + + return { connect, disconnect, ...socketData }; +}; + +export default useSocket; diff --git a/frontend/src/hooks/use-steam-images.ts b/app/frontend/src/hooks/use-steam-images.ts similarity index 100% rename from frontend/src/hooks/use-steam-images.ts rename to app/frontend/src/hooks/use-steam-images.ts diff --git a/frontend/src/hooks/use-theme.ts b/app/frontend/src/hooks/use-theme.ts similarity index 100% rename from frontend/src/hooks/use-theme.ts rename to app/frontend/src/hooks/use-theme.ts diff --git a/app/frontend/src/hooks/use-websocket-context.ts b/app/frontend/src/hooks/use-websocket-context.ts new file mode 100644 index 0000000..0f05381 --- /dev/null +++ b/app/frontend/src/hooks/use-websocket-context.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { WebSocketContext } from '../components/socket-provider'; + +const useWebSocketContext = () => { + return useContext(WebSocketContext); +}; + +export default useWebSocketContext; diff --git a/frontend/src/main.css b/app/frontend/src/main.css similarity index 96% rename from frontend/src/main.css rename to app/frontend/src/main.css index 84dea19..1527fe0 100644 --- a/frontend/src/main.css +++ b/app/frontend/src/main.css @@ -16,7 +16,8 @@ body, height: 100%; } -p { +p, +span { cursor: default; user-select: none; } diff --git a/app/frontend/src/main.tsx b/app/frontend/src/main.tsx new file mode 100644 index 0000000..8201d8e --- /dev/null +++ b/app/frontend/src/main.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './components/app'; +import { store } from './store'; +import { Provider } from 'react-redux'; +import { NextUIProvider } from '@nextui-org/react'; +import './main.css'; +import 'react-toastify/dist/ReactToastify.css'; +import ModalsProvider from './components/modals'; +import { BrowserRouter } from 'react-router-dom'; +import SocketProvider from './components/socket-provider'; +import CustomErrorBoundary from './components/custom-error-boundary'; +import CustomToastContainer from './components/custom-toast-container'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + + + +); diff --git a/frontend/src/screens/about/index.tsx b/app/frontend/src/screens/about/index.tsx similarity index 91% rename from frontend/src/screens/about/index.tsx rename to app/frontend/src/screens/about/index.tsx index b5de527..83dab18 100644 --- a/frontend/src/screens/about/index.tsx +++ b/app/frontend/src/screens/about/index.tsx @@ -1,6 +1,6 @@ import { Button } from '@nextui-org/react'; import Layout from '../../components/layout'; -import { DesktopApi } from '../../desktop'; +import { DesktopAPI } from '../../desktop'; import useHasUpdates from '../../hooks/use-latest-version'; import { checkForUpdates } from '../../actions/app'; import { useState } from 'react'; @@ -28,7 +28,7 @@ const About = () => { - DesktopApi.openUrl( + DesktopAPI.openUrl( 'https://github.com/diogomartino/palworld-ds-gui/releases/latest' ) } @@ -59,7 +59,7 @@ const About = () => { - DesktopApi.openUrl( + DesktopAPI.openUrl( 'https://github.com/diogomartino/palworld-ds-gui' ) } @@ -77,7 +77,7 @@ const About = () => { - DesktopApi.openUrl('https://opensource.org/licenses/MIT') + DesktopAPI.openUrl('https://opensource.org/licenses/MIT') } > MIT License @@ -88,7 +88,7 @@ const About = () => { Created by{' '} DesktopApi.openUrl('https://github.com/diogomartino')} + onClick={() => DesktopAPI.openUrl('https://github.com/diogomartino')} > Diogo Martino diff --git a/frontend/src/screens/admin/index.tsx b/app/frontend/src/screens/admin/index.tsx similarity index 93% rename from frontend/src/screens/admin/index.tsx rename to app/frontend/src/screens/admin/index.tsx index 31e1210..bb596be 100644 --- a/frontend/src/screens/admin/index.tsx +++ b/app/frontend/src/screens/admin/index.tsx @@ -16,7 +16,7 @@ import { getKeyValue } from '@nextui-org/react'; import Layout from '../../components/layout'; -import { DesktopApi } from '../../desktop'; +import { DesktopAPI } from '../../desktop'; import { useEffect, useState } from 'react'; import useSteamImages from '../../hooks/use-steam-images'; import { @@ -35,12 +35,7 @@ import { TRconInfo, TRconPlayer } from '../../types/rcon'; import useServerConfig from '../../hooks/use-server-config'; import { ConfigKey } from '../../types/server-config'; import useRconCredentials from '../../hooks/use-rcon-credentials'; -import { setRconCredentials } from '../../actions/app'; - -/* -name,playeruid,steamid -"bruxo","AAAAAAAA","76561198032964582" -*/ +import { notifySuccess, setRconCredentials } from '../../actions/app'; const columns = [ { @@ -80,7 +75,7 @@ const AdminActions = ({ player }: TAdminActionsProps) => { confirmLabel: 'Ban', variant: 'danger', onConfirm: async () => { - await DesktopApi.rcon.ban(player.uid); + await DesktopAPI.rcon.ban(player.uid); } }); }; @@ -92,7 +87,7 @@ const AdminActions = ({ player }: TAdminActionsProps) => { confirmLabel: 'Kick', variant: 'danger', onConfirm: async () => { - await DesktopApi.rcon.kick(player.uid); + await DesktopAPI.rcon.kick(player.uid); } }); }; @@ -142,7 +137,7 @@ const Admin = () => { const loadInfo = async () => { setIsOnline(false); - const result = await DesktopApi.rcon.getInfo(); + const result = await DesktopAPI.rcon.getInfo(); if (result?.name) { setIsOnline(true); @@ -157,7 +152,7 @@ const Admin = () => { const loadPlayers = async () => { setLoading(true); - const players = await DesktopApi.rcon.getPlayers(); + const players = await DesktopAPI.rcon.getPlayers(); const processedPlayers = players.map((player) => ({ ...player, key: player.uid @@ -168,21 +163,21 @@ const Admin = () => { }; const onSendMessageClick = async () => { - await DesktopApi.rcon.sendMessage(message); - + await DesktopAPI.rcon.sendMessage(message); setMessage(''); + notifySuccess('Message sent'); }; const onSaveClick = async () => { setIsSaving(true); - - await DesktopApi.rcon.save(); - + await DesktopAPI.rcon.save(); setIsSaving(false); + notifySuccess('Save command executed'); }; - const onRefreshClick = () => { - loadInfo(); + const onRefreshClick = async () => { + await loadInfo(); + notifySuccess('Data refreshed'); }; const onConnectClick = async () => { @@ -336,7 +331,7 @@ const Admin = () => { - DesktopApi.openUrl( + DesktopAPI.openUrl( `https://steamcommunity.com/profiles/${item.steamId}` ) } diff --git a/app/frontend/src/screens/app-settings/index.tsx b/app/frontend/src/screens/app-settings/index.tsx new file mode 100644 index 0000000..4a71937 --- /dev/null +++ b/app/frontend/src/screens/app-settings/index.tsx @@ -0,0 +1,69 @@ +import { Button } from '@nextui-org/react'; +import Layout from '../../components/layout'; +import { notifySuccess, toggleTheme } from '../../actions/app'; +import useSelectedTheme from '../../hooks/use-selected-theme'; +import { + IconClearAll, + IconMoon, + IconSun, + IconWriting +} from '@tabler/icons-react'; +import { DesktopAPI } from '../../desktop'; +import { requestConfirmation } from '../../actions/modal'; + +const AppSettings = () => { + const theme = useSelectedTheme(); + + const onClearCacheClick = async () => { + requestConfirmation({ + title: 'Clear Data', + message: + 'Are you sure you want to clear your data? All your app settings will be lost. The data of your dedicated server will NOT be affected. This can help to solve some issues. You will need to restart the app after clearing the data.', + onConfirm: () => { + localStorage.clear(); + notifySuccess('Data cleared. Please restart the app.'); + } + }); + }; + + return ( + +
+ + + + + +
+
+ ); +}; + +export default AppSettings; diff --git a/frontend/src/screens/backups/index.tsx b/app/frontend/src/screens/backups/index.tsx similarity index 61% rename from frontend/src/screens/backups/index.tsx rename to app/frontend/src/screens/backups/index.tsx index e0674c9..f11bf4e 100644 --- a/frontend/src/screens/backups/index.tsx +++ b/app/frontend/src/screens/backups/index.tsx @@ -16,20 +16,23 @@ import { getKeyValue } from '@nextui-org/react'; import Layout from '../../components/layout'; -import { DesktopApi } from '../../desktop'; import { useEffect, useMemo, useState } from 'react'; -import { GetBackupsList } from '../../wailsjs/go/backupsmanager/BackupManager'; import bytesToMb from '../../helpers/bytes-to-mb'; import { + IconArchive, + IconDeviceFloppy, IconDotsVertical, - IconFolderOpen, IconRestore, IconTrash } from '@tabler/icons-react'; import { requestConfirmation } from '../../actions/modal'; import { TGenericObject } from '../../types'; -import useSettings from '../../hooks/use-settings'; -import { changeBackupSettings } from '../../actions/app'; +import { changeBackupSettings, notifySuccess } from '../../actions/app'; +import { ServerAPI } from '../../server'; +import useBackupsList from '../../hooks/use-backups-list'; +import useBackupSettings from '../../hooks/use-backup-settings'; +import useServerCredentials from '../../hooks/use-server-credentials'; +import { DesktopAPI } from '../../desktop'; const columns = [ { @@ -54,10 +57,11 @@ const columns = [ type TBackupActionsProps = { backup: TGenericObject; - loadList: () => void; }; -const BackupActions = ({ backup, loadList }: TBackupActionsProps) => { +const BackupActions = ({ backup }: TBackupActionsProps) => { + const serverCredentials = useServerCredentials(); + const onRestoreClick = async () => { await requestConfirmation({ title: 'Confirmation', @@ -65,7 +69,7 @@ const BackupActions = ({ backup, loadList }: TBackupActionsProps) => { 'Are you sure you want to restore this backup? This action is irreversible. Make sure you have a backup of your current save.', confirmLabel: 'Restore', onConfirm: async () => { - await DesktopApi.backups.restore(backup.originalName); + await ServerAPI.backups.restore(backup.originalName); } }); }; @@ -78,12 +82,21 @@ const BackupActions = ({ backup, loadList }: TBackupActionsProps) => { confirmLabel: 'Delete', variant: 'danger', onConfirm: async () => { - await DesktopApi.backups.delete(backup.originalName); - await loadList(); + await ServerAPI.backups.delete(backup.originalName); } }); }; + const onDownloadClick = () => { + DesktopAPI.downloadFile( + `http://${serverCredentials.host}/backups/${backup.originalName}`, + backup.originalName, + serverCredentials.apiKey + ); + + notifySuccess('Backup download started.'); + }; + return ( @@ -93,13 +106,11 @@ const BackupActions = ({ backup, loadList }: TBackupActionsProps) => { } - onClick={() => { - DesktopApi.backups.open(backup.originalName); - }} + key="download" + endContent={} + onClick={onDownloadClick} > - Show in Explorer + Download { }; const Backups = () => { - const { backup: currentBackupSettings } = useSettings(); - const [backups, setBackups] = useState([]); - const [loading, setLoading] = useState(true); + const currentBackupSettings = useBackupSettings(); + const backups = useBackupsList(); const [isCreating, setIsCreating] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [settings, setSettings] = useState({ enabled: currentBackupSettings.enabled, intervalHours: currentBackupSettings.intervalHours, keepCount: currentBackupSettings.keepCount }); + const [errors, setErrors] = useState({}); const rows = useMemo(() => { return backups.map((backup) => ({ - key: backup.Filename, - originalName: backup.Filename, - save: `${backup.SaveName.substring(0, 12)}...${backup.SaveName.substring( - backup.SaveName.length - 12 + key: backup.fileName, + originalName: backup.fileName, + save: `${backup.saveName.substring(0, 12)}...${backup.saveName.substring( + backup.saveName.length - 12 )}`, - date: new Date(backup.Timestamp * 1000).toLocaleString(), - size: `${bytesToMb(backup.Size)} MB` + date: new Date(backup.timestamp * 1000).toLocaleString(), + size: `${bytesToMb(backup.size)} MB` })); }, [backups]); const onCreateBackupClick = async () => { setIsCreating(true); - await DesktopApi.backups.create(); - await loadList(); + await ServerAPI.backups.create(); setIsCreating(false); }; - const loadList = async () => { - setLoading(true); - setBackups(await GetBackupsList()); - setLoading(false); + const onSaveSettingsClick = async () => { + const newErrors: TGenericObject = {}; + + if ( + isNaN(settings.intervalHours) || + settings.intervalHours < 1 || + settings.intervalHours > 24 + ) { + newErrors.intervalHours = true; + } + + if ( + isNaN(settings.keepCount) || + settings.keepCount < 1 || + settings.keepCount > 100 + ) { + newErrors.keepCount = true; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setIsSaving(true); + await changeBackupSettings( + settings.enabled, + settings.intervalHours, + settings.keepCount + ); + setIsSaving(false); }; const onSettingsChange = (key: string, value: any) => { const newState = { ...settings, [key]: value }; - if (key === 'intervalHours') { - if (value < 1) newState.intervalHours = 1; - if (value > 24) newState.intervalHours = 24; - } else if (key === 'keepCount') { - if (value < 1) newState.keepCount = 1; - if (value > 20) newState.keepCount = 20; - } - + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[key]; + return newErrors; + }); setSettings(newState); - changeBackupSettings( - newState.enabled, - newState.intervalHours, - newState.keepCount - ); }; useEffect(() => { - loadList(); - }, []); + setSettings({ + enabled: currentBackupSettings.enabled, + intervalHours: currentBackupSettings.intervalHours, + keepCount: currentBackupSettings.keepCount + }); + }, [currentBackupSettings]); return ( } + > + Create new backup now + + } >
{ {
onSettingsChange('enabled', Boolean(!settings.enabled)) } @@ -225,10 +274,11 @@ const Backups = () => {
@@ -239,7 +289,6 @@ const Backups = () => { } className="overflow-y-scroll" > @@ -249,7 +298,7 @@ const Backups = () => { if (columnKey === 'actions') { return ( - + ); } diff --git a/frontend/src/screens/home/index.tsx b/app/frontend/src/screens/home/index.tsx similarity index 75% rename from frontend/src/screens/home/index.tsx rename to app/frontend/src/screens/home/index.tsx index 0b8426e..aeeee81 100644 --- a/frontend/src/screens/home/index.tsx +++ b/app/frontend/src/screens/home/index.tsx @@ -1,27 +1,24 @@ -import { Button, Input } from '@nextui-org/react'; +import { Button, Input, Tooltip } from '@nextui-org/react'; import Layout from '../../components/layout'; import useServerConfig from '../../hooks/use-server-config'; import { IconCloudDownload, + IconDeviceFloppy, IconPlayerPlay, IconPlayerStop } from '@tabler/icons-react'; import { ConfigKey } from '../../types/server-config'; import TerminalOutput from '../../components/terminal-output'; import useServerStatus from '../../hooks/use-server-status'; -import { ConsoleId, ServerStatus } from '../../types'; -import { - restartServer, - startServer, - stopServer, - updateServer -} from '../../actions/server'; -import useConsolesById from '../../hooks/use-consoles-by-id'; +import { ServerStatus } from '../../types'; +import useConsolesById from '../../hooks/use-console-entries'; import { IconRefresh } from '@tabler/icons-react'; import { requestConfirmation } from '../../actions/modal'; -import { DesktopApi } from '../../desktop'; +import { DesktopAPI } from '../../desktop'; import useLaunchParams from '../../hooks/use-launch-params'; import { setLaunchParams } from '../../actions/app'; +import { ServerAPI } from '../../server'; +import { useState } from 'react'; const statusDict = { [ServerStatus.STARTED]: 'Server is running', @@ -35,8 +32,9 @@ const statusDict = { const Home = () => { const currentConfig = useServerConfig(); const status = useServerStatus(); - const consoleEntries = useConsolesById([ConsoleId.SERVER]); + const consoleEntries = useConsolesById(); const launchParams = useLaunchParams(); + const [saving, setSaving] = useState(false); const startDisabled = status !== ServerStatus.STOPPED; const stopDisabled = status !== ServerStatus.STARTED; @@ -47,6 +45,12 @@ const Home = () => { setLaunchParams(e.target.value || ''); }; + const onSaveLaunchParamsClick = async () => { + setSaving(true); + await ServerAPI.saveLaunchParams(launchParams || ''); + setSaving(false); + }; + return ( {
- +
+ + + + +
diff --git a/app/frontend/src/screens/initializing/index.tsx b/app/frontend/src/screens/initializing/index.tsx new file mode 100644 index 0000000..a97a404 --- /dev/null +++ b/app/frontend/src/screens/initializing/index.tsx @@ -0,0 +1,112 @@ +import { Button, Image, Input, Tooltip } from '@nextui-org/react'; +import palworldLogo from '../../assets/palworld-logo.webp'; +import useSocket from '../../hooks/use-socket'; +import { IconInfoCircle } from '@tabler/icons-react'; +import { useState } from 'react'; +import { setServerCredentials } from '../../actions/app'; +import useServerCredentials from '../../hooks/use-server-credentials'; +import { DesktopAPI } from '../../desktop'; + +const Initializing = () => { + const serverCredentials = useServerCredentials(); + const { connecting, connect, error } = useSocket(); + const [host, setHost] = useState(serverCredentials.host); + const [apiKey, setApiKey] = useState(serverCredentials.apiKey); + + const onConnectClick = () => { + setServerCredentials(host, apiKey); + connect(host, apiKey); + }; + + return ( +
+
+ Palworld Logo +

Dedicated Server GUI

+

v{APP_VERSION}

+ +
+
+ + + + +
+ } + value={host} + onChange={(event) => setHost(event.target.value)} + /> + + + + +
+ } + value={apiKey} + onChange={(event) => setApiKey(event.target.value)} + /> +
+ + {error && ( +

+ {error === true + ? 'Could not connect. Make sure the GUI server is running and the address and API key are correct.' + : error} +

+ )} + + + +
+

+ If you need help, please create an issue{' '} + + DesktopAPI.openUrl( + 'https://github.com/diogomartino/palworld-ds-gui/issues' + ) + } + > + here. + +

+
+
+ + + ); +}; + +export default Initializing; diff --git a/frontend/src/screens/server-settings/index.tsx b/app/frontend/src/screens/server-settings/index.tsx similarity index 83% rename from frontend/src/screens/server-settings/index.tsx rename to app/frontend/src/screens/server-settings/index.tsx index cd18eee..5647ae4 100644 --- a/frontend/src/screens/server-settings/index.tsx +++ b/app/frontend/src/screens/server-settings/index.tsx @@ -2,10 +2,11 @@ import { Button, Divider, Input, Switch } from '@nextui-org/react'; import Layout from '../../components/layout'; import useServerConfig from '../../hooks/use-server-config'; import { configLabels, configTypes } from '../../types/server-config'; -import { useState } from 'react'; -import { DesktopApi } from '../../desktop'; +import { useEffect, useState } from 'react'; import useServerSaveName from '../../hooks/use-server-save-name.ts'; import { TGenericObject } from '../../types/index.ts'; +import { ServerAPI } from '../../server.ts'; +import { notifySuccess } from '../../actions/app.ts'; const InputProvider = ({ label, @@ -21,7 +22,12 @@ const InputProvider = ({ if (type === 'boolean') { return ( - + {label} ); @@ -57,15 +63,22 @@ const ServerSettings = () => { if (!validate()) return; setIsSaving(true); - await DesktopApi.server.writeConfig(config); + await ServerAPI.writeConfig(config); if (saveName) { - await DesktopApi.server.writeSaveName(saveName); + await ServerAPI.writeSaveName(saveName); } setIsSaving(false); + notifySuccess('Server settings saved'); }; + useEffect(() => { + // trigger a re-render when the currentConfig or currentSaveName changes (eg: other client changed the config) + setConfig(currentConfig); + setSaveName(currentSaveName); + }, [currentConfig, currentSaveName]); + return ( { {config && Object.keys(config).map((key) => ( state.app.settings.theme; export const settingsSelector = (state: IRootState) => state.app.settings; -export const loadingStatusSelector = (state: IRootState) => - state.app.loadingStatus; - export const latestVersionSelector = (state: IRootState) => state.app.latestVersion; @@ -14,7 +11,10 @@ export const steamImagesCacheSelector = (state: IRootState) => state.app.steamImagesCache; export const launchParamsSelector = (state: IRootState) => - state.app.settings.launchParams; + state.app.launchParams; export const rconCredentialsSelector = (state: IRootState) => state.app.rconCredentials; + +export const serverCredentialsSelector = (state: IRootState) => + state.app.settings.serverCredentials; diff --git a/app/frontend/src/selectors/console.ts b/app/frontend/src/selectors/console.ts new file mode 100644 index 0000000..94f764d --- /dev/null +++ b/app/frontend/src/selectors/console.ts @@ -0,0 +1,4 @@ +import { IRootState } from '../store'; + +export const consolesEntriesSelector = (state: IRootState) => + state.consoles.entries; diff --git a/frontend/src/selectors/modals.ts b/app/frontend/src/selectors/modals.ts similarity index 100% rename from frontend/src/selectors/modals.ts rename to app/frontend/src/selectors/modals.ts diff --git a/frontend/src/selectors/server.ts b/app/frontend/src/selectors/server.ts similarity index 60% rename from frontend/src/selectors/server.ts rename to app/frontend/src/selectors/server.ts index f9d2000..e0cb335 100644 --- a/frontend/src/selectors/server.ts +++ b/app/frontend/src/selectors/server.ts @@ -6,3 +6,9 @@ export const serverStatusSelector = (state: IRootState) => state.server.status; export const serverSaveNameSelector = (state: IRootState) => state.server.saveName; + +export const serverBackupsListSelector = (state: IRootState) => + state.server.backupsList; + +export const backupSetingsSelector = (state: IRootState) => + state.server.backupSettings; diff --git a/app/frontend/src/selectors/socket.ts b/app/frontend/src/selectors/socket.ts new file mode 100644 index 0000000..dd38075 --- /dev/null +++ b/app/frontend/src/selectors/socket.ts @@ -0,0 +1,3 @@ +import { IRootState } from '../store'; + +export const socketStateSelector = (state: IRootState) => state.socket; diff --git a/app/frontend/src/server.ts b/app/frontend/src/server.ts new file mode 100644 index 0000000..32a5333 --- /dev/null +++ b/app/frontend/src/server.ts @@ -0,0 +1,191 @@ +import { SocketAction, TGenericObject } from './types'; +import { parseConfig, serializeConfig } from './helpers/config-parser'; +import { setConfig, setSaveName } from './actions/server'; +import { ConfigKey, TConfig } from './types/server-config'; +import { store } from './store'; +import { notifyError, notifySuccess, setRconCredentials } from './actions/app'; +import { socketStateSelector } from './selectors/socket'; +import { + onBackupListUpdated, + onBackupSettingsUpdated, + onClientInited +} from './actions/socket'; +import { DesktopAPI } from './desktop'; + +const TIMEOUT_MS = 10000; + +export const ServerAPI = { + send: async ( + event: SocketAction, + data?: TGenericObject, + waitForResponse = true + ): Promise => { + return new Promise((resolve, reject) => { + const state = store.getState(); + const { socket } = socketStateSelector(state); + let timeoutId: NodeJS.Timeout | undefined = undefined; + + try { + const eventId = Math.random().toString(36).substring(2); + + socket.send(JSON.stringify({ event, eventId, data })); + + if (!waitForResponse) { + resolve({}); + return; + } + + const onMessage = (event) => { + const response = JSON.parse(event.data); + + if (response.eventId === eventId) { + clearTimeout(timeoutId); + socket.removeEventListener('message', onMessage); + + delete response.eventId; + delete response.event; + + if (response.success) { + resolve(response); + } else { + DesktopAPI.logToFile( + `Socket responded with error: ${response.error} for event: ${event} and data: ${data}` + ); + reject(response.error ?? 'Unknown error'); + } + } + }; + + timeoutId = setTimeout(() => { + socket.removeEventListener('message', onMessage); + reject(new Error('Request timed out')); + }, TIMEOUT_MS); + + socket.addEventListener('message', onMessage); + } catch (error) { + clearTimeout(timeoutId); + DesktopAPI.logToFile('Unknown error while handling socket event'); + reject(error ?? 'Unknown error'); + } + }); + }, + fetchConfig: async () => { + const { data: configString } = await ServerAPI.send( + SocketAction.READ_CONFIG + ); + const config = parseConfig(configString); + + setConfig(config); + setRconCredentials( + `127.0.0.1:${config[ConfigKey.RCONPort]}`, + config[ConfigKey.AdminPassword] + ); + }, + writeConfig: async (config: TConfig) => { + try { + const serializedConfig = serializeConfig(config); + await ServerAPI.send(SocketAction.WRITE_CONFIG, { + config: serializedConfig + }); + + notifySuccess('Config saved'); + } catch { + notifyError('Could not save config'); + } + }, + fetchSaveName: async () => { + const { data: saveNameString } = await ServerAPI.send( + SocketAction.READ_SAVE_NAME + ); + + setSaveName(saveNameString); + }, + writeSaveName: async (saveName: string) => { + try { + await ServerAPI.send(SocketAction.WRITE_SAVE_NAME, { saveName }); + notifySuccess('Save name saved'); + } catch { + notifyError('Could not save save name'); + } + }, + start: async () => { + ServerAPI.send(SocketAction.START_SERVER); + }, + stop: () => { + ServerAPI.send(SocketAction.STOP_SERVER); + }, + restart: () => { + ServerAPI.send(SocketAction.RESTART_SERVER); + }, + update: () => { + ServerAPI.send(SocketAction.UPDATE_SERVER); + }, + init: async () => { + const { data } = await ServerAPI.send(SocketAction.INIT); + + onClientInited(data); + }, + saveLaunchParams: async (launchParams: string) => { + try { + await ServerAPI.send(SocketAction.SAVE_LAUNCH_PARAMS, { launchParams }); + notifySuccess('Launch params saved'); + } catch { + notifyError('Could not save launch params'); + } + }, + backups: { + start: async (interval: number, keepCount: number) => { + try { + await ServerAPI.send(SocketAction.START_BACKUPS, { + interval, + keepCount + }); + notifySuccess('Backups are now enabled'); + } catch (error) { + notifyError('Could not start backups'); + } + }, + stop: async () => { + try { + await ServerAPI.send(SocketAction.STOP_BACKUPS); + notifySuccess('Backups are now disabled'); + } catch { + notifyError('Could not stop backups'); + } + }, + fetchCurrentSettings: async () => { + const { data } = await ServerAPI.send(SocketAction.GET_BACKUPS_SETTINGS); + + onBackupSettingsUpdated(data); + }, + fetchList: async () => { + const { data } = await ServerAPI.send(SocketAction.GET_BACKUPS_LIST); + + onBackupListUpdated(data); + }, + delete: async (backupFileName: string) => { + try { + await ServerAPI.send(SocketAction.DELETE_BACKUP, { backupFileName }); + notifySuccess('Backup deleted'); + } catch { + notifyError('Could not delete backup'); + } + }, + create: async () => { + try { + await ServerAPI.send(SocketAction.CREATE_BACKUP); + notifySuccess('Backup created'); + } catch { + notifyError('Could not create backup'); + } + }, + restore: async (backupFileName: string) => { + try { + await ServerAPI.send(SocketAction.RESTORE_BACKUP, { backupFileName }); + notifySuccess('Backup restored'); + } catch { + notifyError('Could not restore backup'); + } + } + } +}; diff --git a/frontend/src/store/app-slice.ts b/app/frontend/src/store/app-slice.ts similarity index 66% rename from frontend/src/store/app-slice.ts rename to app/frontend/src/store/app-slice.ts index 6b64487..c7d3e6f 100644 --- a/frontend/src/store/app-slice.ts +++ b/app/frontend/src/store/app-slice.ts @@ -1,5 +1,6 @@ +import WebSocket from 'ws'; import { createSlice } from '@reduxjs/toolkit'; -import { LoadingStatus, TSettings, TSteamImageMap } from '../types'; +import { TSettings, TSteamImageMap } from '../types'; import { saveSettings } from '../actions/app'; const getStoredSettings = () => { @@ -7,19 +8,14 @@ const getStoredSettings = () => { return { theme: stored.theme ?? 'dark', - backup: { - enabled: stored.backup?.enabled ?? false, - intervalHours: stored.backup?.intervalHours ?? 1, - keepCount: stored.backup?.keepCount ?? 6 - }, - launchParams: - stored.launchParams ?? - '-useperfthreads -NoAsyncLoadingThread -UseMultithreadForDS' + serverCredentials: { + host: stored.serverCredentials?.host ?? '127.0.0.1:21577', + apiKey: stored.serverCredentials?.apiKey ?? '' + } } as TSettings; }; export interface IAppState { - loadingStatus: LoadingStatus; settings: TSettings; latestVersion: string; steamImagesCache: TSteamImageMap; @@ -27,37 +23,34 @@ export interface IAppState { host: string; password: string; }; + socket: WebSocket | undefined; + launchParams: string; } const initialState: IAppState = { - loadingStatus: LoadingStatus.IDLE, + socket: undefined, settings: getStoredSettings(), latestVersion: APP_VERSION, steamImagesCache: {}, rconCredentials: { host: '', password: '' - } + }, + launchParams: '' }; export const appSlice = createSlice({ name: 'modals', initialState, reducers: { - setLoadingStatus: (state, action) => { - state.loadingStatus = action.payload; - }, toggleTheme: (state) => { state.settings.theme = state.settings.theme === 'light' ? 'dark' : 'light'; saveSettings(state.settings); }, - setBackupSettings: (state, action) => { - state.settings.backup = { - ...state.settings.backup, - ...action.payload - }; + setServerCredentials: (state, action) => { + state.settings.serverCredentials = action.payload; saveSettings(state.settings); }, @@ -68,10 +61,13 @@ export const appSlice = createSlice({ state.steamImagesCache[action.payload.steamId] = action.payload.imageUrl; }, setLaunchParams: (state, action) => { - state.settings.launchParams = action.payload; + state.launchParams = action.payload; }, setRconCredentials: (state, action) => { state.rconCredentials = action.payload; + }, + setSocket: (state, action) => { + state.socket = action.payload; } } }); diff --git a/frontend/src/store/console-slice.ts b/app/frontend/src/store/console-slice.ts similarity index 54% rename from frontend/src/store/console-slice.ts rename to app/frontend/src/store/console-slice.ts index d16d8ac..6e06d56 100644 --- a/frontend/src/store/console-slice.ts +++ b/app/frontend/src/store/console-slice.ts @@ -2,24 +2,22 @@ import { createSlice } from '@reduxjs/toolkit'; import { TConsoleEntry } from '../types'; export interface IConsoleSlice { - [consoleId: string]: TConsoleEntry[]; + entries: TConsoleEntry[]; } -const initialState: IConsoleSlice = {}; +const initialState: IConsoleSlice = { + entries: [] +}; export const consoleSlice = createSlice({ name: 'consoles', initialState, reducers: { addConsoleEntry: (state, action) => { - if (!state[action.payload.consoleId]) { - state[action.payload.consoleId] = []; - } - - state[action.payload.consoleId].push(action.payload.entry); + state.entries.push(action.payload); }, - clearConsole: (state, action) => { - state[action.payload] = []; + clearConsole: (state) => { + state.entries = []; } } }); diff --git a/frontend/src/store/index.ts b/app/frontend/src/store/index.ts similarity index 50% rename from frontend/src/store/index.ts rename to app/frontend/src/store/index.ts index ec8936f..3da76e8 100644 --- a/frontend/src/store/index.ts +++ b/app/frontend/src/store/index.ts @@ -3,18 +3,35 @@ import modalsSlice from './modals-slice'; import appSlice from './app-slice'; import consoleSlice from './console-slice'; import serverSlice from './server-slice'; +import socketSlice from './socket-slice'; + +// Logger middleware (only in development) +const loggerMiddleware = (store) => (next) => (action) => { + const prevState = store.getState(); + const result = next(action); + const nextState = store.getState(); + + console.debug('[STORE DEBUG]', action.type, { + action, + prevState, + nextState + }); + + return result; +}; export const store = configureStore({ reducer: { app: appSlice, consoles: consoleSlice, modals: modalsSlice, - server: serverSlice + server: serverSlice, + socket: socketSlice }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false - }) + }).concat(process.env.NODE_ENV !== 'production' ? [loggerMiddleware] : []) }); export type IRootState = ReturnType; diff --git a/frontend/src/store/modals-slice.ts b/app/frontend/src/store/modals-slice.ts similarity index 100% rename from frontend/src/store/modals-slice.ts rename to app/frontend/src/store/modals-slice.ts diff --git a/frontend/src/store/server-slice.ts b/app/frontend/src/store/server-slice.ts similarity index 51% rename from frontend/src/store/server-slice.ts rename to app/frontend/src/store/server-slice.ts index 9d1d655..4c1e5f6 100644 --- a/frontend/src/store/server-slice.ts +++ b/app/frontend/src/store/server-slice.ts @@ -1,17 +1,27 @@ import { createSlice } from '@reduxjs/toolkit'; import { TConfig } from '../types/server-config'; -import { ServerStatus } from '../types'; +import { ServerStatus, TBackup, TBackupSettings } from '../types'; export interface IServerState { config: TConfig; // PalWorldSettings.ini saveName: string; // GameUserSettings.ini -> DedicatedServerName status: ServerStatus; + backupsList: TBackup[]; + backupSettings: TBackupSettings; + version: string | undefined; } const initialState: IServerState = { config: {} as TConfig, saveName: '', - status: ServerStatus.STOPPED + status: ServerStatus.STOPPED, + backupsList: [], + backupSettings: { + enabled: false, + intervalHours: 1, + keepCount: 24 + }, + version: undefined }; export const serverSlice = createSlice({ @@ -26,6 +36,20 @@ export const serverSlice = createSlice({ }, setSaveName: (state, action) => { state.saveName = action.payload; + }, + clearServerState: (state) => { + state.config = {} as TConfig; + state.saveName = ''; + state.status = ServerStatus.STOPPED; + }, + setBackupsList: (state, action) => { + state.backupsList = action.payload; + }, + setBackupSettings: (state, action) => { + state.backupSettings = action.payload; + }, + setVersion: (state, action) => { + state.version = action.payload; } } }); diff --git a/app/frontend/src/store/socket-slice.ts b/app/frontend/src/store/socket-slice.ts new file mode 100644 index 0000000..eec2835 --- /dev/null +++ b/app/frontend/src/store/socket-slice.ts @@ -0,0 +1,46 @@ +import WebSocket from 'ws'; +import { createSlice } from '@reduxjs/toolkit'; + +export interface ISocketState { + socket: WebSocket | undefined; + connecting: boolean; + inited: boolean; + error: string | undefined | boolean; +} + +const initialState: ISocketState = { + socket: undefined, + connecting: false, + inited: false, + error: undefined +}; + +export const socketSlice = createSlice({ + name: 'socket', + initialState, + reducers: { + setSocket: (state, action) => { + state.error = undefined; + state.socket = action.payload; + }, + setConnecting: (state, action) => { + state.connecting = action.payload; + }, + setError: (state, action) => { + state.error = action.payload; + }, + setInited: (state, action) => { + state.inited = action.payload; + }, + clear: (state) => { + state.socket?.close(); + state.socket = undefined; + state.connecting = false; + state.inited = false; + } + } +}); + +export const socketSliceActions = socketSlice.actions; + +export default socketSlice.reducer; diff --git a/app/frontend/src/types/index.ts b/app/frontend/src/types/index.ts new file mode 100644 index 0000000..be1a692 --- /dev/null +++ b/app/frontend/src/types/index.ts @@ -0,0 +1,110 @@ +export type TTheme = 'light' | 'dark'; + +export type TGenericObject = { + [key: string]: any; +}; + +export type TGenericFunction = (...args: any[]) => any; + +export enum Modal { + ACTION_CONFIRMATION = 'ACTION_CONFIRMATION', + VERSION_MISMATCH = 'VERSION_MISMATCH' +} + +export enum ServerStatus { + STARTED = 'STARTED', + STOPPED = 'STOPPED', + STARTING = 'STARTING', + STOPPING = 'STOPPING', + RESTARTING = 'RESTARTING', + UPDATING = 'UPDATING', + ERROR = 'ERROR' +} + +export enum AppEvent { + RETURN_STEAM_IMAGE = 'RETURN_STEAM_IMAGE' +} + +export enum SocketAction { + INIT = 'INIT', + START_SERVER = 'START_SERVER', + STOP_SERVER = 'STOP_SERVER', + RESTART_SERVER = 'RESTART_SERVER', + UPDATE_SERVER = 'UPDATE_SERVER', + READ_CONFIG = 'READ_CONFIG', + READ_SAVE_NAME = 'READ_SAVE_NAME', + WRITE_CONFIG = 'WRITE_CONFIG', + WRITE_SAVE_NAME = 'WRITE_SAVE_NAME', + START_BACKUPS = 'START_BACKUPS', + STOP_BACKUPS = 'STOP_BACKUPS', + GET_BACKUPS_LIST = 'GET_BACKUPS_LIST', + DELETE_BACKUP = 'DELETE_BACKUP', + CREATE_BACKUP = 'CREATE_BACKUP', + OPEN_BACKUP = 'OPEN_BACKUP', + RESTORE_BACKUP = 'RESTORE_BACKUP', + DOWNLOAD_BACKUP = 'DOWNLOAD_BACKUP', + GET_BACKUPS_SETTINGS = 'GET_BACKUPS_SETTINGS', + SAVE_LAUNCH_PARAMS = 'SAVE_LAUNCH_PARAMS' +} + +export enum SocketEvent { + SERVER_STATUS_CHANGED = 'SERVER_STATUS_CHANGED', + SERVER_CONFIG_CHANGED = 'SERVER_CONFIG_CHANGED', + SERVER_SAVE_NAME_CHANGED = 'SERVER_SAVE_NAME_CHANGED', + BACKUP_LIST_CHANGED = 'BACKUP_LIST_CHANGED', + BACKUP_SETTINGS_CHANGED = 'BACKUP_SETTINGS_CHANGED', + LAUNCH_PARAMS_CHANGED = 'LAUNCH_PARAMS_CHANGED', + CUSTOM_ERROR = 'CUSTOM_ERROR', + ADD_CONSOLE_ENTRY = 'ADD_CONSOLE_ENTRY' +} + +export type TConsoleEntry = { + timestamp: number; + message: string; + msgType: 'stdout' | 'stderr'; +}; + +export type TBackupSettings = { + enabled: boolean; + intervalHours: number; + keepCount: number; +}; + +export type TServerCredentials = { + host: string; + apiKey: string; +}; + +export type TClientInitedData = { + currentServerStatus: ServerStatus; + currentLaunchParams: string; + currentConfig: string; + currentSaveName: string; + currentBackupsSettings: TBackupSettings; + currentBackupsList: TBackup[]; + serverVersion: string; +}; + +export type TSettings = { + theme: 'light' | 'dark'; + serverCredentials: TServerCredentials; +}; + +export type TSteamImageMap = { + [steamId64: string]: string; +}; + +export type TSocketMessage = { + event: SocketEvent; + eventId: string; + data: any; + success: boolean; + error: string; +}; + +export type TBackup = { + fileName: string; + saveName: string; + size: number; + timestamp: number; +}; diff --git a/frontend/src/types/rcon.ts b/app/frontend/src/types/rcon.ts similarity index 100% rename from frontend/src/types/rcon.ts rename to app/frontend/src/types/rcon.ts diff --git a/frontend/src/types/server-config.ts b/app/frontend/src/types/server-config.ts similarity index 100% rename from frontend/src/types/server-config.ts rename to app/frontend/src/types/server-config.ts diff --git a/frontend/src/vite-env.d.ts b/app/frontend/src/vite-env.d.ts similarity index 100% rename from frontend/src/vite-env.d.ts rename to app/frontend/src/vite-env.d.ts diff --git a/frontend/src/wailsjs/go/main/App.d.ts b/app/frontend/src/wailsjs/go/main/App.d.ts similarity index 60% rename from frontend/src/wailsjs/go/main/App.d.ts rename to app/frontend/src/wailsjs/go/main/App.d.ts index c11485b..25ff198 100644 --- a/frontend/src/wailsjs/go/main/App.d.ts +++ b/app/frontend/src/wailsjs/go/main/App.d.ts @@ -1,8 +1,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function DownloadFile(arg1:string,arg2:string,arg3:string):Promise; + export function GetSteamProfileURL(arg1:string):Promise; export function InitApp():Promise; export function OpenInBrowser(arg1:string):Promise; + +export function OpenLogs():Promise; + +export function SaveLog(arg1:string):Promise; diff --git a/frontend/src/wailsjs/go/main/App.js b/app/frontend/src/wailsjs/go/main/App.js similarity index 58% rename from frontend/src/wailsjs/go/main/App.js rename to app/frontend/src/wailsjs/go/main/App.js index 97ea353..641d9d1 100644 --- a/frontend/src/wailsjs/go/main/App.js +++ b/app/frontend/src/wailsjs/go/main/App.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function DownloadFile(arg1, arg2, arg3) { + return window['go']['main']['App']['DownloadFile'](arg1, arg2, arg3); +} + export function GetSteamProfileURL(arg1) { return window['go']['main']['App']['GetSteamProfileURL'](arg1); } @@ -13,3 +17,11 @@ export function InitApp() { export function OpenInBrowser(arg1) { return window['go']['main']['App']['OpenInBrowser'](arg1); } + +export function OpenLogs() { + return window['go']['main']['App']['OpenLogs'](); +} + +export function SaveLog(arg1) { + return window['go']['main']['App']['SaveLog'](arg1); +} diff --git a/frontend/src/wailsjs/go/rconclient/RconClient.d.ts b/app/frontend/src/wailsjs/go/rconclient/RconClient.d.ts similarity index 100% rename from frontend/src/wailsjs/go/rconclient/RconClient.d.ts rename to app/frontend/src/wailsjs/go/rconclient/RconClient.d.ts diff --git a/frontend/src/wailsjs/go/rconclient/RconClient.js b/app/frontend/src/wailsjs/go/rconclient/RconClient.js similarity index 100% rename from frontend/src/wailsjs/go/rconclient/RconClient.js rename to app/frontend/src/wailsjs/go/rconclient/RconClient.js diff --git a/frontend/src/wailsjs/runtime/package.json b/app/frontend/src/wailsjs/runtime/package.json similarity index 100% rename from frontend/src/wailsjs/runtime/package.json rename to app/frontend/src/wailsjs/runtime/package.json diff --git a/frontend/src/wailsjs/runtime/runtime.d.ts b/app/frontend/src/wailsjs/runtime/runtime.d.ts similarity index 100% rename from frontend/src/wailsjs/runtime/runtime.d.ts rename to app/frontend/src/wailsjs/runtime/runtime.d.ts diff --git a/frontend/src/wailsjs/runtime/runtime.js b/app/frontend/src/wailsjs/runtime/runtime.js similarity index 100% rename from frontend/src/wailsjs/runtime/runtime.js rename to app/frontend/src/wailsjs/runtime/runtime.js diff --git a/frontend/tailwind.config.js b/app/frontend/tailwind.config.js similarity index 100% rename from frontend/tailwind.config.js rename to app/frontend/tailwind.config.js diff --git a/frontend/tsconfig.json b/app/frontend/tsconfig.json similarity index 100% rename from frontend/tsconfig.json rename to app/frontend/tsconfig.json diff --git a/frontend/tsconfig.node.json b/app/frontend/tsconfig.node.json similarity index 100% rename from frontend/tsconfig.node.json rename to app/frontend/tsconfig.node.json diff --git a/frontend/vite.config.ts b/app/frontend/vite.config.ts similarity index 100% rename from frontend/vite.config.ts rename to app/frontend/vite.config.ts diff --git a/go.mod b/app/go.mod similarity index 80% rename from go.mod rename to app/go.mod index c84334d..97bfccf 100644 --- a/go.mod +++ b/app/go.mod @@ -7,33 +7,25 @@ toolchain go1.21.3 require ( github.com/gocolly/colly/v2 v2.1.0 github.com/gorcon/rcon v1.3.4 - github.com/mholt/archiver/v3 v3.5.1 - github.com/mitchellh/go-ps v1.0.0 - github.com/robfig/cron v1.2.0 github.com/tidwall/gjson v1.17.0 github.com/wailsapp/wails/v2 v2.7.1 ) require ( github.com/PuerkitoBio/goquery v1.5.1 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/cascadia v1.2.0 // indirect github.com/antchfx/htmlquery v1.2.3 // indirect github.com/antchfx/xmlquery v1.2.4 // indirect github.com/antchfx/xpath v1.1.8 // indirect github.com/bep/debounce v1.2.1 // indirect - github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.4.2 // indirect - github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.3.0 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/kennygrant/sanitize v1.2.4 // indirect - github.com/klauspost/compress v1.17.5 // indirect - github.com/klauspost/pgzip v1.2.6 // indirect github.com/labstack/echo/v4 v4.10.2 // indirect github.com/labstack/gommon v0.4.0 // indirect github.com/leaanthony/go-ansi-parser v1.6.0 // indirect @@ -42,8 +34,6 @@ require ( github.com/leaanthony/u v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/nwaples/rardecode v1.1.3 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect @@ -53,12 +43,10 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tkrajina/go-reflector v0.5.6 // indirect - github.com/ulikunitz/xz v0.5.11 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.10 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect golang.org/x/net v0.20.0 // indirect diff --git a/go.sum b/app/go.sum similarity index 85% rename from go.sum rename to app/go.sum index 2aa4372..67d60a8 100644 --- a/go.sum +++ b/app/go.sum @@ -2,9 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= -github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= @@ -22,9 +19,6 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= -github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= -github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -51,14 +45,10 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -70,14 +60,6 @@ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4P github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E= -github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= -github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= @@ -102,16 +84,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= -github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= -github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= -github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= -github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= -github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= -github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= -github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -122,8 +94,6 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= -github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= @@ -145,10 +115,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE= github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= -github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= -github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= @@ -160,8 +126,6 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.7.1 h1:HAzp2c5ODOzsLC6ZMDVtNOB72ozM7/SJecJPB2Ur+UU= github.com/wailsapp/wails/v2 v2.7.1/go.mod h1:oIJVwwso5fdOgprBYWXBBqtx6PaSvxg8/KTQHNGkadc= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= diff --git a/main.go b/app/main.go similarity index 83% rename from main.go rename to app/main.go index 56d9428..bc792f3 100644 --- a/main.go +++ b/app/main.go @@ -6,10 +6,7 @@ package main import ( "embed" "os" - backupsmanager "palword-ds-gui/backups-manager" - dedicatedserver "palword-ds-gui/dedicated-server" rconclient "palword-ds-gui/rcon-client" - "palword-ds-gui/steamcmd" "palword-ds-gui/utils" "github.com/tidwall/gjson" @@ -32,6 +29,10 @@ var ( ) func main() { + if _, err := os.Stat(utils.Config.AppDataDir); os.IsNotExist(err) { + os.MkdirAll(utils.Config.AppDataDir, 0755) + } + logsFile, logErr := os.OpenFile(utils.Config.LogsPath, os.O_RDWR|os.O_CREATE, 0666) if logErr != nil { panic(logErr) @@ -42,11 +43,8 @@ func main() { version := gjson.Get(wailsJSON, "info.productVersion") utils.LogToFile("main.go: main() - Palword Dedicated Server GUI v" + version.String()) - dedicatedServer := dedicatedserver.NewDedicatedServer() - steamCmd := steamcmd.NewSteamCMD() - backupManager := backupsmanager.NewBackupManager(dedicatedServer) rconClient := rconclient.NewRconClient() - app := NewApp(dedicatedServer, steamCmd, backupManager, rconClient) + app := NewApp(rconClient) utils.LogToFile("main.go: main() - Managers created") @@ -77,8 +75,6 @@ func main() { WindowStartState: options.Normal, Bind: []interface{}{ app, - dedicatedServer, - backupManager, rconClient, }, Windows: &windows.Options{ diff --git a/rcon-client/rcon-client.go b/app/rcon-client/rcon-client.go similarity index 100% rename from rcon-client/rcon-client.go rename to app/rcon-client/rcon-client.go diff --git a/app/utils/utils.go b/app/utils/utils.go new file mode 100644 index 0000000..ab82506 --- /dev/null +++ b/app/utils/utils.go @@ -0,0 +1,67 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +type ConsoleEntry struct { + Message string + Timestamp int64 + MsgType string +} + +type AppConfig struct { + LogsPath string + AppDataDir string +} + +var Config AppConfig = AppConfig{ + AppDataDir: GetAppDataDir(), + LogsPath: filepath.Join(GetAppDataDir(), "logs.txt"), +} + +func GetCurrentDir() string { + ex, err := os.Executable() + + if err != nil { + panic(err) + } + + dir := filepath.Dir(ex) + + return dir +} + +func GetAppDataDir() string { + appDataDir := os.Getenv("APPDATA") + return filepath.Join(appDataDir, "PalworldDSGUI") +} + +func LogToFile(message string) { + logsFile, err := os.OpenFile(Config.LogsPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + panic(err) + } + + defer logsFile.Close() + + formatedMessage := fmt.Sprintf("[%s] %s", time.Now().Format("02-01-2006 15:04:05"), message) + + logsFile.WriteString(formatedMessage + "\n") +} + +func OpenExplorerWithFile(folderPath, fileName string) error { + cmd := exec.Command("explorer", "/select,", fileName) + cmd.Dir = folderPath + + err := cmd.Run() + if err != nil { + return err + } + + return nil +} diff --git a/wails.json b/app/wails.json similarity index 95% rename from wails.json rename to app/wails.json index 1704212..6817336 100644 --- a/wails.json +++ b/app/wails.json @@ -15,7 +15,7 @@ "info": { "companyName": "DiogoMartino", "productName": "Palworld Dedicated Server GUI", - "productVersion": "0.0.7", + "productVersion": "1.0.0", "copyright": "2024", "comments": "https://github.com/diogomartino/palworld-ds-gui" } diff --git a/dedicated-server/dedicated-server.go b/dedicated-server/dedicated-server.go deleted file mode 100644 index fdc1eb4..0000000 --- a/dedicated-server/dedicated-server.go +++ /dev/null @@ -1,315 +0,0 @@ -package dedicatedserver - -import ( - "context" - "fmt" - "os" - "os/exec" - "palword-ds-gui/utils" - "regexp" - "strings" - "time" - - "github.com/mitchellh/go-ps" - "github.com/wailsapp/wails/v2/pkg/runtime" -) - -type DedicatedServer struct { - cmd *exec.Cmd - serverCmd *exec.Cmd - serverPid int - launchParams []string -} - -var ctx context.Context -var currentConsoleId string = "DEDICATED_SERVER" - -func NewDedicatedServer() *DedicatedServer { - return &DedicatedServer{} -} - -func Print(message string) { - utils.PrintEx(ctx, message, currentConsoleId) -} - -func (d *DedicatedServer) Init(srcCtx context.Context) { - ctx = srcCtx - proc, _ := utils.FindProcessByName(utils.Config.ServerProcessName) - - if proc != nil { - Print("A server is already running, killing it...") - - err := utils.KillProcessByPid(proc.Pid()) - - if err != nil { - Print("Error stopping server: " + err.Error()) - return - } - - Print("Server killed successfully") - } - - if _, err := os.Stat(utils.Config.ServerPath); os.IsNotExist(err) { - Print("Server directory not found, creating...") - runtime.EventsEmit(ctx, "SET_LOADING_STATUS", "INSTALLING_SERVER") - - os.Mkdir(utils.Config.ServerPath, 0755) - d.DownloadDedicatedServer() - } - - Print("Server is ready") -} - -func (d *DedicatedServer) DownloadDedicatedServer() { - Print("Downloading dedicated server...") - - if d.IsRunning() { - Print("Cannot update server while it's running. Stopping it...") - d.Stop() - return - } - - cmd := exec.Command(utils.Config.SteamCmdExe, - "+force_install_dir", utils.Config.ServerPath, - "+login", "anonymous", - "+app_update", utils.Config.AppId, "validate", - "+quit") - - cmd.Dir = utils.Config.SteamCmdPath - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - d.cmd = cmd - - cmd.Run() -} - -func (d *DedicatedServer) MonitorServerProcess() { - for { - time.Sleep(4 * time.Second) - - // was killed via gui - if d.serverPid == 0 { - break - } - - proc, err := utils.FindProcessByPid(d.serverPid) - - if proc == nil || err != nil { - Print("Server seems to have stopped (crashed?)") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STOPPED") - break - } - } -} - -func (d *DedicatedServer) Start() { - Print("Starting dedicated server...") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STARTING") - - d.serverCmd = exec.Command(utils.Config.ServerExe, d.launchParams...) - d.serverCmd.Dir = utils.Config.ServerPath - d.serverCmd.Stdout = os.Stdout - d.serverCmd.Stderr = os.Stderr - - err := d.serverCmd.Start() - if err != nil { - Print(err.Error()) - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "ERROR") - return - } - - var attempts int = 10 - var proc ps.Process - - whileLoop := true - for whileLoop { - time.Sleep(1 * time.Second) - - proc, err = utils.FindProcessByName(utils.Config.ServerProcessName) - if err != nil { - continue - } - - if proc != nil { - whileLoop = false - } - - attempts-- - - if attempts <= 0 { - whileLoop = false - } - } - - if proc == nil { - Print("Server process not found") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "ERROR") - return - } - - d.serverPid = proc.Pid() - - Print("Server started") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STARTED") - - go d.MonitorServerProcess() -} - -func (d *DedicatedServer) Update() { - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "UPDATING") - d.DownloadDedicatedServer() - Print("Server updated") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STOPPED") -} - -func (d *DedicatedServer) Stop() { - if !d.IsRunning() { - return - } - - Print("Stopping dedicated server...") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STOPPING") - - err := utils.KillProcessByPid(d.serverPid) - if err != nil { - Print(err.Error()) - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "ERROR") - return - } - - if d.serverCmd != nil && d.serverCmd.Process != nil { - err := d.serverCmd.Process.Kill() - if err != nil { - Print(err.Error()) - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "ERROR") - return - } - } - - Print("Server stopped") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "STOPPED") - d.serverPid = 0 -} - -func (d *DedicatedServer) IsRunning() bool { - if d.serverPid == 0 { - return false - } - - proc, _ := utils.FindProcessByPid(d.serverPid) - - return proc != nil -} - -func (d *DedicatedServer) Restart() { - Print("Restarting dedicated server...") - runtime.EventsEmit(ctx, "SET_SERVER_STATUS", "RESTARTING") - - d.Stop() - d.Start() -} - -func (d *DedicatedServer) ReadConfig() string { - configPath := utils.Config.ServerConfigPath - - // If config file doesn't exist yet, use default config - if _, err := os.Stat(configPath); os.IsNotExist(err) { - configPath = utils.Config.ServerDefaultConfigPath - } - - configData, err := os.ReadFile(configPath) - if err != nil { - panic(err) - } - - configString := strings.TrimSpace(string(configData)) - isEmpty := len(configString) == 0 - - // if the config file is empty, use default config - if isEmpty { - configData, err := os.ReadFile(utils.Config.ServerDefaultConfigPath) - - if err != nil { - panic(err) - } - - return strings.TrimSpace(string(configData)) - } - - return configString -} - -func (d *DedicatedServer) WriteConfig(configString string) { - if _, err := os.Stat(utils.Config.ServerConfigDir); os.IsNotExist(err) { - os.MkdirAll(utils.Config.ServerConfigDir, 0755) - } - - if _, err := os.Stat(utils.Config.ServerConfigPath); os.IsNotExist(err) { - - _, err := os.Create(utils.Config.ServerConfigPath) - if err != nil { - panic(err) - } - } - - err := os.WriteFile(utils.Config.ServerConfigPath, []byte(configString), 0644) - if err != nil { - panic(err) - } -} - -func (d *DedicatedServer) SetLaunchParams(params []string) { - d.launchParams = params -} - -func (d *DedicatedServer) ReadSaveName() string { - // If file doesn't exist yet, return empty string (user hasn't joined the server for the first time yet) - if _, err := os.Stat(utils.Config.ServerGameUserSettingsPath); os.IsNotExist(err) { - return "" - } - - settingsData, err := os.ReadFile(utils.Config.ServerGameUserSettingsPath) - if err != nil { - panic(err) - } - - settingsString := strings.TrimSpace(string(settingsData)) - re := regexp.MustCompile(`DedicatedServerName=([^\s]+)`) - match := re.FindStringSubmatch(settingsString) - - if len(match) == 2 { - return match[1] - } - - return "" -} - -func (d *DedicatedServer) WriteSaveName(newSaveName string) error { - settingsData, err := os.ReadFile(utils.Config.ServerGameUserSettingsPath) - if err != nil { - return err - } - - settingsString := strings.TrimSpace(string(settingsData)) - re := regexp.MustCompile(`DedicatedServerName=([^\s]+)`) - settingsString = re.ReplaceAllString(settingsString, fmt.Sprintf("DedicatedServerName=%s", newSaveName)) - - err = os.WriteFile(utils.Config.ServerGameUserSettingsPath, []byte(settingsString), os.ModePerm) - if err != nil { - return err - } - - return nil -} - -func (d *DedicatedServer) Dispose() { - if d.cmd != nil && d.cmd.Process != nil { - err := d.cmd.Process.Kill() - if err != nil { - Print(err.Error()) - } - } - - d.Stop() - utils.LogToFile("dedicated-server.go: Dispose()") -} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 deleted file mode 100644 index cdebf51..0000000 --- a/frontend/package.json.md5 +++ /dev/null @@ -1 +0,0 @@ -b2cabdc755251f56814b02a49d3373c7 \ No newline at end of file diff --git a/frontend/src/actions/console.ts b/frontend/src/actions/console.ts deleted file mode 100644 index 5403c82..0000000 --- a/frontend/src/actions/console.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { consolesSliceActions } from '../store/console-slice'; -import { store } from '../store'; -import { ConsoleId, TConsoleEntry } from '../types'; - -export const addConsoleEntry = (consoleId: ConsoleId, entry: TConsoleEntry) => { - store.dispatch(consolesSliceActions.addConsoleEntry({ consoleId, entry })); -}; diff --git a/frontend/src/actions/server.ts b/frontend/src/actions/server.ts deleted file mode 100644 index 37eb33a..0000000 --- a/frontend/src/actions/server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { serverSliceActions } from '../store/server-slice'; -import { store } from '../store'; -import { TConfig } from '../types/server-config'; -import { ServerStatus } from '../types'; -import { DesktopApi } from '../desktop'; -import { saveSettings } from './app'; - -export const setConfig = (config: TConfig) => { - store.dispatch(serverSliceActions.setConfig(config)); -}; - -export const setSaveName = (saveName: string) => { - store.dispatch(serverSliceActions.setSaveName(saveName)); -}; - -export const setStatus = (status: ServerStatus) => { - store.dispatch(serverSliceActions.setStatus(status)); -}; - -export const startServer = async () => { - await DesktopApi.server.start(); - - saveSettings(); -}; - -export const stopServer = async () => { - await DesktopApi.server.stop(); -}; - -export const restartServer = async () => { - await DesktopApi.server.restart(); -}; - -export const updateServer = async () => { - await DesktopApi.server.update(); -}; diff --git a/frontend/src/components/sidebar/index.tsx b/frontend/src/components/sidebar/index.tsx deleted file mode 100644 index 2b8c194..0000000 --- a/frontend/src/components/sidebar/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { - IconBolt, - IconFiles, - IconInfoHexagon, - IconServer, - IconSettings, - IconSettingsUp -} from '@tabler/icons-react'; -import NavItem from './nav-item'; -import useHasUpdates from '../../hooks/use-latest-version'; - -const Sidebar = () => { - const { hasUpdates } = useHasUpdates(); - - return ( -
-
- - - - - - -
-
- ); -}; - -export default Sidebar; diff --git a/frontend/src/desktop.ts b/frontend/src/desktop.ts deleted file mode 100644 index 8f5e019..0000000 --- a/frontend/src/desktop.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { AppEvent, TGenericFunction } from './types'; -import { EventsOff, EventsOn } from './wailsjs/runtime/runtime'; -import * as DedicatedServer from './wailsjs/go/dedicatedserver/DedicatedServer'; -import * as BackupManager from './wailsjs/go/backupsmanager/BackupManager'; -import * as RconClient from './wailsjs/go/rconclient/RconClient'; -import * as App from './wailsjs/go/main/App'; -import { parseConfig, serializeConfig } from './helpers/config-parser'; -import { setConfig, setSaveName } from './actions/server'; -import { ConfigKey, TConfig } from './types/server-config'; -import { store } from './store'; -import { launchParamsSelector, rconCredentialsSelector } from './selectors/app'; -import { RconCommand, TRconInfo, TRconPlayer } from './types/rcon'; -import { setRconCredentials } from './actions/app'; - -export const DesktopApi = { - onAppEvent: ( - event: AppEvent, - callback, - unsubscribes?: TGenericFunction[] - ) => { - EventsOn(event, callback); - - const unsubscribe = () => { - EventsOff(event, callback); - }; - - if (unsubscribes) { - unsubscribes.push(unsubscribe); - } - - return unsubscribe; - }, - openUrl: async (url: string) => { - await App.OpenInBrowser(url); - }, - initApp: async () => { - await App.InitApp(); - }, - getProfileImageURL: async (steamID64: string) => { - await App.GetSteamProfileURL(steamID64); - }, - server: { - readConfig: async () => { - const configString = await DedicatedServer.ReadConfig(); - const config = parseConfig(configString); - - setConfig(config); - setRconCredentials( - `127.0.0.1:${config[ConfigKey.RCONPort]}`, - config[ConfigKey.AdminPassword] - ); - }, - writeConfig: async (config: TConfig) => { - const serializedConfig = serializeConfig(config); - await DedicatedServer.WriteConfig(serializedConfig); - - // Read again to update the store - await DesktopApi.server.readConfig(); - }, - readSaveName: async () => { - const saveNameString = await DedicatedServer.ReadSaveName(); - - setSaveName(saveNameString); - }, - writeSaveName: async (saveName: string) => { - await DedicatedServer.WriteSaveName(saveName); - - // Read again to update the store - await DesktopApi.server.readSaveName(); - }, - start: async () => { - const state = store.getState(); - const params = launchParamsSelector(state); - const paramsArray = - params - ?.split(' ') - .filter((p) => p !== '') - .map((p) => p.trim()) ?? []; - - await DedicatedServer.SetLaunchParams(paramsArray); - await DedicatedServer.Start(); - }, - stop: async () => { - await DedicatedServer.Stop(); - }, - restart: async () => { - await DedicatedServer.Restart(); - }, - update: async () => { - await DedicatedServer.Update(); - } - }, - backups: { - start: async (interval: number, keepCount: number) => { - await BackupManager.Start(interval, keepCount); - }, - stop: async () => { - await BackupManager.Stop(); - }, - getList: async () => { - return await BackupManager.GetBackupsList(); - }, - delete: async (backupFileName: string) => { - await BackupManager.Delete(backupFileName); - }, - create: async () => { - await BackupManager.CreateBackup(); - }, - open: async (backupFileName: string) => { - await BackupManager.Open(backupFileName); - }, - restore: async (backupFileName: string) => { - await BackupManager.Restore(backupFileName); - } - }, - rcon: { - execute: async (command: string) => { - const state = store.getState(); - const rconCredentials = rconCredentialsSelector(state); - - const result = await RconClient.Execute( - rconCredentials.host, - rconCredentials.password, - command - ); - - return result.trim(); - }, - getInfo: async (): Promise => { - try { - const result = ( - (await DesktopApi.rcon.execute(RconCommand.INFO)) || '' - ).trim(); - - const regex = /Welcome to Pal Server\[(.*?)\]\s*(.*)/; - const match = result.match(regex); - const [, version, name] = match || []; - - return { - version, - name - }; - } catch { - // - } - - return undefined; - }, - getPlayers: async (): Promise => { - try { - const result = ( - (await DesktopApi.rcon.execute(RconCommand.SHOW_PLAYERS)) || '' - ).trim(); - - const lines = result.split('\n'); - - lines.shift(); // remove the first line which is the header - - const players = lines.map((line) => { - const [name, uid, steamId] = line.split(',').map((s) => s.trim()); - const player: TRconPlayer = { - name, - uid, - steamId - }; - - DesktopApi.getProfileImageURL(steamId); // crawl steam profile image and cache the url in the store so we don't have to do it again for the same player - - return player; - }); - - return players; - } catch { - // - } - - return []; - }, - save: async () => { - await DesktopApi.rcon.execute(RconCommand.SAVE); - }, - shutdown: async ( - message: string = 'Server is being shutdown', - seconds?: number - ) => { - const command = `${RconCommand.SHUTDOWN} ${seconds ?? 1} ${message}`; - - await DesktopApi.rcon.execute(command); - }, - sendMessage: async (messages: string) => { - const command = `${RconCommand.BROADCAST} ${messages}`; - - await DesktopApi.rcon.execute(command); - }, - ban: async (uid: string) => { - const command = `${RconCommand.BAN} ${uid}`; - - await DesktopApi.rcon.execute(command); - }, - kick: async (uid: string) => { - const command = `${RconCommand.KICK} ${uid}`; - - await DesktopApi.rcon.execute(command); - } - } -}; diff --git a/frontend/src/hooks/use-consoles-by-id.ts b/frontend/src/hooks/use-consoles-by-id.ts deleted file mode 100644 index ea6787d..0000000 --- a/frontend/src/hooks/use-consoles-by-id.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useSelector } from 'react-redux'; -import { ConsoleId } from '../types'; -import { consolesByIdSelector } from '../selectors/console'; - -const useConsolesById = (ids: ConsoleId[]) => - useSelector((state: any) => consolesByIdSelector(state, ids)); - -export default useConsolesById; diff --git a/frontend/src/hooks/use-events-init.ts b/frontend/src/hooks/use-events-init.ts deleted file mode 100644 index 7d4e28b..0000000 --- a/frontend/src/hooks/use-events-init.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { DesktopApi } from '../desktop'; -import { - AppEvent, - ConsoleId, - LoadingStatus, - ServerStatus, - TConsoleEntry, - TGenericFunction, - TGenericObject -} from '../types'; -import { - addSteamImage, - checkForUpdates, - initApp, - setLoadingStatus -} from '../actions/app'; -import { addConsoleEntry } from '../actions/console'; -import { setStatus } from '../actions/server'; - -const CHECK_FOR_UPDATES_INTERVAL = 1000 * 60 * 60 * 24; // 1 day - -const useEventsInit = () => { - const hasInit = useRef(false); - - useEffect(() => { - if (!hasInit.current) { - DesktopApi.initApp(); - checkForUpdates(); - - setInterval(() => { - checkForUpdates(); - }, CHECK_FOR_UPDATES_INTERVAL); - - hasInit.current = true; - } - }, []); - - useEffect(() => { - const unsubscribes: TGenericFunction[] = []; - - DesktopApi.onAppEvent( - AppEvent.RETURN_STEAM_IMAGE, - (resultString: string) => { - const [steamId, imageUrl] = resultString.split('|'); - - addSteamImage(steamId, imageUrl); - }, - unsubscribes - ); - - DesktopApi.onAppEvent( - AppEvent.SET_LOADING_STATUS, - (status: LoadingStatus) => { - setLoadingStatus(status); - - if (status === LoadingStatus.DONE) { - initApp(); - } - }, - unsubscribes - ); - - DesktopApi.onAppEvent( - AppEvent.ADD_CONSOLE_ENTRY, - (consoleId: ConsoleId, entry: TGenericObject) => { - const entryObj: TConsoleEntry = { - timestamp: entry.Timestamp, - message: entry.Message, - msgType: entry.MsgType - }; - - addConsoleEntry(consoleId, entryObj); - }, - unsubscribes - ); - - DesktopApi.onAppEvent( - AppEvent.SET_SERVER_STATUS, - (status: ServerStatus) => { - setStatus(status); - }, - unsubscribes - ); - - return () => { - unsubscribes.forEach((unsubscribe) => unsubscribe()); - }; - }, []); -}; - -export default useEventsInit; diff --git a/frontend/src/hooks/use-loading-status.ts b/frontend/src/hooks/use-loading-status.ts deleted file mode 100644 index fb0cde3..0000000 --- a/frontend/src/hooks/use-loading-status.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useSelector } from 'react-redux'; -import { loadingStatusSelector } from '../selectors/app'; - -const useLoadingStatus = () => useSelector(loadingStatusSelector); - -export default useLoadingStatus; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index bf7aee7..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './components/app'; -import { store } from './store'; -import { Provider } from 'react-redux'; -import { NextUIProvider } from '@nextui-org/react'; -import './main.css'; -import ModalsProvider from './components/modals'; -import { BrowserRouter } from 'react-router-dom'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - - -); diff --git a/frontend/src/screens/app-settings/index.tsx b/frontend/src/screens/app-settings/index.tsx deleted file mode 100644 index af0fb55..0000000 --- a/frontend/src/screens/app-settings/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Button } from '@nextui-org/react'; -import Layout from '../../components/layout'; -import { toggleTheme } from '../../actions/app'; -import useSelectedTheme from '../../hooks/use-selected-theme'; -import { IconMoon, IconSun } from '@tabler/icons-react'; - -const AppSettings = () => { - const theme = useSelectedTheme(); - - return ( - -
- -
-
- ); -}; - -export default AppSettings; diff --git a/frontend/src/screens/initializing/index.tsx b/frontend/src/screens/initializing/index.tsx deleted file mode 100644 index 7e1f421..0000000 --- a/frontend/src/screens/initializing/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Image, Spinner } from '@nextui-org/react'; -import { LoadingStatus } from '../../types'; -import palworldLogo from '../../assets/palworld-logo.webp'; -import useLoadingStatus from '../../hooks/use-loading-status'; - -const statusText = { - [LoadingStatus.IDLE]: 'Waiting...', - [LoadingStatus.INSTALLING_STEAMCMD]: 'Installing SteamCMD...', - [LoadingStatus.INSTALLING_SERVER]: 'Installing dedicated server...', - [LoadingStatus.DONE]: 'Done!' -}; - -const Initializing = () => { - const status = useLoadingStatus(); - - return ( -
-
- Palworld Logo -

Dedicated Server GUI

-

v{APP_VERSION}

- -
-

Initializing, please wait.

- -

{statusText[status] || `Unknown`}

- -
-

- Be patient, this may take a while depending on your internet speed - and computer specs. -

- -

- Do not close any terminal that pops up, otherwise the installation - will fail. -

-
-
-
-
- ); -}; - -export default Initializing; diff --git a/frontend/src/selectors/console.ts b/frontend/src/selectors/console.ts deleted file mode 100644 index a953da0..0000000 --- a/frontend/src/selectors/console.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IRootState } from '../store'; -import { ConsoleId, TConsoleEntry } from '../types'; - -export const consolesByIdSelector = ( - state: IRootState, - consoleIds: ConsoleId[] -) => { - const result: TConsoleEntry[] = []; - - consoleIds.forEach((consoleId) => { - if (state.consoles[consoleId]) { - result.push(...state.consoles[consoleId]); - } - }); - - // sort by timestamp - result.sort((a, b) => a.timestamp - b.timestamp); - - return result; -}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts deleted file mode 100644 index 644110b..0000000 --- a/frontend/src/types/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type TTheme = 'light' | 'dark'; - -export type TGenericObject = { - [key: string]: any; -}; - -export type TGenericFunction = (...args: any[]) => any; - -export enum Modal { - ACTION_CONFIRMATION = 'ACTION_CONFIRMATION' -} - -export enum LoadingStatus { - IDLE = 'IDLE', - INSTALLING_STEAMCMD = 'INSTALLING_STEAMCMD', - INSTALLING_SERVER = 'INSTALLING_SERVER', - DONE = 'DONE' -} - -export enum ServerStatus { - STARTED = 'STARTED', - STOPPED = 'STOPPED', - STARTING = 'STARTING', - STOPPING = 'STOPPING', - RESTARTING = 'RESTARTING', - UPDATING = 'UPDATING', - ERROR = 'ERROR' -} - -export enum ConsoleId { - STEAM_CMD = 'STEAM_CMD', - SERVER = 'DEDICATED_SERVER' -} - -export enum AppEvent { - SET_LOADING_STATUS = 'SET_LOADING_STATUS', - SET_SERVER_STATUS = 'SET_SERVER_STATUS', - ADD_CONSOLE_ENTRY = 'ADD_CONSOLE_ENTRY', - RETURN_STEAM_IMAGE = 'RETURN_STEAM_IMAGE' -} - -export type TConsoleEntry = { - timestamp: number; - message: string; - msgType: 'stdout' | 'stderr'; -}; - -export type TBackupSettings = { - enabled: boolean; - intervalHours: number; - keepCount: number; -}; - -export type TSettings = { - theme: 'light' | 'dark'; - backup: TBackupSettings; - launchParams: string | undefined; -}; - -export type TSteamImageMap = { - [steamId64: string]: string; -}; diff --git a/frontend/src/wailsjs/go/backupsmanager/BackupManager.d.ts b/frontend/src/wailsjs/go/backupsmanager/BackupManager.d.ts deleted file mode 100644 index 5f81886..0000000 --- a/frontend/src/wailsjs/go/backupsmanager/BackupManager.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT -import {backupsmanager} from '../models'; -import {context} from '../models'; - -export function CheckCount():Promise; - -export function CreateBackup():Promise; - -export function Delete(arg1:string):Promise; - -export function Dispose():Promise; - -export function GetBackupsList():Promise>; - -export function Init(arg1:context.Context):Promise; - -export function Open(arg1:string):Promise; - -export function Restore(arg1:string):Promise; - -export function Start(arg1:number,arg2:number):Promise; - -export function Stop():Promise; diff --git a/frontend/src/wailsjs/go/backupsmanager/BackupManager.js b/frontend/src/wailsjs/go/backupsmanager/BackupManager.js deleted file mode 100644 index f6f2021..0000000 --- a/frontend/src/wailsjs/go/backupsmanager/BackupManager.js +++ /dev/null @@ -1,43 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export function CheckCount() { - return window['go']['backupsmanager']['BackupManager']['CheckCount'](); -} - -export function CreateBackup() { - return window['go']['backupsmanager']['BackupManager']['CreateBackup'](); -} - -export function Delete(arg1) { - return window['go']['backupsmanager']['BackupManager']['Delete'](arg1); -} - -export function Dispose() { - return window['go']['backupsmanager']['BackupManager']['Dispose'](); -} - -export function GetBackupsList() { - return window['go']['backupsmanager']['BackupManager']['GetBackupsList'](); -} - -export function Init(arg1) { - return window['go']['backupsmanager']['BackupManager']['Init'](arg1); -} - -export function Open(arg1) { - return window['go']['backupsmanager']['BackupManager']['Open'](arg1); -} - -export function Restore(arg1) { - return window['go']['backupsmanager']['BackupManager']['Restore'](arg1); -} - -export function Start(arg1, arg2) { - return window['go']['backupsmanager']['BackupManager']['Start'](arg1, arg2); -} - -export function Stop() { - return window['go']['backupsmanager']['BackupManager']['Stop'](); -} diff --git a/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.d.ts b/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.d.ts deleted file mode 100644 index e08cd01..0000000 --- a/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT -import {context} from '../models'; - -export function Dispose():Promise; - -export function DownloadDedicatedServer():Promise; - -export function Init(arg1:context.Context):Promise; - -export function IsRunning():Promise; - -export function MonitorServerProcess():Promise; - -export function ReadConfig():Promise; - -export function ReadSaveName():Promise; - -export function Restart():Promise; - -export function SetLaunchParams(arg1:Array):Promise; - -export function Start():Promise; - -export function Stop():Promise; - -export function Update():Promise; - -export function WriteConfig(arg1:string):Promise; - -export function WriteSaveName(arg1:string):Promise; diff --git a/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.js b/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.js deleted file mode 100644 index e38df1c..0000000 --- a/frontend/src/wailsjs/go/dedicatedserver/DedicatedServer.js +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export function Dispose() { - return window['go']['dedicatedserver']['DedicatedServer']['Dispose'](); -} - -export function DownloadDedicatedServer() { - return window['go']['dedicatedserver']['DedicatedServer']['DownloadDedicatedServer'](); -} - -export function Init(arg1) { - return window['go']['dedicatedserver']['DedicatedServer']['Init'](arg1); -} - -export function IsRunning() { - return window['go']['dedicatedserver']['DedicatedServer']['IsRunning'](); -} - -export function MonitorServerProcess() { - return window['go']['dedicatedserver']['DedicatedServer']['MonitorServerProcess'](); -} - -export function ReadConfig() { - return window['go']['dedicatedserver']['DedicatedServer']['ReadConfig'](); -} - -export function ReadSaveName() { - return window['go']['dedicatedserver']['DedicatedServer']['ReadSaveName'](); -} - -export function Restart() { - return window['go']['dedicatedserver']['DedicatedServer']['Restart'](); -} - -export function SetLaunchParams(arg1) { - return window['go']['dedicatedserver']['DedicatedServer']['SetLaunchParams'](arg1); -} - -export function Start() { - return window['go']['dedicatedserver']['DedicatedServer']['Start'](); -} - -export function Stop() { - return window['go']['dedicatedserver']['DedicatedServer']['Stop'](); -} - -export function Update() { - return window['go']['dedicatedserver']['DedicatedServer']['Update'](); -} - -export function WriteConfig(arg1) { - return window['go']['dedicatedserver']['DedicatedServer']['WriteConfig'](arg1); -} - -export function WriteSaveName(arg1) { - return window['go']['dedicatedserver']['DedicatedServer']['WriteSaveName'](arg1); -} diff --git a/go.work b/go.work new file mode 100644 index 0000000..9b41866 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.21.3 + +use ( + ./app + ./server +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..66b791e --- /dev/null +++ b/go.work.sum @@ -0,0 +1,55 @@ +atomicgo.dev/cursor v0.1.1/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.8/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flytam/filenamify v1.0.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4X+lNVprw= +github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/gookit/color v1.5.2/go.mod h1:w8h4bGiHeeBpvQVePTutdbERIUf3oJE5lZ8HM0UgXyg= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo= +github.com/jaypipes/ghw v0.12.0/go.mod h1:jeJGbkRB2lL3/gxYzNYzEDETV1ZJ56OKr+CSeSEym+g= +github.com/jaypipes/pcidb v1.0.0/go.mod h1:TnYUvqhPBzCKnH34KrIX22kAeEbDCSRJ9cqLRCuNDfk= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/leaanthony/clir v1.3.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0= +github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU= +github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pterm/pterm v0.12.49/go.mod h1:D4OBoWNqAfXkm5QLTjIgjNiMXPHemLJHnIreGUsWzWg= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/sjson v1.1.7/go.mod h1:w/yG+ezBeTdUxiKs5NcPicO9diP38nk96QBAbIIGeFs= +github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28= +github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae/go.mod h1:VTAq37rkGeV+WOybvZwjXiJOicICdpLCN8ifpISjK20= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..7cd1091 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work \ No newline at end of file diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..67a3451 --- /dev/null +++ b/server/api.go @@ -0,0 +1,103 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "os" + "palworld-ds-gui-server/utils" + "path" +) + +type Api struct { +} + +func NewApi() *Api { + return &Api{} +} + +func PrintApiKey() { + fmt.Printf("\n\n🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨\n") + fmt.Printf("Anyone with your API key can control your server. Keep it secret!\n") + fmt.Printf("Your current API key is: %s\n", utils.Settings.General.APIKey) + fmt.Printf("To generate a new API key, run the program with the --newkey flag\n") + fmt.Printf("🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨\n\n") +} + +func (a *Api) Init() { + if !HasApiKey() || utils.Launch.ForceNewKey { + GenerateApiKey() + utils.SaveSettings() + } + + if utils.Launch.ShowKey { + PrintApiKey() + } + + internalIp := utils.GetOutboundIP() + externalIp, err := utils.GetExternalIPv4() + if err != nil { + utils.LogToFile("Failed to get external IP: "+err.Error(), true) + fmt.Printf("Server is running on %s:%d\n", internalIp, utils.Launch.Port) + } else { + fmt.Printf("Server is running on %s:%d (Local IP: %s:%d)\n", externalIp, utils.Launch.Port, internalIp, utils.Launch.Port) + } + + utils.EmitConsoleLog = LogToClient + + http.HandleFunc("/ws", handleWebSocket) + http.HandleFunc("/backups/", func(w http.ResponseWriter, r *http.Request) { + apiKey := r.Header.Get("Authorization") + + fmt.Printf("API Key: %s\n", apiKey) + + if apiKey != utils.Settings.General.APIKey { + utils.Log(fmt.Sprintf("Unauthorized backup download from %s", r.RemoteAddr)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Extract the specific file path from the URL. + filePath := r.URL.Path[len("/backups/"):] + + // Safely join the base path with the requested file path to avoid directory traversal attacks. + safeFilePath := path.Join(utils.Config.BackupsPath, filePath) + + // Ensure the file exists and is not a directory before serving. + if info, err := os.Stat(safeFilePath); err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + + // Serve the requested file. + http.ServeFile(w, r, safeFilePath) + }) + http.ListenAndServe(fmt.Sprintf(":%d", utils.Launch.Port), nil) +} + +func GenerateApiKey() { + randomBytes := make([]byte, 32) + rand.Read(randomBytes) + hasher := sha256.New() + hasher.Write(randomBytes) + hash := hasher.Sum(nil) + stringHash := hex.EncodeToString(hash) + + utils.Settings.General.APIKey = stringHash + err := utils.SaveSettings() + if err != nil { + panic(err) + } + + PrintApiKey() +} + +func HasApiKey() bool { + if utils.Settings.General.APIKey == "" || utils.Settings.General.APIKey == "CHANGE_ME" { + return false + } + + return true +} diff --git a/backups-manager/backups-manager.go b/server/backups-manager.go similarity index 50% rename from backups-manager/backups-manager.go rename to server/backups-manager.go index 44e9f57..05f3e16 100644 --- a/backups-manager/backups-manager.go +++ b/server/backups-manager.go @@ -1,11 +1,9 @@ -package backupsmanager +package main import ( - "context" "fmt" "os" - dedicatedserver "palword-ds-gui/dedicated-server" - "palword-ds-gui/utils" + "palworld-ds-gui-server/utils" "path" "regexp" "sort" @@ -18,10 +16,7 @@ import ( ) type BackupManager struct { - cron *cron.Cron - dedicatedServer *dedicatedserver.DedicatedServer - ctx context.Context - keepCount int + cron *cron.Cron } type Backup struct { @@ -31,33 +26,30 @@ type Backup struct { Size int64 } -func NewBackupManager(dedicatedServer *dedicatedserver.DedicatedServer) *BackupManager { - return &BackupManager{ - dedicatedServer: dedicatedServer, - keepCount: 5, - } +func NewBackupManager() *BackupManager { + return &BackupManager{} } -func (b *BackupManager) GetBackupsList() []Backup { +func (b *BackupManager) GetBackupsList() ([]Backup, error) { files, err := os.ReadDir(utils.Config.BackupsPath) if err != nil { - panic(err) + return nil, err } backups := []Backup{} + re, err := regexp.Compile(`^bckp-\d+-\w+\.zip$`) + if err != nil { + return nil, err + } + for _, file := range files { if file.IsDir() { continue } - matched, err := regexp.MatchString(`^bckp-\d+-\w+\.zip$`, file.Name()) - - if err != nil { - panic(err) - } - + matched := re.MatchString(file.Name()) if !matched { continue } @@ -65,12 +57,12 @@ func (b *BackupManager) GetBackupsList() []Backup { timestamp, err := strconv.ParseInt(strings.Split(file.Name(), "-")[1], 10, 64) if err != nil { - panic(err) + return nil, err } fileInfo, err := file.Info() if err != nil { - panic(err) + return nil, err } backups = append(backups, Backup{ @@ -85,42 +77,46 @@ func (b *BackupManager) GetBackupsList() []Backup { return backups[i].Timestamp < backups[j].Timestamp }) - return backups + return backups, nil } func (b *BackupManager) CheckCount() { - backups := b.GetBackupsList() + backups, err := b.GetBackupsList() + + if err != nil { + return + } - if len(backups) <= b.keepCount { + if len(backups) <= utils.Settings.Backup.KeepCount { return } - utils.PrintEx(b.ctx, fmt.Sprintf("Found %d backups, deleting %d", len(backups), len(backups)-b.keepCount), "DEDICATED_SERVER") + backupsToDelete := len(backups) - utils.Settings.Backup.KeepCount - for i := 0; i < len(backups)-b.keepCount; i++ { + utils.Log(fmt.Sprintf("Found %d backups, deleting %d", len(backups), backupsToDelete)) + + for i := 0; i < backupsToDelete; i++ { b.Delete(backups[i].Filename) } } -func (b *BackupManager) CreateBackup() { - saveName := b.dedicatedServer.ReadSaveName() +func (b *BackupManager) CreateBackup() error { + saveName := ReadSaveName() if len(saveName) == 0 { - utils.PrintEx(b.ctx, "Can't create backup, please join the server first", "DEDICATED_SERVER") - return + return fmt.Errorf("can't create a backup yet, please join the server first") } - utils.PrintEx(b.ctx, "Creating backup...", "DEDICATED_SERVER") + utils.Log("Creating backup...") unix := time.Now().Unix() backupName := fmt.Sprintf("bckp-%d-%s.zip", unix, saveName) worldPath := path.Join(utils.Config.ServerSaveDir, saveName) - archiver := archiver.NewZip() files, err := os.ReadDir(worldPath) if err != nil { - panic(err) + return err } var filePaths []string @@ -131,27 +127,52 @@ func (b *BackupManager) CreateBackup() { err = archiver.Archive(filePaths, path.Join(utils.Config.BackupsPath, backupName)) if err != nil { - panic(err) + return err } - utils.PrintEx(b.ctx, "Backup created", "DEDICATED_SERVER") + utils.Log("Backup created") b.CheckCount() + + b.EmitBackupList() + + return nil } -func (b *BackupManager) Init(ctx context.Context) { - b.ctx = ctx +func (b *BackupManager) EmitBackupList() { + backups, err := b.GetBackupsList() + if err != nil { + utils.Log(err.Error()) + return + } + + type BackupListResponse struct { + Event string `json:"event"` + Success bool `json:"success"` + Data []Backup `json:"data"` + } + + BroadcastJSON(BackupListResponse{ + Event: "BACKUP_LIST_CHANGED", + Success: true, + Data: backups, + }, nil) +} + +func (b *BackupManager) Init() { if _, err := os.Stat(utils.Config.BackupsPath); os.IsNotExist(err) { os.Mkdir(utils.Config.BackupsPath, 0755) } - - utils.PrintEx(b.ctx, "Backups manager is ready", "DEDICATED_SERVER") } func (b *BackupManager) Start(interval int, keepCount int) { - utils.PrintEx(b.ctx, fmt.Sprintf("Creating backups every %d hours (keep %d)", interval, keepCount), "DEDICATED_SERVER") + utils.Log(fmt.Sprintf("Creating backups every %d hours (keep %d)", interval, keepCount)) + + utils.Settings.Backup.Enabled = true + utils.Settings.Backup.KeepCount = keepCount + utils.Settings.Backup.Interval = float32(interval) + utils.SaveSettings() - b.keepCount = keepCount cronString := fmt.Sprintf("@every %dh", interval) if b.cron != nil { @@ -159,60 +180,75 @@ func (b *BackupManager) Start(interval int, keepCount int) { } b.cron = cron.New() - b.cron.AddFunc(cronString, b.CreateBackup) + b.cron.AddFunc(cronString, func() { + err := b.CreateBackup() + if err != nil { + utils.Log(fmt.Sprintf("Error creating backup: %s", err.Error())) + } + }) b.cron.Start() } -func (b *BackupManager) Delete(backupFileName string) { +func (b *BackupManager) Delete(backupFileName string) error { err := os.Remove(path.Join(utils.Config.BackupsPath, backupFileName)) if err != nil { - panic(err) + return err } - utils.PrintEx(b.ctx, fmt.Sprintf("Backup %s deleted", backupFileName), "DEDICATED_SERVER") + utils.Log(fmt.Sprintf("Backup %s deleted", backupFileName)) + + return nil } func (b *BackupManager) Stop() { if b.cron != nil { - utils.PrintEx(b.ctx, "Backups manager stopped", "DEDICATED_SERVER") + utils.Log("Backups manager stopped") b.cron.Stop() + + utils.Settings.Backup.Enabled = false + utils.SaveSettings() } } -func (b *BackupManager) Open(backupFileName string) { - utils.OpenExplorerWithFile(utils.Config.BackupsPath, backupFileName) +func (b *BackupManager) Open(backupFileName string) error { + return utils.OpenExplorerWithFile(utils.Config.BackupsPath, backupFileName) } -func (b *BackupManager) Restore(backupFileName string) { - b.dedicatedServer.Stop() +func (b *BackupManager) Restore(backupFileName string) error { + err := servermanager.Stop() + if err != nil { + return err + } - utils.PrintEx(b.ctx, fmt.Sprintf("Restoring backup %s", backupFileName), "DEDICATED_SERVER") + utils.Log(fmt.Sprintf("Restoring backup %s", backupFileName)) split := strings.Split(backupFileName, "-") saveName := strings.Replace(split[2], ".zip", "", 1) worldPath := path.Join(utils.Config.ServerSaveDir, saveName) - err := os.RemoveAll(worldPath) + err = os.RemoveAll(worldPath) if err != nil { - panic(err) + return err } err = os.Mkdir(worldPath, 0755) if err != nil { - panic(err) + return err } archiver := archiver.NewZip() err = archiver.Unarchive(path.Join(utils.Config.BackupsPath, backupFileName), worldPath) if err != nil { - panic(err) + return err } - utils.PrintEx(b.ctx, "Backup restored", "DEDICATED_SERVER") + utils.Log("Backup restored") + + return nil } func (b *BackupManager) Dispose() { b.Stop() - utils.LogToFile("backups-manager.go: Dispose()") + utils.LogToFile("backups-manager.go: Dispose()", false) } diff --git a/server/client-init-handler.go b/server/client-init-handler.go new file mode 100644 index 0000000..f3346d9 --- /dev/null +++ b/server/client-init-handler.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type ClientInitRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + NewSaveName string `json:"saveName"` + } +} + +type ClientInitResData struct { + CurrentServerStatus string `json:"currentServerStatus"` + CurrentLaunchParams string `json:"currentLaunchParams"` + CurrentConfig string `json:"currentConfig"` + CurrentSaveName string `json:"currentSaveName"` + CurrentBackupsSettings utils.PersistedSettingsBackup `json:"currentBackupsSettings"` + CurrentBackupsList []Backup `json:"currentBackupsList"` + ServerVersion string `json:"serverVersion"` +} + +type ClientInitRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data ClientInitResData `json:"data"` +} + +var clientInitEvent = "INIT" + +func ClientInitHandler(conn *websocket.Conn, data []byte) { + var message ClientInitRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(ClientInitRes{ + Event: clientInitEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + serverRunning := servermanager.IsRunning() + currentState := "STOPPED" + if serverRunning { + currentState = "STARTED" + } + + backupsList, err := backupmanager.GetBackupsList() + if err != nil { + utils.Log(err.Error()) + backupsList = []Backup{} + } + + conn.WriteJSON(ClientInitRes{ + Event: clientInitEvent, + EventId: message.EventId, + Success: true, + Data: ClientInitResData{ + CurrentServerStatus: currentState, + CurrentLaunchParams: utils.Settings.General.LaunchParams, + CurrentConfig: ReadConfig(), + CurrentSaveName: ReadSaveName(), + CurrentBackupsSettings: utils.Settings.Backup, + CurrentBackupsList: backupsList, + ServerVersion: utils.Config.ServerVersion, + }, + }) +} diff --git a/server/config-manager.go b/server/config-manager.go new file mode 100644 index 0000000..535dfc4 --- /dev/null +++ b/server/config-manager.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "os" + "palworld-ds-gui-server/utils" + "regexp" + "strings" +) + +func ReadSaveName() string { + // If file doesn't exist yet, return empty string (user hasn't joined the server for the first time yet) + if _, err := os.Stat(utils.Config.ServerGameUserSettingsPath); os.IsNotExist(err) { + return "" + } + + settingsData, err := os.ReadFile(utils.Config.ServerGameUserSettingsPath) + if err != nil { + panic(err) + } + + settingsString := strings.TrimSpace(string(settingsData)) + re := regexp.MustCompile(`DedicatedServerName=([^\s]+)`) + match := re.FindStringSubmatch(settingsString) + + if len(match) == 2 { + return match[1] + } + + return "" +} + +func WriteSaveName(newSaveName string) error { + settingsData, err := os.ReadFile(utils.Config.ServerGameUserSettingsPath) + if err != nil { + return err + } + + settingsString := strings.TrimSpace(string(settingsData)) + re := regexp.MustCompile(`DedicatedServerName=([^\s]+)`) + settingsString = re.ReplaceAllString(settingsString, fmt.Sprintf("DedicatedServerName=%s", newSaveName)) + + err = os.WriteFile(utils.Config.ServerGameUserSettingsPath, []byte(settingsString), os.ModePerm) + if err != nil { + return err + } + + return nil +} + +func ReadConfig() string { + configPath := utils.Config.ServerConfigPath + + // If config file doesn't exist yet, use default config + if _, err := os.Stat(configPath); os.IsNotExist(err) { + configPath = utils.Config.ServerDefaultConfigPath + } + + configData, err := os.ReadFile(configPath) + if err != nil { + panic(err) + } + + configString := strings.TrimSpace(string(configData)) + isEmpty := len(configString) == 0 + + // if the config file is empty, use default config + if isEmpty { + configData, err := os.ReadFile(utils.Config.ServerDefaultConfigPath) + + if err != nil { + panic(err) + } + + return strings.TrimSpace(string(configData)) + } + + return configString +} + +func WriteConfig(configString string) { + if _, err := os.Stat(utils.Config.ServerConfigDir); os.IsNotExist(err) { + os.MkdirAll(utils.Config.ServerConfigDir, 0755) + } + + if _, err := os.Stat(utils.Config.ServerConfigPath); os.IsNotExist(err) { + + _, err := os.Create(utils.Config.ServerConfigPath) + if err != nil { + panic(err) + } + } + + err := os.WriteFile(utils.Config.ServerConfigPath, []byte(configString), 0644) + if err != nil { + panic(err) + } +} diff --git a/server/create-backup-handler.go b/server/create-backup-handler.go new file mode 100644 index 0000000..252862b --- /dev/null +++ b/server/create-backup-handler.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type CreateBackupRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` +} + +type CreateBackupRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var createBackupEvent = "CREATE_BACKUP" + +func CreateBackupHandler(conn *websocket.Conn, data []byte) { + var message StartBackupsRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(StartBackupsRes{ + Event: startBackupsEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + success := true + err = backupmanager.CreateBackup() + if err != nil { + utils.Log(err.Error()) + success = false + } + + conn.WriteJSON(CreateBackupRes{ + Event: createBackupEvent, + EventId: message.EventId, + Success: success, + }) +} diff --git a/server/delete-backup-handler.go b/server/delete-backup-handler.go new file mode 100644 index 0000000..a4dccde --- /dev/null +++ b/server/delete-backup-handler.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type DeleteBackupRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + Filename string `json:"backupFileName"` + } +} + +type DeleteBackupRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data []Backup `json:"data"` +} + +var deleteBackupEvent = "DELETE_BACKUP" + +func DeleteBackupHandler(conn *websocket.Conn, data []byte) { + var message DeleteBackupRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(DeleteBackupRes{ + Event: deleteBackupEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + err = backupmanager.Delete(message.Data.Filename) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(DeleteBackupRes{ + Event: deleteBackupEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + conn.WriteJSON(CreateBackupRes{ + Event: createBackupEvent, + EventId: message.EventId, + Success: true, + }) + + backupmanager.EmitBackupList() +} diff --git a/server/get-backups-config-handler.go b/server/get-backups-config-handler.go new file mode 100644 index 0000000..2fabc96 --- /dev/null +++ b/server/get-backups-config-handler.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type GetBackupsConfigRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` +} + +type GetBackupsConfigRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data utils.PersistedSettingsBackup `json:"data"` +} + +var getBackupsConfigEvent = "GET_BACKUPS_SETTINGS" + +func GetBackupsConfigHandler(conn *websocket.Conn, data []byte) { + var message GetBackupsConfigRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(GetBackupsConfigRes{ + Event: getBackupsConfigEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + conn.WriteJSON(GetBackupsConfigRes{ + Event: getBackupsConfigEvent, + EventId: message.EventId, + Success: true, + Data: utils.Settings.Backup, + }) +} diff --git a/server/get-backups-list-handler.go b/server/get-backups-list-handler.go new file mode 100644 index 0000000..67946a1 --- /dev/null +++ b/server/get-backups-list-handler.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type GetBackupsRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` +} + +type GetBackupsRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data []Backup `json:"data"` +} + +var getBackupsEvent = "GET_BACKUPS_LIST" + +func GetBackupsHandler(conn *websocket.Conn, data []byte) { + var message GetBackupsRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(GetBackupsRes{ + Event: getBackupsEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + list, err := backupmanager.GetBackupsList() + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(GetBackupsRes{ + Event: getBackupsEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + conn.WriteJSON(GetBackupsRes{ + Event: getBackupsEvent, + EventId: message.EventId, + Success: true, + Data: list, + }) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..f2ec6a7 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,27 @@ +module palworld-ds-gui-server + +go 1.21.3 + +require ( + github.com/gorilla/websocket v1.5.1 + github.com/mholt/archiver/v3 v3.5.1 + github.com/mitchellh/go-ps v1.0.0 +) + +require ( + github.com/andybalholm/brotli v1.0.1 // indirect + github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/golang/snappy v0.0.2 // indirect + github.com/klauspost/compress v1.11.4 // indirect + github.com/klauspost/pgzip v1.2.5 // indirect + github.com/nwaples/rardecode v1.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.2 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/ulikunitz/xz v0.5.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/net v0.20.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..a2dccda --- /dev/null +++ b/server/go.sum @@ -0,0 +1,42 @@ +github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc= +github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.4 h1:kz40R/YWls3iqT9zX9AHN3WoVsrAWVyui5sxuLqiXqU= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= +github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= +github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM= +github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..c747faa --- /dev/null +++ b/server/main.go @@ -0,0 +1,39 @@ +package main + +import ( + _ "embed" + "flag" + "os" + "palworld-ds-gui-server/utils" +) + +var ( + steamcmd *SteamCMD + servermanager *ServerManager + backupmanager *BackupManager + api *Api +) + +//go:embed server.json +var serverJSON string + +func main() { + utils.Init(serverJSON) + + if utils.Launch.Help { + flag.PrintDefaults() + os.Exit(0) + } + + steamcmd = NewSteamCMD() + steamcmd.Init() + + servermanager = NewServerManager() + servermanager.Init() + + backupmanager = NewBackupManager() + backupmanager.Init() + + api = NewApi() + api.Init() +} diff --git a/server/read-config-handler.go b/server/read-config-handler.go new file mode 100644 index 0000000..ef0095a --- /dev/null +++ b/server/read-config-handler.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type ReadConfigRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams []string `json:"launchParams"` + } +} + +type ReadConfigRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +var readConfigEvent = "READ_CONFIG" + +func ReadConfigHandler(conn *websocket.Conn, data []byte) { + var message ReadConfigRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(ReadConfigRes{ + Event: readConfigEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + config := ReadConfig() + + conn.WriteJSON(ReadConfigRes{ + Event: readConfigEvent, + EventId: message.EventId, + Success: true, + Data: config, + }) +} diff --git a/server/read-save-handler.go b/server/read-save-handler.go new file mode 100644 index 0000000..fb94ba5 --- /dev/null +++ b/server/read-save-handler.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type ReadSaveRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams []string `json:"launchParams"` + } +} + +type ReadSaveRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +var readSaveEvent = "READ_SAVE_NAME" + +func ReadSaveHandler(conn *websocket.Conn, data []byte) { + var message ReadSaveRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(ReadSaveRes{ + Event: readSaveEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + config := ReadSaveName() + + conn.WriteJSON(ReadSaveRes{ + Event: readSaveEvent, + EventId: message.EventId, + Success: true, + Data: config, + }) +} diff --git a/server/restart-server-handler.go b/server/restart-server-handler.go new file mode 100644 index 0000000..eb3177c --- /dev/null +++ b/server/restart-server-handler.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type RestartServerRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams []string `json:"launchParams"` + } +} + +type RestartServerRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var restartServerEvent = "RESTART_SERVER" + +func RestartServerHandler(conn *websocket.Conn, data []byte) { + EmitServerStatus("RESTARTING", nil) + + var message RestartServerRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(RestartServerRes{ + Event: restartServerEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + err = servermanager.Restart() + if err != nil { + utils.Log("Error restarting the server: " + err.Error()) + EmitServerStatus("STOPPED", nil) + return + } + + EmitServerStatus("STARTED", nil) +} diff --git a/server/restore-backup-handler.go b/server/restore-backup-handler.go new file mode 100644 index 0000000..23e065d --- /dev/null +++ b/server/restore-backup-handler.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type RestoreBackupRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + Filename string `json:"backupFileName"` + } +} + +type RestoreBackupRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data []Backup `json:"data"` +} + +var restoreBackupEvent = "RESTORE_BACKUP" + +func RestoreBackupHandler(conn *websocket.Conn, data []byte) { + var message RestoreBackupRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(RestoreBackupRes{ + Event: restoreBackupEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + err = backupmanager.Restore(message.Data.Filename) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(RestoreBackupRes{ + Event: restoreBackupEvent, + EventId: message.EventId, + Success: false, + Error: err.Error(), + }) + return + } + + EmitServerStatus("STOPPED", nil) + + conn.WriteJSON(CreateBackupRes{ + Event: createBackupEvent, + EventId: message.EventId, + Success: true, + }) +} diff --git a/server/save-launch-params-handler.go b/server/save-launch-params-handler.go new file mode 100644 index 0000000..e8c766b --- /dev/null +++ b/server/save-launch-params-handler.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type SaveLaunchParamsRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams string `json:"launchParams"` + } +} + +type SaveLaunchParamsRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +var saveLaunchParamsEvent = "SAVE_LAUNCH_PARAMS" + +func SaveLaunchParamsHandler(conn *websocket.Conn, data []byte) { + var message SaveLaunchParamsRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(SaveLaunchParamsRes{ + Event: saveLaunchParamsEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + utils.Settings.General.LaunchParams = message.Data.LaunchParams + + err = utils.SaveSettings() + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(SaveLaunchParamsRes{ + Event: saveLaunchParamsEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + // this response is just to flag the client that the write was successful + // the emit below will send the new save name to all clients + conn.WriteJSON(SaveLaunchParamsRes{ + Event: saveLaunchParamsEvent, + EventId: message.EventId, + Success: true, + }) + + EmitLaunchParams(nil) +} diff --git a/server/server-manager.go b/server/server-manager.go new file mode 100644 index 0000000..eeb2aab --- /dev/null +++ b/server/server-manager.go @@ -0,0 +1,214 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "palworld-ds-gui-server/utils" + "time" + + "github.com/mitchellh/go-ps" +) + +type ServerManager struct { + cmd *exec.Cmd + serverCmd *exec.Cmd + serverPid int + launchParams []string +} + +func NewServerManager() *ServerManager { + return &ServerManager{} +} + +func (s *ServerManager) Init() { + proc, _ := utils.FindProcessByName(utils.Config.ServerProcessName) + + if proc != nil { + utils.Log("A server is already running, killing it...") + + err := utils.KillProcessByPid(proc.Pid()) + + if err != nil { + utils.Log("Error stopping server: " + err.Error()) + return + } + + utils.Log("Server killed successfully") + } + + if _, err := os.Stat(utils.Config.ServerPath); os.IsNotExist(err) { + utils.Log("Server directory not found, creating...") + utils.Log("If you already have a server, please place it in " + utils.Config.ServerPath) + os.Mkdir(utils.Config.ServerPath, 0755) + s.DownloadDedicatedServer() + } +} + +func (s *ServerManager) DownloadDedicatedServer() error { + utils.Log("Downloading dedicated server...") + + if s.IsRunning() { + utils.Log("Cannot update server while it's running. Stopping it...") + s.Stop() + } + + cmd := exec.Command(utils.Config.SteamCmdExe, + "+force_install_dir", utils.Config.ServerPath, + "+login", "anonymous", + "+app_update", utils.Config.AppId, "validate", + "+quit") + + cmd.Dir = utils.Config.SteamCmdPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + s.cmd = cmd + + err := cmd.Run() + if err != nil { + return err + } + + utils.Log("Server downloaded and updated successfully!") + + return nil +} + +func (s *ServerManager) IsRunning() bool { + if s.serverPid == 0 { + return false + } + + proc, _ := utils.FindProcessByPid(s.serverPid) + + return proc != nil +} + +func (s *ServerManager) MonitorServerProcess() { + for { + time.Sleep(4 * time.Second) + + // was killed via gui + if s.serverPid == 0 { + break + } + + proc, err := utils.FindProcessByPid(s.serverPid) + + if proc == nil || err != nil { + utils.Log("Server seems to have stopped (crashed?)") + EmitServerStatus("STOPPED", nil) + break + } + } +} + +func (s *ServerManager) Start() error { + utils.Log("Starting dedicated server...") + + s.serverCmd = exec.Command(utils.Config.ServerExe, s.launchParams...) + s.serverCmd.Dir = utils.Config.ServerPath + s.serverCmd.Stdout = os.Stdout + s.serverCmd.Stderr = os.Stderr + + err := s.serverCmd.Start() + if err != nil { + return err + } + + var attempts int = 10 + var proc ps.Process + + whileLoop := true + for whileLoop { + time.Sleep(1 * time.Second) + + proc, err = utils.FindProcessByName(utils.Config.ServerProcessName) + if err != nil { + continue + } + + if proc != nil { + whileLoop = false + } + + attempts-- + + if attempts <= 0 { + whileLoop = false + } + } + + if proc == nil { + return errors.New("server process not found") + } + + s.serverPid = proc.Pid() + utils.Log("Server started") + + go s.MonitorServerProcess() + + return nil +} + +func (s *ServerManager) Stop() error { + if !s.IsRunning() { + return nil + } + + utils.Log("Stopping dedicated server...") + + err := utils.KillProcessByPid(s.serverPid) + if err != nil { + return err + } + + if s.serverCmd != nil && s.serverCmd.Process != nil { + err := s.serverCmd.Process.Kill() + if err != nil { + return err + } + } + + utils.Log("Server stopped") + s.serverPid = 0 + + return nil +} + +func (s *ServerManager) Restart() error { + utils.Log("Restarting dedicated server...") + + err := s.Stop() + if err != nil { + return err + } + + err = s.Start() + if err != nil { + return err + } + + return nil +} + +func (s *ServerManager) Update() error { + err := s.DownloadDedicatedServer() + if err != nil { + return err + } + + return nil +} + +func (s *ServerManager) Dispose() { + if s.cmd != nil && s.cmd.Process != nil { + err := s.cmd.Process.Kill() + if err != nil { + utils.Log(err.Error()) + } + } + + s.Stop() + utils.Log("dedicated-server.go: Dispose()") +} diff --git a/server/server.json b/server/server.json new file mode 100644 index 0000000..43e690c --- /dev/null +++ b/server/server.json @@ -0,0 +1,7 @@ +{ + "companyName": "DiogoMartino", + "productName": "Palworld Dedicated Server GUI Server", + "productVersion": "1.0.0", + "copyright": "2024", + "comments": "https://github.com/diogomartino/palworld-ds-gui" +} diff --git a/server/start-backups-handler.go b/server/start-backups-handler.go new file mode 100644 index 0000000..f10763b --- /dev/null +++ b/server/start-backups-handler.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type StartBackupsRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + Interval int `json:"interval"` + KeepCount int `json:"keepCount"` + } +} + +type StartBackupsRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var startBackupsEvent = "START_BACKUPS" + +func StartBackupsHandler(conn *websocket.Conn, data []byte) { + var message StartBackupsRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(StartBackupsRes{ + Event: startBackupsEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + backupmanager.Start(message.Data.Interval, message.Data.KeepCount) + + conn.WriteJSON(StartBackupsRes{ + Event: startBackupsEvent, + EventId: message.EventId, + Success: true, + }) + + EmitBackupSettings(nil) +} diff --git a/server/start-server-handler.go b/server/start-server-handler.go new file mode 100644 index 0000000..617e17e --- /dev/null +++ b/server/start-server-handler.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type StartServerRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` +} + +type StartServerRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var startServerEvent = "START_SERVER" + +func StartServerHandler(conn *websocket.Conn, data []byte) { + EmitServerStatus("STARTING", nil) + + var message StartServerRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(StartServerRes{ + Event: startServerEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + err = servermanager.Start() + + if err != nil { + utils.Log(err.Error()) + EmitServerStatus("ERROR", nil) + } else { + EmitServerStatus("STARTED", nil) + } +} diff --git a/steamcmd/steamcmd.go b/server/steamcmd.go similarity index 50% rename from steamcmd/steamcmd.go rename to server/steamcmd.go index ff3d944..07c4905 100644 --- a/steamcmd/steamcmd.go +++ b/server/steamcmd.go @@ -1,44 +1,26 @@ -package steamcmd +package main import ( - "context" "os" - "palword-ds-gui/utils" + "palworld-ds-gui-server/utils" "path" "github.com/mholt/archiver/v3" - "github.com/wailsapp/wails/v2/pkg/runtime" ) type SteamCMD struct { } -var ctx context.Context -var currentConsoleId string = "STEAM_CMD" - func NewSteamCMD() *SteamCMD { return &SteamCMD{} } -func Print(message string) { - utils.PrintEx(ctx, message, currentConsoleId) -} - -func (s *SteamCMD) Init(srcCtx context.Context) { - ctx = srcCtx - - Print("Initializing SteamCMD...") - +func (s *SteamCMD) Init() { if _, err := os.Stat(utils.Config.SteamCmdPath); os.IsNotExist(err) { - Print("SteamCMD not found, downloading...") - runtime.EventsEmit(ctx, "SET_LOADING_STATUS", "INSTALLING_STEAMCMD") + utils.Log("SteamCMD not found, downloading...") os.Mkdir(utils.Config.SteamCmdPath, 0755) s.DownloadExecutable() - } else { - Print("SteamCMD is already installed") } - - Print("Done initializing SteamCMD") } func (s *SteamCMD) DownloadExecutable() { @@ -49,19 +31,19 @@ func (s *SteamCMD) DownloadExecutable() { panic(err) } - Print("Extracting SteamCMD...") + utils.Log("Extracting SteamCMD...") err = archiver.Unarchive(zipPath, utils.Config.SteamCmdPath) if err != nil { panic(err) } - Print("Removing zip file...") + utils.Log("Removing zip file...") err = os.Remove(zipPath) if err != nil { panic(err) } - Print("SteamCMD is ready") + utils.Log("SteamCMD downloaded and extracted successfully!") } diff --git a/server/stop-backups-handler.go b/server/stop-backups-handler.go new file mode 100644 index 0000000..292238a --- /dev/null +++ b/server/stop-backups-handler.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type StopBackupsRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + Interval int `json:"interval"` + KeepCount int `json:"keepCount"` + } +} + +type StopBackupsRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var stopBackupsEvent = "STOP_BACKUPS" + +func StopBackupsHandler(conn *websocket.Conn, data []byte) { + var message StopBackupsRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(StopBackupsRes{ + Event: stopBackupsEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + backupmanager.Stop() + + conn.WriteJSON(StopBackupsRes{ + Event: stopBackupsEvent, + EventId: message.EventId, + Success: true, + }) + + EmitBackupSettings(nil) +} diff --git a/server/stop-server-handler.go b/server/stop-server-handler.go new file mode 100644 index 0000000..bd168e2 --- /dev/null +++ b/server/stop-server-handler.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type StopServerRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams []string `json:"launchParams"` + } +} + +var stopServerEvent = "STOP_SERVER" + +func StopServerHandler(conn *websocket.Conn, data []byte) { + EmitServerStatus("STOPPING", nil) + + var message StopServerRequest + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(BaseResponse{ + Event: stopServerEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + err = servermanager.Stop() + + if err != nil { + utils.Log(err.Error()) + EmitServerStatus("ERROR", nil) + } else { + EmitServerStatus("STOPPED", nil) + } +} diff --git a/server/update-server-handler.go b/server/update-server-handler.go new file mode 100644 index 0000000..66dbb86 --- /dev/null +++ b/server/update-server-handler.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type UpdateServerRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + LaunchParams []string `json:"launchParams"` + } +} + +type UpdateServerRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` +} + +var updateServerEvent = "UPDATE_SERVER" + +func UpdateServerHandler(conn *websocket.Conn, data []byte) { + EmitServerStatus("UPDATING", nil) + + var message UpdateServerRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(UpdateServerRes{ + Event: updateServerEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + err = servermanager.Update() + if err != nil { + utils.Log("Error updating the server: " + err.Error()) + EmitServerStatus("STOPPED", nil) + return + } + + EmitServerStatus("STOPPED", nil) +} diff --git a/utils/utils.go b/server/utils/utils.go similarity index 50% rename from utils/utils.go rename to server/utils/utils.go index e84dd30..aab4144 100644 --- a/utils/utils.go +++ b/server/utils/utils.go @@ -1,9 +1,10 @@ package utils import ( - "context" + "flag" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -11,16 +12,12 @@ import ( "strings" "time" + "github.com/gorilla/websocket" "github.com/mitchellh/go-ps" - "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/tidwall/gjson" + "gopkg.in/ini.v1" ) -type ConsoleEntry struct { - Message string - Timestamp int64 - MsgType string -} - type AppConfig struct { SteamCmdPath string SteamCmdUrl string @@ -35,7 +32,9 @@ type AppConfig struct { ServerProcessName string BackupsPath string LogsPath string + PersistedSettingsPath string AppId string + ServerVersion string } var Config AppConfig = AppConfig{ @@ -51,40 +50,136 @@ var Config AppConfig = AppConfig{ LogsPath: filepath.Join(GetCurrentDir(), "logs.txt"), ServerProcessName: "PalServer-Win64-Test-Cmd.exe", BackupsPath: filepath.Join(GetCurrentDir(), "backups"), + PersistedSettingsPath: filepath.Join(GetCurrentDir(), "gui-server-settings.ini"), SteamCmdUrl: "https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", AppId: "2394010", + ServerVersion: "0.0.7", } -func GetCurrentDir() string { - ex, err := os.Executable() +type PersistedSettingsBackup struct { + Enabled bool `ini:"enabled"` + Interval float32 `ini:"interval"` + KeepCount int `ini:"keepCount"` +} + +type PersistedSettingsGeneral struct { + APIKey string `ini:"apiKey"` + LaunchParams string `ini:"launchParams"` +} + +type PersistedSettings struct { + General PersistedSettingsGeneral + Backup PersistedSettingsBackup +} + +var Settings PersistedSettings = PersistedSettings{ + General: PersistedSettingsGeneral{ + APIKey: "CHANGE_ME", + LaunchParams: "-useperfthreads -NoAsyncLoadingThread -UseMultithreadForDS", + }, + Backup: PersistedSettingsBackup{ + Enabled: false, + Interval: 1, + KeepCount: 24, + }, +} + +type LaunchParams struct { + ForceNewKey bool + ShowKey bool + Help bool + Port int +} +var Launch LaunchParams = LaunchParams{ + ForceNewKey: false, + ShowKey: false, + Help: false, + Port: 21577, +} + +var EmitConsoleLog func(message string, excludeClient *websocket.Conn) + +func Init(serverJSON string) { + Config.ServerVersion = gjson.Get(serverJSON, "productVersion").String() + + logsFile, logErr := os.OpenFile(Config.LogsPath, os.O_RDWR|os.O_CREATE, 0666) + if logErr != nil { + panic(logErr) + } + defer logsFile.Close() + + settingsFile, settingsErr := os.OpenFile(Config.PersistedSettingsPath, os.O_RDWR|os.O_CREATE, 0666) + if settingsErr != nil { + panic(settingsErr) + } + defer settingsFile.Close() + + err := LoadSettings() if err != nil { panic(err) } - dir := filepath.Dir(ex) + flag.BoolVar(&Launch.ForceNewKey, "newkey", false, "Generate a new API key") + flag.BoolVar(&Launch.ShowKey, "showkey", false, "Show the current API key") + flag.BoolVar(&Launch.Help, "help", false, "Show help") + flag.IntVar(&Launch.Port, "port", 21577, "Port to run the server on") - return dir + flag.Parse() + + LogToFile("utils.go: Init() - Palword Dedicated Server GUI v"+Config.ServerVersion, false) } -func DownloadFile(url string, path string) error { - resp, err := http.Get(url) +func LoadSettings() error { + cfg, err := ini.Load(Config.PersistedSettingsPath) if err != nil { - return err + return fmt.Errorf("failed to read file: %v", err) } - defer resp.Body.Close() - out, err := os.Create(path) + if err = cfg.Section("General").MapTo(&Settings.General); err != nil { + return fmt.Errorf("failed to map General section: %v", err) + } + if err = cfg.Section("Backup").MapTo(&Settings.Backup); err != nil { + return fmt.Errorf("failed to map Backup section: %v", err) + } + + return nil +} + +func SaveSettings() error { + cfg := ini.Empty() + + if err := cfg.Section("General").ReflectFrom(&Settings.General); err != nil { + return fmt.Errorf("failed to reflect General section: %v", err) + } + if err := cfg.Section("Backup").ReflectFrom(&Settings.Backup); err != nil { + return fmt.Errorf("failed to reflect Backup section: %v", err) + } + + if err := cfg.SaveTo(Config.PersistedSettingsPath); err != nil { + return fmt.Errorf("failed to save file: %v", err) + } + + return nil +} + +func GetCurrentDir() string { + ex, err := os.Executable() + if err != nil { - return err + panic(err) } - defer out.Close() - _, err = io.Copy(out, resp.Body) - return err + dir := filepath.Dir(ex) + + return dir } -func LogToFile(message string) { +func LogToFile(message string, print bool) { + if print { + println(message) + } + logsFile, err := os.OpenFile(Config.LogsPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { panic(err) @@ -97,15 +192,29 @@ func LogToFile(message string) { logsFile.WriteString(formatedMessage + "\n") } -func PrintEx(ctx context.Context, message string, consoleId string) { - consoleEntry := ConsoleEntry{ - Message: message, - Timestamp: time.Now().Unix(), - MsgType: "stdout", +func Log(message string) { + LogToFile(message, true) + + if EmitConsoleLog != nil { + EmitConsoleLog(message, nil) + } +} + +func DownloadFile(url string, path string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + out, err := os.Create(path) + if err != nil { + return err } + defer out.Close() - runtime.EventsEmit(ctx, "ADD_CONSOLE_ENTRY", consoleId, consoleEntry) - LogToFile(message) + _, err = io.Copy(out, resp.Body) + return err } func FindProcessByName(processName string) (ps.Process, error) { @@ -123,6 +232,20 @@ func FindProcessByName(processName string) (ps.Process, error) { return nil, fmt.Errorf("process with name %s not found", processName) } +func KillProcessByPid(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + + err = process.Kill() + if err != nil { + return err + } + + return nil +} + func FindProcessByPid(pid int) (ps.Process, error) { processes, err := ps.Processes() if err != nil { @@ -138,18 +261,31 @@ func FindProcessByPid(pid int) (ps.Process, error) { return nil, fmt.Errorf("process with pid %d not found", pid) } -func KillProcessByPid(pid int) error { - process, err := os.FindProcess(pid) +func GetOutboundIP() net.IP { + conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { - return err + return nil } + defer conn.Close() - err = process.Kill() + localAddr := conn.LocalAddr().(*net.UDPAddr) + + return localAddr.IP +} + +func GetExternalIPv4() (string, error) { + resp, err := http.Get("https://text.ipv4.wtfismyip.com/") if err != nil { - return err + return "", err } + defer resp.Body.Close() - return nil + ipBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(ipBytes)), nil } func OpenExplorerWithFile(folderPath, fileName string) error { diff --git a/server/websocket.go b/server/websocket.go new file mode 100644 index 0000000..6776053 --- /dev/null +++ b/server/websocket.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "palworld-ds-gui-server/utils" + "strings" + "sync" + + "github.com/gorilla/websocket" +) + +var ( + upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, + } + clients = make(map[*websocket.Conn]bool) + mutex = &sync.Mutex{} +) + +type BaseRequest struct { + Event string `json:"event"` +} + +type BaseResponse struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +func handleWebSocket(w http.ResponseWriter, r *http.Request) { + authToken := r.URL.Query().Get("auth") + + if authToken != utils.Settings.General.APIKey { + utils.Log(fmt.Sprintf("Unauthorized connection attempt from %s", r.RemoteAddr)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + utils.LogToFile(err.Error(), true) + return + } + + mutex.Lock() + clients[conn] = true + mutex.Unlock() + + defer func() { + mutex.Lock() + delete(clients, conn) + mutex.Unlock() + conn.Close() + utils.Log(fmt.Sprintf("%s disconnected from the GUI server", conn.RemoteAddr().String())) + }() + + utils.Log(fmt.Sprintf("%s connected to the GUI server", conn.RemoteAddr().String())) + + for { + messageType, p, err := conn.ReadMessage() + if err != nil { + utils.LogToFile(err.Error(), true) + break + } + + if messageType != websocket.TextMessage { + utils.LogToFile("Received non-text message", true) + continue + } + + var message BaseRequest + err = json.Unmarshal(p, &message) + if err != nil { + utils.LogToFile(err.Error(), true) + continue + } + + switch message.Event { + case startServerEvent: + StartServerHandler(conn, p) + case stopServerEvent: + StopServerHandler(conn, p) + case restartServerEvent: + RestartServerHandler(conn, p) + case readConfigEvent: + ReadConfigHandler(conn, p) + case readSaveEvent: + ReadSaveHandler(conn, p) + case writeSaveEvent: + WriteSaveHandler(conn, p) + case writeConfigEvent: + WriteConfigHandler(conn, p) + case clientInitEvent: + ClientInitHandler(conn, p) + case updateServerEvent: + UpdateServerHandler(conn, p) + case startBackupsEvent: + StartBackupsHandler(conn, p) + case stopBackupsEvent: + StopBackupsHandler(conn, p) + case createBackupEvent: + CreateBackupHandler(conn, p) + case getBackupsEvent: + GetBackupsHandler(conn, p) + case deleteBackupEvent: + DeleteBackupHandler(conn, p) + case restoreBackupEvent: + RestoreBackupHandler(conn, p) + case getBackupsConfigEvent: + GetBackupsConfigHandler(conn, p) + case saveLaunchParamsEvent: + SaveLaunchParamsHandler(conn, p) + default: + utils.LogToFile(fmt.Sprintf("Unknown event: %s", message.Event), true) + } + } +} + +func BroadcastJSON(v interface{}, exclude *websocket.Conn) { + mutex.Lock() + defer mutex.Unlock() + for client := range clients { + if client == exclude { + continue + } + + err := client.WriteJSON(v) + if err != nil { + utils.Log(err.Error()) + client.Close() + delete(clients, client) + } + } +} + +func EmitServerStatus(status string, exclude *websocket.Conn) { + BroadcastJSON(BaseResponse{ + Event: "SERVER_STATUS_CHANGED", + Data: status, + Success: true, + }, exclude) +} + +func EmitServerConfig(config string, exclude *websocket.Conn) { + BroadcastJSON(BaseResponse{ + Event: "SERVER_CONFIG_CHANGED", + Success: true, + Data: config, + }, exclude) +} + +func EmitBackupSettings(exclude *websocket.Conn) { + type BackupSettingsResponse struct { + Event string `json:"event"` + Success bool `json:"success"` + Data utils.PersistedSettingsBackup `json:"data"` + } + + BroadcastJSON(BackupSettingsResponse{ + Event: "BACKUP_SETTINGS_CHANGED", + Success: true, + Data: utils.Settings.Backup, + }, exclude) +} + +func EmitSaveName(name string, exclude *websocket.Conn) { + BroadcastJSON(BaseResponse{ + Event: "SERVER_SAVE_NAME_CHANGED", + Data: name, + Success: true, + }, exclude) +} + +func EmitLaunchParams(exclude *websocket.Conn) { + BroadcastJSON(BaseResponse{ + Event: "SERVER_SAVE_NAME_CHANGED", + Data: utils.Settings.General.LaunchParams, + Success: true, + }, exclude) +} + +func LogToClient(message string, conn *websocket.Conn) { + BroadcastJSON(BaseResponse{ + Event: "ADD_CONSOLE_ENTRY", + Data: strings.TrimSpace(message), + Success: true, + }, conn) +} diff --git a/server/write-config-handler.go b/server/write-config-handler.go new file mode 100644 index 0000000..13a3d66 --- /dev/null +++ b/server/write-config-handler.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type WriteConfigRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + NewConfig string `json:"config"` + } +} + +type WriteConfigRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +var writeConfigEvent = "WRITE_CONFIG" + +func WriteConfigHandler(conn *websocket.Conn, data []byte) { + var message WriteConfigRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(WriteConfigRes{ + Event: writeConfigEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + WriteConfig(message.Data.NewConfig) + + // this response is just to flag the client that the write was successful + // the emit below will send the new config to all clients + conn.WriteJSON(WriteConfigRes{ + Event: writeConfigEvent, + EventId: message.EventId, + Success: true, + }) + + EmitServerConfig(message.Data.NewConfig, nil) +} diff --git a/server/write-save-handler.go b/server/write-save-handler.go new file mode 100644 index 0000000..3fe1ad9 --- /dev/null +++ b/server/write-save-handler.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "palworld-ds-gui-server/utils" + + "github.com/gorilla/websocket" +) + +type WriteSaveRequest struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Data struct { + NewSaveName string `json:"saveName"` + } +} + +type WriteSaveRes struct { + Event string `json:"event"` + EventId string `json:"eventId"` + Success bool `json:"success"` + Error string `json:"error"` + Data string `json:"data"` +} + +var writeSaveEvent = "WRITE_SAVE_NAME" + +func WriteSaveHandler(conn *websocket.Conn, data []byte) { + var message WriteSaveRequest + + err := json.Unmarshal(data, &message) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(WriteSaveRes{ + Event: writeSaveEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + err = WriteSaveName(message.Data.NewSaveName) + if err != nil { + utils.Log(err.Error()) + conn.WriteJSON(WriteSaveRes{ + Event: writeSaveEvent, + EventId: message.EventId, + Success: false, + }) + return + } + + // this response is just to flag the client that the write was successful + // the emit below will send the new save name to all clients + conn.WriteJSON(WriteSaveRes{ + Event: writeSaveEvent, + EventId: message.EventId, + Success: true, + }) + + EmitSaveName(message.Data.NewSaveName, nil) +}