PushGo Auto Commit 2026-03-27T01:00:57.339Z
This commit is contained in:
167
src/main/errors.ts
Normal file
167
src/main/errors.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { AppErrorPayload, ErrorCode } from "../shared/contracts";
|
||||
|
||||
type ErrorDefinition = {
|
||||
message: string;
|
||||
actionText?: string;
|
||||
actionType?: string;
|
||||
};
|
||||
|
||||
const ERROR_MAP: Record<ErrorCode, ErrorDefinition> = {
|
||||
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<AppErrorPayload>) {
|
||||
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<AppErrorPayload>): 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");
|
||||
}
|
||||
131
src/main/ipc.ts
Normal file
131
src/main/ipc.ts
Normal file
@@ -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<T>(run: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
102
src/main/main.ts
Normal file
102
src/main/main.ts
Normal file
@@ -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<void> {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
target.webContents.on("did-fail-load", async (_event, errorCode, errorDescription, validatedUrl) => {
|
||||
const html = `
|
||||
<html lang="zh-CN">
|
||||
<body style="font-family: sans-serif; padding: 24px;">
|
||||
<h2>PushGo 页面加载失败</h2>
|
||||
<p>错误码: ${errorCode}</p>
|
||||
<p>原因: ${errorDescription}</p>
|
||||
<p>地址: ${validatedUrl}</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
332
src/main/services/auth-service.ts
Normal file
332
src/main/services/auth-service.ts
Normal file
@@ -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<Exclude<Platform, "gitea">, 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<string, string>();
|
||||
private readonly browserLoginStore = new Set<string>();
|
||||
|
||||
async isLoggedIn(platform: Platform, options?: AuthOptions): Promise<boolean> {
|
||||
const sessionKey = this.resolveSessionKey(platform, options?.repoUrl);
|
||||
return this.tokenStore.has(sessionKey) || this.browserLoginStore.has(sessionKey);
|
||||
}
|
||||
|
||||
async login(platform: Platform, options?: AuthOptions): Promise<void> {
|
||||
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<void> {
|
||||
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<AuthCodeResult> {
|
||||
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("<h2>已取消授权</h2><p>你可以返回 PushGo 重新发起登录。</p>");
|
||||
settle(() => reject(createAppError("AUTH_CANCELLED")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || returnedState !== state) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end("<h2>授权失败</h2><p>请返回 PushGo 重新尝试。</p>");
|
||||
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("<h2>授权成功</h2><p>请返回 PushGo 继续推送。</p>");
|
||||
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
611
src/main/services/git-service.ts
Normal file
611
src/main/services/git-service.ts
Normal file
@@ -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<boolean> {
|
||||
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<AppErrorPayload | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
const localStore = await this.readGitConfig(projectPath, "credential.credentialStore");
|
||||
if (localStore) {
|
||||
return localStore;
|
||||
}
|
||||
|
||||
return this.readGlobalGitConfig("credential.credentialStore");
|
||||
}
|
||||
|
||||
private async readGlobalGitConfig(key: string): Promise<string | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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]@");
|
||||
}
|
||||
}
|
||||
76
src/main/services/push-orchestrator.ts
Normal file
76
src/main/services/push-orchestrator.ts
Normal file
@@ -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<void> {
|
||||
await this.execute(params);
|
||||
}
|
||||
|
||||
async runWithProgress(params: PushRunParams, onProgress?: ProgressReporter): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/main/services/repo-service.ts
Normal file
89
src/main/services/repo-service.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/main/utils/run-command.ts
Normal file
57
src/main/utils/run-command.ts
Normal file
@@ -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<CommandResult> {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
28
src/preload/preload.ts
Normal file
28
src/preload/preload.ts
Normal file
@@ -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);
|
||||
81
src/renderer/App.tsx
Normal file
81
src/renderer/App.tsx
Normal file
@@ -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<string, string> = {
|
||||
"/": "开始前检查",
|
||||
"/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 (
|
||||
<div className="app-shell">
|
||||
<div className="bg-orb bg-orb-a" />
|
||||
<div className="bg-orb bg-orb-b" />
|
||||
|
||||
<header className="topbar">
|
||||
<div className="brand-line">
|
||||
<span className="brand">PushGo</span>
|
||||
<span className="brand-sub">小白一键推送到 GitHub / Gitee / Gitea</span>
|
||||
</div>
|
||||
<div className="step-label">{currentLabel}</div>
|
||||
</header>
|
||||
|
||||
<main className="main-wrap">
|
||||
{notice ? <div className="hint success">{notice}</div> : null}
|
||||
{error ? (
|
||||
<div className="hint error">
|
||||
<div>{error.message}</div>
|
||||
{error.actionType === "open_git_download" ? (
|
||||
<button
|
||||
className="hint-action"
|
||||
onClick={async () => {
|
||||
await window.pushgo.openExternal({ url: "https://git-scm.com/downloads" });
|
||||
}}
|
||||
>
|
||||
{error.actionText ?? "去安装 Git"}
|
||||
</button>
|
||||
) : null}
|
||||
{error.actionType === "open_gcm_download" ? (
|
||||
<button
|
||||
className="hint-action"
|
||||
onClick={async () => {
|
||||
await window.pushgo.openExternal({ url: "https://github.com/git-ecosystem/git-credential-manager" });
|
||||
}}
|
||||
>
|
||||
{error.actionText ?? "安装凭据助手"}
|
||||
</button>
|
||||
) : null}
|
||||
<button className="hint-close" onClick={() => setError(null)}>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<StartupPage />} />
|
||||
<Route path="/select" element={<SelectProjectPage />} />
|
||||
<Route path="/repo" element={<RepoPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/push" element={<PushPage />} />
|
||||
<Route path="/done" element={<CompletePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/renderer/context/app-context.tsx
Normal file
130
src/renderer/context/app-context.tsx
Normal file
@@ -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<AppContextValue | null>(null);
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AppState>(initialState);
|
||||
const [gitChecked, setGitChecked] = useState(false);
|
||||
const [gitWarning, setGitWarning] = useState<AppErrorPayload | null>(null);
|
||||
const [repoValidation, setRepoValidationState] = useState<ValidateUrlResult | null>(null);
|
||||
const [progress, setProgressState] = useState<PushProgressEvent | null>(null);
|
||||
const [pushMeta, setPushMetaState] = useState<PushRunMeta | null>(null);
|
||||
const [error, setErrorState] = useState<AppErrorPayload | null>(null);
|
||||
const [notice, setNoticeState] = useState<string | null>(null);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAppContext must be used inside AppProvider");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
11
src/renderer/env.d.ts
vendored
Normal file
11
src/renderer/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { PushGoApi } from "../shared/bridge";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pushgo: PushGoApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
16
src/renderer/main.tsx
Normal file
16
src/renderer/main.tsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</HashRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
109
src/renderer/pages/auth-page.tsx
Normal file
109
src/renderer/pages/auth-page.tsx
Normal file
@@ -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<never>((_, 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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>登录并授权</h1>
|
||||
<p className="desc">需要完成 {platformLabel} 授权后才能推送代码</p>
|
||||
<p className="desc">若已配置 OAuth,点击后会在浏览器打开授权页;未配置时首次推送会由系统 Git 凭据窗口发起授权。</p>
|
||||
|
||||
<div className={`status ${loggedIn ? "ok" : "warn"}`}>
|
||||
{checking ? "正在检查登录状态..." : loggedIn ? `已登录 ${platformLabel},可以继续` : `当前会话未登录 ${platformLabel}`}
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={() => navigate("/repo")}>返回上一步</button>
|
||||
<button className="primary" onClick={proceed} disabled={checking || working}>
|
||||
{working ? "处理中..." : loggedIn ? "开始推送" : "去登录授权"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
src/renderer/pages/complete-page.tsx
Normal file
57
src/renderer/pages/complete-page.tsx
Normal file
@@ -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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>推送成功</h1>
|
||||
|
||||
<p className="repo-url">{repoUrl}</p>
|
||||
|
||||
{pushMeta?.fallbackToMaster ? <p className="desc">已自动回退到 master 分支完成推送。</p> : null}
|
||||
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={copyRepo}>
|
||||
复制仓库链接
|
||||
</button>
|
||||
<button className="ghost" onClick={openRepo}>
|
||||
在浏览器打开
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => {
|
||||
resetForNextPush();
|
||||
navigate("/select");
|
||||
}}
|
||||
>
|
||||
再次推送
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
122
src/renderer/pages/push-page.tsx
Normal file
122
src/renderer/pages/push-page.tsx
Normal file
@@ -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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>正在推送</h1>
|
||||
|
||||
<ul className="progress-list">
|
||||
{STEP_ORDER.map((item, index) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`progress-item ${index < activeIndex ? "done" : ""} ${index === activeIndex ? "active" : ""}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="actions">
|
||||
{!running ? (
|
||||
<button className="ghost" onClick={() => navigate("/auth")}>
|
||||
返回上一步
|
||||
</button>
|
||||
) : null}
|
||||
<button className="primary" disabled={running} onClick={() => void runPush()}>
|
||||
{running ? "正在推送..." : "重新推送"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
114
src/renderer/pages/repo-page.tsx
Normal file
114
src/renderer/pages/repo-page.tsx
Normal file
@@ -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<Platform, string> = {
|
||||
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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>填写仓库地址</h1>
|
||||
<p className="desc">粘贴仓库地址,例如 https://github.com/name/repo.git 或 https://gitea.com/name/repo.git</p>
|
||||
|
||||
<label className="field-label" htmlFor="repo-url">
|
||||
仓库 URL
|
||||
</label>
|
||||
<input
|
||||
id="repo-url"
|
||||
className="input"
|
||||
placeholder="粘贴仓库地址,例如 https://github.com/name/repo.git 或 https://gitea.com/name/repo.git"
|
||||
value={urlInput}
|
||||
onChange={(event) => {
|
||||
setUrlInput(event.target.value);
|
||||
setRepoValidation(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{repoValidation?.ok ? <p className="validation ok">仓库地址可用</p> : null}
|
||||
{repoValidation && !repoValidation.ok ? (
|
||||
<p className="validation error">{repoValidation.message ?? "仓库地址格式不正确,请检查后重试"}</p>
|
||||
) : null}
|
||||
|
||||
{mismatchPlatform ? (
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
setPlatform(mismatchPlatform);
|
||||
setRepoValidation(null);
|
||||
clearTransientMessages();
|
||||
}}
|
||||
>
|
||||
一键切换到 {PLATFORM_TEXT[mismatchPlatform]}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={() => navigate("/select")}>
|
||||
返回上一步
|
||||
</button>
|
||||
<button className="primary" disabled={!canSubmit || validating} onClick={validateNow}>
|
||||
{validating ? "校验中..." : "下一步"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
src/renderer/pages/select-project-page.tsx
Normal file
80
src/renderer/pages/select-project-page.tsx
Normal file
@@ -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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>选择平台和项目</h1>
|
||||
|
||||
<div className="platform-grid">
|
||||
{PLATFORM_LIST.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`platform-item ${state.platform === item.key ? "active" : ""}`}
|
||||
onClick={() => setPlatform(item.key)}
|
||||
>
|
||||
<span className="platform-name">{item.label}</span>
|
||||
<span className="platform-desc">{item.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="drop-zone browse-only">
|
||||
<p>点击浏览选择项目文件夹</p>
|
||||
<button className="ghost" onClick={browseFolder} disabled={browsing}>
|
||||
{browsing ? "打开中..." : "点击浏览"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{state.projectPath ? (
|
||||
<p className="path-view">
|
||||
已选文件夹:<span>{state.projectPath}</span>
|
||||
<button className="link-like" onClick={browseFolder}>
|
||||
重新选择
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="actions">
|
||||
<button className="ghost" onClick={() => navigate("/")}>
|
||||
返回上一步
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!canNext}
|
||||
onClick={() => {
|
||||
navigate("/repo");
|
||||
}}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
75
src/renderer/pages/startup-page.tsx
Normal file
75
src/renderer/pages/startup-page.tsx
Normal file
@@ -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 (
|
||||
<section className="panel fade-in">
|
||||
<h1>开始前检查</h1>
|
||||
<p className="desc">我们先确认你的电脑已安装 Git,并具备首次授权弹窗能力</p>
|
||||
|
||||
<div className={`status ${state.gitInstalled && !gitWarning ? "ok" : "warn"}`}>{statusText}</div>
|
||||
|
||||
<div className="actions">
|
||||
{!state.gitInstalled ? (
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
await window.pushgo.openExternal({ url: "https://git-scm.com/downloads" });
|
||||
}}
|
||||
>
|
||||
去安装 Git
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button className="ghost" onClick={runCheck} disabled={checking}>
|
||||
{checking ? "检测中..." : "重新检测"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!state.gitInstalled || checking}
|
||||
onClick={() => {
|
||||
navigate("/select");
|
||||
}}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
424
src/renderer/styles.css
Normal file
424
src/renderer/styles.css
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
||||
30
src/shared/bridge.ts
Normal file
30
src/shared/bridge.ts
Normal file
@@ -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<IpcResult<CheckGitResult>>;
|
||||
selectFolder(): Promise<IpcResult<{ path: string | null }>>;
|
||||
validateRepoUrl(payload: { url: string; platform: Platform }): Promise<IpcResult<ValidateUrlResult>>;
|
||||
authStatus(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
|
||||
authLogin(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
|
||||
authLogout(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
|
||||
runPush(payload: PushRunParams): Promise<IpcResult<{ meta: PushRunMeta }>>;
|
||||
openExternal(payload: { url: string }): Promise<IpcResult<{ opened: boolean }>>;
|
||||
onPushProgress(callback: (event: PushProgressEvent) => void): () => void;
|
||||
}
|
||||
12
src/shared/channels.ts
Normal file
12
src/shared/channels.ts
Normal file
@@ -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";
|
||||
87
src/shared/contracts.ts
Normal file
87
src/shared/contracts.ts
Normal file
@@ -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<boolean>;
|
||||
isRepo(projectPath: string): Promise<boolean>;
|
||||
initRepo(projectPath: string): Promise<void>;
|
||||
ensureUserConfig(projectPath: string): Promise<void>;
|
||||
addAll(projectPath: string): Promise<void>;
|
||||
commit(projectPath: string, message: string): Promise<"committed" | "no_changes">;
|
||||
setRemote(projectPath: string, repoUrl: string): Promise<void>;
|
||||
push(projectPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
isLoggedIn(platform: Platform, options?: { repoUrl?: string }): Promise<boolean>;
|
||||
login(platform: Platform, options?: { repoUrl?: string }): Promise<void>;
|
||||
logout(platform: Platform, options?: { repoUrl?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<T> =
|
||||
| {
|
||||
ok: true;
|
||||
data: T;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: AppErrorPayload;
|
||||
};
|
||||
|
||||
export interface PushRunParams {
|
||||
platform: Platform;
|
||||
projectPath: string;
|
||||
repoUrl: string;
|
||||
}
|
||||
Reference in New Issue
Block a user