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(`
+
1Shell1_
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 @@