Compare commits
4 Commits
main
...
84462f165a
| Author | SHA1 | Date | |
|---|---|---|---|
| 84462f165a | |||
| 2d1fe95be0 | |||
| 67fb44fc73 | |||
| 582b9e10fa |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,6 +6,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.idea
|
||||
.vite
|
||||
.vscode
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
@@ -45,8 +49,6 @@ typings/
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
4
.idea/electron-all.iml
generated
4
.idea/electron-all.iml
generated
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vite" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
margin: auto;
|
||||
max-width: 38rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
804
package-lock.json
generated
804
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,6 @@
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"electron": "^41.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "3.8.1",
|
||||
@@ -44,6 +42,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",
|
||||
@@ -52,7 +51,6 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
|
||||
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,43 @@ 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();
|
||||
|
||||
// ========== Bonjour 服务发现 ==========
|
||||
const bonjour = new Bonjour();
|
||||
let discoveredServices = new Map();
|
||||
|
||||
function getDiscoveredServices() {
|
||||
return Array.from(discoveredServices.values());
|
||||
}
|
||||
|
||||
function startBonjourDiscovery() {
|
||||
const browser = bonjour.find({});
|
||||
|
||||
browser.on('up', (service) => {
|
||||
console.log('[bonjour] Service up:', service.name);
|
||||
discoveredServices.set(service.fqdn, service);
|
||||
notifyServicesChanged();
|
||||
});
|
||||
|
||||
browser.on('down', (service) => {
|
||||
console.log('[bonjour] Service down:', service.name);
|
||||
discoveredServices.delete(service.fqdn);
|
||||
notifyServicesChanged();
|
||||
});
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
function notifyServicesChanged() {
|
||||
const services = getDiscoveredServices();
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send('bonjour:services-updated', services);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== OpenCode 服务管理 ==========
|
||||
const DEFAULT_PORT = 4096;
|
||||
let opencodeProcess = null;
|
||||
@@ -36,7 +70,10 @@ function waitForReady(port, timeout = 15000) {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
const socket = net.createConnection({ port, host: '127.0.0.1' });
|
||||
socket.once('connect', () => { socket.end(); resolve(); });
|
||||
socket.once('connect', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
if (Date.now() - start >= timeout) return reject(new Error('OpenCode 服务启动超时'));
|
||||
@@ -92,7 +129,9 @@ async function startOpencode() {
|
||||
});
|
||||
|
||||
opencodeProcess.stdout?.on('data', (d) => console.log(`[opencode] ${d.toString().trim()}`));
|
||||
opencodeProcess.stderr?.on('data', (d) => console.error(`[opencode error] ${d.toString().trim()}`));
|
||||
opencodeProcess.stderr?.on('data', (d) =>
|
||||
console.error(`[opencode error] ${d.toString().trim()}`)
|
||||
);
|
||||
opencodeProcess.once('error', (e) => console.error('[opencode spawn error]', e));
|
||||
opencodeProcess.once('close', (code) => {
|
||||
console.log(`[opencode exited] code=${code}`);
|
||||
@@ -126,7 +165,10 @@ function stopOpencode() {
|
||||
// ========== IPC Handlers ==========
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle('opencode:start', () => startOpencode());
|
||||
ipcMain.handle('opencode:stop', () => { stopOpencode(); return buildInfo(); });
|
||||
ipcMain.handle('opencode:stop', () => {
|
||||
stopOpencode();
|
||||
return buildInfo();
|
||||
});
|
||||
ipcMain.handle('opencode:info', () => buildInfo());
|
||||
ipcMain.handle('opencode:port', () => opencodePort);
|
||||
|
||||
@@ -158,6 +200,9 @@ function registerIpcHandlers() {
|
||||
if (!res.ok) throw new Error(`发送消息失败: ${res.status}`);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
// Bonjour
|
||||
ipcMain.handle('bonjour:get-services', () => getDiscoveredServices());
|
||||
}
|
||||
|
||||
// ========== 窗口 ==========
|
||||
@@ -195,14 +240,13 @@ const createWindow = () => {
|
||||
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`)
|
||||
);
|
||||
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
|
||||
}
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers();
|
||||
startBonjourDiscovery();
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -16,3 +16,12 @@ contextBridge.exposeInMainWorld('opencode', {
|
||||
createSession: (data) => ipcRenderer.invoke('opencode:session:create', data),
|
||||
sendMessage: (sessionId, text) => ipcRenderer.invoke('opencode:session:send', sessionId, text),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('bonjour', {
|
||||
getServices: () => ipcRenderer.invoke('bonjour:get-services'),
|
||||
onServicesUpdated: (callback) => {
|
||||
const listener = (_event, services) => callback(services);
|
||||
ipcRenderer.on('bonjour:services-updated', listener);
|
||||
return () => ipcRenderer.removeListener('bonjour:services-updated', listener);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* This file will automatically be loaded by vite and run in the "renderer" context.
|
||||
* To learn more about the differences between the "main" and the "renderer" context in
|
||||
* Electron, visit:
|
||||
*
|
||||
* https://electronjs.org/docs/tutorial/process-model
|
||||
*
|
||||
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
|
||||
* in a renderer process, please be aware of potential security implications. You can read
|
||||
* more about security risks here:
|
||||
*
|
||||
* https://electronjs.org/docs/tutorial/security
|
||||
*
|
||||
* To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
|
||||
* flag:
|
||||
*
|
||||
* ```
|
||||
* // Create the browser window.
|
||||
* mainWindow = new BrowserWindow({
|
||||
* width: 800,
|
||||
* height: 600,
|
||||
* webPreferences: {
|
||||
* nodeIntegration: true
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
console.log(
|
||||
'👋 This message is being logged by "renderer.js", included via Vite',
|
||||
);
|
||||
@@ -31,6 +31,10 @@
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<template #title>OpenCode 对话</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/bonjour">
|
||||
<el-icon><Search /></el-icon>
|
||||
<template #title>发现设备</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
@@ -47,7 +51,9 @@
|
||||
<!-- 主内容区 -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="flex items-center justify-between h-14 px-6 bg-white border-b border-gray-200 shrink-0">
|
||||
<header
|
||||
class="flex items-center justify-between h-14 px-6 bg-white border-b border-gray-200 shrink-0"
|
||||
>
|
||||
<h1 class="text-base font-medium text-gray-700">{{ currentTitle }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="32" class="bg-blue-500">U</el-avatar>
|
||||
@@ -68,7 +74,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { House, Monitor, Expand, Fold, Edit, ChatDotRound } from '@element-plus/icons-vue';
|
||||
import { House, Monitor, Expand, Fold, Edit, ChatDotRound, Search } from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
|
||||
@@ -17,6 +17,12 @@ const routes = [
|
||||
component: () => import('@/views/chat/ChatView.vue'),
|
||||
meta: { title: 'OpenCode 对话' },
|
||||
},
|
||||
{
|
||||
path: '/bonjour',
|
||||
name: 'Bonjour',
|
||||
component: () => import('@/views/bonjour/BonjourView.vue'),
|
||||
meta: { title: '发现设备' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
134
src/renderer/views/bonjour/BonjourView.vue
Normal file
134
src/renderer/views/bonjour/BonjourView.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Search, Refresh } from '@element-plus/icons-vue';
|
||||
|
||||
const services = ref([]);
|
||||
const loading = ref(false);
|
||||
let unsubscribe = null;
|
||||
|
||||
const fetchServices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
services.value = await window.bonjour.getServices();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bonjour services:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchServices();
|
||||
unsubscribe = window.bonjour.onServicesUpdated((updatedServices) => {
|
||||
services.value = updatedServices;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bonjour-container">
|
||||
<div class="header">
|
||||
<div class="title-section">
|
||||
<h2 class="title">发现设备</h2>
|
||||
<p class="subtitle">局域网内已发现的 mDNS / Bonjour 设备</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="fetchServices" circle />
|
||||
</div>
|
||||
|
||||
<el-table :data="services" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="protocol" label="协议" width="80" />
|
||||
<el-table-column prop="port" label="端口" width="80" />
|
||||
<el-table-column label="地址" min-width="180">
|
||||
<template #default="scope">
|
||||
<div v-for="addr in scope.row.addresses" :key="addr" class="address-tag">
|
||||
{{ addr }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="TXT 记录" min-width="200">
|
||||
<template #default="scope">
|
||||
<div v-if="Object.keys(scope.row.txt || {}).length > 0">
|
||||
<div v-for="(val, key) in scope.row.txt" :key="key" class="txt-record">
|
||||
<span class="txt-key">{{ key }}:</span> {{ val }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="empty-txt">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="services.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="暂未发现设备" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bonjour-container {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.address-tag {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.txt-record {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.txt-key {
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.empty-txt {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user