feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
101d2164b4
commit
fea6d8db04
72 files changed
+3242
-331
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# Waffo Pancake (NEVER commit real keys)
|
||||
WAFFO_MERCHANT_ID=MER_586CtDwoHUbrnFJyudo3t4
|
||||
WAFFO_PRIVATE_KEY=
|
||||
WAFFO_ENVIRONMENT=test
|
||||
|
||||
# Store / product (from Pancake Dashboard)
|
||||
WAFFO_STORE_ID=
|
||||
WAFFO_STORE_SLUG=opsxshell-pwsi6m4n
|
||||
WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5
|
||||
|
||||
# Optional: permanent hosted checkout URL (preferred when set)
|
||||
WAFFO_CHECKOUT_URL=https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_0pcnzzT4nalEkkcdyIUoD5?type=onetime¤cy=USD&test=true
|
||||
|
||||
# One-time Pro price used by bootstrap (USD)
|
||||
WAFFO_PRODUCT_PRICE=29.00
|
||||
|
||||
# Must match desktop verify key (src-tauri license service DEFAULT / OPSX_LICENSE_HMAC)
|
||||
OPSX_LICENSE_HMAC=opsxshell-dev-license-hmac-v1-change-in-prod
|
||||
|
||||
PORT=8787
|
||||
SUCCESS_URL=opsxshell://pro/activated
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8787
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
*.log
|
||||
@@ -0,0 +1,43 @@
|
||||
# OpsXShell License Server
|
||||
|
||||
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发。
|
||||
|
||||
**私钥只放本机环境变量,不要写入仓库或桌面端。**
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd license-server
|
||||
cp .env.example .env
|
||||
# 填入 WAFFO_PRIVATE_KEY(Dashboard 测试密钥)
|
||||
npm install
|
||||
npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env
|
||||
npm run start
|
||||
```
|
||||
|
||||
桌面端默认请求 `http://127.0.0.1:8787`。可用 `VITE_LICENSE_SERVER_URL` 覆盖。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 健康检查 |
|
||||
| POST | `/checkout` | `{ deviceId }` → `{ checkoutUrl }`(onetime) |
|
||||
| POST | `/webhooks` | Waffo webhook(`order.completed`) |
|
||||
| POST | `/activate` | `{ deviceId }` 取回已签发许可证 |
|
||||
| POST | `/issue-test-license` | 开发用:直接签发终身 Pro token |
|
||||
|
||||
## 测试许可证(无需支付)
|
||||
|
||||
```bash
|
||||
npm run issue-dev-license -- --deviceId '*'
|
||||
# 临时授权可加 --days 30
|
||||
```
|
||||
|
||||
将返回的 `token` 粘贴到 App「已有许可证?激活」。
|
||||
|
||||
## 安全
|
||||
|
||||
- `@waffo/pancake-ts` 仅在本服务使用
|
||||
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
|
||||
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@waffo/pancake-ts": "^0.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@waffo/pancake-ts": {
|
||||
"version": "0.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@waffo/pancake-ts/-/pancake-ts-0.1.9.tgz",
|
||||
"integrity": "sha512-Gbb/Bbe6JqF4m1L+NKaAcGEA9RY4+kceVKgIA3uwZqdP8skTmi7PIUDXdPTAeRYmx9uZUqkKLhoHFJ1H2K88JA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "opsxshell-license-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "OpsXShell Pro license + Waffo Pancake checkout (server-side only)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"start": "node src/server.mjs",
|
||||
"bootstrap": "node scripts/bootstrap-products.mjs",
|
||||
"issue-dev-license": "node scripts/issue-dev-license.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waffo/pancake-ts": "^0.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* First-time bootstrap: create Store "OpsXShell" and one-time Pro product,
|
||||
* then write IDs into .env (never logs the private key).
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const ENV_PATH = join(ROOT, '.env')
|
||||
|
||||
function loadEnv() {
|
||||
if (!existsSync(ENV_PATH)) {
|
||||
console.error('缺少 .env — 请先 cp .env.example .env 并填入 WAFFO_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
const raw = readFileSync(ENV_PATH, 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEnv(key, value) {
|
||||
let raw = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : ''
|
||||
const line = `${key}=${value}`
|
||||
const re = new RegExp(`^${key}=.*$`, 'm')
|
||||
if (re.test(raw)) raw = raw.replace(re, line)
|
||||
else raw = `${raw.trimEnd()}\n${line}\n`
|
||||
writeFileSync(ENV_PATH, raw)
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const merchantId = process.env.WAFFO_MERCHANT_ID
|
||||
const privateKey = process.env.WAFFO_PRIVATE_KEY
|
||||
if (!merchantId || !privateKey) {
|
||||
console.error('需要 WAFFO_MERCHANT_ID 与 WAFFO_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { WaffoPancake } = require('@waffo/pancake-ts')
|
||||
const client = new WaffoPancake({
|
||||
merchantId,
|
||||
privateKey,
|
||||
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
|
||||
})
|
||||
|
||||
async function main() {
|
||||
let storeId = process.env.WAFFO_STORE_ID
|
||||
if (!storeId) {
|
||||
console.log('Creating store OpsXShell…')
|
||||
const store = await client.stores.create({
|
||||
name: 'OpsXShell',
|
||||
slug: `opsxshell-${Date.now().toString(36)}`,
|
||||
})
|
||||
storeId = store.id || store.storeId
|
||||
if (!storeId) throw new Error(`stores.create 未返回 id: ${JSON.stringify(store)}`)
|
||||
upsertEnv('WAFFO_STORE_ID', storeId)
|
||||
console.log('Store:', storeId)
|
||||
} else {
|
||||
console.log('Using existing store:', storeId)
|
||||
}
|
||||
|
||||
if (process.env.WAFFO_PRODUCT_ID) {
|
||||
console.log(`Using WAFFO_PRODUCT_ID=${process.env.WAFFO_PRODUCT_ID}`)
|
||||
console.log('\nBootstrap complete.')
|
||||
return
|
||||
}
|
||||
|
||||
const price = process.env.WAFFO_PRODUCT_PRICE || '29.00'
|
||||
console.log(`Creating one-time product OpsXShell Pro (USD ${price})…`)
|
||||
const { product } = await client.onetimeProducts.create({
|
||||
storeId,
|
||||
name: 'OpsXShell Pro',
|
||||
description: 'One-time unlock for OpsXShell professional features',
|
||||
prices: {
|
||||
USD: { amount: price, taxCategory: 'software' },
|
||||
},
|
||||
successUrl: process.env.SUCCESS_URL || 'opsxshell://pro/activated',
|
||||
metadata: { app: 'opsxshell', license: 'lifetime' },
|
||||
})
|
||||
const id = product?.id
|
||||
if (!id) throw new Error(`create onetime product failed: ${JSON.stringify(product)}`)
|
||||
|
||||
if (client.onetimeProducts.publish) {
|
||||
await client.onetimeProducts.publish({ id }).catch(() => undefined)
|
||||
}
|
||||
upsertEnv('WAFFO_PRODUCT_ID', id)
|
||||
console.log('WAFFO_PRODUCT_ID', id)
|
||||
console.log('\nBootstrap complete. Product ID written to .env')
|
||||
console.log('Next: npm start')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = join(ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function arg(name, fallback) {
|
||||
const idx = args.indexOf(`--${name}`)
|
||||
if (idx >= 0 && args[idx + 1]) return args[idx + 1]
|
||||
return fallback
|
||||
}
|
||||
|
||||
const deviceId = arg('deviceId', '*')
|
||||
const daysArg = arg('days', null)
|
||||
const secret =
|
||||
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
plan: 'pro',
|
||||
deviceId,
|
||||
iat: now,
|
||||
features: ['*'],
|
||||
}
|
||||
if (daysArg != null) {
|
||||
payload.exp = now + Number(daysArg) * 86400
|
||||
}
|
||||
const payloadB64 = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', secret).update(payloadB64).digest()
|
||||
const token = `opsx1.${payloadB64}.${b64url(sig)}`
|
||||
|
||||
console.log(JSON.stringify({ token, payload }, null, 2))
|
||||
@@ -0,0 +1,333 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const DATA_DIR = join(ROOT, 'data')
|
||||
const ENTITLEMENTS_PATH = join(DATA_DIR, 'entitlements.json')
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = join(ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
const raw = readFileSync(envPath, 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
let WaffoPancake
|
||||
let verifyWebhook
|
||||
try {
|
||||
const sdk = require('@waffo/pancake-ts')
|
||||
WaffoPancake = sdk.WaffoPancake
|
||||
verifyWebhook = sdk.verifyWebhook
|
||||
} catch (error) {
|
||||
console.warn('[opsxshell-license] @waffo/pancake-ts not installed yet:', error.message)
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787)
|
||||
const LICENSE_HMAC =
|
||||
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
|
||||
const SUCCESS_URL = process.env.SUCCESS_URL || 'opsxshell://pro/activated'
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function issueLicense({ deviceId, days = null, features = ['*'] }) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
plan: 'pro',
|
||||
deviceId: deviceId || '*',
|
||||
iat: now,
|
||||
features,
|
||||
}
|
||||
// Lifetime (one-time) licenses omit exp. Pass days for temporary/test tokens.
|
||||
if (days != null && Number(days) > 0) {
|
||||
payload.exp = now + Math.max(1, Number(days)) * 86400
|
||||
}
|
||||
const payloadB64 = b64url(JSON.stringify(payload))
|
||||
const sig = createHmac('sha256', LICENSE_HMAC).update(payloadB64).digest()
|
||||
return { token: `opsx1.${payloadB64}.${b64url(sig)}`, payload }
|
||||
}
|
||||
|
||||
async function loadEntitlements() {
|
||||
try {
|
||||
return JSON.parse(await readFile(ENTITLEMENTS_PATH, 'utf8'))
|
||||
} catch {
|
||||
return { byDevice: {}, byOrder: {}, pending: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEntitlements(data) {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(ENTITLEMENTS_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (!WaffoPancake) throw new Error('请先在 license-server 目录 npm install @waffo/pancake-ts')
|
||||
const merchantId = process.env.WAFFO_MERCHANT_ID
|
||||
const privateKey = process.env.WAFFO_PRIVATE_KEY
|
||||
if (!merchantId || !privateKey) {
|
||||
throw new Error('缺少 WAFFO_MERCHANT_ID / WAFFO_PRIVATE_KEY(见 .env.example)')
|
||||
}
|
||||
return new WaffoPancake({
|
||||
merchantId,
|
||||
privateKey,
|
||||
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function productId() {
|
||||
return process.env.WAFFO_PRODUCT_ID || ''
|
||||
}
|
||||
|
||||
function buildHostedCheckoutUrl(deviceId) {
|
||||
const success = `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`
|
||||
if (process.env.WAFFO_CHECKOUT_URL) {
|
||||
const url = new URL(process.env.WAFFO_CHECKOUT_URL)
|
||||
if (!url.searchParams.get('successUrl')) url.searchParams.set('successUrl', success)
|
||||
url.searchParams.set('deviceId', deviceId)
|
||||
return url.toString()
|
||||
}
|
||||
const slug = process.env.WAFFO_STORE_SLUG
|
||||
const pid = productId()
|
||||
if (slug && pid) {
|
||||
const url = new URL(`https://pancake.waffo.ai/store/${slug}/product/${pid}`)
|
||||
url.searchParams.set('type', 'onetime')
|
||||
url.searchParams.set('currency', 'USD')
|
||||
if ((process.env.WAFFO_ENVIRONMENT || 'test') === 'test') {
|
||||
url.searchParams.set('test', 'true')
|
||||
}
|
||||
url.searchParams.set('successUrl', success)
|
||||
url.searchParams.set('deviceId', deviceId)
|
||||
return url.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks)
|
||||
const text = raw.toString('utf8')
|
||||
let json = null
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
return { text, json }
|
||||
}
|
||||
|
||||
function send(res, status, body) {
|
||||
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Waffo-Signature',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
send(res, 204, '')
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`)
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
send(res, 200, {
|
||||
ok: true,
|
||||
hasWaffoKey: Boolean(process.env.WAFFO_PRIVATE_KEY),
|
||||
sdk: Boolean(WaffoPancake),
|
||||
productId: Boolean(productId()),
|
||||
pricing: 'onetime',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/checkout') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '').trim()
|
||||
if (!deviceId) {
|
||||
send(res, 400, { error: 'deviceId 必填' })
|
||||
return
|
||||
}
|
||||
|
||||
const pid = productId()
|
||||
const storeId = process.env.WAFFO_STORE_ID
|
||||
let checkoutUrl = null
|
||||
let mode = 'hosted'
|
||||
|
||||
// Prefer API session when merchant key + store/product IDs are ready
|
||||
if (pid && storeId && process.env.WAFFO_PRIVATE_KEY) {
|
||||
try {
|
||||
const client = getClient()
|
||||
const session = await client.checkout.createSession({
|
||||
storeId,
|
||||
productId: pid,
|
||||
productType: 'onetime',
|
||||
currency: 'USD',
|
||||
successUrl: `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`,
|
||||
darkMode: true,
|
||||
metadata: { deviceId, app: 'opsxshell', license: 'lifetime' },
|
||||
})
|
||||
checkoutUrl = session.checkoutUrl || session.url
|
||||
mode = 'session'
|
||||
} catch (error) {
|
||||
console.warn('[checkout] createSession failed, falling back to hosted URL:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkoutUrl) {
|
||||
checkoutUrl = buildHostedCheckoutUrl(deviceId)
|
||||
}
|
||||
|
||||
if (!checkoutUrl) {
|
||||
send(res, 500, {
|
||||
error:
|
||||
'未配置结账信息。请在 license-server/.env 填写 WAFFO_PRODUCT_ID 与 WAFFO_STORE_SLUG(或 WAFFO_CHECKOUT_URL)',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const store = await loadEntitlements()
|
||||
store.pending = store.pending || {}
|
||||
store.pending[deviceId] = {
|
||||
productId: pid || null,
|
||||
createdAt: Date.now(),
|
||||
mode,
|
||||
}
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, { checkoutUrl, deviceId, pricing: 'onetime', mode })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/webhooks') {
|
||||
const { text } = await readBody(req)
|
||||
if (!verifyWebhook) {
|
||||
send(res, 500, { error: 'SDK verifyWebhook 不可用' })
|
||||
return
|
||||
}
|
||||
const signature = String(req.headers['x-waffo-signature'] || '')
|
||||
let event
|
||||
try {
|
||||
event = verifyWebhook(text, signature)
|
||||
} catch (error) {
|
||||
send(res, 401, { error: 'Invalid signature', detail: String(error) })
|
||||
return
|
||||
}
|
||||
|
||||
const eventType = String(event.eventType || event.type || '')
|
||||
const data = event.data || {}
|
||||
const metadata = data.metadata || {}
|
||||
const deviceId = metadata.deviceId || data.deviceId
|
||||
const orderId = data.orderId || data.id
|
||||
const store = await loadEntitlements()
|
||||
|
||||
if (
|
||||
eventType.includes('order.completed') ||
|
||||
eventType.includes('OrderCompleted')
|
||||
) {
|
||||
if (deviceId) {
|
||||
const issued = issueLicense({ deviceId })
|
||||
store.byDevice[deviceId] = {
|
||||
status: 'active',
|
||||
orderId,
|
||||
token: issued.token,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
if (orderId) {
|
||||
store.byOrder = store.byOrder || {}
|
||||
store.byOrder[orderId] = {
|
||||
status: 'completed',
|
||||
deviceId,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, 'OK')
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/activate') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '').trim()
|
||||
if (!deviceId) {
|
||||
send(res, 400, { error: 'deviceId 必填' })
|
||||
return
|
||||
}
|
||||
const store = await loadEntitlements()
|
||||
const existing = store.byDevice[deviceId]
|
||||
if (existing?.status === 'active' && existing.token) {
|
||||
send(res, 200, { token: existing.token, status: 'active' })
|
||||
return
|
||||
}
|
||||
if (json?.forceIssue) {
|
||||
const days = json.days != null ? Number(json.days) : null
|
||||
const issued = issueLicense({ deviceId, days })
|
||||
store.byDevice[deviceId] = {
|
||||
status: 'active',
|
||||
token: issued.token,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
await saveEntitlements(store)
|
||||
send(res, 200, { token: issued.token, status: 'active' })
|
||||
return
|
||||
}
|
||||
send(res, 404, { error: '尚未收到付款完成事件,请完成支付或使用测试许可证' })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/issue-test-license') {
|
||||
const { json } = await readBody(req)
|
||||
const deviceId = String(json?.deviceId || '*').trim() || '*'
|
||||
// Default: lifetime. Pass days for temporary tokens.
|
||||
const days = json?.days != null ? Number(json.days) : null
|
||||
send(res, 200, issueLicense({ deviceId, days }))
|
||||
return
|
||||
}
|
||||
|
||||
send(res, 404, { error: 'not found' })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
send(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[opsxshell-license] http://127.0.0.1:${PORT}`)
|
||||
console.log(`[opsxshell-license] waffo key: ${Boolean(process.env.WAFFO_PRIVATE_KEY)}`)
|
||||
})
|
||||
Generated
+32
@@ -882,6 +882,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1683,6 +1684,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2205,6 +2215,26 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.29"
|
||||
@@ -2690,6 +2720,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"hmac",
|
||||
"open",
|
||||
"portable-pty",
|
||||
"rand 0.8.7",
|
||||
@@ -3784,6 +3815,7 @@ dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"io-kit-sys",
|
||||
"libudev",
|
||||
"mach2",
|
||||
"nix 0.26.4",
|
||||
"scopeguard",
|
||||
|
||||
@@ -27,8 +27,9 @@ ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
portable-pty = "0.9"
|
||||
serialport = { version = "4.8", default-features = false }
|
||||
serialport = { version = "4.8", default-features = false, features = ["libudev"] }
|
||||
tauri = { version = "2.8", features = [] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -331,9 +331,15 @@ fn permission_bits(mode: u32) -> String {
|
||||
}
|
||||
|
||||
fn expand_home(value: &str) -> PathBuf {
|
||||
if let Some(relative) = value.strip_prefix("~/") {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(relative);
|
||||
return home.join(std::path::Path::new(relative));
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::services::license::{self, LicenseStatus};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_get(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license::read_status(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_activate(app: AppHandle, token: String) -> Result<LicenseStatus, String> {
|
||||
license::activate(&app, &token)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_clear(app: AppHandle) -> Result<LicenseStatus, String> {
|
||||
license::clear(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn license_device_id(app: AppHandle) -> Result<String, String> {
|
||||
license::get_or_create_device_id(&app)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod ai;
|
||||
pub mod deep_link;
|
||||
pub mod files;
|
||||
pub mod known_hosts;
|
||||
pub mod license;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
|
||||
+14
-1
@@ -12,6 +12,15 @@ use tauri::{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
fn queue_ssh_link(app: &tauri::AppHandle, raw_url: &str) {
|
||||
if raw_url.starts_with("opsxshell://") {
|
||||
let _ = app.emit("app:pro-link", raw_url.to_string());
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
match state.deep_links.enqueue(raw_url) {
|
||||
Ok(true) => {
|
||||
@@ -36,7 +45,7 @@ pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
for argument in args {
|
||||
if argument.starts_with("ssh://") {
|
||||
if argument.starts_with("ssh://") || argument.starts_with("opsxshell://") {
|
||||
queue_ssh_link(app, &argument);
|
||||
}
|
||||
}
|
||||
@@ -112,6 +121,10 @@ pub fn run() {
|
||||
commands::known_hosts::known_hosts_list,
|
||||
commands::known_hosts::known_hosts_remove,
|
||||
commands::known_hosts::known_hosts_clear,
|
||||
commands::license::license_get,
|
||||
commands::license::license_activate,
|
||||
commands::license::license_clear,
|
||||
commands::license::license_device_id,
|
||||
]);
|
||||
|
||||
let builder = builder
|
||||
|
||||
@@ -43,11 +43,6 @@ fn set_directory_permissions(path: &std::path::Path) -> Result<(), String> {
|
||||
.map_err(|error| format!("设置密钥目录权限失败: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_directory_permissions(_path: &std::path::Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -55,7 +50,63 @@ fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
|
||||
.map_err(|error| format!("设置主密钥权限失败: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
/// Restrict ACL to the current user on Windows (best-effort via icacls).
|
||||
#[cfg(windows)]
|
||||
fn current_windows_user() -> Option<String> {
|
||||
std::env::var("USERNAME")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("USER").ok())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run_icacls(args: &[&str]) -> Result<(), String> {
|
||||
let status = std::process::Command::new("icacls")
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|error| format!("运行 icacls 失败: {error}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("icacls 返回失败状态: {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_directory_permissions(path: &std::path::Path) -> Result<(), String> {
|
||||
let Some(user) = current_windows_user() else {
|
||||
return Ok(());
|
||||
};
|
||||
let path_str = path.to_string_lossy();
|
||||
// Remove inherited ACEs, then grant current user modify on the directory tree.
|
||||
let _ = run_icacls(&[&path_str, "/inheritance:r"]);
|
||||
run_icacls(&[
|
||||
&path_str,
|
||||
"/grant:r",
|
||||
&format!("{user}:(OI)(CI)F"),
|
||||
])
|
||||
.or_else(|_| {
|
||||
// Fallback without propagation flags if the shell quoting differs.
|
||||
run_icacls(&[&path_str, "/grant:r", &format!("{user}:F")])
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_file_permissions(path: &std::path::Path) -> Result<(), String> {
|
||||
let Some(user) = current_windows_user() else {
|
||||
return Ok(());
|
||||
};
|
||||
let path_str = path.to_string_lossy();
|
||||
let _ = run_icacls(&[&path_str, "/inheritance:r"]);
|
||||
run_icacls(&[&path_str, "/grant:r", &format!("{user}:F")])
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn set_directory_permissions(_path: &std::path::Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Device-bound Pro license helpers (HMAC signed tokens).
|
||||
//! Signing secret stays on license-server; desktop embeds the matching
|
||||
//! verify key for offline activate (rotate for production).
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Dev/test HMAC shared with `license-server`. Override via `OPSX_LICENSE_HMAC`.
|
||||
pub const DEFAULT_LICENSE_HMAC: &str = "opsxshell-dev-license-hmac-v1-change-in-prod";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicensePayload {
|
||||
pub plan: String,
|
||||
pub device_id: String,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exp: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LicenseStatus {
|
||||
pub plan: String,
|
||||
pub device_id: String,
|
||||
pub activated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
pub features: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
fn hmac_secret() -> String {
|
||||
std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string())
|
||||
}
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("无法确定应用数据目录: {error}"))
|
||||
}
|
||||
|
||||
fn device_id_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(data_dir(app)?.join("device.id"))
|
||||
}
|
||||
|
||||
fn license_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(data_dir(app)?.join("license.opsx"))
|
||||
}
|
||||
|
||||
fn ensure_parent(path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| format!("创建数据目录失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_or_create_device_id(app: &AppHandle) -> Result<String, String> {
|
||||
let path = device_id_path(app)?;
|
||||
if path.exists() {
|
||||
let id = fs::read_to_string(&path).map_err(|error| format!("读取设备 ID 失败: {error}"))?;
|
||||
let trimmed = id.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
ensure_parent(&path)?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
fs::write(&path, &id).map_err(|error| format!("写入设备 ID 失败: {error}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn sign_payload(payload_b64: &str, secret: &str) -> Result<String, String> {
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| format!("HMAC 初始化失败: {error}"))?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Allow helpers used by unit tests / future issue endpoints.
|
||||
#[allow(dead_code)]
|
||||
pub fn issue_token(payload: &LicensePayload, secret: &str) -> Result<String, String> {
|
||||
let json = serde_json::to_vec(payload).map_err(|error| format!("序列化许可证失败: {error}"))?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(json);
|
||||
let sig = sign_payload(&payload_b64, secret)?;
|
||||
Ok(format!("opsx1.{payload_b64}.{sig}"))
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<LicensePayload, String> {
|
||||
let trimmed = token.trim();
|
||||
let parts: Vec<&str> = trimmed.split('.').collect();
|
||||
if parts.len() != 3 || parts[0] != "opsx1" {
|
||||
return Err("许可证格式无效".to_string());
|
||||
}
|
||||
let expected = sign_payload(parts[1], secret)?;
|
||||
if expected != parts[2] {
|
||||
return Err("许可证签名无效".to_string());
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|_| "许可证载荷损坏".to_string())?;
|
||||
let payload: LicensePayload =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "许可证载荷无法解析".to_string())?;
|
||||
if payload.plan != "pro" {
|
||||
return Err("许可证计划无效".to_string());
|
||||
}
|
||||
if let Some(exp) = payload.exp {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if now > exp {
|
||||
return Err("许可证已过期".to_string());
|
||||
}
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub fn read_status(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let path = license_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
let token = fs::read_to_string(&path).map_err(|error| format!("读取许可证失败: {error}"))?;
|
||||
match verify_token(&token, &hmac_secret()) {
|
||||
Ok(payload) => {
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
});
|
||||
}
|
||||
Ok(LicenseStatus {
|
||||
plan: payload.plan,
|
||||
device_id,
|
||||
activated: true,
|
||||
expires_at: payload.exp,
|
||||
features: payload.features,
|
||||
token: Some(token.trim().to_string()),
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(LicenseStatus {
|
||||
plan: "free".to_string(),
|
||||
device_id,
|
||||
activated: false,
|
||||
expires_at: None,
|
||||
features: vec![],
|
||||
token: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(app: &AppHandle, token: &str) -> Result<LicenseStatus, String> {
|
||||
let device_id = get_or_create_device_id(app)?;
|
||||
let payload = verify_token(token, &hmac_secret())?;
|
||||
if payload.device_id != "*" && payload.device_id != device_id {
|
||||
return Err("许可证与当前设备不匹配".to_string());
|
||||
}
|
||||
let path = license_path(app)?;
|
||||
ensure_parent(&path)?;
|
||||
fs::write(&path, token.trim()).map_err(|error| format!("保存许可证失败: {error}"))?;
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
pub fn clear(app: &AppHandle) -> Result<LicenseStatus, String> {
|
||||
let path = license_path(app)?;
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|error| format!("清除许可证失败: {error}"))?;
|
||||
}
|
||||
read_status(app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_token() {
|
||||
let payload = LicensePayload {
|
||||
plan: "pro".to_string(),
|
||||
device_id: "dev-1".to_string(),
|
||||
iat: 1,
|
||||
exp: None,
|
||||
features: vec!["*".to_string()],
|
||||
};
|
||||
let token = issue_token(&payload, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
let parsed = verify_token(&token, DEFAULT_LICENSE_HMAC).unwrap();
|
||||
assert_eq!(parsed.device_id, "dev-1");
|
||||
}
|
||||
}
|
||||
@@ -69,14 +69,20 @@ impl LocalTerminalService {
|
||||
options: LocalShellOptions,
|
||||
preferred_id: Option<String>,
|
||||
) -> Result<LocalShellCreateResponse, String> {
|
||||
let shell = options.shell.unwrap_or_else(default_shell);
|
||||
let mut shell = options.shell.unwrap_or_else(default_shell);
|
||||
if !Path::new(&shell).is_file() {
|
||||
// Prefer the first available shell when the preferred default vanished.
|
||||
if let Some(fallback) = self.list_shells().into_iter().next() {
|
||||
shell = fallback.path;
|
||||
}
|
||||
}
|
||||
if !Path::new(&shell).is_file() {
|
||||
return Err(format!("shell does not exist: {shell}"));
|
||||
}
|
||||
let cwd = options
|
||||
.cwd
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| env::var_os("HOME").map(PathBuf::from))
|
||||
.or_else(local_home_dir)
|
||||
.or_else(|| env::current_dir().ok())
|
||||
.ok_or_else(|| "unable to determine local shell working directory".to_string())?;
|
||||
let cols = options.cols.unwrap_or(80).max(2);
|
||||
@@ -93,8 +99,11 @@ impl LocalTerminalService {
|
||||
.map_err(|error| format!("failed to open local PTY: {error}"))?;
|
||||
let mut command = CommandBuilder::new(&shell);
|
||||
command.cwd(cwd);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
command.env("TERM", "xterm-256color");
|
||||
command.env("COLORTERM", "truecolor");
|
||||
}
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
@@ -205,25 +214,94 @@ fn spawn_reader(app: AppHandle, shell_id: String, mut reader: Box<dyn Read + Sen
|
||||
});
|
||||
}
|
||||
|
||||
fn local_home_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.or_else(|| env::var_os("HOME").map(PathBuf::from))
|
||||
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
|
||||
}
|
||||
|
||||
fn first_existing(paths: impl IntoIterator<Item = String>) -> Option<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.find(|path| !path.is_empty() && Path::new(path).is_file())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn lookup_on_path(binary: &str) -> Option<String> {
|
||||
let path_var = env::var_os("PATH")?;
|
||||
for dir in env::split_paths(&path_var) {
|
||||
let candidate = dir.join(binary);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate.to_string_lossy().to_string());
|
||||
}
|
||||
let with_exe = dir.join(format!("{binary}.exe"));
|
||||
if with_exe.is_file() {
|
||||
return Some(with_exe.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
env::var("COMSPEC").unwrap_or_else(|_| "C:\\Windows\\System32\\cmd.exe".to_string())
|
||||
// Prefer modern PowerShell when installed; otherwise Windows PowerShell / COMSPEC.
|
||||
if let Some(shell) = first_existing([
|
||||
lookup_on_path("pwsh").unwrap_or_default(),
|
||||
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
|
||||
r"C:\Program Files\PowerShell\7-preview\pwsh.exe".to_string(),
|
||||
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
|
||||
env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()),
|
||||
r"C:\Windows\System32\cmd.exe".to_string(),
|
||||
]) {
|
||||
return shell;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
|
||||
env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|path| Path::new(path).is_file())
|
||||
.or_else(|| first_existing(["/bin/zsh".into(), "/bin/bash".into(), "/bin/sh".into()]))
|
||||
.unwrap_or_else(|| "/bin/zsh".to_string())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|path| Path::new(path).is_file())
|
||||
.or_else(|| {
|
||||
first_existing([
|
||||
"/bin/bash".into(),
|
||||
"/usr/bin/bash".into(),
|
||||
"/bin/sh".into(),
|
||||
"/usr/bin/zsh".into(),
|
||||
"/bin/zsh".into(),
|
||||
])
|
||||
})
|
||||
.unwrap_or_else(|| "/bin/bash".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_candidates() -> Vec<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
vec![
|
||||
"C:\\Windows\\System32\\cmd.exe".to_string(),
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe".to_string(),
|
||||
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string(),
|
||||
]
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(comspec) = env::var("COMSPEC") {
|
||||
candidates.push(comspec);
|
||||
}
|
||||
if let Some(pwsh) = lookup_on_path("pwsh") {
|
||||
candidates.push(pwsh);
|
||||
}
|
||||
candidates.extend([
|
||||
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
|
||||
r"C:\Program Files\PowerShell\7-preview\pwsh.exe".to_string(),
|
||||
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(),
|
||||
r"C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe".to_string(),
|
||||
r"C:\Windows\System32\cmd.exe".to_string(),
|
||||
]);
|
||||
candidates
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
@@ -238,6 +316,7 @@ fn shell_candidates() -> Vec<String> {
|
||||
"/usr/local/bin/fish",
|
||||
"/opt/homebrew/bin/fish",
|
||||
"/bin/sh",
|
||||
"/usr/bin/sh",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
@@ -290,7 +369,10 @@ fn shell_order(kind: &str) -> u8 {
|
||||
"zsh" => 0,
|
||||
"bash" => 1,
|
||||
"fish" => 2,
|
||||
_ => 3,
|
||||
"pwsh" => 3,
|
||||
"cmd" => 4,
|
||||
"sh" => 5,
|
||||
_ => 6,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +385,10 @@ mod tests {
|
||||
assert_eq!(shell_kind("/bin/zsh"), "zsh");
|
||||
assert_eq!(shell_kind("/usr/local/bin/bash"), "bash");
|
||||
assert_eq!(shell_kind("C:\\Windows\\System32\\cmd.exe"), "cmd");
|
||||
assert_eq!(
|
||||
shell_kind("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
"pwsh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,4 +399,13 @@ mod tests {
|
||||
assert!(Path::new(&first.path).is_file());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_shell_points_to_existing_binary_when_possible() {
|
||||
let shell = default_shell();
|
||||
// On CI/dev hosts the resolved default should exist; skip soft if sandbox has none.
|
||||
if Path::new(&shell).is_file() {
|
||||
assert!(!shell.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod ai;
|
||||
mod credential_store;
|
||||
pub mod deep_link;
|
||||
pub mod license;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
if let Some(relative) = value.strip_prefix("~/") {
|
||||
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(relative);
|
||||
return home.join(Path::new(relative));
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
@@ -20,4 +25,13 @@ mod tests {
|
||||
fn leaves_absolute_paths_unchanged() {
|
||||
assert_eq!(expand_home("/tmp/key"), PathBuf::from("/tmp/key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_unix_and_windows_home_prefixes() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert_eq!(expand_home("~/a/b"), home.join("a/b"));
|
||||
assert_eq!(expand_home("~\\a\\b"), home.join(Path::new("a\\b")));
|
||||
assert_eq!(expand_home("~"), home);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,6 @@
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 18,
|
||||
"y": 20
|
||||
},
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
@@ -55,7 +49,8 @@
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"ssh"
|
||||
"ssh",
|
||||
"opsxshell"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Visible",
|
||||
"hiddenTitle": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 18,
|
||||
"y": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpsXShell",
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Visible",
|
||||
"hiddenTitle": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+68
-7
@@ -11,7 +11,7 @@
|
||||
import { useEffect, useState, useCallback, lazy, Suspense } from 'react'
|
||||
import AppShell from './AppShell'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore, useCommandPaletteStore, type Session } from '../stores'
|
||||
import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores'
|
||||
import { getConnectionStore } from '../stores/connection'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { createLogger } from '../utils/logger'
|
||||
@@ -25,12 +25,14 @@ import HostKeyDialog from '../components/terminal/HostKeyDialog'
|
||||
import { parseSshConfig } from '../utils/sshConfig'
|
||||
import { formatSshLinkTarget } from '../utils/sshLink'
|
||||
import { suppressWebViewContextMenu } from '../utils/nativeContextMenu'
|
||||
import { licenseServerBaseUrl } from '../stores/entitlementStore'
|
||||
|
||||
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
|
||||
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
|
||||
const QuickConnectDialog = lazy(() => import('../components/sessions/QuickConnectDialog'))
|
||||
const QuickLauncher = lazy(() => import('../components/sessions/QuickLauncher'))
|
||||
const SerialConnectDialog = lazy(() => import('../components/serial/SerialConnectDialog'))
|
||||
const UpgradeWall = lazy(() => import('../components/pro/UpgradeWall'))
|
||||
const CommandPalette = lazy(() =>
|
||||
import('../components/common/CommandPalette').then((m) => ({ default: m.CommandPalette }))
|
||||
)
|
||||
@@ -52,6 +54,8 @@ function App() {
|
||||
const settings = useSettingsStore((state) => state.settings)
|
||||
const settingsLoaded = useSettingsStore((state) => state.isLoaded)
|
||||
const loadSettings = useSettingsStore((state) => state.loadSettings)
|
||||
const hydrateEntitlement = useEntitlementStore((state) => state.hydrate)
|
||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||
useTheme()
|
||||
useKeyboardShortcuts()
|
||||
|
||||
@@ -64,7 +68,8 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [loadSettings])
|
||||
void hydrateEntitlement()
|
||||
}, [loadSettings, hydrateEntitlement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
@@ -161,6 +166,7 @@ function App() {
|
||||
}, [])
|
||||
|
||||
const handleExportSessions = useCallback(async () => {
|
||||
if (!requirePro('batchImportExport')) return
|
||||
try {
|
||||
const result = await window.opsxApi?.showSaveDialog({
|
||||
title: '导出会话',
|
||||
@@ -199,7 +205,7 @@ function App() {
|
||||
logger.error('Export failed:', error)
|
||||
toast.error('导出会话失败')
|
||||
}
|
||||
}, [sessions, toast])
|
||||
}, [requirePro, sessions, toast])
|
||||
|
||||
const handleImportSessions = useCallback(async () => {
|
||||
try {
|
||||
@@ -225,8 +231,10 @@ function App() {
|
||||
|
||||
type ImportCandidate = Partial<Session> & { proxyJumpAlias?: string }
|
||||
let importSessions: ImportCandidate[] = []
|
||||
let isJsonMigration = false
|
||||
try {
|
||||
const data = JSON.parse(fileResult.data)
|
||||
isJsonMigration = true
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
importSessions = data
|
||||
@@ -258,6 +266,10 @@ function App() {
|
||||
importSessions = parseSshConfig(fileResult.data)
|
||||
}
|
||||
|
||||
if (isJsonMigration && importSessions.length > 0 && !requirePro('batchImportExport')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (importSessions.length === 0) {
|
||||
toast.warning('未找到可导入的会话')
|
||||
return
|
||||
@@ -322,7 +334,7 @@ function App() {
|
||||
logger.error('Import failed:', error)
|
||||
toast.error('导入会话失败')
|
||||
}
|
||||
}, [sessions, toast])
|
||||
}, [requirePro, sessions, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.opsxApi) return
|
||||
@@ -343,6 +355,47 @@ function App() {
|
||||
window.opsxApi.onSshLinkAvailable(handleSshLinkAvailable)
|
||||
void openPendingSshLink()
|
||||
|
||||
const handleProLink = (url: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const tokenFromQuery = parsed.searchParams.get('token')
|
||||
if (tokenFromQuery) {
|
||||
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
|
||||
if (result.success) {
|
||||
toast.success('Pro 许可证已激活')
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
}
|
||||
}
|
||||
const deviceId =
|
||||
useEntitlementStore.getState().deviceId ||
|
||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||
if (!deviceId) return
|
||||
const res = await fetch(`${licenseServerBaseUrl()}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId }),
|
||||
})
|
||||
const data = (await res.json()) as { token?: string; error?: string }
|
||||
if (data.token) {
|
||||
const result = await useEntitlementStore.getState().activate(data.token)
|
||||
if (result.success) {
|
||||
toast.success('Pro 许可证已激活')
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
return
|
||||
}
|
||||
}
|
||||
toast.info('支付完成后请粘贴许可证,或稍后再试激活')
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
} catch (error) {
|
||||
logger.warn('处理 Pro 深链接失败', error)
|
||||
useEntitlementStore.getState().openUpgradeTab()
|
||||
}
|
||||
})()
|
||||
}
|
||||
window.opsxApi.onProLink?.(handleProLink)
|
||||
|
||||
window.opsxApi.onMenuNewSession(() => {
|
||||
openNewSession()
|
||||
})
|
||||
@@ -372,9 +425,10 @@ function App() {
|
||||
window.opsxApi.removeAllListeners('menu:export-sessions')
|
||||
window.opsxApi.removeAllListeners('menu:open-settings')
|
||||
window.opsxApi.removeAllListeners('app:ssh-link-available')
|
||||
window.opsxApi.removeAllListeners('app:pro-link')
|
||||
}
|
||||
}
|
||||
}, [handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect])
|
||||
}, [handleExportSessions, handleImportSessions, handleOpenSettings, openNewSession, openQuickConnect, toast])
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -422,7 +476,10 @@ function App() {
|
||||
onClose={() => setIsLauncherOpen(false)}
|
||||
onNewSession={openNewSession}
|
||||
onQuickConnect={openQuickConnect}
|
||||
onSerialConnect={() => setIsSerialConnectOpen(true)}
|
||||
onSerialConnect={() => {
|
||||
if (!requirePro('serial')) return
|
||||
setIsSerialConnectOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -432,7 +489,7 @@ function App() {
|
||||
onClose={() => setIsSerialConnectOpen(false)}
|
||||
onConnect={(serialConfig) => {
|
||||
useTabStore.getState().addTab({
|
||||
title: serialConfig.path.split('/').pop() || serialConfig.path,
|
||||
title: serialConfig.path.split(/[\\/]/).pop() || serialConfig.path,
|
||||
type: 'serial',
|
||||
serialConfig,
|
||||
})
|
||||
@@ -440,6 +497,10 @@ function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeWall />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<CommandPalette
|
||||
onNewSession={() => {
|
||||
|
||||
@@ -33,19 +33,19 @@ export interface AppShellProps {
|
||||
|
||||
function TitleBar() {
|
||||
const platform = getDesktopPlatform()
|
||||
const leftSpacer = platform === 'macos' ? 'w-[78px]' : 'w-4'
|
||||
const rightSpacer = platform === 'windows' ? 'w-[138px]' : platform === 'macos' ? 'w-[78px]' : 'w-4'
|
||||
// Overlay title bar is macOS-only; Win/Linux keep the native chrome.
|
||||
if (platform !== 'macos') return null
|
||||
|
||||
return (
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
className="bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||
>
|
||||
<div data-tauri-drag-region className={`${leftSpacer} h-full flex-shrink-0`} aria-hidden />
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2">
|
||||
<span className="pointer-events-none truncate text-[11px] tracking-wide text-txt-faint">OpsXShell</span>
|
||||
</div>
|
||||
<div data-tauri-drag-region className={`${rightSpacer} h-full flex-shrink-0`} aria-hidden />
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
PanelLeft,
|
||||
} from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore, useSnippetStore, useCommandPaletteStore, type Session } from '../../stores'
|
||||
import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores'
|
||||
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
|
||||
|
||||
// ─── 类型定义 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -67,7 +68,7 @@ export function CommandPalette({
|
||||
const { sessions } = useSessionStore()
|
||||
const { snippets } = useSnippetStore()
|
||||
const { isOpen, close } = useCommandPaletteStore()
|
||||
const { addTab, closeTab, tabs, activeTabId } = useTabStore()
|
||||
const { addTab, tabs, activeTabId } = useTabStore()
|
||||
|
||||
// ── 构建命令列表 ──
|
||||
const buildCommands = useCallback((): CommandPaletteItem[] => {
|
||||
@@ -136,7 +137,7 @@ export function CommandPalette({
|
||||
icon: <X size={16} />,
|
||||
action: () => {
|
||||
if (activeTabId && tabs.length > 1) {
|
||||
closeTab(activeTabId)
|
||||
void disposeAndCloseTab(activeTabId)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -146,15 +147,28 @@ export function CommandPalette({
|
||||
label: '关闭所有标签',
|
||||
description: '关闭所有标签页并回到欢迎页',
|
||||
icon: <X size={16} />,
|
||||
action: () => useTabStore.getState().closeAllTabs(),
|
||||
action: () => {
|
||||
void disposeAndCloseAllTabs()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cmd:new-portforward',
|
||||
type: 'command',
|
||||
label: '新建端口转发',
|
||||
description: '打开端口转发管理标签',
|
||||
description: '打开端口转发管理标签(Pro)',
|
||||
icon: <ChevronRight size={16} />,
|
||||
action: () => addTab({ title: '端口转发', type: 'portforward' }),
|
||||
action: () => {
|
||||
if (!useEntitlementStore.getState().requirePro('portForward')) return
|
||||
addTab({ title: '端口转发', type: 'portforward' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cmd:upgrade-pro',
|
||||
type: 'command',
|
||||
label: '升级 OpsXShell Pro',
|
||||
description: '查看专业能力对比与激活',
|
||||
icon: <ChevronRight size={16} />,
|
||||
action: () => useEntitlementStore.getState().openUpgradeTab(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -207,7 +221,7 @@ export function CommandPalette({
|
||||
}
|
||||
|
||||
return items
|
||||
}, [sessions, snippets, addTab, closeTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession])
|
||||
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession])
|
||||
|
||||
// ── 过滤结果 ──
|
||||
const filteredItems = useMemo(() => {
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Plus, Zap, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare, PanelLeftClose } from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../../stores'
|
||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTerminalBroadcastStore } from '../../stores/terminalBroadcastStore'
|
||||
import { SessionTree } from './sidebar/SessionTree'
|
||||
import { SearchBox } from './sidebar/SearchBox'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import BatchCommandDialog from '../sessions/BatchCommandDialog'
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 180
|
||||
@@ -39,8 +39,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
|
||||
const [batchCommandSessions, setBatchCommandSessions] = useState<Session[]>([])
|
||||
const [batchCommandOpen, setBatchCommandOpen] = useState(false)
|
||||
const requirePro = useEntitlementStore((s) => s.requirePro)
|
||||
const setBroadcastSelectedSessionIds = useTerminalBroadcastStore((s) => s.setSelectedSessionIds)
|
||||
const setBroadcastScope = useTerminalBroadcastStore((s) => s.setScope)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
@@ -174,7 +175,14 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
const finishBatchMode = useCallback(() => {
|
||||
setBatchMode(false)
|
||||
setBatchSelection(new Set())
|
||||
}, [])
|
||||
setBroadcastSelectedSessionIds([])
|
||||
}, [setBroadcastSelectedSessionIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchMode) return
|
||||
setBroadcastSelectedSessionIds(Array.from(batchSelection))
|
||||
setBroadcastScope('selected-sessions')
|
||||
}, [batchMode, batchSelection, setBroadcastScope, setBroadcastSelectedSessionIds])
|
||||
|
||||
const handleBatchTerminal = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
@@ -185,21 +193,30 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
connectOnMount: true,
|
||||
})
|
||||
}
|
||||
finishBatchMode()
|
||||
}, [addTab, finishBatchMode, selectedSessions])
|
||||
// Keep selection for sync-input / further batch actions
|
||||
}, [addTab, selectedSessions])
|
||||
|
||||
const handleBatchSftp = useCallback(() => {
|
||||
for (const session of selectedSessions) {
|
||||
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
|
||||
}
|
||||
finishBatchMode()
|
||||
}, [addTab, finishBatchMode, selectedSessions])
|
||||
}, [addTab, selectedSessions])
|
||||
|
||||
const handleBatchCommand = useCallback(() => {
|
||||
setBatchCommandSessions(selectedSessions)
|
||||
setBatchCommandOpen(true)
|
||||
finishBatchMode()
|
||||
}, [finishBatchMode, selectedSessions])
|
||||
if (!requirePro('batchExec')) return
|
||||
addTab({
|
||||
title: '批量执行',
|
||||
type: 'batch',
|
||||
batchSessionIds: selectedSessions.map((session) => session.id),
|
||||
})
|
||||
}, [addTab, requirePro, selectedSessions])
|
||||
|
||||
const handleBatchSyncInput = useCallback(() => {
|
||||
if (!requirePro('broadcast')) return
|
||||
setBroadcastSelectedSessionIds(selectedSessions.map((s) => s.id))
|
||||
setBroadcastScope('selected-sessions')
|
||||
useTerminalBroadcastStore.getState().setEnabled(true)
|
||||
}, [requirePro, selectedSessions, setBroadcastScope, setBroadcastSelectedSessionIds])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
|
||||
e.preventDefault()
|
||||
@@ -365,13 +382,21 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<TerminalSquare className="h-3.5 w-3.5" />
|
||||
批量命令
|
||||
批量执行 · Pro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchSyncInput}
|
||||
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded border border-accent-orange/40 bg-accent-orange/10 text-xs font-medium text-accent-orange disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
同步输入 · Pro
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedSessions.length === 0}
|
||||
onClick={handleBatchTerminal}
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
打开终端
|
||||
@@ -450,12 +475,6 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
|
||||
/>
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
<BatchCommandDialog
|
||||
isOpen={batchCommandOpen}
|
||||
sessions={sessions}
|
||||
initialSelectedIds={batchCommandSessions.map((session) => session.id)}
|
||||
onClose={() => setBatchCommandOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,13 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConnectionPool } from '../../stores/connection'
|
||||
import {
|
||||
disposeAndCloseAllTabs,
|
||||
disposeAndCloseLeftTabs,
|
||||
disposeAndCloseOtherTabs,
|
||||
disposeAndCloseRightTabs,
|
||||
disposeAndCloseTab,
|
||||
} from '../../utils/disposeTab'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
|
||||
@@ -18,17 +24,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
tabs,
|
||||
activeTabId,
|
||||
activateTab,
|
||||
closeTab,
|
||||
addTab,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeLeftTabs,
|
||||
closeAllTabs,
|
||||
moveTab,
|
||||
} = useTabStore()
|
||||
|
||||
const { disconnectConnection } = useConnectionPool()
|
||||
|
||||
// Drag state
|
||||
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
@@ -41,11 +40,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
})
|
||||
|
||||
const handleCloseTab = (tabId: string) => {
|
||||
const tab = tabs.find((t) => t.id === tabId)
|
||||
if (tab?.connectionInstance?.connectionId) {
|
||||
disconnectConnection(tab.connectionInstance.connectionId)
|
||||
}
|
||||
closeTab(tabId)
|
||||
void disposeAndCloseTab(tabId)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
|
||||
@@ -164,10 +159,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
position={menuState.position}
|
||||
onClose={() => setMenuState((prev) => ({ ...prev, isOpen: false }))}
|
||||
onCloseTab={() => menuState.tabId && handleCloseTab(menuState.tabId)}
|
||||
onCloseOthers={() => menuState.tabId && closeOtherTabs(menuState.tabId)}
|
||||
onCloseRight={() => menuState.tabId && closeRightTabs(menuState.tabId)}
|
||||
onCloseLeft={() => menuState.tabId && closeLeftTabs(menuState.tabId)}
|
||||
onCloseAll={closeAllTabs}
|
||||
onCloseOthers={() => menuState.tabId && void disposeAndCloseOtherTabs(menuState.tabId)}
|
||||
onCloseRight={() => menuState.tabId && void disposeAndCloseRightTabs(menuState.tabId)}
|
||||
onCloseLeft={() => menuState.tabId && void disposeAndCloseLeftTabs(menuState.tabId)}
|
||||
onCloseAll={() => void disposeAndCloseAllTabs()}
|
||||
onClone={() => {
|
||||
if (currentTab) {
|
||||
addTab({
|
||||
@@ -176,6 +171,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
sessionId: currentTab.sessionId,
|
||||
connectionConfig: currentTab.connectionConfig,
|
||||
serialConfig: currentTab.serialConfig,
|
||||
isLocal: currentTab.isLocal,
|
||||
localShellKind: currentTab.localShellKind,
|
||||
localShellPath: currentTab.localShellPath,
|
||||
splitLayout: currentTab.splitLayout,
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,9 @@ import SFTPView from '../sftp/SFTPView'
|
||||
import PortForwardView from '../connection/PortForwardView'
|
||||
import SettingsView from '../settings/SettingsView'
|
||||
import SerialTerminalView from '../serial/SerialTerminalView'
|
||||
import UpgradeView from '../pro/UpgradeView'
|
||||
import BatchExecView from '../sessions/BatchExecView'
|
||||
import { useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
|
||||
|
||||
interface TabContentProps {
|
||||
activeTabId: string | null
|
||||
@@ -13,6 +16,20 @@ interface TabContentProps {
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
function ProGate({
|
||||
feature,
|
||||
children,
|
||||
}: {
|
||||
feature: ProFeature
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const canUse = useEntitlementStore((s) => s.canUse(feature))
|
||||
if (!canUse) {
|
||||
return <UpgradeView feature={feature} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
|
||||
@@ -21,7 +38,6 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId
|
||||
|
||||
// 根据类型渲染不同内容
|
||||
let content: React.ReactNode
|
||||
|
||||
switch (tab.type) {
|
||||
@@ -31,15 +47,38 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
|
||||
case 'sftp':
|
||||
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
|
||||
break
|
||||
case 'serial':
|
||||
content = <SerialTerminalView tabId={tab.id} isActive={isActive} />
|
||||
break
|
||||
case 'portforward':
|
||||
content = <PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
content = (
|
||||
<ProGate feature="portForward">
|
||||
<PortForwardView tabId={tab.id} isActive={isActive} />
|
||||
</ProGate>
|
||||
)
|
||||
break
|
||||
case 'serial':
|
||||
content = (
|
||||
<ProGate feature="serial">
|
||||
<SerialTerminalView tabId={tab.id} isActive={isActive} />
|
||||
</ProGate>
|
||||
)
|
||||
break
|
||||
case 'settings':
|
||||
content = <SettingsView />
|
||||
break
|
||||
case 'upgrade':
|
||||
content = (
|
||||
<UpgradeView
|
||||
feature={tab.upgradeFeature as ProFeature | undefined}
|
||||
comingSoon={tab.upgradeComingSoon}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'batch':
|
||||
content = (
|
||||
<ProGate feature="batchExec">
|
||||
<BatchExecView tabId={tab.id} initialSessionIds={tab.batchSessionIds} />
|
||||
</ProGate>
|
||||
)
|
||||
break
|
||||
case 'welcome':
|
||||
default:
|
||||
content = <WelcomeView onNewSession={onNewSession} onQuickConnect={onQuickConnect} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Laptop, ChevronDown } from 'lucide-react'
|
||||
import { useTabStore } from '../../../stores/tabStore'
|
||||
import { shellDisplayFromPath } from '../../../utils/shellName'
|
||||
import { fallbackLocalShells } from '../../../utils/platformShells'
|
||||
|
||||
export interface ShellOption {
|
||||
path: string
|
||||
@@ -27,10 +28,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = () => setShells([
|
||||
{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true },
|
||||
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: false },
|
||||
])
|
||||
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
|
||||
|
||||
void window.opsxApi?.localListShells?.().then((res) => {
|
||||
if (res?.success && res.shells?.length) {
|
||||
@@ -115,7 +113,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-content-primary">本地 Shell</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">选择 Zsh / Bash 打开本机终端</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">打开本机终端</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-txt-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* TabItem — local shell shows zsh/bash badge like professional terminal UIs
|
||||
*/
|
||||
|
||||
import { X, Terminal, FolderSync, Cable } from 'lucide-react'
|
||||
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
|
||||
import type { Tab } from '../../../stores/tabStore'
|
||||
|
||||
interface TabItemProps {
|
||||
@@ -86,6 +86,10 @@ export function TabItem({
|
||||
return <FolderSync className={`w-3.5 h-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
|
||||
case 'serial':
|
||||
return <Cable className={`w-3.5 h-3.5 ${isActive ? 'text-accent-green' : ''}`} />
|
||||
case 'upgrade':
|
||||
return <Crown className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
|
||||
case 'batch':
|
||||
return <TerminalSquare className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
Crown,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Minus,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
licenseServerBaseUrl,
|
||||
PRO_FEATURE_LABELS,
|
||||
useEntitlementStore,
|
||||
type ProFeature,
|
||||
} from '../../stores/entitlementStore'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
|
||||
const logger = createLogger('UpgradeView')
|
||||
|
||||
interface UpgradeViewProps {
|
||||
feature?: ProFeature
|
||||
comingSoon?: boolean
|
||||
}
|
||||
|
||||
const FREE_ITEMS = ['SSH 终端', 'SFTP 文件管理', '本地终端', '会话 CRUD', '单条 / ssh_config 导入']
|
||||
const PRO_ITEMS = [
|
||||
'端口转发',
|
||||
'会话代理 / SOCKS',
|
||||
'串口终端',
|
||||
'终端 AI 助手',
|
||||
'批量执行 / 广播同步',
|
||||
'批量导入导出主机清单',
|
||||
'RDP / VNC(即将推出)',
|
||||
]
|
||||
|
||||
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
|
||||
const plan = useEntitlementStore((s) => s.plan)
|
||||
const deviceId = useEntitlementStore((s) => s.deviceId)
|
||||
const expiresAt = useEntitlementStore((s) => s.expiresAt)
|
||||
const activate = useEntitlementStore((s) => s.activate)
|
||||
const clear = useEntitlementStore((s) => s.clear)
|
||||
const hydrate = useEntitlementStore((s) => s.hydrate)
|
||||
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false)
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||||
const [licenseInput, setLicenseInput] = useState('')
|
||||
const [activateError, setActivateError] = useState<string | null>(null)
|
||||
const [activateOk, setActivateOk] = useState(false)
|
||||
const [showActivate, setShowActivate] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
const highlight = feature ? PRO_FEATURE_LABELS[feature] : null
|
||||
|
||||
const startCheckout = useCallback(async () => {
|
||||
setCheckoutLoading(true)
|
||||
setCheckoutError(null)
|
||||
try {
|
||||
const id =
|
||||
deviceId ||
|
||||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
|
||||
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId: id }),
|
||||
})
|
||||
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
|
||||
if (!res.ok || !data.checkoutUrl) {
|
||||
throw new Error(data.error || `结账服务不可用 (${res.status})`)
|
||||
}
|
||||
await window.opsxApi.openExternal(data.checkoutUrl)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.warn('checkout failed', message)
|
||||
setCheckoutError(message)
|
||||
} finally {
|
||||
setCheckoutLoading(false)
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
setActivateError(null)
|
||||
setActivateOk(false)
|
||||
const result = await activate(licenseInput)
|
||||
if (result.success) {
|
||||
setActivateOk(true)
|
||||
setLicenseInput('')
|
||||
} else {
|
||||
setActivateError(result.error || '激活失败')
|
||||
}
|
||||
}, [activate, licenseInput])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
||||
<div className="mx-auto w-full max-w-3xl px-6 py-8">
|
||||
<header className="mb-8 flex items-start gap-3 border-b border-dark-700/40 pb-6">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
|
||||
<Crown className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-content-primary">
|
||||
OpsXShell Pro · 解锁专业运维能力
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-txt-muted">
|
||||
{comingSoon && highlight
|
||||
? `${highlight} 即将推出 · 升级 Pro 可优先体验`
|
||||
: highlight
|
||||
? `正在解锁:${highlight}`
|
||||
: '转发、代理、串口、AI、批量与广播'}
|
||||
</p>
|
||||
{plan === 'pro' && (
|
||||
<p className="mt-2 text-xs text-accent-green">
|
||||
已激活 Pro
|
||||
{expiresAt
|
||||
? ` · 有效期至 ${new Date(expiresAt * 1000).toLocaleDateString()}`
|
||||
: ' · 终身授权'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
|
||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">Free</div>
|
||||
<ul className="space-y-2">
|
||||
{FREE_ITEMS.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-content-secondary">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
|
||||
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">Pro</div>
|
||||
<ul className="space-y-2">
|
||||
{PRO_ITEMS.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-content-primary">
|
||||
{item.includes('即将推出') ? (
|
||||
<Minus className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
) : (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||
)}
|
||||
<span className={item.includes('即将推出') ? 'text-txt-muted' : ''}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan !== 'pro' && (
|
||||
<>
|
||||
<p className="mb-3 text-center text-sm text-txt-muted">
|
||||
一次性费用 · 本机终身授权 · 无需订阅
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => void startCheckout()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
{checkoutLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
)}
|
||||
去付费升级
|
||||
</button>
|
||||
{checkoutError && (
|
||||
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActivate((v) => !v)}
|
||||
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
|
||||
>
|
||||
已有许可证?激活
|
||||
</button>
|
||||
|
||||
{showActivate && (
|
||||
<div className="mt-3 space-y-2 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4">
|
||||
<label className="ui-label" htmlFor="license-token">
|
||||
粘贴许可证
|
||||
</label>
|
||||
<textarea
|
||||
id="license-token"
|
||||
value={licenseInput}
|
||||
onChange={(e) => setLicenseInput(e.target.value)}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="opsx1...."
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!licenseInput.trim()}
|
||||
onClick={() => void handleActivate()}
|
||||
className="rounded-lg bg-dark-700 px-3 py-2 text-sm text-content-primary hover:bg-dark-600 disabled:opacity-40"
|
||||
>
|
||||
激活
|
||||
</button>
|
||||
{activateError && <p className="text-xs text-red-400">{activateError}</p>}
|
||||
{activateOk && <p className="text-xs text-accent-green">激活成功</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'pro' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clear()}
|
||||
className="rounded-lg border border-dark-600 px-3 py-2 text-sm text-txt-muted hover:bg-dark-800"
|
||||
>
|
||||
清除本机许可证
|
||||
</button>
|
||||
)}
|
||||
|
||||
<footer className="mt-10 space-y-2 border-t border-dark-700/40 pt-5 text-[11px] leading-relaxed text-txt-faint">
|
||||
<p className="flex items-start gap-1.5">
|
||||
<Shield className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
支付由 Waffo 托管收银台处理;本应用不收集或保存银行卡信息。
|
||||
</p>
|
||||
<p>测试环境 · 设备 ID:{deviceId || '…'}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* UpgradeWall — intercepts Pro features for Free users
|
||||
*/
|
||||
|
||||
import { Crown, Sparkles, X } from 'lucide-react'
|
||||
import {
|
||||
PRO_FEATURE_LABELS,
|
||||
useEntitlementStore,
|
||||
type ProFeature,
|
||||
} from '../../stores/entitlementStore'
|
||||
|
||||
export default function UpgradeWall() {
|
||||
const wall = useEntitlementStore((s) => s.wall)
|
||||
const closeWall = useEntitlementStore((s) => s.closeWall)
|
||||
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||
|
||||
if (!wall.open) return null
|
||||
|
||||
const feature = wall.feature as ProFeature | undefined
|
||||
const label = feature ? PRO_FEATURE_LABELS[feature] : '专业功能'
|
||||
const comingSoon = wall.comingSoon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upgrade-wall-title"
|
||||
onClick={closeWall}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-dark-600/80 bg-dark-800 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeWall}
|
||||
className="absolute right-3 top-3 rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-content-primary"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="border-b border-dark-700/50 bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
|
||||
<Crown className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
|
||||
{comingSoon ? `${label} · 即将推出` : `解锁 ${label}`}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
|
||||
{wall.message ||
|
||||
(comingSoon
|
||||
? '该能力属于 OpsXShell Pro,协议实现尚在开发中。升级后可优先体验。'
|
||||
: '此功能属于 OpsXShell Pro。升级后即可在本机使用。')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeWall}
|
||||
className="flex-1 rounded-lg border border-dark-600 px-3 py-2.5 text-sm text-content-secondary transition-colors hover:bg-dark-700"
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUpgradeTab(feature, comingSoon)}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-600"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
查看 Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UpgradeView } from './UpgradeView'
|
||||
export { default as UpgradeWall } from './UpgradeWall'
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SerialParity,
|
||||
SerialPortSummary,
|
||||
} from '../../../shared/types/serial.types'
|
||||
import { getDesktopPlatform } from '../../platform/runtime'
|
||||
|
||||
interface SerialConnectDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -17,13 +18,20 @@ interface SerialConnectDialogProps {
|
||||
|
||||
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]
|
||||
|
||||
function defaultSerialPath(): string {
|
||||
const platform = getDesktopPlatform()
|
||||
if (platform === 'windows') return 'COM1'
|
||||
if (platform === 'linux') return '/dev/ttyUSB0'
|
||||
return '/dev/tty.usbserial'
|
||||
}
|
||||
|
||||
export default function SerialConnectDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnect,
|
||||
}: SerialConnectDialogProps) {
|
||||
const [ports, setPorts] = useState<SerialPortSummary[]>([])
|
||||
const [path, setPath] = useState('/dev/tty.debug-console')
|
||||
const [path, setPath] = useState(defaultSerialPath)
|
||||
const [baudRate, setBaudRate] = useState(115200)
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8)
|
||||
const [stopBits, setStopBits] = useState<1 | 2>(1)
|
||||
@@ -98,7 +106,7 @@ export default function SerialConnectDialog({
|
||||
id="serial-path"
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
placeholder="/dev/tty.debug-console"
|
||||
placeholder={defaultSerialPath()}
|
||||
className="ui-input font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
@@ -122,8 +130,8 @@ export default function SerialConnectDialog({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-txt-faint">
|
||||
未枚举到设备时可直接输入路径,例如 /dev/tty.debug-console 或
|
||||
/dev/tty.Bluetooth-Incoming-Port。
|
||||
未枚举到设备时可直接输入路径,例如 {defaultSerialPath()}
|
||||
{getDesktopPlatform() === 'windows' ? '、COM3' : getDesktopPlatform() === 'linux' ? '、/dev/ttyACM0' : '、/dev/tty.usbserial-*'}。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* BatchExecView — Pro 批量命令结果矩阵(独立标签,关闭即丢结果)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Loader2,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
TerminalSquare,
|
||||
} from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { getConnectionStore, getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import Button from '../common/Button'
|
||||
|
||||
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
interface BatchCommandResult {
|
||||
sessionId: string
|
||||
status: BatchCommandStatus
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface BatchExecViewProps {
|
||||
tabId: string
|
||||
initialSessionIds?: string[]
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 5
|
||||
|
||||
export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchExecViewProps) {
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const [command, setCommand] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSessionIds))
|
||||
const [running, setRunning] = useState(false)
|
||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
const available = new Set(sessions.map((session) => session.id))
|
||||
const requested = initialSessionIds.filter((id) => available.has(id))
|
||||
const initial = requested.length > 0 ? requested : []
|
||||
setSelectedIds(new Set(initial))
|
||||
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
|
||||
}, [initialSessionIds, sessions])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions
|
||||
return sessions.filter((session) =>
|
||||
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalized))
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sessions]
|
||||
)
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
setResults((current) =>
|
||||
current.map((result) => (result.sessionId === sessionId ? { ...result, ...updates } : result))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const executeForSession = useCallback(
|
||||
async (session: Session, commandText: string) => {
|
||||
const startedAt = Date.now()
|
||||
const poolTabId = `batch:${tabId}:${session.id}`
|
||||
let connectionId: string | undefined
|
||||
updateResult(session.id, { status: 'running', output: undefined, error: undefined })
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(session.id)
|
||||
if (!config) throw new Error('无法读取会话配置')
|
||||
|
||||
const created = await getConnectionStore().createConnection(poolTabId, {
|
||||
...config,
|
||||
timeoutSeconds: config.timeoutSeconds ?? timeout,
|
||||
})
|
||||
if (!created.success || !created.connectionId) {
|
||||
throw new Error(created.error || '连接失败')
|
||||
}
|
||||
connectionId = created.connectionId
|
||||
|
||||
const executed = await window.opsxApi.sshExec(connectionId, commandText)
|
||||
if (!executed.success) throw new Error(executed.error || '命令执行失败')
|
||||
|
||||
updateResult(session.id, {
|
||||
status: 'success',
|
||||
output: executed.result?.trimEnd() || '(无输出)',
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
setExpanded((prev) => new Set(prev).add(session.id))
|
||||
} catch (error) {
|
||||
updateResult(session.id, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
setExpanded((prev) => new Set(prev).add(session.id))
|
||||
} finally {
|
||||
if (connectionId) {
|
||||
await getConnectionStore().releaseConnection(connectionId, poolTabId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabId, timeout, updateResult]
|
||||
)
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
if (!commandText || running || selectedSessions.length === 0) return
|
||||
|
||||
const targets = [...selectedSessions]
|
||||
setRunning(true)
|
||||
setResults(targets.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
setExpanded(new Set())
|
||||
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < targets.length) {
|
||||
const session = targets[nextIndex]
|
||||
nextIndex += 1
|
||||
await executeForSession(session, commandText)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, targets.length) }, () => worker())
|
||||
)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [command, executeForSession, running, selectedSessions])
|
||||
|
||||
const toggleSession = (sessionId: string) => {
|
||||
if (running) return
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(sessionId)) next.delete(sessionId)
|
||||
else next.add(sessionId)
|
||||
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const allFilteredSelected =
|
||||
filteredSessions.length > 0 && filteredSessions.every((session) => selectedIds.has(session.id))
|
||||
const toggleFiltered = () => {
|
||||
if (running) return
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const session of filteredSessions) {
|
||||
if (allFilteredSelected) next.delete(session.id)
|
||||
else next.add(session.id)
|
||||
}
|
||||
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
success: results.filter((result) => result.status === 'success').length,
|
||||
error: results.filter((result) => result.status === 'error').length,
|
||||
running: results.filter((result) => result.status === 'running').length,
|
||||
}),
|
||||
[results]
|
||||
)
|
||||
const completed = summary.success + summary.error
|
||||
const progress =
|
||||
selectedSessions.length > 0 ? Math.round((completed / selectedSessions.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-dark-900">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-dark-700/40 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TerminalSquare className="h-4 w-4 text-accent-blue" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-primary">批量执行</div>
|
||||
<div className="text-[11px] text-txt-muted">
|
||||
已选 {selectedSessions.length} 台 · 并发 {MAX_CONCURRENCY} · 关闭标签即丢弃结果
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.success > 0 && (
|
||||
<span className="text-xs text-accent-green">成功 {summary.success}</span>
|
||||
)}
|
||||
{summary.error > 0 && <span className="text-xs text-red-400">失败 {summary.error}</span>}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={() => void execute()}
|
||||
disabled={!command.trim() || selectedSessions.length === 0}
|
||||
loading={running}
|
||||
>
|
||||
{completed > 0 ? '再次执行' : `执行 ${selectedSessions.length} 台`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4 lg:flex-row">
|
||||
<section className="flex w-full flex-col gap-3 lg:w-[320px] lg:flex-shrink-0">
|
||||
<div className="rounded-xl border border-dark-700/70 bg-dark-800/40 p-3">
|
||||
<label htmlFor="batch-command-panel" className="mb-2 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
</label>
|
||||
<textarea
|
||||
id="batch-command-panel"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
}}
|
||||
disabled={running}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder="例如:uptime"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-sm text-content-primary outline-none focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
{(running || completed > 0) && (
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-blue transition-[width]"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-800/40">
|
||||
<div className="space-y-2 border-b border-dark-700/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5" /> 目标主机
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={running || filteredSessions.length === 0}
|
||||
onClick={toggleFiltered}
|
||||
className="text-[11px] text-accent-blue disabled:opacity-40"
|
||||
>
|
||||
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
|
||||
<input
|
||||
aria-label="筛选主机"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
disabled={running}
|
||||
placeholder="名称、IP、分组或标签"
|
||||
className="ui-input-sm w-full pl-8"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{filteredSessions.map((session) => {
|
||||
const selected = selectedIds.has(session.id)
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
disabled={running}
|
||||
onClick={() => toggleSession(session.id)}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-accent-blue/40 bg-accent-blue/10'
|
||||
: 'border-transparent hover:border-dark-700 hover:bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
|
||||
selected
|
||||
? 'border-accent-blue bg-accent-blue text-white'
|
||||
: 'border-dark-500 bg-dark-900'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-content-primary">
|
||||
{session.name}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-[10px] text-txt-faint">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-800/40">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-content-secondary">结果矩阵</span>
|
||||
<span className="text-[10px] text-txt-faint">主机 × 状态 × 输出</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
|
||||
{selectedSessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
const isOpen = expanded.has(session.id)
|
||||
const hasBody = !!(result?.output || result?.error)
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/80">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-h-10 items-center gap-2 px-3 py-2 text-left"
|
||||
onClick={() => {
|
||||
if (!hasBody) return
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(session.id)) next.delete(session.id)
|
||||
else next.add(session.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{hasBody ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-txt-faint" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-txt-faint" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-content-primary">
|
||||
{session.name}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-txt-muted">{session.host}</span>
|
||||
{result?.durationMs !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && hasBody && (
|
||||
<pre
|
||||
className={`max-h-56 overflow-auto whitespace-pre-wrap break-words border-t border-dark-700/70 px-3 py-2 text-[11px] leading-relaxed ${
|
||||
result?.error ? 'text-red-300' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{result?.error || result?.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{selectedSessions.length === 0 && (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">
|
||||
从左侧选择目标主机,或在侧栏批量模式保留选中后打开
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,6 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="快速连接"
|
||||
description="粘贴 user@host:port 即可,不必拆成多个框"
|
||||
icon={<Zap className="h-5 w-5" />}
|
||||
size="md"
|
||||
showFooter={false}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
listLocalShells,
|
||||
type AvailableLocalShell,
|
||||
} from '../../platform/localTerminal'
|
||||
import { fallbackLocalShells } from '../../utils/platformShells'
|
||||
|
||||
interface QuickLauncherProps {
|
||||
isOpen: boolean
|
||||
@@ -100,7 +101,7 @@ export default function QuickLauncher({
|
||||
<div className="space-y-1">
|
||||
{(localShells.length > 0
|
||||
? localShells.slice(0, 4)
|
||||
: [{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true }]
|
||||
: fallbackLocalShells()
|
||||
).map((shell) => (
|
||||
<button
|
||||
key={shell.path}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState, useEffect, type ComponentType } from 'react'
|
||||
import {
|
||||
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
|
||||
} from 'lucide-react'
|
||||
import { useSessionStore, Session } from '../../stores'
|
||||
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
|
||||
import type { AuthType, ProxyType } from '../../../shared/types/session.types'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import { parseSshTarget } from './QuickConnectDialog'
|
||||
@@ -475,7 +475,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
|
||||
return
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
@@ -487,7 +490,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
||||
},
|
||||
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`flex-1 py-1.5 rounded-lg text-[11px] border transition-colors ${
|
||||
formData.proxy.type === t.value
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
import { Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Zap, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
|
||||
import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShellMenu'
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const requirePro = useEntitlementStore((s) => s.requirePro)
|
||||
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const recentSessions = sessions.slice(-6).reverse()
|
||||
const recentSessions = sessions.slice(-8).reverse()
|
||||
const modKey = useMemo(() => modKeyLabel(), [])
|
||||
|
||||
const [shells, setShells] = useState<ShellOption[]>([])
|
||||
const [shellMenuOpen, setShellMenuOpen] = useState(false)
|
||||
const shellMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
|
||||
void window.opsxApi
|
||||
?.localListShells?.()
|
||||
.then((res) => {
|
||||
if (res?.success && res.shells?.length) setShells(res.shells)
|
||||
else fallback()
|
||||
})
|
||||
.catch(fallback)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellMenuOpen) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (shellMenuRef.current && !shellMenuRef.current.contains(e.target as Node)) {
|
||||
setShellMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [shellMenuOpen])
|
||||
|
||||
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
|
||||
const defaultShellLabel = defaultShell
|
||||
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
|
||||
: 'Shell'
|
||||
|
||||
const openDefaultLocal = useCallback(() => {
|
||||
openLocalShellTab(
|
||||
defaultShell
|
||||
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
|
||||
: undefined
|
||||
)
|
||||
}, [defaultShell])
|
||||
|
||||
const handleSessionClick = (session: (typeof sessions)[number]) => {
|
||||
addTab({
|
||||
@@ -24,116 +78,205 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center bg-dark-900 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
|
||||
{/* Hero — 短文案 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-8 py-12 sm:py-16">
|
||||
<header className="mb-10 flex items-center gap-3.5">
|
||||
<img
|
||||
src={appIconUrl}
|
||||
alt="OpsXShell"
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-14 flex-shrink-0 rounded-2xl object-cover shadow-lg shadow-black/20"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
|
||||
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 终端</p>
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
|
||||
<p className="text-xs text-txt-muted">远程终端 · SFTP · 端口转发</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主路径:快速连接 */}
|
||||
<section aria-label="开始" className="mb-12">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="
|
||||
group w-full flex items-center gap-4 p-5 mb-3 rounded-2xl text-left
|
||||
bg-accent-blue hover:bg-blue-500 border border-blue-400/30
|
||||
shadow-lg shadow-accent-blue/10 transition-all duration-150
|
||||
"
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Zap className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Kbd>{`${modKey}K`}</Kbd>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white text-base">快速连接</div>
|
||||
<div className="text-sm text-blue-100/80 mt-0.5">粘贴 user@host:port,一步连上</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">快速连接</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">临时 SSH,不写入会话库</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-white/70 group-hover:text-white group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* 次路径:保存常用会话 */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="
|
||||
group flex items-center gap-3 p-4 rounded-2xl text-left
|
||||
bg-dark-800/70 border border-dark-700/50 hover:border-accent-green/40
|
||||
hover:bg-dark-800 transition-all duration-150
|
||||
"
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-green/15 border border-accent-green/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-green/25">
|
||||
<Plus className="w-5 h-5 text-accent-green" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Kbd>{`${modKey}N`}</Kbd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-content-primary text-sm">新建会话</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">保存后侧栏双击即开终端</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">新建会话</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">保存主机,侧栏双击连接</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 本地 Shell — 降级为次要入口 */}
|
||||
<div className="mb-10 opacity-90">
|
||||
<LocalShellMenu />
|
||||
</div>
|
||||
|
||||
{/* 最近会话 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-txt-faint" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-txt-faint">最近会话</h3>
|
||||
</div>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-faint">{recentSessions.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-dark-600/60 bg-dark-800/30 p-6">
|
||||
<EmptyState icon={Server} title="暂无会话" description="创建会话或快速连接开始运维" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/40 overflow-hidden divide-y divide-dark-700/40">
|
||||
{recentSessions.map((session) => (
|
||||
<div ref={shellMenuRef} className="relative">
|
||||
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-700/40 transition-colors text-left group"
|
||||
onClick={openDefaultLocal}
|
||||
className="flex min-w-0 flex-1 flex-col gap-4 p-4 text-left"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-blue/20">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-content-secondary">
|
||||
<Laptop className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary truncate">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="text-xs text-txt-faint font-mono truncate">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">本地 Shell</div>
|
||||
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
|
||||
{defaultShellLabel}
|
||||
</div>
|
||||
</div>
|
||||
{session.group && (
|
||||
<span className="ui-chip flex-shrink-0">{session.group}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="选择 Shell"
|
||||
aria-expanded={shellMenuOpen}
|
||||
onClick={() => setShellMenuOpen((v) => !v)}
|
||||
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
|
||||
>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shellMenuOpen && shells.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
|
||||
{shells.map((s) => (
|
||||
<button
|
||||
key={s.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
|
||||
setShellMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
|
||||
>
|
||||
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
|
||||
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
|
||||
{s.isDefault && (
|
||||
<span className="text-[10px] text-txt-faint">默认</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-txt-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Pro 即将推出" className="mb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">Pro · 即将推出</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('rdp', { comingSoon: true })) return
|
||||
openUpgradeTab('rdp', true)
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">RDP 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('vnc', { comingSoon: true })) return
|
||||
openUpgradeTab('vnc', true)
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<Cast className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">VNC 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="最近会话" className="min-h-0 flex-1">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-txt-faint" />
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
最近会话
|
||||
</h2>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="暂无已保存会话"
|
||||
description="用快速连接试连,或新建会话以便复用"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
|
||||
{recentSessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-dark-800 text-txt-muted group-hover:text-accent-blue">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
</span>
|
||||
{session.group ? (
|
||||
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
|
||||
{session.group}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -178,8 +178,6 @@ export default function SettingsView() {
|
||||
? [
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||
runtimeInfo.nativeBackend,
|
||||
runtimeInfo.sshBackend,
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
].join('\n')
|
||||
: `OpsXShell v${appVersion}`
|
||||
@@ -577,7 +575,9 @@ export default function SettingsView() {
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="本地主密钥" description="仅当前系统用户可读取">
|
||||
<code className="text-xs text-txt-muted">~/.opsxshsell/master.key</code>
|
||||
<code className="text-xs text-txt-muted break-all">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
|
||||
</code>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<KnownHostsSettingsCard />
|
||||
@@ -600,9 +600,6 @@ export default function SettingsView() {
|
||||
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
|
||||
应用 v{appVersion}
|
||||
</span>
|
||||
<span className="border-dark-600/50 inline-flex items-center rounded-md border bg-dark-700 px-2.5 py-1 font-mono text-xs text-txt-muted">
|
||||
Tauri 2 · Rust
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
@@ -624,16 +621,12 @@ export default function SettingsView() {
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>运行环境</SettingSectionTitle>
|
||||
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
|
||||
{[
|
||||
['系统', runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'],
|
||||
['原生运行时', runtimeInfo?.nativeBackend ?? '读取中...'],
|
||||
['SSH 后端', runtimeInfo?.sshBackend ?? '读取中...'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-txt-muted">{label}</span>
|
||||
<code className="text-right text-xs text-content-secondary">{value}</code>
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-txt-muted">系统</span>
|
||||
<code className="text-right text-xs text-content-secondary">
|
||||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
@@ -656,7 +649,7 @@ export default function SettingsView() {
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">MIT License · Tauri 2 · Rust · React · XTerm.js</p>
|
||||
<p className="px-1 text-[11px] text-txt-faint">MIT License · Rust · XTerm.js</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
/**
|
||||
* Breadcrumb - 路径导航组件
|
||||
* Breadcrumb - 路径导航组件(本地支持 Windows / UNC,远程保持 POSIX)
|
||||
*/
|
||||
|
||||
import { ChevronRight, Home } from 'lucide-react'
|
||||
import { splitPathSegments } from '../../utils/localPath'
|
||||
|
||||
interface BreadcrumbProps {
|
||||
path: string
|
||||
isLocal?: boolean
|
||||
onNavigate: (p: string) => void
|
||||
onHome: () => void
|
||||
}
|
||||
|
||||
export function Breadcrumb({ path, onNavigate, onHome }: BreadcrumbProps) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: BreadcrumbProps) {
|
||||
const { root, parts, join } = splitPathSegments(path || (isLocal ? '' : '/'), isLocal)
|
||||
const rootLabel = isLocal && root !== '/' ? root.replace(/\\$/, '') || root : '/'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHome}
|
||||
className="p-1.5 hover:bg-dark-700 rounded transition-colors flex-shrink-0"
|
||||
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
|
||||
title="主目录"
|
||||
>
|
||||
<Home className="w-4 h-4 text-txt-muted" />
|
||||
<Home className="h-4 w-4 text-txt-muted" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-txt-muted hover:text-dark-200 transition-colors"
|
||||
type="button"
|
||||
onClick={() => onNavigate(root || '/')}
|
||||
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
/
|
||||
{rootLabel}
|
||||
</button>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className="flex items-center min-w-0">
|
||||
<ChevronRight className="w-3 h-3 text-dark-600 mx-0.5 flex-shrink-0" />
|
||||
<span key={`${part}-${index}`} className="flex min-w-0 items-center">
|
||||
<ChevronRight className="mx-0.5 h-3 w-3 flex-shrink-0 text-dark-600" />
|
||||
<button
|
||||
onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))}
|
||||
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-content-primary hover:text-content-primary transition-colors truncate max-w-[120px]"
|
||||
type="button"
|
||||
onClick={() => onNavigate(join(parts.slice(0, index + 1)))}
|
||||
className="max-w-[120px] truncate rounded px-1.5 py-0.5 text-content-primary transition-colors hover:bg-dark-700"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* ConnectionPanel - SFTP 连接状态面板
|
||||
*
|
||||
* 显示连接状态或连接选择界面
|
||||
* 显示连接状态或连接选择界面(已连接时不占位,避免与双栏重复)
|
||||
*/
|
||||
|
||||
import { Monitor, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { ConnectionSelector } from './ConnectionSelector'
|
||||
import type { Session } from '../../../shared/types/session.types'
|
||||
|
||||
@@ -29,37 +29,30 @@ export function ConnectionPanel({
|
||||
onShowSelector,
|
||||
onCloseSelector,
|
||||
}: ConnectionPanelProps) {
|
||||
// Connected State
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-500/10 border-b border-green-500/20">
|
||||
<Monitor className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm text-green-400">已连接</span>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Connecting State
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent-blue/10 border-b border-accent-blue/20">
|
||||
<Loader2 className="w-4 h-4 text-accent-blue animate-spin" />
|
||||
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
|
||||
<span className="text-sm text-accent-blue">连接中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error State
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-red-500/10 border-b border-red-500/20">
|
||||
<div className="flex items-center justify-between border-b border-red-500/20 bg-red-500/10 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowSelector}
|
||||
className="px-3 py-1 bg-dark-700 hover:bg-dark-600 rounded text-sm transition-colors"
|
||||
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
@@ -67,25 +60,21 @@ export function ConnectionPanel({
|
||||
)
|
||||
}
|
||||
|
||||
// Disconnected State
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/30 bg-dark-800 px-4 py-2">
|
||||
<span className="text-sm text-txt-muted">未连接</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowSelector}
|
||||
className="px-3 py-1.5 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
|
||||
className="rounded bg-accent-blue px-3 py-1.5 text-sm font-medium transition-colors hover:bg-blue-600"
|
||||
>
|
||||
选择服务器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSelector && (
|
||||
<ConnectionSelector
|
||||
sessions={sessions}
|
||||
onSelect={onConnect}
|
||||
onClose={onCloseSelector}
|
||||
/>
|
||||
<ConnectionSelector sessions={sessions} onSelect={onConnect} onClose={onCloseSelector} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -286,7 +286,12 @@ export function FileListPanel({
|
||||
{/* Breadcrumb + search */}
|
||||
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
|
||||
<Breadcrumb
|
||||
path={path}
|
||||
isLocal={isLocal}
|
||||
onNavigate={onNavigate}
|
||||
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-txt-muted pointer-events-none" />
|
||||
|
||||
@@ -25,6 +25,7 @@ import FilePropertiesDialog from './FilePropertiesDialog'
|
||||
import ChmodDialog from './ChmodDialog'
|
||||
import RemoteTextEditor from './RemoteTextEditor'
|
||||
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
|
||||
import { parentPath } from '../../utils/localPath'
|
||||
import type { SftpDragEntry } from './dragDrop'
|
||||
|
||||
interface SFTPViewProps {
|
||||
@@ -278,13 +279,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
const parent = localPath.split('/').slice(0, -1).join('/') || '/'
|
||||
navigateLocal(parent)
|
||||
navigateLocal(parentPath(localPath, true))
|
||||
}, [localPath, navigateLocal])
|
||||
|
||||
const navigateRemoteUp = useCallback(() => {
|
||||
const parent = remotePath.split('/').slice(0, -1).join('/') || '/'
|
||||
navigateRemote(parent)
|
||||
navigateRemote(parentPath(remotePath, false))
|
||||
}, [remotePath, navigateRemote])
|
||||
|
||||
// Handle file open
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Toolbar - SFTP 工具栏
|
||||
*
|
||||
* 包含上传、下载、刷新、新建文件夹等操作按钮
|
||||
* Toolbar - SFTP 工具栏(图标优先,文案放 title/aria-label)
|
||||
*/
|
||||
|
||||
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
|
||||
@@ -15,44 +13,59 @@ interface ToolbarProps {
|
||||
onNewFolder: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ isConnected, canDownload, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
|
||||
const iconBtn =
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
export function Toolbar({
|
||||
isConnected,
|
||||
canDownload,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
onNewFolder,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 border-b border-dark-700/30 bg-dark-800 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-blue hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
title="上传"
|
||||
aria-label="上传"
|
||||
className={`${iconBtn} bg-accent-blue text-white hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
上传
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
disabled={!isConnected || !canDownload}
|
||||
title={canDownload ? '下载选中的远程文件' : '请先选择远程文件'}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
aria-label="下载"
|
||||
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
下载
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
title="刷新"
|
||||
aria-label="刷新"
|
||||
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewFolder}
|
||||
disabled={!isConnected}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
|
||||
title="新建文件夹"
|
||||
aria-label="新建文件夹"
|
||||
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
新建文件夹
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
|
||||
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { expandSnippetVariables, SNIPPET_VARIABLE_HINTS } from '../../utils/snippetVariables'
|
||||
import { shellWrite } from '../../utils/shellIO'
|
||||
import { localShellEnterKey } from '../../utils/platformShells'
|
||||
import Button from '../common/Button'
|
||||
import Modal from '../common/Modal'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
@@ -149,7 +151,11 @@ export default function SnippetPanel() {
|
||||
})
|
||||
|
||||
try {
|
||||
await window.opsxApi?.sshShellWrite(shellId, expanded + '\n')
|
||||
const enter =
|
||||
activeTab?.isLocal || shellId.startsWith('local-')
|
||||
? localShellEnterKey(activeTab?.localShellKind)
|
||||
: '\n'
|
||||
await shellWrite(shellId, expanded + enter)
|
||||
} catch (err) {
|
||||
console.error('Failed to insert snippet:', err)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ import { useTabStore } from '../../stores/tabStore'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
import {
|
||||
getTerminalBroadcastTargets,
|
||||
terminalBroadcastTargetCount,
|
||||
selectBroadcastTargetCount,
|
||||
useTerminalBroadcastStore,
|
||||
type BroadcastScope,
|
||||
} from '../../stores/terminalBroadcastStore'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
||||
@@ -363,15 +365,24 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
|
||||
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
|
||||
const setBroadcastInput = useTerminalBroadcastStore((state) => state.setEnabled)
|
||||
const broadcastScope = useTerminalBroadcastStore((state) => state.scope)
|
||||
const setBroadcastScope = useTerminalBroadcastStore((state) => state.setScope)
|
||||
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
|
||||
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
|
||||
const broadcastTargetCount = useTerminalBroadcastStore(terminalBroadcastTargetCount)
|
||||
const broadcastTargetCount = useTerminalBroadcastStore(selectBroadcastTargetCount(tabId))
|
||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
const isLocalTab = !!currentTab?.isLocal
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
paneId: string
|
||||
position: { x: number; y: number }
|
||||
hasSelection: boolean
|
||||
} | null>(null)
|
||||
|
||||
// AI 助手仅适用于 SSH 远程终端;切到本地时强制关闭面板
|
||||
useEffect(() => {
|
||||
if (isLocalTab) setAiPanelOpen(false)
|
||||
}, [isLocalTab])
|
||||
const draggingRef = useRef(false)
|
||||
const layoutHydrated = useRef(false)
|
||||
|
||||
@@ -432,11 +443,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
shellId: pane.shellId,
|
||||
connectionId,
|
||||
label: currentTab?.title || 'SSH 终端',
|
||||
sessionId: currentTab?.sessionId,
|
||||
}]
|
||||
: [])
|
||||
: []
|
||||
registerBroadcastWorkspace({ tabId, targets })
|
||||
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
|
||||
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
|
||||
|
||||
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
|
||||
|
||||
@@ -628,7 +640,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
currentTab?.isLocal,
|
||||
connectionIdRef.current
|
||||
)
|
||||
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets() : []
|
||||
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
|
||||
const targets = shouldBroadcast
|
||||
? broadcastTargets.map((target) => target.shellId)
|
||||
: resolveTerminalInputTargets(panesRef.current, sourcePaneId, false)
|
||||
@@ -647,7 +659,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
}
|
||||
}
|
||||
},
|
||||
[broadcastInput, connectionIdRef, currentTab?.isLocal]
|
||||
[broadcastInput, connectionIdRef, currentTab?.isLocal, tabId]
|
||||
)
|
||||
|
||||
const split = useCallback(
|
||||
@@ -874,15 +886,24 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
</button>
|
||||
)}
|
||||
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
|
||||
{!isLocal && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={broadcastInput
|
||||
? `广播已开启:键盘输入将发送到全部 ${broadcastTargetCount} 个已连接 SSH 终端`
|
||||
: `广播输入到所有打开的 SSH 终端(${broadcastTargetCount})`}
|
||||
? `广播已开启:输入将发送到范围内 ${broadcastTargetCount} 个 SSH 终端`
|
||||
: `广播同步输入(Pro)· 当前范围 ${broadcastTargetCount} 个目标`}
|
||||
aria-label="广播输入"
|
||||
aria-pressed={broadcastInput}
|
||||
disabled={isLocal || connectionState !== 'connected' || broadcastTargetCount < 2}
|
||||
onClick={() => setBroadcastInput(!broadcastInput)}
|
||||
disabled={connectionState !== 'connected' || (broadcastInput ? false : broadcastTargetCount < 2)}
|
||||
onClick={() => {
|
||||
if (broadcastInput) {
|
||||
setBroadcastInput(false)
|
||||
return
|
||||
}
|
||||
if (!requirePro('broadcast')) return
|
||||
setBroadcastInput(true)
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
|
||||
broadcastInput
|
||||
? 'bg-accent-orange/15 text-accent-orange'
|
||||
@@ -892,19 +913,43 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
广播
|
||||
</button>
|
||||
<select
|
||||
aria-label="广播目标范围"
|
||||
value={broadcastScope}
|
||||
onChange={(event) => {
|
||||
if (!requirePro('broadcast')) return
|
||||
setBroadcastScope(event.target.value as BroadcastScope)
|
||||
}}
|
||||
className="max-w-[7.5rem] truncate rounded-md border border-dark-600 bg-dark-800 px-1.5 py-1 text-[10px] text-txt-muted outline-none hover:text-content-secondary"
|
||||
>
|
||||
<option value="current-tab">当前标签</option>
|
||||
<option value="selected-sessions">侧栏选中</option>
|
||||
<option value="all">全部已开 SSH</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{!isLocal && (
|
||||
<button
|
||||
type="button"
|
||||
title="终端 AI 助手"
|
||||
title="终端 AI 助手(Pro)"
|
||||
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
|
||||
aria-pressed={aiPanelOpen}
|
||||
data-testid="terminal-ai-button"
|
||||
onClick={() => setAiPanelOpen((open) => !open)}
|
||||
onClick={() => {
|
||||
if (aiPanelOpen) {
|
||||
setAiPanelOpen(false)
|
||||
return
|
||||
}
|
||||
if (!requirePro('ai')) return
|
||||
setAiPanelOpen(true)
|
||||
}}
|
||||
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> AI 助手
|
||||
</button>
|
||||
{broadcastInput && (
|
||||
)}
|
||||
{broadcastInput && !isLocal && (
|
||||
<div className="ui-chip border-accent-orange/30 text-accent-orange">
|
||||
广播中 · {broadcastTargetCount}
|
||||
</div>
|
||||
@@ -993,13 +1038,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
onResume={resumeConnection}
|
||||
/>
|
||||
</div>
|
||||
{aiPanelOpen && (
|
||||
{!isLocal && aiPanelOpen && (
|
||||
<AiTerminalPanel
|
||||
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
|
||||
onClose={() => setAiPanelOpen(false)}
|
||||
connectionId={connectionState === 'connected' && !isLocal ? connectionIdRef.current : null}
|
||||
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
|
||||
targetLabel={currentTab?.title || '当前 SSH 会话'}
|
||||
isLocal={isLocal}
|
||||
isLocal={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Vendored
+27
@@ -109,6 +109,32 @@ export interface DesktopApiContract {
|
||||
settingsGet: (key: string) => Promise<{ success: boolean; value?: unknown; error?: string }>
|
||||
settingsSet: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Pro license (device-bound entitlement)
|
||||
licenseGet: () => Promise<{
|
||||
plan: string
|
||||
deviceId: string
|
||||
activated: boolean
|
||||
expiresAt?: number
|
||||
features: string[]
|
||||
token?: string
|
||||
}>
|
||||
licenseActivate: (token: string) => Promise<{
|
||||
plan: string
|
||||
deviceId: string
|
||||
activated: boolean
|
||||
expiresAt?: number
|
||||
features: string[]
|
||||
token?: string
|
||||
}>
|
||||
licenseClear: () => Promise<{
|
||||
plan: string
|
||||
deviceId: string
|
||||
activated: boolean
|
||||
expiresAt?: number
|
||||
features: string[]
|
||||
}>
|
||||
licenseDeviceId: () => Promise<string>
|
||||
|
||||
// Terminal AI (API keys stay in the Rust encrypted secret store)
|
||||
aiProviderSaveKey: (providerId: string, apiKey: string) => Promise<{ success: boolean; error?: string }>
|
||||
aiProviderHasKey: (providerId: string) => Promise<boolean>
|
||||
@@ -162,6 +188,7 @@ export interface DesktopApiContract {
|
||||
onMenuExportSessions: (callback: () => void) => void
|
||||
onMenuOpenSettings: (callback: () => void) => void
|
||||
onSshLinkAvailable: (callback: () => void) => void
|
||||
onProLink: (callback: (url: string) => void) => void
|
||||
onShellData: (callback: (event: unknown, connectionId: string, shellId: string, data: Uint8Array) => void) => void
|
||||
onShellClosed: (
|
||||
callback: (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useCommandPaletteStore } from '../stores/commandPaletteStore'
|
||||
import { useKeybindingStore } from '../stores/keybindingStore'
|
||||
import { KeybindingAction } from '../constants/keybindings'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { disposeAndCloseTab } from '../utils/disposeTab'
|
||||
|
||||
const logger = createLogger('KeyboardShortcuts')
|
||||
|
||||
@@ -219,10 +220,10 @@ export function useKeyboardShortcuts(): void {
|
||||
// ---- Ctrl+W: 关闭标签 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, closeTab } = useTabStore.getState()
|
||||
const { tabs, activeTabId } = useTabStore.getState()
|
||||
if (activeTabId && tabs.length > 1) {
|
||||
logger.debug('Ctrl+W: closing tab', activeTabId)
|
||||
closeTab(activeTabId)
|
||||
void disposeAndCloseTab(activeTabId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+13
-18
@@ -7,7 +7,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { useConnectionPool, getSessionConfig } from '../stores/connection'
|
||||
import { useConnectionPool, getConnectionStore, getSessionConfig } from '../stores/connection'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
remotePath as toRemotePath,
|
||||
uploadRemoteFile,
|
||||
} from '../platform/sftp'
|
||||
import { joinLocalPath } from '../utils/localPath'
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
@@ -155,11 +156,6 @@ function fileNameFromPath(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).pop() || filePath
|
||||
}
|
||||
|
||||
function joinLocalPath(directory: string, fileName: string): string {
|
||||
const separator = directory.includes('\\') && !directory.includes('/') ? '\\' : '/'
|
||||
return `${directory.replace(/[\\/]$/, '')}${separator}${fileName}`
|
||||
}
|
||||
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
@@ -169,7 +165,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [localFiles, setLocalFiles] = useState<FileItem[]>([])
|
||||
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set())
|
||||
const [localHistory, setLocalHistory] = useState<string[]>(['/'])
|
||||
const [localHistory, setLocalHistory] = useState<string[]>([])
|
||||
const [localHistoryIndex, setLocalHistoryIndex] = useState(0)
|
||||
|
||||
// Remote state
|
||||
@@ -246,7 +242,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const type = localEntryType(f)
|
||||
return {
|
||||
name: f.name,
|
||||
path: f.path || `${path}/${f.name}`.replace(/\/+/g, '/'),
|
||||
path: f.path || joinLocalPath(path, f.name),
|
||||
type,
|
||||
size: f.size || 0,
|
||||
modified: f.modified ?? f.mtime ?? Date.now() / 1000,
|
||||
@@ -467,20 +463,19 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
// Close SFTP session via SSH disconnect (SFTP is cleaned up when connection closes)
|
||||
if (connectionId && window.opsxApi) {
|
||||
if (connectionId) {
|
||||
try {
|
||||
await window.opsxApi.sshDisconnect(connectionId)
|
||||
logger.info('SFTP connection closed', { connectionId, sftpId })
|
||||
await getConnectionStore().releaseConnection(connectionId, tabId)
|
||||
logger.info('SFTP connection released', { connectionId, sftpId })
|
||||
} catch (err) {
|
||||
logger.error('Error closing SFTP connection', err)
|
||||
logger.error('Error releasing SFTP connection', err)
|
||||
}
|
||||
}
|
||||
setIsConnected(false)
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId])
|
||||
}, [connectionId, sftpId, tabId])
|
||||
|
||||
// Upload files (real implementation via IPC)
|
||||
const uploadFiles = useCallback(
|
||||
@@ -845,17 +840,17 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
[sftpId, remotePath, loadRemoteDirectory]
|
||||
)
|
||||
|
||||
// Cleanup on unmount — use refs to avoid re-running when connectionId changes
|
||||
// Cleanup on unmount — release pool ref instead of hard-disconnecting shared sessions
|
||||
const connectionIdRef = useRef(connectionId)
|
||||
connectionIdRef.current = connectionId
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const cid = connectionIdRef.current
|
||||
if (cid && window.opsxApi) {
|
||||
window.opsxApi.sshDisconnect(cid).catch(() => {})
|
||||
if (cid) {
|
||||
void getConnectionStore().releaseConnection(cid, tabId)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [tabId])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
|
||||
@@ -141,6 +141,10 @@ export const desktopApi: DesktopApi = {
|
||||
|
||||
settingsGet: (key) => call('settings_get', { key }),
|
||||
settingsSet: (key, value) => call('settings_set', { key, value }),
|
||||
licenseGet: () => call('license_get'),
|
||||
licenseActivate: (token) => call('license_activate', { token }),
|
||||
licenseClear: () => call('license_clear'),
|
||||
licenseDeviceId: () => call('license_device_id'),
|
||||
aiProviderSaveKey: (providerId, apiKey) => call('ai_provider_save_key', { providerId, apiKey }),
|
||||
aiProviderHasKey: (providerId) => call('ai_provider_has_key', { providerId }),
|
||||
aiProviderDeleteKey: (providerId) => call('ai_provider_delete_key', { providerId }),
|
||||
@@ -214,6 +218,8 @@ export const desktopApi: DesktopApi = {
|
||||
onMenuExportSessions: (callback) => addListener('menu:export-sessions', callback, callback),
|
||||
onMenuOpenSettings: (callback) => addListener('menu:open-settings', callback, callback),
|
||||
onSshLinkAvailable: (callback) => addListener('app:ssh-link-available', callback, callback),
|
||||
onProLink: (callback) =>
|
||||
addListener<string>('app:pro-link', callback as Listener, (payload) => callback(payload)),
|
||||
removeListener: (channel, callback) => removeListener(channel, callback),
|
||||
removeAllListeners,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from './types'
|
||||
import type { CreateConnectionOptions, CreateConnectionResult, PoolStats } from './types'
|
||||
import { useSettingsStore } from '../settingsStore'
|
||||
import { connectionReuseFingerprint } from '../../utils/connectionFingerprint'
|
||||
|
||||
// ============== Pool State ==============
|
||||
|
||||
@@ -83,19 +84,14 @@ export const usePoolStore = create<PoolState>((set, get) => ({
|
||||
},
|
||||
|
||||
createConnection: async (tabId, config, _options = {}): Promise<CreateConnectionResult> => {
|
||||
console.log('=== POOL STORE createConnection ===')
|
||||
console.log(' tabId:', tabId)
|
||||
console.log(' config:', config.host, config.port, config.username)
|
||||
|
||||
const { connections, config: poolConfig } = get()
|
||||
const now = Date.now()
|
||||
const reuseKey = connectionReuseFingerprint(config)
|
||||
|
||||
// 1. 检查是否已存在相同配置的连接 (连接复用)
|
||||
for (const [existingId, conn] of connections.entries()) {
|
||||
if (
|
||||
conn.config.host === config.host &&
|
||||
conn.config.port === config.port &&
|
||||
conn.config.username === config.username &&
|
||||
connectionReuseFingerprint(conn.config) === reuseKey &&
|
||||
conn.state !== 'disconnected' &&
|
||||
conn.state !== 'error'
|
||||
) {
|
||||
@@ -345,6 +341,9 @@ export const usePoolStore = create<PoolState>((set, get) => ({
|
||||
const conn = get().connections.get(connectionId)
|
||||
if (!conn) return
|
||||
|
||||
// Idempotent: ignore tabs that already released this connection
|
||||
if (!conn.tabIds.has(tabId)) return
|
||||
|
||||
conn.tabIds.delete(tabId)
|
||||
conn.refCount = Math.max(0, conn.refCount - 1)
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Pro entitlement — free/pro feature gates
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { useTabStore } from './tabStore'
|
||||
|
||||
const logger = createLogger('Entitlement')
|
||||
|
||||
export type PlanId = 'free' | 'pro'
|
||||
|
||||
export type ProFeature =
|
||||
| 'ai'
|
||||
| 'portForward'
|
||||
| 'proxy'
|
||||
| 'serial'
|
||||
| 'batchExec'
|
||||
| 'broadcast'
|
||||
| 'batchImportExport'
|
||||
| 'rdp'
|
||||
| 'vnc'
|
||||
|
||||
export const PRO_FEATURE_LABELS: Record<ProFeature, string> = {
|
||||
ai: '终端 AI 助手',
|
||||
portForward: '端口转发',
|
||||
proxy: '会话代理 / SOCKS',
|
||||
serial: '串口终端',
|
||||
batchExec: '批量执行',
|
||||
broadcast: '广播同步输入',
|
||||
batchImportExport: '批量导入导出主机清单',
|
||||
rdp: 'RDP 远程桌面',
|
||||
vnc: 'VNC 远程桌面',
|
||||
}
|
||||
|
||||
/** Features that are Pro-gated but not yet implemented. */
|
||||
export const COMING_SOON_FEATURES: ReadonlySet<ProFeature> = new Set(['rdp', 'vnc'])
|
||||
|
||||
const ALL_PRO_FEATURES: ProFeature[] = [
|
||||
'ai',
|
||||
'portForward',
|
||||
'proxy',
|
||||
'serial',
|
||||
'batchExec',
|
||||
'broadcast',
|
||||
'batchImportExport',
|
||||
'rdp',
|
||||
'vnc',
|
||||
]
|
||||
|
||||
export interface UpgradeWallState {
|
||||
open: boolean
|
||||
feature?: ProFeature
|
||||
comingSoon?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface EntitlementState {
|
||||
plan: PlanId
|
||||
deviceId: string
|
||||
expiresAt: number | null
|
||||
features: string[]
|
||||
loaded: boolean
|
||||
wall: UpgradeWallState
|
||||
hydrate: () => Promise<void>
|
||||
activate: (token: string) => Promise<{ success: boolean; error?: string }>
|
||||
clear: () => Promise<void>
|
||||
canUse: (feature: ProFeature) => boolean
|
||||
requirePro: (feature: ProFeature, options?: { comingSoon?: boolean; message?: string }) => boolean
|
||||
openWall: (feature: ProFeature, options?: { comingSoon?: boolean; message?: string }) => void
|
||||
closeWall: () => void
|
||||
openUpgradeTab: (feature?: ProFeature, comingSoon?: boolean) => void
|
||||
}
|
||||
|
||||
function applyStatus(
|
||||
set: (partial: Partial<EntitlementState>) => void,
|
||||
status: {
|
||||
plan: string
|
||||
deviceId: string
|
||||
activated: boolean
|
||||
expiresAt?: number | null
|
||||
features?: string[]
|
||||
}
|
||||
) {
|
||||
const plan: PlanId = status.activated && status.plan === 'pro' ? 'pro' : 'free'
|
||||
set({
|
||||
plan,
|
||||
deviceId: status.deviceId,
|
||||
expiresAt: status.expiresAt ?? null,
|
||||
features: status.features ?? [],
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
export const useEntitlementStore = create<EntitlementState>((set, get) => ({
|
||||
plan: 'free',
|
||||
deviceId: '',
|
||||
expiresAt: null,
|
||||
features: [],
|
||||
loaded: false,
|
||||
wall: { open: false },
|
||||
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const status = await window.opsxApi.licenseGet()
|
||||
applyStatus(set, status)
|
||||
} catch (error) {
|
||||
logger.warn('加载许可证失败', error)
|
||||
set({ loaded: true, plan: 'free' })
|
||||
}
|
||||
},
|
||||
|
||||
activate: async (token) => {
|
||||
try {
|
||||
const status = await window.opsxApi.licenseActivate(token.trim())
|
||||
applyStatus(set, status)
|
||||
if (!status.activated) {
|
||||
return { success: false, error: '激活失败:许可证无效' }
|
||||
}
|
||||
set({ wall: { open: false } })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
},
|
||||
|
||||
clear: async () => {
|
||||
try {
|
||||
const status = await window.opsxApi.licenseClear()
|
||||
applyStatus(set, status)
|
||||
} catch (error) {
|
||||
logger.warn('清除许可证失败', error)
|
||||
set({ plan: 'free', features: [], expiresAt: null })
|
||||
}
|
||||
},
|
||||
|
||||
canUse: (feature) => {
|
||||
const { plan, features, expiresAt } = get()
|
||||
if (plan !== 'pro') return false
|
||||
if (expiresAt && Date.now() / 1000 > expiresAt) return false
|
||||
if (features.includes('*') || features.length === 0) return true
|
||||
return features.includes(feature)
|
||||
},
|
||||
|
||||
requirePro: (feature, options) => {
|
||||
if (get().canUse(feature)) return true
|
||||
get().openWall(feature, {
|
||||
comingSoon: options?.comingSoon ?? COMING_SOON_FEATURES.has(feature),
|
||||
message: options?.message,
|
||||
})
|
||||
return false
|
||||
},
|
||||
|
||||
openWall: (feature, options) => {
|
||||
set({
|
||||
wall: {
|
||||
open: true,
|
||||
feature,
|
||||
comingSoon: options?.comingSoon ?? COMING_SOON_FEATURES.has(feature),
|
||||
message: options?.message,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
closeWall: () => set({ wall: { open: false } }),
|
||||
|
||||
openUpgradeTab: (feature, comingSoon) => {
|
||||
useTabStore.getState().addTab({
|
||||
title: '升级 Pro',
|
||||
type: 'upgrade',
|
||||
upgradeFeature: feature,
|
||||
upgradeComingSoon: comingSoon,
|
||||
})
|
||||
set({ wall: { open: false } })
|
||||
},
|
||||
}))
|
||||
|
||||
export function isProFeature(feature: string): feature is ProFeature {
|
||||
return (ALL_PRO_FEATURES as string[]).includes(feature)
|
||||
}
|
||||
|
||||
/** License server base URL (dev default). */
|
||||
export function licenseServerBaseUrl(): string {
|
||||
return (
|
||||
(typeof import.meta !== 'undefined' &&
|
||||
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_LICENSE_SERVER_URL) ||
|
||||
'http://127.0.0.1:8787'
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
export { useTabStore } from './tabStore'
|
||||
export { useSettingsStore } from './settingsStore'
|
||||
export { useTerminalSnapshotStore } from './terminalSnapshotStore'
|
||||
export { useEntitlementStore } from './entitlementStore'
|
||||
export type { ProFeature, PlanId } from './entitlementStore'
|
||||
|
||||
// 会话管理
|
||||
export { useSessionStore, useConnectionStore as useSessionStoreCompat } from './sessionStore'
|
||||
|
||||
+14
-1
@@ -6,7 +6,15 @@ import type { SerialConfig } from '../../shared/types/serial.types'
|
||||
|
||||
const logger = createLogger('TabStore')
|
||||
|
||||
export type TabType = 'welcome' | 'terminal' | 'serial' | 'sftp' | 'portforward' | 'settings'
|
||||
export type TabType =
|
||||
| 'welcome'
|
||||
| 'terminal'
|
||||
| 'serial'
|
||||
| 'sftp'
|
||||
| 'portforward'
|
||||
| 'settings'
|
||||
| 'upgrade'
|
||||
| 'batch'
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
// 连接实例信息
|
||||
@@ -47,6 +55,11 @@ export interface Tab {
|
||||
localShellPath?: string
|
||||
/** Native serial terminal configuration. */
|
||||
serialConfig?: SerialConfig
|
||||
/** Pro upgrade tab context */
|
||||
upgradeFeature?: string
|
||||
upgradeComingSoon?: boolean
|
||||
/** Batch exec tab: preselected session ids */
|
||||
batchSessionIds?: string[]
|
||||
}
|
||||
|
||||
// 用于持久化的序列化 Tab 数据(排除敏感字段和运行时状态)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { useSessionStore } from './sessionStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
|
||||
export type BroadcastScope = 'all' | 'current-tab' | 'selected-sessions'
|
||||
|
||||
export interface TerminalBroadcastTarget {
|
||||
tabId: string
|
||||
@@ -6,6 +10,7 @@ export interface TerminalBroadcastTarget {
|
||||
shellId: string
|
||||
connectionId: string
|
||||
label: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
interface TerminalBroadcastWorkspace {
|
||||
@@ -15,8 +20,13 @@ interface TerminalBroadcastWorkspace {
|
||||
|
||||
interface TerminalBroadcastState {
|
||||
enabled: boolean
|
||||
scope: BroadcastScope
|
||||
/** Used when scope === 'selected-sessions' */
|
||||
selectedSessionIds: string[]
|
||||
workspaces: Record<string, TerminalBroadcastWorkspace>
|
||||
setEnabled: (enabled: boolean) => void
|
||||
setScope: (scope: BroadcastScope) => void
|
||||
setSelectedSessionIds: (ids: string[]) => void
|
||||
registerWorkspace: (workspace: TerminalBroadcastWorkspace) => void
|
||||
unregisterWorkspace: (tabId: string) => void
|
||||
reset: () => void
|
||||
@@ -24,8 +34,12 @@ interface TerminalBroadcastState {
|
||||
|
||||
export const useTerminalBroadcastStore = create<TerminalBroadcastState>((set) => ({
|
||||
enabled: false,
|
||||
scope: 'all',
|
||||
selectedSessionIds: [],
|
||||
workspaces: {},
|
||||
setEnabled: (enabled) => set({ enabled }),
|
||||
setScope: (scope) => set({ scope }),
|
||||
setSelectedSessionIds: (selectedSessionIds) => set({ selectedSessionIds }),
|
||||
registerWorkspace: (workspace) =>
|
||||
set((state) => ({
|
||||
workspaces: { ...state.workspaces, [workspace.tabId]: workspace },
|
||||
@@ -36,27 +50,60 @@ export const useTerminalBroadcastStore = create<TerminalBroadcastState>((set) =>
|
||||
delete workspaces[tabId]
|
||||
return { workspaces }
|
||||
}),
|
||||
reset: () => set({ enabled: false, workspaces: {} }),
|
||||
reset: () => set({ enabled: false, scope: 'all', selectedSessionIds: [], workspaces: {} }),
|
||||
}))
|
||||
|
||||
/** Every ready remote shell across all currently open terminal tabs and panes. */
|
||||
export function getTerminalBroadcastTargets(): TerminalBroadcastTarget[] {
|
||||
function resolveSessionIdForTab(tabId: string): string | undefined {
|
||||
return useTabStore.getState().tabs.find((tab) => tab.id === tabId)?.sessionId
|
||||
}
|
||||
|
||||
/** Ready remote shells filtered by current broadcast scope. */
|
||||
export function getTerminalBroadcastTargets(sourceTabId?: string): TerminalBroadcastTarget[] {
|
||||
const state = useTerminalBroadcastStore.getState()
|
||||
const seen = new Set<string>()
|
||||
const targets: TerminalBroadcastTarget[] = []
|
||||
for (const workspace of Object.values(useTerminalBroadcastStore.getState().workspaces)) {
|
||||
|
||||
const selected = new Set(state.selectedSessionIds)
|
||||
const sessionById = new Map(useSessionStore.getState().sessions.map((s) => [s.id, s]))
|
||||
|
||||
for (const workspace of Object.values(state.workspaces)) {
|
||||
if (state.scope === 'current-tab' && sourceTabId && workspace.tabId !== sourceTabId) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const target of workspace.targets) {
|
||||
if (seen.has(target.shellId)) continue
|
||||
|
||||
const sessionId = target.sessionId || resolveSessionIdForTab(target.tabId)
|
||||
if (state.scope === 'selected-sessions') {
|
||||
if (!sessionId || !selected.has(sessionId)) continue
|
||||
// Prefer known session names when available
|
||||
void sessionById
|
||||
}
|
||||
|
||||
seen.add(target.shellId)
|
||||
targets.push(target)
|
||||
targets.push({ ...target, sessionId })
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
export function terminalBroadcastTargetCountForTab(tabId: string): number {
|
||||
return getTerminalBroadcastTargets(tabId).length
|
||||
}
|
||||
|
||||
/** Zustand selector helper — pass tabId via closure in the component. */
|
||||
export function selectBroadcastTargetCount(tabId: string) {
|
||||
return (state: TerminalBroadcastState): number => {
|
||||
void state.workspaces
|
||||
void state.scope
|
||||
void state.selectedSessionIds
|
||||
return getTerminalBroadcastTargets(tabId).length
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated prefer selectBroadcastTargetCount(tabId) */
|
||||
export function terminalBroadcastTargetCount(state: TerminalBroadcastState): number {
|
||||
const shellIds = new Set<string>()
|
||||
for (const workspace of Object.values(state.workspaces)) {
|
||||
for (const target of workspace.targets) shellIds.add(target.shellId)
|
||||
}
|
||||
return shellIds.size
|
||||
void state
|
||||
return getTerminalBroadcastTargets().length
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Connection reuse fingerprint — identity beyond host/port/user.
|
||||
*/
|
||||
|
||||
import type { JumpHostConfig, ProxyConfig, SSHConnectionConfig } from '../../shared/types/session.types'
|
||||
|
||||
function proxyFingerprint(proxy?: ProxyConfig | null): string {
|
||||
if (!proxy || proxy.type === 'none') return 'proxy:none'
|
||||
return [
|
||||
'proxy',
|
||||
proxy.type,
|
||||
proxy.host,
|
||||
String(proxy.port),
|
||||
proxy.username || '',
|
||||
proxy.password ? 'pwd' : '',
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function jumpFingerprint(jump?: JumpHostConfig | null): string {
|
||||
if (!jump) return ''
|
||||
return [
|
||||
'jump',
|
||||
jump.host,
|
||||
String(jump.port ?? 22),
|
||||
jump.username,
|
||||
jump.authType || '',
|
||||
jump.privateKey ? `key:${jump.privateKey.slice(0, 64)}` : '',
|
||||
jump.password ? 'pwd' : '',
|
||||
jump.passphrase ? 'pass' : '',
|
||||
proxyFingerprint(jump.proxy),
|
||||
].join('|')
|
||||
}
|
||||
|
||||
/** Stable key used by the connection pool to decide whether two configs may share a live session. */
|
||||
export function connectionReuseFingerprint(config: SSHConnectionConfig): string {
|
||||
const jumps =
|
||||
config.jumpHosts && config.jumpHosts.length > 0
|
||||
? config.jumpHosts.map(jumpFingerprint).join('>')
|
||||
: jumpFingerprint(config.jumpHost)
|
||||
|
||||
return [
|
||||
config.host,
|
||||
String(config.port),
|
||||
config.username,
|
||||
config.authType || (config.privateKey ? 'key' : config.password ? 'password' : 'agent'),
|
||||
config.privateKey ? `key:${config.privateKey.slice(0, 64)}` : '',
|
||||
config.password ? 'pwd' : '',
|
||||
config.passphrase ? 'pass' : '',
|
||||
proxyFingerprint(config.proxy),
|
||||
jumps,
|
||||
(config.jumpSessionIds && config.jumpSessionIds.length > 0
|
||||
? config.jumpSessionIds.join(',')
|
||||
: config.jumpSessionId) || '',
|
||||
].join('::')
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Unified tab disposal — release SSH pool refs / kill local shells before closeTab.
|
||||
*/
|
||||
|
||||
import { getConnectionStore } from '../stores/connection'
|
||||
import { useTabStore, type Tab } from '../stores/tabStore'
|
||||
import { createLogger } from './logger'
|
||||
import { shellKill } from './shellIO'
|
||||
|
||||
const logger = createLogger('DisposeTab')
|
||||
|
||||
function isSshPoolConnectionId(connectionId: string | undefined | null): connectionId is string {
|
||||
return !!connectionId && connectionId !== 'local'
|
||||
}
|
||||
|
||||
/** Release native resources held by a tab (safe to call more than once). */
|
||||
export async function disposeTabResources(tab: Tab): Promise<void> {
|
||||
if (tab.type === 'welcome' || tab.type === 'settings') return
|
||||
|
||||
const connectionId = tab.connectionInstance?.connectionId
|
||||
const shellId = tab.connectionInstance?.shellId
|
||||
|
||||
if (shellId && (tab.isLocal || connectionId === 'local' || shellId.startsWith('local-'))) {
|
||||
const result = await shellKill(shellId).catch((error) => ({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (!result.success) {
|
||||
logger.warn('关闭本地 Shell 失败', { tabId: tab.id, shellId, error: result.error })
|
||||
}
|
||||
}
|
||||
|
||||
if (isSshPoolConnectionId(connectionId)) {
|
||||
try {
|
||||
await getConnectionStore().releaseConnection(connectionId, tab.id)
|
||||
} catch (error) {
|
||||
logger.warn('释放 SSH 连接失败', {
|
||||
tabId: tab.id,
|
||||
connectionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disposeTabs(tabs: Tab[]): Promise<void> {
|
||||
await Promise.all(tabs.map((tab) => disposeTabResources(tab)))
|
||||
}
|
||||
|
||||
/** Dispose resources then remove the tab from the store. */
|
||||
export async function disposeAndCloseTab(tabId: string): Promise<void> {
|
||||
const { tabs, getTabById, closeTab } = useTabStore.getState()
|
||||
if (tabs.length <= 1) return
|
||||
const tab = getTabById(tabId)
|
||||
if (tab) await disposeTabResources(tab)
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
export async function disposeAndCloseOtherTabs(keepTabId: string): Promise<void> {
|
||||
const { tabs } = useTabStore.getState()
|
||||
const toClose = tabs.filter((tab) => tab.id !== keepTabId)
|
||||
await disposeTabs(toClose)
|
||||
useTabStore.getState().closeOtherTabs(keepTabId)
|
||||
}
|
||||
|
||||
export async function disposeAndCloseRightTabs(tabId: string): Promise<void> {
|
||||
const { tabs } = useTabStore.getState()
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index === -1) return
|
||||
await disposeTabs(tabs.slice(index + 1))
|
||||
useTabStore.getState().closeRightTabs(tabId)
|
||||
}
|
||||
|
||||
export async function disposeAndCloseLeftTabs(tabId: string): Promise<void> {
|
||||
const { tabs } = useTabStore.getState()
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index === -1) return
|
||||
await disposeTabs(tabs.slice(0, index))
|
||||
useTabStore.getState().closeLeftTabs(tabId)
|
||||
}
|
||||
|
||||
export async function disposeAndCloseAllTabs(): Promise<void> {
|
||||
const { tabs } = useTabStore.getState()
|
||||
await disposeTabs(tabs)
|
||||
useTabStore.getState().closeAllTabs()
|
||||
}
|
||||
@@ -7,6 +7,17 @@ export { handleError, createError, ErrorCode, withErrorHandling } from './error-
|
||||
export { getDefaultProxyConfig, isProxyConfigured, validateProxyConfig, parseProxyUrl, buildProxyUrl } from './proxy'
|
||||
export { applyKeywordHighlights, DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from './keywordHighlight'
|
||||
export { expandSnippetVariables, SNIPPET_VARIABLE_HINTS, type SnippetVariableContext } from './snippetVariables'
|
||||
export { connectionReuseFingerprint } from './connectionFingerprint'
|
||||
export {
|
||||
disposeTabResources,
|
||||
disposeAndCloseTab,
|
||||
disposeAndCloseOtherTabs,
|
||||
disposeAndCloseRightTabs,
|
||||
disposeAndCloseLeftTabs,
|
||||
disposeAndCloseAllTabs,
|
||||
} from './disposeTab'
|
||||
export { isWindowsLocalPath, joinLocalPath, parentPath, splitPathSegments } from './localPath'
|
||||
export { fallbackLocalShells, localShellEnterKey, modKeyLabel } from './platformShells'
|
||||
export { decodeShellPayload, shellPayloadByteLength } from './shellData'
|
||||
export { TerminalPipeline, type TerminalPipelineOptions } from './terminalPipeline'
|
||||
export { buildTerminalFontFamily, TERMINAL_FONT_OPTIONS, NERD_FONT_STACK } from './terminalFont'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Cross-platform local path helpers for the renderer.
|
||||
*/
|
||||
|
||||
export function isWindowsLocalPath(path: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\')
|
||||
}
|
||||
|
||||
/** Split a local or remote path into breadcrumb segments. */
|
||||
export function splitPathSegments(path: string, isLocal: boolean): {
|
||||
root: string
|
||||
parts: string[]
|
||||
join: (parts: string[]) => string
|
||||
} {
|
||||
if (isLocal && isWindowsLocalPath(path)) {
|
||||
const normalized = path.replace(/\//g, '\\')
|
||||
if (normalized.startsWith('\\\\')) {
|
||||
// UNC \\server\share\...
|
||||
const rest = normalized.slice(2)
|
||||
const bits = rest.split('\\').filter(Boolean)
|
||||
const root = bits.length >= 2 ? `\\\\${bits[0]}\\${bits[1]}` : `\\\\${bits[0] || ''}`
|
||||
const parts = bits.slice(2)
|
||||
return {
|
||||
root,
|
||||
parts,
|
||||
join: (segments) => (segments.length ? `${root}\\${segments.join('\\')}` : root),
|
||||
}
|
||||
}
|
||||
const match = /^([a-zA-Z]:)(.*)$/.exec(normalized)
|
||||
const drive = match?.[1] || 'C:'
|
||||
const rest = (match?.[2] || '').replace(/^\\+/, '')
|
||||
const parts = rest ? rest.split('\\').filter(Boolean) : []
|
||||
const root = `${drive}\\`
|
||||
return {
|
||||
root,
|
||||
parts,
|
||||
join: (segments) => (segments.length ? `${drive}\\${segments.join('\\')}` : root),
|
||||
}
|
||||
}
|
||||
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return {
|
||||
root: '/',
|
||||
parts,
|
||||
join: (segments) => (segments.length ? `/${segments.join('/')}` : '/'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Parent directory for local or remote paths. */
|
||||
export function parentPath(path: string, isLocal: boolean): string {
|
||||
if (isLocal && isWindowsLocalPath(path)) {
|
||||
const { root, parts, join } = splitPathSegments(path, true)
|
||||
if (parts.length === 0) return root
|
||||
return join(parts.slice(0, -1))
|
||||
}
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length === 0) return '/'
|
||||
return `/${parts.slice(0, -1).join('/')}` || '/'
|
||||
}
|
||||
|
||||
export function joinLocalPath(directory: string, fileName: string): string {
|
||||
if (!directory) return fileName
|
||||
if (isWindowsLocalPath(directory) || directory.includes('\\')) {
|
||||
const base = directory.replace(/[\\/]+$/, '')
|
||||
return `${base}\\${fileName}`
|
||||
}
|
||||
const base = directory.replace(/\/+$/, '') || ''
|
||||
if (!base || base === '/') return `/${fileName}`
|
||||
return `${base}/${fileName}`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Platform-aware local shell fallbacks when native list_shells is unavailable.
|
||||
*/
|
||||
|
||||
import { getDesktopPlatform, type DesktopPlatform } from '../platform/runtime'
|
||||
import type { AvailableLocalShell } from '../platform/localTerminal'
|
||||
|
||||
export function fallbackLocalShells(
|
||||
platform: DesktopPlatform = getDesktopPlatform()
|
||||
): AvailableLocalShell[] {
|
||||
if (platform === 'windows') {
|
||||
return [
|
||||
{
|
||||
path: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
name: 'PowerShell',
|
||||
kind: 'pwsh',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
path: 'C:\\Windows\\System32\\cmd.exe',
|
||||
name: 'CMD',
|
||||
kind: 'cmd',
|
||||
isDefault: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return [
|
||||
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: true },
|
||||
{ path: '/bin/sh', name: 'Sh', kind: 'sh', isDefault: false },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true },
|
||||
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: false },
|
||||
]
|
||||
}
|
||||
|
||||
/** Enter key sequence for injecting commands into a local shell. */
|
||||
export function localShellEnterKey(kind?: string | null): string {
|
||||
if (kind === 'cmd' || kind === 'pwsh' || kind === 'powershell') return '\r\n'
|
||||
return '\n'
|
||||
}
|
||||
|
||||
export function modKeyLabel(platform: DesktopPlatform = getDesktopPlatform()): string {
|
||||
return platform === 'macos' ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
@@ -14,12 +14,11 @@ export function isTerminalPasteShortcut(event: KeyboardEvent): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** macOS: ⌘D;Win/Linux: Ctrl+Shift+D(避免与 Ctrl+D EOF 冲突) */
|
||||
export function isTerminalSplitShortcut(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
event.key.toLowerCase() === 'd'
|
||||
)
|
||||
const key = event.key.toLowerCase()
|
||||
if (key !== 'd') return false
|
||||
if (event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) return true
|
||||
if (event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey) return true
|
||||
return false
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import TerminalWorkspace from '../../src/components/terminal/TerminalWorkspace'
|
||||
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||
import { useSettingsStore } from '../../src/stores/settingsStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { unlockProForTests } from '../helpers/entitlement'
|
||||
|
||||
describe('AI entry points', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
useSettingsStore.setState({ isLoaded: true })
|
||||
useAiProviderStore.setState({
|
||||
providers: [],
|
||||
@@ -32,7 +34,7 @@ describe('AI entry points', () => {
|
||||
expect(screen.getByRole('button', { name: '添加' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps a prominent AI assistant control wired into every terminal workspace', () => {
|
||||
it('keeps a prominent AI assistant control on SSH terminal workspaces', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
@@ -56,4 +58,25 @@ describe('AI entry points', () => {
|
||||
fireEvent.click(entry)
|
||||
expect(entry).toHaveAttribute('aria-label', '关闭终端 AI 助手')
|
||||
})
|
||||
|
||||
it('hides the AI assistant control on local shell workspaces', () => {
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: 'local-ai-test',
|
||||
title: 'Local',
|
||||
type: 'terminal',
|
||||
isActive: false,
|
||||
isLocal: true,
|
||||
localShellKind: 'zsh',
|
||||
localShellPath: '/bin/zsh',
|
||||
},
|
||||
],
|
||||
activeTabId: null,
|
||||
})
|
||||
|
||||
render(<TerminalWorkspace tabId="local-ai-test" isActive={false} />)
|
||||
|
||||
expect(screen.queryByTestId('terminal-ai-button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from '../../src/app/App'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { unlockProForTests } from '../helpers/entitlement'
|
||||
|
||||
describe('App foundation acceptance', () => {
|
||||
afterEach(() => {
|
||||
@@ -60,6 +61,13 @@ describe('App foundation acceptance', () => {
|
||||
|
||||
it('writes exported sessions to the selected JSON file', async () => {
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
vi.mocked(mockDesktopApi.licenseGet).mockResolvedValue({
|
||||
plan: 'pro',
|
||||
deviceId: 'test-device',
|
||||
activated: true,
|
||||
features: ['*'],
|
||||
})
|
||||
window.opsxApi = mockDesktopApi
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
|
||||
@@ -7,6 +7,7 @@ import SerialConnectDialog from '../../src/components/serial/SerialConnectDialog
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { unlockProForTests } from '../helpers/entitlement'
|
||||
|
||||
const serialMocks = vi.hoisted(() => ({
|
||||
listSerialPorts: vi.fn(),
|
||||
@@ -19,6 +20,7 @@ vi.mock('../../src/platform/serial', () => ({
|
||||
describe('Quick launcher and serial acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
window.opsxApi = mockDesktopApi
|
||||
serialMocks.listSerialPorts.mockResolvedValue([
|
||||
{ path: '/dev/tty.debug-console', portType: 'Serial' },
|
||||
@@ -96,12 +98,12 @@ describe('Quick launcher and serial acceptance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the documented manual serial path when enumeration returns no devices', async () => {
|
||||
it('keeps the platform default serial path when enumeration returns no devices', async () => {
|
||||
serialMocks.listSerialPorts.mockResolvedValue([])
|
||||
render(<SerialConnectDialog isOpen onClose={vi.fn()} onConnect={vi.fn()} />)
|
||||
|
||||
await waitFor(() => expect(serialMocks.listSerialPorts).toHaveBeenCalled())
|
||||
expect(screen.getByLabelText('设备')).toHaveValue('/dev/tty.debug-console')
|
||||
expect(screen.getByText(/\/dev\/tty\.Bluetooth-Incoming-Port/)).toBeVisible()
|
||||
expect(screen.getByLabelText('设备')).toHaveValue('/dev/tty.usbserial')
|
||||
expect(screen.queryByRole('button', { name: /Bluetooth-Incoming-Port/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -70,7 +70,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -86,7 +86,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
|
||||
await screen.findByText('已连接')
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(await screen.findByText('remote.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '下载' }))
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
|
||||
expect(await screen.findByText(/Permission denied/)).toBeVisible()
|
||||
@@ -127,7 +127,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('local.txt'), { dataTransfer: transfer })
|
||||
@@ -144,7 +144,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
|
||||
it('downloads by dragging a remote row onto the local panel', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('remote.txt'), { dataTransfer: transfer })
|
||||
|
||||
@@ -6,10 +6,12 @@ import QuickConnectDialog from '../../src/components/sessions/QuickConnectDialog
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { unlockProForTests } from '../helpers/entitlement'
|
||||
|
||||
describe('Session workflow acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
window.opsxApi = mockDesktopApi
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
useTabStore.setState({
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEntitlementStore } from '../../src/stores/entitlementStore'
|
||||
|
||||
/** Enable Pro features for acceptance tests that exercise gated flows. */
|
||||
export function unlockProForTests(): void {
|
||||
useEntitlementStore.setState({
|
||||
plan: 'pro',
|
||||
deviceId: 'test-device',
|
||||
expiresAt: null,
|
||||
features: ['*'],
|
||||
loaded: true,
|
||||
wall: { open: false },
|
||||
})
|
||||
}
|
||||
|
||||
export function resetEntitlementForTests(): void {
|
||||
useEntitlementStore.setState({
|
||||
plan: 'free',
|
||||
deviceId: 'test-device',
|
||||
expiresAt: null,
|
||||
features: [],
|
||||
loaded: true,
|
||||
wall: { open: false },
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,25 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
// Settings
|
||||
settingsGet: vi.fn().mockResolvedValue({ success: true, value: undefined }),
|
||||
settingsSet: vi.fn().mockResolvedValue({ success: true }),
|
||||
licenseGet: vi.fn().mockResolvedValue({
|
||||
plan: 'free',
|
||||
deviceId: 'test-device',
|
||||
activated: false,
|
||||
features: [],
|
||||
}),
|
||||
licenseActivate: vi.fn().mockResolvedValue({
|
||||
plan: 'pro',
|
||||
deviceId: 'test-device',
|
||||
activated: true,
|
||||
features: ['*'],
|
||||
}),
|
||||
licenseClear: vi.fn().mockResolvedValue({
|
||||
plan: 'free',
|
||||
deviceId: 'test-device',
|
||||
activated: false,
|
||||
features: [],
|
||||
}),
|
||||
licenseDeviceId: vi.fn().mockResolvedValue('test-device'),
|
||||
aiProviderSaveKey: vi.fn().mockResolvedValue({ success: true }),
|
||||
aiProviderHasKey: vi.fn().mockResolvedValue(false),
|
||||
aiProviderDeleteKey: vi.fn().mockResolvedValue({ success: true }),
|
||||
@@ -126,6 +145,7 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
onMenuExportSessions: vi.fn(),
|
||||
onMenuOpenSettings: vi.fn(),
|
||||
onSshLinkAvailable: vi.fn(),
|
||||
onProLink: vi.fn(),
|
||||
respondHostKeyPrompt: vi.fn(),
|
||||
knownHostsList: vi.fn().mockResolvedValue({ success: true, entries: [] }),
|
||||
knownHostsRemove: vi.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { connectionReuseFingerprint } from '../../src/utils/connectionFingerprint'
|
||||
import type { SSHConnectionConfig } from '../../shared/types/session.types'
|
||||
|
||||
const base = (): SSHConnectionConfig => ({
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: 'secret',
|
||||
})
|
||||
|
||||
describe('connectionReuseFingerprint', () => {
|
||||
it('matches identical configs', () => {
|
||||
expect(connectionReuseFingerprint(base())).toBe(connectionReuseFingerprint(base()))
|
||||
})
|
||||
|
||||
it('differs when password presence changes', () => {
|
||||
const a = base()
|
||||
const b = { ...base(), password: undefined, authType: 'agent' as const }
|
||||
expect(connectionReuseFingerprint(a)).not.toBe(connectionReuseFingerprint(b))
|
||||
})
|
||||
|
||||
it('differs when private key path differs', () => {
|
||||
const a = { ...base(), authType: 'key' as const, password: undefined, privateKey: '/a' }
|
||||
const b = { ...base(), authType: 'key' as const, password: undefined, privateKey: '/b' }
|
||||
expect(connectionReuseFingerprint(a)).not.toBe(connectionReuseFingerprint(b))
|
||||
})
|
||||
|
||||
it('differs when proxy differs', () => {
|
||||
const a = {
|
||||
...base(),
|
||||
proxy: { type: 'socks5' as const, host: '127.0.0.1', port: 1080 },
|
||||
}
|
||||
const b = {
|
||||
...base(),
|
||||
proxy: { type: 'http' as const, host: '127.0.0.1', port: 8080 },
|
||||
}
|
||||
expect(connectionReuseFingerprint(a)).not.toBe(connectionReuseFingerprint(b))
|
||||
})
|
||||
|
||||
it('differs when jump host differs', () => {
|
||||
const a = {
|
||||
...base(),
|
||||
jumpHost: { host: 'bastion', username: 'jump', authType: 'password' as const, password: 'x' },
|
||||
}
|
||||
const b = {
|
||||
...base(),
|
||||
jumpHost: { host: 'other', username: 'jump', authType: 'password' as const, password: 'x' },
|
||||
}
|
||||
expect(connectionReuseFingerprint(a)).not.toBe(connectionReuseFingerprint(b))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isWindowsLocalPath, joinLocalPath, parentPath, splitPathSegments } from '../../src/utils/localPath'
|
||||
import { fallbackLocalShells, localShellEnterKey } from '../../src/utils/platformShells'
|
||||
|
||||
describe('localPath', () => {
|
||||
it('detects windows paths', () => {
|
||||
expect(isWindowsLocalPath('C:\\Users\\a')).toBe(true)
|
||||
expect(isWindowsLocalPath('D:/tmp')).toBe(true)
|
||||
expect(isWindowsLocalPath('/home/a')).toBe(false)
|
||||
})
|
||||
|
||||
it('computes windows parents without collapsing to /', () => {
|
||||
expect(parentPath('C:\\Users\\logan\\docs', true)).toBe('C:\\Users\\logan')
|
||||
expect(parentPath('C:\\Users', true)).toBe('C:\\')
|
||||
expect(parentPath('C:\\', true)).toBe('C:\\')
|
||||
})
|
||||
|
||||
it('splits windows breadcrumbs with drive root', () => {
|
||||
const segments = splitPathSegments('C:\\Users\\logan', true)
|
||||
expect(segments.root).toBe('C:\\')
|
||||
expect(segments.parts).toEqual(['Users', 'logan'])
|
||||
expect(segments.join(['Users'])).toBe('C:\\Users')
|
||||
})
|
||||
|
||||
it('keeps posix remote parents', () => {
|
||||
expect(parentPath('/var/log', false)).toBe('/var')
|
||||
expect(parentPath('/', false)).toBe('/')
|
||||
})
|
||||
|
||||
it('joins local paths with platform separators', () => {
|
||||
expect(joinLocalPath('C:\\Users', 'a.txt')).toBe('C:\\Users\\a.txt')
|
||||
expect(joinLocalPath('/home/a', 'b.txt')).toBe('/home/a/b.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('platformShells', () => {
|
||||
it('returns windows shells for windows platform', () => {
|
||||
const shells = fallbackLocalShells('windows')
|
||||
expect(shells.some((shell) => shell.kind === 'cmd')).toBe(true)
|
||||
expect(shells.some((shell) => shell.kind === 'pwsh')).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults linux to bash', () => {
|
||||
const shells = fallbackLocalShells('linux')
|
||||
expect(shells[0]?.kind).toBe('bash')
|
||||
expect(shells[0]?.isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('uses CRLF enter for windows shells', () => {
|
||||
expect(localShellEnterKey('cmd')).toBe('\r\n')
|
||||
expect(localShellEnterKey('pwsh')).toBe('\r\n')
|
||||
expect(localShellEnterKey('bash')).toBe('\n')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user