Tracker Setup
This commit is contained in:
commit
e358ac36cb
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-react
|
||||||
|
dist-electron
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
nextSteps.txt
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Electron Course
|
||||||
|
|
||||||
|
This is the example code from the following freeCodeCamp course: <ToBeDefined></ToBeDefined>
|
||||||
|
|
||||||
|
It tackles setting up an electron app that displays system resource usages for your CPU, RAM and File-System
|
||||||
|
- using react and typescript
|
||||||
|
- with typesafe IPC communication
|
||||||
|
- with a hide-to-tray functionality
|
||||||
|
- with a custom menu-bar
|
||||||
|
- with a custom window-frame
|
||||||
|
|
||||||
|
It's tested using both E2E- (Playwright) and Unit-Tests (Vitest)
|
||||||
|
|
||||||
|
If you find run into any errors please post them as an issue in this Repository.
|
||||||
|
|
||||||
|
Have a nice day :)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { test, expect, _electron } from '@playwright/test';
|
||||||
|
|
||||||
|
let electronApp: Awaited<ReturnType<typeof _electron.launch>>;
|
||||||
|
let mainPage: Awaited<ReturnType<typeof electronApp.firstWindow>>;
|
||||||
|
|
||||||
|
async function waitForPreloadScript() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const electronBridge = await mainPage.evaluate(() => {
|
||||||
|
return (window as Window & { electron?: any }).electron;
|
||||||
|
});
|
||||||
|
if (electronBridge) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
electronApp = await _electron.launch({
|
||||||
|
args: ['.'],
|
||||||
|
env: { NODE_ENV: 'development' },
|
||||||
|
});
|
||||||
|
mainPage = await electronApp.firstWindow();
|
||||||
|
await waitForPreloadScript();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await electronApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom frame should minimize the mainWindow', async () => {
|
||||||
|
await mainPage.click('#minimize');
|
||||||
|
const isMinimized = await electronApp.evaluate((electron) => {
|
||||||
|
return electron.BrowserWindow.getAllWindows()[0].isMinimized();
|
||||||
|
});
|
||||||
|
expect(isMinimized).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a custom menu', async () => {
|
||||||
|
const menu = await electronApp.evaluate((electron) => {
|
||||||
|
return electron.Menu.getApplicationMenu();
|
||||||
|
});
|
||||||
|
expect(menu).not.toBeNull();
|
||||||
|
expect(menu?.items).toHaveLength(2);
|
||||||
|
expect(menu?.items[0].submenu?.items).toHaveLength(2);
|
||||||
|
expect(menu?.items[1].submenu?.items).toHaveLength(3);
|
||||||
|
expect(menu?.items[1].label).toBe('View');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"appId": "com.n-ziermann.electron-course",
|
||||||
|
"files": ["dist-electron", "dist-react"],
|
||||||
|
"extraResources": ["dist-electron/preload.cjs", "src/assets/**"],
|
||||||
|
"icon": "./desktopIcon.png",
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"category": "Utility"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": ["portable", "msi"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/ui/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"name": "electron-course",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm-run-all --parallel dev:react dev:electron",
|
||||||
|
"dev:react": "vite",
|
||||||
|
"dev:electron": "npm run transpile:electron && cross-env NODE_ENV=development electron .",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"transpile:electron": "tsc --project src/electron/tsconfig.json",
|
||||||
|
"dist:mac": "npm run transpile:electron && npm run build && electron-builder --mac --arm64",
|
||||||
|
"dist:win": "npm run transpile:electron && npm run build && electron-builder --win --x64",
|
||||||
|
"dist:linux": "npm run transpile:electron && npm run build && electron-builder --linux --x64",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:unit": "vitest src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"os-utils": "^0.0.14",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.12.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.47.2",
|
||||||
|
"@types/node": "^22.5.5",
|
||||||
|
"@types/os-utils": "^0.0.4",
|
||||||
|
"@types/react": "^18.3.8",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^32.1.2",
|
||||||
|
"electron-builder": "^25.0.5",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.7",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://127.0.0.1:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev:react',
|
||||||
|
url: 'http://localhost:5123',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { app, BrowserWindow, Menu, desktopCapturer } from 'electron';
|
||||||
|
import { ipcMainHandle, ipcMainOn, isDev } from './util.js';
|
||||||
|
import { getStaticData, pollResources } from './resourceManager.js';
|
||||||
|
import { getPreloadPath, getUIPath } from './pathResolver.js';
|
||||||
|
import { createTray } from './tray.js';
|
||||||
|
import { createMenu } from './menu.js';
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
app.on('ready', async () => {
|
||||||
|
const mainWindow = new BrowserWindow({
|
||||||
|
webPreferences: {
|
||||||
|
preload: getPreloadPath(),
|
||||||
|
},
|
||||||
|
// disables default system frame (dont do this if you want a proper working menu bar)
|
||||||
|
frame: process.platform === 'darwin' ? false : true,
|
||||||
|
});
|
||||||
|
if (isDev()) {
|
||||||
|
mainWindow.loadURL('http://localhost:5123');
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(getUIPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
pollResources(mainWindow);
|
||||||
|
|
||||||
|
ipcMainHandle('getStaticData', () => {
|
||||||
|
return getStaticData();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMainOn('sendFrameAction', (payload) => {
|
||||||
|
switch (payload) {
|
||||||
|
case 'CLOSE':
|
||||||
|
mainWindow.close();
|
||||||
|
break;
|
||||||
|
case 'MAXIMIZE':
|
||||||
|
mainWindow.maximize();
|
||||||
|
break;
|
||||||
|
case 'MINIMIZE':
|
||||||
|
mainWindow.minimize();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createTray(mainWindow);
|
||||||
|
handleCloseEvents(mainWindow);
|
||||||
|
createMenu(mainWindow);
|
||||||
|
await takeScreenshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function takeScreenshot() {
|
||||||
|
// const sources = await desktopCapturer.getSources({ types: ["screen"] });
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['window'], thumbnailSize: {
|
||||||
|
height: 768,
|
||||||
|
width: 1366
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture the first screen (you can choose others by filtering sources)
|
||||||
|
const screen = sources[0];
|
||||||
|
const image = screen.thumbnail.toPNG();
|
||||||
|
|
||||||
|
const folderPath = "C:\\Users\\Public\\vatracker\\screenshots";
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(folderPath)) {
|
||||||
|
fs.mkdirSync(folderPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(folderPath, `screenshot-${Date.now()}.png`);
|
||||||
|
fs.writeFileSync(filePath, image);
|
||||||
|
console.log(`Screenshot saved at: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseEvents(mainWindow: BrowserWindow) {
|
||||||
|
let willClose = false;
|
||||||
|
|
||||||
|
mainWindow.on('close', (e) => {
|
||||||
|
if (willClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
mainWindow.hide();
|
||||||
|
if (app.dock) {
|
||||||
|
app.dock.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
willClose = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('show', () => {
|
||||||
|
willClose = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { BrowserWindow, Menu, app } from 'electron';
|
||||||
|
import { ipcWebContentsSend, isDev } from './util.js';
|
||||||
|
|
||||||
|
export function createMenu(mainWindow: BrowserWindow) {
|
||||||
|
Menu.setApplicationMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: process.platform === 'darwin' ? undefined : 'App',
|
||||||
|
type: 'submenu',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: app.quit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DevTools',
|
||||||
|
click: () => mainWindow.webContents.openDevTools(),
|
||||||
|
visible: isDev(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
type: 'submenu',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'CPU',
|
||||||
|
click: () =>
|
||||||
|
ipcWebContentsSend('changeView', mainWindow.webContents, 'CPU'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RAM',
|
||||||
|
click: () =>
|
||||||
|
ipcWebContentsSend('changeView', mainWindow.webContents, 'RAM'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'STORAGE',
|
||||||
|
click: () =>
|
||||||
|
ipcWebContentsSend(
|
||||||
|
'changeView',
|
||||||
|
mainWindow.webContents,
|
||||||
|
'STORAGE'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { isDev } from './util.js';
|
||||||
|
|
||||||
|
export function getPreloadPath() {
|
||||||
|
return path.join(
|
||||||
|
app.getAppPath(),
|
||||||
|
isDev() ? '.' : '..',
|
||||||
|
'/dist-electron/preload.cjs'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUIPath() {
|
||||||
|
return path.join(app.getAppPath(), '/dist-react/index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetPath() {
|
||||||
|
return path.join(app.getAppPath(), isDev() ? '.' : '..', '/src/assets');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
const electron = require('electron');
|
||||||
|
|
||||||
|
electron.contextBridge.exposeInMainWorld('electron', {
|
||||||
|
subscribeStatistics: (callback) =>
|
||||||
|
ipcOn('statistics', (stats) => {
|
||||||
|
callback(stats);
|
||||||
|
}),
|
||||||
|
subscribeChangeView: (callback) =>
|
||||||
|
ipcOn('changeView', (view) => {
|
||||||
|
callback(view);
|
||||||
|
}),
|
||||||
|
getStaticData: () => ipcInvoke('getStaticData'),
|
||||||
|
sendFrameAction: (payload) => ipcSend('sendFrameAction', payload),
|
||||||
|
} satisfies Window['electron']);
|
||||||
|
|
||||||
|
function ipcInvoke<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key
|
||||||
|
): Promise<EventPayloadMapping[Key]> {
|
||||||
|
return electron.ipcRenderer.invoke(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipcOn<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key,
|
||||||
|
callback: (payload: EventPayloadMapping[Key]) => void
|
||||||
|
) {
|
||||||
|
const cb = (_: Electron.IpcRendererEvent, payload: any) => callback(payload);
|
||||||
|
electron.ipcRenderer.on(key, cb);
|
||||||
|
return () => electron.ipcRenderer.off(key, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipcSend<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key,
|
||||||
|
payload: EventPayloadMapping[Key]
|
||||||
|
) {
|
||||||
|
electron.ipcRenderer.send(key, payload);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import osUtils from 'os-utils';
|
||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
import { ipcWebContentsSend } from './util.js';
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 500;
|
||||||
|
|
||||||
|
export function pollResources(mainWindow: BrowserWindow) {
|
||||||
|
setInterval(async () => {
|
||||||
|
const cpuUsage = await getCpuUsage();
|
||||||
|
const ramUsage = getRamUsage();
|
||||||
|
const storageData = getStorageData();
|
||||||
|
ipcWebContentsSend('statistics', mainWindow.webContents, {
|
||||||
|
cpuUsage,
|
||||||
|
ramUsage,
|
||||||
|
storageUsage: storageData.usage,
|
||||||
|
});
|
||||||
|
}, POLLING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStaticData() {
|
||||||
|
const totalStorage = getStorageData().total;
|
||||||
|
const cpuModel = os.cpus()[0].model;
|
||||||
|
const totalMemoryGB = Math.floor(osUtils.totalmem() / 1024);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalStorage,
|
||||||
|
cpuModel,
|
||||||
|
totalMemoryGB,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCpuUsage(): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
osUtils.cpuUsage(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRamUsage() {
|
||||||
|
return 1 - osUtils.freememPercentage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorageData() {
|
||||||
|
// requires node 18
|
||||||
|
const stats = fs.statfsSync(process.platform === 'win32' ? 'C://' : '/');
|
||||||
|
const total = stats.bsize * stats.blocks;
|
||||||
|
const free = stats.bsize * stats.bfree;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: Math.floor(total / 1_000_000_000),
|
||||||
|
usage: 1 - free / total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { expect, Mock, test, vi } from 'vitest';
|
||||||
|
import { createTray } from './tray.js';
|
||||||
|
import { app, BrowserWindow, Menu } from 'electron';
|
||||||
|
|
||||||
|
vi.mock('electron', () => {
|
||||||
|
return {
|
||||||
|
Tray: vi.fn().mockReturnValue({
|
||||||
|
setContextMenu: vi.fn(),
|
||||||
|
}),
|
||||||
|
app: {
|
||||||
|
getAppPath: vi.fn().mockReturnValue('/'),
|
||||||
|
dock: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
quit: vi.fn(),
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
buildFromTemplate: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainWindow = {
|
||||||
|
show: vi.fn(),
|
||||||
|
} satisfies Partial<BrowserWindow> as any as BrowserWindow;
|
||||||
|
|
||||||
|
test('', () => {
|
||||||
|
createTray(mainWindow);
|
||||||
|
|
||||||
|
const calls = (Menu.buildFromTemplate as any as Mock).mock.calls;
|
||||||
|
const args = calls[0] as Parameters<typeof Menu.buildFromTemplate>;
|
||||||
|
const template = args[0];
|
||||||
|
expect(template).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(template[0].label).toEqual('Show');
|
||||||
|
template[0]?.click?.(null as any, null as any, null as any);
|
||||||
|
expect(mainWindow.show).toHaveBeenCalled();
|
||||||
|
expect(app.dock.show).toHaveBeenCalled();
|
||||||
|
|
||||||
|
template[1]?.click?.(null as any, null as any, null as any);
|
||||||
|
expect(app.quit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BrowserWindow, Menu, Tray, app } from 'electron';
|
||||||
|
import { getAssetPath } from './pathResolver.js';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function createTray(mainWindow: BrowserWindow) {
|
||||||
|
const tray = new Tray(
|
||||||
|
path.join(
|
||||||
|
getAssetPath(),
|
||||||
|
process.platform === 'darwin' ? 'trayIconTemplate.png' : 'trayIcon.png'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Show',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.show();
|
||||||
|
if (app.dock) {
|
||||||
|
app.dock.show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: () => app.quit(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// require strict types (null-save)
|
||||||
|
"strict": true,
|
||||||
|
// tell TypeScript to generate ESM Syntax
|
||||||
|
"target": "ESNext",
|
||||||
|
// tell TypeScript to require ESM Syntax as input (including .js file imports)
|
||||||
|
"module": "NodeNext",
|
||||||
|
// define where to put generated JS
|
||||||
|
"outDir": "../../dist-electron",
|
||||||
|
// ignore errors from dependencies
|
||||||
|
"skipLibCheck": true,
|
||||||
|
// add global types
|
||||||
|
"types": ["../../types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { ipcMain, WebContents, WebFrameMain } from 'electron';
|
||||||
|
import { getUIPath } from './pathResolver.js';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
|
export function isDev(): boolean {
|
||||||
|
return process.env.NODE_ENV === 'development';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipcMainHandle<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key,
|
||||||
|
handler: () => EventPayloadMapping[Key]
|
||||||
|
) {
|
||||||
|
ipcMain.handle(key, (event) => {
|
||||||
|
validateEventFrame(event.senderFrame);
|
||||||
|
return handler();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipcMainOn<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key,
|
||||||
|
handler: (payload: EventPayloadMapping[Key]) => void
|
||||||
|
) {
|
||||||
|
ipcMain.on(key, (event, payload) => {
|
||||||
|
validateEventFrame(event.senderFrame);
|
||||||
|
return handler(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipcWebContentsSend<Key extends keyof EventPayloadMapping>(
|
||||||
|
key: Key,
|
||||||
|
webContents: WebContents,
|
||||||
|
payload: EventPayloadMapping[Key]
|
||||||
|
) {
|
||||||
|
webContents.send(key, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEventFrame(frame: WebFrameMain) {
|
||||||
|
if (isDev() && new URL(frame.url).host === 'localhost:5123') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (frame.url !== pathToFileURL(getUIPath()).toString()) {
|
||||||
|
throw new Error('Malicious event');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
header {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #181818;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
header button {
|
||||||
|
all: unset;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin: 0.25rem;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minimize {
|
||||||
|
background-color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
#maximize {
|
||||||
|
background-color: lime;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16rem auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainGrid {
|
||||||
|
height: 10rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOption {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOption:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOptionTitle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOptionTitle :first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOptionTitle :nth-child(2) {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOptionChart {
|
||||||
|
height: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectOptionChart * {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Tracker</h1>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,24 @@
|
||||||
|
:root {
|
||||||
|
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["./types"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/electron"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
type Statistics = {
|
||||||
|
cpuUsage: number;
|
||||||
|
ramUsage: number;
|
||||||
|
storageUsage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StaticData = {
|
||||||
|
totalStorage: number;
|
||||||
|
cpuModel: string;
|
||||||
|
totalMemoryGB: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type View = 'CPU' | 'RAM' | 'STORAGE';
|
||||||
|
|
||||||
|
type FrameWindowAction = 'CLOSE' | 'MAXIMIZE' | 'MINIMIZE';
|
||||||
|
|
||||||
|
type EventPayloadMapping = {
|
||||||
|
statistics: Statistics;
|
||||||
|
getStaticData: StaticData;
|
||||||
|
changeView: View;
|
||||||
|
sendFrameAction: FrameWindowAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnsubscribeFunction = () => void;
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electron: {
|
||||||
|
subscribeStatistics: (
|
||||||
|
callback: (statistics: Statistics) => void
|
||||||
|
) => UnsubscribeFunction;
|
||||||
|
getStaticData: () => Promise<StaticData>;
|
||||||
|
subscribeChangeView: (
|
||||||
|
callback: (view: View) => void
|
||||||
|
) => UnsubscribeFunction;
|
||||||
|
sendFrameAction: (payload: FrameWindowAction) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-react',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5123,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue