diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..f7edbb2 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,65 @@ +name: Release macOS Desktop + +on: + workflow_dispatch: + inputs: + release_tag: + description: GitHub Release tag to upload assets to + required: true + default: v4.0.0 + upload_assets: + description: Upload generated DMG files to the release + required: true + type: boolean + default: true + +permissions: + contents: write + +jobs: + build-macos: + name: macOS ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + os: macos-13 + - arch: arm64 + os: macos-14 + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install root dependencies + run: npm ci + env: + npm_config_arch: ${{ matrix.arch }} + + - name: Build macOS desktop package + run: npm run release:desktop:mac -- --arch=${{ matrix.arch }} + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + npm_config_arch: ${{ matrix.arch }} + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: 1Shell-mac-${{ matrix.arch }} + path: release/desktop-mac/1Shell-v*-mac-${{ matrix.arch }}.dmg + if-no-files-found: error + + - name: Upload to GitHub Release + if: ${{ inputs.upload_assets }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: gh release upload "$RELEASE_TAG" release/desktop-mac/1Shell-v*-mac-${{ matrix.arch }}.dmg --repo "$GITHUB_REPOSITORY" --clobber diff --git a/desktop-start.bat b/desktop-start.bat new file mode 100644 index 0000000..3b09817 --- /dev/null +++ b/desktop-start.bat @@ -0,0 +1,14 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal +cd /d "%~dp0" +title 1Shell Desktop + +if exist "%~dp0node\npm.cmd" ( + set "NPM_CMD=%~dp0node\npm.cmd" +) else ( + set "NPM_CMD=npm" +) + +"%NPM_CMD%" run desktop:dev +if errorlevel 1 pause diff --git a/electron/icon.ico b/electron/icon.ico new file mode 100644 index 0000000..d198416 Binary files /dev/null and b/electron/icon.ico differ diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..abe5f81 --- /dev/null +++ b/electron/main.js @@ -0,0 +1,397 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); +const path = require('path'); +const { spawn } = require('child_process'); +const { app, BrowserWindow, Menu, Tray, nativeImage, shell, dialog, ipcMain } = require('electron'); + +app.setName('1Shell'); +Menu.setApplicationMenu(null); + +let mainWindow = null; +let tray = null; +let backendProcess = null; +let backendRoot = null; +let backendPort = 3301; +let isQuitting = false; + +function mainLog(message) { + try { + const logPath = path.join(app.getPath('userData'), 'desktop-main.log'); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, 'utf8'); + } catch { + // Ignore early logging failures. + } +} + +function randomSecret(bytes = 24) { + return crypto.randomBytes(bytes).toString('base64url'); +} + +function resolveBackendRoot() { + if (app.isPackaged) return path.join(process.resourcesPath, 'backend'); + return path.resolve(__dirname, '..'); +} + +function getLogPath() { + return path.join(backendRoot, 'logs', 'desktop.log'); +} + +function appendLog(line) { + try { + fs.mkdirSync(path.dirname(getLogPath()), { recursive: true }); + fs.appendFileSync(getLogPath(), line, 'utf8'); + } catch { + // Logging must never prevent app startup. + } +} + +function findAvailablePort(startPort) { + return new Promise((resolve) => { + function probe(port) { + const server = net.createServer(); + server.once('error', () => probe(port + 1)); + server.once('listening', () => { + server.close(() => resolve(port)); + }); + server.listen(port, '127.0.0.1'); + } + probe(startPort); + }); +} + +function readEnvValue(name) { + const envPath = path.join(backendRoot, '.env'); + try { + const env = fs.readFileSync(envPath, 'utf8'); + const match = env.match(new RegExp(`^${name}=(.*)$`, 'm')); + return match ? match[1].trim() : ''; + } catch { + return ''; + } +} + +async function ensureEnv() { + const envPath = path.join(backendRoot, '.env'); + if (fs.existsSync(envPath)) { + const configuredPort = Number(readEnvValue('PORT')); + backendPort = Number.isInteger(configuredPort) && configuredPort > 0 ? configuredPort : 3301; + return; + } + + backendPort = await findAvailablePort(3301); + const content = [ + 'OPENAI_API_BASE=https://api.openai.com/v1', + 'OPENAI_API_KEY=', + 'OPENAI_MODEL=gpt-4o', + 'APP_LOGIN_USERNAME=', + 'APP_LOGIN_PASSWORD=', + `APP_SECRET=${randomSecret(32)}`, + 'APP_SESSION_TTL_HOURS=12', + `PORT=${backendPort}`, + `BRIDGE_TOKEN=${randomSecret(32)}`, + '', + ].join('\n'); + + fs.writeFileSync(envPath, content, 'utf8'); +} + +function backendCommand(serverPath) { + const bundledNode = path.join(backendRoot, 'node', 'node.exe'); + if (process.platform === 'win32' && fs.existsSync(bundledNode)) { + return { executable: bundledNode, args: [serverPath], env: {} }; + } + return { + executable: process.execPath, + args: [serverPath], + env: { ELECTRON_RUN_AS_NODE: '1' }, + }; +} + +function startBackend() { + if (backendProcess && !backendProcess.killed) return; + + const serverPath = path.join(backendRoot, 'server.js'); + const command = backendCommand(serverPath); + const env = { + ...process.env, + ...command.env, + NODE_PATH: path.join(backendRoot, 'deps'), + PORT: String(backendPort), + ELECTRON_DESKTOP: '1', + ONESHELL_DESKTOP: '1', + }; + + appendLog(`\n[desktop] starting backend: ${new Date().toISOString()}\n`); + backendProcess = spawn(command.executable, command.args, { + cwd: backendRoot, + env, + windowsHide: true, + }); + + backendProcess.stdout.on('data', chunk => appendLog(chunk.toString())); + backendProcess.stderr.on('data', chunk => appendLog(chunk.toString())); + backendProcess.on('exit', (code, signal) => { + appendLog(`[desktop] backend exited code=${code} signal=${signal}\n`); + backendProcess = null; + if (!isQuitting && mainWindow) showErrorPage('本地服务已停止。', '请从托盘菜单选择“重启服务”,或退出后重新打开 1Shell。'); + }); +} + +function stopBackend() { + if (!backendProcess || backendProcess.killed) return; + const pid = backendProcess.pid; + backendProcess.kill('SIGINT'); + if (process.platform === 'win32' && pid) { + setTimeout(() => { + if (backendProcess) spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true }); + }, 1500).unref(); + } +} + +function waitForHealth(deadlineMs = 30000) { + const startedAt = Date.now(); + const healthUrl = `http://127.0.0.1:${backendPort}/api/health`; + + return new Promise((resolve, reject) => { + function retry() { + if (Date.now() - startedAt > deadlineMs) { + reject(new Error(`Backend did not become ready at ${healthUrl}`)); + return; + } + setTimeout(probe, 500); + } + + function probe() { + const req = http.get(healthUrl, res => { + res.resume(); + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + req.on('error', retry); + req.setTimeout(1000, () => { + req.destroy(); + retry(); + }); + } + + probe(); + }); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function loadingHtml(message, detail = '') { + const safeMessage = escapeHtml(message); + const safeDetail = escapeHtml(detail); + return `data:text/html;charset=utf-8,${encodeURIComponent(` +1Shell
1_
1Shell
${safeMessage}
${safeDetail ? `
${safeDetail}
` : '
'}
` )}`; +} + +function showErrorPage(message, detail = '') { + if (!mainWindow) return; + mainWindow.loadURL(loadingHtml(message, detail)); +} + +function getDesktopSettingsPath() { + return path.join(backendRoot, 'data', 'desktop-settings.json'); +} + +function readDesktopPreferences() { + try { + return JSON.parse(fs.readFileSync(getDesktopSettingsPath(), 'utf8')); + } catch { + return {}; + } +} + +function writeDesktopPreferences(preferences) { + const settingsPath = getDesktopSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(preferences, null, 2), 'utf8'); +} + +function getLoginItemOptions() { + return process.platform === 'win32' ? { path: process.execPath } : {}; +} + +function desktopSettings() { + const preferences = readDesktopPreferences(); + const loginItemSettings = app.getLoginItemSettings(getLoginItemOptions()); + return { + isDesktop: true, + runtime: 'electron', + backgroundEnabled: preferences.backgroundEnabled === true, + autostartEnabled: Boolean(loginItemSettings.openAtLogin), + autostartAvailable: process.platform === 'win32' || process.platform === 'darwin', + }; +} + +function setDesktopBackgroundEnabled(enabled) { + writeDesktopPreferences({ + ...readDesktopPreferences(), + backgroundEnabled: enabled, + }); + return desktopSettings(); +} + +function setDesktopAutostartEnabled(enabled) { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + throw new Error('当前系统不支持开机自启'); + } + app.setLoginItemSettings({ + ...getLoginItemOptions(), + openAtLogin: enabled, + openAsHidden: false, + }); + return desktopSettings(); +} + +function registerDesktopIpc() { + ipcMain.handle('desktop:get-settings', () => desktopSettings()); + ipcMain.handle('desktop:set-background-enabled', (_event, enabled) => setDesktopBackgroundEnabled(Boolean(enabled))); + ipcMain.handle('desktop:set-autostart-enabled', (_event, enabled) => setDesktopAutostartEnabled(Boolean(enabled))); +} + +function iconPath() { + const candidates = [ + path.join(backendRoot, 'frontend', 'dist', 'favicon.png'), + path.join(backendRoot, 'public', 'favicon.ico'), + ]; + return candidates.find(candidate => fs.existsSync(candidate)) || ''; +} + +function createWindow() { + const icon = iconPath(); + mainWindow = new BrowserWindow({ + width: 1280, + height: 860, + minWidth: 960, + minHeight: 640, + show: false, + title: '1Shell', + autoHideMenuBar: true, + icon: icon || undefined, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.on('close', (event) => { + if (isQuitting) return; + if (desktopSettings().backgroundEnabled && tray) { + event.preventDefault(); + mainWindow.hide(); + } + }); + mainWindow.on('closed', () => { + mainWindow = null; + if (!isQuitting) quitApp(); + }); + + mainWindow.loadURL(loadingHtml('正在启动本地服务,请稍候...')); +} + +function createTray() { + const icon = iconPath(); + if (!icon) return; + + const image = nativeImage.createFromPath(icon); + if (image.isEmpty()) return; + + tray = new Tray(image.resize({ width: 16, height: 16 })); + tray.setToolTip('1Shell'); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: '打开 1Shell', click: () => showMainWindow() }, + { label: '重启服务', click: () => restartBackend() }, + { label: '打开日志目录', click: () => shell.openPath(path.dirname(getLogPath())) }, + { type: 'separator' }, + { label: '退出', click: () => quitApp() }, + ])); + tray.on('double-click', () => showMainWindow()); +} + +function showMainWindow() { + if (!mainWindow) createWindow(); + mainWindow.show(); + mainWindow.focus(); +} + +async function loadApp() { + try { + await waitForHealth(); + await mainWindow.loadURL(`http://127.0.0.1:${backendPort}/app/?runtime=desktop`); + } catch (error) { + appendLog(`[desktop] ${error.stack || error.message}\n`); + showErrorPage('本地服务启动失败。', '请打开托盘菜单中的日志目录,查看 desktop.log。'); + } +} + +async function restartBackend() { + stopBackend(); + await new Promise(resolve => setTimeout(resolve, 1200)); + startBackend(); + if (mainWindow) mainWindow.loadURL(loadingHtml('正在重启本地服务...')); + await loadApp(); +} + +function quitApp() { + isQuitting = true; + stopBackend(); + app.quit(); +} + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + mainLog('single instance lock denied'); + app.quit(); +} else { + mainLog('single instance lock acquired'); + app.on('second-instance', () => showMainWindow()); + + app.whenReady().then(async () => { + mainLog('app ready'); + registerDesktopIpc(); + backendRoot = resolveBackendRoot(); + mainLog(`backendRoot=${backendRoot}`); + await ensureEnv(); + mainLog(`backendPort=${backendPort}`); + createWindow(); + createTray(); + startBackend(); + await loadApp(); + }).catch(error => { + mainLog(`startup failed: ${error.stack || error.message}`); + dialog.showErrorBox('1Shell failed to start', error.stack || error.message); + app.quit(); + }); + + app.on('activate', () => showMainWindow()); + app.on('before-quit', () => { + isQuitting = true; + stopBackend(); + }); + app.on('window-all-closed', () => { + if (!isQuitting) quitApp(); + }); +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..0bb8632 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,12 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('__ONESHELL_RUNTIME__', 'desktop'); +contextBridge.exposeInMainWorld('oneShellDesktop', { + runtime: 'desktop', + platform: process.platform, + getSettings: () => ipcRenderer.invoke('desktop:get-settings'), + setAutostartEnabled: enabled => ipcRenderer.invoke('desktop:set-autostart-enabled', Boolean(enabled)), + setBackgroundEnabled: enabled => ipcRenderer.invoke('desktop:set-background-enabled', Boolean(enabled)), +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c11a2c8..b2a31bd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,6 +10,8 @@ import AppAiFab from './components/AppAiFab.vue'; import ConfirmModal from './components/ConfirmModal.vue'; import AppBackground from './components/AppBackground.vue'; import LoginScreen from './components/main/LoginScreen.vue'; +import SettingsModal from './components/main/SettingsModal.vue'; +import { isDesktopRuntime } from './utils/desktop'; interface AuthStatusResp { enabled?: boolean; @@ -21,13 +23,14 @@ const { requestJson } = useApiClient(); const bootstrapping = ref(true); const bootstrapError = ref(null); +const settingsOpen = ref(false); function prefetchProbeSilently(force = false): void { void prefetchProbePageState(requestJson, force).catch(() => undefined); } -// 全局登录闸门:auth 启用且未登录 → 必须先登录 -const needLogin = computed(() => auth.enabled && !auth.authenticated); +const desktopRuntime = isDesktopRuntime(); +const needLogin = computed(() => !desktopRuntime && auth.enabled && !auth.authenticated); async function bootstrapAuth(): Promise { try { @@ -73,7 +76,7 @@ onMounted(() => {
- +
@@ -84,5 +87,6 @@ onMounted(() => { +
diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 2cc8b96..9c89f03 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -13,7 +13,6 @@ interface NavItem { const navItems: NavItem[] = [ { to: '/', label: '主页', icon: 'globe', title: '世界地图主页' }, { to: '/console', label: '主控', icon: 'console', title: '主控台' }, - { to: '/hosts', label: '主机', icon: 'server', title: 'VPS 仓库' }, { to: '/programs', label: '程序', icon: 'cog', title: '长驻程序' }, { to: '/scripts', label: '脚本', icon: 'terminal', title: '脚本库' }, { to: '/skills', label: '仓库', icon: 'package', title: '技能仓库' }, @@ -23,6 +22,8 @@ const navItems: NavItem[] = [ { to: '/audit', label: '审计', icon: 'clipboard', title: '审计日志' }, ]; +const emit = defineEmits<{ 'open-settings': [] }>(); + const isDark = ref(true); const themeIcon = ref<'sun' | 'moon'>('sun'); @@ -80,6 +81,15 @@ onMounted(syncFromDom); 主题 + diff --git a/frontend/src/components/main/SettingsModal.vue b/frontend/src/components/main/SettingsModal.vue index 9ef822b..7511bb4 100644 --- a/frontend/src/components/main/SettingsModal.vue +++ b/frontend/src/components/main/SettingsModal.vue @@ -1,9 +1,16 @@