mirror of
https://github.com/acepanel/helper.git
synced 2026-02-04 08:57:15 +08:00
feat: init
This commit is contained in:
31
internal/app/helper.go
Normal file
31
internal/app/helper.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/internal/ui"
|
||||
)
|
||||
|
||||
type Helper struct {
|
||||
installer service.Installer
|
||||
uninstaller service.Uninstaller
|
||||
mounter service.Mounter
|
||||
}
|
||||
|
||||
func NewHelper(installer service.Installer, uninstaller service.Uninstaller, mounter service.Mounter) *Helper {
|
||||
return &Helper{
|
||||
installer: installer,
|
||||
uninstaller: uninstaller,
|
||||
mounter: mounter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Helper) Run() error {
|
||||
app := ui.NewApp(h.installer, h.uninstaller, h.mounter)
|
||||
p := tea.NewProgram(app, tea.WithAltScreen())
|
||||
app.SetProgram(p)
|
||||
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
7
internal/app/wire.go
Normal file
7
internal/app/wire.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewHelper,
|
||||
)
|
||||
467
internal/service/installer.go
Normal file
467
internal/service/installer.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Installer 安装器接口
|
||||
type Installer interface {
|
||||
Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error
|
||||
}
|
||||
|
||||
type installer struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
firewall system.Firewall
|
||||
systemd system.Systemd
|
||||
userMgr system.UserManager
|
||||
}
|
||||
|
||||
// NewInstaller 创建安装器
|
||||
func NewInstaller(
|
||||
detector system.Detector,
|
||||
executor system.Executor,
|
||||
firewall system.Firewall,
|
||||
systemd system.Systemd,
|
||||
userMgr system.UserManager,
|
||||
) Installer {
|
||||
return &installer{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
firewall: firewall,
|
||||
systemd: systemd,
|
||||
userMgr: userMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *installer) Install(ctx context.Context, cfg *types.InstallConfig, progress chan<- types.Progress) error {
|
||||
steps := []struct {
|
||||
name string
|
||||
weight float64
|
||||
fn func(ctx context.Context, cfg *types.InstallConfig) error
|
||||
}{
|
||||
{i18n.T().Get("Checking system requirements"), 0.05, i.checkSystem},
|
||||
{i18n.T().Get("Creating www user"), 0.02, i.createUser},
|
||||
{i18n.T().Get("Optimizing system settings"), 0.08, i.optimizeSystem},
|
||||
{i18n.T().Get("Installing dependencies"), 0.20, i.installDeps},
|
||||
{i18n.T().Get("Creating swap file"), 0.05, i.createSwap},
|
||||
{i18n.T().Get("Downloading panel"), 0.30, i.downloadPanel},
|
||||
{i18n.T().Get("Configuring firewall"), 0.10, i.configureFirewall},
|
||||
{i18n.T().Get("Creating systemd service"), 0.10, i.createService},
|
||||
{i18n.T().Get("Initializing panel"), 0.08, i.initPanel},
|
||||
{i18n.T().Get("Detecting installed apps"), 0.02, i.detectApps},
|
||||
}
|
||||
|
||||
var currentProgress float64
|
||||
for _, step := range steps {
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: step.name + "...",
|
||||
}
|
||||
|
||||
if err := step.fn(ctx, cfg); err != nil {
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: fmt.Sprintf("%s: %v", i18n.T().Get("Error"), err),
|
||||
IsError: true,
|
||||
Error: err,
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
currentProgress += step.weight
|
||||
progress <- types.Progress{
|
||||
Step: step.name,
|
||||
Percent: currentProgress,
|
||||
Message: step.name + " " + i18n.T().Get("completed"),
|
||||
}
|
||||
}
|
||||
|
||||
progress <- types.Progress{
|
||||
Step: i18n.T().Get("Installation complete"),
|
||||
Percent: 1.0,
|
||||
Message: i18n.T().Get("Panel installed successfully"),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) checkSystem(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 检查root权限
|
||||
if err := i.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测系统信息
|
||||
info, err := i.detector.Detect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查OS
|
||||
if info.OS == types.OSUnknown {
|
||||
return errors.New(i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
|
||||
// 检查架构
|
||||
if info.Arch == types.ArchUnknown {
|
||||
return errors.New(i18n.T().Get("Unsupported CPU architecture"))
|
||||
}
|
||||
|
||||
// 检查CPU特性
|
||||
if err := i.detector.CheckCPUFeatures(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查内核版本
|
||||
if info.KernelVersion != "" {
|
||||
parts := strings.Split(info.KernelVersion, ".")
|
||||
if len(parts) > 0 {
|
||||
major := 0
|
||||
_, _ = fmt.Sscanf(parts[0], "%d", &major)
|
||||
if major < 4 {
|
||||
return errors.New(i18n.T().Get("Kernel version too old, requires 4.x or above"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否64位
|
||||
if !info.Is64Bit {
|
||||
return errors.New(i18n.T().Get("Requires 64-bit system"))
|
||||
}
|
||||
|
||||
// 检查是否已安装
|
||||
if i.detector.CheckPanelInstalled(cfg.SetupPath) {
|
||||
return errors.New(i18n.T().Get("Panel is already installed"))
|
||||
}
|
||||
|
||||
// 保存系统信息到配置
|
||||
cfg.InChina = info.InChina
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) createUser(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
return i.userMgr.EnsureUserAndGroup(ctx, "www", "www")
|
||||
}
|
||||
|
||||
func (i *installer) optimizeSystem(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 设置时区
|
||||
_, _ = i.executor.Run(ctx, "timedatectl", "set-timezone", "Asia/Shanghai")
|
||||
|
||||
// 禁用SELinux
|
||||
_, _ = i.executor.Run(ctx, "setenforce", "0")
|
||||
_, _ = i.executor.Run(ctx, "sed", "-i", "s/SELINUX=enforcing/SELINUX=disabled/g", "/etc/selinux/config")
|
||||
|
||||
// 系统参数优化
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-w", "vm.overcommit_memory=1")
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-w", "net.core.somaxconn=1024")
|
||||
|
||||
// 写入sysctl配置
|
||||
sysctlConf := `fs.file-max = 2147483584
|
||||
net.core.somaxconn = 1024
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
`
|
||||
_ = os.WriteFile("/etc/sysctl.d/99-panel.conf", []byte(sysctlConf), 0644)
|
||||
|
||||
// 写入limits配置
|
||||
limitsConf := `* soft nofile 1048576
|
||||
* hard nofile 1048576
|
||||
* soft nproc 1048576
|
||||
* hard nproc 1048576
|
||||
`
|
||||
// 追加到limits.conf
|
||||
f, err := os.OpenFile("/etc/security/limits.conf", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(limitsConf)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
// 重载sysctl
|
||||
_, _ = i.executor.Run(ctx, "sysctl", "-p")
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "restart", "systemd-sysctl")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) installDeps(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
pkgMgr := system.NewPackageManager(info.OS, i.executor)
|
||||
if pkgMgr == nil {
|
||||
return errors.New(i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
|
||||
// 设置镜像源
|
||||
if err := pkgMgr.SetMirror(ctx, cfg.InChina); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
if err := pkgMgr.UpdateCache(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 安装EPEL (RHEL系)
|
||||
if info.OS == types.OSRHEL {
|
||||
_ = pkgMgr.EnableEPEL(ctx, cfg.InChina)
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
var packages []string
|
||||
if info.OS == types.OSRHEL {
|
||||
packages = []string{"bash", "curl", "wget", "zip", "unzip", "tar", "git", "jq", "make", "sudo"}
|
||||
} else {
|
||||
packages = []string{"bash", "curl", "wget", "zip", "unzip", "tar", "git", "jq", "make", "sudo"}
|
||||
}
|
||||
|
||||
return pkgMgr.Install(ctx, packages...)
|
||||
}
|
||||
|
||||
func (i *installer) createSwap(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
|
||||
// 如果已有swap或内存>=4G,跳过
|
||||
if info.Swap > 1 || info.Memory >= 3900 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !cfg.AutoSwap {
|
||||
return nil
|
||||
}
|
||||
|
||||
swapFile := cfg.SetupPath + "/swap"
|
||||
|
||||
// 创建swap文件
|
||||
_, _ = i.executor.Run(ctx, "dd", "if=/dev/zero", "of="+swapFile, "bs=8M", "count=256")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "600", swapFile)
|
||||
_, _ = i.executor.Run(ctx, "mkswap", "-f", swapFile)
|
||||
_, _ = i.executor.Run(ctx, "swapon", swapFile)
|
||||
|
||||
// 添加到fstab
|
||||
fstabEntry := fmt.Sprintf("%s swap swap defaults 0 0\n", swapFile)
|
||||
f, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err == nil {
|
||||
_, _ = f.WriteString(fstabEntry)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) downloadPanel(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 创建目录
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/panel", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/server/webhook", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/server/cron/logs", 0755)
|
||||
_ = os.MkdirAll(cfg.SetupPath+"/projects", 0755)
|
||||
|
||||
// 获取最新版本信息
|
||||
client := resty.New()
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
Get("https://api.acepanel.net/version/latest")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to get version info"), err)
|
||||
}
|
||||
|
||||
var versionResp struct {
|
||||
Data struct {
|
||||
Version string `json:"version"`
|
||||
Downloads []struct {
|
||||
Arch string `json:"arch"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"checksum"`
|
||||
} `json:"downloads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(resp.Body(), &versionResp); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to parse version info"), err)
|
||||
}
|
||||
|
||||
// 根据架构选择下载链接
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
var downloadURL string
|
||||
arch := "amd64"
|
||||
if info.Arch == types.ArchARM64 {
|
||||
arch = "arm64"
|
||||
}
|
||||
|
||||
for _, dl := range versionResp.Data.Downloads {
|
||||
if dl.Arch == arch {
|
||||
downloadURL = dl.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return errors.New(i18n.T().Get("No download URL found for architecture %s", arch))
|
||||
}
|
||||
|
||||
// 下载面板
|
||||
zipPath := cfg.SetupPath + "/panel/panel.zip"
|
||||
resp, err = client.R().
|
||||
SetContext(ctx).
|
||||
SetOutput(zipPath).
|
||||
Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to download panel"), err)
|
||||
}
|
||||
|
||||
// 解压
|
||||
result, err := i.executor.Run(ctx, "unzip", "-o", zipPath, "-d", cfg.SetupPath+"/panel")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to unzip panel"))
|
||||
}
|
||||
|
||||
// 删除zip文件
|
||||
_ = os.Remove(zipPath)
|
||||
|
||||
// 移动配置文件
|
||||
_ = os.Rename(cfg.SetupPath+"/panel/config.example.yml", cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 替换配置中的路径
|
||||
_, _ = i.executor.Run(ctx, "sed", "-i", fmt.Sprintf("s|/opt/ace|%s|g", cfg.SetupPath), cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 设置权限
|
||||
_, _ = i.executor.Run(ctx, "chmod", "-R", "700", cfg.SetupPath+"/panel")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "600", cfg.SetupPath+"/panel/storage/config.yml")
|
||||
|
||||
// 移动CLI工具
|
||||
_ = os.Rename(cfg.SetupPath+"/panel/cli", "/usr/local/sbin/acepanel")
|
||||
_, _ = i.executor.Run(ctx, "chmod", "+x", "/usr/local/sbin/acepanel")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) configureFirewall(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 安装firewalld
|
||||
if err := i.firewall.Install(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启用
|
||||
if err := i.firewall.Enable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取SSH端口
|
||||
info, _ := i.detector.Detect(ctx)
|
||||
|
||||
// 添加端口
|
||||
ports := []struct {
|
||||
port int
|
||||
protocol string
|
||||
}{
|
||||
{22, "tcp"},
|
||||
{80, "tcp"},
|
||||
{443, "tcp"},
|
||||
{443, "udp"},
|
||||
{info.SSHPort, "tcp"},
|
||||
}
|
||||
|
||||
for _, p := range ports {
|
||||
_ = i.firewall.AddPort(ctx, p.port, p.protocol)
|
||||
}
|
||||
|
||||
// 重载
|
||||
return i.firewall.Reload(ctx)
|
||||
}
|
||||
|
||||
func (i *installer) createService(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
serviceContent := fmt.Sprintf(`[Unit]
|
||||
Description=AcePanel
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s/panel/ace
|
||||
WorkingDirectory=%s/panel
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, cfg.SetupPath, cfg.SetupPath)
|
||||
|
||||
if err := i.systemd.WriteServiceFile("acepanel", serviceContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := i.systemd.DaemonReload(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.systemd.Enable(ctx, "acepanel")
|
||||
}
|
||||
|
||||
func (i *installer) initPanel(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 初始化面板
|
||||
result, err := i.executor.Run(ctx, "/usr/local/sbin/acepanel", "init")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to initialize panel"))
|
||||
}
|
||||
|
||||
// 同步
|
||||
result, err = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "sync")
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to sync panel"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *installer) detectApps(ctx context.Context, cfg *types.InstallConfig) error {
|
||||
// 检测Docker
|
||||
result, _ := i.executor.Run(ctx, "which", "docker")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "docker")
|
||||
versionResult, _ := i.executor.Run(ctx, "docker", "-v")
|
||||
if versionResult != nil {
|
||||
parts := strings.Fields(versionResult.Stdout)
|
||||
if len(parts) >= 3 {
|
||||
version := strings.TrimSuffix(parts[2], ",")
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "docker", "stable", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测Podman
|
||||
result, _ = i.executor.Run(ctx, "which", "podman")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "podman")
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "podman.socket")
|
||||
versionResult, _ := i.executor.Run(ctx, "podman", "-v")
|
||||
if versionResult != nil {
|
||||
parts := strings.Fields(versionResult.Stdout)
|
||||
if len(parts) >= 3 {
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "podman", "stable", parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测Fail2ban
|
||||
result, _ = i.executor.Run(ctx, "which", "fail2ban-server")
|
||||
if result != nil && result.ExitCode == 0 {
|
||||
_, _ = i.executor.Run(ctx, "systemctl", "enable", "--now", "fail2ban")
|
||||
versionResult, _ := i.executor.Run(ctx, "fail2ban-server", "-V")
|
||||
if versionResult != nil {
|
||||
_, _ = i.executor.Run(ctx, "/usr/local/sbin/acepanel", "app", "write", "fail2ban", "stable", strings.TrimSpace(versionResult.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
172
internal/service/mounter.go
Normal file
172
internal/service/mounter.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Mounter 磁盘挂载器接口
|
||||
type Mounter interface {
|
||||
ListDisks(ctx context.Context) ([]types.DiskInfo, error)
|
||||
IsPartitioned(disk string) bool
|
||||
Mount(ctx context.Context, cfg *types.MountConfig, progress ProgressCallback) error
|
||||
}
|
||||
|
||||
type mounter struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
}
|
||||
|
||||
// NewMounter 创建挂载器
|
||||
func NewMounter(detector system.Detector, executor system.Executor) Mounter {
|
||||
return &mounter{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mounter) ListDisks(ctx context.Context) ([]types.DiskInfo, error) {
|
||||
return m.detector.ListDisks(ctx)
|
||||
}
|
||||
|
||||
func (m *mounter) IsPartitioned(disk string) bool {
|
||||
_, err := os.Stat("/dev/" + disk + "1")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *mounter) Mount(ctx context.Context, cfg *types.MountConfig, progress ProgressCallback) error {
|
||||
// 检查root权限
|
||||
if err := m.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查磁盘是否存在
|
||||
if !m.detector.CheckDiskExists(cfg.Disk) {
|
||||
return errors.New(i18n.T().Get("Disk not found"))
|
||||
}
|
||||
|
||||
// 检查是否为系统盘
|
||||
if m.detector.IsSystemDisk(cfg.Disk) {
|
||||
return errors.New(i18n.T().Get("Cannot operate on system disk"))
|
||||
}
|
||||
|
||||
// 安装分区工具
|
||||
progress(i18n.T().Get("Installing partition tools"), i18n.T().Get("Installing partition tools..."))
|
||||
info, _ := m.detector.Detect(ctx)
|
||||
pkgMgr := system.NewPackageManager(info.OS, m.executor)
|
||||
if pkgMgr != nil {
|
||||
if info.OS == types.OSRHEL {
|
||||
if err := pkgMgr.Install(ctx, "xfsprogs", "e2fsprogs", "util-linux"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := pkgMgr.Install(ctx, "xfsprogs", "e2fsprogs", "fdisk", "util-linux"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建挂载点
|
||||
progress(i18n.T().Get("Creating mount point"), i18n.T().Get("Creating %s...", cfg.MountPoint))
|
||||
if err := os.MkdirAll(cfg.MountPoint, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查挂载点是否为空
|
||||
entries, err := os.ReadDir(cfg.MountPoint)
|
||||
if err == nil && len(entries) > 0 {
|
||||
return errors.New(i18n.T().Get("Mount point is not empty"))
|
||||
}
|
||||
|
||||
// 卸载已有分区
|
||||
_, _ = m.executor.Run(ctx, "umount", "/dev/"+cfg.Disk+"1")
|
||||
|
||||
// 删除所有分区
|
||||
progress(i18n.T().Get("Deleting existing partitions"), i18n.T().Get("Deleting existing partitions..."))
|
||||
fdiskInput := ""
|
||||
// 获取现有分区数
|
||||
result, _ := m.executor.Run(ctx, "lsblk", "-no", "NAME", "/dev/"+cfg.Disk)
|
||||
if result != nil {
|
||||
lines := strings.Split(strings.TrimSpace(result.Stdout), "\n")
|
||||
partCount := len(lines) - 1 // 减去磁盘本身
|
||||
for i := 0; i < partCount; i++ {
|
||||
fdiskInput += "d\n"
|
||||
}
|
||||
}
|
||||
fdiskInput += "w\n"
|
||||
_, _ = m.executor.RunWithInput(ctx, fdiskInput, "fdisk", "/dev/"+cfg.Disk)
|
||||
|
||||
// 创建新分区
|
||||
progress(i18n.T().Get("Creating partition"), i18n.T().Get("Creating partition on /dev/%s...", cfg.Disk))
|
||||
partitionInput := "g\nn\n1\n\n\nw\n"
|
||||
result, err = m.executor.RunWithInput(ctx, partitionInput, "fdisk", "/dev/"+cfg.Disk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T().Get("Failed to create partition"), err)
|
||||
}
|
||||
|
||||
// 格式化
|
||||
progress(i18n.T().Get("Formatting partition"), i18n.T().Get("Formatting /dev/%s1 as %s...", cfg.Disk, cfg.FSType))
|
||||
switch cfg.FSType {
|
||||
case types.FSTypeExt4:
|
||||
result, err = m.executor.Run(ctx, "mkfs.ext4", "-F", "/dev/"+cfg.Disk+"1")
|
||||
case types.FSTypeXFS:
|
||||
result, err = m.executor.Run(ctx, "mkfs.xfs", "-f", "/dev/"+cfg.Disk+"1")
|
||||
default:
|
||||
return errors.New(i18n.T().Get("Unsupported filesystem type: %s", cfg.FSType))
|
||||
}
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("Format failed"))
|
||||
}
|
||||
|
||||
// 重载systemd
|
||||
_, _ = m.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
|
||||
// 挂载
|
||||
progress(i18n.T().Get("Mounting partition"), i18n.T().Get("Mounting /dev/%s1 to %s...", cfg.Disk, cfg.MountPoint))
|
||||
result, err = m.executor.Run(ctx, "mount", "/dev/"+cfg.Disk+"1", cfg.MountPoint)
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("Mount failed"))
|
||||
}
|
||||
|
||||
// 获取UUID
|
||||
progress(i18n.T().Get("Updating fstab"), i18n.T().Get("Updating /etc/fstab for auto-mount..."))
|
||||
result, err = m.executor.Run(ctx, "blkid", "-s", "UUID", "-o", "value", "/dev/"+cfg.Disk+"1")
|
||||
if err != nil || result == nil || result.ExitCode != 0 {
|
||||
return errors.New(i18n.T().Get("Failed to get UUID"))
|
||||
}
|
||||
uuid := strings.TrimSpace(result.Stdout)
|
||||
|
||||
// 更新fstab
|
||||
// 先删除旧条目
|
||||
_, _ = m.executor.Run(ctx, "sed", "-i", fmt.Sprintf("\\|/dev/%s1|d", cfg.Disk), "/etc/fstab")
|
||||
_, _ = m.executor.Run(ctx, "sed", "-i", fmt.Sprintf("\\|%s|d", cfg.MountPoint), "/etc/fstab")
|
||||
|
||||
// 添加新条目
|
||||
fstabEntry := fmt.Sprintf("UUID=%s %s %s defaults 0 0\n", uuid, cfg.MountPoint, cfg.FSType)
|
||||
f, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(f *os.File) { _ = f.Close() }(f)
|
||||
_, err = f.WriteString(fstabEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重载并挂载
|
||||
_, _ = m.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
result, err = m.executor.Run(ctx, "mount", "-a")
|
||||
if err != nil || (result != nil && result.ExitCode != 0) {
|
||||
return errors.New(i18n.T().Get("fstab configuration error"))
|
||||
}
|
||||
|
||||
progress(i18n.T().Get("Mount complete"), i18n.T().Get("Disk partition and mount successful"))
|
||||
return nil
|
||||
}
|
||||
86
internal/service/uninstaller.go
Normal file
86
internal/service/uninstaller.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/acepanel/helper/internal/system"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// ProgressCallback 进度回调
|
||||
type ProgressCallback func(step, message string)
|
||||
|
||||
// Uninstaller 卸载器接口
|
||||
type Uninstaller interface {
|
||||
Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error
|
||||
}
|
||||
|
||||
type uninstaller struct {
|
||||
detector system.Detector
|
||||
executor system.Executor
|
||||
systemd system.Systemd
|
||||
}
|
||||
|
||||
// NewUninstaller 创建卸载器
|
||||
func NewUninstaller(
|
||||
detector system.Detector,
|
||||
executor system.Executor,
|
||||
systemd system.Systemd,
|
||||
) Uninstaller {
|
||||
return &uninstaller{
|
||||
detector: detector,
|
||||
executor: executor,
|
||||
systemd: systemd,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *uninstaller) Uninstall(ctx context.Context, setupPath string, progress ProgressCallback) error {
|
||||
// 检查root权限
|
||||
if err := u.detector.CheckRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否已安装
|
||||
if !u.detector.CheckPanelInstalled(setupPath) {
|
||||
return fmt.Errorf(i18n.T().Get("Panel is not installed"))
|
||||
}
|
||||
|
||||
// 停止服务
|
||||
progress(i18n.T().Get("Stopping panel service"), i18n.T().Get("Stopping acepanel service..."))
|
||||
_ = u.systemd.Stop(ctx, "acepanel")
|
||||
_ = u.systemd.Disable(ctx, "acepanel")
|
||||
|
||||
// 删除服务文件
|
||||
progress(i18n.T().Get("Removing service file"), i18n.T().Get("Removing systemd service file..."))
|
||||
_ = u.systemd.RemoveServiceFile("acepanel")
|
||||
_ = u.systemd.DaemonReload(ctx)
|
||||
|
||||
// 删除CLI工具
|
||||
progress(i18n.T().Get("Removing CLI tool"), i18n.T().Get("Removing /usr/local/sbin/acepanel..."))
|
||||
_ = os.Remove("/usr/local/sbin/acepanel")
|
||||
|
||||
// 移除swap
|
||||
progress(i18n.T().Get("Removing swap file"), i18n.T().Get("Removing swap file..."))
|
||||
swapFile := setupPath + "/swap"
|
||||
if _, err := os.Stat(swapFile); err == nil {
|
||||
_, _ = u.executor.Run(ctx, "swapoff", swapFile)
|
||||
_ = os.Remove(swapFile)
|
||||
// 从fstab中删除swap条目
|
||||
_, _ = u.executor.Run(ctx, "sed", "-i", "/swap/d", "/etc/fstab")
|
||||
}
|
||||
|
||||
// 验证fstab
|
||||
result, _ := u.executor.Run(ctx, "mount", "-a")
|
||||
if result != nil && result.ExitCode != 0 {
|
||||
return fmt.Errorf(i18n.T().Get("fstab configuration error, please check /etc/fstab"))
|
||||
}
|
||||
|
||||
// 删除安装目录
|
||||
progress(i18n.T().Get("Removing installation directory"), i18n.T().Get("Removing %s...", setupPath))
|
||||
_ = os.RemoveAll(setupPath)
|
||||
|
||||
progress(i18n.T().Get("Uninstallation complete"), i18n.T().Get("Panel uninstalled successfully"))
|
||||
return nil
|
||||
}
|
||||
9
internal/service/wire.go
Normal file
9
internal/service/wire.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package service
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewInstaller,
|
||||
NewUninstaller,
|
||||
NewMounter,
|
||||
)
|
||||
305
internal/system/detector.go
Normal file
305
internal/system/detector.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// Detector 系统检测器接口
|
||||
type Detector interface {
|
||||
// Detect 检测系统信息
|
||||
Detect(ctx context.Context) (*types.SystemInfo, error)
|
||||
// CheckRoot 检查root权限
|
||||
CheckRoot() error
|
||||
// CheckCPUFeatures 检查CPU特性(x86-64-v2)
|
||||
CheckCPUFeatures(ctx context.Context) error
|
||||
// CheckPanelInstalled 检查面板是否已安装
|
||||
CheckPanelInstalled(path string) bool
|
||||
// ListDisks 列出可用磁盘
|
||||
ListDisks(ctx context.Context) ([]types.DiskInfo, error)
|
||||
// CheckDiskExists 检查磁盘是否存在
|
||||
CheckDiskExists(disk string) bool
|
||||
// IsSystemDisk 检查是否为系统盘
|
||||
IsSystemDisk(disk string) bool
|
||||
}
|
||||
|
||||
type detector struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewDetector 创建检测器
|
||||
func NewDetector(executor Executor) Detector {
|
||||
return &detector{executor: executor}
|
||||
}
|
||||
|
||||
func (d *detector) Detect(ctx context.Context) (*types.SystemInfo, error) {
|
||||
info := &types.SystemInfo{}
|
||||
|
||||
// 检测OS类型
|
||||
info.OS = d.detectOS()
|
||||
// 检测架构
|
||||
info.Arch = d.detectArch()
|
||||
// 检测内核版本
|
||||
info.KernelVersion = d.detectKernelVersion(ctx)
|
||||
// 检测是否64位
|
||||
info.Is64Bit = d.detect64Bit(ctx)
|
||||
// 检测内存
|
||||
info.Memory = d.detectMemory(ctx)
|
||||
// 检测Swap
|
||||
info.Swap = d.detectSwap(ctx)
|
||||
// 检测是否在中国
|
||||
info.InChina = d.detectInChina(ctx)
|
||||
// 检测SSH端口
|
||||
info.SSHPort = d.detectSSHPort()
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (d *detector) detectOS() types.OSType {
|
||||
// 读取 /etc/os-release
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return types.OSUnknown
|
||||
}
|
||||
defer func(file *os.File) { _ = file.Close() }(file)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var id string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch id {
|
||||
case "debian":
|
||||
return types.OSDebian
|
||||
case "ubuntu":
|
||||
return types.OSUbuntu
|
||||
case "rhel", "centos", "rocky", "almalinux", "fedora":
|
||||
return types.OSRHEL
|
||||
default:
|
||||
// 检查ID_LIKE
|
||||
_, _ = file.Seek(0, 0)
|
||||
scanner = bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "ID_LIKE=") {
|
||||
idLike := strings.Trim(strings.TrimPrefix(line, "ID_LIKE="), "\"")
|
||||
if strings.Contains(idLike, "debian") {
|
||||
return types.OSDebian
|
||||
}
|
||||
if strings.Contains(idLike, "rhel") || strings.Contains(idLike, "fedora") {
|
||||
return types.OSRHEL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types.OSUnknown
|
||||
}
|
||||
|
||||
func (d *detector) detectArch() types.ArchType {
|
||||
arch := runtime.GOARCH
|
||||
switch arch {
|
||||
case "amd64":
|
||||
return types.ArchAMD64
|
||||
case "arm64":
|
||||
return types.ArchARM64
|
||||
default:
|
||||
return types.ArchUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (d *detector) detectKernelVersion(ctx context.Context) string {
|
||||
result, err := d.executor.Run(ctx, "uname", "-r")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout)
|
||||
}
|
||||
|
||||
func (d *detector) detect64Bit(ctx context.Context) bool {
|
||||
result, err := d.executor.Run(ctx, "getconf", "LONG_BIT")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) == "64"
|
||||
}
|
||||
|
||||
func (d *detector) detectMemory(ctx context.Context) int64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb / 1024 // 转换为MB
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *detector) detectSwap(ctx context.Context) int64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "SwapTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb / 1024 // 转换为MB
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *detector) detectInChina(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://perfops.cloudflareperf.com/cdn-cgi/trace", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
if scanner.Text() == "loc=CN" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *detector) detectSSHPort() int {
|
||||
file, err := os.Open("/etc/ssh/sshd_config")
|
||||
if err != nil {
|
||||
return 22
|
||||
}
|
||||
defer func(file *os.File) { _ = file.Close() }(file)
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "Port ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil {
|
||||
return port
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 22
|
||||
}
|
||||
|
||||
func (d *detector) CheckRoot() error {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentUser.Uid != "0" {
|
||||
return errors.New(i18n.T().Get("Please run with root privileges"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckCPUFeatures(ctx context.Context) error {
|
||||
// 只有x86_64需要检查
|
||||
if runtime.GOARCH != "amd64" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否支持ssse3 (x86-64-v2的标志之一)
|
||||
if !strings.Contains(string(data), "ssse3") {
|
||||
return errors.New(i18n.T().Get("CPU must support at least x86-64-v2 instruction set"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckPanelInstalled(path string) bool {
|
||||
_, err := os.Stat(path + "/panel/web")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (d *detector) ListDisks(ctx context.Context) ([]types.DiskInfo, error) {
|
||||
result, err := d.executor.Run(ctx, "lsblk", "-dno", "NAME,SIZE,TYPE")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var disks []types.DiskInfo
|
||||
lines := strings.Split(strings.TrimSpace(result.Stdout), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[2] == "disk" {
|
||||
// 排除系统盘
|
||||
if d.IsSystemDisk(fields[0]) {
|
||||
continue
|
||||
}
|
||||
disks = append(disks, types.DiskInfo{
|
||||
Name: fields[0],
|
||||
Size: fields[1],
|
||||
Type: fields[2],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
func (d *detector) CheckDiskExists(disk string) bool {
|
||||
_, err := os.Stat("/dev/" + disk)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (d *detector) IsSystemDisk(disk string) bool {
|
||||
// 系统盘通常以a结尾 (sda, vda, nvme0n1)
|
||||
matched, _ := regexp.MatchString(`^(sd|vd|hd)a$`, disk)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
matched, _ = regexp.MatchString(`^nvme0n1$`, disk)
|
||||
return matched
|
||||
}
|
||||
87
internal/system/executor.go
Normal file
87
internal/system/executor.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// CommandResult 命令执行结果
|
||||
type CommandResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
// Executor 命令执行器接口
|
||||
type Executor interface {
|
||||
// Run 执行命令并等待完成
|
||||
Run(ctx context.Context, name string, args ...string) (*CommandResult, error)
|
||||
// RunWithInput 执行命令并提供输入
|
||||
RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error)
|
||||
// RunStream 执行命令并流式输出
|
||||
RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error
|
||||
}
|
||||
|
||||
type executor struct{}
|
||||
|
||||
// NewExecutor 创建执行器
|
||||
func NewExecutor() Executor {
|
||||
return &executor{}
|
||||
}
|
||||
|
||||
func (e *executor) Run(ctx context.Context, name string, args ...string) (*CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := &CommandResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *executor) RunWithInput(ctx context.Context, input string, name string, args ...string) (*CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = bytes.NewBufferString(input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := &CommandResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *executor) RunStream(ctx context.Context, stdout, stderr io.Writer, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
96
internal/system/firewall.go
Normal file
96
internal/system/firewall.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// Firewall 防火墙接口
|
||||
type Firewall interface {
|
||||
// Install 安装防火墙
|
||||
Install(ctx context.Context) error
|
||||
// Enable 启用防火墙
|
||||
Enable(ctx context.Context) error
|
||||
// AddPort 添加端口
|
||||
AddPort(ctx context.Context, port int, protocol string) error
|
||||
// RemovePort 移除端口
|
||||
RemovePort(ctx context.Context, port int, protocol string) error
|
||||
// Reload 重载配置
|
||||
Reload(ctx context.Context) error
|
||||
}
|
||||
|
||||
type firewall struct {
|
||||
executor Executor
|
||||
detector Detector
|
||||
}
|
||||
|
||||
// NewFirewall 创建防火墙管理器
|
||||
func NewFirewall(executor Executor, detector Detector) Firewall {
|
||||
return &firewall{
|
||||
executor: executor,
|
||||
detector: detector,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *firewall) Install(ctx context.Context) error {
|
||||
info, err := f.detector.Detect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgMgr := NewPackageManager(info.OS, f.executor)
|
||||
if pkgMgr == nil {
|
||||
return fmt.Errorf("%s", i18n.T().Get("Unsupported operating system"))
|
||||
}
|
||||
return pkgMgr.Install(ctx, "firewalld")
|
||||
}
|
||||
|
||||
func (f *firewall) Enable(ctx context.Context) error {
|
||||
result, err := f.executor.Run(ctx, "systemctl", "enable", "--now", "firewalld")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to enable firewalld"), result.Stderr)
|
||||
}
|
||||
|
||||
// 设置默认zone
|
||||
_, err = f.executor.Run(ctx, "firewall-cmd", "--set-default-zone=public")
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *firewall) AddPort(ctx context.Context, port int, protocol string) error {
|
||||
portStr := fmt.Sprintf("%d/%s", port, protocol)
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--permanent", "--zone=public", "--add-port="+portStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to add port"), portStr, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *firewall) RemovePort(ctx context.Context, port int, protocol string) error {
|
||||
portStr := fmt.Sprintf("%d/%s", port, protocol)
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--permanent", "--zone=public", "--remove-port="+portStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to remove port"), portStr, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *firewall) Reload(ctx context.Context) error {
|
||||
result, err := f.executor.Run(ctx, "firewall-cmd", "--reload")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to reload firewall"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
229
internal/system/package.go
Normal file
229
internal/system/package.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// PackageManager 包管理器接口
|
||||
type PackageManager interface {
|
||||
// UpdateCache 更新软件源缓存
|
||||
UpdateCache(ctx context.Context) error
|
||||
// Install 安装软件包
|
||||
Install(ctx context.Context, packages ...string) error
|
||||
// Remove 移除软件包
|
||||
Remove(ctx context.Context, packages ...string) error
|
||||
// IsInstalled 检查是否已安装
|
||||
IsInstalled(ctx context.Context, pkg string) bool
|
||||
// SetMirror 设置镜像源
|
||||
SetMirror(ctx context.Context, inChina bool) error
|
||||
// EnableEPEL 启用EPEL源 (仅RHEL系)
|
||||
EnableEPEL(ctx context.Context, inChina bool) error
|
||||
}
|
||||
|
||||
// NewPackageManager 根据OS类型创建包管理器
|
||||
func NewPackageManager(osType types.OSType, executor Executor) PackageManager {
|
||||
switch osType {
|
||||
case types.OSRHEL:
|
||||
return &dnfManager{executor: executor}
|
||||
case types.OSDebian, types.OSUbuntu:
|
||||
return &aptManager{executor: executor, osType: osType}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// dnfManager DNF包管理器 (RHEL系)
|
||||
type dnfManager struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
func (m *dnfManager) UpdateCache(ctx context.Context) error {
|
||||
result, err := m.executor.Run(ctx, "dnf", "makecache", "-y")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf makecache failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) Install(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"install", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "dnf", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf install failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) Remove(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"remove", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "dnf", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("dnf remove failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) IsInstalled(ctx context.Context, pkg string) bool {
|
||||
result, _ := m.executor.Run(ctx, "rpm", "-q", pkg)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (m *dnfManager) SetMirror(ctx context.Context, inChina bool) error {
|
||||
if !inChina {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rocky Linux
|
||||
m.sedReplace("/etc/yum.repos.d/[Rr]ocky*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.tencent.com/rocky|g",
|
||||
)
|
||||
|
||||
// AlmaLinux
|
||||
m.sedReplace("/etc/yum.repos.d/[Aa]lmalinux*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=https://repo.almalinux.org|baseurl=https://mirrors.tencent.com|g",
|
||||
)
|
||||
|
||||
// CentOS Stream
|
||||
m.sedReplace("/etc/yum.repos.d/[Cc]ent*.repo",
|
||||
"s|^mirrorlist=|#mirrorlist=|g",
|
||||
"s|^#baseurl=http://mirror.centos.org/$contentdir|baseurl=https://mirrors.tencent.com/centos-stream|g",
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) EnableEPEL(ctx context.Context, inChina bool) error {
|
||||
// 启用CRB
|
||||
_, _ = m.executor.Run(ctx, "dnf", "config-manager", "--set-enabled", "crb")
|
||||
_, _ = m.executor.Run(ctx, "/usr/bin/crb", "enable")
|
||||
|
||||
// 安装EPEL
|
||||
result, _ := m.executor.Run(ctx, "dnf", "install", "-y", "epel-release")
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
// 手动安装
|
||||
var url string
|
||||
if inChina {
|
||||
url = "https://mirrors.tencent.com/epel/epel-release-latest-9.noarch.rpm"
|
||||
} else {
|
||||
url = "https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm"
|
||||
}
|
||||
_, _ = m.executor.Run(ctx, "dnf", "install", "-y", url)
|
||||
}
|
||||
|
||||
if inChina {
|
||||
// 删除无镜像的repo
|
||||
_ = os.Remove("/etc/yum.repos.d/epel-cisco-openh264.repo")
|
||||
// 设置EPEL镜像
|
||||
m.sedReplace("/etc/yum.repos.d/epel*.repo",
|
||||
"s|^#baseurl=https://download.example/pub|baseurl=https://mirrors.tencent.com|g",
|
||||
"s|^metalink|#metalink|g",
|
||||
)
|
||||
}
|
||||
|
||||
_, _ = m.executor.Run(ctx, "dnf", "config-manager", "--set-enabled", "epel")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dnfManager) sedReplace(filePattern string, expressions ...string) {
|
||||
for _, expr := range expressions {
|
||||
_, _ = m.executor.Run(context.Background(), "sed", "-i", expr, filePattern)
|
||||
}
|
||||
}
|
||||
|
||||
// aptManager APT包管理器 (Debian/Ubuntu)
|
||||
type aptManager struct {
|
||||
executor Executor
|
||||
osType types.OSType
|
||||
}
|
||||
|
||||
func (m *aptManager) UpdateCache(ctx context.Context) error {
|
||||
result, err := m.executor.Run(ctx, "apt-get", "update", "-y")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get update failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) Install(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"install", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "apt-get", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get install failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) Remove(ctx context.Context, packages ...string) error {
|
||||
args := append([]string{"purge", "--auto-remove", "-y"}, packages...)
|
||||
result, err := m.executor.Run(ctx, "apt-get", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("apt-get remove failed"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) IsInstalled(ctx context.Context, pkg string) bool {
|
||||
result, _ := m.executor.Run(ctx, "dpkg", "-s", pkg)
|
||||
return result != nil && result.ExitCode == 0 && strings.Contains(result.Stdout, "Status: install ok installed")
|
||||
}
|
||||
|
||||
func (m *aptManager) SetMirror(ctx context.Context, inChina bool) error {
|
||||
if !inChina {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.osType == types.OSDebian {
|
||||
// Debian
|
||||
m.sedReplace("/etc/apt/sources.list", "s/deb.debian.org/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/debian.sources", "s/deb.debian.org/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list",
|
||||
"s|security.debian.org/\\? |security.debian.org/debian-security |g",
|
||||
"s|security.debian.org|mirrors.tencent.com|g",
|
||||
)
|
||||
} else {
|
||||
// Ubuntu
|
||||
m.sedReplace("/etc/apt/sources.list", "s@//.*archive.ubuntu.com@//mirrors.tencent.com@g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/ubuntu.sources", "s@//.*archive.ubuntu.com@//mirrors.tencent.com@g")
|
||||
m.sedReplace("/etc/apt/sources.list", "s/security.ubuntu.com/mirrors.tencent.com/g")
|
||||
m.sedReplace("/etc/apt/sources.list.d/ubuntu.sources", "s/security.ubuntu.com/mirrors.tencent.com/g")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) EnableEPEL(ctx context.Context, inChina bool) error {
|
||||
// APT不需要EPEL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *aptManager) sedReplace(file string, expressions ...string) {
|
||||
for _, expr := range expressions {
|
||||
_, _ = m.executor.Run(context.Background(), "sed", "-i", expr, file)
|
||||
}
|
||||
}
|
||||
122
internal/system/systemd.go
Normal file
122
internal/system/systemd.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// Systemd systemd服务管理接口
|
||||
type Systemd interface {
|
||||
// Start 启动服务
|
||||
Start(ctx context.Context, service string) error
|
||||
// Stop 停止服务
|
||||
Stop(ctx context.Context, service string) error
|
||||
// Enable 启用服务
|
||||
Enable(ctx context.Context, service string) error
|
||||
// Disable 禁用服务
|
||||
Disable(ctx context.Context, service string) error
|
||||
// Restart 重启服务
|
||||
Restart(ctx context.Context, service string) error
|
||||
// IsActive 检查服务是否运行
|
||||
IsActive(ctx context.Context, service string) bool
|
||||
// DaemonReload 重载systemd配置
|
||||
DaemonReload(ctx context.Context) error
|
||||
// WriteServiceFile 写入服务文件
|
||||
WriteServiceFile(name string, content string) error
|
||||
// RemoveServiceFile 删除服务文件
|
||||
RemoveServiceFile(name string) error
|
||||
}
|
||||
|
||||
type systemd struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewSystemd 创建systemd管理器
|
||||
func NewSystemd(executor Executor) Systemd {
|
||||
return &systemd{executor: executor}
|
||||
}
|
||||
|
||||
func (s *systemd) Start(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "start", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to start"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Stop(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "stop", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to stop"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Enable(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "enable", "--now", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to enable"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Disable(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "disable", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to disable"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) Restart(ctx context.Context, service string) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "restart", service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to restart"), service, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) IsActive(ctx context.Context, service string) bool {
|
||||
result, _ := s.executor.Run(ctx, "systemctl", "is-active", service)
|
||||
return result != nil && strings.TrimSpace(result.Stdout) == "active"
|
||||
}
|
||||
|
||||
func (s *systemd) DaemonReload(ctx context.Context) error {
|
||||
result, err := s.executor.Run(ctx, "systemctl", "daemon-reload")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s: %s", i18n.T().Get("Failed to daemon-reload"), result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *systemd) WriteServiceFile(name string, content string) error {
|
||||
path := fmt.Sprintf("/etc/systemd/system/%s.service", name)
|
||||
return os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func (s *systemd) RemoveServiceFile(name string) error {
|
||||
path := fmt.Sprintf("/etc/systemd/system/%s.service", name)
|
||||
return os.Remove(path)
|
||||
}
|
||||
87
internal/system/user.go
Normal file
87
internal/system/user.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
)
|
||||
|
||||
// UserManager 用户管理接口
|
||||
type UserManager interface {
|
||||
// UserExists 检查用户是否存在
|
||||
UserExists(ctx context.Context, username string) bool
|
||||
// GroupExists 检查组是否存在
|
||||
GroupExists(ctx context.Context, groupname string) bool
|
||||
// CreateUser 创建用户
|
||||
CreateUser(ctx context.Context, username, groupname string, nologin bool) error
|
||||
// CreateGroup 创建组
|
||||
CreateGroup(ctx context.Context, groupname string) error
|
||||
// EnsureUserAndGroup 确保用户和组存在
|
||||
EnsureUserAndGroup(ctx context.Context, username, groupname string) error
|
||||
}
|
||||
|
||||
type userManager struct {
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewUserManager 创建用户管理器
|
||||
func NewUserManager(executor Executor) UserManager {
|
||||
return &userManager{executor: executor}
|
||||
}
|
||||
|
||||
func (u *userManager) UserExists(ctx context.Context, username string) bool {
|
||||
result, _ := u.executor.Run(ctx, "id", "-u", username)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (u *userManager) GroupExists(ctx context.Context, groupname string) bool {
|
||||
result, _ := u.executor.Run(ctx, "getent", "group", groupname)
|
||||
return result != nil && result.ExitCode == 0
|
||||
}
|
||||
|
||||
func (u *userManager) CreateUser(ctx context.Context, username, groupname string, nologin bool) error {
|
||||
args := []string{"-g", groupname}
|
||||
if nologin {
|
||||
args = append(args, "-s", "/sbin/nologin")
|
||||
}
|
||||
args = append(args, username)
|
||||
|
||||
result, err := u.executor.Run(ctx, "useradd", args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to create user"), username, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *userManager) CreateGroup(ctx context.Context, groupname string) error {
|
||||
result, err := u.executor.Run(ctx, "groupadd", groupname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return fmt.Errorf("%s %s: %s", i18n.T().Get("Failed to create group"), groupname, result.Stderr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *userManager) EnsureUserAndGroup(ctx context.Context, username, groupname string) error {
|
||||
// 确保组存在
|
||||
if !u.GroupExists(ctx, groupname) {
|
||||
if err := u.CreateGroup(ctx, groupname); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 确保用户存在
|
||||
if !u.UserExists(ctx, username) {
|
||||
if err := u.CreateUser(ctx, username, groupname, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
11
internal/system/wire.go
Normal file
11
internal/system/wire.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package system
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewExecutor,
|
||||
NewDetector,
|
||||
NewFirewall,
|
||||
NewSystemd,
|
||||
NewUserManager,
|
||||
)
|
||||
872
internal/ui/app.go
Normal file
872
internal/ui/app.go
Normal file
@@ -0,0 +1,872 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/helper/pkg/embed"
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/leonelquinteros/gotext"
|
||||
|
||||
"github.com/acepanel/helper/internal/service"
|
||||
"github.com/acepanel/helper/pkg/i18n"
|
||||
"github.com/acepanel/helper/pkg/types"
|
||||
)
|
||||
|
||||
// ViewState 视图状态
|
||||
type ViewState int
|
||||
|
||||
const (
|
||||
ViewLanguageSelect ViewState = iota
|
||||
ViewMainMenu
|
||||
ViewInstall
|
||||
ViewUninstall
|
||||
ViewMount
|
||||
)
|
||||
|
||||
// MenuChoice 菜单选项
|
||||
type MenuChoice int
|
||||
|
||||
const (
|
||||
MenuInstall MenuChoice = iota
|
||||
MenuUninstall
|
||||
MenuMount
|
||||
MenuExit
|
||||
)
|
||||
|
||||
// 消息类型
|
||||
type (
|
||||
progressMsg types.Progress
|
||||
installCompleteMsg struct{ err error }
|
||||
uninstallCompleteMsg struct{ err error }
|
||||
mountCompleteMsg struct{ err error }
|
||||
countdownTickMsg struct{ remaining int }
|
||||
disksLoadedMsg struct {
|
||||
disks []types.DiskInfo
|
||||
err error
|
||||
}
|
||||
)
|
||||
|
||||
// App 主应用
|
||||
type App struct {
|
||||
state ViewState
|
||||
width int
|
||||
height int
|
||||
program *tea.Program
|
||||
installer service.Installer
|
||||
uninstaller service.Uninstaller
|
||||
mounter service.Mounter
|
||||
|
||||
// 语言选择
|
||||
langForm *huh.Form
|
||||
langChoice string
|
||||
|
||||
// 主菜单
|
||||
menuForm *huh.Form
|
||||
menuChoice MenuChoice
|
||||
|
||||
// 安装
|
||||
installForm *huh.Form
|
||||
installConfirmed bool
|
||||
setupPath string
|
||||
installSpinner spinner.Model
|
||||
installProgress progress.Model
|
||||
installStep string
|
||||
installPercent float64
|
||||
installLogs []string
|
||||
installErr error
|
||||
installRunning bool
|
||||
installDone bool
|
||||
|
||||
// 卸载
|
||||
uninstallCountdown int
|
||||
uninstallSkipCount int // 连按enter跳过倒计时的计数
|
||||
uninstallForm *huh.Form
|
||||
uninstallConfirm bool
|
||||
uninstallSpinner spinner.Model
|
||||
uninstallStep string
|
||||
uninstallLogs []string
|
||||
uninstallErr error
|
||||
uninstallRunning bool
|
||||
uninstallDone bool
|
||||
uninstallState int // 0=warning, 1=countdown, 2=confirm, 3=running, 4=done
|
||||
|
||||
// 磁盘分区
|
||||
disks []types.DiskInfo
|
||||
mountDiskForm *huh.Form
|
||||
mountPointForm *huh.Form
|
||||
mountFSForm *huh.Form
|
||||
mountConfirmForm *huh.Form
|
||||
mountConfirmed bool
|
||||
mountConfig types.MountConfig
|
||||
mountSpinner spinner.Model
|
||||
mountStep string
|
||||
mountLogs []string
|
||||
mountErr error
|
||||
mountRunning bool
|
||||
mountDone bool
|
||||
mountState int // 0=loading, 1=selectDisk, 2=selectMount, 3=selectFS, 4=confirm, 5=running, 6=done
|
||||
}
|
||||
|
||||
// NewApp 创建应用
|
||||
func NewApp(installer service.Installer, uninstaller service.Uninstaller, mounter service.Mounter) *App {
|
||||
app := &App{
|
||||
state: ViewLanguageSelect,
|
||||
langChoice: "zh_CN",
|
||||
installer: installer,
|
||||
uninstaller: uninstaller,
|
||||
mounter: mounter,
|
||||
setupPath: "/opt/ace",
|
||||
mountConfig: types.MountConfig{
|
||||
MountPoint: "/opt/ace",
|
||||
FSType: types.FSTypeExt4,
|
||||
},
|
||||
}
|
||||
|
||||
// 初始化 spinner
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||||
app.installSpinner = s
|
||||
app.uninstallSpinner = s
|
||||
app.mountSpinner = s
|
||||
|
||||
// 初始化进度条
|
||||
app.installProgress = progress.New(progress.WithDefaultGradient())
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) SetProgram(p *tea.Program) {
|
||||
a.program = p
|
||||
}
|
||||
|
||||
func (a *App) Init() tea.Cmd {
|
||||
a.langForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select Language / 选择语言").
|
||||
Options(
|
||||
huh.NewOption("简体中文", "zh_CN"),
|
||||
huh.NewOption("繁體中文", "zh_TW"),
|
||||
huh.NewOption("English", "en_US"),
|
||||
).
|
||||
Value(&a.langChoice),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.langForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.installProgress.Width = msg.Width - 10
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
switch a.state {
|
||||
case ViewLanguageSelect:
|
||||
return a.updateLanguageSelect(msg)
|
||||
case ViewMainMenu:
|
||||
return a.updateMainMenu(msg)
|
||||
case ViewInstall:
|
||||
return a.updateInstall(msg)
|
||||
case ViewUninstall:
|
||||
return a.updateUninstall(msg)
|
||||
case ViewMount:
|
||||
return a.updateMount(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) View() string {
|
||||
switch a.state {
|
||||
case ViewLanguageSelect:
|
||||
return a.viewLanguageSelect()
|
||||
case ViewMainMenu:
|
||||
return a.viewMainMenu()
|
||||
case ViewInstall:
|
||||
return a.viewInstall()
|
||||
case ViewUninstall:
|
||||
return a.viewUninstall()
|
||||
case ViewMount:
|
||||
return a.viewMount()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ========== 语言选择 ==========
|
||||
|
||||
func (a *App) updateLanguageSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
form, cmd := a.langForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.langForm = f
|
||||
}
|
||||
|
||||
if a.langForm.State == huh.StateCompleted {
|
||||
locale := gotext.NewLocaleFSWithPath(a.langChoice, embed.LocalesFS, "locales")
|
||||
locale.AddDomain("helper")
|
||||
i18n.Init(locale)
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *App) viewLanguageSelect() string {
|
||||
return RenderLogo() + "\n" + a.langForm.View()
|
||||
}
|
||||
|
||||
// ========== 主菜单 ==========
|
||||
|
||||
func (a *App) initMainMenu() tea.Cmd {
|
||||
a.menuForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[MenuChoice]().
|
||||
Title(i18n.T().Get("Select Operation")).
|
||||
Options(
|
||||
huh.NewOption(i18n.T().Get("Install Panel"), MenuInstall),
|
||||
huh.NewOption(i18n.T().Get("Uninstall Panel"), MenuUninstall),
|
||||
huh.NewOption(i18n.T().Get("Disk Partition"), MenuMount),
|
||||
huh.NewOption(i18n.T().Get("Exit"), MenuExit),
|
||||
).
|
||||
Value(&a.menuChoice),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.menuForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
form, cmd := a.menuForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.menuForm = f
|
||||
}
|
||||
|
||||
if a.menuForm.State == huh.StateCompleted {
|
||||
switch a.menuChoice {
|
||||
case MenuInstall:
|
||||
a.state = ViewInstall
|
||||
a.installRunning = false
|
||||
a.installDone = false
|
||||
a.installErr = nil
|
||||
a.installLogs = nil
|
||||
return a, a.initInstall()
|
||||
case MenuUninstall:
|
||||
a.state = ViewUninstall
|
||||
a.uninstallState = 0
|
||||
a.uninstallRunning = false
|
||||
a.uninstallDone = false
|
||||
a.uninstallErr = nil
|
||||
a.uninstallLogs = nil
|
||||
a.uninstallCountdown = 60
|
||||
return a, nil
|
||||
case MenuMount:
|
||||
a.state = ViewMount
|
||||
a.mountState = 0
|
||||
a.mountRunning = false
|
||||
a.mountDone = false
|
||||
a.mountErr = nil
|
||||
a.mountLogs = nil
|
||||
return a, tea.Batch(a.mountSpinner.Tick, a.loadDisks())
|
||||
case MenuExit:
|
||||
return a, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *App) viewMainMenu() string {
|
||||
return RenderLogo() + "\n" + a.menuForm.View()
|
||||
}
|
||||
|
||||
// ========== 安装面板 ==========
|
||||
|
||||
func (a *App) initInstall() tea.Cmd {
|
||||
a.installForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Installation")).
|
||||
Description(i18n.T().Get("Panel will be installed to %s", a.setupPath)).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.installConfirmed),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return a.installForm.Init()
|
||||
}
|
||||
|
||||
func (a *App) updateInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "esc" && !a.installRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
if msg.String() == "enter" && a.installDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
|
||||
case progressMsg:
|
||||
a.installStep = msg.Step
|
||||
a.installPercent = msg.Percent
|
||||
if msg.Message != "" {
|
||||
a.installLogs = append(a.installLogs, msg.Message)
|
||||
if len(a.installLogs) > 8 {
|
||||
a.installLogs = a.installLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case installCompleteMsg:
|
||||
a.installRunning = false
|
||||
a.installDone = true
|
||||
a.installErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.installRunning {
|
||||
var cmd tea.Cmd
|
||||
a.installSpinner, cmd = a.installSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if !a.installRunning && !a.installDone {
|
||||
form, cmd := a.installForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.installForm = f
|
||||
}
|
||||
|
||||
if a.installForm.State == huh.StateCompleted {
|
||||
if a.installConfirmed {
|
||||
a.installRunning = true
|
||||
return a, tea.Batch(a.installSpinner.Tick, a.startInstall())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) startInstall() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
progressCh := make(chan types.Progress, 10)
|
||||
|
||||
go func() {
|
||||
for p := range progressCh {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg(p))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cfg := &types.InstallConfig{
|
||||
SetupPath: a.setupPath,
|
||||
AutoSwap: true,
|
||||
}
|
||||
|
||||
err := a.installer.Install(ctx, cfg, progressCh)
|
||||
close(progressCh)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return installCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewInstall() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Install Panel")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if a.installDone {
|
||||
if a.installErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Installation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.installErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Installation successful")))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
} else if a.installRunning {
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.installSpinner.View(), a.installStep))
|
||||
sb.WriteString(a.installProgress.ViewAs(a.installPercent))
|
||||
sb.WriteString("\n\n")
|
||||
for _, log := range a.installLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(a.installForm.View())
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ========== 卸载面板 ==========
|
||||
|
||||
func (a *App) updateUninstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if !a.uninstallRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
case "enter":
|
||||
if a.uninstallState == 0 {
|
||||
a.uninstallState = 1
|
||||
a.uninstallSkipCount = 0
|
||||
return a, a.tickCountdown()
|
||||
}
|
||||
if a.uninstallState == 1 {
|
||||
// 倒计时期间连按10次enter可跳过
|
||||
a.uninstallSkipCount++
|
||||
if a.uninstallSkipCount >= 10 {
|
||||
a.uninstallState = 2
|
||||
a.initUninstallConfirm()
|
||||
return a, a.uninstallForm.Init()
|
||||
}
|
||||
}
|
||||
if a.uninstallDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
case countdownTickMsg:
|
||||
a.uninstallCountdown = msg.remaining
|
||||
if a.uninstallCountdown <= 0 {
|
||||
a.uninstallState = 2
|
||||
a.initUninstallConfirm()
|
||||
return a, a.uninstallForm.Init()
|
||||
}
|
||||
return a, a.tickCountdown()
|
||||
|
||||
case progressMsg:
|
||||
a.uninstallStep = msg.Step
|
||||
if msg.Message != "" {
|
||||
a.uninstallLogs = append(a.uninstallLogs, msg.Message)
|
||||
if len(a.uninstallLogs) > 8 {
|
||||
a.uninstallLogs = a.uninstallLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case uninstallCompleteMsg:
|
||||
a.uninstallRunning = false
|
||||
a.uninstallDone = true
|
||||
a.uninstallState = 4
|
||||
a.uninstallErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.uninstallRunning {
|
||||
var cmd tea.Cmd
|
||||
a.uninstallSpinner, cmd = a.uninstallSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if a.uninstallState == 2 {
|
||||
form, cmd := a.uninstallForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.uninstallForm = f
|
||||
}
|
||||
|
||||
if a.uninstallForm.State == huh.StateCompleted {
|
||||
if a.uninstallConfirm {
|
||||
a.uninstallState = 3
|
||||
a.uninstallRunning = true
|
||||
return a, tea.Batch(a.uninstallSpinner.Tick, a.startUninstall())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) initUninstallConfirm() {
|
||||
a.uninstallConfirm = false
|
||||
a.uninstallForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Uninstallation")).
|
||||
Description(i18n.T().Get("Are you sure you want to uninstall the panel?")).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.uninstallConfirm),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) tickCountdown() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return countdownTickMsg{remaining: a.uninstallCountdown - 1}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) startUninstall() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := a.uninstaller.Uninstall(ctx, a.setupPath, func(step, message string) {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg{Step: step, Message: message})
|
||||
}
|
||||
})
|
||||
return uninstallCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewUninstall() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Uninstall Panel")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
switch a.uninstallState {
|
||||
case 0: // warning
|
||||
sb.WriteString(WarningBoxStyle.Render(
|
||||
RenderWarning(i18n.T().Get("High-risk operation")) + "\n\n" +
|
||||
i18n.T().Get("Please backup all data before uninstalling.") + "\n" +
|
||||
i18n.T().Get("All data will be cleared and cannot be recovered!"),
|
||||
))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Continue"), "Esc", i18n.T().Get("Back")))
|
||||
|
||||
case 1: // countdown
|
||||
sb.WriteString(WarningBoxStyle.Render(
|
||||
fmt.Sprintf("%s\n\n%s %d %s",
|
||||
i18n.T().Get("For safety, please wait before proceeding"),
|
||||
i18n.T().Get("Waiting:"),
|
||||
a.uninstallCountdown,
|
||||
i18n.T().Get("seconds"),
|
||||
),
|
||||
))
|
||||
sb.WriteString("\n\n")
|
||||
if a.uninstallSkipCount > 0 {
|
||||
sb.WriteString(MutedStyle.Render(fmt.Sprintf("Press Enter %d more times to skip", 10-a.uninstallSkipCount)))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(RenderHelp("Esc", i18n.T().Get("Cancel"), "Enter×10", i18n.T().Get("Skip")))
|
||||
|
||||
case 2: // confirm
|
||||
sb.WriteString(a.uninstallForm.View())
|
||||
|
||||
case 3: // running
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.uninstallSpinner.View(), a.uninstallStep))
|
||||
for _, log := range a.uninstallLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
case 4: // done
|
||||
if a.uninstallErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Uninstallation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.uninstallErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Uninstallation successful")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(i18n.T().Get("Thank you for using AcePanel."))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ========== 磁盘分区 ==========
|
||||
|
||||
func (a *App) loadDisks() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
disks, err := a.mounter.ListDisks(ctx)
|
||||
return disksLoadedMsg{disks: disks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) updateMount(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if !a.mountRunning {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
case "enter":
|
||||
if a.mountDone {
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
case disksLoadedMsg:
|
||||
if msg.err != nil {
|
||||
a.mountDone = true
|
||||
a.mountErr = msg.err
|
||||
return a, nil
|
||||
}
|
||||
if len(msg.disks) == 0 {
|
||||
a.mountDone = true
|
||||
a.mountErr = fmt.Errorf(i18n.T().Get("No available disks found"))
|
||||
return a, nil
|
||||
}
|
||||
a.disks = msg.disks
|
||||
a.mountState = 1
|
||||
a.initDiskForm()
|
||||
return a, a.mountDiskForm.Init()
|
||||
|
||||
case progressMsg:
|
||||
a.mountStep = msg.Step
|
||||
if msg.Message != "" {
|
||||
a.mountLogs = append(a.mountLogs, msg.Message)
|
||||
if len(a.mountLogs) > 8 {
|
||||
a.mountLogs = a.mountLogs[1:]
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case mountCompleteMsg:
|
||||
a.mountRunning = false
|
||||
a.mountDone = true
|
||||
a.mountErr = msg.err
|
||||
return a, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if a.mountState == 0 || a.mountRunning {
|
||||
var cmd tea.Cmd
|
||||
a.mountSpinner, cmd = a.mountSpinner.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch a.mountState {
|
||||
case 1: // select disk
|
||||
form, cmd := a.mountDiskForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountDiskForm = f
|
||||
}
|
||||
if a.mountDiskForm.State == huh.StateCompleted {
|
||||
a.mountState = 2
|
||||
a.initMountPointForm()
|
||||
return a, a.mountPointForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 2: // select mount point
|
||||
form, cmd := a.mountPointForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountPointForm = f
|
||||
}
|
||||
if a.mountPointForm.State == huh.StateCompleted {
|
||||
a.mountState = 3
|
||||
a.initFSForm()
|
||||
return a, a.mountFSForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 3: // select fs
|
||||
form, cmd := a.mountFSForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountFSForm = f
|
||||
}
|
||||
if a.mountFSForm.State == huh.StateCompleted {
|
||||
a.mountState = 4
|
||||
a.initMountConfirmForm()
|
||||
return a, a.mountConfirmForm.Init()
|
||||
}
|
||||
return a, cmd
|
||||
|
||||
case 4: // confirm
|
||||
form, cmd := a.mountConfirmForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
a.mountConfirmForm = f
|
||||
}
|
||||
if a.mountConfirmForm.State == huh.StateCompleted {
|
||||
if a.mountConfirmed {
|
||||
a.mountState = 5
|
||||
a.mountRunning = true
|
||||
return a, tea.Batch(a.mountSpinner.Tick, a.startMount())
|
||||
}
|
||||
a.state = ViewMainMenu
|
||||
return a, a.initMainMenu()
|
||||
}
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) initDiskForm() {
|
||||
options := make([]huh.Option[string], len(a.disks))
|
||||
for i, disk := range a.disks {
|
||||
options[i] = huh.NewOption(fmt.Sprintf("%s (%s)", disk.Name, disk.Size), disk.Name)
|
||||
}
|
||||
|
||||
a.mountDiskForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(i18n.T().Get("Select Disk")).
|
||||
Description(i18n.T().Get("Select a disk to partition and mount")).
|
||||
Options(options...).
|
||||
Value(&a.mountConfig.Disk),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initMountPointForm() {
|
||||
a.mountPointForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(i18n.T().Get("Mount Point")).
|
||||
Description(i18n.T().Get("Enter the mount point path (e.g. /opt/ace)")).
|
||||
Value(&a.mountConfig.MountPoint).
|
||||
Validate(func(s string) error {
|
||||
if len(s) == 0 || s[0] != '/' {
|
||||
return fmt.Errorf(i18n.T().Get("Please enter an absolute path"))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initFSForm() {
|
||||
a.mountFSForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[types.FSType]().
|
||||
Title(i18n.T().Get("File System")).
|
||||
Description(i18n.T().Get("Select the file system type")).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf("ext4 (%s)", i18n.T().Get("Recommended")), types.FSTypeExt4),
|
||||
huh.NewOption("xfs", types.FSTypeXFS),
|
||||
).
|
||||
Value(&a.mountConfig.FSType),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) initMountConfirmForm() {
|
||||
a.mountConfirmed = false
|
||||
a.mountConfirmForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(i18n.T().Get("Confirm Operation")).
|
||||
Description(i18n.T().Get("Disk: %s, Mount Point: %s, File System: %s",
|
||||
a.mountConfig.Disk,
|
||||
a.mountConfig.MountPoint,
|
||||
a.mountConfig.FSType,
|
||||
)).
|
||||
Affirmative(i18n.T().Get("Yes")).
|
||||
Negative(i18n.T().Get("No")).
|
||||
Value(&a.mountConfirmed),
|
||||
),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
}
|
||||
|
||||
func (a *App) startMount() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := a.mounter.Mount(ctx, &a.mountConfig, func(step, message string) {
|
||||
if a.program != nil {
|
||||
a.program.Send(progressMsg{Step: step, Message: message})
|
||||
}
|
||||
})
|
||||
return mountCompleteMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) viewMount() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(RenderLogo())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(RenderTitle(i18n.T().Get("Disk Partition")))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if a.mountDone {
|
||||
if a.mountErr != nil {
|
||||
sb.WriteString(RenderError(i18n.T().Get("Operation failed")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(ErrorBoxStyle.Render(a.mountErr.Error()))
|
||||
} else {
|
||||
sb.WriteString(RenderSuccess(i18n.T().Get("Partition and mount successful")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(BoxStyle.Render(fmt.Sprintf(
|
||||
"%s: /dev/%s1\n%s: %s\n%s: %s",
|
||||
i18n.T().Get("Device"), a.mountConfig.Disk,
|
||||
i18n.T().Get("Mount Point"), a.mountConfig.MountPoint,
|
||||
i18n.T().Get("File System"), a.mountConfig.FSType,
|
||||
)))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(RenderHelp("Enter", i18n.T().Get("Back")))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
switch a.mountState {
|
||||
case 0: // loading
|
||||
sb.WriteString(fmt.Sprintf("%s %s", a.mountSpinner.View(), i18n.T().Get("Loading disk list...")))
|
||||
|
||||
case 1: // select disk
|
||||
sb.WriteString(i18n.T().Get("Available disks:"))
|
||||
sb.WriteString("\n\n")
|
||||
for _, disk := range a.disks {
|
||||
sb.WriteString(fmt.Sprintf(" • %s (%s)\n", disk.Name, disk.Size))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(a.mountDiskForm.View())
|
||||
|
||||
case 2: // select mount point
|
||||
sb.WriteString(a.mountPointForm.View())
|
||||
|
||||
case 3: // select fs
|
||||
sb.WriteString(a.mountFSForm.View())
|
||||
|
||||
case 4: // confirm
|
||||
sb.WriteString(WarningBoxStyle.Render(i18n.T().Get("Warning: This will erase all data on the disk!")))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(a.mountConfirmForm.View())
|
||||
|
||||
case 5: // running
|
||||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.mountSpinner.View(), a.mountStep))
|
||||
for _, log := range a.mountLogs {
|
||||
sb.WriteString(LogStyle.Render(log))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
112
internal/ui/styles.go
Normal file
112
internal/ui/styles.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// 颜色定义
|
||||
var (
|
||||
ColorPrimary = lipgloss.Color("#18a058") // 绿色主色
|
||||
ColorSecondary = lipgloss.Color("#36ad6a") // 浅绿色
|
||||
ColorSuccess = lipgloss.Color("#18a058") // 绿色成功
|
||||
ColorWarning = lipgloss.Color("#f0a020") // 橙黄色警告
|
||||
ColorError = lipgloss.Color("#d03050") // 红色错误
|
||||
ColorMuted = lipgloss.Color("#909399") // 灰色次要
|
||||
ColorHighlight = lipgloss.Color("#2080f0") // 蓝色高亮
|
||||
)
|
||||
|
||||
// Logo ASCII艺术
|
||||
const Logo = `
|
||||
_ ____ _
|
||||
/ \ ___ ___ | _ \ __ _ _ __ ___| |
|
||||
/ _ \ / __/ _ \ | |_) / _` + "`" + ` | '_ \ / _ \ |
|
||||
/ ___ \ (_| __/ | __/ (_| | | | | __/ |
|
||||
/_/ \_\___\___| |_| \__,_|_| |_|\___|_|
|
||||
`
|
||||
|
||||
// 样式定义
|
||||
var (
|
||||
LogoStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorPrimary).
|
||||
MarginBottom(1)
|
||||
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorPrimary).
|
||||
MarginBottom(1)
|
||||
|
||||
SuccessStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorSuccess).
|
||||
Bold(true)
|
||||
|
||||
WarningStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorWarning).
|
||||
Bold(true)
|
||||
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorError).
|
||||
Bold(true)
|
||||
|
||||
MutedStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted)
|
||||
|
||||
BoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorMuted).
|
||||
Padding(1, 2)
|
||||
|
||||
WarningBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorWarning).
|
||||
Padding(1, 2)
|
||||
|
||||
ErrorBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorError).
|
||||
Padding(1, 2)
|
||||
|
||||
LogStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
PaddingLeft(4)
|
||||
|
||||
KeyStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
Background(lipgloss.Color("#374151")).
|
||||
Padding(0, 1)
|
||||
|
||||
HelpStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
func RenderLogo() string {
|
||||
return LogoStyle.Render(Logo)
|
||||
}
|
||||
|
||||
func RenderTitle(title string) string {
|
||||
return TitleStyle.Render(title)
|
||||
}
|
||||
|
||||
func RenderSuccess(msg string) string {
|
||||
return SuccessStyle.Render("✓ " + msg)
|
||||
}
|
||||
|
||||
func RenderError(msg string) string {
|
||||
return ErrorStyle.Render("✗ " + msg)
|
||||
}
|
||||
|
||||
func RenderWarning(msg string) string {
|
||||
return WarningStyle.Render("⚠ " + msg)
|
||||
}
|
||||
|
||||
func RenderHelp(keys ...string) string {
|
||||
var result string
|
||||
for i := 0; i < len(keys); i += 2 {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
if i+1 < len(keys) {
|
||||
result += KeyStyle.Render(keys[i]) + " " + MutedStyle.Render(keys[i+1])
|
||||
}
|
||||
}
|
||||
return HelpStyle.Render(result)
|
||||
}
|
||||
Reference in New Issue
Block a user