From 4f58aa58f2a9b8a20d3821926f09c063425d8991 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 22:42:30 +0300 Subject: [PATCH] Register four IPC channels nobody registered, and guard the exit (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error invoking remote method 'rbac:list-roles': No handler registered for 'rbac:list-roles' `rbac:list-roles`, `rbac:upsert-role`, `rbac:delete-role` and `web:user-roles` were declared in ipc.ts, exposed by the preload and called by the panel — and none of them had a handler. Every one has answered "no handler registered" since the day it was written, so named roles have never worked at all. It stayed hidden because the panel loaded them inside a chain that swallowed the rejection, which is also why the API keys after them never appeared (#153). Fixing that chain in the last release is what made this visible, which is the whole argument for surfacing errors instead of eating them. The class of bug now has a test: every channel the PRELOAD exposes must be registered in `register.ts`. It found `web:user-roles` immediately — a second one I had not been told about. That test was vacuous twice before it worked, and both times only breaking it on purpose showed that: Commenting the handler out left `IPC.rbacList` sitting in the comment, the scan found it, and the gate stayed GREEN over a channel that answers nothing. Comments are stripped first now. Then it flagged three channels that were fine, because they are registered as `H(\n IPC.x,` across two lines and the pattern wanted them on one. --- MSMS no longer closes silently while a server is running. It names the servers that are up and asks. Confirmed, it stops them the way an operator would: `immediate` is gone, so the configured countdown is broadcast to players and the world gets time to save. Anything still up after 45 seconds is killed rather than left orphaned holding the world files. Asked on the window's own close as well as on before-quit — a dialog that appears after the window has already gone reads as the app having crashed and then argued about it. --- The API key panel now links to the route reference this install serves itself — so it always matches the running version — and to the repository for the written documentation and the source. Both surfaces, desktop and web. README updated: version, the new shutdown behaviour, touch on the map, named roles, and the map page taking the site's colours. Verified: 12/12 gates. Removing the handler again fails with "the preload calls rbacList and nothing registers a handler". --- README.md | 26 +++++- package.json | 2 +- src/main/index.ts | 103 +++++++++++++++++++++++- src/main/ipc/register.ts | 32 ++++++++ src/main/smoke.ts | 44 ++++++++++ src/main/web/panelHtml.ts | 11 ++- src/renderer/src/locales/en.ts | 2 + src/renderer/src/locales/tr.ts | 2 + src/renderer/src/views/WebPanelView.tsx | 21 ++++- src/shared/apiUsage.ts | 9 ++- 10 files changed, 240 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 08f216f..4da5687 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ A Turkish project summary is available near the end of this README: [Türkçe Ö ## 📌 Project Status -**Current package version:** `0.2.0` +**Current package version:** `0.2.3` MSMS is under active development, and the whole of it now runs: the desktop manager, the **admin web panel**, the **public website with its store and @@ -622,7 +622,10 @@ per-server permissions. JavaScript and Python samples built against your own install's address. A key can be **disabled** — reversibly, unlike revoking — or deleted from the list. - **A documented REST surface** at `/api/v1`, with an OpenAPI document and a - human-readable reference the app serves itself. + human-readable reference **the app serves itself**, so it always matches the + version you are running rather than whatever the docs said last release. Both + the desktop app and the panel link to it from the key list, next to the code + samples. - **Audit log** of every action, with the actor, the source and the IP. ### Secure defaults @@ -643,6 +646,15 @@ Treat panel tokens and API keys as secrets. --- +### Closing MSMS with a server running + +It refuses, and says which servers are up. Confirm and it stops them the way you +would — the configured countdown is broadcast to players, the world is saved, +and anything still up after 45 seconds is killed rather than left orphaned +holding the world files. + +--- + ## 🛒 Store & Economy A server currency and shop that players use from the public website. @@ -690,7 +702,9 @@ a firewall rule rather than with trust. note; hovering or clicking it shows them. Created by selecting chunks on the map, by typing coordinates, or over the API. - **The same map engine** as the panel and the website, so all four surfaces - draw the same world. + draw the same world — and **your website's colours**, so it looks like part of + the same server rather than a different product. +- **Touch:** one finger pans, two pinch, and a tap opens an area's note. Terrain is read from the server's own region files. MSMS never *generates* world — a map that could grow a world by being panned would be a map that can @@ -751,6 +765,9 @@ fill a disk. | Fullscreen map page (own listener) | ✅ | | Named chunk areas | ✅ | | Item icons + block colours from the client jar | ✅ | +| Named roles, assigned per server | ✅ | +| Touch / mobile map (pan, pinch, tap) | ✅ | +| Guarded shutdown while a server runs | ✅ | | MSMS Bridge plugin | ✅ builds, lightly field-tested | | Full visual website/CMS builder | 🗺️ Planned / evolving | @@ -1490,6 +1507,9 @@ No. - Kendi portunda **tam ekran harita sayfası** - **Adlandırılmış chunk alanları** — renk, ad ve not; arayüzden veya API ile - Item görselleri ve harita renkleri **Mojang'ın kendi client jar'ından** +- Sunucu başlıkken kapatmaya karşı **koruma** — onaylarsan geri sayımla düzgün durdurur +- Haritada **dokunmatik** — tek parmak kaydırma, iki parmak yakınlaştırma +- Sunucu bazlı **adlandırılmış roller** ### Taşınabilir çalışma mantığı diff --git a/package.json b/package.json index 7b4c3fc..8f574e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msms", - "version": "0.2.2", + "version": "0.2.3", "description": "Minecraft Server Management System — portable, bilingual (EN/TR) desktop control panel for Minecraft servers.", "author": "CaYatur", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index d90ea90..f1f4c08 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,10 @@ -import { app, BrowserWindow, shell, Menu, session } from 'electron' +import { app, BrowserWindow, shell, Menu, session, dialog } from 'electron' import { join } from 'node:path' import { existsSync } from 'node:fs' import { loadConfig, getConfig } from './config' import { registerIpc } from './ipc/register' import { processManager } from './core/processManager' +import { listServers } from './core/serverRegistry' import { initScheduler, stopAllJobs } from './core/scheduler' import { initWebServer, stopWebServer } from './web/server' import { initEconomy } from './store/economy' @@ -41,6 +42,89 @@ let splash: BrowserWindow | null = null let splashShownAt = 0 let cleanupDone = false + +/** + * Refuse to close while a server is running, and say why. + * + * Quitting used to stop every server with `immediate: true` — no countdown, no + * warning, no chance to say no. From the operator's side that is the app + * closing and their players being dropped without a word; from a player's side + * it is the server vanishing mid-sentence. + * + * So: ask first, and then stop properly. `immediate` is gone — the configured + * countdown runs, which broadcasts to players and gives the world time to save. + * A server that will not go down in time is killed rather than left orphaned, + * because the alternative is a Java process holding the world files after MSMS + * has exited. + */ +let quitConfirmed = false + +function runningServerNames(): string[] { + return listServers() + .filter((s) => processManager.isRunning(s.id)) + .map((s) => s.name) +} + +async function confirmQuit(parent?: BrowserWindow): Promise { + if (quitConfirmed) return true + const names = runningServerNames() + if (!names.length) return true + const tr = getConfig().language === 'tr' + const list = names.join(', ') + const opts = { + type: 'warning' as const, + buttons: tr ? ['Sunucuları durdur ve kapat', 'Vazgeç'] : ['Stop servers and quit', 'Cancel'], + defaultId: 1, + cancelId: 1, + title: tr ? 'Sunucu hâlâ çalışıyor' : 'A server is still running', + message: tr + ? names.length === 1 + ? `"${list}" hâlâ çalışıyor.` + : `${names.length} sunucu hâlâ çalışıyor: ${list}` + : names.length === 1 + ? `"${list}" is still running.` + : `${names.length} servers are still running: ${list}`, + detail: tr + ? 'Kapatmadan önce durdurulacaklar. Oyunculara geri sayım duyurulur ve dünya kaydedilir; bu birkaç saniye sürebilir.' + : 'They will be stopped before MSMS exits. Players get the countdown and the world is saved, which can take a few seconds.' + } + const r = parent + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts) + if (r.response !== 0) return false + quitConfirmed = true + return true +} + +/** Stop everything the way an operator would, then give up on stragglers. */ +async function shutdownServers(): Promise { + const running = listServers().filter((s) => processManager.isRunning(s.id)) + if (!running.length) return + log.info(`Shutting down — stopping ${running.length} running server(s) with the usual countdown…`) + await Promise.all( + running.map(async (s) => { + try { + // A ceiling, so one wedged server cannot keep the app alive forever. + // Whatever is still up after it is killed rather than orphaned. + await Promise.race([ + processManager.stop(s.id), + new Promise((r) => setTimeout(r, 45_000)) + ]) + } catch (e) { + log.warn(`Stopping ${s.name} failed:`, e) + } + if (processManager.isRunning(s.id)) { + log.warn(`${s.name} did not stop in time; killing it`) + try { + await processManager.kill(s.id) + } catch { + /* nothing left to try */ + } + } + }) + ) +} + function createSplash(): void { splash = new BrowserWindow({ width: 440, @@ -103,6 +187,18 @@ function createWindow(): void { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + mainWindow.on('close', (e) => { + // Asked HERE as well as on before-quit: closing the window is the usual way + // out, and a dialog that appears after the window has already gone reads as + // the app having crashed and then argued about it. + if (quitConfirmed || cleanupDone) return + if (!runningServerNames().length) return + e.preventDefault() + void confirmQuit(mainWindow ?? undefined).then((ok) => { + if (ok) mainWindow?.close() + }) + }) + mainWindow.on('closed', () => { mainWindow = null }) @@ -306,11 +402,12 @@ if (!gotLock) { app.on('before-quit', async (e) => { if (cleanupDone) return e.preventDefault() - log.info('Shutting down — stopping running servers…') + // Every other way out — the menu, a signal, the taskbar — lands here. + if (!(await confirmQuit(mainWindow ?? undefined))) return try { stopAllJobs() stopWebServer() - await processManager.stopAll() + await shutdownServers() flushMetrics() } catch (err) { log.error('Error during shutdown:', err) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index ea2c416..ac544de 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -447,6 +447,19 @@ export function registerIpc(): void { ) H(IPC.webUserDelete, (_e, id: string) => auth.deleteUser(id)) H(IPC.webUserPerms, (_e, id: string, perms: Record) => auth.setUserPerms(id, perms)) + // Assigning named roles, which the panel has always offered and which has + // never reached the main process — same omission as the rbac channels. + H(IPC.webUserRoles, (_e, id: string, roleMap: Record) => { + const username = auth.setUserRoles(id, roleMap) + audit.record({ + source: 'panel', + action: 'user.roles', + actor: 'operator', + target: username, + detail: Object.keys(roleMap ?? {}).length + ' servers' + }) + return username + }) H(IPC.webUserAudit, (_e, id: string, canAudit: boolean) => { // Granting/revoking sight of the personal-data audit log is itself audited. const username = auth.setUserAudit(id, !!canAudit) @@ -461,6 +474,25 @@ export function registerIpc(): void { H(IPC.webUserPassword, (_e, id: string, password: string) => auth.setUserPassword(id, password)) H(IPC.webUserMc, (_e, id: string, mcName: string) => auth.setUserMc(id, mcName)) + // --- named roles (#28) --- + // + // Declared in `ipc.ts`, exposed in the preload, called by the panel — and + // never registered here, so every one of them answered "No handler + // registered for 'rbac:list-roles'" from the day they were written. It went + // unnoticed because the view loaded them inside a chain that swallowed the + // rejection, which is also why the API keys after them never appeared (#153). + H(IPC.rbacList, () => roles.listRoles()) + H(IPC.rbacUpsert, (_e, role: Partial & { id?: string }) => { + const r = roles.upsertRole(role) + audit.record({ source: 'panel', action: 'role.upsert', actor: 'operator', target: r.name }) + return r + }) + H(IPC.rbacDelete, (_e, roleId: string) => { + const gone = roles.listRoles().find((r) => r.id === roleId) + roles.deleteRole(roleId) + audit.record({ source: 'panel', action: 'role.delete', actor: 'operator', target: gone?.name ?? roleId }) + }) + // --- API keys (#48). Desktop is the owner console, so no scope gate here; // the HTTP surface has its own owner check. Issue/revoke are audited on both // paths, because "who minted this credential" is the whole question later. diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 9a918ef..0467ecb 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -2241,6 +2241,50 @@ export async function runWorldsSmoke(): Promise { rmSync(emptyZip, { force: true }) console.log('WORLDS-SMOKE: import refuses zip-slip and worldless archives, cleans up after itself') + // --- 11z. every IPC channel the preload exposes must have a handler ----- + // + // `rbac:list-roles`, `rbac:upsert-role` and `rbac:delete-role` were + // declared in ipc.ts, exposed by the preload and called by the panel — and + // never registered. Every one answered "No handler registered for + // 'rbac:list-roles'" from the day they were written, and nothing noticed + // because the caller swallowed the rejection (#153). + // + // Read out of the SOURCE rather than from ipcMain, because the smoke + // registers handlers itself and asking the live process would only prove + // that this run wired them up. + { + const ipcSrc = readFileSync(join(process.cwd(), 'src', 'shared', 'ipc.ts'), 'utf-8') + const regSrc = readFileSync(join(process.cwd(), 'src', 'main', 'ipc', 'register.ts'), 'utf-8') + const preSrc = readFileSync(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf-8') + + // `name: 'channel:string',` inside the channel table. + const declared = [...ipcSrc.matchAll(/^\s{2}(\w+):\s*'([a-z0-9:-]+)',?$/gim)].map((m) => m[1]) + if (declared.length < 40) return fail('only found ' + declared.length + ' IPC channels; the scan is wrong') + + // COMMENTS STRIPPED FIRST. Without this the check is vacuous: commenting + // a handler out leaves `IPC.rbacList` sitting in the comment, the scan + // finds it, and the test stays green over a channel that answers "no + // handler registered". Proved by doing exactly that. + const code = regSrc + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + + const missing: string[] = [] + for (const key of declared) { + // Only channels the renderer can actually reach are worth this: a + // channel declared and used nowhere is dead code, not a broken call. + if (!preSrc.includes('IPC.' + key)) continue + // The registration itself, not a mention of the name — and tolerant of + // the multi-line form several handlers use, where the channel sits on + // its own line under the call. + if (!new RegExp('H\\(\\s*IPC\\.' + key + '\\b').test(code)) missing.push(key) + } + if (missing.length) { + return fail('the preload calls ' + missing.join(', ') + ' and nothing registers a handler') + } + console.log('SMOKE: every IPC channel the preload exposes has a handler') + } + // --- 12a. a region parse must not freeze the process (#151) ------------- { // A real region file: 1024 slots, each a deflated NBT compound. The diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index 7b3ba11..378d753 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -4,7 +4,7 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi' import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi' import { avatarUrl } from '@shared/profile' import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons' -import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage' +import { usageSamples, API_KEY_HEADER, USAGE_NOTES, REPO_URL } from '@shared/apiUsage' import { API_PREFIX } from '@shared/apiSurface' import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' import { AREA_COLOURS } from '@shared/chunkAreas' @@ -1103,6 +1103,7 @@ var avatarUrl=${avatarUrl.toString()}; var API_PREFIX=${JSON.stringify(API_PREFIX)}; var API_KEY_HEADER=${JSON.stringify(API_KEY_HEADER)}; var API_USAGE_NOTES=${JSON.stringify(USAGE_NOTES)}; +var API_REPO_URL=${JSON.stringify(REPO_URL)}; var apiUsageSamples=${usageSamples.toString()}; var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)}; var MAP_ICONS=STRUCTURE_ICONS; @@ -1389,7 +1390,13 @@ function showKeyUsage(id){ API_USAGE_NOTES.map(function(n){return esc(n)}).join('
')+''+ s.map(function(x){return '
'+esc(x.lang)+''+ '
'+esc(x.code)+'
'}).join('')} + 'padding:9px;border-radius:8px;margin:4px 0 0">'+esc(x.code)+''}).join('')+ + /* Where to go next: the reference this install serves itself, so it always + matches the running version, and the repository for the written docs. */ + ''} function createKey(){var label=document.getElementById('kLabel').value.trim();if(!label){alert('Give the key a label.');return} var scopes=KEY_SCOPES.filter(function(s){return kScopeSel[s]}); var all=document.getElementById('kAll').checked; diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index 2d6d082..ab46c48 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -975,6 +975,8 @@ export default { keyNoServers: 'no servers', noKeys: 'No API keys yet.', noServers: 'No servers yet.', + apiDocs: 'Route reference', + apiRepo: 'Documentation & source', apiOrigins: 'Browser origins allowed to call the API', apiOriginsHint: 'One origin per line, e.g. https://dash.example.com. Empty means no browser page may call the API from another site — which is the right setting unless you are building one. There is no wildcard.', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index ed138f9..07ec2a0 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -980,6 +980,8 @@ const tr: typeof en = { keyNoServers: 'sunucu yok', noKeys: 'Henüz API anahtarı yok.', noServers: 'Henüz sunucu yok.', + apiDocs: 'Rota referansı', + apiRepo: 'Dokümantasyon ve kaynak', apiOrigins: "API'yi çağırabilecek tarayıcı kaynakları", apiOriginsHint: 'Her satıra bir kaynak, örn. https://panel.ornek.com. Boş bırakmak, hiçbir tarayıcı sayfasının API\'yi başka bir siteden çağıramayacağı anlamına gelir — böyle bir şey geliştirmiyorsan doğru ayar budur. Joker karakter yoktur.', diff --git a/src/renderer/src/views/WebPanelView.tsx b/src/renderer/src/views/WebPanelView.tsx index 86721d8..4acb630 100644 --- a/src/renderer/src/views/WebPanelView.tsx +++ b/src/renderer/src/views/WebPanelView.tsx @@ -22,7 +22,7 @@ import { effectiveScopes } from '@shared/rbac' import { isKeyUsable } from '@shared/apikeys' import { MAP_PAGE_DEFAULTS } from '@shared/mapPage' import type { MapPageConfig, MapPageAccess } from '@shared/mapPage' -import { usageSamples, USAGE_NOTES } from '@shared/apiUsage' +import { usageSamples, USAGE_NOTES, REPO_URL } from '@shared/apiUsage' import type { RoleDef } from '@shared/rbac' import type { ApiKeyView, KeyServers } from '@shared/apikeys' import type { Scope, WebRole, WebStatus, WebUserView } from '@shared/web' @@ -779,6 +779,25 @@ export function WebPanelView(): JSX.Element { ))} + {/* Where to go next. The reference at /docs is served by this + install so it always matches the running version; the repo + is where the written documentation and the source are. */} +
+ + +
)} diff --git a/src/shared/apiUsage.ts b/src/shared/apiUsage.ts index 4ac6022..bf0b383 100644 --- a/src/shared/apiUsage.ts +++ b/src/shared/apiUsage.ts @@ -107,8 +107,12 @@ export function usageSamples(opts: { ] } +/** Where the written documentation lives, for the "learn more" link. */ +export const DOCS_URL = 'https://github.com/CaYatur/MinecraftServerManagementSystem#-web-panel' +export const REPO_URL = 'https://github.com/CaYatur/MinecraftServerManagementSystem' + /** - * The three sentences an operator needs before any of the above makes sense. + * The four sentences an operator needs before any of the above makes sense. * * A plain value, unlike `usageSamples` — it reaches the pages as JSON, so it may * refer to the shared constants directly. @@ -116,5 +120,6 @@ export function usageSamples(opts: { export const USAGE_NOTES = [ 'Send the key in the ' + API_KEY_HEADER + ' header. It is never accepted in a query string, where it would end up in server logs and browser history.', 'A key carries scopes, never a role: 401 means the key is wrong, disabled or expired; 403 means it is a good key without the scope that route needs.', - 'The full route list, with the scope each one requires, is at ' + API_PREFIX + '/docs.' + 'The full route list, with the scope each one requires, is at ' + API_PREFIX + '/docs — served by this install, so it always matches the version you are running.', + 'Machine-readable: ' + API_PREFIX + '/openapi.json. Written documentation and the source: ' + REPO_URL + '.' ]