@thibautrey/chatons-extension-minecraft-servers 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/chaton.extension.json +152 -0
- package/handler.js +473 -0
- package/icon.svg +9 -0
- package/index.html +178 -0
- package/index.js +420 -0
- package/package.json +7 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "@thibautrey/chatons-extension-minecraft-servers",
|
|
3
|
+
"name": "Minecraft Servers",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Manage and inspect a list of Minecraft servers from Chatons.",
|
|
6
|
+
"icon": "icon.svg",
|
|
7
|
+
"capabilities": [
|
|
8
|
+
"ui.menu",
|
|
9
|
+
"ui.mainView",
|
|
10
|
+
"llm.tools",
|
|
11
|
+
"storage.kv"
|
|
12
|
+
],
|
|
13
|
+
"ui": {
|
|
14
|
+
"menuItems": [
|
|
15
|
+
{
|
|
16
|
+
"id": "minecraft-servers.menu",
|
|
17
|
+
"label": "Minecraft Servers",
|
|
18
|
+
"icon": "Blocks",
|
|
19
|
+
"location": "sidebar",
|
|
20
|
+
"order": 26,
|
|
21
|
+
"openMainView": "minecraft-servers.main"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"mainViews": [
|
|
25
|
+
{
|
|
26
|
+
"viewId": "minecraft-servers.main",
|
|
27
|
+
"title": "Minecraft Servers",
|
|
28
|
+
"webviewUrl": "chaton-extension://@thibautrey/chatons-extension-minecraft-servers/index.html",
|
|
29
|
+
"initialRoute": "/"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"apis": {
|
|
34
|
+
"exposes": [
|
|
35
|
+
{ "name": "minecraft.servers.list", "version": "1.0.0" },
|
|
36
|
+
{ "name": "minecraft.servers.add", "version": "1.0.0" },
|
|
37
|
+
{ "name": "minecraft.servers.update", "version": "1.0.0" },
|
|
38
|
+
{ "name": "minecraft.servers.remove", "version": "1.0.0" },
|
|
39
|
+
{ "name": "minecraft.servers.status", "version": "1.0.0" },
|
|
40
|
+
{ "name": "minecraft.servers.players", "version": "1.0.0" }
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"llm": {
|
|
44
|
+
"tools": [
|
|
45
|
+
{
|
|
46
|
+
"name": "minecraft_servers_list",
|
|
47
|
+
"label": "List Minecraft servers",
|
|
48
|
+
"description": "List the configured Minecraft servers with their saved metadata.",
|
|
49
|
+
"promptSnippet": "List configured Minecraft servers.",
|
|
50
|
+
"parameters": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"includeStatus": {
|
|
54
|
+
"type": "boolean",
|
|
55
|
+
"description": "When true, also fetch live public status for each server."
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "minecraft_servers_add",
|
|
62
|
+
"label": "Add Minecraft server",
|
|
63
|
+
"description": "Add a Minecraft server to the saved list.",
|
|
64
|
+
"promptSnippet": "Add a Minecraft server to the saved list.",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"name": { "type": "string", "description": "Display name for the server." },
|
|
69
|
+
"host": { "type": "string", "description": "Hostname or IP address." },
|
|
70
|
+
"port": { "type": "number", "description": "Server port. Defaults to 25565 for Java or 19132 for Bedrock." },
|
|
71
|
+
"edition": { "type": "string", "description": "Edition: java, bedrock, or auto." },
|
|
72
|
+
"notes": { "type": "string", "description": "Optional notes." },
|
|
73
|
+
"tags": {
|
|
74
|
+
"type": "array",
|
|
75
|
+
"items": { "type": "string" },
|
|
76
|
+
"description": "Optional tags."
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"required": ["name", "host"]
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "minecraft_servers_update",
|
|
84
|
+
"label": "Update Minecraft server",
|
|
85
|
+
"description": "Update an existing saved Minecraft server by id, name, or host.",
|
|
86
|
+
"promptSnippet": "Update a saved Minecraft server.",
|
|
87
|
+
"parameters": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"id": { "type": "string", "description": "Saved server id." },
|
|
91
|
+
"lookup": { "type": "string", "description": "Alternative lookup by name or host if id is unknown." },
|
|
92
|
+
"name": { "type": "string", "description": "New display name." },
|
|
93
|
+
"host": { "type": "string", "description": "New hostname or IP address." },
|
|
94
|
+
"port": { "type": "number", "description": "New port." },
|
|
95
|
+
"edition": { "type": "string", "description": "Edition: java, bedrock, or auto." },
|
|
96
|
+
"notes": { "type": "string", "description": "New notes value." },
|
|
97
|
+
"tags": {
|
|
98
|
+
"type": "array",
|
|
99
|
+
"items": { "type": "string" },
|
|
100
|
+
"description": "Replacement tag list."
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "minecraft_servers_remove",
|
|
107
|
+
"label": "Remove Minecraft server",
|
|
108
|
+
"description": "Remove a saved Minecraft server by id, name, or host.",
|
|
109
|
+
"promptSnippet": "Remove a saved Minecraft server.",
|
|
110
|
+
"parameters": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"properties": {
|
|
113
|
+
"id": { "type": "string", "description": "Saved server id." },
|
|
114
|
+
"lookup": { "type": "string", "description": "Alternative lookup by name or host." }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "minecraft_servers_status",
|
|
120
|
+
"label": "Get Minecraft server status",
|
|
121
|
+
"description": "Fetch public status information for a Minecraft server without logging in, including MOTD, version, player counts, latency, and favicon when available.",
|
|
122
|
+
"promptSnippet": "Fetch public Minecraft server status information.",
|
|
123
|
+
"parameters": {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"properties": {
|
|
126
|
+
"id": { "type": "string", "description": "Saved server id." },
|
|
127
|
+
"lookup": { "type": "string", "description": "Saved server name or host." },
|
|
128
|
+
"host": { "type": "string", "description": "Direct hostname or IP if not using a saved server." },
|
|
129
|
+
"port": { "type": "number", "description": "Port override." },
|
|
130
|
+
"edition": { "type": "string", "description": "Edition: java, bedrock, or auto." }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "minecraft_servers_players",
|
|
136
|
+
"label": "List online Minecraft players",
|
|
137
|
+
"description": "List the players currently reported as online on a Minecraft server using public status data. The result depends on what the server chooses to expose.",
|
|
138
|
+
"promptSnippet": "List online players from Minecraft server status.",
|
|
139
|
+
"parameters": {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": {
|
|
142
|
+
"id": { "type": "string", "description": "Saved server id." },
|
|
143
|
+
"lookup": { "type": "string", "description": "Saved server name or host." },
|
|
144
|
+
"host": { "type": "string", "description": "Direct hostname or IP if not using a saved server." },
|
|
145
|
+
"port": { "type": "number", "description": "Port override." },
|
|
146
|
+
"edition": { "type": "string", "description": "Edition: java, bedrock, or auto." }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
}
|
package/handler.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
const EXTENSION_ID = '@thibautrey/chatons-extension-minecraft-servers'
|
|
2
|
+
const STORAGE_KEY = 'minecraft.servers'
|
|
3
|
+
const JAVA_DEFAULT_PORT = 25565
|
|
4
|
+
const BEDROCK_DEFAULT_PORT = 19132
|
|
5
|
+
const SOCKET_TIMEOUT_MS = 5000
|
|
6
|
+
|
|
7
|
+
function safeArray(value) {
|
|
8
|
+
return Array.isArray(value) ? value : []
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nowIso() {
|
|
12
|
+
return new Date().toISOString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function randomId() {
|
|
16
|
+
return 'mc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeString(value) {
|
|
20
|
+
return String(value == null ? '' : value).trim()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeEdition(value) {
|
|
24
|
+
const edition = normalizeString(value).toLowerCase()
|
|
25
|
+
if (edition === 'java' || edition === 'bedrock') return edition
|
|
26
|
+
return 'auto'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolvePort(port, edition) {
|
|
30
|
+
if (Number.isFinite(port) && Number(port) > 0 && Number(port) < 65536) return Number(port)
|
|
31
|
+
return edition === 'bedrock' ? BEDROCK_DEFAULT_PORT : JAVA_DEFAULT_PORT
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeTags(value) {
|
|
35
|
+
const input = Array.isArray(value) ? value : []
|
|
36
|
+
return input
|
|
37
|
+
.map((item) => normalizeString(item))
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeServer(input, previous) {
|
|
42
|
+
const edition = normalizeEdition(input.edition || (previous && previous.edition))
|
|
43
|
+
const host = normalizeString(input.host || (previous && previous.host))
|
|
44
|
+
const name = normalizeString(input.name || (previous && previous.name))
|
|
45
|
+
const port = resolvePort(Number(input.port), edition === 'auto' ? normalizeEdition(previous && previous.edition) : edition)
|
|
46
|
+
if (!name) throw new Error('Server name is required')
|
|
47
|
+
if (!host) throw new Error('Server host is required')
|
|
48
|
+
return {
|
|
49
|
+
id: normalizeString(input.id || (previous && previous.id)) || randomId(),
|
|
50
|
+
name,
|
|
51
|
+
host,
|
|
52
|
+
port,
|
|
53
|
+
edition,
|
|
54
|
+
notes: normalizeString(input.notes || (previous && previous.notes)),
|
|
55
|
+
tags: normalizeTags(typeof input.tags === 'undefined' ? (previous && previous.tags) : input.tags),
|
|
56
|
+
updatedAt: nowIso(),
|
|
57
|
+
createdAt: previous && previous.createdAt ? previous.createdAt : nowIso(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loadServers(ctx) {
|
|
62
|
+
const result = await ctx.storageKvGet(EXTENSION_ID, STORAGE_KEY)
|
|
63
|
+
if (result && result.ok && Array.isArray(result.data)) {
|
|
64
|
+
return result.data
|
|
65
|
+
}
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function saveServers(ctx, servers) {
|
|
70
|
+
return ctx.storageKvSet(EXTENSION_ID, STORAGE_KEY, servers)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findServer(servers, payload) {
|
|
74
|
+
const id = normalizeString(payload && payload.id)
|
|
75
|
+
const lookup = normalizeString(payload && payload.lookup).toLowerCase()
|
|
76
|
+
if (id) {
|
|
77
|
+
return servers.find((server) => server.id === id) || null
|
|
78
|
+
}
|
|
79
|
+
if (lookup) {
|
|
80
|
+
return servers.find((server) => {
|
|
81
|
+
return (
|
|
82
|
+
normalizeString(server.name).toLowerCase() === lookup ||
|
|
83
|
+
normalizeString(server.host).toLowerCase() === lookup ||
|
|
84
|
+
(normalizeString(server.host) + ':' + String(server.port)).toLowerCase() === lookup
|
|
85
|
+
)
|
|
86
|
+
}) || null
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notFound(message) {
|
|
92
|
+
return { ok: false, error: { code: 'not_found', message } }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalid(message) {
|
|
96
|
+
return { ok: false, error: { code: 'invalid_args', message } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function internal(message) {
|
|
100
|
+
return { ok: false, error: { code: 'internal', message } }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toUnsignedVarInt(value) {
|
|
104
|
+
const out = []
|
|
105
|
+
let remaining = value >>> 0
|
|
106
|
+
do {
|
|
107
|
+
let temp = remaining & 0x7f
|
|
108
|
+
remaining >>>= 7
|
|
109
|
+
if (remaining !== 0) temp |= 0x80
|
|
110
|
+
out.push(temp)
|
|
111
|
+
} while (remaining !== 0)
|
|
112
|
+
return Buffer.from(out)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encodeString(value) {
|
|
116
|
+
const buf = Buffer.from(String(value), 'utf8')
|
|
117
|
+
return Buffer.concat([toUnsignedVarInt(buf.length), buf])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readVarInt(buffer, offset) {
|
|
121
|
+
let num = 0
|
|
122
|
+
let shift = 0
|
|
123
|
+
let pos = offset
|
|
124
|
+
while (true) {
|
|
125
|
+
if (pos >= buffer.length) throw new Error('Unexpected end of varint')
|
|
126
|
+
const byte = buffer[pos]
|
|
127
|
+
num |= (byte & 0x7f) << shift
|
|
128
|
+
pos += 1
|
|
129
|
+
if ((byte & 0x80) !== 0x80) break
|
|
130
|
+
shift += 7
|
|
131
|
+
if (shift > 35) throw new Error('Varint too large')
|
|
132
|
+
}
|
|
133
|
+
return { value: num, offset: pos }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildJavaHandshake(host, port) {
|
|
137
|
+
const packetId = Buffer.from([0x00])
|
|
138
|
+
const protocolVersion = toUnsignedVarInt(758)
|
|
139
|
+
const hostField = encodeString(host)
|
|
140
|
+
const portField = Buffer.alloc(2)
|
|
141
|
+
portField.writeUInt16BE(port, 0)
|
|
142
|
+
const nextState = toUnsignedVarInt(1)
|
|
143
|
+
const payload = Buffer.concat([packetId, protocolVersion, hostField, portField, nextState])
|
|
144
|
+
return Buffer.concat([toUnsignedVarInt(payload.length), payload])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildJavaStatusRequest() {
|
|
148
|
+
return Buffer.from([0x01, 0x00])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripMinecraftFormatting(input) {
|
|
152
|
+
return String(input == null ? '' : input).replace(/§[0-9A-FK-OR]/gi, '')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function simplifyJavaDescription(description) {
|
|
156
|
+
if (typeof description === 'string') return stripMinecraftFormatting(description)
|
|
157
|
+
if (!description || typeof description !== 'object') return ''
|
|
158
|
+
if (typeof description.text === 'string') {
|
|
159
|
+
return stripMinecraftFormatting(description.text)
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(description.extra)) {
|
|
162
|
+
return description.extra.map(simplifyJavaDescription).join(' ').trim()
|
|
163
|
+
}
|
|
164
|
+
return ''
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function javaStatus(host, port) {
|
|
168
|
+
const net = await import('node:net')
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const socket = new net.Socket()
|
|
171
|
+
const chunks = []
|
|
172
|
+
let done = false
|
|
173
|
+
const started = Date.now()
|
|
174
|
+
|
|
175
|
+
function finish(err, payload) {
|
|
176
|
+
if (done) return
|
|
177
|
+
done = true
|
|
178
|
+
try {
|
|
179
|
+
socket.destroy()
|
|
180
|
+
} catch {}
|
|
181
|
+
if (err) reject(err)
|
|
182
|
+
else resolve(payload)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
socket.setTimeout(SOCKET_TIMEOUT_MS)
|
|
186
|
+
socket.on('timeout', () => finish(new Error('Java status request timed out')))
|
|
187
|
+
socket.on('error', (error) => finish(error))
|
|
188
|
+
socket.on('data', (chunk) => {
|
|
189
|
+
chunks.push(chunk)
|
|
190
|
+
try {
|
|
191
|
+
const buffer = Buffer.concat(chunks)
|
|
192
|
+
const packetLength = readVarInt(buffer, 0)
|
|
193
|
+
if (buffer.length < packetLength.offset + packetLength.value) return
|
|
194
|
+
const packetStart = packetLength.offset
|
|
195
|
+
const packetId = readVarInt(buffer, packetStart)
|
|
196
|
+
if (packetId.value !== 0x00) {
|
|
197
|
+
finish(new Error('Unexpected Java status packet id'))
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const jsonLength = readVarInt(buffer, packetId.offset)
|
|
201
|
+
const jsonStart = jsonLength.offset
|
|
202
|
+
const jsonEnd = jsonStart + jsonLength.value
|
|
203
|
+
if (buffer.length < jsonEnd) return
|
|
204
|
+
const raw = buffer.slice(jsonStart, jsonEnd).toString('utf8')
|
|
205
|
+
const parsed = JSON.parse(raw)
|
|
206
|
+
const players = parsed.players || {}
|
|
207
|
+
const sample = Array.isArray(players.sample) ? players.sample : []
|
|
208
|
+
finish(null, {
|
|
209
|
+
ok: true,
|
|
210
|
+
resolvedEdition: 'java',
|
|
211
|
+
host,
|
|
212
|
+
port,
|
|
213
|
+
latencyMs: Date.now() - started,
|
|
214
|
+
motd: simplifyJavaDescription(parsed.description),
|
|
215
|
+
version: parsed.version && parsed.version.name ? parsed.version.name : null,
|
|
216
|
+
protocol: parsed.version && typeof parsed.version.protocol === 'number' ? parsed.version.protocol : null,
|
|
217
|
+
players: {
|
|
218
|
+
online: typeof players.online === 'number' ? players.online : null,
|
|
219
|
+
max: typeof players.max === 'number' ? players.max : null,
|
|
220
|
+
sample: sample
|
|
221
|
+
.map((entry) => (entry && typeof entry.name === 'string' ? entry.name : null))
|
|
222
|
+
.filter(Boolean),
|
|
223
|
+
},
|
|
224
|
+
favicon: typeof parsed.favicon === 'string' ? parsed.favicon : null,
|
|
225
|
+
raw: parsed,
|
|
226
|
+
})
|
|
227
|
+
} catch (error) {
|
|
228
|
+
finish(error)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
socket.connect(port, host, () => {
|
|
232
|
+
socket.write(buildJavaHandshake(host, port))
|
|
233
|
+
socket.write(buildJavaStatusRequest())
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildBedrockUnconnectedPing() {
|
|
239
|
+
const magic = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex')
|
|
240
|
+
const buffer = Buffer.alloc(1 + 8 + 8 + 16)
|
|
241
|
+
let offset = 0
|
|
242
|
+
buffer.writeUInt8(0x01, offset)
|
|
243
|
+
offset += 1
|
|
244
|
+
const pingTime = BigInt(Date.now())
|
|
245
|
+
buffer.writeBigInt64BE(pingTime, offset)
|
|
246
|
+
offset += 8
|
|
247
|
+
buffer.writeBigInt64BE(0x0123456789ABCDEFn, offset)
|
|
248
|
+
offset += 8
|
|
249
|
+
magic.copy(buffer, offset)
|
|
250
|
+
return buffer
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseBedrockMotd(raw) {
|
|
254
|
+
const parts = String(raw || '').split(';')
|
|
255
|
+
return {
|
|
256
|
+
edition: parts[0] || null,
|
|
257
|
+
motd: parts[1] || null,
|
|
258
|
+
protocol: parts[2] ? Number(parts[2]) : null,
|
|
259
|
+
version: parts[3] || null,
|
|
260
|
+
online: parts[4] ? Number(parts[4]) : null,
|
|
261
|
+
max: parts[5] ? Number(parts[5]) : null,
|
|
262
|
+
serverId: parts[6] || null,
|
|
263
|
+
map: parts[7] || null,
|
|
264
|
+
gamemode: parts[8] || null,
|
|
265
|
+
gamemodeNumeric: parts[9] ? Number(parts[9]) : null,
|
|
266
|
+
portIPv4: parts[10] ? Number(parts[10]) : null,
|
|
267
|
+
portIPv6: parts[11] ? Number(parts[11]) : null,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function bedrockStatus(host, port) {
|
|
272
|
+
const dgram = await import('node:dgram')
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const socket = dgram.createSocket('udp4')
|
|
275
|
+
const started = Date.now()
|
|
276
|
+
let done = false
|
|
277
|
+
|
|
278
|
+
function finish(err, payload) {
|
|
279
|
+
if (done) return
|
|
280
|
+
done = true
|
|
281
|
+
try {
|
|
282
|
+
socket.close()
|
|
283
|
+
} catch {}
|
|
284
|
+
if (err) reject(err)
|
|
285
|
+
else resolve(payload)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
socket.on('error', (error) => finish(error))
|
|
289
|
+
socket.on('message', (msg) => {
|
|
290
|
+
try {
|
|
291
|
+
if (msg.length < 35 || msg.readUInt8(0) !== 0x1c) {
|
|
292
|
+
finish(new Error('Unexpected Bedrock pong packet'))
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const motdLength = msg.readUInt16BE(33)
|
|
296
|
+
const motd = msg.slice(35, 35 + motdLength).toString('utf8')
|
|
297
|
+
const parsed = parseBedrockMotd(motd)
|
|
298
|
+
finish(null, {
|
|
299
|
+
ok: true,
|
|
300
|
+
resolvedEdition: 'bedrock',
|
|
301
|
+
host,
|
|
302
|
+
port,
|
|
303
|
+
latencyMs: Date.now() - started,
|
|
304
|
+
motd: parsed.motd,
|
|
305
|
+
version: parsed.version,
|
|
306
|
+
protocol: parsed.protocol,
|
|
307
|
+
players: {
|
|
308
|
+
online: Number.isFinite(parsed.online) ? parsed.online : null,
|
|
309
|
+
max: Number.isFinite(parsed.max) ? parsed.max : null,
|
|
310
|
+
sample: [],
|
|
311
|
+
},
|
|
312
|
+
gamemode: parsed.gamemode,
|
|
313
|
+
map: parsed.map,
|
|
314
|
+
raw: parsed,
|
|
315
|
+
})
|
|
316
|
+
} catch (error) {
|
|
317
|
+
finish(error)
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
socket.send(buildBedrockUnconnectedPing(), port, host, (error) => {
|
|
322
|
+
if (error) {
|
|
323
|
+
finish(error)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
setTimeout(() => finish(new Error('Bedrock status request timed out')), SOCKET_TIMEOUT_MS)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function probeServer(target) {
|
|
332
|
+
const edition = normalizeEdition(target.edition)
|
|
333
|
+
const host = normalizeString(target.host)
|
|
334
|
+
if (!host) throw new Error('Server host is required')
|
|
335
|
+
|
|
336
|
+
const candidates = edition === 'auto'
|
|
337
|
+
? [
|
|
338
|
+
{ edition: 'java', port: resolvePort(Number(target.port), 'java') },
|
|
339
|
+
{ edition: 'bedrock', port: resolvePort(Number(target.port), 'bedrock') },
|
|
340
|
+
]
|
|
341
|
+
: [{ edition, port: resolvePort(Number(target.port), edition) }]
|
|
342
|
+
|
|
343
|
+
const errors = []
|
|
344
|
+
for (const candidate of candidates) {
|
|
345
|
+
try {
|
|
346
|
+
if (candidate.edition === 'java') {
|
|
347
|
+
return await javaStatus(host, candidate.port)
|
|
348
|
+
}
|
|
349
|
+
return await bedrockStatus(host, candidate.port)
|
|
350
|
+
} catch (error) {
|
|
351
|
+
errors.push(candidate.edition + ': ' + (error && error.message ? error.message : String(error)))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
host,
|
|
357
|
+
port: Number(target.port) || null,
|
|
358
|
+
resolvedEdition: edition === 'auto' ? null : edition,
|
|
359
|
+
checkedAt: nowIso(),
|
|
360
|
+
error: errors.join(' | ') || 'Server unreachable',
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function apiListServers(payload, ctx) {
|
|
365
|
+
const servers = await loadServers(ctx)
|
|
366
|
+
if (payload && payload.includeStatus) {
|
|
367
|
+
const enriched = []
|
|
368
|
+
for (const server of servers) {
|
|
369
|
+
const status = await probeServer(server)
|
|
370
|
+
enriched.push({ ...server, status })
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, data: { servers: enriched } }
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, data: { servers } }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function apiAddServer(payload, ctx) {
|
|
378
|
+
const servers = await loadServers(ctx)
|
|
379
|
+
const normalized = sanitizeServer(payload || {}, null)
|
|
380
|
+
const duplicate = servers.find((server) => normalizeString(server.host).toLowerCase() === normalized.host.toLowerCase() && Number(server.port) === Number(normalized.port))
|
|
381
|
+
if (duplicate) {
|
|
382
|
+
return invalid('A server with the same host and port already exists')
|
|
383
|
+
}
|
|
384
|
+
const next = servers.concat([normalized])
|
|
385
|
+
await saveServers(ctx, next)
|
|
386
|
+
return { ok: true, data: { server: normalized, servers: next } }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function apiUpdateServer(payload, ctx) {
|
|
390
|
+
const servers = await loadServers(ctx)
|
|
391
|
+
const current = findServer(servers, payload || {})
|
|
392
|
+
if (!current) return notFound('Saved server not found')
|
|
393
|
+
const updated = sanitizeServer(payload || {}, current)
|
|
394
|
+
const next = servers.map((server) => {
|
|
395
|
+
if (server.id !== current.id) return server
|
|
396
|
+
return updated
|
|
397
|
+
})
|
|
398
|
+
await saveServers(ctx, next)
|
|
399
|
+
return { ok: true, data: { server: updated, servers: next } }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function apiRemoveServer(payload, ctx) {
|
|
403
|
+
const servers = await loadServers(ctx)
|
|
404
|
+
const current = findServer(servers, payload || {})
|
|
405
|
+
if (!current) return notFound('Saved server not found')
|
|
406
|
+
const next = servers.filter((server) => server.id !== current.id)
|
|
407
|
+
await saveServers(ctx, next)
|
|
408
|
+
return { ok: true, data: { removed: current, servers: next } }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function resolveProbeTarget(payload, ctx) {
|
|
412
|
+
const id = normalizeString(payload && payload.id)
|
|
413
|
+
const lookup = normalizeString(payload && payload.lookup)
|
|
414
|
+
if (id || lookup) {
|
|
415
|
+
const servers = await loadServers(ctx)
|
|
416
|
+
const saved = findServer(servers, payload || {})
|
|
417
|
+
if (!saved) throw new Error('Saved server not found')
|
|
418
|
+
return saved
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const host = normalizeString(payload && payload.host)
|
|
422
|
+
if (!host) throw new Error('Provide id, lookup, or host')
|
|
423
|
+
return {
|
|
424
|
+
host,
|
|
425
|
+
port: Number(payload && payload.port),
|
|
426
|
+
edition: normalizeEdition(payload && payload.edition),
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function apiStatus(payload, ctx) {
|
|
431
|
+
const target = await resolveProbeTarget(payload, ctx)
|
|
432
|
+
const status = await probeServer(target)
|
|
433
|
+
return { ok: true, data: status }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function apiPlayers(payload, ctx) {
|
|
437
|
+
const target = await resolveProbeTarget(payload, ctx)
|
|
438
|
+
const status = await probeServer(target)
|
|
439
|
+
if (status.ok === false) {
|
|
440
|
+
return { ok: false, error: { code: 'internal', message: status.error || 'Server is offline' } }
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
data: {
|
|
445
|
+
host: status.host,
|
|
446
|
+
port: status.port,
|
|
447
|
+
edition: status.resolvedEdition,
|
|
448
|
+
online: status.players ? status.players.online : null,
|
|
449
|
+
max: status.players ? status.players.max : null,
|
|
450
|
+
players: status.players && Array.isArray(status.players.sample) ? status.players.sample : [],
|
|
451
|
+
status,
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export default async function handler(apiName, payload, ctx) {
|
|
457
|
+
try {
|
|
458
|
+
if (!ctx || typeof ctx.storageKvGet !== 'function' || typeof ctx.storageKvSet !== 'function') {
|
|
459
|
+
return internal('Missing handler context')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (apiName === 'minecraft.servers.list') return apiListServers(payload, ctx)
|
|
463
|
+
if (apiName === 'minecraft.servers.add') return apiAddServer(payload, ctx)
|
|
464
|
+
if (apiName === 'minecraft.servers.update') return apiUpdateServer(payload, ctx)
|
|
465
|
+
if (apiName === 'minecraft.servers.remove') return apiRemoveServer(payload, ctx)
|
|
466
|
+
if (apiName === 'minecraft.servers.status') return apiStatus(payload, ctx)
|
|
467
|
+
if (apiName === 'minecraft.servers.players') return apiPlayers(payload, ctx)
|
|
468
|
+
|
|
469
|
+
return notFound('Unknown API: ' + apiName)
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return internal(error && error.message ? error.message : String(error))
|
|
472
|
+
}
|
|
473
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<rect width="64" height="64" rx="16" fill="#1f2937"/>
|
|
3
|
+
<rect x="14" y="16" width="36" height="10" rx="3" fill="#22c55e"/>
|
|
4
|
+
<rect x="14" y="29" width="36" height="8" rx="3" fill="#4b5563"/>
|
|
5
|
+
<rect x="14" y="40" width="36" height="8" rx="3" fill="#4b5563"/>
|
|
6
|
+
<circle cx="20" cy="33" r="2" fill="#a7f3d0"/>
|
|
7
|
+
<circle cx="20" cy="44" r="2" fill="#d1d5db"/>
|
|
8
|
+
<path d="M40 20h4M46 20h0" stroke="#052e16" stroke-width="3" stroke-linecap="round"/>
|
|
9
|
+
</svg>
|
package/index.html
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Minecraft Servers</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
}
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
17
|
+
background: transparent;
|
|
18
|
+
color: inherit;
|
|
19
|
+
}
|
|
20
|
+
.app {
|
|
21
|
+
display: grid;
|
|
22
|
+
gap: 16px;
|
|
23
|
+
}
|
|
24
|
+
.layout {
|
|
25
|
+
display: grid;
|
|
26
|
+
grid-template-columns: 1.1fr 1.4fr;
|
|
27
|
+
gap: 16px;
|
|
28
|
+
align-items: start;
|
|
29
|
+
}
|
|
30
|
+
.toolbar {
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-wrap: wrap;
|
|
33
|
+
gap: 10px;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
align-items: center;
|
|
36
|
+
}
|
|
37
|
+
.stats {
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 10px;
|
|
40
|
+
flex-wrap: wrap;
|
|
41
|
+
}
|
|
42
|
+
.stat {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
gap: 8px;
|
|
45
|
+
align-items: center;
|
|
46
|
+
padding: 8px 10px;
|
|
47
|
+
border-radius: 999px;
|
|
48
|
+
background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 60%, transparent);
|
|
49
|
+
font-size: 12px;
|
|
50
|
+
}
|
|
51
|
+
.field-grid {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: 1fr 1fr;
|
|
54
|
+
gap: 12px;
|
|
55
|
+
}
|
|
56
|
+
.field-grid .full {
|
|
57
|
+
grid-column: 1 / -1;
|
|
58
|
+
}
|
|
59
|
+
.actions {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: wrap;
|
|
62
|
+
gap: 10px;
|
|
63
|
+
}
|
|
64
|
+
input, textarea, select {
|
|
65
|
+
width: 100%;
|
|
66
|
+
box-sizing: border-box;
|
|
67
|
+
}
|
|
68
|
+
textarea {
|
|
69
|
+
min-height: 84px;
|
|
70
|
+
resize: vertical;
|
|
71
|
+
}
|
|
72
|
+
.server-list {
|
|
73
|
+
display: grid;
|
|
74
|
+
gap: 12px;
|
|
75
|
+
}
|
|
76
|
+
.server-card {
|
|
77
|
+
border: 1px solid color-mix(in srgb, var(--ce-border, #d1d5db) 90%, transparent);
|
|
78
|
+
border-radius: 14px;
|
|
79
|
+
padding: 14px;
|
|
80
|
+
background: color-mix(in srgb, var(--ce-card, #ffffff) 88%, transparent);
|
|
81
|
+
display: grid;
|
|
82
|
+
gap: 10px;
|
|
83
|
+
}
|
|
84
|
+
.server-card.is-selected {
|
|
85
|
+
border-color: color-mix(in srgb, #22c55e 55%, var(--ce-border, #d1d5db));
|
|
86
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, #22c55e 30%, transparent);
|
|
87
|
+
}
|
|
88
|
+
.server-head {
|
|
89
|
+
display: flex;
|
|
90
|
+
justify-content: space-between;
|
|
91
|
+
align-items: start;
|
|
92
|
+
gap: 12px;
|
|
93
|
+
}
|
|
94
|
+
.server-title {
|
|
95
|
+
display: grid;
|
|
96
|
+
gap: 4px;
|
|
97
|
+
}
|
|
98
|
+
.server-title h3,
|
|
99
|
+
.server-title p,
|
|
100
|
+
.server-title small {
|
|
101
|
+
margin: 0;
|
|
102
|
+
}
|
|
103
|
+
.server-badges {
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-wrap: wrap;
|
|
106
|
+
gap: 6px;
|
|
107
|
+
}
|
|
108
|
+
.badge {
|
|
109
|
+
display: inline-flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
gap: 6px;
|
|
112
|
+
padding: 4px 8px;
|
|
113
|
+
border-radius: 999px;
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 75%, transparent);
|
|
116
|
+
}
|
|
117
|
+
.badge.ok {
|
|
118
|
+
background: rgba(34, 197, 94, 0.18);
|
|
119
|
+
}
|
|
120
|
+
.badge.warn {
|
|
121
|
+
background: rgba(245, 158, 11, 0.18);
|
|
122
|
+
}
|
|
123
|
+
.badge.error {
|
|
124
|
+
background: rgba(239, 68, 68, 0.18);
|
|
125
|
+
}
|
|
126
|
+
.row-actions {
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-wrap: wrap;
|
|
129
|
+
gap: 8px;
|
|
130
|
+
}
|
|
131
|
+
.muted {
|
|
132
|
+
color: var(--ce-muted-fg, #6b7280);
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
}
|
|
135
|
+
.empty {
|
|
136
|
+
padding: 18px;
|
|
137
|
+
border-radius: 12px;
|
|
138
|
+
background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 55%, transparent);
|
|
139
|
+
font-size: 13px;
|
|
140
|
+
}
|
|
141
|
+
.status-box {
|
|
142
|
+
display: grid;
|
|
143
|
+
gap: 8px;
|
|
144
|
+
font-size: 13px;
|
|
145
|
+
}
|
|
146
|
+
.status-box pre {
|
|
147
|
+
margin: 0;
|
|
148
|
+
padding: 12px;
|
|
149
|
+
border-radius: 12px;
|
|
150
|
+
overflow: auto;
|
|
151
|
+
background: color-mix(in srgb, var(--ce-muted, #e5e7eb) 55%, transparent);
|
|
152
|
+
font-size: 12px;
|
|
153
|
+
line-height: 1.4;
|
|
154
|
+
white-space: pre-wrap;
|
|
155
|
+
word-break: break-word;
|
|
156
|
+
}
|
|
157
|
+
@media (max-width: 960px) {
|
|
158
|
+
.layout, .field-grid {
|
|
159
|
+
grid-template-columns: 1fr;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
163
|
+
</head>
|
|
164
|
+
<body>
|
|
165
|
+
<div class="ce-page">
|
|
166
|
+
<div class="ce-page-header">
|
|
167
|
+
<div class="ce-page-title-group">
|
|
168
|
+
<h1 class="ce-page-title">Minecraft Servers</h1>
|
|
169
|
+
<p class="ce-page-description">Gere une liste de serveurs Minecraft et interroge leur statut public sans connexion joueur.</p>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div id="app" class="app"></div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<script src="./index.js"></script>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
package/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
const EXTENSION_ID = '@thibautrey/chatons-extension-minecraft-servers'
|
|
2
|
+
const STORAGE_KEY = 'minecraft.servers'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FORM = {
|
|
5
|
+
id: '',
|
|
6
|
+
name: '',
|
|
7
|
+
host: '',
|
|
8
|
+
port: '',
|
|
9
|
+
edition: 'auto',
|
|
10
|
+
notes: '',
|
|
11
|
+
tags: '',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clone(value) {
|
|
15
|
+
return JSON.parse(JSON.stringify(value))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function escapeHtml(value) {
|
|
19
|
+
return String(value == null ? '' : value)
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function kvGet(key, fallback) {
|
|
28
|
+
const res = await window.chaton.extensionStorageKvGet(EXTENSION_ID, key)
|
|
29
|
+
if (!res || !res.ok || typeof res.data === 'undefined') return clone(fallback)
|
|
30
|
+
return res.data
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function call(apiName, payload) {
|
|
34
|
+
return window.chaton.extensionCall('chatons-ui', EXTENSION_ID, apiName, '^1.0.0', payload || {})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const state = {
|
|
38
|
+
servers: [],
|
|
39
|
+
selectedId: null,
|
|
40
|
+
form: clone(DEFAULT_FORM),
|
|
41
|
+
liveById: {},
|
|
42
|
+
message: '',
|
|
43
|
+
messageKind: '',
|
|
44
|
+
loading: false,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeTagsText(tags) {
|
|
48
|
+
return Array.isArray(tags) ? tags.join(', ') : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setMessage(text, kind) {
|
|
52
|
+
state.message = text || ''
|
|
53
|
+
state.messageKind = kind || ''
|
|
54
|
+
const node = document.getElementById('statusMessage')
|
|
55
|
+
if (node) {
|
|
56
|
+
node.textContent = state.message
|
|
57
|
+
node.dataset.kind = state.messageKind
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formFromServer(server) {
|
|
62
|
+
return {
|
|
63
|
+
id: server.id || '',
|
|
64
|
+
name: server.name || '',
|
|
65
|
+
host: server.host || '',
|
|
66
|
+
port: server.port == null ? '' : String(server.port),
|
|
67
|
+
edition: server.edition || 'auto',
|
|
68
|
+
notes: server.notes || '',
|
|
69
|
+
tags: normalizeTagsText(server.tags),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseTags(value) {
|
|
74
|
+
return String(value || '')
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((item) => item.trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hydrateFormFromDom() {
|
|
81
|
+
state.form = {
|
|
82
|
+
id: document.getElementById('field-id').value.trim(),
|
|
83
|
+
name: document.getElementById('field-name').value.trim(),
|
|
84
|
+
host: document.getElementById('field-host').value.trim(),
|
|
85
|
+
port: document.getElementById('field-port').value.trim(),
|
|
86
|
+
edition: document.getElementById('field-edition').value,
|
|
87
|
+
notes: document.getElementById('field-notes').value.trim(),
|
|
88
|
+
tags: document.getElementById('field-tags').value.trim(),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function selectedServer() {
|
|
93
|
+
return state.servers.find((server) => server.id === state.selectedId) || null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadServers() {
|
|
97
|
+
state.servers = await kvGet(STORAGE_KEY, [])
|
|
98
|
+
if (!state.selectedId && state.servers.length > 0) {
|
|
99
|
+
state.selectedId = state.servers[0].id
|
|
100
|
+
}
|
|
101
|
+
if (state.selectedId && !state.servers.some((server) => server.id === state.selectedId)) {
|
|
102
|
+
state.selectedId = state.servers.length > 0 ? state.servers[0].id : null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function describeLiveStatus(status) {
|
|
107
|
+
if (!status) return '<span class="badge">Aucun statut charge</span>'
|
|
108
|
+
if (status.ok === false) {
|
|
109
|
+
return '<span class="badge error">Hors ligne</span>'
|
|
110
|
+
}
|
|
111
|
+
const badges = []
|
|
112
|
+
badges.push('<span class="badge ok">En ligne</span>')
|
|
113
|
+
if (status.resolvedEdition) {
|
|
114
|
+
badges.push('<span class="badge">' + escapeHtml(status.resolvedEdition) + '</span>')
|
|
115
|
+
}
|
|
116
|
+
if (status.players && typeof status.players.online === 'number') {
|
|
117
|
+
badges.push('<span class="badge">' + escapeHtml(String(status.players.online)) + ' joueurs</span>')
|
|
118
|
+
}
|
|
119
|
+
if (typeof status.latencyMs === 'number') {
|
|
120
|
+
badges.push('<span class="badge">' + escapeHtml(String(status.latencyMs)) + ' ms</span>')
|
|
121
|
+
}
|
|
122
|
+
return badges.join('')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function serverCardHtml(server) {
|
|
126
|
+
const status = state.liveById[server.id]
|
|
127
|
+
const endpoint = server.host + ':' + server.port
|
|
128
|
+
return `
|
|
129
|
+
<div class="server-card ${state.selectedId === server.id ? 'is-selected' : ''}" data-server-id="${escapeHtml(server.id)}">
|
|
130
|
+
<div class="server-head">
|
|
131
|
+
<div class="server-title">
|
|
132
|
+
<h3>${escapeHtml(server.name)}</h3>
|
|
133
|
+
<p class="muted">${escapeHtml(endpoint)}</p>
|
|
134
|
+
<small class="muted">${escapeHtml(server.notes || '')}</small>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="server-badges">
|
|
137
|
+
<span class="badge">${escapeHtml(server.edition || 'auto')}</span>
|
|
138
|
+
${(server.tags || []).map((tag) => `<span class="badge">${escapeHtml(tag)}</span>`).join('')}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="server-badges">${describeLiveStatus(status)}</div>
|
|
142
|
+
<div class="row-actions">
|
|
143
|
+
<button class="chaton-ui-button chaton-ui-button--ghost" data-action="select" data-server-id="${escapeHtml(server.id)}">Editer</button>
|
|
144
|
+
<button class="chaton-ui-button" data-action="probe" data-server-id="${escapeHtml(server.id)}">Tester</button>
|
|
145
|
+
<button class="chaton-ui-button chaton-ui-button--ghost" data-action="players" data-server-id="${escapeHtml(server.id)}">Joueurs</button>
|
|
146
|
+
<button class="chaton-ui-button chaton-ui-button--ghost" data-action="delete" data-server-id="${escapeHtml(server.id)}">Supprimer</button>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function statusDetailHtml(server, status) {
|
|
153
|
+
if (!server) {
|
|
154
|
+
return '<div class="empty">Selectionne un serveur pour voir les details.</div>'
|
|
155
|
+
}
|
|
156
|
+
if (!status) {
|
|
157
|
+
return '<div class="empty">Aucun statut live charge pour ce serveur.</div>'
|
|
158
|
+
}
|
|
159
|
+
return `
|
|
160
|
+
<div class="status-box">
|
|
161
|
+
<div class="server-badges">${describeLiveStatus(status)}</div>
|
|
162
|
+
<pre>${escapeHtml(JSON.stringify(status, null, 2))}</pre>
|
|
163
|
+
</div>
|
|
164
|
+
`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function render() {
|
|
168
|
+
const app = document.getElementById('app')
|
|
169
|
+
const selected = selectedServer()
|
|
170
|
+
const statsCount = state.servers.length
|
|
171
|
+
const onlineCount = Object.values(state.liveById).filter((entry) => entry && entry.ok !== false).length
|
|
172
|
+
|
|
173
|
+
app.innerHTML = `
|
|
174
|
+
<div class="toolbar">
|
|
175
|
+
<div class="stats">
|
|
176
|
+
<div class="stat"><strong>${statsCount}</strong><span>serveurs</span></div>
|
|
177
|
+
<div class="stat"><strong>${onlineCount}</strong><span>en ligne probes</span></div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="actions">
|
|
180
|
+
<button id="refreshAllBtn" class="chaton-ui-button">Actualiser tout</button>
|
|
181
|
+
<button id="resetFormBtn" class="chaton-ui-button chaton-ui-button--ghost">Nouveau serveur</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div id="statusMessage" class="muted"></div>
|
|
186
|
+
|
|
187
|
+
<div class="layout">
|
|
188
|
+
<section class="ce-card">
|
|
189
|
+
<div class="ce-card__body">
|
|
190
|
+
<h2 class="ce-section-title">Configuration</h2>
|
|
191
|
+
<div class="field-grid">
|
|
192
|
+
<div class="full ce-field">
|
|
193
|
+
<label class="ce-label" for="field-name">Nom</label>
|
|
194
|
+
<input id="field-name" value="${escapeHtml(state.form.name)}" placeholder="Mon serveur survie">
|
|
195
|
+
</div>
|
|
196
|
+
<div class="ce-field">
|
|
197
|
+
<label class="ce-label" for="field-host">Host</label>
|
|
198
|
+
<input id="field-host" value="${escapeHtml(state.form.host)}" placeholder="play.example.net">
|
|
199
|
+
</div>
|
|
200
|
+
<div class="ce-field">
|
|
201
|
+
<label class="ce-label" for="field-port">Port</label>
|
|
202
|
+
<input id="field-port" value="${escapeHtml(state.form.port)}" placeholder="25565">
|
|
203
|
+
</div>
|
|
204
|
+
<div class="ce-field">
|
|
205
|
+
<label class="ce-label" for="field-edition">Edition</label>
|
|
206
|
+
<select id="field-edition">
|
|
207
|
+
<option value="auto" ${state.form.edition === 'auto' ? 'selected' : ''}>Auto</option>
|
|
208
|
+
<option value="java" ${state.form.edition === 'java' ? 'selected' : ''}>Java</option>
|
|
209
|
+
<option value="bedrock" ${state.form.edition === 'bedrock' ? 'selected' : ''}>Bedrock</option>
|
|
210
|
+
</select>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="full ce-field">
|
|
213
|
+
<label class="ce-label" for="field-tags">Tags</label>
|
|
214
|
+
<input id="field-tags" value="${escapeHtml(state.form.tags)}" placeholder="survie, prive, amis">
|
|
215
|
+
</div>
|
|
216
|
+
<div class="full ce-field">
|
|
217
|
+
<label class="ce-label" for="field-notes">Notes</label>
|
|
218
|
+
<textarea id="field-notes" placeholder="Infos libres sur le serveur">${escapeHtml(state.form.notes)}</textarea>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<input id="field-id" type="hidden" value="${escapeHtml(state.form.id)}">
|
|
222
|
+
<div class="actions">
|
|
223
|
+
<button id="saveBtn" class="chaton-ui-button chaton-ui-button--primary">${state.form.id ? 'Mettre a jour' : 'Ajouter'}</button>
|
|
224
|
+
<button id="probeBtn" class="chaton-ui-button">Tester ce serveur</button>
|
|
225
|
+
<button id="playersBtn" class="chaton-ui-button chaton-ui-button--ghost">Lister les joueurs</button>
|
|
226
|
+
</div>
|
|
227
|
+
<div class="muted">Les informations live utilisent uniquement le ping public du serveur. La liste des joueurs depend de ce que le serveur expose.</div>
|
|
228
|
+
</div>
|
|
229
|
+
</section>
|
|
230
|
+
|
|
231
|
+
<section class="ce-card">
|
|
232
|
+
<div class="ce-card__body">
|
|
233
|
+
<h2 class="ce-section-title">Serveurs enregistres</h2>
|
|
234
|
+
<div class="server-list">
|
|
235
|
+
${state.servers.length ? state.servers.map(serverCardHtml).join('') : '<div class="empty">Aucun serveur configure pour le moment.</div>'}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</section>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<section class="ce-card">
|
|
242
|
+
<div class="ce-card__body">
|
|
243
|
+
<h2 class="ce-section-title">Details du statut</h2>
|
|
244
|
+
${statusDetailHtml(selected, selected ? state.liveById[selected.id] : null)}
|
|
245
|
+
</div>
|
|
246
|
+
</section>
|
|
247
|
+
`
|
|
248
|
+
|
|
249
|
+
bindEvents()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function refresh() {
|
|
253
|
+
await loadServers()
|
|
254
|
+
if (!state.form.id && state.selectedId) {
|
|
255
|
+
const server = selectedServer()
|
|
256
|
+
if (server) state.form = formFromServer(server)
|
|
257
|
+
}
|
|
258
|
+
render()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function saveCurrentForm() {
|
|
262
|
+
hydrateFormFromDom()
|
|
263
|
+
const payload = {
|
|
264
|
+
id: state.form.id || undefined,
|
|
265
|
+
name: state.form.name,
|
|
266
|
+
host: state.form.host,
|
|
267
|
+
port: state.form.port ? Number(state.form.port) : undefined,
|
|
268
|
+
edition: state.form.edition,
|
|
269
|
+
notes: state.form.notes,
|
|
270
|
+
tags: parseTags(state.form.tags),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!payload.name || !payload.host) {
|
|
274
|
+
setMessage('Le nom et le host sont obligatoires.', 'error')
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const result = payload.id
|
|
279
|
+
? await call('minecraft.servers.update', payload)
|
|
280
|
+
: await call('minecraft.servers.add', payload)
|
|
281
|
+
|
|
282
|
+
if (!result || !result.ok) {
|
|
283
|
+
setMessage(result && result.error ? result.error.message : 'Echec de sauvegarde.', 'error')
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
state.selectedId = result.data && result.data.server ? result.data.server.id : state.selectedId
|
|
288
|
+
state.form = state.selectedId && result.data && result.data.server ? formFromServer(result.data.server) : clone(DEFAULT_FORM)
|
|
289
|
+
setMessage(payload.id ? 'Serveur mis a jour.' : 'Serveur ajoute.', 'success')
|
|
290
|
+
await refresh()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function probeServer(server) {
|
|
294
|
+
if (!server) {
|
|
295
|
+
setMessage('Aucun serveur selectionne.', 'error')
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
const result = await call('minecraft.servers.status', { id: server.id })
|
|
299
|
+
if (!result || !result.ok) {
|
|
300
|
+
state.liveById[server.id] = { ok: false, error: result && result.error ? result.error.message : 'Probe failed' }
|
|
301
|
+
setMessage(result && result.error ? result.error.message : 'Probe impossible.', 'error')
|
|
302
|
+
render()
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
state.liveById[server.id] = result.data
|
|
306
|
+
setMessage('Statut mis a jour pour ' + server.name + '.', 'success')
|
|
307
|
+
render()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function listPlayers(server) {
|
|
311
|
+
if (!server) {
|
|
312
|
+
setMessage('Aucun serveur selectionne.', 'error')
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
const result = await call('minecraft.servers.players', { id: server.id })
|
|
316
|
+
if (!result || !result.ok) {
|
|
317
|
+
setMessage(result && result.error ? result.error.message : 'Impossible de lister les joueurs.', 'error')
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const players = result.data && Array.isArray(result.data.players) ? result.data.players : []
|
|
321
|
+
setMessage(players.length ? ('Joueurs en ligne: ' + players.join(', ')) : 'Aucun joueur nomme expose par le serveur.', 'success')
|
|
322
|
+
if (result.data && result.data.status) {
|
|
323
|
+
state.liveById[server.id] = result.data.status
|
|
324
|
+
}
|
|
325
|
+
render()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function removeServer(serverId) {
|
|
329
|
+
const result = await call('minecraft.servers.remove', { id: serverId })
|
|
330
|
+
if (!result || !result.ok) {
|
|
331
|
+
setMessage(result && result.error ? result.error.message : 'Suppression impossible.', 'error')
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
delete state.liveById[serverId]
|
|
335
|
+
if (state.selectedId === serverId) {
|
|
336
|
+
state.selectedId = null
|
|
337
|
+
state.form = clone(DEFAULT_FORM)
|
|
338
|
+
}
|
|
339
|
+
setMessage('Serveur supprime.', 'success')
|
|
340
|
+
await refresh()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function refreshAll() {
|
|
344
|
+
await loadServers()
|
|
345
|
+
for (const server of state.servers) {
|
|
346
|
+
const result = await call('minecraft.servers.status', { id: server.id })
|
|
347
|
+
state.liveById[server.id] = result && result.ok ? result.data : { ok: false, error: result && result.error ? result.error.message : 'Probe failed' }
|
|
348
|
+
}
|
|
349
|
+
setMessage('Statuts actualises.', 'success')
|
|
350
|
+
render()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function bindEvents() {
|
|
354
|
+
const saveBtn = document.getElementById('saveBtn')
|
|
355
|
+
const probeBtn = document.getElementById('probeBtn')
|
|
356
|
+
const playersBtn = document.getElementById('playersBtn')
|
|
357
|
+
const resetFormBtn = document.getElementById('resetFormBtn')
|
|
358
|
+
const refreshAllBtn = document.getElementById('refreshAllBtn')
|
|
359
|
+
|
|
360
|
+
if (saveBtn) {
|
|
361
|
+
saveBtn.onclick = function () {
|
|
362
|
+
void saveCurrentForm()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (probeBtn) {
|
|
366
|
+
probeBtn.onclick = function () {
|
|
367
|
+
void probeServer(selectedServer())
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (playersBtn) {
|
|
371
|
+
playersBtn.onclick = function () {
|
|
372
|
+
void listPlayers(selectedServer())
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (resetFormBtn) {
|
|
376
|
+
resetFormBtn.onclick = function () {
|
|
377
|
+
state.selectedId = null
|
|
378
|
+
state.form = clone(DEFAULT_FORM)
|
|
379
|
+
render()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (refreshAllBtn) {
|
|
383
|
+
refreshAllBtn.onclick = function () {
|
|
384
|
+
void refreshAll()
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
Array.from(document.querySelectorAll('[data-action]')).forEach((node) => {
|
|
389
|
+
node.onclick = function () {
|
|
390
|
+
const action = node.getAttribute('data-action')
|
|
391
|
+
const serverId = node.getAttribute('data-server-id')
|
|
392
|
+
const server = state.servers.find((entry) => entry.id === serverId)
|
|
393
|
+
if (!server) return
|
|
394
|
+
if (action === 'select') {
|
|
395
|
+
state.selectedId = server.id
|
|
396
|
+
state.form = formFromServer(server)
|
|
397
|
+
render()
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
if (action === 'probe') {
|
|
401
|
+
void probeServer(server)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
if (action === 'players') {
|
|
405
|
+
void listPlayers(server)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (action === 'delete') {
|
|
409
|
+
void removeServer(server.id)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
;(async function init() {
|
|
416
|
+
if (window.chatonExtensionComponents) {
|
|
417
|
+
window.chatonExtensionComponents.ensureStyles()
|
|
418
|
+
}
|
|
419
|
+
await refresh()
|
|
420
|
+
})()
|