2
0
mirror of https://github.com/acepanel/helper.git synced 2026-02-04 08:57:15 +08:00

feat: init

This commit is contained in:
2026-01-17 22:58:56 +08:00
commit 3799eaae4b
34 changed files with 3745 additions and 0 deletions

31
internal/app/helper.go Normal file
View 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
View File

@@ -0,0 +1,7 @@
package app
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewHelper,
)

View 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
View 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
}

View 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
View 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
View 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
}

View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}