feat(devices): 实现局域网mDNS设备发现功能
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"await-to-js": "^3.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"element-plus": "^2.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -2524,6 +2525,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "2.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz",
|
||||
@@ -4350,6 +4357,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bonjour-service": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
|
||||
"integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -5281,6 +5298,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
||||
@@ -6834,7 +6863,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -9203,6 +9231,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dns-packet": "^5.2.2",
|
||||
"thunky": "^1.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
|
||||
@@ -10995,6 +11036,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-each-async": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"dependencies": {
|
||||
"await-to-js": "^3.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"element-plus": "^2.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
56
src/main.js
56
src/main.js
@@ -1,56 +0,0 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import path from 'node:path'
|
||||
import started from 'electron-squirrel-startup'
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
})
|
||||
|
||||
// and load the index.html of the app.
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`))
|
||||
}
|
||||
|
||||
// Open the DevTools.
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and import them here.
|
||||
@@ -4,9 +4,13 @@ import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
import started from 'electron-squirrel-startup'
|
||||
import Bonjour from 'bonjour-service'
|
||||
|
||||
if (started) app.quit()
|
||||
|
||||
const bonjour = new Bonjour()
|
||||
const devices = new Map()
|
||||
|
||||
// ========== OpenCode 服务管理 ==========
|
||||
const DEFAULT_PORT = 4096
|
||||
let opencodeProcess = null
|
||||
@@ -164,6 +168,25 @@ function registerIpcHandlers() {
|
||||
if (!res.ok) throw new Error(`发送消息失败: ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
|
||||
// ========== Bonjour 设备发现 IPC ==========
|
||||
ipcMain.handle('get-devices', () => {
|
||||
return Array.from(devices.values())
|
||||
})
|
||||
|
||||
ipcMain.on('refresh-devices', () => {
|
||||
// 重新扫描逻辑
|
||||
// const allWindows = BrowserWindow.getAllWindows();
|
||||
// const mainWindow = allWindows[0]; // 假设第一个是主窗口
|
||||
|
||||
// 停止并重新开始搜索
|
||||
// 注意:这里需要访问 browser 实例,我们可以在 registerIpcHandlers 外层定义或在应用启动时初始化
|
||||
if (global.bonjourBrowser) {
|
||||
global.bonjourBrowser.stop()
|
||||
devices.clear()
|
||||
global.bonjourBrowser.start()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 窗口 ==========
|
||||
@@ -184,6 +207,32 @@ const createWindow = () => {
|
||||
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show())
|
||||
|
||||
// Setup Bonjour discovery
|
||||
const browser = bonjour.find({})
|
||||
global.bonjourBrowser = browser
|
||||
|
||||
browser.on('up', (service) => {
|
||||
console.log('Found device:', service.name)
|
||||
const device = {
|
||||
id: service.fqdn,
|
||||
name: service.name,
|
||||
type: service.type,
|
||||
port: service.port,
|
||||
addresses: service.addresses,
|
||||
txt: service.txt,
|
||||
host: service.host,
|
||||
referer: service.referer,
|
||||
}
|
||||
devices.set(device.id, device)
|
||||
mainWindow.webContents.send('device-found', device)
|
||||
})
|
||||
|
||||
browser.on('down', (service) => {
|
||||
console.log('Lost device:', service.name)
|
||||
devices.delete(service.fqdn)
|
||||
mainWindow.webContents.send('device-lost', service.fqdn)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
@@ -5,6 +5,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
send: (channel, data) => ipcRenderer.send(channel, data),
|
||||
on: (channel, callback) => ipcRenderer.on(channel, (_event, ...args) => callback(...args)),
|
||||
invoke: (channel, data) => ipcRenderer.invoke(channel, data),
|
||||
|
||||
// Bonjour 设备发现 API
|
||||
onDeviceFound: (callback) => ipcRenderer.on('device-found', (_event, value) => callback(value)),
|
||||
onDeviceLost: (callback) => ipcRenderer.on('device-lost', (_event, value) => callback(value)),
|
||||
getDevices: () => ipcRenderer.invoke('get-devices'),
|
||||
refreshDevices: () => ipcRenderer.send('refresh-devices'),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('opencode', {
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<template #title>OpenCode 对话</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/devices">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<template #title>发现设备</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
|
||||
@@ -17,6 +17,12 @@ const routes = [
|
||||
component: () => import('@/views/chat/ChatView.vue'),
|
||||
meta: { title: 'OpenCode 对话' },
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'Devices',
|
||||
component: () => import('@/views/devices/DevicesView.vue'),
|
||||
meta: { title: '发现设备' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
109
src/renderer/views/devices/DevicesView.vue
Normal file
109
src/renderer/views/devices/DevicesView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">发现设备</h2>
|
||||
<p class="text-sm text-gray-500">局域网内已发现的 mDNS 设备</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Refresh" @click="refreshDevices" :loading="loading"> 重新扫描 </el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="devices.length === 0" class="flex-1 flex items-center justify-center">
|
||||
<el-empty description="未发现设备,正在扫描中..." />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<el-card v-for="device in devices" :key="device.id" shadow="hover" class="device-card">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-bold truncate" :title="device.name">{{ device.name }}</span>
|
||||
<el-tag size="small">{{ device.type }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-start">
|
||||
<span class="text-gray-400 w-16 shrink-0">地址:</span>
|
||||
<span class="text-gray-700 break-all">{{ device.addresses.join(', ') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-400 w-16 shrink-0">端口:</span>
|
||||
<span class="text-gray-700">{{ device.port }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-gray-400 w-16 shrink-0">主机名:</span>
|
||||
<span class="text-gray-700 truncate" :title="device.host">{{ device.host }}</span>
|
||||
</div>
|
||||
<div v-if="Object.keys(device.txt || {}).length > 0" class="mt-2 pt-2 border-t border-gray-100">
|
||||
<div class="text-xs font-semibold text-gray-500 mb-1">额外信息 (TXT):</div>
|
||||
<div v-for="(val, key) in device.txt" :key="key" class="flex items-start text-xs">
|
||||
<span class="text-gray-400 w-16 shrink-0">{{ key }}:</span>
|
||||
<span class="text-gray-600 break-all">{{ val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const list = await window.electronAPI.getDevices()
|
||||
devices.value = list
|
||||
} catch (err) {
|
||||
console.error('Failed to get devices:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDevices = () => {
|
||||
loading.value = true
|
||||
devices.value = []
|
||||
window.electronAPI.refreshDevices()
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
loadDevices()
|
||||
ElMessage.success('刷新指令已发送')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDevices()
|
||||
|
||||
window.electronAPI.onDeviceFound((device) => {
|
||||
const index = devices.value.findIndex((d) => d.id === device.id)
|
||||
if (index === -1) {
|
||||
devices.value.push(device)
|
||||
} else {
|
||||
devices.value[index] = device
|
||||
}
|
||||
})
|
||||
|
||||
window.electronAPI.onDeviceLost((deviceId) => {
|
||||
devices.value = devices.value.filter((d) => d.id !== deviceId)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// IPC 监听在渲染进程中可能需要清理,但在 Electron contextBridge 中
|
||||
// 如果是简单的 `on` 监听,关闭页面通常会自动处理,或者这里需要实现更复杂的移除逻辑
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-card :deep(.el-card__header) {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.device-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user