diff --git a/internal/app/app.go b/internal/app/app.go index 17e01bf..fd73578 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -84,7 +84,8 @@ func Run(ctx context.Context, cfg config.InstallConfig) error { return err } - if os.Geteuid() != 0 { + missingPackages := fcitx.MissingPackages() + if os.Geteuid() != 0 && len(missingPackages) > 0 { if dialogUI != nil { if err := validateSudoWithDialog(ctx, runner, dialogUI, detectedEnv.FingerprintAvailable); err != nil { if errors.Is(err, ui.ErrCancelled) { @@ -140,7 +141,7 @@ func Run(ctx context.Context, cfg config.InstallConfig) error { } if err := step("安装 Fcitx5 与依赖", func() error { - return fcitx.InstallPackages(ctx, runner, cfg, detectedEnv) + return fcitx.InstallPackages(ctx, runner, missingPackages, detectedEnv) }); err != nil { closeGauge(gauge) return fail(dialogUI, logger, err) @@ -181,7 +182,7 @@ func Run(ctx context.Context, cfg config.InstallConfig) error { if err := step("下载并补丁雾凇拼音", func() error { var err error - rimeWorkspace, err = rime.PrepareWorkspace(ctx, downloader, cfg.WorkspaceDir) + rimeWorkspace, err = rime.PrepareWorkspace(ctx, downloader, cfg.WorkspaceDir, cfg.AssetDir) return err }); err != nil { closeGauge(gauge) @@ -191,7 +192,7 @@ func Run(ctx context.Context, cfg config.InstallConfig) error { if cfg.EnableWanxiang { if err := step("下载并启用万象语法模型", func() error { var err error - modelRelease, err = wanxiang.Integrate(ctx, downloader, cfg.WorkspaceDir, rimeWorkspace.SourceDir) + modelRelease, err = wanxiang.Integrate(ctx, downloader, cfg.WorkspaceDir, rimeWorkspace.SourceDir, cfg.AssetDir) return err }); err != nil { closeGauge(gauge) diff --git a/internal/config/config.go b/internal/config/config.go index e3554e8..2efc8c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type InstallConfig struct { DryRun bool Verbose bool WorkspaceDir string + AssetDir string } type DetectedEnv struct { diff --git a/internal/fcitx/fcitx.go b/internal/fcitx/fcitx.go index fba202a..f252ca1 100644 --- a/internal/fcitx/fcitx.go +++ b/internal/fcitx/fcitx.go @@ -34,10 +34,23 @@ const ( defaultGroupName = "Default" ) -func InstallPackages(ctx context.Context, runner *system.Runner, cfg config.InstallConfig, env config.DetectedEnv) error { - args := append([]string{"-S", "--needed", "--noconfirm"}, pacmanPackages...) - if err := runner.RunPrivileged(ctx, "pacman", args...); err != nil { - return err +// MissingPackages 返回尚未安装的 pacman 包。 +func MissingPackages() []string { + var missing []string + for _, pkg := range pacmanPackages { + if exec.Command("pacman", "-Q", pkg).Run() != nil { + missing = append(missing, pkg) + } + } + return missing +} + +func InstallPackages(ctx context.Context, runner *system.Runner, missing []string, env config.DetectedEnv) error { + if len(missing) > 0 { + args := append([]string{"-S", "--needed", "--noconfirm"}, missing...) + if err := runner.RunPrivileged(ctx, "pacman", args...); err != nil { + return err + } } if !env.HasOctagramPlugin { diff --git a/internal/rime/asset_dir_test.go b/internal/rime/asset_dir_test.go new file mode 100644 index 0000000..9e66346 --- /dev/null +++ b/internal/rime/asset_dir_test.go @@ -0,0 +1,49 @@ +package rime + +import ( + "archive/zip" + "context" + "os" + "path/filepath" + "testing" +) + +func TestPrepareWorkspaceReadsFullZipFromAssetDir(t *testing.T) { + assetDir := t.TempDir() + archive, err := os.Create(filepath.Join(assetDir, rimeIceAsset)) + if err != nil { + t.Fatal(err) + } + writer := zip.NewWriter(archive) + entry, err := writer.Create("rime_ice.schema.yaml") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte("schema:\n schema_id: rime_ice\n")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + archive.Close() + + workspace := t.TempDir() + prepared, err := PrepareWorkspace(context.Background(), nil, workspace, assetDir) + if err != nil { + t.Fatalf("PrepareWorkspace() error = %v", err) + } + if prepared.Release.Tag != "local" || prepared.Release.AssetName != rimeIceAsset { + t.Fatalf("release = %+v, want local %s", prepared.Release, rimeIceAsset) + } + for _, name := range []string{"rime_ice.schema.yaml", "default.custom.yaml", "user.yaml"} { + if _, err := os.Stat(filepath.Join(prepared.SourceDir, name)); err != nil { + t.Fatalf("%s missing from prepared workspace: %v", name, err) + } + } +} + +func TestPrepareWorkspaceFailsWithoutLocalFullZip(t *testing.T) { + if _, err := PrepareWorkspace(context.Background(), nil, t.TempDir(), t.TempDir()); err == nil { + t.Fatal("PrepareWorkspace() succeeded without full.zip in the asset dir") + } +} diff --git a/internal/rime/rime.go b/internal/rime/rime.go index 1db520b..45900be 100644 --- a/internal/rime/rime.go +++ b/internal/rime/rime.go @@ -26,20 +26,28 @@ type PreparedWorkspace struct { Release config.ReleaseInfo } -func PrepareWorkspace(ctx context.Context, client *download.Client, workspace string) (*PreparedWorkspace, error) { - release, err := client.ReleaseByTag(ctx, rimeIceOwner, rimeIceRepo, rimeIceTag) - if err != nil { - return nil, err - } +func PrepareWorkspace(ctx context.Context, client *download.Client, workspace, assetDir string) (*PreparedWorkspace, error) { + info := config.ReleaseInfo{Owner: rimeIceOwner, Repo: rimeIceRepo, Tag: "local", AssetName: rimeIceAsset} + downloadPath := filepath.Join(assetDir, rimeIceAsset) + if assetDir == "" { + release, err := client.ReleaseByTag(ctx, rimeIceOwner, rimeIceRepo, rimeIceTag) + if err != nil { + return nil, err + } - asset, err := download.FindAsset(release, rimeIceAsset) - if err != nil { - return nil, err - } + asset, err := download.FindAsset(release, rimeIceAsset) + if err != nil { + return nil, err + } - downloadPath := filepath.Join(workspace, "downloads", asset.Name) - if err := client.DownloadAsset(ctx, asset, downloadPath); err != nil { - return nil, err + downloadPath = filepath.Join(workspace, "downloads", asset.Name) + if err := client.DownloadAsset(ctx, asset, downloadPath); err != nil { + return nil, err + } + info.Tag = release.TagName + info.AssetName = asset.Name + info.Digest = normalizeDigest(asset.Digest) + info.URL = asset.BrowserDownloadURL } sourceDir := filepath.Join(workspace, "source", "rime-ice") @@ -54,17 +62,7 @@ func PrepareWorkspace(ctx context.Context, client *download.Client, workspace st return nil, err } - return &PreparedWorkspace{ - SourceDir: sourceDir, - Release: config.ReleaseInfo{ - Owner: rimeIceOwner, - Repo: rimeIceRepo, - Tag: release.TagName, - AssetName: asset.Name, - Digest: normalizeDigest(asset.Digest), - URL: asset.BrowserDownloadURL, - }, - }, nil + return &PreparedWorkspace{SourceDir: sourceDir, Release: info}, nil } func MergeDefaultCustomConfig(path string) error { diff --git a/internal/wanxiang/asset_dir_test.go b/internal/wanxiang/asset_dir_test.go new file mode 100644 index 0000000..23554b3 --- /dev/null +++ b/internal/wanxiang/asset_dir_test.go @@ -0,0 +1,33 @@ +package wanxiang + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIntegrateReadsModelFromAssetDir(t *testing.T) { + assetDir := t.TempDir() + if err := os.WriteFile(filepath.Join(assetDir, modelAsset), []byte("model"), 0o644); err != nil { + t.Fatal(err) + } + + sourceDir := t.TempDir() + info, err := Integrate(context.Background(), nil, t.TempDir(), sourceDir, assetDir) + if err != nil { + t.Fatalf("Integrate() error = %v", err) + } + if info.Tag != "local" || info.AssetName != modelAsset { + t.Fatalf("release = %+v, want local %s", info, modelAsset) + } + model, err := os.ReadFile(filepath.Join(sourceDir, modelAsset)) + if err != nil || string(model) != "model" { + t.Fatalf("model copy = %q, %v", model, err) + } + custom, err := os.ReadFile(filepath.Join(sourceDir, "rime_ice.custom.yaml")) + if err != nil || !strings.Contains(string(custom), "wanxiang-lts-zh-hans") { + t.Fatalf("custom yaml = %q, %v", custom, err) + } +} diff --git a/internal/wanxiang/wanxiang.go b/internal/wanxiang/wanxiang.go index f629977..a0fd266 100644 --- a/internal/wanxiang/wanxiang.go +++ b/internal/wanxiang/wanxiang.go @@ -21,23 +21,31 @@ const ( modelAsset = "wanxiang-lts-zh-hans.gram" ) -func Integrate(ctx context.Context, client *download.Client, workspace, sourceDir string) (*config.ReleaseInfo, error) { - release, err := client.ReleaseByTag(ctx, modelOwner, modelRepo, modelTag) - if err != nil { - return nil, err - } +func Integrate(ctx context.Context, client *download.Client, workspace, sourceDir, assetDir string) (*config.ReleaseInfo, error) { + info := &config.ReleaseInfo{Owner: modelOwner, Repo: modelRepo, Tag: "local", AssetName: modelAsset} + downloadPath := filepath.Join(assetDir, modelAsset) + if assetDir == "" { + release, err := client.ReleaseByTag(ctx, modelOwner, modelRepo, modelTag) + if err != nil { + return nil, err + } - asset, err := download.FindAsset(release, modelAsset) - if err != nil { - return nil, err - } + asset, err := download.FindAsset(release, modelAsset) + if err != nil { + return nil, err + } - downloadPath := filepath.Join(workspace, "downloads", asset.Name) - if err := client.DownloadAsset(ctx, asset, downloadPath); err != nil { - return nil, err + downloadPath = filepath.Join(workspace, "downloads", asset.Name) + if err := client.DownloadAsset(ctx, asset, downloadPath); err != nil { + return nil, err + } + info.Tag = release.TagName + info.AssetName = asset.Name + info.Digest = strings.TrimPrefix(asset.Digest, "sha256:") + info.URL = asset.BrowserDownloadURL } - targetModelPath := filepath.Join(sourceDir, asset.Name) + targetModelPath := filepath.Join(sourceDir, modelAsset) modelContent, err := os.ReadFile(downloadPath) if err != nil { return nil, fmt.Errorf("读取模型文件失败: %w", err) @@ -50,14 +58,7 @@ func Integrate(ctx context.Context, client *download.Client, workspace, sourceDi return nil, err } - return &config.ReleaseInfo{ - Owner: modelOwner, - Repo: modelRepo, - Tag: release.TagName, - AssetName: asset.Name, - Digest: strings.TrimPrefix(asset.Digest, "sha256:"), - URL: asset.BrowserDownloadURL, - }, nil + return info, nil } func MergeCustomConfig(path string) error { diff --git a/main.go b/main.go index 50f00f3..15439bd 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ func main() { flag.BoolVar(&cfg.EnableWanxiang, "enable-wanxiang", cfg.EnableWanxiang, "下载并启用万象 LTS 语法模型") flag.IntVar(&cfg.FontSize, "font-size", cfg.FontSize, "Fcitx5 候选框字体大小(8-48)") flag.StringVar(&cfg.WorkspaceDir, "workspace-dir", cfg.WorkspaceDir, "工作目录,用于下载缓存和日志") + flag.StringVar(&cfg.AssetDir, "asset-dir", "", "从此目录读取 full.zip 和万象模型,不访问 GitHub") flag.Parse() if err := app.Run(context.Background(), cfg); err != nil {