diff --git a/packages/client/.editorconfig b/packages/client/.editorconfig new file mode 100644 index 0000000..3dce414 --- /dev/null +++ b/packages/client/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/packages/client/.eslintignore b/packages/client/.eslintignore new file mode 100644 index 0000000..a6f34fe --- /dev/null +++ b/packages/client/.eslintignore @@ -0,0 +1,4 @@ +node_modules +dist +out +.gitignore diff --git a/packages/client/.eslintrc.cjs b/packages/client/.eslintrc.cjs new file mode 100644 index 0000000..63c9779 --- /dev/null +++ b/packages/client/.eslintrc.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + '@electron-toolkit/eslint-config-ts/recommended', + '@electron-toolkit/eslint-config-prettier' + ] +} diff --git a/packages/client/.gitignore b/packages/client/.gitignore new file mode 100644 index 0000000..42bd71b --- /dev/null +++ b/packages/client/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +out +.DS_Store +*.log* diff --git a/packages/client/.npmrc b/packages/client/.npmrc new file mode 100644 index 0000000..34862ff --- /dev/null +++ b/packages/client/.npmrc @@ -0,0 +1,2 @@ +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/packages/client/.prettierignore b/packages/client/.prettierignore new file mode 100644 index 0000000..9c6b791 --- /dev/null +++ b/packages/client/.prettierignore @@ -0,0 +1,6 @@ +out +dist +pnpm-lock.yaml +LICENSE.md +tsconfig.json +tsconfig.*.json diff --git a/packages/client/.prettierrc.yaml b/packages/client/.prettierrc.yaml new file mode 100644 index 0000000..35893b3 --- /dev/null +++ b/packages/client/.prettierrc.yaml @@ -0,0 +1,4 @@ +singleQuote: true +semi: false +printWidth: 100 +trailingComma: none diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..33396c5 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,34 @@ +# client-electron + +An Electron application with React and TypeScript + +## Recommended IDE Setup + +- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + +## Project Setup + +### Install + +```bash +$ npm install +``` + +### Development + +```bash +$ npm run dev +``` + +### Build + +```bash +# For windows +$ npm run build:win + +# For macOS +$ npm run build:mac + +# For Linux +$ npm run build:linux +``` diff --git a/packages/client/build/entitlements.mac.plist b/packages/client/build/entitlements.mac.plist new file mode 100644 index 0000000..38c887b --- /dev/null +++ b/packages/client/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/packages/client/build/icon.icns b/packages/client/build/icon.icns new file mode 100644 index 0000000..28644aa Binary files /dev/null and b/packages/client/build/icon.icns differ diff --git a/packages/client/build/icon.ico b/packages/client/build/icon.ico new file mode 100644 index 0000000..72c391e Binary files /dev/null and b/packages/client/build/icon.ico differ diff --git a/packages/client/build/icon.png b/packages/client/build/icon.png new file mode 100644 index 0000000..cf9e8b2 Binary files /dev/null and b/packages/client/build/icon.png differ diff --git a/packages/client/dev-app-update.yml b/packages/client/dev-app-update.yml new file mode 100644 index 0000000..3f8bc6d --- /dev/null +++ b/packages/client/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: generic +url: https://example.com/auto-updates +updaterCacheDirName: client-updater diff --git a/packages/client/electron-builder.yml b/packages/client/electron-builder.yml new file mode 100644 index 0000000..59dbfa6 --- /dev/null +++ b/packages/client/electron-builder.yml @@ -0,0 +1,43 @@ +appId: com.entityseven.com +productName: skymp-launcher +directories: + buildResources: build +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' + - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' +asarUnpack: + - resources/** +win: + executableName: skymp-launcher +nsis: + artifactName: ${name}-${version}-setup.${ext} + shortcutName: ${productName} + uninstallDisplayName: ${productName} + createDesktopShortcut: always +mac: + entitlementsInherit: build/entitlements.mac.plist + extendInfo: + - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. + - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. + notarize: false +dmg: + artifactName: ${name}-${version}.${ext} +linux: + target: + - AppImage + - snap + - deb + maintainer: entityseven.com + category: Utility +appImage: + artifactName: ${name}-${version}.${ext} +npmRebuild: false +publish: + provider: generic + url: https://example.com/auto-updates +electronDownload: + mirror: https://npmmirror.com/mirrors/electron/ diff --git a/packages/client/electron.vite.config.ts b/packages/client/electron.vite.config.ts new file mode 100644 index 0000000..d3b7509 --- /dev/null +++ b/packages/client/electron.vite.config.ts @@ -0,0 +1,21 @@ +import { resolve } from 'path' +import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin(), bytecodePlugin()] + }, + preload: { + plugins: [externalizeDepsPlugin(), bytecodePlugin()] + }, + renderer: { + resolve: { + alias: { + '@renderer': resolve('src/renderer/src') + } + }, + plugins: [react(), tailwindcss()] + } +}) diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..324cddc --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,51 @@ +{ + "name": "skymp-launcher", + "version": "1.0.0", + "description": "Client Launcher for SkyMP Platform.", + "main": "./out/main/index.js", + "author": "entityseven.com", + "homepage": "https://entityseven.com", + "scripts": { + "format": "prettier --write .", + "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", + "typecheck": "npm run typecheck:node && npm run typecheck:web", + "start": "electron-vite preview", + "dev": "electron-vite dev", + "build": "npm run typecheck && electron-vite build", + "postinstall": "electron-builder install-app-deps", + "build:unpack": "npm run build && electron-builder --dir", + "build:win": "npm run build && electron-builder --win", + "build:mac": "electron-vite build && electron-builder --mac", + "build:linux": "electron-vite build && electron-builder --linux" + }, + "dependencies": { + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^3.0.0", + "@tailwindcss/vite": "^4.0.4", + "electron-updater": "^6.1.7", + "axios": "^1.7.9", + "steam-game-path": "^2.3.0", + "tailwindcss": "^4.0.4" + }, + "devDependencies": { + "@electron-toolkit/eslint-config-prettier": "^2.0.0", + "@electron-toolkit/eslint-config-ts": "^2.0.0", + "@electron-toolkit/tsconfig": "^1.0.1", + "@types/node": "^20.14.8", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "electron": "^31.0.2", + "electron-builder": "^24.13.3", + "electron-vite": "^2.3.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.3", + "prettier": "^3.3.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.5.2", + "vite": "^5.3.1" + } +} diff --git a/packages/client/resources/icon.png b/packages/client/resources/icon.png new file mode 100644 index 0000000..cf9e8b2 Binary files /dev/null and b/packages/client/resources/icon.png differ diff --git a/packages/client/src/main/api/index.ts b/packages/client/src/main/api/index.ts new file mode 100644 index 0000000..bfbc6e5 --- /dev/null +++ b/packages/client/src/main/api/index.ts @@ -0,0 +1,14 @@ +import axios from 'axios' +import type { Manifest, Profile } from './types' + +export const api = axios.create({ + baseURL: 'http://localhost:5454' + '/api' +}) + +export const getProfiles = async (): Promise => { + return api.get('/profiles').then((res) => res.data) +} + +export const getManifest = async (id: string): Promise => { + return api.get(`/manifest/${id}`).then((res) => res.data) +} diff --git a/packages/client/src/main/api/types.ts b/packages/client/src/main/api/types.ts new file mode 100644 index 0000000..ff5d984 --- /dev/null +++ b/packages/client/src/main/api/types.ts @@ -0,0 +1,14 @@ +export interface Profile { + id: string + name: string + assetsFolder: string + enabled: boolean +} + +export interface ManifestFile { + path: string + hash: string + size: number +} + +export type Manifest = ManifestFile[] diff --git a/packages/client/src/main/events/index.ts b/packages/client/src/main/events/index.ts new file mode 100644 index 0000000..8f1eebf --- /dev/null +++ b/packages/client/src/main/events/index.ts @@ -0,0 +1,11 @@ +import { ipcMain } from 'electron' + +import { getProfiles } from '../api' + +import { Profile } from '../api/types' + +ipcMain.handle('getProfiles', requestServerProfiles) + +async function requestServerProfiles(): Promise { + return getProfiles() +} diff --git a/packages/client/src/main/index.ts b/packages/client/src/main/index.ts new file mode 100644 index 0000000..fd3ac79 --- /dev/null +++ b/packages/client/src/main/index.ts @@ -0,0 +1,85 @@ +import { app, shell, BrowserWindow, ipcMain } from 'electron' +import { join } from 'path' +import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import icon from '../../resources/icon.png?asset' +import { verifySkyrimInstallation } from './utils' + +import './events' + +export let mainWindow: BrowserWindow + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1280, + height: 720, + show: false, + resizable: false, + autoHideMenuBar: true, + fullscreenable: false, + titleBarStyle: 'hidden', + ...(process.platform === 'linux' ? { icon } : {}), + webPreferences: { + webSecurity: false, + autoplayPolicy: 'no-user-gesture-required', + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + + verifySkyrimInstallation() +} + +// This method will be called when Electron has finished +// initialization and is ready to create browser windows. +// Some APIs can only be used after this event occurs. +app.whenReady().then(() => { + // Set app user model id for windows + electronApp.setAppUserModelId('com.electron') + + // Default open or close DevTools by F12 in development + // and ignore CommandOrControl + R in production. + // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + // IPC test + ipcMain.on('ping', () => console.log('pong')) + + createWindow() + + app.on('activate', function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +// In this file you can include the rest of your app"s specific main process +// code. You can also put them in separate files and require them here. diff --git a/packages/client/src/main/utils/assets-utils.ts b/packages/client/src/main/utils/assets-utils.ts new file mode 100644 index 0000000..006d124 --- /dev/null +++ b/packages/client/src/main/utils/assets-utils.ts @@ -0,0 +1,46 @@ +import * as path from 'node:path' +import * as fs from 'node:fs' + +import { loadSettings, saveSettings } from './settings' + +function copyOriginalData(): void { + const settings = loadSettings() + + const originalDataPath = path.join(settings.SkyrimPath, 'Data') + const copyDataPath = path.join(settings.SkyrimPath, 'Data_Original') + + fs.cpSync(originalDataPath, copyDataPath, { recursive: true }) + fs.rmdirSync(originalDataPath, { recursive: true }) +} + +function isFirstInstallation(): boolean { + const settings = loadSettings() + return settings.currentServer.length === 0 +} + +function isServerDataExists(id: string): boolean { + const settings = loadSettings() + return fs.existsSync(path.join(settings.SkyrimPath, `Data_${id}`)) +} + +function loadServerData(id: string): void { + const settings = loadSettings() + + if (isServerDataExists(id)) { + const dataFolder = path.join(settings.SkyrimPath, `Data_${id}`) + fs.renameSync(dataFolder, path.join(settings.SkyrimPath, 'Data')) + + settings.currentServer = id + saveSettings(settings) + } else { + // download manifest and files + } +} + +function selectServer(id: string): void { + if (isFirstInstallation()) { + copyOriginalData() + } + + loadServerData(id) +} diff --git a/packages/client/src/main/utils/index.ts b/packages/client/src/main/utils/index.ts new file mode 100644 index 0000000..eca32dd --- /dev/null +++ b/packages/client/src/main/utils/index.ts @@ -0,0 +1 @@ +export * from './settings' diff --git a/packages/client/src/main/utils/manifest.ts b/packages/client/src/main/utils/manifest.ts new file mode 100644 index 0000000..8d3d3c8 --- /dev/null +++ b/packages/client/src/main/utils/manifest.ts @@ -0,0 +1,91 @@ +import path from 'node:path' +import * as fs from 'node:fs' + +import { app } from 'electron' +import { getManifest } from '../api' +import { Manifest } from '../api/types' + +export const manifestFolder = path.join(app.getPath('userData'), 'manifest') + +function extToJson(value: string): string { + return value + '.json' +} + +function isManifestExists(name: string): boolean { + return fs.existsSync(path.join(manifestFolder, name)) +} + +function readManifest(id: string): Manifest { + const manifest = fs.readFileSync(path.join(manifestFolder, extToJson(id)), 'utf-8') + return JSON.parse(manifest) as Manifest +} + +async function downloadNewManifest(id: string): Promise { + const manifest = isManifestExists(extToJson(id)) + const oldManifest = isManifestExists(extToJson(`${id}.old`)) + + if (manifest) { + if (oldManifest) { + const pathToOldManifest = path.join(manifestFolder, extToJson(`${id}.old`)) + fs.rmSync(pathToOldManifest) + } + + const pathToNewManifest = path.join(manifestFolder, extToJson(id)) + fs.renameSync(pathToNewManifest, path.join(manifestFolder, extToJson(`${id}.old`))) + } + + const downloadedManifest = await getManifest(id) + fs.writeFileSync(path.join(manifestFolder, extToJson(id)), JSON.stringify(downloadedManifest)) +} + +/** + * Compares old and new manifests to determine file changes + * @param {string} id - The manifest identifier + * @returns {Object} Object containing arrays of removed, new, and modified files + */ +function compareManifests(id: string): { removedFiles: Manifest; filesToDownload: Manifest } { + const newManifest = readManifest(id) + const oldManifest = readManifest(`${id}.old`) + + // Find removed files (present in old but not in new) + const removedFiles = oldManifest.filter( + (oldFile) => !newManifest.some((newFile) => newFile.path === oldFile.path) + ) + + // Find new and modified files + const filesToDownload = newManifest.filter((newFile) => { + const oldFile = oldManifest.find((old) => old.path === newFile.path) + + // If file doesn't exist in old manifest, it's new + if (!oldFile) { + return true + } + + // If file exists but hash or size is different, it needs to be redownloaded + return newFile.hash !== oldFile.hash || newFile.size !== oldFile.size + }) + + return { + removedFiles, + filesToDownload + } +} + +/** + * Process manifest changes and return detailed information + * @param {string} id - The manifest identifier + * @returns {Object} Detailed manifest comparison results + */ +function processManifestChanges(id: string): { removedFiles: Manifest; filesToDownload: Manifest } { + const { removedFiles, filesToDownload } = compareManifests(id) + + return { + removedFiles, + filesToDownload + // summary: { + // removedCount: removedFiles.length, + // downloadCount: filesToDownload.length, + // totalChanges: removedFiles.length + filesToDownload.length + // } + } +} diff --git a/packages/client/src/main/utils/settings.ts b/packages/client/src/main/utils/settings.ts new file mode 100644 index 0000000..096c958 --- /dev/null +++ b/packages/client/src/main/utils/settings.ts @@ -0,0 +1,96 @@ +import { app, dialog } from 'electron' +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { getGamePath } from 'steam-game-path' + +import { mainWindow } from '../index' + +export interface Settings { + SkyrimPath: string + lang: 'en' | 'ru' | 'uk' + autoUpdate: boolean + startOnBoot: boolean + currentServer: string +} + +const defaultSettings: Settings = { + autoUpdate: true, + startOnBoot: true, + lang: 'en', + SkyrimPath: '', + currentServer: '' +} + +const settingsFilePath = path.join(app.getPath('userData'), 'settings.json') + +export function loadSettings(): Settings { + try { + if (!fs.existsSync(settingsFilePath)) { + fs.writeFileSync(settingsFilePath, JSON.stringify(defaultSettings, null, 2)) + return defaultSettings + } + + const data = fs.readFileSync(settingsFilePath, 'utf-8') + return JSON.parse(data) as Settings + } catch (error) { + console.error('Error loading settings:', error) + return defaultSettings + } +} + +export function saveSettings(newSettings: Settings): void { + try { + fs.writeFileSync(settingsFilePath, JSON.stringify(newSettings, null, 2)) + } catch (error) { + console.error('Error saving settings:', error) + } +} + +function showSkyrimSelectorDialog(): string[] | undefined { + return dialog.showOpenDialogSync(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'SkyrimSE.exe', extensions: ['exe'] }] + }) +} + +function skyrimSelectorDialog(): boolean { + const filePaths = showSkyrimSelectorDialog() + + if (!filePaths || filePaths.length === 0) { + const settings = loadSettings() + settings.SkyrimPath = '' + saveSettings(settings) + return false + } + + const selectedFile = filePaths[0] + + if (path.basename(selectedFile).toLowerCase() !== 'skyrimse.exe') { + dialog.showErrorBox('Invalid file', 'Please select SkyrimSE.exe') + skyrimSelectorDialog() + return false + } + + const settings = loadSettings() + settings.SkyrimPath = path.dirname(selectedFile) + saveSettings(settings) + + return true +} + +export function verifySkyrimInstallation(): void { + const localSettings = loadSettings() + if (localSettings.SkyrimPath.length !== 0) return + + const foundedSSEPath = getGamePath(489830, true) + + if (foundedSSEPath && foundedSSEPath.game) { + localSettings.SkyrimPath = foundedSSEPath.game.path + saveSettings(localSettings) + return + } + + const result = skyrimSelectorDialog() + console.log(`Status of Skyrim selection: ${result}`) +} diff --git a/packages/client/src/preload/index.d.ts b/packages/client/src/preload/index.d.ts new file mode 100644 index 0000000..31b3124 --- /dev/null +++ b/packages/client/src/preload/index.d.ts @@ -0,0 +1,12 @@ +import { ElectronAPI } from '@electron-toolkit/preload' + +import type { Profile } from '../main/api/types' + +declare global { + interface Window { + electron: ElectronAPI + api: { + requestProfiles: () => Promise + } + } +} diff --git a/packages/client/src/preload/index.ts b/packages/client/src/preload/index.ts new file mode 100644 index 0000000..3a98b57 --- /dev/null +++ b/packages/client/src/preload/index.ts @@ -0,0 +1,27 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { electronAPI } from '@electron-toolkit/preload' + +import type { Profile } from '../main/api/types' + +// Custom APIs for renderer +const api = { + requestProfiles: async (): Promise => + ipcRenderer.invoke('getProfiles').then((profiles) => profiles) +} + +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', api) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI + // @ts-ignore (define in dts) + window.api = api +} diff --git a/packages/client/src/renderer/index.html b/packages/client/src/renderer/index.html new file mode 100644 index 0000000..e198e05 --- /dev/null +++ b/packages/client/src/renderer/index.html @@ -0,0 +1,17 @@ + + + + + Electron + + + + + +
+ + + diff --git a/packages/client/src/renderer/src/App.tsx b/packages/client/src/renderer/src/App.tsx new file mode 100644 index 0000000..870c8d1 --- /dev/null +++ b/packages/client/src/renderer/src/App.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react' +import video from './assets/background.mp4' + +function App(): JSX.Element { + const [selectedServer, setSelectedServer] = useState('') + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + + const [servers, setServers] = useState< + { id: string; name: string; assetsFolder: string; enabled: boolean }[] + >([]) + + useEffect(() => { + window.api.requestProfiles().then((profiles) => { + setServers(profiles) + }) + }, []) + + const handleWindowControl = (action: 'close' | 'minimize') => { + window.electron.ipcRenderer.send(action) + } + + return ( +
+ {/* Video Background */} +