feat(devices): 实现局域网mDNS设备发现功能

This commit is contained in:
2026-04-10 10:48:01 +08:00
parent 44c581dd44
commit 157c8914b0
9 changed files with 223 additions and 59 deletions

View File

@@ -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.

View File

@@ -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' }

View File

@@ -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

View File

@@ -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', {

View File

@@ -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>
<!-- 折叠按钮 -->

View File

@@ -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: '发现设备' },
},
],
},
]

View 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>