bridge-update-server 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.
@@ -0,0 +1,86 @@
1
+ name: Publish to npm and create GitHub Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version:
7
+ description: "Version to publish, e.g. 1.2.3"
8
+ required: true
9
+ type: string
10
+ prerelease:
11
+ description: "Mark GitHub Release as prerelease"
12
+ required: true
13
+ type: boolean
14
+ default: false
15
+
16
+ permissions:
17
+ contents: write
18
+ id-token: write
19
+
20
+ jobs:
21
+ publish:
22
+ runs-on: ubuntu-latest
23
+
24
+ steps:
25
+ - name: Checkout
26
+ uses: actions/checkout@v4
27
+
28
+ - name: Setup Node
29
+ uses: actions/setup-node@v4
30
+ with:
31
+ node-version: "24"
32
+ registry-url: "https://registry.npmjs.org"
33
+
34
+ - name: Upgrade npm
35
+ run: npm i -g npm@latest
36
+
37
+ - name: Install dependencies
38
+ run: npm ci
39
+
40
+ - name: Validate version
41
+ run: |
42
+ VERSION="${{ inputs.version }}"
43
+ if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
44
+ echo "Invalid semver: $VERSION"
45
+ exit 1
46
+ fi
47
+
48
+ - name: Check version does not already exist on npm
49
+ run: |
50
+ PKG_NAME=$(node -p "require('./package.json').name")
51
+ VERSION="${{ inputs.version }}"
52
+ if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then
53
+ echo "Version ${PKG_NAME}@${VERSION} already exists on npm"
54
+ exit 1
55
+ fi
56
+
57
+ - name: Update package version locally
58
+ run: npm version "${{ inputs.version }}" --no-git-tag-version
59
+
60
+ - name: Show package info
61
+ run: |
62
+ node -e "const p=require('./package.json'); console.log('Publishing:', p.name, p.version)"
63
+
64
+ - name: Build
65
+ run: npm run build --if-present
66
+
67
+ - name: Publish to npm
68
+ run: npm publish --provenance
69
+
70
+ - name: Create GitHub Release
71
+ env:
72
+ GH_TOKEN: ${{ github.token }}
73
+ run: |
74
+ VERSION="${{ inputs.version }}"
75
+ TAG="v$VERSION"
76
+
77
+ EXTRA_ARGS=""
78
+ if [ "${{ inputs.prerelease }}" = "true" ]; then
79
+ EXTRA_ARGS="--prerelease"
80
+ fi
81
+
82
+ gh release create "$TAG" \
83
+ --target "${{ github.sha }}" \
84
+ --title "$TAG" \
85
+ --generate-notes \
86
+ $EXTRA_ARGS
@@ -0,0 +1,5 @@
1
+ <component name="ProjectCodeStyleConfiguration">
2
+ <state>
3
+ <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
4
+ </state>
5
+ </component>
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {startServer} from '../src/server.js';
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ function getArg(name, defaultValue) {
8
+ const idx = args.indexOf(name);
9
+ if (idx === -1 || idx + 1 >= args.length) {
10
+ return defaultValue;
11
+ }
12
+ return args[idx + 1];
13
+ }
14
+
15
+ if (args.includes('--help') || args.includes('-h')) {
16
+ console.log([
17
+ '',
18
+ 'bridge-updater - Bridge App Update Server',
19
+ '',
20
+ '用法: bridge-updater [options]',
21
+ '',
22
+ '选项:',
23
+ ' --port <number> HTTP 端口 (默认: 51145)',
24
+ ' --mdns-host <string> mDNS 广播的 host 覆盖值',
25
+ ' --mdns-port <number> mDNS 广播的 port 覆盖值',
26
+ ' --mdns-path <string> API 路径前缀 (默认: "")',
27
+ ' --help, -h 显示帮助信息',
28
+ ''
29
+ ].join('\n'));
30
+ process.exit(0);
31
+ }
32
+
33
+ const port = parseInt(getArg('--port', '51145'), 10);
34
+ const mdnsHost = getArg('--mdns-host', '') || '';
35
+ const mdnsPort = parseInt(getArg('--mdns-port', '0'), 10) || 0;
36
+ const mdnsPath = (getArg('--mdns-path', '') || '').replace(/^\/+/, '').replace(/\/+$/, '');
37
+ const normalizedPath = mdnsPath ? '/' + mdnsPath : '';
38
+
39
+ startServer({port, mdnsHost, mdnsPort, mdnsPath: normalizedPath});
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "bridge-update-server",
3
+ "version": "1.0.0",
4
+ "description": "Bridge App Update Server",
5
+ "type": "module",
6
+ "bin": {
7
+ "bridge-updater": "./bin/bridge-updater.js"
8
+ },
9
+ "main": "src/server.js",
10
+ "scripts": {
11
+ "start": "node bin/bridge-updater.js"
12
+ },
13
+ "repository": {
14
+ "url": "git+https://github.com/NXY666/bridge-update-server.git",
15
+ "branch": "master"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.0.0"
19
+ },
20
+ "dependencies": {
21
+ "@homebridge/ciao": "^1.3.5",
22
+ "bonjour-service": "^1.3.0",
23
+ "express": "^5.2.1"
24
+ }
25
+ }
@@ -0,0 +1,147 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Bridge 下载</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ margin: 0;
11
+ padding: 0;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ min-height: 100vh;
20
+ padding: 24px;
21
+ background: #F0F2F5;
22
+ }
23
+
24
+ .card {
25
+ width: 100%;
26
+ max-width: 400px;
27
+ padding: 32px 24px 24px;
28
+ border-radius: 12px;
29
+ background: #FFF;
30
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
31
+ }
32
+
33
+ h1 {
34
+ font-size: 1.6rem;
35
+ font-weight: 700;
36
+ margin-bottom: 6px;
37
+ }
38
+
39
+ .version-info {
40
+ font-size: 0.85rem;
41
+ color: #888;
42
+ }
43
+
44
+ .version-info span {
45
+ margin-right: 12px;
46
+ }
47
+
48
+ #buttons {
49
+ display: flex;
50
+ flex-direction: column;
51
+ margin-top: 34px;
52
+ gap: 18px;
53
+ }
54
+
55
+ .btn {
56
+ font-size: 0.95rem;
57
+ font-weight: 500;
58
+ display: block;
59
+ width: 100%;
60
+ padding: 13px;
61
+ cursor: pointer;
62
+ text-align: center;
63
+ text-decoration: none;
64
+ color: #FFF;
65
+ border: none;
66
+ border-radius: 8px;
67
+ }
68
+
69
+ .btn-cache {
70
+ background: #1976D2;
71
+ }
72
+
73
+ .btn-github {
74
+ background: #2E7D32;
75
+ }
76
+
77
+ .status {
78
+ font-size: 0.9rem;
79
+ color: #999;
80
+ }
81
+
82
+ .status.error {
83
+ color: #C62828;
84
+ }
85
+
86
+ #sha256 {
87
+ font-size: 0.75rem;
88
+ margin-top: 18px;
89
+ word-break: break-all;
90
+ color: #AAA;
91
+ }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <div class="card">
96
+ <h1>Bridge</h1>
97
+ <div class="version-info" id="version-info">
98
+ <span class="status" id="status-text">正在获取版本信息…</span>
99
+ </div>
100
+ <div id="buttons" style="display: none;">
101
+ <a id="btn-cache" class="btn btn-cache">本地下载</a>
102
+ <a id="btn-github" class="btn btn-github" target="_blank" rel="noopener">GitHub 下载</a>
103
+ </div>
104
+ <div id="sha256"></div>
105
+ </div>
106
+ <script>
107
+ const base = window.location.pathname.replace(/[^/]*$/, '');
108
+ fetch(base + 'version')
109
+ .then(r => r.json())
110
+ .then(res => {
111
+ if (!res.state) {
112
+ document.getElementById('status-text').textContent = '版本信息不可用:' + res.message;
113
+ document.getElementById('status-text').className = 'status error';
114
+ return;
115
+ }
116
+
117
+ const {versionName, versionCode, downUrl, sha256} = res.data;
118
+
119
+ const versionInfo = document.getElementById('version-info');
120
+ versionInfo.innerHTML =
121
+ '<span>版本 ' + versionName + ' (' + versionCode + ')</span>';
122
+
123
+ const btnCache = document.getElementById('btn-cache');
124
+ const btnGithub = document.getElementById('btn-github');
125
+
126
+ if (downUrl.local) {
127
+ btnCache.href = base + 'download';
128
+ } else {
129
+ btnCache.removeAttribute('href');
130
+ btnCache.classList.add('btn-disabled');
131
+ }
132
+
133
+ btnGithub.href = downUrl.github;
134
+
135
+ if (sha256) {
136
+ document.getElementById('sha256').textContent = 'SHA-256: ' + sha256;
137
+ }
138
+
139
+ document.getElementById('buttons').style.display = '';
140
+ })
141
+ .catch(() => {
142
+ document.getElementById('status-text').textContent = '无法连接到服务器';
143
+ document.getElementById('status-text').className = 'status error';
144
+ });
145
+ </script>
146
+ </body>
147
+ </html>
package/scan.js ADDED
@@ -0,0 +1,395 @@
1
+ // mdns-scan.js
2
+ // 用法: node mdns-scan.js
3
+ // 说明: 监听本地 mDNS 流量并主动做 DNS-SD 枚举(IPv4)
4
+ // 需要 Node.js 18+(建议用最新 LTS)
5
+
6
+ import dgram from "node:dgram";
7
+ import os from "node:os";
8
+
9
+ const MDNS_ADDR = '224.0.0.251';
10
+ const MDNS_PORT = 5353;
11
+
12
+ const TYPE = {
13
+ A: 1,
14
+ PTR: 12,
15
+ TXT: 16,
16
+ AAAA: 28,
17
+ SRV: 33,
18
+ ANY: 255
19
+ };
20
+
21
+ const CLASS_IN = 1;
22
+
23
+ const knownServiceTypes = new Set();
24
+ const queriedNames = new Set();
25
+
26
+ function labelsToName(name) {
27
+ return name.endsWith('.') ? name : `${name}.`;
28
+ }
29
+
30
+ function encodeName(name) {
31
+ const fqdn = labelsToName(name);
32
+ const parts = fqdn.split('.').filter(Boolean);
33
+ const out = [];
34
+ for (const p of parts) {
35
+ const b = Buffer.from(p, 'utf8');
36
+ if (b.length > 63) {
37
+ throw new Error(`label too long: ${p}`);
38
+ }
39
+ out.push(Buffer.from([b.length]));
40
+ out.push(b);
41
+ }
42
+ out.push(Buffer.from([0]));
43
+ return Buffer.concat(out);
44
+ }
45
+
46
+ function buildQueryPacket({id = 0, questions = []}) {
47
+ // 标准 DNS 头 12 字节
48
+ const header = Buffer.alloc(12);
49
+ header.writeUInt16BE(id & 0xffff, 0); // ID(mDNS 中通常忽略)
50
+ header.writeUInt16BE(0x0000, 2); // Flags = query
51
+ header.writeUInt16BE(questions.length, 4); // QDCOUNT
52
+ header.writeUInt16BE(0, 6); // ANCOUNT
53
+ header.writeUInt16BE(0, 8); // NSCOUNT
54
+ header.writeUInt16BE(0, 10); // ARCOUNT
55
+
56
+ const qbufs = [];
57
+ for (const q of questions) {
58
+ qbufs.push(encodeName(q.name));
59
+ const tail = Buffer.alloc(4);
60
+ tail.writeUInt16BE(q.type, 0);
61
+ tail.writeUInt16BE(q.class || CLASS_IN, 2);
62
+ qbufs.push(tail);
63
+ }
64
+
65
+ return Buffer.concat([header, ...qbufs]);
66
+ }
67
+
68
+ function readName(buf, offset, depth = 0) {
69
+ if (depth > 20) {
70
+ throw new Error('DNS name compression too deep');
71
+ }
72
+
73
+ const labels = [];
74
+ let pos = offset;
75
+ let consumed = 0;
76
+ let jumped = false;
77
+
78
+ while (true) {
79
+ if (pos >= buf.length) {
80
+ throw new Error('readName out of bounds');
81
+ }
82
+ const len = buf[pos];
83
+
84
+ // pointer: 11xxxxxx xxxxxxxx
85
+ if ((len & 0xc0) === 0xc0) {
86
+ if (pos + 1 >= buf.length) {
87
+ throw new Error('bad compression pointer');
88
+ }
89
+ const ptr = ((len & 0x3f) << 8) | buf[pos + 1];
90
+ if (!jumped) {
91
+ consumed += 2;
92
+ }
93
+ const rec = readName(buf, ptr, depth + 1);
94
+ labels.push(rec.name.replace(/\.$/, ''));
95
+ pos += 2;
96
+ jumped = true;
97
+ break;
98
+ }
99
+
100
+ if (len === 0) {
101
+ if (!jumped) {
102
+ consumed += 1;
103
+ }
104
+ pos += 1;
105
+ break;
106
+ }
107
+
108
+ const start = pos + 1;
109
+ const end = start + len;
110
+ if (end > buf.length) {
111
+ throw new Error('label out of bounds');
112
+ }
113
+
114
+ labels.push(buf.slice(start, end).toString('utf8'));
115
+ pos = end;
116
+ if (!jumped) {
117
+ consumed += 1 + len;
118
+ }
119
+ }
120
+
121
+ return {
122
+ name: labels.filter(Boolean).join('.') + '.',
123
+ bytes: consumed
124
+ };
125
+ }
126
+
127
+ function parseTXT(rdata) {
128
+ const out = [];
129
+ let i = 0;
130
+ while (i < rdata.length) {
131
+ const n = rdata[i];
132
+ i += 1;
133
+ if (i + n > rdata.length) {
134
+ break;
135
+ }
136
+ out.push(rdata.slice(i, i + n).toString('utf8'));
137
+ i += n;
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function parseIPv6(buf) {
143
+ const parts = [];
144
+ for (let i = 0; i < 16; i += 2) {
145
+ parts.push(buf.readUInt16BE(i).toString(16));
146
+ }
147
+ return parts.join(':').replace(/\b:?(?:0:){2,}/, '::');
148
+ }
149
+
150
+ function parseRRData(type, rdata, fullBuf, rdataOffset) {
151
+ try {
152
+ switch (type) {
153
+ case TYPE.A:
154
+ if (rdata.length !== 4) {
155
+ return {raw: rdata.toString('hex')};
156
+ }
157
+ return {address: `${rdata[0]}.${rdata[1]}.${rdata[2]}.${rdata[3]}`};
158
+
159
+ case TYPE.AAAA:
160
+ if (rdata.length !== 16) {
161
+ return {raw: rdata.toString('hex')};
162
+ }
163
+ return {address: parseIPv6(rdata)};
164
+
165
+ case TYPE.PTR: {
166
+ const n = readName(fullBuf, rdataOffset);
167
+ return {ptrdname: n.name};
168
+ }
169
+
170
+ case TYPE.SRV: {
171
+ if (rdata.length < 6) {
172
+ return {raw: rdata.toString('hex')};
173
+ }
174
+ const priority = rdata.readUInt16BE(0);
175
+ const weight = rdata.readUInt16BE(2);
176
+ const port = rdata.readUInt16BE(4);
177
+ const target = readName(fullBuf, rdataOffset + 6).name;
178
+ return {priority, weight, port, target};
179
+ }
180
+
181
+ case TYPE.TXT:
182
+ return {txt: parseTXT(rdata)};
183
+
184
+ default:
185
+ return {raw: rdata.toString('hex')};
186
+ }
187
+ } catch (e) {
188
+ return {parseError: String(e.message || e), raw: rdata.toString('hex')};
189
+ }
190
+ }
191
+
192
+ function parsePacket(buf) {
193
+ if (buf.length < 12) {
194
+ throw new Error('packet too short');
195
+ }
196
+
197
+ const header = {
198
+ id: buf.readUInt16BE(0),
199
+ flags: buf.readUInt16BE(2),
200
+ qd: buf.readUInt16BE(4),
201
+ an: buf.readUInt16BE(6),
202
+ ns: buf.readUInt16BE(8),
203
+ ar: buf.readUInt16BE(10)
204
+ };
205
+
206
+ let off = 12;
207
+
208
+ const questions = [];
209
+ for (let i = 0; i < header.qd; i++) {
210
+ const qn = readName(buf, off);
211
+ off += qn.bytes;
212
+ if (off + 4 > buf.length) {
213
+ throw new Error('question tail out of bounds');
214
+ }
215
+ const qtype = buf.readUInt16BE(off);
216
+ const qclass = buf.readUInt16BE(off + 2);
217
+ off += 4;
218
+ questions.push({name: qn.name, type: qtype, class: qclass});
219
+ }
220
+
221
+ function parseRRSection(count) {
222
+ const arr = [];
223
+ for (let i = 0; i < count; i++) {
224
+ const nn = readName(buf, off);
225
+ off += nn.bytes;
226
+ if (off + 10 > buf.length) {
227
+ throw new Error('RR header out of bounds');
228
+ }
229
+
230
+ const type = buf.readUInt16BE(off);
231
+ const rrclass = buf.readUInt16BE(off + 2);
232
+ const ttl = buf.readUInt32BE(off + 4);
233
+ const rdlen = buf.readUInt16BE(off + 8);
234
+ off += 10;
235
+
236
+ const rdataOffset = off;
237
+ const rdataEnd = off + rdlen;
238
+ if (rdataEnd > buf.length) {
239
+ throw new Error('RDATA out of bounds');
240
+ }
241
+ const rdata = buf.slice(rdataOffset, rdataEnd);
242
+
243
+ const data = parseRRData(type, rdata, buf, rdataOffset);
244
+ off = rdataEnd;
245
+
246
+ arr.push({
247
+ name: nn.name,
248
+ type,
249
+ class: rrclass & 0x7fff,
250
+ cacheFlush: Boolean(rrclass & 0x8000),
251
+ ttl,
252
+ data
253
+ });
254
+ }
255
+ return arr;
256
+ }
257
+
258
+ const answers = parseRRSection(header.an);
259
+ const authorities = parseRRSection(header.ns);
260
+ const additionals = parseRRSection(header.ar);
261
+
262
+ return {header, questions, answers, authorities, additionals};
263
+ }
264
+
265
+ function rrTypeName(t) {
266
+ return Object.entries(TYPE).find(([, v]) => v === t)?.[0] || `TYPE${t}`;
267
+ }
268
+
269
+ function logRR(prefix, rr) {
270
+ const base = `${prefix} ${rr.name} ${rrTypeName(rr.type)} ttl=${rr.ttl}`;
271
+ const d = rr.data;
272
+
273
+ if (d.ptrdname) {
274
+ console.log(`${base} -> ${d.ptrdname}`);
275
+ } else if (d.address) {
276
+ console.log(`${base} -> ${d.address}`);
277
+ } else if (d.port !== undefined) {
278
+ console.log(`${base} -> ${d.target}:${d.port} (pri=${d.priority}, w=${d.weight})`);
279
+ } else if (d.txt) {
280
+ console.log(`${base} -> TXT ${JSON.stringify(d.txt)}`);
281
+ } else {
282
+ console.log(`${base} -> ${JSON.stringify(d)}`);
283
+ }
284
+ }
285
+
286
+ function sendPTRQuery(sock, name) {
287
+ const fqdn = labelsToName(name);
288
+ if (queriedNames.has(fqdn)) {
289
+ return;
290
+ }
291
+ queriedNames.add(fqdn);
292
+
293
+ const packet = buildQueryPacket({
294
+ questions: [{name: fqdn, type: TYPE.PTR, class: CLASS_IN}]
295
+ });
296
+
297
+ sock.send(packet, MDNS_PORT, MDNS_ADDR, (err) => {
298
+ if (err) {
299
+ console.error(`[send query error] ${fqdn}`, err.message);
300
+ } else {
301
+ console.log(`\n[QUERY] PTR ${fqdn}`);
302
+ }
303
+ });
304
+ }
305
+
306
+ function getIPv4Interfaces() {
307
+ const nets = os.networkInterfaces();
308
+ const out = [];
309
+ for (const [ifname, addrs] of Object.entries(nets)) {
310
+ for (const a of addrs || []) {
311
+ if (a.family === 'IPv4' && !a.internal) {
312
+ out.push({ifname, address: a.address});
313
+ }
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+
319
+ function main() {
320
+ const sock = dgram.createSocket({type: 'udp4', reuseAddr: true});
321
+
322
+ sock.on('error', (err) => {
323
+ console.error('[socket error]', err);
324
+ });
325
+
326
+ sock.on('message', (msg, rinfo) => {
327
+ let pkt;
328
+ try {
329
+ pkt = parsePacket(msg);
330
+ } catch (e) {
331
+ console.error(`[parse error] from ${rinfo.address}:${rinfo.port}:`, e.message);
332
+ return;
333
+ }
334
+
335
+ const isQuery = (pkt.header.flags & 0x8000) === 0;
336
+ const qr = isQuery ? 'Q' : 'R';
337
+ console.log(`\n[${qr}] ${rinfo.address}:${rinfo.port} len=${msg.length} qd=${pkt.header.qd} an=${pkt.header.an} ns=${pkt.header.ns} ar=${pkt.header.ar}`);
338
+
339
+ for (const q of pkt.questions) {
340
+ console.log(` ? ${q.name} ${rrTypeName(q.type)}`);
341
+ }
342
+
343
+ for (const rr of [...pkt.answers, ...pkt.authorities, ...pkt.additionals]) {
344
+ logRR(' *', rr);
345
+
346
+ // 如果是服务类型枚举返回的 PTR(_services._dns-sd._udp.local -> _http._tcp.local)
347
+ if (
348
+ rr.type === TYPE.PTR &&
349
+ rr.name.toLowerCase() === '_services._dns-sd._udp.local.' &&
350
+ rr.data?.ptrdname
351
+ ) {
352
+ const serviceType = rr.data.ptrdname.toLowerCase();
353
+ if (!knownServiceTypes.has(serviceType)) {
354
+ knownServiceTypes.add(serviceType);
355
+ console.log(` [+] 发现服务类型: ${serviceType}`);
356
+ // 继续枚举这个服务类型下的实例
357
+ sendPTRQuery(sock, serviceType);
358
+ }
359
+ }
360
+ }
361
+ });
362
+
363
+ sock.bind(MDNS_PORT, () => {
364
+ try {
365
+ sock.addMembership(MDNS_ADDR);
366
+ sock.setMulticastTTL(255); // mDNS 常用 hop limit/TTL 255
367
+ sock.setMulticastLoopback(true);
368
+ } catch (e) {
369
+ console.error('[multicast setup error]', e.message);
370
+ }
371
+
372
+ const ifs = getIPv4Interfaces();
373
+ console.log('[mDNS listener started]');
374
+ console.log(` bind: 0.0.0.0:${MDNS_PORT}`);
375
+ console.log(` multicast: ${MDNS_ADDR}:${MDNS_PORT}`);
376
+ console.log(` interfaces: ${ifs.map(i => `${i.ifname}(${i.address})`).join(', ') || '(none)'}`);
377
+
378
+ // 先做服务类型枚举(RFC 6763 Section 9)
379
+ sendPTRQuery(sock, '_services._dns-sd._udp.local');
380
+
381
+ // 可选:顺便查询常见服务类型(加快看到结果)
382
+ setTimeout(() => sendPTRQuery(sock, '_http._tcp.local'), 500);
383
+ setTimeout(() => sendPTRQuery(sock, '_ipp._tcp.local'), 700);
384
+ setTimeout(() => sendPTRQuery(sock, '_airplay._tcp.local'), 900);
385
+ setTimeout(() => sendPTRQuery(sock, '_googlecast._tcp.local'), 1100);
386
+ });
387
+
388
+ process.on('SIGINT', () => {
389
+ console.log('\n[exit]');
390
+ try { sock.close(); } catch {}
391
+ process.exit(0);
392
+ });
393
+ }
394
+
395
+ main();
package/src/cache.js ADDED
@@ -0,0 +1,171 @@
1
+ import {createReadStream, createWriteStream, existsSync} from 'node:fs';
2
+ import {mkdir, rename, stat, unlink} from 'node:fs/promises';
3
+ import {join} from 'node:path';
4
+ import {tmpdir} from 'node:os';
5
+ import {createHash} from 'node:crypto';
6
+ import {pipeline} from 'node:stream/promises';
7
+ import {Readable} from 'node:stream';
8
+ import {getVersionInfo} from './github.js';
9
+
10
+ const CACHE_DIR = join(tmpdir(), 'bridge-updater', 'apk');
11
+ const APK_FILENAME = 'latest-app.apk';
12
+ let downloading = false;
13
+
14
+ // 获取已缓存APK的文件路径
15
+ export function getCachedApkPath() {
16
+ const filePath = join(CACHE_DIR, APK_FILENAME);
17
+ return existsSync(filePath) ? filePath : null;
18
+ }
19
+
20
+ // 是否已有APK缓存
21
+ export function hasCachedApk() {
22
+ return getCachedApkPath() !== null;
23
+ }
24
+
25
+ // 获取APK文件流
26
+ export function getCachedApkStream() {
27
+ const filePath = getCachedApkPath();
28
+ if (!filePath) {
29
+ return null;
30
+ }
31
+ return createReadStream(filePath);
32
+ }
33
+
34
+ // 获取APK文件大小
35
+ export async function getCachedApkSize() {
36
+ const filePath = getCachedApkPath();
37
+ if (!filePath) {
38
+ return 0;
39
+ }
40
+ const s = await stat(filePath);
41
+ return s.size;
42
+ }
43
+
44
+ function computeSha256(filePath) {
45
+ return new Promise((resolve, reject) => {
46
+ const hash = createHash('sha256');
47
+ const stream = createReadStream(filePath);
48
+ stream.on('data', chunk => hash.update(chunk));
49
+ stream.on('end', () => resolve(hash.digest('hex')));
50
+ stream.on('error', reject);
51
+ });
52
+ }
53
+
54
+ // 验证本地缓存文件的SHA256是否匹配
55
+ export async function verifyCachedApk() {
56
+ const info = getVersionInfo();
57
+ const filePath = getCachedApkPath();
58
+ if (!filePath || !info?.sha256) {
59
+ return false;
60
+ }
61
+ const actual = await computeSha256(filePath);
62
+ return actual === info.sha256;
63
+ }
64
+
65
+ async function downloadFromUrl(url, destPath) {
66
+ const res = await fetch(url, {
67
+ headers: {'User-Agent': 'bridge-update-server'}
68
+ });
69
+ if (!res.ok) {
70
+ throw new Error('下载失败: ' + res.status);
71
+ }
72
+ await mkdir(join(destPath, '..'), {recursive: true});
73
+ const fileStream = createWriteStream(destPath);
74
+ await pipeline(Readable.fromWeb(res.body), fileStream);
75
+ }
76
+
77
+ // 强制重新下载APK并替换本地缓存
78
+ export async function triggerRebuild(discoverPeers) {
79
+ const info = getVersionInfo();
80
+ if (!info || downloading) {
81
+ return;
82
+ }
83
+
84
+ downloading = true;
85
+ const tempPath = join(CACHE_DIR, APK_FILENAME + '.tmp');
86
+ const finalPath = join(CACHE_DIR, APK_FILENAME);
87
+
88
+ try {
89
+ await mkdir(CACHE_DIR, {recursive: true});
90
+ await unlink(finalPath).catch(() => {});
91
+ let downloaded = false;
92
+
93
+ // 优先从局域网peer下载
94
+ if (discoverPeers) {
95
+ try {
96
+ const peers = await discoverPeers();
97
+ for (const peer of peers) {
98
+ try {
99
+ const peerVersionRes = await fetch(peer.versionUrl, {
100
+ signal: AbortSignal.timeout(5000)
101
+ });
102
+ if (!peerVersionRes.ok) {
103
+ continue;
104
+ }
105
+
106
+ const peerVersion = await peerVersionRes.json();
107
+ if (!peerVersion.state || peerVersion.data?.versionCode !== info.versionCode) {
108
+ continue;
109
+ }
110
+
111
+ console.log('[Cache]', '从局域网peer下载', 'peer=', peer.downloadUrl);
112
+ await downloadFromUrl(peer.downloadUrl, tempPath);
113
+ downloaded = true;
114
+ break;
115
+ } catch {
116
+ // peer不可用,继续尝试下一个
117
+ }
118
+ }
119
+ } catch {
120
+ // peer发现失败
121
+ }
122
+ }
123
+
124
+ // 从GitHub下载
125
+ if (!downloaded) {
126
+ console.log('[Cache]', '从GitHub下载', 'url=', info.apkUrl);
127
+ await downloadFromUrl(info.apkUrl, tempPath);
128
+ }
129
+
130
+ // 验证SHA256
131
+ if (info.sha256) {
132
+ const actual = await computeSha256(tempPath);
133
+ if (actual !== info.sha256) {
134
+ console.warn('[Cache]', 'SHA256验证失败', 'expected=', info.sha256, 'actual=', actual);
135
+ await unlink(tempPath).catch(() => {});
136
+ return;
137
+ }
138
+ console.log('[Cache]', 'SHA256验证成功');
139
+ }
140
+
141
+ await rename(tempPath, finalPath);
142
+ console.log('[Cache]', 'APK缓存完成');
143
+ } catch (err) {
144
+ console.warn('[Cache]', '下载失败', 'error=', err.message);
145
+ await unlink(tempPath).catch(() => {});
146
+ } finally {
147
+ downloading = false;
148
+ }
149
+ }
150
+
151
+ // 确保APK缓存可用。返回true表示已就绪,false表示正在同步中
152
+ export async function syncApk(discoverPeers) {
153
+ const info = getVersionInfo();
154
+ if (!info || downloading) return false;
155
+
156
+ const filePath = getCachedApkPath();
157
+ if (!filePath) {
158
+ triggerRebuild(discoverPeers);
159
+ return false;
160
+ }
161
+
162
+ if (info.sha256) {
163
+ const actual = await computeSha256(filePath);
164
+ if (actual !== info.sha256) {
165
+ console.warn('[Cache]', 'SHA256不匹配', 'expected=', info.sha256, 'actual=', actual);
166
+ triggerRebuild(discoverPeers);
167
+ return false;
168
+ }
169
+ }
170
+ return true;
171
+ }
package/src/github.js ADDED
@@ -0,0 +1,135 @@
1
+ import {mkdir, readFile, writeFile} from 'node:fs/promises';
2
+ import {join} from 'node:path';
3
+ import {tmpdir} from 'node:os';
4
+
5
+ const REPO = 'NXY666/bridge-app';
6
+ const API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
7
+ const CACHE_DIR = join(tmpdir(), 'bridge-updater');
8
+ const VERSION_FILE = join(CACHE_DIR, 'version.json');
9
+ const POLL_INTERVAL = 60 * 60 * 1000;
10
+
11
+ let versionInfo = null;
12
+ let afterPollCallback = null;
13
+
14
+ async function fetchLatestRelease() {
15
+ const res = await fetch(API_URL, {
16
+ headers: {
17
+ 'Accept': 'application/vnd.github+json',
18
+ 'User-Agent': 'bridge-update-server'
19
+ }
20
+ });
21
+ if (!res.ok) {
22
+ console.warn('[GitHub]', '获取最新发布失败', 'status=', res.status);
23
+ return null;
24
+ }
25
+ const release = await res.json();
26
+
27
+ // 查找APK资源
28
+ const apkAsset = release.assets.find(a => /^Bridge_v.*\.apk$/.test(a.name));
29
+ if (!apkAsset) {
30
+ console.warn('[GitHub]', '未找到APK资源');
31
+ return null;
32
+ }
33
+
34
+ // 查找metadata.json资源
35
+ const metadataAsset = release.assets.find(a => a.name === 'metadata.json');
36
+ if (!metadataAsset) {
37
+ console.warn('[GitHub]', '未找到metadata.json资源');
38
+ return null;
39
+ }
40
+
41
+ // 下载metadata.json
42
+ const metaRes = await fetch(metadataAsset.browser_download_url, {
43
+ headers: {'User-Agent': 'bridge-update-server'}
44
+ });
45
+ if (!metaRes.ok) {
46
+ console.warn('[GitHub]', '下载metadata.json失败', 'status=', metaRes.status);
47
+ return null;
48
+ }
49
+ const metadata = await metaRes.json();
50
+
51
+ // 从GitHub API的digest字段获取SHA256
52
+ const sha256 = apkAsset.digest ? apkAsset.digest.replace(/^sha256:/, '') : '';
53
+
54
+ return {
55
+ versionName: String(metadata.name),
56
+ versionCode: Number(metadata.code),
57
+ apkUrl: apkAsset.browser_download_url,
58
+ apkFileName: apkAsset.name,
59
+ sha256,
60
+ size: apkAsset.size,
61
+ cachedAt: Date.now()
62
+ };
63
+ }
64
+
65
+ async function loadCachedVersion() {
66
+ try {
67
+ const data = await readFile(VERSION_FILE, 'utf8');
68
+ const parsed = JSON.parse(data);
69
+ if (Date.now() - parsed.cachedAt < POLL_INTERVAL) {
70
+ return parsed;
71
+ }
72
+ } catch {
73
+ // 无可用缓存
74
+ }
75
+ return null;
76
+ }
77
+
78
+ async function saveVersionCache(info) {
79
+ await mkdir(CACHE_DIR, {recursive: true});
80
+ await writeFile(VERSION_FILE, JSON.stringify(info, null, 2));
81
+ }
82
+
83
+ async function poll() {
84
+ try {
85
+ const info = await fetchLatestRelease();
86
+ if (info) {
87
+ versionInfo = info;
88
+ await saveVersionCache(info);
89
+ console.log('[GitHub]', '版本信息已更新', 'version=', info.versionName, 'code=', info.versionCode);
90
+ if (afterPollCallback) {
91
+ afterPollCallback(info);
92
+ }
93
+ }
94
+ } catch (err) {
95
+ console.warn('[GitHub]', '轮询失败', 'error=', err.message);
96
+ }
97
+ }
98
+
99
+ // 获取当前版本信息
100
+ export function getVersionInfo() {
101
+ return versionInfo;
102
+ }
103
+
104
+ // 获取APK的GitHub直链
105
+ export function getApkUrl() {
106
+ return versionInfo?.apkUrl || '';
107
+ }
108
+
109
+ // 注册每次轮询成功后的回调
110
+ export function onPollComplete(cb) {
111
+ afterPollCallback = cb;
112
+ }
113
+
114
+ // 启动GitHub轮询
115
+ export async function startGithubPolling() {
116
+ const cached = await loadCachedVersion();
117
+ if (cached) {
118
+ versionInfo = cached;
119
+
120
+ if (afterPollCallback) {
121
+ afterPollCallback(cached);
122
+ }
123
+
124
+ // 等待缓存过期后再开始轮询
125
+ setTimeout(async () => {
126
+ await poll();
127
+ setInterval(poll, POLL_INTERVAL);
128
+ }, POLL_INTERVAL - (Date.now() - cached.cachedAt));
129
+
130
+ console.log('[GitHub]', '使用缓存的版本信息', 'version=', cached.versionName);
131
+ } else {
132
+ await poll();
133
+ setInterval(poll, POLL_INTERVAL);
134
+ }
135
+ }
package/src/mdns.js ADDED
@@ -0,0 +1,78 @@
1
+ import ciao from '@homebridge/ciao';
2
+ import {Bonjour} from 'bonjour-service';
3
+ import {randomUUID} from 'node:crypto';
4
+
5
+ const SERVICE_NAME = 'Bridge Updater';
6
+ const SERVICE_TYPE = 'bridge-updater';
7
+
8
+ let bonjourInstance = null;
9
+ let ciaoResponder = null;
10
+ let publishedService = null;
11
+ const instanceId = randomUUID();
12
+
13
+ // 启动mDNS广播
14
+ export async function startMdns(port, mdnsHost, mdnsPort, mdnsPath) {
15
+ bonjourInstance = new Bonjour();
16
+ ciaoResponder = ciao.getResponder();
17
+
18
+ const txt = {instance: instanceId};
19
+ if (mdnsPath) {
20
+ txt.path = mdnsPath;
21
+ }
22
+ if (mdnsHost) {
23
+ txt.host = mdnsHost;
24
+ }
25
+ if (mdnsPort) {
26
+ txt.port = String(mdnsPort);
27
+ }
28
+
29
+ publishedService = ciaoResponder.createService({
30
+ name: SERVICE_NAME,
31
+ type: SERVICE_TYPE,
32
+ port,
33
+ txt
34
+ });
35
+ await publishedService.advertise();
36
+
37
+ console.log('[mDNS]', '服务已发布', 'type=', SERVICE_TYPE, 'port=', port);
38
+ }
39
+
40
+ // 扫描局域网内的其它update-server
41
+ export function discoverPeers() {
42
+ return new Promise((resolve) => {
43
+ if (!bonjourInstance) {
44
+ resolve([]);
45
+ return;
46
+ }
47
+
48
+ const peers = [];
49
+ const browser = bonjourInstance.find({type: SERVICE_TYPE});
50
+
51
+ browser.on('up', (service) => {
52
+ // 过滤自身
53
+ if (service.txt?.instance === instanceId) {
54
+ return;
55
+ }
56
+
57
+ const txtPath = service.txt?.path || '';
58
+ const txtHost = service.txt?.host;
59
+ const txtPort = service.txt?.port;
60
+
61
+ const host = txtHost || service.host;
62
+ const port = txtPort ? parseInt(txtPort) : service.port;
63
+
64
+ peers.push({
65
+ name: service.name,
66
+ host,
67
+ port,
68
+ versionUrl: `http://${host}:${port}${txtPath}/version`,
69
+ downloadUrl: `http://${host}:${port}${txtPath}/download`
70
+ });
71
+ });
72
+
73
+ setTimeout(() => {
74
+ browser.stop();
75
+ resolve(peers);
76
+ }, 10000);
77
+ });
78
+ }
package/src/router.js ADDED
@@ -0,0 +1,72 @@
1
+ import {fileURLToPath} from 'node:url';
2
+ import {dirname, join} from 'node:path';
3
+ import express, {Router} from 'express';
4
+ import {getVersionInfo} from './github.js';
5
+ import {getCachedApkSize, getCachedApkStream, hasCachedApk, syncApk} from './cache.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ // 创建REST API路由
10
+ export function createRouter(mdnsPath, discoverPeers) {
11
+ const router = Router();
12
+
13
+ // 静态文件
14
+ router.use(express.static(join(__dirname, '../public')));
15
+
16
+ // 获取最新版本信息
17
+ router.get('/version', (req, res) => {
18
+ const info = getVersionInfo();
19
+ if (!info) {
20
+ return res.status(503).json({
21
+ state: false,
22
+ message: '版本信息尚未获取'
23
+ });
24
+ }
25
+
26
+ let localUrl = null;
27
+ if (hasCachedApk()) {
28
+ const proto = req.get('X-Forwarded-Proto') || req.protocol || 'http';
29
+ const host = req.get('X-Forwarded-Host') || req.get('Host');
30
+ localUrl = `${proto}://${host}${mdnsPath}/download`;
31
+ }
32
+
33
+ res.json({
34
+ state: true,
35
+ data: {
36
+ versionName: info.versionName,
37
+ versionCode: info.versionCode,
38
+ downUrl: {
39
+ local: localUrl,
40
+ github: info.apkUrl
41
+ },
42
+ sha256: info.sha256
43
+ }
44
+ });
45
+ });
46
+
47
+ // 下载APK
48
+ router.get('/download', async (req, res) => {
49
+ const ready = await syncApk(discoverPeers);
50
+ if (ready) {
51
+ const size = await getCachedApkSize();
52
+ const filename = 'Bridge_v' + getVersionInfo().versionName + '.apk';
53
+ res.set('Content-Type', 'application/vnd.android.package-archive');
54
+ res.set('Content-Disposition', 'attachment; filename="' + filename + '"');
55
+ if (size > 0) {
56
+ res.set('Content-Length', String(size));
57
+ }
58
+ getCachedApkStream().pipe(res);
59
+ return;
60
+ }
61
+ const apkUrl = getVersionInfo()?.apkUrl;
62
+ if (!apkUrl) {
63
+ return res.status(503).json({
64
+ state: false,
65
+ message: 'APK尚未可用'
66
+ });
67
+ }
68
+ res.redirect(302, apkUrl);
69
+ });
70
+
71
+ return router;
72
+ }
package/src/server.js ADDED
@@ -0,0 +1,37 @@
1
+ import express from 'express';
2
+ import {createServer} from 'node:http';
3
+ import {createRouter} from './router.js';
4
+ import {onPollComplete, startGithubPolling} from './github.js';
5
+ import {syncApk} from './cache.js';
6
+ import {discoverPeers, startMdns} from './mdns.js';
7
+
8
+ export async function startServer(options) {
9
+ const {port, mdnsHost, mdnsPort, mdnsPath} = options;
10
+
11
+ const app = express();
12
+ const server = createServer(app);
13
+
14
+ // 挂载路由
15
+ const router = createRouter(mdnsPath, discoverPeers);
16
+ if (mdnsPath) {
17
+ app.use(mdnsPath, router);
18
+ } else {
19
+ app.use(router);
20
+ }
21
+
22
+ // 每次轮询成功后同步APK
23
+ onPollComplete(() => syncApk(discoverPeers));
24
+
25
+ // 启动GitHub轮询
26
+ await startGithubPolling();
27
+
28
+ // 启动HTTP服务
29
+ server.listen(port, () => {
30
+ console.log('[Server]', '服务已启动', 'port=', port);
31
+ });
32
+
33
+ // 启动mDNS广播
34
+ await startMdns(port, mdnsHost, mdnsPort, mdnsPath);
35
+
36
+ return server;
37
+ }