🎉 首次提交

This commit is contained in:
刘嘉伟 2023-11-27 15:03:25 +08:00
parent f9c49b4ce7
commit 7b2d212549
54 changed files with 3079 additions and 1 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dist-electron
release
*.local
# Editor directories and files
.vscode/.debug.env
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# lockfile
package-lock.json
pnpm-lock.yaml
yarn.lock

6
.prettierrc.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
bracketSpacing: true,
singleQuote: false,
arrowParens: "avoid",
trailingComma: "none"
};

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 8473136
Copyright (c) 2023 刘嘉伟
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

46
electron-builder.json5 Normal file
View File

@ -0,0 +1,46 @@
/**
* @see https://www.electron.build/configuration/configuration
*/
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "priv.liujiawei.frpc.desktop",
"asar": true,
"productName": "Frpc-Desktop",
"directories": {
"output": "release/${version}"
},
"icon": "./public/logo/512x512.png",
"files": [
"dist",
"dist-electron"
],
"mac": {
"target": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
},
}

42
electron/api/config.ts Normal file
View File

@ -0,0 +1,42 @@
import { ipcMain } from "electron";
import { getConfig, saveConfig } from "../storage/config";
import { listVersion } from "../storage/version";
export const initConfigApi = () => {
ipcMain.on("config.saveConfig", async (event, args) => {
saveConfig(args, (err, numberOfUpdated, upsert) => {
event.reply("Config.saveConfig.hook", {
err: err,
numberOfUpdated: numberOfUpdated,
upsert: upsert
});
});
});
ipcMain.on("config.getConfig", async (event, args) => {
getConfig((err, doc) => {
event.reply("Config.getConfig.hook", {
err: err,
data: doc
});
});
});
ipcMain.on("config.versions", event => {
listVersion((err, doc) => {
event.reply("Config.versions.hook", {
err: err,
data: doc
});
});
});
ipcMain.on("config.hasConfig", event => {
getConfig((err, doc) => {
event.reply("Config.getConfig.hook", {
err: err,
data: doc
});
});
});
};

183
electron/api/frpc.ts Normal file
View File

@ -0,0 +1,183 @@
import { app, ipcMain } from "electron";
import { Config, getConfig } from "../storage/config";
import { listProxy } from "../storage/proxy";
import { getVersionById } from "../storage/version";
const fs = require("fs");
const path = require("path");
const { exec, spawn } = require("child_process");
export let frpcProcess = null;
const runningCmd = {
commandPath: null,
configPath: null
};
// const getFrpc = (config: Config) => {
// getVersionById(config.currentVersion, (err, document) => {
// if (!err) {
// }
// });
// };
/**
*
* @param versionId ID
* @param callback
*/
const getFrpcVersionWorkerPath = (
versionId: string,
callback: (workerPath: string) => void
) => {
getVersionById(versionId, (err2, version) => {
if (!err2) {
callback(version["frpcVersionPath"]);
}
});
};
/**
*
*/
export const generateConfig = (
config: Config,
callback: (configPath: string) => void
) => {
listProxy((err3, proxys) => {
if (!err3) {
const proxyToml = proxys.map(m => {
let toml = `
[[proxies]]
name = "${m.name}"
type = "${m.type}"
localIP = "${m.localIp}"
localPort = ${m.localPort}
`;
switch (m.type) {
case "tcp":
toml += `remotePort = ${m.remotePort}`;
break;
case "http":
case "https":
toml += `customDomains=[${m.customDomains.map(m => `"${m}"`)}]`;
break;
default:
break;
}
return toml;
});
let toml = `
serverAddr = "${config.serverAddr}"
serverPort = ${config.serverPort}
auth.method = "${config.authMethod}"
auth.token = "${config.authToken}"
log.to = "frpc.log"
log.level = "debug"
log.maxDays = 3
webServer.addr = "127.0.0.1"
webServer.port = 57400
${proxyToml}
`;
// const configPath = path.join("frp.toml");
const filename = "frp.toml";
fs.writeFile(
path.join(app.getPath("userData"), filename), // 配置文件目录
toml, // 配置文件内容
{ flag: "w" },
err => {
if (!err) {
callback(filename);
}
}
);
}
});
};
/**
* frpc子进程
* @param cwd
* @param commandPath
* @param configPath
*/
const startFrpcProcess = (commandPath: string, configPath: string) => {
const command = `${commandPath} -c ${configPath}`;
frpcProcess = spawn(command, {
cwd: app.getPath("userData"),
shell: true
});
runningCmd.commandPath = commandPath;
runningCmd.configPath = configPath;
frpcProcess.stdout.on("data", data => {
console.log(`命令输出: ${data}`);
});
frpcProcess.stdout.on("error", data => {
console.log(`执行错误: ${data}`);
frpcProcess.kill("SIGINT");
});
};
export const reloadFrpcProcess = () => {
if (frpcProcess && !frpcProcess.killed) {
getConfig((err1, config) => {
if (!err1) {
if (config) {
generateConfig(config, configPath => {
const command = `${runningCmd.commandPath} reload -c ${configPath}`;
console.log("重启", command);
exec(command, {
cwd: app.getPath("userData"),
shell: true
});
});
}
}
});
}
};
export const initFrpcApi = () => {
ipcMain.handle("frpc.running", async (event, args) => {
if (!frpcProcess) {
return false;
} else {
return !frpcProcess.killed;
}
});
ipcMain.on("frpc.start", async (event, args) => {
getConfig((err1, config) => {
if (!err1) {
if (config) {
getFrpcVersionWorkerPath(
config.currentVersion,
(frpcVersionPath: string) => {
generateConfig(config, configPath => {
startFrpcProcess(
path.join(frpcVersionPath, "frpc"),
configPath
);
});
}
);
} else {
event.reply(
"Home.frpc.start.error.hook",
"请先前往设置页面,修改配置后再启动"
);
}
}
});
});
ipcMain.on("frpc.stop", () => {
if (frpcProcess && !frpcProcess.killed) {
console.log("关闭");
frpcProcess.kill();
}
});
};

121
electron/api/github.ts Normal file
View File

@ -0,0 +1,121 @@
import { app, BrowserWindow, ipcMain, net } from "electron";
import { insertVersion } from "../storage/version";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const { download } = require("electron-dl");
const unTarGZ = tarGzPath => {
const tar = require("tar");
const targetPath = path.resolve(path.join(app.getPath("userData"), "frp"));
const unzip = zlib.createGunzip();
const readStream = fs.createReadStream(tarGzPath);
if (!fs.existsSync(unzip)) {
fs.mkdirSync(targetPath, { recursive: true });
}
readStream.pipe(unzip).pipe(
tar.extract({
cwd: targetPath,
filter: filePath => path.basename(filePath) === "frpc"
})
);
return path.join("frp", path.basename(tarGzPath, ".tar.gz"));
// .on("finish", () => {
// console.log("解压完成!");
// });
};
export const initGitHubApi = () => {
// 版本
let versions = [];
const getVersion = versionId => {
return versions.find(f => f.id === versionId);
};
const getAdaptiveAsset = versionId => {
const version = getVersion(versionId);
const arch = process.arch;
const platform = process.platform;
const { assets } = version;
const asset = assets.find(
f => f.name.indexOf(platform) != -1 && f.name.indexOf(arch) != -1
);
return asset;
};
/**
* github上的frp所有版本
*/
ipcMain.on("github.getFrpVersions", async event => {
const request = net.request({
method: "get",
url: "https://api.github.com/repos/fatedier/frp/releases"
});
request.on("response", response => {
let responseData: Buffer = Buffer.alloc(0);
response.on("data", (data: Buffer) => {
responseData = Buffer.concat([responseData, data]);
});
response.on("end", () => {
versions = JSON.parse(responseData.toString());
// const borderContent: Electron.WebContents =
// BrowserWindow.getFocusedWindow().webContents;
const downloadPath = path.join(app.getPath("userData"), "download");
const returnVersionsData = versions
.filter(f => getAdaptiveAsset(f.id))
.map(m => {
const asset = getAdaptiveAsset(m.id);
if (asset) {
// const absPath = `${downloadPath}/${asset.id}_${asset.name}`;
const absPath = `${downloadPath}/${asset.name}`;
m.download_completed = fs.existsSync(absPath);
} else {
console.log("buzhicih");
}
// m.download_completed = fs.existsSync(
// `${downloadPath}/${asset.id}_${asset.name}`
// );
return m;
});
event.reply("Download.frpVersionHook", returnVersionsData);
});
});
request.end();
});
/**
*
*/
ipcMain.on("github.download", async (event, args) => {
const version = getVersion(args);
const asset = getAdaptiveAsset(args);
const { browser_download_url } = asset;
// 数据目录
await download(BrowserWindow.getFocusedWindow(), browser_download_url, {
filename: `${asset.name}`,
directory: path.join(app.getPath("userData"), "download"),
onProgress: progress => {
event.reply("Download.frpVersionDownloadOnProgress", {
id: args,
progress: progress
});
},
onCompleted: () => {
const frpcVersionPath = unTarGZ(
path.join(
path.join(app.getPath("userData"), "download"),
`${asset.name}`
)
);
version["frpcVersionPath"] = frpcVersionPath;
insertVersion(version, (err, document) => {
if (!err) {
event.reply("Download.frpVersionDownloadOnCompleted", args);
version.download_completed = true;
}
});
}
});
});
};

30
electron/api/logger.ts Normal file
View File

@ -0,0 +1,30 @@
import { app, ipcMain } from "electron";
const fs = require("fs");
const path = require("path");
export const initLoggerApi = () => {
const logPath = path.join(app.getPath("userData"), "frpc.log");
const readLogger = (callback: (content: string) => void) => {
fs.readFile(logPath, "utf-8", (error, data) => {
if (!error) {
callback(data);
}
});
};
ipcMain.on("logger.getLog", async (event, args) => {
readLogger(content => {
event.reply("Logger.getLog.hook", content);
});
});
ipcMain.on("logger.update", (event, args) => {
fs.watch(logPath, (eventType, filename) => {
if (eventType === "change") {
readLogger(content => {
event.reply("Logger.update.hook", content);
});
}
});
});
};

69
electron/api/proxy.ts Normal file
View File

@ -0,0 +1,69 @@
import { ipcMain } from "electron";
import {
deleteProxyById,
getProxyById,
insertProxy,
listProxy,
updateProxyById
} from "../storage/proxy";
import { reloadFrpcProcess } from "./frpc";
export const initProxyApi = () => {
ipcMain.on("proxy.getProxys", async (event, args) => {
listProxy((err, documents) => {
event.reply("Proxy.getProxys.hook", {
err: err,
data: documents
});
});
});
ipcMain.on("proxy.insertProxy", async (event, args) => {
delete args["_id"];
insertProxy(args, (err, documents) => {
if (!err) {
reloadFrpcProcess()
}
event.reply("Proxy.insertProxy.hook", {
err: err,
data: documents
});
});
});
ipcMain.on("proxy.deleteProxyById", async (event, args) => {
deleteProxyById(args, (err, documents) => {
if (!err) {
reloadFrpcProcess()
}
event.reply("Proxy.deleteProxyById.hook", {
err: err,
data: documents
});
});
});
ipcMain.on("proxy.getProxyById", async (event, args) => {
getProxyById(args, (err, documents) => {
event.reply("Proxy.getProxyById.hook", {
err: err,
data: documents
});
});
});
ipcMain.on("proxy.updateProxy", async (event, args) => {
if (!args._id) return;
updateProxyById(args, (err, documents) => {
if (!err) {
reloadFrpcProcess()
}
event.reply("Proxy.updateProxy.hook", {
err: err,
data: documents
});
});
});
};

11
electron/electron-env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
VSCODE_DEBUG?: 'true'
DIST_ELECTRON: string
DIST: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
}

136
electron/main/index.ts Normal file
View File

@ -0,0 +1,136 @@
import { app, BrowserWindow, ipcMain, shell } from "electron";
import { release } from "node:os";
import { join } from "node:path";
import { initGitHubApi } from "../api/github";
import { initConfigApi } from "../api/config";
import { initProxyApi } from "../api/proxy";
import { initFrpcApi } from "../api/frpc";
import { initLoggerApi } from "../api/logger";
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.js > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
process.env.DIST_ELECTRON = join(__dirname, "..");
process.env.DIST = join(process.env.DIST_ELECTRON, "../dist");
process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL
? join(process.env.DIST_ELECTRON, "../public")
: process.env.DIST;
// Disable GPU Acceleration for Windows 7
if (release().startsWith("6.1")) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications
if (process.platform === "win32") app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
// Remove electron security warnings
// This warning only shows in development mode
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
let win: BrowserWindow | null = null;
// Here, you can also use other preload
const preload = join(__dirname, "../preload/index.js");
const url = process.env.VITE_DEV_SERVER_URL;
const indexHtml = join(process.env.DIST, "index.html");
async function createWindow() {
win = new BrowserWindow({
title: "Main window",
icon: join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false
}
});
if (process.env.VITE_DEV_SERVER_URL) {
// electron-vite-vue#298
win.loadURL(url);
// Open devTool if the app is not packaged
win.webContents.openDevTools();
} else {
win.loadFile(indexHtml);
}
// Test actively push message to the Electron-Renderer
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:")) shell.openExternal(url);
return { action: "deny" };
});
// 隐藏菜单栏
const { Menu } = require("electron");
Menu.setApplicationMenu(null);
// hide menu for Mac
if (process.platform !== "darwin") {
app.dock.hide();
}
// win.webContents.on('will-navigate', (event, url) => { }) #344
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
win = null;
if (process.platform !== "darwin") app.quit();
});
app.on("second-instance", () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
} else {
createWindow();
}
});
// New window example arg: new windows url
ipcMain.handle("open-win", (_, arg) => {
const childWindow = new BrowserWindow({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false
}
});
if (process.env.VITE_DEV_SERVER_URL) {
childWindow.loadURL(`${url}#${arg}`);
} else {
childWindow.loadFile(indexHtml, { hash: arg });
}
});
initGitHubApi();
initConfigApi();
initProxyApi();
initFrpcApi();
initLoggerApi();

127
electron/main/index111.ts Normal file
View File

@ -0,0 +1,127 @@
import { app, BrowserWindow, ipcMain, shell } from "electron";
import { release } from "node:os";
import { join } from "node:path";
import { initGitHubApi } from "../api/github";
import { initConfigApi } from "../api/config";
import { initProxyApi } from "../api/proxy";
import { initFrpcApi } from "../api/frpc";
import { initLoggerApi } from "../api/logger";
// The built directory structure
//
// ├─┬ dist-electron
// │ ├─┬ main
// │ │ └── index.js > Electron-Main
// │ └─┬ preload
// │ └── index.js > Preload-Scripts
// ├─┬ dist
// │ └── index.html > Electron-Renderer
//
process.env.DIST_ELECTRON = join(__dirname, "..");
process.env.DIST = join(process.env.DIST_ELECTRON, "../dist");
process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL
? join(process.env.DIST_ELECTRON, "../public")
: process.env.DIST;
// Disable GPU Acceleration for Windows 7
if (release().startsWith("6.1")) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications
if (process.platform === "win32") app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
// Remove electron security warnings
// This warning only shows in development mode
// Read more on https://www.electronjs.org/docs/latest/tutorial/security
// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
let win: BrowserWindow | null = null;
// Here, you can also use other preload
const preload = join(__dirname, '../preload/index.js')
const url = process.env.VITE_DEV_SERVER_URL;
const indexHtml = join(process.env.DIST, "index.html");
async function createWindow() {
win = new BrowserWindow({
width: 800,
height: 600,
resizable: false,
title: "Frpc Desktop",
icon: join(process.env.VITE_PUBLIC, "favicon.ico"),
webPreferences: {
preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: true,
contextIsolation: false
}
});
if (process.env.VITE_DEV_SERVER_URL) {
// electron-vite-vue#298
win.loadURL(url);
// Open devTool if the app is not packaged
win.webContents.openDevTools();
} else {
win.loadFile(indexHtml);
}
// Make all links open with the browser, not with the application
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:")) shell.openExternal(url);
return { action: "deny" };
});
// win.webContents.on('will-navigate', (event, url) => { }) #344
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
win = null;
if (process.platform !== "darwin") app.quit();
});
app.on("second-instance", () => {
if (win) {
// Focus on the main window if the user tried to open another
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("activate", () => {
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length) {
allWindows[0].focus();
} else {
createWindow();
}
});
// New window example arg: new windows url
ipcMain.handle('open-win', (_, arg) => {
const childWindow = new BrowserWindow({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
})
if (process.env.VITE_DEV_SERVER_URL) {
childWindow.loadURL(`${url}#${arg}`)
} else {
childWindow.loadFile(indexHtml, { hash: arg })
}
})
initGitHubApi();
initConfigApi();
initProxyApi();
initFrpcApi();
initLoggerApi();

92
electron/preload/index.ts Normal file
View File

@ -0,0 +1,92 @@
function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) {
return new Promise((resolve) => {
if (condition.includes(document.readyState)) {
resolve(true)
} else {
document.addEventListener('readystatechange', () => {
if (condition.includes(document.readyState)) {
resolve(true)
}
})
}
})
}
const safeDOM = {
append(parent: HTMLElement, child: HTMLElement) {
if (!Array.from(parent.children).find(e => e === child)) {
return parent.appendChild(child)
}
},
remove(parent: HTMLElement, child: HTMLElement) {
if (Array.from(parent.children).find(e => e === child)) {
return parent.removeChild(child)
}
},
}
/**
* https://tobiasahlin.com/spinkit
* https://connoratherton.com/loaders
* https://projects.lukehaas.me/css-loaders
* https://matejkustec.github.io/SpinThatShit
*/
function useLoading() {
const className = `loaders-css__square-spin`
const styleContent = `
@keyframes square-spin {
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
}
.${className} > div {
animation-fill-mode: both;
width: 50px;
height: 50px;
background: #fff;
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
}
.app-loading-wrap {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #282c34;
z-index: 9;
}
`
const oStyle = document.createElement('style')
const oDiv = document.createElement('div')
oStyle.id = 'app-loading-style'
oStyle.innerHTML = styleContent
oDiv.className = 'app-loading-wrap'
oDiv.innerHTML = `<div class="${className}"><div></div></div>`
return {
appendLoading() {
safeDOM.append(document.head, oStyle)
safeDOM.append(document.body, oDiv)
},
removeLoading() {
safeDOM.remove(document.head, oStyle)
safeDOM.remove(document.body, oDiv)
},
}
}
// ----------------------------------------------------------------------
const { appendLoading, removeLoading } = useLoading()
domReady().then(appendLoading)
window.onmessage = (ev) => {
ev.data.payload === 'removeLoading' && removeLoading()
}
setTimeout(removeLoading, 4999)

View File

@ -0,0 +1,37 @@
import Datastore from "nedb";
import path from "path";
import { app } from "electron";
const configDB = new Datastore({
autoload: true,
filename: path.join(app.getPath("userData"), "config.db")
});
export type Config = {
currentVersion: any;
serverAddr: string;
serverPort: number;
authMethod: string;
authToken: string;
};
/**
*
*/
export const saveConfig = (
document: Config,
cb?: (err: Error | null, numberOfUpdated: number, upsert: boolean) => void
) => {
document["_id"] = "1";
configDB.update({ _id: "1" }, document, { upsert: true }, cb);
};
/**
*
* @param cb
*/
export const getConfig = (
cb: (err: Error | null, document: Config) => void
) => {
configDB.findOne({ _id: "1" }, cb);
};

74
electron/storage/proxy.ts Normal file
View File

@ -0,0 +1,74 @@
import Datastore from "nedb";
import path from "path";
import { app } from "electron";
const proxyDB = new Datastore({
autoload: true,
filename: path.join(app.getPath("userData"), "proxy.db")
});
export type Proxy = {
_id: string;
name: string;
type: string;
localIp: string;
localPort: number;
remotePort: number;
customDomains: string[];
};
/**
*
* @param proxy
* @param cb
*/
export const insertProxy = (
proxy: Proxy,
cb?: (err: Error | null, document: Proxy) => void
) => {
console.log("新增", proxy);
proxyDB.insert(proxy, cb);
};
/**
*
* @param _id
* @param cb
*/
export const deleteProxyById = (
_id: string,
cb?: (err: Error | null, n: number) => void
) => {
proxyDB.remove({ _id: _id }, cb);
};
/**
*
*/
export const updateProxyById = (
proxy: Proxy,
cb?: (err: Error | null, numberOfUpdated: number, upsert: boolean) => void
) => {
proxyDB.update({ _id: proxy._id }, proxy, {}, cb);
};
/**
*
* @param cb
*/
export const listProxy = (
callback: (err: Error | null, documents: Proxy[]) => void
) => {
proxyDB.find({}, callback);
};
/**
* id查询
* @param id
* @param callback
*/
export const getProxyById = (
id: string,
callback: (err: Error | null, document: Proxy) => void
) => {
proxyDB.findOne({ _id: id }, callback);
};

View File

@ -0,0 +1,38 @@
import Datastore from "nedb";
import path from "path";
import { Proxy } from "./proxy";
import { app } from "electron";
const versionDB = new Datastore({
autoload: true,
filename: path.join(app.getPath("userData"), "version.db")
});
/**
*
* @param proxy
* @param cb
*/
export const insertVersion = (
version: any,
cb?: (err: Error | null, document: any) => void
) => {
versionDB.insert(version, cb);
};
/**
*
* @param cb
*/
export const listVersion = (
callback: (err: Error | null, documents: any[]) => void
) => {
versionDB.find({}, callback);
};
export const getVersionById = (
id: string,
callback: (err: Error | null, document: any) => void
) => {
versionDB.findOne({ id: id }, callback);
};

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo/32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
<title>Frpc Desktop</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

62
package.json Normal file
View File

@ -0,0 +1,62 @@
{
"name": "Frpc-Desktop",
"version": "1.0.0",
"main": "dist-electron/main/index.js",
"description": "一个frpc桌面客户端",
"author": "刘嘉伟 <8473136@qq.com>",
"license": "MIT",
"private": true,
"keywords": [
"electron",
"rollup",
"vite",
"vue3",
"vue"
],
"debug": {
"env": {
"VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/"
}
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && electron-builder",
"build:win": "vue-tsc --noEmit && vite build && electron-builder -w",
"preview": "vite preview",
"electron:generate-icons": "electron-icon-builder --input=./public/logo.png --output=build --flatten"
},
"devDependencies": {
"@iconify/vue": "^4.1.1",
"@types/nedb": "^1.8.16",
"@vitejs/plugin-vue": "^4.3.3",
"@vue/eslint-config-prettier": "^7.1.0",
"@vueuse/core": "^9.13.0",
"autoprefixer": "^10.4.15",
"cssnano": "^6.0.1",
"electron": "^26.0.0",
"electron-builder": "^24.6.3",
"element-plus": "^2.4.2",
"eslint-plugin-prettier": "^4.2.1",
"moment": "^2.29.4",
"nedb": "^1.8.0",
"node-cmd": "^5.0.0",
"prettier": "^2.8.8",
"sass": "^1.66.1",
"sass-loader": "^13.3.2",
"tailwindcss": "^3.3.3",
"tree-kill": "^1.2.2",
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-electron": "^0.15.3",
"vite-plugin-electron-renderer": "^0.14.5",
"vue-tsc": "^1.8.8",
"vue-types": "^5.1.1",
"electron-icon-builder": "^2.0.1",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"dependencies": {
"electron-dl": "^3.5.1",
"tar": "^6.2.0"
}
}

8
postcss.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {})
}
};

BIN
public/logo/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/logo/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/logo/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

BIN
public/logo/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

BIN
public/logo/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/logo/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

BIN
public/logo/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

BIN
public/logo/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/logo/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

23
src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<script lang="ts">
import { defineComponent } from "vue";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
export default defineComponent({
name: "app",
components: {
[ElConfigProvider.name]: ElConfigProvider
},
computed: {
currentLocale() {
return zhCn;
}
}
});
</script>
<template>
<el-config-provider :locale="currentLocale">
<router-view />
</el-config-provider>
</template>

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import { computed, defineComponent } from "vue";
import router from "@/router";
defineComponent({
name: "AppMain"
});
const currentRoute = computed(() => {
return router.currentRoute.value;
});
</script>
<template>
<div class="main-container">
<router-view v-slot="{ Component }">
<keep-alive v-if="currentRoute.meta['keepAlive']">
<component :is="Component" />
</keep-alive>
<component v-else :is="Component" />
</router-view>
</div>
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import { computed, defineComponent } from "vue";
import router from "@/router";
defineComponent({
name: "Breadcrumb"
});
const currentRoute = computed(() => {
return router.currentRoute.value;
});
</script>
<template>
<div class="flex justify-between">
<div class="breadcrumb">
<Icon
class="inline-block mr-2"
:icon="currentRoute.meta['icon'] as string"
/>
<span>{{ currentRoute.meta["title"] }}</span>
</div>
<div class="right">
<slot></slot>
</div>
</div>
</template>

View File

@ -0,0 +1,54 @@
<script lang="ts" setup>
import { computed, defineComponent, onMounted, ref } from "vue";
import { Icon } from "@iconify/vue";
import router from "@/router";
import { RouteRecordRaw } from "vue-router";
defineComponent({
name: "AppMain"
});
const routes = ref<Array<RouteRecordRaw>>([]);
const currentRoute = computed(() => {
return router.currentRoute.value;
});
/**
* 菜单切换
* @param mi 菜单索引
*/
const handleMenuChange = (route: RouteRecordRaw) => {
if (currentRoute.value.name === route.name) {
return;
}
router.push({
path: route.path
});
};
onMounted(() => {
routes.value = router.options.routes[0].children?.filter(
f => !f.meta?.hidden
) as Array<RouteRecordRaw>;
});
</script>
<template>
<div class="left-menu-container drop-shadow-xl">
<div class="logo-container">
<img src="/logo/64x64.png" class="logo" alt="Logo" />
</div>
<ul class="menu-container">
<li
class="menu"
:class="currentRoute?.name === r.name ? 'menu-selected' : ''"
v-for="r in routes"
:key="r.name"
@click="handleMenuChange(r)"
>
<Icon :icon="r?.meta?.icon as string" />
</li>
</ul>
</div>
</template>

15
src/layout/index.vue Normal file
View File

@ -0,0 +1,15 @@
<script lang="ts" setup>
import { defineComponent } from "vue";
import AppMain from "./compoenets/AppMain.vue";
import LeftMenu from "./compoenets/LeftMenu.vue";
defineComponent({
name: "Layout"
});
</script>
<template>
<div class="w-full h-full flex">
<left-menu />
<app-main />
</div>
</template>

13
src/main.ts Normal file
View File

@ -0,0 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./styles/index.scss";
import ElementPlus from "element-plus";
createApp(App)
.use(router)
.use(ElementPlus)
.mount("#app")
.$nextTick(() => {
postMessage({ payload: "removeLoading" }, "*");
});

82
src/router/index.ts Normal file
View File

@ -0,0 +1,82 @@
import { createRouter, createWebHistory, createWebHashHistory, RouteRecordRaw } from "vue-router";
const Layout = () => import("@/layout/index.vue");
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "Index",
component: Layout,
redirect: "/home",
children: [
{
path: "/home",
name: "Home",
meta: {
title: "连接",
icon: "material-symbols:rocket-launch-rounded",
keepAlive: true
},
component: () => import("@/views/home/index.vue")
},
{
path: "/proxy",
name: "Proxy",
meta: {
title: "穿透列表",
icon: "material-symbols:cloud",
keepAlive: true
},
component: () => import("@/views/proxy/index.vue")
},
{
path: "/download",
name: "Download",
meta: {
title: "版本下载",
icon: "material-symbols:download-2",
keepAlive: true
},
component: () => import("@/views/download/index.vue")
},
{
path: "/config",
name: "Config",
meta: {
title: "系统配置",
icon: "material-symbols:settings",
keepAlive: true
},
component: () => import("@/views/config/index.vue")
},
{
path: "/logger",
name: "Logger",
meta: {
title: "日志",
icon: "material-symbols:file-copy-sharp",
keepAlive: true,
hidden: false
},
component: () => import("@/views/logger/index.vue")
}
// {
// path: "/comingSoon",
// name: "ComingSoon",
// meta: {
// title: "敬请期待",
// icon: "material-symbols:file-copy-sharp",
// keepAlive: false,
// hidden: true
// },
// component: () => import("@/views/comingSoon/index.vue")
// }
]
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;

9
src/styles/element.scss Normal file
View File

@ -0,0 +1,9 @@
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'primary': (
'base': #5F3BB0,
),
),
);
@use "element-plus/theme-chalk/src/index.scss" as *;

21
src/styles/index.scss Normal file
View File

@ -0,0 +1,21 @@
@import "reset";
@import "layout";
@import "element";
@import "tailwind";
/* 自定义全局 CssVar */
:root {
--pure-transition-duration: 0.016s;
}
/* 灰色模式 */
.html-grey {
filter: grayscale(100%);
}
/* 色弱模式 */
.html-weakness {
filter: invert(80%);
}
html {
}

86
src/styles/layout.scss Normal file
View File

@ -0,0 +1,86 @@
$main-bg: #F3F3F3;
$primary-color: #5F3BB0;
.main-container {
background: $main-bg;
width: calc(100% - 60px);
height: 100vh;
padding: 20px;
.main {
height: 100%;
width: 100%;
overflow: hidden;
}
.app-container-breadcrumb {
height: calc(100% - 50px);
overflow-y: auto;
overflow-x: hidden;
}
.breadcrumb {
color: $primary-color;
font-size: 36px;
height: 50px;
svg {
vertical-align: top;
}
span {
vertical-align: top;
color: black;
font-size: 18px;
font-weight: bold;
transform: translateY(5px);
display: inline-block;
}
}
}
.left-menu-container {
background: #fff;
width: 60px;
height: 100vh;
.menu-container {
.menu {
display: flex;
height: 40px;
justify-content: center;
align-items: center;
font-size: 20px;
color: #ADADAD;
margin-bottom: 10px;
}
.menu-selected {
background: #EBE6F7;
color: $primary-color;
}
.menu:hover {
color: $primary-color;
cursor: pointer;
}
}
}
.logo-container {
height: 60px;
display: flex;
justify-content: center;
align-items: center;
.logo {
width: 60%;
}
}
.primary-text {
color: $primary-color;
}

275
src/styles/reset.scss Normal file
View File

@ -0,0 +1,275 @@
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
*{
-webkit-touch-callout:none; /*系统默认菜单被禁用*/
-webkit-user-select:none; /*webkit浏览器*/
-khtml-user-select:none; /*早期浏览器*/
-moz-user-select:none;/*火狐*/
-ms-user-select:none; /*IE10*/
user-select:none;
}
input{
-webkit-user-select:auto; /*webkit浏览器*/
}
textarea{
-webkit-user-select:auto; /*webkit浏览器*/
}
#app {
width: 100%;
height: 100%;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
width: 100%;
height: 100%;
box-sizing: border-box;
}
body {
margin: 0;
line-height: inherit;
width: 100%;
height: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizelegibility;
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
text-decoration: underline dotted;
}
a {
color: inherit;
text-decoration: inherit;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: inherit;
color: inherit;
margin: 0;
padding: 0;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
background-image: none;
}
:-moz-focusring {
outline: auto;
}
:-moz-ui-invalid {
box-shadow: none;
}
progress {
vertical-align: baseline;
}
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
}
::-webkit-file-upload-button {
font: inherit;
}
summary {
display: list-item;
}
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
input::placeholder,
textarea::placeholder {
opacity: 1;
color: #9ca3af;
}
button,
[role="button"] {
cursor: pointer;
}
:disabled {
cursor: default;
}
//img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
}
img,
video {
max-width: 100%;
height: auto;
}
[hidden] {
display: none;
}
.dark {
color-scheme: dark;
}
label {
font-weight: 700;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
a:focus,
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
div:focus {
outline: none;
}
.clearfix {
&::after {
visibility: hidden;
display: block;
font-size: 0;
content: " ";
clear: both;
height: 0;
}
}

3
src/styles/tailwind.scss Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module 'element-plus/dist/locale/zh-cn.mjs' {
const zhLocale: any;
export default zhLocale;
}
declare module 'element-plus/dist/locale/en.mjs' {
const enLocale: any;
export default enLocale;
}

22
src/types/shims-tsx.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
import Vue, { VNode } from "vue";
declare module "*.tsx" {
import Vue from "compatible-vue";
export default Vue;
}
declare global {
namespace JSX {
interface Element extends VNode {}
interface ElementClass extends Vue {}
interface ElementAttributesProperty {
$props: any;
}
interface IntrinsicElements {
[elem: string]: any;
}
interface IntrinsicAttributes {
[elem: string]: any;
}
}
}

10
src/types/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "*.scss" {
const scss: Record<string, string>;
export default scss;
}

19
src/utils/clone.ts Normal file
View File

@ -0,0 +1,19 @@
export function clone<T>(value: T): T {
/** 空 */
if (!value) return value;
/** 数组 */
if (Array.isArray(value))
return value.map(item => clone(item)) as unknown as T;
/** 日期 */
if (value instanceof Date) return new Date(value) as unknown as T;
/** 普通对象 */
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]: [string, any]) => {
return [k, clone(v)];
})
) as unknown as T;
}
/** 基本类型 */
return value;
}

View File

@ -0,0 +1,15 @@
<script lang="ts" setup>
import { defineComponent } from "vue";
defineComponent({
name: "ComingSoon"
});
</script>
<template>
<div
class="w-full h-full bg-white rounded p-2 overflow-hidden drop-shadow-xl flex justify-center items-center"
>
<el-empty description="敬请期待" />
</div>
</template>

203
src/views/config/index.vue Normal file
View File

@ -0,0 +1,203 @@
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref, reactive } from "vue";
import { ipcRenderer } from "electron";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import Breadcrumb from "@/layout/compoenets/Breadcrumb.vue";
import { useDebounceFn } from "@vueuse/core";
import { clone } from "@/utils/clone";
import { Icon } from "@iconify/vue";
defineComponent({
name: "Config"
});
type Config = {
currentVersion: string;
serverAddr: string;
serverPort: number;
authMethod: string;
authToken: string;
};
type Version = {
id: string;
name: string;
}
const formData = ref<Config>({
currentVersion: "",
serverAddr: "",
serverPort: 7000,
authMethod: "",
authToken: ""
});
const loading = ref(1);
const rules = reactive<FormRules>({
currentVersion: [{ required: true, message: "请选择版本", trigger: "blur" }],
serverAddr: [
{ required: true, message: "请输入服务端地址", trigger: "blur" },
{
pattern: /^[\w-]+(\.[\w-]+)+$/,
message: "请输入正确的服务端地址",
trigger: "blur"
}
],
serverPort: [
{ required: true, message: "请输入服务器端口", trigger: "blur" }
],
// authMethod: [{ required: true, message: "", trigger: "blur" }],
authToken: [{ required: true, message: "请输入token值 ", trigger: "blur" }]
});
const versions = ref<Array<Version>>([]);
const formRef = ref<FormInstance>();
const handleSubmit = useDebounceFn(() => {
if (!formRef.value) return;
formRef.value.validate(valid => {
if (valid) {
loading.value = 1;
const data = clone(formData.value);
ipcRenderer.send("config.saveConfig", data);
}
});
}, 300);
const handleLoadVersions = () => {
ipcRenderer.send("config.versions");
};
onMounted(() => {
ipcRenderer.send("config.getConfig");
handleLoadVersions();
ipcRenderer.on("Config.getConfig.hook", (event, args) => {
const { err, data } = args;
if (!err) {
if (data) {
formData.value = data;
}
}
loading.value--;
});
ipcRenderer.on("Config.saveConfig.hook", (event, args) => {
ElMessage({
type: "success",
message: "保存成功"
});
loading.value--;
});
ipcRenderer.on("Config.versions.hook", (event, args) => {
const { err, data } = args;
if (!err) {
versions.value = data;
}
});
});
onUnmounted(() => {
ipcRenderer.removeAllListeners("Config.getConfig.hook");
ipcRenderer.removeAllListeners("Config.saveConfig.hook");
ipcRenderer.removeAllListeners("Config.versions.hook");
});
</script>
<template>
<div>
<breadcrumb />
<div
class="w-full bg-white p-4 rounded drop-shadow-lg"
v-loading="loading > 0"
>
<el-form
:model="formData"
:rules="rules"
label-position="right"
ref="formRef"
label-width="120"
>
<el-row :gutter="10">
<el-col :span="24">
<el-form-item label="选择版本:" prop="currentVersion">
<el-select
v-model="formData.currentVersion"
class="w-full"
clearable
>
<el-option
v-for="v in versions"
:key="v.id"
:label="v.name"
:value="v.id"
></el-option>
</el-select>
<div class="w-full flex justify-end">
<el-button type="text" @click="handleLoadVersions">
<Icon class="mr-1" icon="material-symbols:refresh-rounded" />
手动刷新
</el-button>
<el-button
type="text"
@click="$router.replace({ name: 'Download' })"
>
<Icon class="mr-1" icon="material-symbols:download-2" />
点击这里去下载
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="服务端地址:" prop="serverAddr">
<el-input
v-model="formData.serverAddr"
placeholder="127.0.0.1"
></el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="服务端端口:" prop="serverPort">
<el-input-number
placeholder="7000"
v-model="formData.serverPort"
:min="0"
:max="65535"
controls-position="right"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="验证方式:" prop="authMethod">
<el-select
v-model="formData.authMethod"
placeholder="请选择验证方式"
clearable
>
<el-option label="token" value="token"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" v-if="formData.authMethod === 'token'">
<el-form-item label="token" prop="authToken">
<el-input
placeholder="token"
type="password"
v-model="formData.authToken"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item>
<el-button plain type="primary" @click="handleSubmit">
<Icon class="mr-1" icon="material-symbols:save" />
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,116 @@
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import { ipcRenderer } from "electron";
import moment from "moment";
import Breadcrumb from "@/layout/compoenets/Breadcrumb.vue";
defineComponent({
name: "Download"
});
type Version = {
id: string;
name: string;
published_at: string;
download_completed: boolean;
};
const versions = ref<Array<Version>>([]);
const loading = ref(1);
const downloadPercentage = ref(0);
const downloading = ref<Map<string, number>>(new Map<string, number>());
/**
* 获取版本
*/
const handleLoadVersions = () => {
ipcRenderer.send("github.getFrpVersions");
};
/**
* 下载
* @param version
*/
const handleDownload = (version: Version) => {
ipcRenderer.send("github.download", version.id);
downloading.value.set(version.id, 0);
};
const handleInitDownloadHook = () => {
ipcRenderer.on("Download.frpVersionHook", (event, args) => {
loading.value--;
versions.value = args as Array<Version>;
});
//
ipcRenderer.on("Download.frpVersionDownloadOnProgress", (event, args) => {
const { id, progress } = args;
downloading.value.set(
id,
Number(Number(progress.percent * 100).toFixed(2))
);
});
ipcRenderer.on("Download.frpVersionDownloadOnCompleted", (event, args) => {
downloading.value.delete(args);
const version: Version | undefined = versions.value.find(
f => f.id === args
);
if (version) {
version.download_completed = true;
}
});
};
onMounted(() => {
handleLoadVersions();
handleInitDownloadHook();
// ipcRenderer.invoke("process").then((r: any) => {
// console.log(r, "rrr");
// });
});
onUnmounted(() => {
ipcRenderer.removeAllListeners("Download.frpVersionDownloadOnProgress");
ipcRenderer.removeAllListeners("Download.frpVersionDownloadOnCompleted");
ipcRenderer.removeAllListeners("Download.frpVersionHook");
});
</script>
<template>
<div class="main">
<breadcrumb />
<div class="app-container-breadcrumb" v-loading="loading > 0">
<div
class="w-full bg-white mb-4 rounded p-4 drop-shadow-lg flex justify-between items-center"
v-for="version in versions"
:key="version.id"
>
<div class="left">
<div class="mb-2">
<el-tag>{{ version.name }}</el-tag>
</div>
<div class="text-sm">
发布时间<span class="text-gray-00">{{
moment(version.published_at).format("YYYY-MM-DD HH:mm:ss")
}}</span>
</div>
</div>
<div class="right">
<span
class="primary-text text-sm font-bold"
v-if="version.download_completed"
>已下载</span
>
<template v-else>
<div class="w-32" v-if="downloading.has(version.id)">
<el-progress
:percentage="downloading.get(version.id)"
:text-inside="false"
/>
</div>
<el-button v-else @click="handleDownload(version)">下载</el-button>
</template>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

199
src/views/home/index.vue Normal file
View File

@ -0,0 +1,199 @@
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import Breadcrumb from "@/layout/compoenets/Breadcrumb.vue";
import { ipcRenderer } from "electron";
import { Icon } from "@iconify/vue";
import { ElMessageBox } from "element-plus";
import router from "@/router";
defineComponent({
name: "Home"
});
const running = ref(false);
const handleStartFrpc = () => {
ipcRenderer.send("frpc.start");
};
const handleStopFrpc = () => {
ipcRenderer.send("frpc.stop");
};
const handleButtonClick = () => {
if (running.value) {
handleStopFrpc();
} else {
handleStartFrpc();
}
};
onMounted(() => {
setInterval(() => {
ipcRenderer.invoke("frpc.running").then(data => {
running.value = data;
});
}, 300);
ipcRenderer.on("Home.frpc.start.error.hook", (event, args) => {
if (args) {
ElMessageBox.alert(args, "启动失败", {
showCancelButton: true,
cancelButtonText: "取消",
confirmButtonText: "去设置"
}).then(() => {
router.replace({
name: "Config"
});
});
}
});
});
onUnmounted(() => {
ipcRenderer.removeAllListeners("Home.frpc.start.error.hook");
});
</script>
<template>
<div class="main">
<breadcrumb />
<div class="app-container-breadcrumb">
<div
class="w-full h-full bg-white p-4 rounded drop-shadow-lg overflow-y-auto flex justify-center items-center"
>
<div class="flex">
<div
class="w-40 h-40 border-[#5A3DAA] text-[#5A3DAA] border-4 rounded-full flex justify-center items-center text-[100px] relative"
>
<transition name="fade">
<div
v-show="running"
class="z-0 rounded-full opacity-20 left-circle bg-[#5A3DAA] w-full h-full animation-rotate-1"
/>
</transition>
<transition name="fade">
<div
v-show="running"
class="z-0 rounded-full opacity-20 right-circle top-[10px] bg-[#5A3DAA] w-full h-full animation-rotate-2"
/>
</transition>
<transition name="fade">
<div
v-show="running"
class="z-0 rounded-full opacity-20 top-circle bg-[#5A3DAA] w-full h-full animation-rotate-3"
/>
</transition>
<div
class="bg-white z-10 w-full h-full bg-white absolute rounded-full flex justify-center items-center"
>
<Icon icon="material-symbols:rocket-launch-rounded" />
</div>
</div>
<div class="flex justify-center items-center">
<div class="pl-8 h-28 w-52 flex flex-col justify-between">
<transition name="fade">
<div class="font-bold text-2xl text-center">
Frpc {{ running ? "已启动" : "已断开" }}
</div>
</transition>
<el-button
class="block"
type="text"
v-if="running"
@click="$router.replace({ name: 'Logger' })"
>查看日志
</el-button>
<div
class="w-full h-8 bg-[#563EA4] rounded flex justify-center items-center text-white font-bold cursor-pointer"
@click="handleButtonClick"
>
{{ running ? "断 开" : "启 动" }}
</div>
</div>
</div>
</div>
</div>
<!-- <el-button-->
<!-- plain-->
<!-- type="primary"-->
<!-- @click="handleStartFrpc"-->
<!-- :disabled="running"-->
<!-- >启动-->
<!-- </el-button>-->
<!-- <el-button-->
<!-- plain-->
<!-- type="danger"-->
<!-- :disabled="!running"-->
<!-- @click="handleStopFrpc"-->
<!-- >停止-->
<!-- </el-button>-->
</div>
</div>
</template>
<style scoped lang="scss">
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes transform-opacity {
0% {
opacity: 0;
}
100% {
opacity: 0.3;
}
}
$offset: 10px;
.animation-rotate-1 {
animation: rotate 5s linear infinite;
}
.animation-rotate-2 {
animation: rotate 4s linear infinite;
}
.animation-rotate-3 {
animation: rotate 6s linear infinite;
}
.top-circle {
position: absolute;
bottom: $offset;
transform-origin: center calc(50% - $offset);
}
.left-circle {
position: absolute;
left: $offset;
top: $offset;
transform-origin: calc(50% + $offset) center;
//transform-origin: calc(50% - 5px) center;
}
.right-circle {
position: absolute;
right: $offset;
top: $offset;
transform-origin: calc(50% - $offset) center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
import Breadcrumb from "@/layout/compoenets/Breadcrumb.vue";
import { ipcRenderer } from "electron";
defineComponent({
name: "Logger"
});
const loggerContent = ref('<div class="text-white">暂无日志</div>');
const handleLog2Html = (logContent: string) => {
const logs = logContent
.split("\n")
.filter(f => f)
.map(m => {
if (m.indexOf("[E]") !== -1) {
return `<div class="text-[#FF0006]">${m}</div> `;
} else if (m.indexOf("[I]") !== -1) {
return `<div class="text-[#48BB31]">${m}</div> `;
} else if (m.indexOf("[D]") !== -1) {
return `<div class="text-[#0070BB]">${m}</div> `;
} else if (m.indexOf("[W]") !== -1) {
return `<div class="text-[#BBBB23]">${m}</div> `;
} else {
return `<div class="text-[#BBBBBB]">${m}</div> `;
}
});
return logs.reverse().join("");
};
onMounted(() => {
ipcRenderer.send("logger.getLog");
ipcRenderer.on("Logger.getLog.hook", (event, args) => {
// console.log("", args, args.indexOf("\n"));
// const logs = args.split("\n");
// console.log(logs, "2");
if (args) {
loggerContent.value = handleLog2Html(args);
}
ipcRenderer.send("logger.update");
});
ipcRenderer.on("Logger.update.hook", (event, args) => {
if (args) {
loggerContent.value = handleLog2Html(args);
}
});
});
onUnmounted(() => {
ipcRenderer.removeAllListeners("Logger.getLog.hook");
ipcRenderer.removeAllListeners("Logger.update.hook");
});
</script>
<template>
<div class="main">
<breadcrumb />
<div class="app-container-breadcrumb">
<div
class="w-full h-full p-4 bg-[#2B2B2B] rounded drop-shadow-lg overflow-y-auto"
v-html="loggerContent"
></div>
</div>
</div>
</template>
<style lang="scss" scoped></style>

487
src/views/proxy/index.vue Normal file
View File

@ -0,0 +1,487 @@
<script lang="ts" setup>
import { defineComponent, onMounted, onUnmounted, reactive, ref } from "vue";
import { Icon } from "@iconify/vue";
import Breadcrumb from "@/layout/compoenets/Breadcrumb.vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { ipcRenderer } from "electron";
import { clone } from "@/utils/clone";
defineComponent({
name: "Proxy"
});
type Proxy = {
_id: string;
name: string;
type: string;
localIp: string;
localPort: number;
remotePort: number;
customDomains: string[];
};
/**
* 代理列表
*/
const proxys = ref<Array<Proxy>>([]);
/**
* loading
*/
const loading = ref({
list: 1,
form: 0
});
/**
* 弹出层属性
*/
const edit = ref({
title: "新增代理",
visible: false
});
/**
* 表单内容
*/
const editForm = ref<Proxy>({
_id: "",
name: "",
type: "http",
localIp: "",
localPort: 8080,
remotePort: 8080,
customDomains: [""]
});
/**
* 表单校验
*/
const editFormRules = reactive<FormRules>({
name: [
{ required: true, message: "请输入名称", trigger: "blur" },
// {
// pattern: /^[a-zA-Z]+$/,
// message: "",
// trigger: "blur"
// }
],
type: [{ required: true, message: "请选择类型", trigger: "blur" }],
localIp: [
{ required: true, message: "请输入内网地址", trigger: "blur" },
{
pattern: /^[\w-]+(\.[\w-]+)+$/,
message: "请输入正确的内网地址",
trigger: "blur"
}
],
localPort: [{ required: true, message: "请输入本地端口", trigger: "blur" }],
remotePort: [{ required: true, message: "请输入远程端口", trigger: "blur" }]
});
/**
* 表单dom
*/
const editFormRef = ref<FormInstance>();
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!editFormRef.value) return;
await editFormRef.value.validate(valid => {
if (valid) {
loading.value.form = 1;
const data = clone(editForm.value);
if (data._id) {
ipcRenderer.send("proxy.updateProxy", data);
} else {
ipcRenderer.send("proxy.insertProxy", data);
}
}
});
};
/**
* 添加代理域名
*/
const handleAddDomain = () => {
editForm.value.customDomains.push("");
};
/**
* 删除代理列表
* @param index
*/
const handleDeleteDomain = (index: number) => {
editForm.value.customDomains.splice(index, 1);
};
/**
* 加载代理
*/
const handleLoadProxys = () => {
ipcRenderer.send("proxy.getProxys");
};
/**
* 删除代理
* @param proxy
*/
const handleDeleteProxy = (proxy: Proxy) => {
ipcRenderer.send("proxy.deleteProxyById", proxy._id);
};
/**
* 重置表单
*/
const handleResetForm = () => {
editForm.value = {
_id: "",
name: "",
type: "http",
localIp: "",
localPort: 0,
remotePort: 0,
customDomains: [""]
};
};
/**
* 初始化回调
*/
const handleInitHook = () => {
const InsertOrUpdateHook = (message: string, args: any) => {
loading.value.form--;
const { err } = args;
if (!err) {
ElMessage({
type: "success",
message: message
});
handleResetForm();
handleLoadProxys();
edit.value.visible = false;
}
};
ipcRenderer.on("Proxy.insertProxy.hook", (event, args) => {
InsertOrUpdateHook("新增成功", args);
});
ipcRenderer.on("Proxy.updateProxy.hook", (event, args) => {
InsertOrUpdateHook("修改成功", args);
});
// ipcRenderer.on("Proxy.updateProxy.hook", (event, args) => {
// loading.value.form--;
// const { err } = args;
// if (!err) {
// ElMessage({
// type: "success",
// message: ""
// });
// handleResetForm();
// handleLoadProxys();
// edit.value.visible = false;
// }
// });
ipcRenderer.on("Proxy.getProxys.hook", (event, args) => {
loading.value.list--;
const { err, data } = args;
if (!err) {
proxys.value = data;
}
});
ipcRenderer.on("Proxy.deleteProxyById.hook", (event, args) => {
const { err, data } = args;
if (!err) {
handleLoadProxys();
ElMessage({
type: "success",
message: "删除成功"
});
}
});
};
const handleOpenInsert = () => {
edit.value = {
title: "新增代理",
visible: true
};
};
const handleOpenUpdate = (proxy: Proxy) => {
editForm.value = clone(proxy);
edit.value = {
title: "修改代理",
visible: true
};
};
onMounted(() => {
handleInitHook();
handleLoadProxys();
});
onUnmounted(() => {
ipcRenderer.removeAllListeners("Proxy.insertProxy.hook");
ipcRenderer.removeAllListeners("Proxy.updateProxy.hook");
ipcRenderer.removeAllListeners("Proxy.deleteProxyById.hook");
ipcRenderer.removeAllListeners("Proxy.getProxys.hook");
});
</script>
<template>
<!-- <coming-soon />-->
<div class="main">
<breadcrumb>
<div
class="cursor-pointer h-[36px] w-[36px] bg-[#5f3bb0] rounded text-white flex justify-center items-center"
@click="handleOpenInsert"
>
<Icon icon="material-symbols:add" />
</div>
</breadcrumb>
<div class="app-container-breadcrumb" v-loading="loading.list > 0">
<template v-if="proxys && proxys.length > 0">
<el-row :gutter="20">
<el-col
v-for="proxy in proxys"
:key="proxy._id"
:lg="6"
:md="8"
:sm="12"
:xl="6"
:xs="24"
class="mb-[20px]"
>
<div class="bg-white w-full rounded drop-shadow-xl p-4">
<div class="w-full flex justify-between">
<div class="flex">
<div
class="w-12 h-12 rounded mr-4 flex justify-center items-center"
:class="proxy.type"
>
<span class="text-white text-sm">{{ proxy.type }}</span>
</div>
<div class="h-12 relative">
<div class="text-sm font-bold">{{ proxy.name }}</div>
<!-- <el-tag-->
<!-- size="small"-->
<!-- class="absolute bottom-0"-->
<!-- type="success"-->
<!-- effect="plain"-->
<!-- >正常-->
<!-- </el-tag>-->
</div>
</div>
<div>
<el-dropdown size="small">
<a
href="#"
class="text-xl text-[#ADADAD] hover:text-[#5A3DAA]"
>
<Icon icon="material-symbols:more-vert" />
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleOpenUpdate(proxy)">
<Icon
icon="material-symbols:edit"
class="primary-text text-[14px]"
/>
<span class="ml-1"> </span>
</el-dropdown-item>
<el-dropdown-item @click="handleDeleteProxy(proxy)">
<Icon
icon="material-symbols:delete-rounded"
class="text-red-500 text-[14px]"
/>
<span class="ml-1"> </span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="flex justify-between mt-4">
<div class="text-sm text-left">
<p class="text-[#ADADAD] font-bold">内网地址</p>
<p>{{ proxy.localIp }}</p>
</div>
<div class="text-sm text-center" v-if="proxy.type === 'tcp'">
<p class="text-[#ADADAD] font-bold">外网端口</p>
<p>{{ proxy.remotePort }}</p>
</div>
<div class="text-sm text-center">
<p class="text-[#ADADAD] font-bold">内网端口</p>
<p>{{ proxy.localPort }}</p>
</div>
</div>
<!-- <div class="text-sm text-[#ADADAD] py-2">本地地址 本地端口</div>-->
</div>
</el-col>
</el-row>
</template>
<div
v-else
class="w-full h-full bg-white rounded p-2 overflow-hidden drop-shadow-xl flex justify-center items-center"
>
<el-empty description="暂无代理" />
</div>
</div>
<el-dialog
v-model="edit.visible"
:title="edit.title"
class="w-[400px]"
top="30px"
>
<el-form
v-loading="loading.form"
label-position="top"
:model="editForm"
:rules="editFormRules"
ref="editFormRef"
>
<el-row :gutter="10">
<el-col :span="24">
<el-form-item label="代理类型:" prop="proxyType">
<el-radio-group v-model="editForm.type">
<el-radio label="http" model-value="http" />
<el-radio label="https" model-value="https" />
<el-radio label="tcp" model-value="tcp" />
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="代理名称:" prop="proxyName">
<el-input v-model="editForm.name" placeholder="代理名称" />
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="内网地址:" prop="localIp">
<el-input v-model="editForm.localIp" placeholder="127.0.0.1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="内网端口:" prop="localPort">
<el-input-number
placeholder="8080"
class="!w-full"
:min="0"
:max="65535"
v-model="editForm.localPort"
controls-position="right"
/>
</el-form-item>
</el-col>
<template v-if="editForm.type === 'tcp'">
<el-col :span="8">
<el-form-item label="外网端口:" prop="remotePort">
<el-input-number
:min="0"
:max="65535"
placeholder="8080"
v-model="editForm.remotePort"
controls-position="right"
/>
</el-form-item>
</el-col>
</template>
<template
v-if="editForm.type === 'http' || editForm.type === 'https'"
>
<el-col :span="24">
<el-form-item
v-for="(d, di) in editForm.customDomains"
:key="'domain' + di"
:label="di === 0 ? '自定义域名:' : ''"
:prop="`customDomains.${di}`"
:rules="[
{
required: true,
message: `自定义域名不能为空`,
trigger: 'blur'
},
{
pattern:
/^(?=^.{3,255}$)[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/,
message: '请输入正确的域名',
trigger: 'blur'
}
]"
>
<el-input
class="domain-input"
placeholder="github.com"
v-model="editForm.customDomains[di]"
/>
<!-- <div class="domain-input-button !bg-[#67c23a]">-->
<!-- <Icon icon="material-symbols:add" />-->
<!-- </div>-->
<el-button
class="ml-[10px]"
type="primary"
plain
@click="handleAddDomain"
>
<Icon icon="material-symbols:add" />
</el-button>
<el-button
type="danger"
plain
@click="handleDeleteDomain(di)"
:disabled="editForm.customDomains.length === 1"
>
<Icon icon="material-symbols:delete-rounded" />
</el-button>
<!-- <div class="domain-input-button !bg-[#d3585b]">-->
<!-- <Icon icon="material-symbols:delete-rounded" />-->
<!-- </div>-->
</el-form-item>
</el-col>
</template>
<el-col :span="24">
<el-form-item>
<div class="w-full flex justify-end">
<el-button @click="edit.visible = false"> </el-button>
<el-button plain type="primary" @click="handleSubmit"
>
</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.http {
background: #d3585b;
}
.tcp {
background: #7bbc71;
}
.https {
background: #5f3bb0;
}
.domain-input {
width: calc(100% - 115px);
}
.domain-input-button {
background: #5f3bb0;
display: flex;
justify-content: center;
align-items: center;
color: white;
width: 32px;
height: 32px;
border-radius: 4px;
margin-left: 10px;
cursor: pointer;
}
</style>

20
tailwind.config.js Normal file
View File

@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
corePlugins: {
preflight: false
},
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
bg_color: "var(--el-bg-color)",
primary: "var(--el-color-primary)",
primary_light_9: "var(--el-color-primary-light-9)",
text_color_primary: "var(--el-text-color-primary)",
text_color_regular: "var(--el-text-color-regular)",
text_color_disabled: "var(--el-text-color-disabled)"
}
}
}
};

49
tsconfig.json Normal file
View File

@ -0,0 +1,49 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"node",
"vite/client",
"element-plus/global"
],
"typeRoots": [
"./types",
"./node_modules/@types/",
"./node_modules"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"types/*.d.ts",
"package.json",
"electron",
"vite.config.ts"
],
"exclude": [
"node_modules",
"dist",
"**/*.js"
]
}

105
vite.config.ts Normal file
View File

@ -0,0 +1,105 @@
import { rmSync } from "node:fs";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import electron from "vite-plugin-electron";
import renderer from "vite-plugin-electron-renderer";
import { notBundle } from "vite-plugin-electron/plugin";
import { resolve } from "path";
import pkg from "./package.json";
/** 路径查找 */
const pathResolve = (dir: string): string => {
return resolve(__dirname, ".", dir);
};
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
rmSync("dist-electron", { recursive: true, force: true });
const isServe = command === "serve";
const isBuild = command === "build";
const sourcemap = isServe || !!process.env.VSCODE_DEBUG;
return {
plugins: [
vue(),
electron([
{
// Main process entry file of the Electron App.
entry: "electron/main/index.ts",
onstart({ startup }) {
if (process.env.VSCODE_DEBUG) {
console.log(
/* For `.vscode/.debug.script.mjs` */ "[startup] Electron App"
);
} else {
startup();
}
},
vite: {
build: {
sourcemap,
minify: isBuild,
outDir: "dist-electron/main",
rollupOptions: {
// Some third-party Node.js libraries may not be built correctly by Vite, especially `C/C++` addons,
// we can use `external` to exclude them to ensure they work correctly.
// Others need to put them in `dependencies` to ensure they are collected into `app.asar` after the app is built.
// Of course, this is not absolute, just this way is relatively simple. :)
external: Object.keys(
"dependencies" in pkg ? pkg.dependencies : {}
)
}
},
plugins: [
// This is just an option to improve build performance, it's non-deterministic!
// e.g. `import log from 'electron-log'` -> `const log = require('electron-log')`
isServe && notBundle()
]
}
},
{
entry: "electron/preload/index.ts",
onstart({ reload }) {
// Notify the Renderer process to reload the page when the Preload scripts build is complete,
// instead of restarting the entire Electron App.
reload();
},
vite: {
build: {
sourcemap: sourcemap ? "inline" : undefined, // #332
minify: isBuild,
outDir: "dist-electron/preload",
rollupOptions: {
external: Object.keys(
"dependencies" in pkg ? pkg.dependencies : {}
)
}
},
plugins: [isServe && notBundle()]
}
}
]),
// Use Node.js API in the Renderer process
renderer()
],
resolve: {
alias: {
"@": pathResolve("src"),
"@build": pathResolve("build")
}
},
server:
process.env.VSCODE_DEBUG &&
(() => {
const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL);
return {
host: url.hostname,
port: +url.port
};
})(),
clearScreen: false
};
});