commit 3799eaae4b3a99ab74ebb1d0bac9b928cf45f663 Author: 耗子 Date: Sat Jan 17 22:58:56 2026 +0800 feat: init diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dfb17a5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,44 @@ +name: Build +on: + push: + branches: + - main + pull_request: +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + goarch: [ amd64, arm64 ] + fail-fast: true + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: true + go-version: 'stable' + - name: Install dependencies + run: go mod tidy + - name: Build ${{ matrix.goarch }} + env: + CGO_ENABLED: 0 + GOOS: linux + GOARCH: ${{ matrix.goarch }} + run: | + LDFLAGS="-s -w --extldflags '-static'" + go build -trimpath -buildvcs=false -ldflags "${LDFLAGS}" -o helper-${{ matrix.goarch }} ./cmd/helper + - name: Compress ${{ matrix.goarch }} + run: | + upx --best --lzma helper-${{ matrix.goarch }} + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: helper-${{ matrix.goarch }} + path: | + helper-${{ matrix.goarch }} diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..e4e7113 --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,27 @@ +name: Release +on: + push: + tags: + - 'v*' +permissions: + contents: write +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: true + go-version: 'stable' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml new file mode 100644 index 0000000..0d597bf --- /dev/null +++ b/.github/workflows/l10n.yml @@ -0,0 +1,47 @@ +name: L10n +on: + workflow_dispatch: +concurrency: + group: l10n + cancel-in-progress: true +permissions: + contents: write + pull-requests: write +jobs: + l10n: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: true + go-version: 'stable' + - name: Install gettext + run: | + sudo apt-get install -y gettext + - name: Install xgotext + run: | + go install github.com/leonelquinteros/gotext/cli/xgotext@latest + - name: Generate pot files + run: | + ~/go/bin/xgotext -default helper -pkg-tree ./cmd/helper -out ./pkg/embed/locales + - uses: stefanzweifel/git-auto-commit-action@v7 + name: Commit changes + with: + commit_message: "chore(l10n): update pot files" + - name: Sync with Crowdin + uses: crowdin/github-action@v2 + with: + config: crowdin.yml + upload_sources: true + upload_translations: false + download_translations: true + export_only_approved: true + create_pull_request: true + pull_request_title: 'l10n: sync translations with Crowdin' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..695d633 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint +on: + push: + branches: + - main + pull_request: +permissions: + contents: read +jobs: + golangci: + name: golanci-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: true + go-version: 'stable' + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + skip-cache: true + version: latest + args: --timeout=30m ./... + govulncheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Go + uses: actions/setup-go@v6 + with: + cache: true + go-version: 'stable' + - name: Install Govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run Govulncheck + run: govulncheck ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efdb5b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# `go test -c` 生成的二进制文件 +*.test + +# go coverage 工具 +*.out +*.prof +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +# 编译文件 +/helper +*.com +*.class +*.dll +*.exe +*.o +*.so + +# 压缩包 +# Git 自带压缩,如果这些压缩包里有代码,建议解压后 commit +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# 日志文件和数据库及配置 +*.log +*.sqlite +*.db +config.yml + +# 临时文件 +tmp/ +.tmp/ + +# 系统生成文件 +.DS_Store +.DS_Store? +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +.TemporaryItems +.fseventsd +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# IDE 和编辑器 +.idea/ +/go_build_* +out/ +.vscode/ +.vscode/settings.json +*.sublime* +__debug_bin +.project diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7757327 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,42 @@ +version: 2 +project_name: helper + +builds: + - id: helper + main: ./cmd/helper + binary: helper + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + flags: + - -trimpath + - -buildvcs=false + ldflags: + - -s -w --extldflags "-static" + +upx: + - enabled: true + # Filter by build ID. + ids: + - helper + # Compress argument. + # Valid options are from '1' (faster) to '9' (better), and 'best'. + compress: best + # Whether to try LZMA (slower). + lzma: true + # Whether to try all methods and filters (slow). + brute: false + +archives: + - id: helper + ids: + - helper + formats: ["zip"] + wrap_in_directory: false + strip_binary_directory: true + files: + - LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f84ca8d --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +Copyright (c) 2022-2025, AcePanel contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +免责声明 + +本软件是按照现有技术和条件所能达到的现状提供的,用户出于自愿而使用本软件,树新峰(耗子科技)不作任何形式的保证,包括但不限于: + +1. 树新峰不对于因使用或无法使用本软件而造成的任何直接、间接、偶然、附带、特殊及后续的「损害」承担责任,即使已被告知此类「损害」的可能性; +2. 树新峰无组织本软件的交流社区的义务与责任,不承担因技术交流导致某一方故障而产生的「损害」; +3. 树新峰不对本软件的无故障、适用性、可用性、准确性、质量满意度等做任何形式的保证; +4. 树新峰不保证本软件可以满足您的要求,也不保证其内容、服务或功能无任何错误或不会中断,不保证任何缺陷将得到更正; +5. 树新峰对本软件提供的任何信息或建议均不构成任何担保; +6. 用户因使用本软件违反国家法律法规的,树新峰不承担任何责任; +7. 本条款所指的「损害」包括但不限于经济损失、利润损失、收入损失、业务中断、数据丢失等。 diff --git a/cmd/helper/main.go b/cmd/helper/main.go new file mode 100644 index 0000000..4a00975 --- /dev/null +++ b/cmd/helper/main.go @@ -0,0 +1,16 @@ +package main + +import ( + _ "time/tzdata" +) + +func main() { + helper, err := initHelper() + if err != nil { + panic(err) + } + + if err = helper.Run(); err != nil { + panic(err) + } +} diff --git a/cmd/helper/wire.go b/cmd/helper/wire.go new file mode 100644 index 0000000..c2bb5c2 --- /dev/null +++ b/cmd/helper/wire.go @@ -0,0 +1,19 @@ +//go:build wireinject + +package main + +import ( + "github.com/google/wire" + + "github.com/acepanel/helper/internal/app" + "github.com/acepanel/helper/internal/service" + "github.com/acepanel/helper/internal/system" +) + +func initHelper() (*app.Helper, error) { + panic(wire.Build( + system.ProviderSet, + service.ProviderSet, + app.ProviderSet, + )) +} diff --git a/cmd/helper/wire_gen.go b/cmd/helper/wire_gen.go new file mode 100644 index 0000000..205800c --- /dev/null +++ b/cmd/helper/wire_gen.go @@ -0,0 +1,32 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "github.com/acepanel/helper/internal/app" + "github.com/acepanel/helper/internal/service" + "github.com/acepanel/helper/internal/system" +) + +import ( + _ "time/tzdata" +) + +// Injectors from wire.go: + +func initHelper() (*app.Helper, error) { + executor := system.NewExecutor() + detector := system.NewDetector(executor) + firewall := system.NewFirewall(executor, detector) + systemd := system.NewSystemd(executor) + userManager := system.NewUserManager(executor) + installer := service.NewInstaller(detector, executor, firewall, systemd, userManager) + uninstaller := service.NewUninstaller(detector, executor, systemd) + mounter := service.NewMounter(detector, executor) + helper := app.NewHelper(installer, uninstaller, mounter) + return helper, nil +} diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..611c12f --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,5 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +files: + - source: /pkg/embed/locales/*.pot + translation: /pkg/embed/locales/%locale_with_underscore%/%file_name%.po diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3b48f7d --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/acepanel/helper + +go 1.25 + +require ( + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-resty/resty/v2 v2.17.1 + github.com/google/wire v0.7.0 + github.com/leonelquinteros/gotext v1.7.2 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.11.4 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.7.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..91c7d3e --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= +github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= +github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed h1:pzOOuU7EBl363fKRy1cEpm6iwuzp0dkL9D0+ykomuIg= +github.com/charmbracelet/x/exp/strings v0.0.0-20260116010723-b770f9f0bfed/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc= +github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= diff --git a/internal/app/helper.go b/internal/app/helper.go new file mode 100644 index 0000000..61e1a15 --- /dev/null +++ b/internal/app/helper.go @@ -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 +} diff --git a/internal/app/wire.go b/internal/app/wire.go new file mode 100644 index 0000000..23be300 --- /dev/null +++ b/internal/app/wire.go @@ -0,0 +1,7 @@ +package app + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet( + NewHelper, +) diff --git a/internal/service/installer.go b/internal/service/installer.go new file mode 100644 index 0000000..9c01dc6 --- /dev/null +++ b/internal/service/installer.go @@ -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 +} diff --git a/internal/service/mounter.go b/internal/service/mounter.go new file mode 100644 index 0000000..2dce67b --- /dev/null +++ b/internal/service/mounter.go @@ -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 +} diff --git a/internal/service/uninstaller.go b/internal/service/uninstaller.go new file mode 100644 index 0000000..0b52371 --- /dev/null +++ b/internal/service/uninstaller.go @@ -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 +} diff --git a/internal/service/wire.go b/internal/service/wire.go new file mode 100644 index 0000000..489f9fd --- /dev/null +++ b/internal/service/wire.go @@ -0,0 +1,9 @@ +package service + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet( + NewInstaller, + NewUninstaller, + NewMounter, +) diff --git a/internal/system/detector.go b/internal/system/detector.go new file mode 100644 index 0000000..e647b52 --- /dev/null +++ b/internal/system/detector.go @@ -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 +} diff --git a/internal/system/executor.go b/internal/system/executor.go new file mode 100644 index 0000000..eb6eaee --- /dev/null +++ b/internal/system/executor.go @@ -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() +} diff --git a/internal/system/firewall.go b/internal/system/firewall.go new file mode 100644 index 0000000..db8e17b --- /dev/null +++ b/internal/system/firewall.go @@ -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 +} diff --git a/internal/system/package.go b/internal/system/package.go new file mode 100644 index 0000000..39b1153 --- /dev/null +++ b/internal/system/package.go @@ -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) + } +} diff --git a/internal/system/systemd.go b/internal/system/systemd.go new file mode 100644 index 0000000..c0dea32 --- /dev/null +++ b/internal/system/systemd.go @@ -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) +} diff --git a/internal/system/user.go b/internal/system/user.go new file mode 100644 index 0000000..65d64c4 --- /dev/null +++ b/internal/system/user.go @@ -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 +} diff --git a/internal/system/wire.go b/internal/system/wire.go new file mode 100644 index 0000000..2f1cfb9 --- /dev/null +++ b/internal/system/wire.go @@ -0,0 +1,11 @@ +package system + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet( + NewExecutor, + NewDetector, + NewFirewall, + NewSystemd, + NewUserManager, +) diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..1dfdc01 --- /dev/null +++ b/internal/ui/app.go @@ -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() +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..ac6989c --- /dev/null +++ b/internal/ui/styles.go @@ -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) +} diff --git a/pkg/embed/embed.go b/pkg/embed/embed.go new file mode 100644 index 0000000..2c95743 --- /dev/null +++ b/pkg/embed/embed.go @@ -0,0 +1,6 @@ +package embed + +import "embed" + +//go:embed all:locales/* +var LocalesFS embed.FS diff --git a/pkg/embed/locales/.gitkeep b/pkg/embed/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/embed/locales/helper.pot b/pkg/embed/locales/helper.pot new file mode 100644 index 0000000..45b1017 --- /dev/null +++ b/pkg/embed/locales/helper.pot @@ -0,0 +1,398 @@ +msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: \n" +"X-Generator: xgotext\n" + +#: internal/ui/app.go:558 +msgid "All data will be cleared and cannot be recovered!" +msgstr "" + +#: internal/ui/app.go:840 +msgid "Available disks:" +msgstr "" + +#: internal/ui/app.go:415 +#: internal/ui/app.go:561 +#: internal/ui/app.go:600 +#: internal/ui/app.go:831 +msgid "Back" +msgstr "" + +#: internal/ui/app.go:577 +msgid "Cancel" +msgstr "" + +#: internal/service/mounter.go:58 +msgid "Cannot operate on system disk" +msgstr "" + +#: internal/service/installer.go:55 +msgid "Checking system requirements" +msgstr "" + +#: internal/service/installer.go:61 +msgid "Configuring firewall" +msgstr "" + +#: internal/ui/app.go:305 +msgid "Confirm Installation" +msgstr "" + +#: internal/ui/app.go:783 +msgid "Confirm Operation" +msgstr "" + +#: internal/ui/app.go:561 +msgid "Continue" +msgstr "" + +#: internal/service/mounter.go:78 +msgid "Creating %s..." +msgstr "" + +#: internal/service/mounter.go:78 +msgid "Creating mount point" +msgstr "" + +#: internal/service/mounter.go:108 +msgid "Creating partition" +msgstr "" + +#: internal/service/mounter.go:108 +msgid "Creating partition on /dev/%s..." +msgstr "" + +#: internal/service/installer.go:59 +msgid "Creating swap file" +msgstr "" + +#: internal/service/installer.go:62 +msgid "Creating systemd service" +msgstr "" + +#: internal/service/installer.go:56 +msgid "Creating www user" +msgstr "" + +#: internal/service/mounter.go:93 +msgid "Deleting existing partitions" +msgstr "" + +#: internal/service/mounter.go:93 +msgid "Deleting existing partitions..." +msgstr "" + +#: internal/service/installer.go:64 +msgid "Detecting installed apps" +msgstr "" + +#: internal/ui/app.go:825 +msgid "Device" +msgstr "" + +#: internal/ui/app.go:245 +#: internal/ui/app.go:812 +msgid "Disk Partition" +msgstr "" + +#: internal/service/mounter.go:53 +msgid "Disk not found" +msgstr "" + +#: internal/service/mounter.go:171 +msgid "Disk partition and mount successful" +msgstr "" + +#: internal/ui/app.go:784 +msgid "Disk: %s, Mount Point: %s, File System: %s" +msgstr "" + +#: internal/service/installer.go:60 +msgid "Downloading panel" +msgstr "" + +#: internal/ui/app.go:522 +msgid "Enter 'y' to confirm uninstallation" +msgstr "" + +#: internal/ui/app.go:751 +msgid "Enter the mount point path (e.g. /opt/ace)" +msgstr "" + +#: internal/service/installer.go:79 +msgid "Error" +msgstr "" + +#: internal/ui/app.go:246 +msgid "Exit" +msgstr "" + +#: internal/service/mounter.go:112 +msgid "Failed to create partition" +msgstr "" + +#: internal/service/mounter.go:143 +msgid "Failed to get UUID" +msgstr "" + +#: internal/ui/app.go:767 +#: internal/ui/app.go:827 +msgid "File System" +msgstr "" + +#: internal/ui/app.go:566 +msgid "For safety, please wait before proceeding" +msgstr "" + +#: internal/service/mounter.go:126 +msgid "Format failed" +msgstr "" + +#: internal/service/mounter.go:116 +msgid "Formatting /dev/%s1 as %s..." +msgstr "" + +#: internal/service/mounter.go:116 +msgid "Formatting partition" +msgstr "" + +#: internal/ui/app.go:556 +msgid "High-risk operation" +msgstr "" + +#: internal/service/installer.go:63 +msgid "Initializing panel" +msgstr "" + +#: internal/ui/app.go:243 +#: internal/ui/app.go:403 +msgid "Install Panel" +msgstr "" + +#: internal/service/installer.go:95 +msgid "Installation complete" +msgstr "" + +#: internal/ui/app.go:408 +msgid "Installation failed" +msgstr "" + +#: internal/ui/app.go:412 +msgid "Installation successful" +msgstr "" + +#: internal/service/installer.go:58 +msgid "Installing dependencies" +msgstr "" + +#: internal/service/mounter.go:62 +msgid "Installing partition tools" +msgstr "" + +#: internal/service/mounter.go:62 +msgid "Installing partition tools..." +msgstr "" + +#: internal/ui/app.go:837 +msgid "Loading disk list..." +msgstr "" + +#: internal/ui/app.go:750 +#: internal/ui/app.go:826 +msgid "Mount Point" +msgstr "" + +#: internal/service/mounter.go:171 +msgid "Mount complete" +msgstr "" + +#: internal/service/mounter.go:136 +msgid "Mount failed" +msgstr "" + +#: internal/service/mounter.go:86 +msgid "Mount point is not empty" +msgstr "" + +#: internal/service/mounter.go:133 +msgid "Mounting /dev/%s1 to %s..." +msgstr "" + +#: internal/service/mounter.go:133 +msgid "Mounting partition" +msgstr "" + +#: internal/ui/app.go:308 +#: internal/ui/app.go:790 +msgid "No" +msgstr "" + +#: internal/ui/app.go:640 +msgid "No available disks found" +msgstr "" + +#: internal/ui/app.go:817 +msgid "Operation failed" +msgstr "" + +#: internal/service/installer.go:57 +msgid "Optimizing system settings" +msgstr "" + +#: internal/service/installer.go:97 +msgid "Panel installed successfully" +msgstr "" + +#: internal/service/uninstaller.go:48 +msgid "Panel is not installed" +msgstr "" + +#: internal/service/uninstaller.go:85 +msgid "Panel uninstalled successfully" +msgstr "" + +#: internal/ui/app.go:306 +msgid "Panel will be installed to %s" +msgstr "" + +#: internal/ui/app.go:821 +msgid "Partition and mount successful" +msgstr "" + +#: internal/ui/app.go:557 +msgid "Please backup all data before uninstalling." +msgstr "" + +#: internal/ui/app.go:755 +msgid "Please enter an absolute path" +msgstr "" + +#: internal/ui/app.go:770 +msgid "Recommended" +msgstr "" + +#: internal/service/uninstaller.go:82 +msgid "Removing %s..." +msgstr "" + +#: internal/service/uninstaller.go:62 +msgid "Removing /usr/local/sbin/acepanel..." +msgstr "" + +#: internal/service/uninstaller.go:62 +msgid "Removing CLI tool" +msgstr "" + +#: internal/service/uninstaller.go:82 +msgid "Removing installation directory" +msgstr "" + +#: internal/service/uninstaller.go:57 +msgid "Removing service file" +msgstr "" + +#: internal/service/uninstaller.go:66 +msgid "Removing swap file" +msgstr "" + +#: internal/service/uninstaller.go:66 +msgid "Removing swap file..." +msgstr "" + +#: internal/service/uninstaller.go:57 +msgid "Removing systemd service file..." +msgstr "" + +#: internal/ui/app.go:738 +msgid "Select Disk" +msgstr "" + +#: internal/ui/app.go:241 +msgid "Select Operation" +msgstr "" + +#: internal/ui/app.go:739 +msgid "Select a disk to partition and mount" +msgstr "" + +#: internal/ui/app.go:768 +msgid "Select the file system type" +msgstr "" + +#: internal/ui/app.go:577 +msgid "Skip" +msgstr "" + +#: internal/service/uninstaller.go:52 +msgid "Stopping acepanel service..." +msgstr "" + +#: internal/service/uninstaller.go:52 +msgid "Stopping panel service" +msgstr "" + +#: internal/ui/app.go:597 +msgid "Thank you for using AcePanel." +msgstr "" + +#: internal/ui/app.go:244 +#: internal/ui/app.go:550 +msgid "Uninstall Panel" +msgstr "" + +#: internal/service/uninstaller.go:85 +msgid "Uninstallation complete" +msgstr "" + +#: internal/ui/app.go:591 +msgid "Uninstallation failed" +msgstr "" + +#: internal/ui/app.go:595 +msgid "Uninstallation successful" +msgstr "" + +#: internal/service/mounter.go:123 +msgid "Unsupported filesystem type: %s" +msgstr "" + +#: internal/service/mounter.go:140 +msgid "Updating /etc/fstab for auto-mount..." +msgstr "" + +#: internal/service/mounter.go:140 +msgid "Updating fstab" +msgstr "" + +#: internal/ui/app.go:567 +msgid "Waiting:" +msgstr "" + +#: internal/ui/app.go:855 +msgid "Warning: This will erase all data on the disk!" +msgstr "" + +#: internal/ui/app.go:307 +#: internal/ui/app.go:789 +msgid "Yes" +msgstr "" + +#: internal/service/installer.go:90 +msgid "completed" +msgstr "" + +#: internal/service/mounter.go:168 +msgid "fstab configuration error" +msgstr "" + +#: internal/service/uninstaller.go:78 +msgid "fstab configuration error, please check /etc/fstab" +msgstr "" + +#: internal/ui/app.go:569 +msgid "seconds" +msgstr "" \ No newline at end of file diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go new file mode 100644 index 0000000..24d2569 --- /dev/null +++ b/pkg/i18n/i18n.go @@ -0,0 +1,26 @@ +package i18n + +import ( + "sync" + + "github.com/leonelquinteros/gotext" +) + +var ( + locale *gotext.Locale + mu sync.RWMutex +) + +// Init 设置全局 locale +func Init(l *gotext.Locale) { + mu.Lock() + defer mu.Unlock() + locale = l +} + +// T 获取全局 locale +func T() *gotext.Locale { + mu.RLock() + defer mu.RUnlock() + return locale +} diff --git a/pkg/types/types.go b/pkg/types/types.go new file mode 100644 index 0000000..fb8a1b9 --- /dev/null +++ b/pkg/types/types.go @@ -0,0 +1,81 @@ +package types + +// OSType 操作系统类型 +type OSType string + +const ( + OSUnknown OSType = "unknown" + OSRHEL OSType = "rhel" // RHEL/CentOS/Rocky/Alma + OSDebian OSType = "debian" // Debian + OSUbuntu OSType = "ubuntu" // Ubuntu +) + +// ArchType CPU架构类型 +type ArchType string + +const ( + ArchUnknown ArchType = "unknown" + ArchAMD64 ArchType = "x86_64" + ArchARM64 ArchType = "aarch64" +) + +// FSType 文件系统类型 +type FSType string + +const ( + FSTypeExt4 FSType = "ext4" + FSTypeXFS FSType = "xfs" +) + +// DiskInfo 磁盘信息 +type DiskInfo struct { + Name string // 设备名 (如 sda, vdb) + Size string // 大小 + Type string // 类型 (disk) + Partitions []PartitionInfo // 分区列表 +} + +// PartitionInfo 分区信息 +type PartitionInfo struct { + Name string // 分区名 (如 sda1) + Size string // 大小 + FSType string // 文件系统类型 + MountPoint string // 挂载点 + UUID string // UUID +} + +// SystemInfo 系统信息 +type SystemInfo struct { + OS OSType // 操作系统类型 + Arch ArchType // CPU架构 + KernelVersion string // 内核版本 + Is64Bit bool // 是否64位 + Memory int64 // 内存大小 (MB) + Swap int64 // Swap大小 (MB) + InChina bool // 是否在中国 + SSHPort int // SSH端口 +} + +// InstallConfig 安装配置 +type InstallConfig struct { + SetupPath string // 安装路径 + InChina bool // 是否使用中国镜像 + AutoSwap bool // 是否自动创建swap +} + +// MountConfig 挂载配置 +type MountConfig struct { + Disk string // 磁盘设备名 + MountPoint string // 挂载点 + FSType FSType // 文件系统类型 + Repartition bool // 是否重新分区 +} + +// Progress 进度信息 +type Progress struct { + Step string // 当前步骤 + Message string // 消息 + Percent float64 // 进度百分比 (0-1) + IsError bool // 是否错误 + Error error // 错误信息 +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..731f2cf --- /dev/null +++ b/renovate.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "labels": [ + "🤖 Dependencies" + ], + "commitMessagePrefix": "chore(deps): ", + "lockFileMaintenance": { + "enabled": true, + "automerge": true + }, + "platformAutomerge": true, + "postUpdateOptions": [ + "gomodTidy", + "gomodUpdateImportPaths" + ], + "packageRules": [ + { + "groupName": "non-major dependencies", + "matchUpdateTypes": [ + "digest", + "pin", + "patch", + "minor" + ], + "automerge": true + } + ], + "ignoreDeps": [] +}