commit eafa1ffd573e05bf7e7f34f3e1c116dbd51f1f79 Author: laowang Date: Fri Mar 27 09:00:57 2026 +0800 PushGo Auto Commit 2026-03-27T01:00:57.339Z diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cf4f4c --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# PushGo MVP + +小白一键把本地项目推送到 GitHub / Gitee / Gitea 的桌面应用(Electron + React + TypeScript + Vite)。 + +## 功能覆盖 + +- 启动自动检测 Git 安装状态与授权组件可用性 +- 平台选择(GitHub / Gitee / Gitea) +- 项目文件夹仅支持“点击浏览”选择(已移除拖拽入口,提升跨平台稳定性) +- 仓库 URL 格式与平台匹配校验 +- 系统浏览器授权(不使用内嵌网页) +- Gitea 在自建场景下优先按仓库 URL 域名发起登录/授权 +- 自动执行 `git init` / `add -A` / `commit` / `remote` / `push` +- 推送默认 `main`,必要时自动回退 `master` +- 成功页支持复制仓库链接和浏览器打开 +- 不保存历史记录(路径、URL、操作记录不落盘) +- 登录 token 仅保留在进程内存(关闭应用即失效) + +## 技术栈 + +- Electron +- React + React Router +- TypeScript +- Vite +- Node `child_process` 调用本机 `git` + +## 目录结构 + +```txt +src/ + main/ # Electron 主进程、服务层、IPC + preload/ # 白名单桥接 API + renderer/ # React UI 与流程页面 + shared/ # 前后端共享类型与通道常量 +docs/ + ai-progress.md + ai-handoff.md + git-identity-setup.md +``` + +Git 身份设置教程:`docs/git-identity-setup.md` + +## 本地运行 + +### 1) 安装依赖 + +```bash +npm install +``` + +### 2) 开发启动 + +```bash +npm run dev +``` + +### 3) 构建 + +```bash +npm run build +``` + +### 4) 打包 + +```bash +npm run package +``` + +## OAuth 配置(可选增强) + +当前支持两种模式: + +- 默认模式(未配置 OAuth env):推送时走系统 git 凭据授权链路(如 Git Credential Manager)。首次通常会出现登录/授权窗口,后续会复用授权;若在平台端撤销授权会再次触发。 +- 增强模式(配置 OAuth env):走完整 OAuth 授权码流程并换取 token(仅内存保存,会话结束清空)。 + +说明: + +- Gitea 域名优先级:`repoUrl > PUSHGO_GITEA_BASE_URL > https://gitea.com`。 +- 若目标系统缺失 git 凭据助手,默认模式可能无法完成授权。 + +如需启用增强模式,请配置: + +```bash +# GitHub OAuth App +export PUSHGO_GITHUB_CLIENT_ID=your_client_id +export PUSHGO_GITHUB_CLIENT_SECRET=your_client_secret + +# Gitee OAuth App +export PUSHGO_GITEE_CLIENT_ID=your_client_id +export PUSHGO_GITEE_CLIENT_SECRET=your_client_secret + +# Gitea OAuth App(可用自建域名) +export PUSHGO_GITEA_BASE_URL=https://gitea.com +export PUSHGO_GITEA_CLIENT_ID=your_client_id +export PUSHGO_GITEA_CLIENT_SECRET=your_client_secret +# 可选,默认 write:repository +export PUSHGO_GITEA_SCOPE=write:repository +``` + +授权回调采用本地临时端口(`http://127.0.0.1:{port}/oauth/callback`),请在 OAuth 应用配置中允许本地回调。 + +## IPC 通道 + +- `app:check-git` +- `auth:login` +- `auth:logout` +- `auth:status` +- `repo:validate-url` +- `git:select-folder` +- `push:run` +- `app:open-external` +- 事件:`push:progress` + +## 错误提示策略 + +主流程使用统一中文错误对象: + +```ts +{ code, message, actionText?, actionType? } +``` + +覆盖文档要求的核心错误码,包括: + +- Git 未安装 +- URL 格式错误 / 平台不匹配 +- 授权取消 / 授权失败 +- 仓库初始化失败 +- 无变更提交 +- 远端设置失败 +- 推送鉴权失败 / 推送被拒绝 +- 网络超时 +- 未知错误 + +## 隐私与会话策略 + +- 不写入 localStorage 的历史数据 +- 不落盘保存项目路径/仓库 URL 历史 +- token 仅存内存 Map +- BrowserWindow 使用临时 session 分区(关闭应用即清空) + +## 已知限制 + +- 默认模式依赖系统 git 凭据能力,若环境未安装/未启用可能无法完成首授。 +- 若远端启用更严格策略(受保护分支、组织 SSO、强制签名),推送可能被拒绝。 +- 当前为 MVP:不包含冲突可视化、分支管理、PR 流程等扩展能力。 + +## 故障排查(授权弹窗) + +- 若你看到的是“终端要求用户名/密码”而不是浏览器授权弹窗,通常不是 `npm run dev` 本身的问题,而是当前 `git` 没有启用 `credential.helper`。 +- 启动页会自动检测并尽量自动配置弹窗型凭据助手(GCM);若仍未就绪会在页面顶部给出明确提示和跳转入口。 +- 可用以下命令检查: + +```bash +git config --get-all credential.helper +``` + +- 若为空,请安装并启用 Git Credential Manager(或在当前系统配置有效的 git 凭据助手)后重试。 +- PushGo 在推送时会关闭终端口令提示并强制 GCM 交互模式;打包后无终端窗口时也应走浏览器/系统授权弹窗。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..69dfeec --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + PushGo + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..667176e --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "pushgo-v1", + "version": "0.1.0", + "private": true, + "description": "PushGo desktop MVP: 小白一键推送项目到 GitHub/Gitee", + "main": "dist-electron/main/main.js", + "scripts": { + "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:main\" \"npm:dev:electron\"", + "dev:renderer": "vite", + "dev:main": "tsc -p tsconfig.main.json --watch", + "dev:electron": "wait-on tcp:5173 file:dist-electron/main/main.js && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .", + "build": "npm run build:renderer && npm run build:main", + "build:renderer": "vite build", + "build:main": "tsc -p tsconfig.main.json", + "preview": "vite preview", + "package": "npm run build && electron-builder" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.0", + "cross-env": "^7.0.3", + "electron": "^36.0.0", + "electron-builder": "^25.1.8", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "wait-on": "^8.0.1" + }, + "build": { + "appId": "com.pushgo.app", + "productName": "PushGo", + "files": [ + "dist/**", + "dist-electron/**", + "package.json" + ], + "directories": { + "buildResources": "build" + }, + "mac": { + "target": "dmg" + }, + "win": { + "target": "nsis" + }, + "linux": { + "target": "AppImage" + } + } +} diff --git a/src/main/errors.ts b/src/main/errors.ts new file mode 100644 index 0000000..d4ca0ab --- /dev/null +++ b/src/main/errors.ts @@ -0,0 +1,167 @@ +import { AppErrorPayload, ErrorCode } from "../shared/contracts"; + +type ErrorDefinition = { + message: string; + actionText?: string; + actionType?: string; +}; + +const ERROR_MAP: Record = { + APP_GIT_NOT_FOUND: { + message: "未检测到 Git,请先安装后再继续", + actionText: "去安装 Git", + actionType: "open_git_download" + }, + APP_GIT_HELPER_MISSING: { + message: "已安装 Git,但未检测到可用凭据助手,首次授权窗口可能无法弹出", + actionText: "安装凭据助手", + actionType: "open_gcm_download" + }, + APP_GIT_HELPER_UNSUPPORTED: { + message: "当前 Git 凭据助手不支持授权弹窗,请切换到 Git Credential Manager", + actionText: "查看配置说明", + actionType: "open_gcm_download" + }, + APP_FOLDER_INVALID: { + message: "请选择有效的项目文件夹" + }, + URL_INVALID_FORMAT: { + message: "仓库地址格式不正确,请检查后重试" + }, + URL_PLATFORM_MISMATCH: { + message: "仓库地址与当前平台不一致,是否切换平台?" + }, + AUTH_CANCELLED: { + message: "你已取消登录授权,可重新发起" + }, + AUTH_FAILED: { + message: "登录失败,请稍后重试" + }, + GIT_INIT_FAILED: { + message: "初始化仓库失败,请检查文件夹权限" + }, + GIT_COMMIT_NO_CHANGES: { + message: "没有检测到新变更,已尝试直接推送" + }, + GIT_REMOTE_SET_FAILED: { + message: "设置远端仓库失败,请检查仓库地址" + }, + GIT_PUSH_AUTH_FAILED: { + message: "推送失败:账号无权限或授权已失效" + }, + GIT_PUSH_REJECTED: { + message: "推送被拒绝,远端可能有更新,请先同步" + }, + NETWORK_TIMEOUT: { + message: "网络超时,请检查网络后重试" + }, + UNKNOWN: { + message: "发生未知错误,请重试" + } +}; + +export class AppError extends Error { + readonly payload: AppErrorPayload; + + constructor(code: ErrorCode, overrides?: Partial) { + const base = ERROR_MAP[code]; + super(overrides?.message ?? base.message); + this.name = "AppError"; + this.payload = { + code, + message: overrides?.message ?? base.message, + actionText: overrides?.actionText ?? base.actionText, + actionType: overrides?.actionType ?? base.actionType + }; + } +} + +export function createAppError(code: ErrorCode, overrides?: Partial): AppError { + return new AppError(code, overrides); +} + +export function toAppErrorPayload(error: unknown, fallbackCode: ErrorCode = "UNKNOWN"): AppErrorPayload { + if (error instanceof AppError) { + return error.payload; + } + + if (isPayload(error)) { + return error; + } + + const base = ERROR_MAP[fallbackCode]; + return { + code: fallbackCode, + message: base.message, + actionText: base.actionText, + actionType: base.actionType + }; +} + +function isPayload(error: unknown): error is AppErrorPayload { + return ( + typeof error === "object" && + error !== null && + "code" in error && + "message" in error && + typeof (error as { code: unknown }).code === "string" && + typeof (error as { message: unknown }).message === "string" + ); +} + +export function isGitNotFound(output: string): boolean { + const content = output.toLowerCase(); + return content.includes("command not found") || content.includes("is not recognized") || content.includes("enoent"); +} + +export function inferGitPushError(stderr: string): AppError { + const message = stderr.toLowerCase(); + + if (message.includes("no credential store has been selected") || message.includes("credential.credentialstore")) { + return createAppError("GIT_PUSH_AUTH_FAILED", { + message: "Git Credential Manager 未配置凭据存储。请先配置 credential.credentialStore(如 cache 或 secretservice)后重试。" + }); + } + + if (message.includes("can not use the 'cache' credential store on windows")) { + return createAppError("GIT_PUSH_AUTH_FAILED", { + message: "Windows 不支持 cache 凭据存储。请改用 wincredman 后重试。" + }); + } + + if (message.includes("credential-manager") && message.includes("is not a git command")) { + return createAppError("GIT_PUSH_AUTH_FAILED", { + message: "未找到 Git Credential Manager 可执行程序,请安装并启用后重试。" + }); + } + + if ( + message.includes("user interaction is not allowed") || + message.includes("cannot prompt because user interactivity has been disabled") || + message.includes("unable to open browser") || + message.includes("failed to launch browser") + ) { + return createAppError("GIT_PUSH_AUTH_FAILED", { + message: "无法拉起授权窗口。请检查系统默认浏览器与图形会话后重试。" + }); + } + + if ( + message.includes("authentication failed") || + message.includes("could not read username") || + message.includes("403") || + message.includes("access denied") + ) { + return createAppError("GIT_PUSH_AUTH_FAILED"); + } + + if (message.includes("failed to push some refs") || message.includes("non-fast-forward") || message.includes("rejected")) { + return createAppError("GIT_PUSH_REJECTED"); + } + + if (message.includes("timed out") || message.includes("timeout")) { + return createAppError("NETWORK_TIMEOUT"); + } + + return createAppError("UNKNOWN"); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..3b6313b --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,131 @@ +import { dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron"; +import { IPC_CHANNELS, PUSH_PROGRESS_CHANNEL } from "../shared/channels"; +import { AppErrorPayload, IpcResult, Platform, PushRunParams } from "../shared/contracts"; +import { createAppError, toAppErrorPayload } from "./errors"; +import { SessionAuthService } from "./services/auth-service"; +import { CliGitService } from "./services/git-service"; +import { DefaultPushOrchestrator } from "./services/push-orchestrator"; +import { DefaultRepoService } from "./services/repo-service"; + +type Services = { + gitService: CliGitService; + authService: SessionAuthService; + repoService: DefaultRepoService; + pushOrchestrator: DefaultPushOrchestrator; +}; + +export function registerIpcHandlers(services: Services): void { + ipcMain.handle(IPC_CHANNELS.CHECK_GIT, async () => { + return wrap(async () => { + const installed = await services.gitService.checkInstalled(); + if (!installed) { + const notFound = createAppError("APP_GIT_NOT_FOUND"); + return { + installed: false, + warning: notFound.payload + }; + } + + const helperWarning = await services.gitService.checkCredentialHelperReadiness(); + + return { + installed: true, + warning: helperWarning ?? undefined + }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.SELECT_FOLDER, async () => { + return wrap(async () => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"] + }); + + return { + path: result.canceled ? null : result.filePaths[0] ?? null + }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.VALIDATE_URL, async (_event, payload: { url: string; platform: Platform }) => { + return wrap(async () => { + const validation = services.repoService.validateRepoUrl(payload.url, payload.platform); + const detectedPlatform = services.repoService.detectPlatformFromUrl(payload.url); + + return { + ...validation, + detectedPlatform + }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.AUTH_STATUS, async (_event, payload: { platform: Platform; repoUrl?: string }) => { + return wrap(async () => { + const loggedIn = await services.authService.isLoggedIn(payload.platform, { repoUrl: payload.repoUrl }); + return { loggedIn }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.AUTH_LOGIN, async (_event, payload: { platform: Platform; repoUrl?: string }) => { + return wrap(async () => { + await services.authService.login(payload.platform, { repoUrl: payload.repoUrl }); + return { loggedIn: true }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (_event, payload: { platform: Platform; repoUrl?: string }) => { + return wrap(async () => { + await services.authService.logout(payload.platform, { repoUrl: payload.repoUrl }); + return { loggedIn: false }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.PUSH_RUN, async (event: IpcMainInvokeEvent, payload: PushRunParams) => { + return wrap(async () => { + validatePushParams(payload); + + await services.pushOrchestrator.runWithProgress(payload, (progress) => { + event.sender.send(PUSH_PROGRESS_CHANNEL, progress); + }); + + return { + meta: services.pushOrchestrator.consumeLastMeta() + }; + }); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event, payload: { url: string }) => { + return wrap(async () => { + if (!payload?.url) { + throw createAppError("UNKNOWN"); + } + + await shell.openExternal(payload.url); + return { opened: true }; + }); + }); +} + +function validatePushParams(payload: PushRunParams): void { + if (!payload.projectPath) { + throw createAppError("APP_FOLDER_INVALID"); + } + + if (!payload.repoUrl) { + throw createAppError("URL_INVALID_FORMAT"); + } +} + +async function wrap(run: () => Promise): Promise> { + try { + const data = await run(); + return { ok: true, data }; + } catch (error) { + console.error("[PushGo][IPC] handler error:", error); + const normalized: AppErrorPayload = toAppErrorPayload(error); + return { + ok: false, + error: normalized + }; + } +} diff --git a/src/main/main.ts b/src/main/main.ts new file mode 100644 index 0000000..4aa107b --- /dev/null +++ b/src/main/main.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import { app, BrowserWindow } from "electron"; +import { registerIpcHandlers } from "./ipc"; +import { SessionAuthService } from "./services/auth-service"; +import { CliGitService } from "./services/git-service"; +import { DefaultPushOrchestrator } from "./services/push-orchestrator"; +import { DefaultRepoService } from "./services/repo-service"; + +let mainWindow: BrowserWindow | null = null; +let ipcRegistered = false; + +function createMainWindow(): void { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 980, + minHeight: 680, + show: false, + backgroundColor: "#081321", + title: "PushGo", + webPreferences: { + preload: path.join(__dirname, "../preload/preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + partition: "temp:pushgo-session" + } + }); + + void loadRenderer(mainWindow); + + mainWindow.once("ready-to-show", () => { + mainWindow?.show(); + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +async function loadRenderer(target: BrowserWindow): Promise { + const devServerUrl = process.env.VITE_DEV_SERVER_URL; + + target.webContents.on("did-fail-load", async (_event, errorCode, errorDescription, validatedUrl) => { + const html = ` + + +

PushGo 页面加载失败

+

错误码: ${errorCode}

+

原因: ${errorDescription}

+

地址: ${validatedUrl}

+ + + `; + await target.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + }); + + if (!app.isPackaged && devServerUrl) { + await target.loadURL(devServerUrl); + return; + } + + const rendererEntry = path.join(app.getAppPath(), "dist", "index.html"); + await target.loadFile(rendererEntry); +} + +function registerIpcIfNeeded(): void { + if (ipcRegistered) { + return; + } + + const gitService = new CliGitService(); + const authService = new SessionAuthService(); + const repoService = new DefaultRepoService(); + const pushOrchestrator = new DefaultPushOrchestrator(gitService, authService); + + registerIpcHandlers({ + gitService, + authService, + repoService, + pushOrchestrator + }); + + ipcRegistered = true; +} + +app.whenReady().then(() => { + registerIpcIfNeeded(); + createMainWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); diff --git a/src/main/services/auth-service.ts b/src/main/services/auth-service.ts new file mode 100644 index 0000000..ebaf831 --- /dev/null +++ b/src/main/services/auth-service.ts @@ -0,0 +1,332 @@ +import http from "node:http"; +import { randomBytes } from "node:crypto"; +import { AddressInfo } from "node:net"; +import { shell } from "electron"; +import { AuthService, Platform } from "../../shared/contracts"; +import { createAppError } from "../errors"; + +const CALLBACK_PATH = "/oauth/callback"; +const AUTH_TIMEOUT_MS = 120_000; +const TOKEN_TIMEOUT_MS = 20_000; +const DEFAULT_GITEA_BASE_URL = "https://gitea.com"; +const DEFAULT_GITEA_SCOPE = "write:repository"; + +type OAuthConfig = { + authorizeUrl: string; + tokenUrl: string; + scope: string; + clientIdEnv: string; + clientSecretEnv: string; +}; + +type AuthOptions = { + repoUrl?: string; +}; + +const OAUTH_CONFIG_BASE: Record, OAuthConfig> = { + github: { + authorizeUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + scope: "repo", + clientIdEnv: "PUSHGO_GITHUB_CLIENT_ID", + clientSecretEnv: "PUSHGO_GITHUB_CLIENT_SECRET" + }, + gitee: { + authorizeUrl: "https://gitee.com/oauth/authorize", + tokenUrl: "https://gitee.com/oauth/token", + scope: "projects", + clientIdEnv: "PUSHGO_GITEE_CLIENT_ID", + clientSecretEnv: "PUSHGO_GITEE_CLIENT_SECRET" + } +}; + +type AuthCodeResult = { + code: string; + redirectUri: string; +}; + +export class SessionAuthService implements AuthService { + private readonly tokenStore = new Map(); + private readonly browserLoginStore = new Set(); + + async isLoggedIn(platform: Platform, options?: AuthOptions): Promise { + const sessionKey = this.resolveSessionKey(platform, options?.repoUrl); + return this.tokenStore.has(sessionKey) || this.browserLoginStore.has(sessionKey); + } + + async login(platform: Platform, options?: AuthOptions): Promise { + const sessionKey = this.resolveSessionKey(platform, options?.repoUrl); + + if (!this.hasOAuthCredentials(platform)) { + // 默认模式:由 git push 触发系统凭据授权链路(如 GCM)。 + this.browserLoginStore.add(sessionKey); + return; + } + + const config = this.resolveConfig(platform, options?.repoUrl); + const state = randomBytes(16).toString("hex"); + + const { code, redirectUri } = await this.requestAuthorizationCode(platform, config, state); + const token = await this.exchangeToken(config, code, redirectUri); + + if (!token) { + throw createAppError("AUTH_FAILED"); + } + + this.tokenStore.set(sessionKey, token); + this.browserLoginStore.delete(sessionKey); + } + + async logout(platform: Platform, options?: AuthOptions): Promise { + const sessionKey = this.resolveSessionKey(platform, options?.repoUrl); + this.tokenStore.delete(sessionKey); + this.browserLoginStore.delete(sessionKey); + } + + getToken(platform: Platform, options?: AuthOptions): string | null { + const sessionKey = this.resolveSessionKey(platform, options?.repoUrl); + return this.tokenStore.get(sessionKey) ?? null; + } + + private resolveSessionKey(platform: Platform, repoUrl?: string): string { + if (platform !== "gitea") { + return platform; + } + + const origin = this.resolveGiteaBaseUrl(repoUrl).toLowerCase(); + return `gitea:${origin}`; + } + + private hasOAuthCredentials(platform: Platform): boolean { + const base = this.getOAuthConfig(platform); + const clientId = process.env[base.clientIdEnv]?.trim(); + const clientSecret = process.env[base.clientSecretEnv]?.trim(); + return Boolean(clientId && clientSecret); + } + + private resolveConfig(platform: Platform, repoUrl?: string): OAuthConfig & { clientId: string; clientSecret: string } { + const base = this.getOAuthConfig(platform, repoUrl); + const clientId = process.env[base.clientIdEnv]?.trim(); + const clientSecret = process.env[base.clientSecretEnv]?.trim(); + + if (!clientId || !clientSecret) { + throw createAppError("AUTH_FAILED"); + } + + return { + ...base, + clientId, + clientSecret + }; + } + + private getOAuthConfig(platform: Platform, repoUrl?: string): OAuthConfig { + if (platform !== "gitea") { + return OAUTH_CONFIG_BASE[platform]; + } + + const giteaBaseUrl = this.resolveGiteaBaseUrl(repoUrl); + return { + authorizeUrl: `${giteaBaseUrl}/login/oauth/authorize`, + tokenUrl: `${giteaBaseUrl}/login/oauth/access_token`, + scope: (process.env.PUSHGO_GITEA_SCOPE ?? DEFAULT_GITEA_SCOPE).trim(), + clientIdEnv: "PUSHGO_GITEA_CLIENT_ID", + clientSecretEnv: "PUSHGO_GITEA_CLIENT_SECRET" + }; + } + + private resolveGiteaBaseUrl(repoUrl?: string): string { + const repoOrigin = this.resolveOriginFromRepoUrl(repoUrl); + if (repoOrigin) { + return repoOrigin; + } + + const raw = (process.env.PUSHGO_GITEA_BASE_URL ?? DEFAULT_GITEA_BASE_URL).trim(); + const withProtocol = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`; + + try { + const parsed = new URL(withProtocol); + return parsed.origin; + } catch { + return DEFAULT_GITEA_BASE_URL; + } + } + + private resolveOriginFromRepoUrl(repoUrl?: string): string | null { + if (!repoUrl) { + return null; + } + + try { + const parsed = new URL(repoUrl.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + + return parsed.origin; + } catch { + return null; + } + } + + private async requestAuthorizationCode( + platform: Platform, + config: OAuthConfig & { clientId: string; clientSecret: string }, + state: string + ): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(); + let settled = false; + + const cleanup = () => { + if (server.listening) { + server.close(); + } + }; + + const settle = (fn: () => void) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + cleanup(); + fn(); + }; + + const timeout = setTimeout(() => { + settle(() => reject(createAppError("AUTH_CANCELLED"))); + }, AUTH_TIMEOUT_MS); + + server.on("request", (req, res) => { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + + if (requestUrl.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not Found"); + return; + } + + const code = requestUrl.searchParams.get("code"); + const returnedState = requestUrl.searchParams.get("state"); + const oauthError = requestUrl.searchParams.get("error"); + + if (oauthError === "access_denied") { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end("

已取消授权

你可以返回 PushGo 重新发起登录。

"); + settle(() => reject(createAppError("AUTH_CANCELLED"))); + return; + } + + if (!code || returnedState !== state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end("

授权失败

请返回 PushGo 重新尝试。

"); + settle(() => reject(createAppError("AUTH_FAILED"))); + return; + } + + const address = server.address() as AddressInfo; + const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end("

授权成功

请返回 PushGo 继续推送。

"); + + settle(() => resolve({ code, redirectUri })); + }); + + server.on("error", () => { + settle(() => reject(createAppError("AUTH_FAILED"))); + }); + + server.listen(0, "127.0.0.1", async () => { + try { + const address = server.address() as AddressInfo; + const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; + const authUrl = this.buildAuthorizationUrl(platform, config, state, redirectUri); + await shell.openExternal(authUrl); + } catch { + settle(() => reject(createAppError("AUTH_FAILED"))); + } + }); + }); + } + + private buildAuthorizationUrl( + platform: Platform, + config: OAuthConfig & { clientId: string; clientSecret: string }, + state: string, + redirectUri: string + ): string { + const url = new URL(config.authorizeUrl); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", state); + + if (config.scope) { + url.searchParams.set("scope", config.scope); + } + + if (platform === "gitee") { + url.searchParams.set("force_verify", "true"); + } + + return url.toString(); + } + + private async exchangeToken( + config: OAuthConfig & { clientId: string; clientSecret: string }, + code: string, + redirectUri: string + ): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS); + + try { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json" + }, + body, + signal: controller.signal + }); + + const data = (await response.json()) as { + access_token?: string; + error?: string; + }; + + if (data.error === "access_denied") { + throw createAppError("AUTH_CANCELLED"); + } + + if (!response.ok || !data.access_token) { + throw createAppError("AUTH_FAILED"); + } + + return data.access_token; + } catch (error) { + if ((error as { name?: string }).name === "AbortError") { + throw createAppError("NETWORK_TIMEOUT"); + } + + if (error instanceof Error && error.name === "AppError") { + throw error; + } + + throw createAppError("AUTH_FAILED"); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/main/services/git-service.ts b/src/main/services/git-service.ts new file mode 100644 index 0000000..e0a796e --- /dev/null +++ b/src/main/services/git-service.ts @@ -0,0 +1,611 @@ +import { Buffer } from "node:buffer"; +import { stat } from "node:fs/promises"; +import { AppErrorPayload, GitService, Platform, PushRunMeta } from "../../shared/contracts"; +import { createAppError, inferGitPushError, isGitNotFound } from "../errors"; +import { CommandExecutionError, runCommand } from "../utils/run-command"; + +type AuthContext = { + platform: Platform; + token: string | null; +}; + +export class CliGitService implements GitService { + private authContext: AuthContext | null = null; + private pushMeta: PushRunMeta = { + commitStatus: "committed", + fallbackToMaster: false + }; + + async checkInstalled(): Promise { + try { + await runCommand("git", ["--version"], { timeoutMs: 10_000 }); + return true; + } catch (error) { + if (error instanceof CommandExecutionError) { + const output = `${error.stdout}\n${error.stderr}\n${error.message}`; + if (isGitNotFound(output)) { + return false; + } + } + + return false; + } + } + + async checkCredentialHelperReadiness(): Promise { + const helpers = await this.readCredentialHelpers(); + if (this.hasPopupCapableHelper(helpers)) { + return null; + } + + const autoHelper = await this.detectAutoCredentialHelper(); + if (autoHelper) { + const configured = await this.trySetGlobalCredentialHelper(autoHelper); + if (configured) { + return null; + } + + return createAppError("APP_GIT_HELPER_MISSING", { + message: "检测到凭据助手组件,但自动配置失败。请手动启用 Git Credential Manager 后重试。" + }).payload; + } + + if (helpers.length > 0) { + return createAppError("APP_GIT_HELPER_UNSUPPORTED", { + message: `当前凭据助手不支持授权弹窗(${helpers.join(", ")})。请切换到 Git Credential Manager。` + }).payload; + } + + return createAppError("APP_GIT_HELPER_MISSING").payload; + } + + async isRepo(projectPath: string): Promise { + await this.ensureProjectDir(projectPath); + + try { + const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: projectPath, + timeoutMs: 15_000 + }); + return result.stdout.trim() === "true"; + } catch { + return false; + } + } + + async initRepo(projectPath: string): Promise { + await this.ensureProjectDir(projectPath); + + try { + await runCommand("git", ["init"], { cwd: projectPath, timeoutMs: 15_000 }); + } catch { + throw createAppError("GIT_INIT_FAILED"); + } + } + + async ensureUserConfig(projectPath: string): Promise { + await this.ensureProjectDir(projectPath); + + const name = await this.readGitConfig(projectPath, "user.name"); + const email = await this.readGitConfig(projectPath, "user.email"); + + if (!name) { + await runCommand("git", ["config", "user.name", "PushGo User"], { + cwd: projectPath, + timeoutMs: 10_000 + }); + } + + if (!email) { + await runCommand("git", ["config", "user.email", "pushgo@local.invalid"], { + cwd: projectPath, + timeoutMs: 10_000 + }); + } + } + + async addAll(projectPath: string): Promise { + await this.ensureProjectDir(projectPath); + await runCommand("git", ["add", "-A"], { cwd: projectPath, timeoutMs: 20_000 }); + } + + async commit(projectPath: string, message: string): Promise<"committed" | "no_changes"> { + await this.ensureProjectDir(projectPath); + + const hasChanges = await this.hasStagedChanges(projectPath); + if (!hasChanges) { + this.pushMeta.commitStatus = "no_changes"; + return "no_changes"; + } + + try { + await runCommand("git", ["commit", "-m", message], { cwd: projectPath, timeoutMs: 20_000 }); + this.pushMeta.commitStatus = "committed"; + return "committed"; + } catch (error) { + if (error instanceof CommandExecutionError) { + const detail = `${error.stdout}\n${error.stderr}`.toLowerCase(); + if ( + detail.includes("nothing to commit") || + detail.includes("no changes added") || + detail.includes("working tree clean") + ) { + this.pushMeta.commitStatus = "no_changes"; + return "no_changes"; + } + + const compact = this.compactDetail(this.composeCommandErrorDetail(error)); + if (compact) { + throw createAppError("UNKNOWN", { + message: `提交变更失败(原始信息:${compact})` + }); + } + } + + throw createAppError("UNKNOWN"); + } + } + + async setRemote(projectPath: string, repoUrl: string): Promise { + await this.ensureProjectDir(projectPath); + + try { + const remotes = await runCommand("git", ["remote"], { cwd: projectPath, timeoutMs: 10_000 }); + const hasOrigin = remotes.stdout + .split("\n") + .map((item) => item.trim()) + .includes("origin"); + + if (hasOrigin) { + await runCommand("git", ["remote", "set-url", "origin", repoUrl], { + cwd: projectPath, + timeoutMs: 10_000 + }); + } else { + await runCommand("git", ["remote", "add", "origin", repoUrl], { + cwd: projectPath, + timeoutMs: 10_000 + }); + } + } catch { + throw createAppError("GIT_REMOTE_SET_FAILED"); + } + } + + async push(projectPath: string): Promise { + await this.ensureProjectDir(projectPath); + this.pushMeta.fallbackToMaster = false; + + const helpers = await this.ensureCredentialHelperReady(projectPath); + await this.ensureCredentialStoreReady(projectPath, helpers); + + const authArgs = this.buildAuthArgs(); + const pushEnv = this.buildPushEnv(); + + await this.primeRemoteAuth(projectPath, pushEnv); + + try { + await runCommand("git", [...authArgs, "push", "-u", "origin", "HEAD:main"], { + cwd: projectPath, + timeoutMs: 120_000, + env: pushEnv + }); + return; + } catch (error) { + if (this.shouldFallbackToMaster(error)) { + try { + await runCommand("git", [...authArgs, "push", "-u", "origin", "HEAD:master"], { + cwd: projectPath, + timeoutMs: 120_000, + env: pushEnv + }); + this.pushMeta.fallbackToMaster = true; + return; + } catch (masterError) { + throw this.mapPushError(masterError); + } + } + + throw this.mapPushError(error); + } + } + + setAuthContext(platform: Platform, token: string | null): void { + this.authContext = { platform, token }; + } + + clearAuthContext(): void { + this.authContext = null; + } + + consumePushMeta(): PushRunMeta { + const snapshot = { ...this.pushMeta }; + this.pushMeta = { + commitStatus: "committed", + fallbackToMaster: false + }; + return snapshot; + } + + private async ensureProjectDir(projectPath: string): Promise { + if (!projectPath) { + throw createAppError("APP_FOLDER_INVALID"); + } + + try { + const info = await stat(projectPath); + if (!info.isDirectory()) { + throw createAppError("APP_FOLDER_INVALID"); + } + } catch { + throw createAppError("APP_FOLDER_INVALID"); + } + } + + private async readGitConfig(projectPath: string, key: string): Promise { + try { + const result = await runCommand("git", ["config", "--get", key], { + cwd: projectPath, + timeoutMs: 10_000 + }); + const value = result.stdout.trim(); + return value || null; + } catch { + return null; + } + } + + private async hasStagedChanges(projectPath: string): Promise { + try { + await runCommand("git", ["diff", "--cached", "--quiet"], { + cwd: projectPath, + timeoutMs: 10_000 + }); + return false; + } catch (error) { + if (error instanceof CommandExecutionError && this.isExitCode(error.code, 1)) { + return true; + } + + throw createAppError("UNKNOWN", { + message: "检查变更状态失败,请重试" + }); + } + } + + private buildAuthArgs(): string[] { + if (!this.authContext?.token) { + return []; + } + + const username = this.authContext.platform === "github" ? "x-access-token" : "oauth2"; + const credential = Buffer.from(`${username}:${this.authContext.token}`).toString("base64"); + const header = `http.extraheader=AUTHORIZATION: Basic ${credential}`; + + return ["-c", header]; + } + + private buildPushEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + // 禁止回落到终端用户名/密码输入,避免出现“卡在命令行提示”的体验。 + GIT_TERMINAL_PROMPT: "0", + // GUI 进程中强制允许 GCM 交互,避免被判定为非交互模式。 + GCM_INTERACTIVE: "always" + }; + } + + private async ensureCredentialHelperReady(projectPath: string): Promise { + if (this.authContext?.token) { + return []; + } + + const helpers = await this.readCredentialHelpers(projectPath); + if (this.hasPopupCapableHelper(helpers)) { + return helpers; + } + + const autoHelper = await this.detectAutoCredentialHelper(); + if (autoHelper) { + await this.setCredentialHelper(projectPath, autoHelper); + return [autoHelper]; + } + + if (helpers.length > 0) { + throw createAppError("GIT_PUSH_AUTH_FAILED", { + message: `当前凭据助手不支持浏览器授权弹窗(${helpers.join(", ")})。请切换到 Git Credential Manager 后重试。` + }); + } + + throw createAppError("GIT_PUSH_AUTH_FAILED", { + message: "未检测到 Git 凭据助手,无法弹出授权窗口。请先安装并启用 Git Credential Manager 后重试。" + }); + } + + private async readCredentialHelpers(projectPath?: string): Promise { + try { + const result = await runCommand("git", ["config", "--get-all", "credential.helper"], { + cwd: projectPath, + timeoutMs: 10_000 + }); + + return result.stdout + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + } catch { + return []; + } + } + + private async detectAutoCredentialHelper(): Promise { + if (await this.canRunGitCredentialManager("credential-manager")) { + return "manager"; + } + + if (await this.canRunGitCredentialManager("credential-manager-core")) { + return "manager-core"; + } + + return null; + } + + private async canRunGitCredentialManager(command: "credential-manager" | "credential-manager-core"): Promise { + if (await this.canRunCommand("git", [command, "--version"])) { + return true; + } + + const helperCommand = command === "credential-manager" ? "git-credential-manager" : "git-credential-manager-core"; + for (const candidate of this.commandCandidates(helperCommand)) { + if (await this.canRunCommand(candidate, ["--version"])) { + return true; + } + } + + return false; + } + + private async canRunCommand(command: string, args: string[]): Promise { + try { + await runCommand(command, args, { + timeoutMs: 10_000 + }); + return true; + } catch { + return false; + } + } + + private commandCandidates(base: string): string[] { + if (process.platform === "win32") { + return [base, `${base}.exe`]; + } + + return [base]; + } + + private hasPopupCapableHelper(helpers: string[]): boolean { + return helpers.some((helper) => /(manager|credential-manager|oauth)/i.test(helper)); + } + + private async setCredentialHelper(projectPath: string, helper: string): Promise { + try { + await runCommand("git", ["config", "credential.helper", helper], { + cwd: projectPath, + timeoutMs: 10_000 + }); + } catch { + throw createAppError("GIT_PUSH_AUTH_FAILED", { + message: "自动配置 Git 凭据助手失败,请手动安装或启用 Git Credential Manager 后重试。" + }); + } + } + + private async ensureCredentialStoreReady(projectPath: string, helpers: string[]): Promise { + if (this.authContext?.token) { + return; + } + + if (!this.usesGcmHelper(helpers)) { + return; + } + + const envStore = process.env.GCM_CREDENTIAL_STORE?.trim(); + if (envStore && !this.isCredentialStoreSupported(envStore)) { + throw createAppError("GIT_PUSH_AUTH_FAILED", { + message: `当前环境变量 GCM_CREDENTIAL_STORE=${envStore} 与当前系统不兼容,请移除或改为 ${this.preferredCredentialStore()} 后重试。` + }); + } + + const configuredStore = await this.readConfiguredCredentialStore(projectPath); + if (configuredStore && this.isCredentialStoreSupported(configuredStore)) { + return; + } + + const configured = await this.trySetCredentialStore(projectPath, this.preferredCredentialStore()); + if (!configured) { + throw createAppError("GIT_PUSH_AUTH_FAILED", { + message: "检测到 Git Credential Manager,但凭据存储未配置且自动修复失败。请配置 credential.credentialStore 后重试。" + }); + } + } + + private usesGcmHelper(helpers: string[]): boolean { + return helpers.some((helper) => /(manager|credential-manager)/i.test(helper)); + } + + private isCredentialStoreSupported(store: string): boolean { + const normalized = store.trim().toLowerCase(); + if (!normalized) { + return false; + } + + if (process.platform === "win32" && normalized === "cache") { + return false; + } + + return true; + } + + private preferredCredentialStore(): string { + if (process.platform === "win32") { + return "wincredman"; + } + + if (process.platform === "darwin") { + return "keychain"; + } + + return "cache"; + } + + private async readConfiguredCredentialStore(projectPath: string): Promise { + const localStore = await this.readGitConfig(projectPath, "credential.credentialStore"); + if (localStore) { + return localStore; + } + + return this.readGlobalGitConfig("credential.credentialStore"); + } + + private async readGlobalGitConfig(key: string): Promise { + try { + const result = await runCommand("git", ["config", "--global", "--get", key], { + timeoutMs: 10_000 + }); + const value = result.stdout.trim(); + return value || null; + } catch { + return null; + } + } + + private async trySetCredentialStore(projectPath: string, store: string): Promise { + try { + await runCommand("git", ["config", "credential.credentialStore", store], { + cwd: projectPath, + timeoutMs: 10_000 + }); + return true; + } catch { + return false; + } + } + + private async primeRemoteAuth(projectPath: string, env: NodeJS.ProcessEnv): Promise { + if (this.authContext?.token) { + return; + } + + try { + // 在正式 push 前预热一次远端鉴权,尽量提前触发凭据助手弹窗。 + await runCommand("git", ["ls-remote", "--heads", "origin"], { + cwd: projectPath, + timeoutMs: 60_000, + env + }); + } catch (error) { + throw this.mapPushError(error); + } + } + + private async trySetGlobalCredentialHelper(helper: string): Promise { + try { + await runCommand("git", ["config", "--global", "credential.helper", helper], { + timeoutMs: 10_000 + }); + return true; + } catch { + return false; + } + } + + private shouldFallbackToMaster(error: unknown): boolean { + if (!(error instanceof CommandExecutionError)) { + return false; + } + + const detail = `${error.stdout}\n${error.stderr}`.toLowerCase(); + + if ( + detail.includes("authentication failed") || + detail.includes("could not read username") || + detail.includes("access denied") || + detail.includes("non-fast-forward") || + detail.includes("failed to push some refs") + ) { + return false; + } + + return detail.includes("main") || detail.includes("refs/heads/main") || detail.includes("src refspec main"); + } + + private mapPushError(error: unknown) { + if (error instanceof CommandExecutionError) { + const detail = this.composeCommandErrorDetail(error); + const mapped = inferGitPushError(detail); + const compact = this.compactDetail(detail); + + if (compact) { + console.error(`[PushGo][GitService][push][${mapped.payload.code}]`, compact); + } + + if (mapped.payload.code !== "UNKNOWN") { + if (compact) { + return { + ...mapped.payload, + message: `${mapped.payload.message}(原始信息:${compact})` + }; + } + + return mapped; + } + + if (compact) { + console.error("[PushGo][GitService][push] unknown command error:", compact); + return createAppError("UNKNOWN", { + message: `推送失败(原始信息:${compact})` + }); + } + + return mapped; + } + + console.error("[PushGo][GitService][push] unknown non-command error:", error); + return createAppError("UNKNOWN"); + } + + private compactDetail(detail: string): string { + return detail + .replace(/\s+/g, " ") + .trim() + .slice(0, 220); + } + + private isExitCode(code: number | string | null, expected: number): boolean { + if (typeof code === "number") { + return code === expected; + } + + if (typeof code === "string") { + const parsed = Number.parseInt(code, 10); + return Number.isFinite(parsed) && parsed === expected; + } + + return false; + } + + private composeCommandErrorDetail(error: CommandExecutionError): string { + const raw = [error.stderr, error.stdout, error.message] + .filter(Boolean) + .join("\n"); + + return this.redactSensitive(raw); + } + + private redactSensitive(content: string): string { + return content + .replace(/(authorization:\s*basic\s+)[a-z0-9+/=]+/gi, "$1[REDACTED]") + .replace(/(https?:\/\/[^:\s/]+:)[^@\s]+@/gi, "$1[REDACTED]@"); + } +} diff --git a/src/main/services/push-orchestrator.ts b/src/main/services/push-orchestrator.ts new file mode 100644 index 0000000..ba23a33 --- /dev/null +++ b/src/main/services/push-orchestrator.ts @@ -0,0 +1,76 @@ +import { PushOrchestrator, PushProgressEvent, PushRunMeta, PushRunParams } from "../../shared/contracts"; +import { createAppError } from "../errors"; +import { SessionAuthService } from "./auth-service"; +import { CliGitService } from "./git-service"; + +type ProgressReporter = (event: PushProgressEvent) => void; + +export class DefaultPushOrchestrator implements PushOrchestrator { + private lastMeta: PushRunMeta = { + commitStatus: "committed", + fallbackToMaster: false + }; + + constructor( + private readonly gitService: CliGitService, + private readonly authService: SessionAuthService + ) {} + + async run(params: PushRunParams): Promise { + await this.execute(params); + } + + async runWithProgress(params: PushRunParams, onProgress?: ProgressReporter): Promise { + await this.execute(params, onProgress); + } + + consumeLastMeta(): PushRunMeta { + const snapshot = { ...this.lastMeta }; + this.lastMeta = { + commitStatus: "committed", + fallbackToMaster: false + }; + return snapshot; + } + + private async execute(params: PushRunParams, onProgress?: ProgressReporter): Promise { + if (!params.projectPath) { + throw createAppError("APP_FOLDER_INVALID"); + } + + this.lastMeta = { + commitStatus: "committed", + fallbackToMaster: false + }; + + const token = this.authService.getToken(params.platform, { repoUrl: params.repoUrl }); + this.gitService.setAuthContext(params.platform, token); + + try { + onProgress?.({ stage: "prepare", message: "准备仓库" }); + const isRepo = await this.gitService.isRepo(params.projectPath); + if (!isRepo) { + await this.gitService.initRepo(params.projectPath); + } + await this.gitService.ensureUserConfig(params.projectPath); + + onProgress?.({ stage: "collect", message: "整理文件" }); + await this.gitService.addAll(params.projectPath); + + onProgress?.({ stage: "commit", message: "提交变更" }); + const commitStatus = await this.gitService.commit(params.projectPath, `PushGo Auto Commit ${new Date().toISOString()}`); + + onProgress?.({ stage: "upload", message: "上传到远端" }); + await this.gitService.setRemote(params.projectPath, params.repoUrl); + await this.gitService.push(params.projectPath); + + const pushMeta = this.gitService.consumePushMeta(); + this.lastMeta = { + commitStatus, + fallbackToMaster: pushMeta.fallbackToMaster + }; + } finally { + this.gitService.clearAuthContext(); + } + } +} diff --git a/src/main/services/repo-service.ts b/src/main/services/repo-service.ts new file mode 100644 index 0000000..aa6b7af --- /dev/null +++ b/src/main/services/repo-service.ts @@ -0,0 +1,89 @@ +import { Platform, RepoService } from "../../shared/contracts"; +import { createAppError } from "../errors"; + +const DEFAULT_GITEA_BASE_URL = "https://gitea.com"; + +export class DefaultRepoService implements RepoService { + detectPlatformFromUrl(url: string): Platform | null { + try { + const parsed = new URL(url.trim()); + const host = parsed.hostname.toLowerCase(); + const pathParts = parsed.pathname.split("/").filter(Boolean); + + if (host === "github.com" || host.endsWith(".github.com")) { + return "github"; + } + + if (host === "gitee.com" || host.endsWith(".gitee.com")) { + return "gitee"; + } + + const giteaHost = this.getConfiguredGiteaHost(); + if (host === giteaHost || host.endsWith(`.${giteaHost}`) || host.includes("gitea")) { + return "gitea"; + } + + // 对自建域名仓库,默认按 Gitea 识别,保证可继续流程。 + if (pathParts.length >= 2) { + return "gitea"; + } + + return null; + } catch { + return null; + } + } + + validateRepoUrl(url: string, platform: Platform): { ok: boolean; code?: string; message?: string } { + const raw = url.trim(); + + if (!raw) { + const error = createAppError("URL_INVALID_FORMAT"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + const error = createAppError("URL_INVALID_FORMAT"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + const error = createAppError("URL_INVALID_FORMAT"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + const pathParts = parsed.pathname.split("/").filter(Boolean); + if (pathParts.length < 2) { + const error = createAppError("URL_INVALID_FORMAT"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + const detectedPlatform = this.detectPlatformFromUrl(raw); + + if (detectedPlatform && detectedPlatform !== platform) { + const error = createAppError("URL_PLATFORM_MISMATCH"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + if (!detectedPlatform && platform !== "gitea") { + const error = createAppError("URL_INVALID_FORMAT"); + return { ok: false, code: error.payload.code, message: error.payload.message }; + } + + return { ok: true }; + } + + private getConfiguredGiteaHost(): string { + const rawBase = (process.env.PUSHGO_GITEA_BASE_URL ?? DEFAULT_GITEA_BASE_URL).trim(); + + try { + const normalized = rawBase.startsWith("http") ? rawBase : `https://${rawBase}`; + return new URL(normalized).hostname.toLowerCase(); + } catch { + return "gitea.com"; + } + } +} diff --git a/src/main/utils/run-command.ts b/src/main/utils/run-command.ts new file mode 100644 index 0000000..838377c --- /dev/null +++ b/src/main/utils/run-command.ts @@ -0,0 +1,57 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface CommandResult { + stdout: string; + stderr: string; +} + +export class CommandExecutionError extends Error { + readonly stdout: string; + readonly stderr: string; + readonly code: number | string | null; + + constructor(message: string, params: { stdout?: string; stderr?: string; code?: number | string | null }) { + super(message); + this.name = "CommandExecutionError"; + this.stdout = params.stdout ?? ""; + this.stderr = params.stderr ?? ""; + this.code = params.code ?? null; + } +} + +export async function runCommand( + command: string, + args: string[], + options?: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } +): Promise { + try { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd: options?.cwd, + timeout: options?.timeoutMs ?? 60_000, + maxBuffer: 1024 * 1024 * 8, + env: options?.env, + windowsHide: true + }); + + return { + stdout: stdout.toString(), + stderr: stderr.toString() + }; + } catch (error) { + const err = error as { + message?: string; + stdout?: string | Buffer; + stderr?: string | Buffer; + code?: number | string; + }; + + throw new CommandExecutionError(err.message ?? "命令执行失败", { + stdout: err.stdout?.toString(), + stderr: err.stderr?.toString(), + code: err.code ?? null + }); + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts new file mode 100644 index 0000000..8ed9c89 --- /dev/null +++ b/src/preload/preload.ts @@ -0,0 +1,28 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; +import { IPC_CHANNELS, PUSH_PROGRESS_CHANNEL } from "../shared/channels"; +import { PushGoApi } from "../shared/bridge"; +import { PushProgressEvent } from "../shared/contracts"; + +const api: PushGoApi = { + checkGit: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_GIT), + selectFolder: () => ipcRenderer.invoke(IPC_CHANNELS.SELECT_FOLDER), + validateRepoUrl: (payload) => ipcRenderer.invoke(IPC_CHANNELS.VALIDATE_URL, payload), + authStatus: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_STATUS, payload), + authLogin: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGIN, payload), + authLogout: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT, payload), + runPush: (payload) => ipcRenderer.invoke(IPC_CHANNELS.PUSH_RUN, payload), + openExternal: (payload) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, payload), + onPushProgress: (callback) => { + const listener = (_event: IpcRendererEvent, payload: PushProgressEvent) => { + callback(payload); + }; + + ipcRenderer.on(PUSH_PROGRESS_CHANNEL, listener); + + return () => { + ipcRenderer.removeListener(PUSH_PROGRESS_CHANNEL, listener); + }; + } +}; + +contextBridge.exposeInMainWorld("pushgo", api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..be77bd0 --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,81 @@ +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { useAppContext } from "./context/app-context"; +import { AuthPage } from "./pages/auth-page"; +import { CompletePage } from "./pages/complete-page"; +import { PushPage } from "./pages/push-page"; +import { RepoPage } from "./pages/repo-page"; +import { SelectProjectPage } from "./pages/select-project-page"; +import { StartupPage } from "./pages/startup-page"; + +const STEP_LABELS: Record = { + "/": "开始前检查", + "/select": "选择平台和项目", + "/repo": "填写仓库地址", + "/auth": "登录并授权", + "/push": "正在推送", + "/done": "推送成功" +}; + +export default function App() { + const location = useLocation(); + const { error, notice, setError } = useAppContext(); + + const currentLabel = STEP_LABELS[location.pathname] ?? "PushGo"; + + return ( +
+
+
+ +
+
+ PushGo + 小白一键推送到 GitHub / Gitee / Gitea +
+
{currentLabel}
+
+ +
+ {notice ?
{notice}
: null} + {error ? ( +
+
{error.message}
+ {error.actionType === "open_git_download" ? ( + + ) : null} + {error.actionType === "open_gcm_download" ? ( + + ) : null} + +
+ ) : null} + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/src/renderer/context/app-context.tsx b/src/renderer/context/app-context.tsx new file mode 100644 index 0000000..cb9a162 --- /dev/null +++ b/src/renderer/context/app-context.tsx @@ -0,0 +1,130 @@ +import { createContext, ReactNode, useContext, useMemo, useState } from "react"; +import { ValidateUrlResult } from "../../shared/bridge"; +import { AppErrorPayload, AppState, PushProgressEvent, PushRunMeta, Platform } from "../../shared/contracts"; + +const initialState: AppState = { + gitInstalled: false, + platform: null, + projectPath: null, + repoUrl: null, + auth: { + github: false, + gitee: false, + gitea: false + } +}; + +type AppContextValue = { + state: AppState; + gitChecked: boolean; + gitWarning: AppErrorPayload | null; + repoValidation: ValidateUrlResult | null; + progress: PushProgressEvent | null; + pushMeta: PushRunMeta | null; + error: AppErrorPayload | null; + notice: string | null; + setPlatform: (platform: Platform) => void; + setProjectPath: (projectPath: string | null) => void; + setRepoUrl: (repoUrl: string | null) => void; + setGitResult: (installed: boolean, warning?: AppErrorPayload) => void; + setAuthStatus: (platform: Platform, loggedIn: boolean) => void; + setRepoValidation: (result: ValidateUrlResult | null) => void; + setProgress: (progress: PushProgressEvent | null) => void; + setPushMeta: (meta: PushRunMeta | null) => void; + setError: (error: AppErrorPayload | null) => void; + setNotice: (notice: string | null) => void; + clearTransientMessages: () => void; + resetForNextPush: () => void; +}; + +const AppContext = createContext(null); + +export function AppProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(initialState); + const [gitChecked, setGitChecked] = useState(false); + const [gitWarning, setGitWarning] = useState(null); + const [repoValidation, setRepoValidationState] = useState(null); + const [progress, setProgressState] = useState(null); + const [pushMeta, setPushMetaState] = useState(null); + const [error, setErrorState] = useState(null); + const [notice, setNoticeState] = useState(null); + + const value = useMemo( + () => ({ + state, + gitChecked, + gitWarning, + repoValidation, + progress, + pushMeta, + error, + notice, + setPlatform: (platform) => { + setState((prev) => ({ ...prev, platform })); + }, + setProjectPath: (projectPath) => { + setState((prev) => ({ ...prev, projectPath })); + }, + setRepoUrl: (repoUrl) => { + setState((prev) => ({ ...prev, repoUrl })); + }, + setGitResult: (installed, warning) => { + setGitChecked(true); + setGitWarning(warning ?? null); + setState((prev) => ({ ...prev, gitInstalled: installed })); + }, + setAuthStatus: (platform, loggedIn) => { + setState((prev) => { + if (prev.auth[platform] === loggedIn) { + return prev; + } + + return { + ...prev, + auth: { + ...prev.auth, + [platform]: loggedIn + } + }; + }); + }, + setRepoValidation: (result) => { + setRepoValidationState(result); + }, + setProgress: (nextProgress) => { + setProgressState(nextProgress); + }, + setPushMeta: (meta) => { + setPushMetaState(meta); + }, + setError: (nextError) => { + setErrorState(nextError); + }, + setNotice: (nextNotice) => { + setNoticeState(nextNotice); + }, + clearTransientMessages: () => { + setErrorState(null); + setNoticeState(null); + }, + resetForNextPush: () => { + setProgressState(null); + setPushMetaState(null); + setErrorState(null); + setNoticeState(null); + } + }), + [state, gitChecked, gitWarning, repoValidation, progress, pushMeta, error, notice] + ); + + return {children}; +} + +export function useAppContext(): AppContextValue { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error("useAppContext must be used inside AppProvider"); + } + + return ctx; +} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts new file mode 100644 index 0000000..8181da2 --- /dev/null +++ b/src/renderer/env.d.ts @@ -0,0 +1,11 @@ +/// + +import type { PushGoApi } from "../shared/bridge"; + +declare global { + interface Window { + pushgo: PushGoApi; + } +} + +export {}; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 0000000..4283074 --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { HashRouter } from "react-router-dom"; +import App from "./App"; +import { AppProvider } from "./context/app-context"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + +); diff --git a/src/renderer/pages/auth-page.tsx b/src/renderer/pages/auth-page.tsx new file mode 100644 index 0000000..2ed7aa3 --- /dev/null +++ b/src/renderer/pages/auth-page.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAppContext } from "../context/app-context"; + +const AUTH_STATUS_TIMEOUT_MS = 8_000; + +export function AuthPage() { + const navigate = useNavigate(); + const { state, setAuthStatus, setError, clearTransientMessages } = useAppContext(); + const [checking, setChecking] = useState(false); + const [working, setWorking] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + if (!state.platform || !state.repoUrl || !state.projectPath) { + navigate("/select", { replace: true }); + return; + } + + let cancelled = false; + + const checkStatus = async () => { + setChecking(true); + try { + const result = await Promise.race([ + window.pushgo.authStatus({ platform: state.platform!, repoUrl: state.repoUrl! }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("AUTH_STATUS_TIMEOUT")), AUTH_STATUS_TIMEOUT_MS); + }) + ]); + + if (result.ok) { + if (!cancelled) { + setLoggedIn(result.data.loggedIn); + setAuthStatus(state.platform!, result.data.loggedIn); + } + } else { + if (!cancelled) { + setError(result.error); + } + } + } catch { + if (!cancelled) { + setError({ code: "UNKNOWN", message: "登录状态检查失败,请点击“去登录授权”继续" }); + } + } finally { + if (!cancelled) { + setChecking(false); + } + } + }; + + void checkStatus(); + + return () => { + cancelled = true; + }; + }, [navigate, state.platform, state.projectPath, state.repoUrl]); + + const proceed = async () => { + if (!state.platform) { + return; + } + + setWorking(true); + clearTransientMessages(); + + try { + if (!loggedIn) { + const loginResult = await window.pushgo.authLogin({ platform: state.platform, repoUrl: state.repoUrl! }); + if (!loginResult.ok) { + setError(loginResult.error); + return; + } + + setAuthStatus(state.platform, true); + setLoggedIn(true); + } + + navigate("/push"); + } catch { + setError({ code: "UNKNOWN", message: "发起授权失败,请重试" }); + } finally { + setWorking(false); + } + }; + + const platformLabel = + state.platform === "github" ? "GitHub" : state.platform === "gitee" ? "Gitee" : "Gitea"; + + return ( +
+

登录并授权

+

需要完成 {platformLabel} 授权后才能推送代码

+

若已配置 OAuth,点击后会在浏览器打开授权页;未配置时首次推送会由系统 Git 凭据窗口发起授权。

+ +
+ {checking ? "正在检查登录状态..." : loggedIn ? `已登录 ${platformLabel},可以继续` : `当前会话未登录 ${platformLabel}`} +
+ +
+ + +
+
+ ); +} diff --git a/src/renderer/pages/complete-page.tsx b/src/renderer/pages/complete-page.tsx new file mode 100644 index 0000000..1f02003 --- /dev/null +++ b/src/renderer/pages/complete-page.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router-dom"; +import { useAppContext } from "../context/app-context"; + +export function CompletePage() { + const navigate = useNavigate(); + const { state, pushMeta, setNotice, setError, resetForNextPush } = useAppContext(); + + const repoUrl = state.repoUrl ?? ""; + + const copyRepo = async () => { + try { + await navigator.clipboard.writeText(repoUrl); + setNotice("仓库链接已复制"); + } catch { + setError({ code: "UNKNOWN", message: "复制失败,请手动复制链接" }); + } + }; + + const openRepo = async () => { + if (!repoUrl) { + return; + } + + const result = await window.pushgo.openExternal({ url: repoUrl }); + if (!result.ok) { + setError(result.error); + } + }; + + return ( +
+

推送成功

+ +

{repoUrl}

+ + {pushMeta?.fallbackToMaster ?

已自动回退到 master 分支完成推送。

: null} + +
+ + + +
+
+ ); +} diff --git a/src/renderer/pages/push-page.tsx b/src/renderer/pages/push-page.tsx new file mode 100644 index 0000000..0da1e9e --- /dev/null +++ b/src/renderer/pages/push-page.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PushStage } from "../../shared/contracts"; +import { useAppContext } from "../context/app-context"; + +const STEP_ORDER: Array<{ key: PushStage; label: string }> = [ + { key: "prepare", label: "准备仓库" }, + { key: "collect", label: "整理文件" }, + { key: "commit", label: "提交变更" }, + { key: "upload", label: "上传到远端" } +]; + +export function PushPage() { + const navigate = useNavigate(); + const { state, progress, setProgress, setPushMeta, setError, setNotice, clearTransientMessages } = useAppContext(); + const [running, setRunning] = useState(false); + const startedRef = useRef(false); + + useEffect(() => { + if (!state.platform || !state.projectPath || !state.repoUrl) { + navigate("/select", { replace: true }); + } + }, [navigate, state.platform, state.projectPath, state.repoUrl]); + + useEffect(() => { + const unsubscribe = window.pushgo.onPushProgress((event) => { + setProgress(event); + }); + + return () => { + unsubscribe(); + }; + }, [setProgress]); + + const runPush = useCallback(async () => { + if (!state.platform || !state.projectPath || !state.repoUrl || running) { + return; + } + + setRunning(true); + clearTransientMessages(); + + const result = await window.pushgo.runPush({ + platform: state.platform, + projectPath: state.projectPath, + repoUrl: state.repoUrl + }); + + if (result.ok) { + setPushMeta(result.data.meta); + const notices: string[] = []; + if (result.data.meta.commitStatus === "no_changes") { + notices.push("没有检测到新变更,已尝试直接推送"); + } + if (result.data.meta.fallbackToMaster) { + notices.push("远端仅支持 master,已自动回退并完成推送"); + } + if (notices.length > 0) { + setNotice(notices.join(";")); + } + + navigate("/done"); + } else { + setError(result.error); + } + + setRunning(false); + }, [ + clearTransientMessages, + navigate, + running, + setError, + setNotice, + setPushMeta, + state.platform, + state.projectPath, + state.repoUrl + ]); + + useEffect(() => { + if (!state.platform || !state.projectPath || !state.repoUrl || startedRef.current) { + return; + } + + startedRef.current = true; + void runPush(); + }, [runPush, state.platform, state.projectPath, state.repoUrl]); + + const activeIndex = useMemo(() => { + const currentStage = progress?.stage ?? "prepare"; + const idx = STEP_ORDER.findIndex((item) => item.key === currentStage); + return idx === -1 ? 0 : idx; + }, [progress?.stage]); + + return ( +
+

正在推送

+ +
    + {STEP_ORDER.map((item, index) => ( +
  • + {item.label} +
  • + ))} +
+ +
+ {!running ? ( + + ) : null} + +
+
+ ); +} diff --git a/src/renderer/pages/repo-page.tsx b/src/renderer/pages/repo-page.tsx new file mode 100644 index 0000000..5d6a957 --- /dev/null +++ b/src/renderer/pages/repo-page.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Platform } from "../../shared/contracts"; +import { useAppContext } from "../context/app-context"; + +const PLATFORM_TEXT: Record = { + github: "GitHub", + gitee: "Gitee", + gitea: "Gitea" +}; + +export function RepoPage() { + const navigate = useNavigate(); + const { state, setPlatform, setRepoUrl, setRepoValidation, repoValidation, setError, clearTransientMessages } = + useAppContext(); + const [urlInput, setUrlInput] = useState(state.repoUrl ?? ""); + const [validating, setValidating] = useState(false); + + useEffect(() => { + if (!state.platform || !state.projectPath) { + navigate("/select", { replace: true }); + } + }, [navigate, state.platform, state.projectPath]); + + useEffect(() => { + setUrlInput(state.repoUrl ?? ""); + }, [state.repoUrl]); + + const canSubmit = useMemo(() => Boolean(urlInput.trim()), [urlInput]); + + const validateNow = async () => { + if (!state.platform) { + return; + } + + setValidating(true); + setRepoUrl(urlInput.trim()); + clearTransientMessages(); + + const result = await window.pushgo.validateRepoUrl({ + url: urlInput.trim(), + platform: state.platform + }); + + if (result.ok) { + setRepoValidation(result.data); + if (result.data.ok) { + navigate("/auth"); + } else { + setError({ + code: result.data.code ?? "UNKNOWN", + message: result.data.message ?? "仓库地址格式不正确,请检查后重试" + }); + } + } else { + setError(result.error); + } + + setValidating(false); + }; + + const mismatchPlatform = + repoValidation?.code === "URL_PLATFORM_MISMATCH" && repoValidation.detectedPlatform + ? repoValidation.detectedPlatform + : null; + + return ( +
+

填写仓库地址

+

粘贴仓库地址,例如 https://github.com/name/repo.git 或 https://gitea.com/name/repo.git

+ + + { + setUrlInput(event.target.value); + setRepoValidation(null); + }} + /> + + {repoValidation?.ok ?

仓库地址可用

: null} + {repoValidation && !repoValidation.ok ? ( +

{repoValidation.message ?? "仓库地址格式不正确,请检查后重试"}

+ ) : null} + + {mismatchPlatform ? ( + + ) : null} + +
+ + +
+
+ ); +} diff --git a/src/renderer/pages/select-project-page.tsx b/src/renderer/pages/select-project-page.tsx new file mode 100644 index 0000000..1e9de32 --- /dev/null +++ b/src/renderer/pages/select-project-page.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Platform } from "../../shared/contracts"; +import { useAppContext } from "../context/app-context"; + +const PLATFORM_LIST: Array<{ key: Platform; label: string; desc: string }> = [ + { key: "github", label: "GitHub", desc: "适合开源与国际协作" }, + { key: "gitee", label: "Gitee", desc: "适合国内网络环境" }, + { key: "gitea", label: "Gitea", desc: "适合私有化或自建代码服务" } +]; + +export function SelectProjectPage() { + const navigate = useNavigate(); + const { state, setPlatform, setProjectPath, setError, clearTransientMessages } = useAppContext(); + const [browsing, setBrowsing] = useState(false); + + const browseFolder = async () => { + setBrowsing(true); + const result = await window.pushgo.selectFolder(); + if (result.ok) { + setProjectPath(result.data.path); + clearTransientMessages(); + } else { + setError(result.error); + } + setBrowsing(false); + }; + + const canNext = Boolean(state.platform && state.projectPath); + + return ( +
+

选择平台和项目

+ +
+ {PLATFORM_LIST.map((item) => ( + + ))} +
+ +
+

点击浏览选择项目文件夹

+ +
+ + {state.projectPath ? ( +

+ 已选文件夹:{state.projectPath} + +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/src/renderer/pages/startup-page.tsx b/src/renderer/pages/startup-page.tsx new file mode 100644 index 0000000..bb4eed0 --- /dev/null +++ b/src/renderer/pages/startup-page.tsx @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAppContext } from "../context/app-context"; + +export function StartupPage() { + const navigate = useNavigate(); + const { state, gitChecked, gitWarning, setGitResult, setError, clearTransientMessages } = useAppContext(); + const [checking, setChecking] = useState(false); + + const runCheck = useCallback(async () => { + setChecking(true); + clearTransientMessages(); + + const result = await window.pushgo.checkGit(); + if (result.ok) { + setGitResult(result.data.installed, result.data.warning); + if (result.data.warning) { + setError(result.data.warning); + } + } else { + setGitResult(false, result.error); + setError(result.error); + } + + setChecking(false); + }, [clearTransientMessages, setError, setGitResult]); + + useEffect(() => { + if (!gitChecked) { + void runCheck(); + } + }, [gitChecked, runCheck]); + + const statusText = !state.gitInstalled + ? "未检测到 Git,安装后即可使用" + : gitWarning + ? "已安装 Git,但授权组件未就绪(请按提示处理)" + : "已检测到 Git 和授权组件,可继续"; + + return ( +
+

开始前检查

+

我们先确认你的电脑已安装 Git,并具备首次授权弹窗能力

+ +
{statusText}
+ +
+ {!state.gitInstalled ? ( + + ) : null} + + + + +
+
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..14d7ab9 --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,424 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&display=swap"); + +:root { + --bg-0: #07131f; + --bg-1: #10263d; + --panel: rgba(7, 23, 38, 0.8); + --panel-border: rgba(157, 196, 232, 0.24); + --text: #f3f9ff; + --text-dim: rgba(234, 245, 255, 0.72); + --accent: #5fe0b9; + --accent-strong: #1bb28a; + --warn: #ffb86c; + --danger: #ff6e6e; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; + font-family: "Noto Sans SC", "PingFang SC", sans-serif; + color: var(--text); + background: radial-gradient(120% 120% at 10% 0%, #11365a 0%, var(--bg-0) 55%, #040c14 100%); +} + +button, +input { + font: inherit; +} + +.app-shell { + min-height: 100vh; + position: relative; + overflow: hidden; + padding: 2rem clamp(1rem, 3vw, 2.5rem); +} + +.bg-orb { + position: absolute; + border-radius: 999px; + filter: blur(14px); + opacity: 0.22; + pointer-events: none; + animation: float 16s ease-in-out infinite; +} + +.bg-orb-a { + width: 280px; + height: 280px; + background: #6f9dff; + top: -70px; + right: -40px; +} + +.bg-orb-b { + width: 340px; + height: 340px; + background: #2cc8a9; + bottom: -120px; + left: -80px; + animation-delay: -4s; +} + +.topbar { + position: relative; + z-index: 2; + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.4rem; +} + +.brand-line { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.brand { + font-family: "Space Grotesk", "Noto Sans SC", sans-serif; + font-size: clamp(1.8rem, 2.6vw, 2.4rem); + letter-spacing: 0.02em; + font-weight: 700; +} + +.brand-sub { + color: var(--text-dim); + font-size: 0.94rem; +} + +.step-label { + color: rgba(243, 249, 255, 0.82); + font-weight: 500; + font-size: 0.96rem; + background: rgba(13, 33, 51, 0.8); + border: 1px solid rgba(165, 199, 228, 0.28); + border-radius: 999px; + padding: 0.5rem 1rem; +} + +.main-wrap { + position: relative; + z-index: 2; + max-width: 900px; +} + +.panel { + background: linear-gradient(165deg, rgba(9, 31, 48, 0.94), rgba(5, 19, 32, 0.9)); + border: 1px solid var(--panel-border); + border-radius: 22px; + padding: clamp(1.2rem, 3vw, 2.1rem); + backdrop-filter: blur(8px); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3); +} + +.panel h1 { + margin: 0; + font-family: "Space Grotesk", "Noto Sans SC", sans-serif; + font-size: clamp(1.45rem, 2.3vw, 2rem); +} + +.desc { + margin-top: 0.7rem; + color: var(--text-dim); + line-height: 1.55; +} + +.status { + margin-top: 1.3rem; + border-radius: 12px; + padding: 0.78rem 1rem; + font-weight: 500; + border: 1px solid transparent; +} + +.status.ok { + border-color: rgba(95, 224, 185, 0.45); + background: rgba(25, 74, 64, 0.48); +} + +.status.warn { + border-color: rgba(255, 184, 108, 0.42); + background: rgba(96, 62, 19, 0.36); +} + +.platform-grid { + margin-top: 1.2rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} + +.platform-item { + border: 1px solid rgba(167, 198, 225, 0.28); + border-radius: 14px; + background: rgba(7, 29, 45, 0.66); + color: var(--text); + text-align: left; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.32rem; + transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease; +} + +.platform-item:hover { + transform: translateY(-2px); +} + +.platform-item.active { + border-color: rgba(95, 224, 185, 0.75); + background: rgba(17, 67, 58, 0.62); +} + +.platform-name { + font-weight: 700; +} + +.platform-desc { + font-size: 0.88rem; + color: var(--text-dim); +} + +.drop-zone { + margin-top: 1rem; + border: 1.5px dashed rgba(164, 203, 231, 0.48); + border-radius: 16px; + padding: 1.3rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: rgba(9, 35, 53, 0.45); +} + +.path-view { + margin-top: 0.9rem; + color: rgba(230, 244, 255, 0.86); + word-break: break-all; +} + +.path-view span { + color: #7ee6c6; +} + +.field-label { + margin-top: 1.2rem; + display: inline-block; + font-size: 0.92rem; + color: rgba(232, 244, 255, 0.82); +} + +.input { + width: 100%; + margin-top: 0.45rem; + border-radius: 12px; + border: 1px solid rgba(167, 198, 225, 0.35); + background: rgba(7, 28, 44, 0.88); + color: var(--text); + padding: 0.72rem 0.9rem; +} + +.input:focus { + outline: none; + border-color: rgba(95, 224, 185, 0.88); +} + +.validation { + margin-top: 0.66rem; + font-size: 0.92rem; +} + +.validation.ok { + color: #88eccf; +} + +.validation.error { + color: #ffb0a4; +} + +.repo-url { + margin-top: 0.9rem; + padding: 0.75rem 0.95rem; + border-radius: 10px; + background: rgba(7, 27, 43, 0.72); + border: 1px solid rgba(153, 195, 226, 0.32); + word-break: break-all; +} + +.progress-list { + margin: 1.1rem 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.66rem; +} + +.progress-item { + border: 1px solid rgba(161, 201, 230, 0.28); + background: rgba(10, 33, 50, 0.55); + border-radius: 12px; + padding: 0.78rem 0.95rem; +} + +.progress-item.active { + border-color: rgba(95, 224, 185, 0.8); + animation: pulse 1.5s ease infinite; +} + +.progress-item.done { + border-color: rgba(95, 224, 185, 0.5); + color: rgba(213, 243, 233, 0.92); +} + +.actions { + margin-top: 1.3rem; + display: flex; + flex-wrap: wrap; + gap: 0.62rem; +} + +.actions.single { + justify-content: flex-end; +} + +button { + border: 0; + border-radius: 12px; + padding: 0.68rem 1.15rem; + cursor: pointer; + transition: transform 0.16s ease, opacity 0.16s ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; +} + +.primary { + color: #02251e; + font-weight: 700; + background: linear-gradient(120deg, var(--accent), #9ef7d8); +} + +.ghost { + color: var(--text); + border: 1px solid rgba(168, 200, 228, 0.34); + background: rgba(12, 37, 56, 0.62); +} + +.link-like { + margin-left: 0.45rem; + padding: 0; + border: 0; + background: transparent; + color: #9be8cf; + cursor: pointer; +} + +.hint { + margin-bottom: 0.85rem; + border-radius: 13px; + border: 1px solid transparent; + padding: 0.75rem 0.9rem; + display: flex; + gap: 0.65rem; + align-items: center; + flex-wrap: wrap; +} + +.hint.success { + border-color: rgba(95, 224, 185, 0.52); + background: rgba(20, 76, 64, 0.38); +} + +.hint.error { + border-color: rgba(255, 129, 129, 0.5); + background: rgba(96, 34, 34, 0.42); +} + +.hint-action, +.hint-close { + background: rgba(255, 255, 255, 0.15); + color: #f8fdff; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.42rem 0.72rem; + border-radius: 8px; +} + +.fade-in { + animation: slideIn 0.45s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(95, 224, 185, 0.35); + } + 70% { + box-shadow: 0 0 0 10px rgba(95, 224, 185, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(95, 224, 185, 0); + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(14px); + } +} + +@media (max-width: 900px) { + .app-shell { + padding: 1rem; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + } + + .platform-grid { + grid-template-columns: 1fr; + } + + .drop-zone { + flex-direction: column; + align-items: flex-start; + } + + .actions, + .actions.single { + justify-content: stretch; + } + + .actions button { + width: 100%; + } +} diff --git a/src/shared/bridge.ts b/src/shared/bridge.ts new file mode 100644 index 0000000..3b3b4cb --- /dev/null +++ b/src/shared/bridge.ts @@ -0,0 +1,30 @@ +import { IpcResult, Platform, PushProgressEvent, PushRunMeta, PushRunParams } from "./contracts"; + +export type CheckGitResult = { + installed: boolean; + warning?: { + code: string; + message: string; + actionText?: string; + actionType?: string; + }; +}; + +export type ValidateUrlResult = { + ok: boolean; + code?: string; + message?: string; + detectedPlatform: Platform | null; +}; + +export interface PushGoApi { + checkGit(): Promise>; + selectFolder(): Promise>; + validateRepoUrl(payload: { url: string; platform: Platform }): Promise>; + authStatus(payload: { platform: Platform; repoUrl?: string }): Promise>; + authLogin(payload: { platform: Platform; repoUrl?: string }): Promise>; + authLogout(payload: { platform: Platform; repoUrl?: string }): Promise>; + runPush(payload: PushRunParams): Promise>; + openExternal(payload: { url: string }): Promise>; + onPushProgress(callback: (event: PushProgressEvent) => void): () => void; +} diff --git a/src/shared/channels.ts b/src/shared/channels.ts new file mode 100644 index 0000000..5af0da5 --- /dev/null +++ b/src/shared/channels.ts @@ -0,0 +1,12 @@ +export const IPC_CHANNELS = { + CHECK_GIT: "app:check-git", + OPEN_EXTERNAL: "app:open-external", + AUTH_LOGIN: "auth:login", + AUTH_LOGOUT: "auth:logout", + AUTH_STATUS: "auth:status", + VALIDATE_URL: "repo:validate-url", + SELECT_FOLDER: "git:select-folder", + PUSH_RUN: "push:run" +} as const; + +export const PUSH_PROGRESS_CHANNEL = "push:progress"; diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts new file mode 100644 index 0000000..ae60f62 --- /dev/null +++ b/src/shared/contracts.ts @@ -0,0 +1,87 @@ +export type Platform = "github" | "gitee" | "gitea"; + +export interface AppState { + gitInstalled: boolean; + platform: Platform | null; + projectPath: string | null; + repoUrl: string | null; + auth: { github: boolean; gitee: boolean; gitea: boolean }; +} + +export interface GitService { + checkInstalled(): Promise; + isRepo(projectPath: string): Promise; + initRepo(projectPath: string): Promise; + ensureUserConfig(projectPath: string): Promise; + addAll(projectPath: string): Promise; + commit(projectPath: string, message: string): Promise<"committed" | "no_changes">; + setRemote(projectPath: string, repoUrl: string): Promise; + push(projectPath: string): Promise; +} + +export interface AuthService { + isLoggedIn(platform: Platform, options?: { repoUrl?: string }): Promise; + login(platform: Platform, options?: { repoUrl?: string }): Promise; + logout(platform: Platform, options?: { repoUrl?: string }): Promise; +} + +export interface RepoService { + validateRepoUrl(url: string, platform: Platform): { ok: boolean; code?: string; message?: string }; + detectPlatformFromUrl(url: string): Platform | null; +} + +export interface PushOrchestrator { + run(params: { platform: Platform; projectPath: string; repoUrl: string }): Promise; +} + +export type PushStage = "prepare" | "collect" | "commit" | "upload"; + +export interface PushProgressEvent { + stage: PushStage; + message: string; +} + +export interface PushRunMeta { + commitStatus: "committed" | "no_changes"; + fallbackToMaster: boolean; +} + +export type ErrorCode = + | "APP_GIT_NOT_FOUND" + | "APP_GIT_HELPER_MISSING" + | "APP_GIT_HELPER_UNSUPPORTED" + | "APP_FOLDER_INVALID" + | "URL_INVALID_FORMAT" + | "URL_PLATFORM_MISMATCH" + | "AUTH_CANCELLED" + | "AUTH_FAILED" + | "GIT_INIT_FAILED" + | "GIT_COMMIT_NO_CHANGES" + | "GIT_REMOTE_SET_FAILED" + | "GIT_PUSH_AUTH_FAILED" + | "GIT_PUSH_REJECTED" + | "NETWORK_TIMEOUT" + | "UNKNOWN"; + +export interface AppErrorPayload { + code: ErrorCode | string; + message: string; + actionText?: string; + actionType?: string; +} + +export type IpcResult = + | { + ok: true; + data: T; + } + | { + ok: false; + error: AppErrorPayload; + }; + +export interface PushRunParams { + platform: Platform; + projectPath: string; + repoUrl: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a7988b0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "types": ["vite/client"] + }, + "include": ["src/renderer", "src/shared", "vite.config.ts"] +} diff --git a/tsconfig.main.json b/tsconfig.main.json new file mode 100644 index 0000000..0ed2817 --- /dev/null +++ b/tsconfig.main.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022", "DOM"], + "moduleResolution": "Node", + "outDir": "dist-electron", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "electron"], + "sourceMap": true + }, + "include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/shared/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..850df32 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + base: "./", + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true + } +});