diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml deleted file mode 100644 index f7edbb2..0000000 --- a/.github/workflows/release-macos.yml +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 3b09817..0000000 --- a/desktop-start.bat +++ /dev/null @@ -1,14 +0,0 @@ -@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 deleted file mode 100644 index d198416..0000000 Binary files a/electron/icon.ico and /dev/null differ diff --git a/electron/main.js b/electron/main.js deleted file mode 100644 index abe5f81..0000000 --- a/electron/main.js +++ /dev/null @@ -1,397 +0,0 @@ -'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 deleted file mode 100644 index 0bb8632..0000000 --- a/electron/preload.js +++ /dev/null @@ -1,12 +0,0 @@ -'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 b2a31bd..c11a2c8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,8 +10,6 @@ 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; @@ -23,14 +21,13 @@ 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); } -const desktopRuntime = isDesktopRuntime(); -const needLogin = computed(() => !desktopRuntime && auth.enabled && !auth.authenticated); +// 全局登录闸门:auth 启用且未登录 → 必须先登录 +const needLogin = computed(() => auth.enabled && !auth.authenticated); async function bootstrapAuth(): Promise { try { @@ -76,7 +73,7 @@ onMounted(() => {
- +
@@ -87,6 +84,5 @@ onMounted(() => { -
diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 9c89f03..2cc8b96 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -13,6 +13,7 @@ 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: '技能仓库' }, @@ -22,8 +23,6 @@ const navItems: NavItem[] = [ { to: '/audit', label: '审计', icon: 'clipboard', title: '审计日志' }, ]; -const emit = defineEmits<{ 'open-settings': [] }>(); - const isDark = ref(true); const themeIcon = ref<'sun' | 'moon'>('sun'); @@ -81,15 +80,6 @@ onMounted(syncFromDom); 主题 - diff --git a/frontend/src/components/main/SettingsModal.vue b/frontend/src/components/main/SettingsModal.vue index 7511bb4..9ef822b 100644 --- a/frontend/src/components/main/SettingsModal.vue +++ b/frontend/src/components/main/SettingsModal.vue @@ -1,16 +1,9 @@