diff --git a/license-server/.env.example b/license-server/.env.example new file mode 100644 index 0000000..08ae07d --- /dev/null +++ b/license-server/.env.example @@ -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 diff --git a/license-server/.gitignore b/license-server/.gitignore new file mode 100644 index 0000000..c44e23f --- /dev/null +++ b/license-server/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.env.local +data/ +*.log diff --git a/license-server/README.md b/license-server/README.md new file mode 100644 index 0000000..a1f4801 --- /dev/null +++ b/license-server/README.md @@ -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 **轮换** diff --git a/license-server/package-lock.json b/license-server/package-lock.json new file mode 100644 index 0000000..fc9caff --- /dev/null +++ b/license-server/package-lock.json @@ -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" + } + } + } +} diff --git a/license-server/package.json b/license-server/package.json new file mode 100644 index 0000000..fac4641 --- /dev/null +++ b/license-server/package.json @@ -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" + } +} diff --git a/license-server/scripts/bootstrap-products.mjs b/license-server/scripts/bootstrap-products.mjs new file mode 100644 index 0000000..d1985a1 --- /dev/null +++ b/license-server/scripts/bootstrap-products.mjs @@ -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) +}) diff --git a/license-server/scripts/issue-dev-license.mjs b/license-server/scripts/issue-dev-license.mjs new file mode 100644 index 0000000..77e5c4d --- /dev/null +++ b/license-server/scripts/issue-dev-license.mjs @@ -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)) diff --git a/license-server/src/server.mjs b/license-server/src/server.mjs new file mode 100644 index 0000000..9110f2d --- /dev/null +++ b/license-server/src/server.mjs @@ -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)}`) +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7021242..af4ab7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7c9ec54..f649589 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index bd655b4..9c31e6a 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -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) diff --git a/src-tauri/src/commands/license.rs b/src-tauri/src/commands/license.rs new file mode 100644 index 0000000..35d3f18 --- /dev/null +++ b/src-tauri/src/commands/license.rs @@ -0,0 +1,23 @@ +use tauri::AppHandle; + +use crate::services::license::{self, LicenseStatus}; + +#[tauri::command] +pub fn license_get(app: AppHandle) -> Result { + license::read_status(&app) +} + +#[tauri::command] +pub fn license_activate(app: AppHandle, token: String) -> Result { + license::activate(&app, &token) +} + +#[tauri::command] +pub fn license_clear(app: AppHandle) -> Result { + license::clear(&app) +} + +#[tauri::command] +pub fn license_device_id(app: AppHandle) -> Result { + license::get_or_create_device_id(&app) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a9c7c76..3ddcb0c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e65079c..b750bd0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::(); 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 diff --git a/src-tauri/src/services/credential_store.rs b/src-tauri/src/services/credential_store.rs index a720686..c099736 100644 --- a/src-tauri/src/services/credential_store.rs +++ b/src-tauri/src/services/credential_store.rs @@ -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 { + 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(()) } diff --git a/src-tauri/src/services/license.rs b/src-tauri/src/services/license.rs new file mode 100644 index 0000000..288a71d --- /dev/null +++ b/src-tauri/src/services/license.rs @@ -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; + +/// 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, + #[serde(default)] + pub features: Vec, +} + +#[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, + pub features: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +fn hmac_secret() -> String { + std::env::var("OPSX_LICENSE_HMAC").unwrap_or_else(|_| DEFAULT_LICENSE_HMAC.to_string()) +} + +fn data_dir(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|error| format!("无法确定应用数据目录: {error}")) +} + +fn device_id_path(app: &AppHandle) -> Result { + Ok(data_dir(app)?.join("device.id")) +} + +fn license_path(app: &AppHandle) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/src-tauri/src/services/local_terminal.rs b/src-tauri/src/services/local_terminal.rs index 3e41c8f..6ba1562 100644 --- a/src-tauri/src/services/local_terminal.rs +++ b/src-tauri/src/services/local_terminal.rs @@ -69,14 +69,20 @@ impl LocalTerminalService { options: LocalShellOptions, preferred_id: Option, ) -> Result { - 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); - command.env("TERM", "xterm-256color"); - command.env("COLORTERM", "truecolor"); + #[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 Option { + 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) -> Option { + paths + .into_iter() + .find(|path| !path.is_empty() && Path::new(path).is_file()) +} + +#[cfg(target_os = "windows")] +fn lookup_on_path(binary: &str) -> Option { + 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; + } + env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()) } - #[cfg(not(target_os = "windows"))] + #[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 { #[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 { "/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()); + } + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 489a9e8..df7becb 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/ssh/path.rs b/src-tauri/src/services/ssh/path.rs index df9971d..fa0d206 100644 --- a/src-tauri/src/services/ssh/path.rs +++ b/src-tauri/src/services/ssh/path.rs @@ -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); + } + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 825322d..9a03eaa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" ] } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..613819a --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "OpsXShell", + "decorations": true, + "titleBarStyle": "Visible", + "hiddenTitle": false + } + ] + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..3cf4adb --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -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 + } + } + ] + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..613819a --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "OpsXShell", + "decorations": true, + "titleBarStyle": "Visible", + "hiddenTitle": false + } + ] + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index da5afb2..a4d0bce 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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 & { 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 ( setIsLauncherOpen(false)} onNewSession={openNewSession} onQuickConnect={openQuickConnect} - onSerialConnect={() => setIsSerialConnectOpen(true)} + onSerialConnect={() => { + if (!requirePro('serial')) return + setIsSerialConnectOpen(true) + }} /> @@ -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() { /> + + + + { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index cec2587..4721e53 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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 (
-
+
OpsXShell
-
+
) } diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index 252322c..ac58208 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -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: , action: () => { if (activeTabId && tabs.length > 1) { - closeTab(activeTabId) + void disposeAndCloseTab(activeTabId) } }, }, @@ -146,15 +147,28 @@ export function CommandPalette({ label: '关闭所有标签', description: '关闭所有标签页并回到欢迎页', icon: , - action: () => useTabStore.getState().closeAllTabs(), + action: () => { + void disposeAndCloseAllTabs() + }, }, { id: 'cmd:new-portforward', type: 'command', label: '新建端口转发', - description: '打开端口转发管理标签', + description: '打开端口转发管理标签(Pro)', icon: , - 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: , + 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(() => { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 77ee38f..6b73334 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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>(new Set()) - const [batchCommandSessions, setBatchCommandSessions] = useState([]) - 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(null) const sidebarRef = useRef(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" > - 批量命令 + 批量执行 · Pro + + diff --git a/src/components/layout/tabbar/TabItem.tsx b/src/components/layout/tabbar/TabItem.tsx index fa56510..bcdccd9 100644 --- a/src/components/layout/tabbar/TabItem.tsx +++ b/src/components/layout/tabbar/TabItem.tsx @@ -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 case 'serial': return + case 'upgrade': + return + case 'batch': + return default: return null } diff --git a/src/components/pro/UpgradeView.tsx b/src/components/pro/UpgradeView.tsx new file mode 100644 index 0000000..8d7966b --- /dev/null +++ b/src/components/pro/UpgradeView.tsx @@ -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(null) + const [licenseInput, setLicenseInput] = useState('') + const [activateError, setActivateError] = useState(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 ( +
+
+
+
+ +
+
+

+ OpsXShell Pro · 解锁专业运维能力 +

+

+ {comingSoon && highlight + ? `${highlight} 即将推出 · 升级 Pro 可优先体验` + : highlight + ? `正在解锁:${highlight}` + : '转发、代理、串口、AI、批量与广播'} +

+ {plan === 'pro' && ( +

+ 已激活 Pro + {expiresAt + ? ` · 有效期至 ${new Date(expiresAt * 1000).toLocaleDateString()}` + : ' · 终身授权'} +

+ )} +
+
+ +
+
+
Free
+
    + {FREE_ITEMS.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+
Pro
+
    + {PRO_ITEMS.map((item) => ( +
  • + {item.includes('即将推出') ? ( + + ) : ( + + )} + {item} +
  • + ))} +
+
+
+ + {plan !== 'pro' && ( + <> +

+ 一次性费用 · 本机终身授权 · 无需订阅 +

+ + + {checkoutError && ( +

{checkoutError}

+ )} + + + + {showActivate && ( +
+ +