diff --git a/README.md b/README.md index 97d0082..cf5dbc4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ English | [中文](docs/README.zh.md) `go-chrome-ai` is a cross-platform Chrome profile patcher written in Go, with both **CLI** and **GUI** modes. -It helps enable Chrome AI-related features, including **Ask Gemini**, without reinstalling Chrome or recreating your profile. +It helps enable Chrome AI-related features (including **Ask Gemini**) without reinstalling Chrome or recreating your profile, and can also **block Chrome from silently downloading the on-device Gemini Nano model** (~2–4 GB) by flipping the relevant `chrome://flags` and applying Google's [`GenAILocalFoundationalModelSettings`](https://chromeenterprise.google/policies/gen-ai-local-foundational-model-settings/) Enterprise policy. ![go-chrome-ai social preview](docs/images/social-preview.png) @@ -67,6 +67,17 @@ It enables Chrome AI-related features (such as **Ask Gemini**) by patching local - `variations_country` -> `"us"` - `variations_permanent_consistency_country` -> `["", "us"]` (if field exists and is patchable) +It can also **block on-device AI model downloads** (Gemini Nano), which is the default in both CLI and GUI. The block applies three changes: + +- `chrome://flags/#optimization-guide-on-device-model` -> Disabled +- `chrome://flags/#prompt-api-for-gemini-nano` -> Disabled +- `GenAILocalFoundationalModelSettings = 1` written to the OS managed-policy store + - macOS: `defaults write com.google.Chrome GenAILocalFoundationalModelSettings -int 1` + - Linux: `/etc/opt/chrome/policies/managed/go-chrome-ai.json` (needs sudo) + - Windows: `HKLM\Software\Policies\Google\Chrome` REG_DWORD + +Because the third change is an Enterprise policy, Chrome will display the "managed by your organization" banner afterwards. Pass `-disable-ai-download=false` (CLI) or untick the option (GUI) if you do not want that. + ## Screenshot ![go-chrome-ai GUI](docs/images/go-chrome-ai-gui.png) @@ -86,6 +97,7 @@ Flags: - `-dry-run`: show changes without writing files or killing Chrome - `-no-restart`: patch but do not restart Chrome +- `-disable-ai-download` (default `true`): block on-device AI model downloads by disabling the relevant `chrome://flags` entries and writing `GenAILocalFoundationalModelSettings=1` to the OS managed-policy store. Use `-disable-ai-download=false` to skip. ## Run GUI @@ -98,9 +110,11 @@ Prebuilt Linux and Windows releases are CLI-only. Build from source if you want The GUI includes: - auto-detection of installed Chrome channels +- left/right split layout (configuration on the left, run controls + logs on the right) - one-click patch flow - progress bar - real-time logs +- a "Disable on-device AI model download" toggle that previews the exact `chrome://flags` and `chrome://policy` changes before you press Run ## Build From Source diff --git a/docs/README.zh.md b/docs/README.zh.md index 5db58b4..3396e1c 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -3,7 +3,7 @@ [English](../README.md) | 中文 `go-chrome-ai` 是一个用 Go 编写的跨平台 Chrome 配置修补工具,同时支持 **CLI** 和 **GUI**。 -它可以在不重装 Chrome、不重建用户配置的情况下启用相关 AI 功能(包括 **Ask Gemini**)。 +它可以在不重装 Chrome、不重建用户配置的情况下启用相关 AI 功能(包括 **Ask Gemini**),也可以通过翻转相关的 `chrome://flags` 配合 Google 官方的 [`GenAILocalFoundationalModelSettings`](https://chromeenterprise.google/policies/gen-ai-local-foundational-model-settings/) 企业策略,**阻止 Chrome 静默下载 Gemini Nano 本地大模型**(约 2–4 GB)。 ![go-chrome-ai social preview](images/social-preview.png) @@ -65,6 +65,17 @@ xattr -d com.apple.quarantine $(which go-chrome-ai) - 将 `variations_country` 设为 `"us"` - 将 `variations_permanent_consistency_country` 设为 `["", "us"]`(仅当该字段存在且可修改) +同时还可以**阻止 Chrome 下载本地 AI 模型**(Gemini Nano),CLI 与 GUI 都默认开启。该行为会做三件事: + +- `chrome://flags/#optimization-guide-on-device-model` -> Disabled +- `chrome://flags/#prompt-api-for-gemini-nano` -> Disabled +- `GenAILocalFoundationalModelSettings = 1` 写入系统受管策略存储 + - macOS:`defaults write com.google.Chrome GenAILocalFoundationalModelSettings -int 1` + - Linux:`/etc/opt/chrome/policies/managed/go-chrome-ai.json`(需要 sudo) + - Windows:`HKLM\Software\Policies\Google\Chrome` REG_DWORD + +第三步属于企业策略,Chrome 之后会显示「由贵组织管理」横幅。如果你不希望出现该横幅,可以在 CLI 中加 `-disable-ai-download=false`,或在 GUI 中取消勾选该选项。 + ## 截图 ![go-chrome-ai GUI](images/go-chrome-ai-gui.png) @@ -84,6 +95,7 @@ go run ./cmd/go-chrome-ai - `-dry-run`:只显示将修改的内容,不写入文件,也不关闭 Chrome - `-no-restart`:修补后不重启 Chrome +- `-disable-ai-download`(默认 `true`):阻止本地 AI 模型下载——禁用相关的 `chrome://flags` 并向系统受管策略存储写入 `GenAILocalFoundationalModelSettings=1`。使用 `-disable-ai-download=false` 可跳过 ## 运行 GUI @@ -96,9 +108,11 @@ Linux 和 Windows 的预编译发布包默认只包含 CLI;如果需要 Fyne G GUI 功能: - 自动检测已安装的 Chrome 渠道 +- 左右分栏布局(左侧参数配置,右侧执行控件 + 日志) - 一键修补 - 进度条 - 实时日志 +- 「Disable on-device AI model download」开关,会在执行前预览将要应用到 `chrome://flags` 与 `chrome://policy` 的具体改动 ## 从源码构建 diff --git a/docs/images/go-chrome-ai-gui.png b/docs/images/go-chrome-ai-gui.png index 651d8b0..7e60f0c 100644 Binary files a/docs/images/go-chrome-ai-gui.png and b/docs/images/go-chrome-ai-gui.png differ diff --git a/internal/app/cli.go b/internal/app/cli.go index 8dd32bd..d61e965 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -8,6 +8,7 @@ import ( "os" "github.com/itamaker/go-chrome-ai/internal/chrome" + "github.com/itamaker/go-chrome-ai/internal/meta" ) func RunCLI(args []string, stderr io.Writer) int { @@ -15,11 +16,15 @@ func RunCLI(args []string, stderr io.Writer) int { stderr = os.Stderr } + fmt.Println("go-chrome-ai -", meta.RepoURL) + fs := flag.NewFlagSet("go-chrome-ai", flag.ContinueOnError) fs.SetOutput(stderr) dryRun := fs.Bool("dry-run", false, "Show what would change without modifying files") noRestart := fs.Bool("no-restart", false, "Do not restart Chrome after patching") + disableAI := fs.Bool("disable-ai-download", true, + "Block on-device Gemini Nano download (use -disable-ai-download=false to skip)") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -28,9 +33,34 @@ func RunCLI(args []string, stderr io.Writer) int { return 2 } + if *disableAI { + fmt.Println("Disable AI model download - will apply:") + fmt.Println(" Local flag overrides (chrome://flags):") + for _, action := range chrome.DisableAIDownloadActions() { + if action.EnterprisePolicy { + continue + } + fmt.Println(" - " + action.Label) + } + fmt.Println(" Enterprise policy (chrome://policy):") + for _, action := range chrome.DisableAIDownloadActions() { + if !action.EnterprisePolicy { + continue + } + fmt.Println(" - " + action.Label) + if action.Detail != "" { + fmt.Println(" " + action.Detail) + } + if action.PolicyNote != "" { + fmt.Println(" ! " + action.PolicyNote) + } + } + } + summary, err := chrome.Run(chrome.Options{ - DryRun: *dryRun, - NoRestart: *noRestart, + DryRun: *dryRun, + NoRestart: *noRestart, + DisableAIModelDownload: *disableAI, }, chrome.Callbacks{ Log: func(message string) { fmt.Println(message) diff --git a/internal/chrome/flags.go b/internal/chrome/flags.go new file mode 100644 index 0000000..907173d --- /dev/null +++ b/internal/chrome/flags.go @@ -0,0 +1,105 @@ +package chrome + +import "strings" + +// Chrome stores flag selections in Local State under +// `browser.enabled_labs_experiments` as a list of strings of the form +// `@` where 0=Default, 1=Enabled, 2=Disabled. +const flagDisabledSuffix = "@2" + +// AIDownloadFlagNames are the chrome://flags entries this tool forces to +// "Disabled" so Chrome does not download Gemini Nano / on-device models. +var AIDownloadFlagNames = []string{ + "optimization-guide-on-device-model", + "prompt-api-for-gemini-nano", +} + +// DisableAIDownloadAction describes one transform applied by the +// "disable AI model download" feature. +type DisableAIDownloadAction struct { + Label string // human-readable label, e.g. "chrome://flags/#foo -> Disabled" + Detail string // optional second line (e.g. policy storage location) + EnterprisePolicy bool // true if the action writes a managed Chrome policy + PolicyNote string // extra warning shown when EnterprisePolicy is true +} + +// DisableAIDownloadActions returns the ordered list of changes applied when +// the user enables "disable AI model download". The last entry is an +// Enterprise policy write that causes Chrome to display the +// "managed by your organization" banner. +func DisableAIDownloadActions() []DisableAIDownloadAction { + actions := make([]DisableAIDownloadAction, 0, len(AIDownloadFlagNames)+1) + for _, name := range AIDownloadFlagNames { + actions = append(actions, DisableAIDownloadAction{ + Label: "chrome://flags/#" + name + " -> Disabled", + }) + } + actions = append(actions, DisableAIDownloadAction{ + Label: GenAIPolicyName + " = 1 (Disabled)", + Detail: policyStorageDescription(), + EnterprisePolicy: true, + PolicyNote: `Chrome will show the "managed by your organization" banner`, + }) + return actions +} + + +// setFlagsDisabled rewrites browser.enabled_labs_experiments so each requested +// flag appears exactly once with the Disabled choice (@2). Returns the list +// of flag names whose state actually changed. +func setFlagsDisabled(localState map[string]any, flags []string) []string { + browser, _ := localState["browser"].(map[string]any) + if browser == nil { + browser = map[string]any{} + localState["browser"] = browser + } + + rawList, _ := browser["enabled_labs_experiments"].([]any) + existing := make([]string, 0, len(rawList)) + for _, item := range rawList { + if s, ok := item.(string); ok { + existing = append(existing, s) + } + } + + targets := make(map[string]bool, len(flags)) + for _, name := range flags { + targets[name] = true + } + + kept := make([]string, 0, len(existing)) + alreadyDisabled := make(map[string]bool, len(flags)) + for _, entry := range existing { + name := entry + if idx := strings.IndexByte(entry, '@'); idx >= 0 { + name = entry[:idx] + } + if targets[name] { + if entry == name+flagDisabledSuffix && !alreadyDisabled[name] { + alreadyDisabled[name] = true + kept = append(kept, entry) + } + continue + } + kept = append(kept, entry) + } + + changed := make([]string, 0, len(flags)) + for _, name := range flags { + if !alreadyDisabled[name] { + kept = append(kept, name+flagDisabledSuffix) + changed = append(changed, name) + } + } + + if len(changed) == 0 && len(kept) == len(existing) { + return nil + } + + next := make([]any, len(kept)) + for i, s := range kept { + next[i] = s + } + browser["enabled_labs_experiments"] = next + return changed +} diff --git a/internal/chrome/flags_test.go b/internal/chrome/flags_test.go new file mode 100644 index 0000000..8387912 --- /dev/null +++ b/internal/chrome/flags_test.go @@ -0,0 +1,94 @@ +package chrome + +import ( + "reflect" + "sort" + "testing" +) + +func TestSetFlagsDisabled(t *testing.T) { + cases := []struct { + name string + input map[string]any + flags []string + want []string + wantList []any + wantNil bool + }{ + { + name: "fresh local state, no browser key", + input: map[string]any{}, + flags: []string{"foo"}, + want: []string{"foo"}, + wantList: []any{"foo@2"}, + }, + { + name: "flag already disabled is left alone", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"foo@2"}, + }, + }, + flags: []string{"foo"}, + wantNil: true, + wantList: []any{"foo@2"}, + }, + { + name: "flag previously enabled is flipped to disabled", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"foo@1", "bar"}, + }, + }, + flags: []string{"foo"}, + want: []string{"foo"}, + wantList: []any{"bar", "foo@2"}, + }, + { + name: "duplicates are coalesced", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"foo@1", "foo@2"}, + }, + }, + flags: []string{"foo"}, + wantList: []any{"foo@2"}, + }, + { + name: "multiple flags", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"keep"}, + }, + }, + flags: []string{"foo", "bar"}, + want: []string{"foo", "bar"}, + wantList: []any{"keep", "foo@2", "bar@2"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := setFlagsDisabled(tc.input, tc.flags) + if tc.wantNil && got != nil { + t.Fatalf("expected nil changed list, got %v", got) + } + if !tc.wantNil && len(tc.want) > 0 { + sort.Strings(got) + want := append([]string(nil), tc.want...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("changed list mismatch: got %v want %v", got, want) + } + } + browser, _ := tc.input["browser"].(map[string]any) + if browser == nil { + t.Fatalf("expected browser key to exist") + } + gotList, _ := browser["enabled_labs_experiments"].([]any) + if !reflect.DeepEqual(gotList, tc.wantList) { + t.Fatalf("list mismatch: got %v want %v", gotList, tc.wantList) + } + }) + } +} diff --git a/internal/chrome/patch.go b/internal/chrome/patch.go index 32a6f09..4a0bfb4 100644 --- a/internal/chrome/patch.go +++ b/internal/chrome/patch.go @@ -14,6 +14,12 @@ type PatchResult struct { GLICEligiblePatched bool VariationsCountryPatched bool VariationsPermanentConsistencyCountryWasPatched bool + DisabledFlags []string +} + +// PatchOptions controls which transforms PatchLocalState applies. +type PatchOptions struct { + DisableAIDownloadFlags bool } func ReadLastVersion(userDataPath string) (string, error) { @@ -26,7 +32,7 @@ func ReadLastVersion(userDataPath string) (string, error) { } // PatchLocalState updates Local State for one Chrome profile directory. -func PatchLocalState(userDataPath, lastVersion string, dryRun bool) (PatchResult, error) { +func PatchLocalState(userDataPath, lastVersion string, dryRun bool, opts PatchOptions) (PatchResult, error) { localStateFile := filepath.Join(userDataPath, "Local State") raw, err := os.ReadFile(localStateFile) if err != nil { @@ -64,6 +70,13 @@ func PatchLocalState(userDataPath, lastVersion string, dryRun bool) (PatchResult } } + if opts.DisableAIDownloadFlags { + if changed := setFlagsDisabled(localState, AIDownloadFlagNames); len(changed) > 0 { + result.DisabledFlags = changed + result.Modified = true + } + } + if !result.Modified || dryRun { return result, nil } diff --git a/internal/chrome/policy.go b/internal/chrome/policy.go new file mode 100644 index 0000000..0a5ea30 --- /dev/null +++ b/internal/chrome/policy.go @@ -0,0 +1,22 @@ +package chrome + +// GenAIPolicyName is the Chrome Enterprise policy that controls whether the +// Gemini Nano / on-device foundational model is downloaded and used locally. +// +// Value 0 = Allowed (default), 1 = Disabled (do not download, delete cached +// copy). See: +// https://chromeenterprise.google/policies/gen-ai-local-foundational-model-settings/ +const GenAIPolicyName = "GenAILocalFoundationalModelSettings" + +// PolicyResult is the outcome of applying a host-level Chrome policy. +type PolicyResult struct { + Applied bool // true if the platform store was updated (or would be, in dry-run) + Location string // human-readable destination (file path, registry key, defaults domain) + Skipped string // populated when nothing was written and Applied == false +} + +// ApplyDisableAIDownloadPolicy writes GenAILocalFoundationalModelSettings=1 +// to the platform's Chrome managed-policy store. +func ApplyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + return applyDisableAIDownloadPolicy(dryRun) +} diff --git a/internal/chrome/policy_darwin.go b/internal/chrome/policy_darwin.go new file mode 100644 index 0000000..d662514 --- /dev/null +++ b/internal/chrome/policy_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package chrome + +import ( + "fmt" + "os/exec" + "strings" +) + +// On macOS, Chrome reads user-level managed preferences from the +// `com.google.Chrome` defaults domain. Writing there with `defaults` is the +// supported user-mode way to set policies without an MDM profile. +const macChromeDefaultsDomain = "com.google.Chrome" + +func policyStorageDescription() string { + return "macOS: defaults write " + macChromeDefaultsDomain + " " + GenAIPolicyName + " -int 1" +} + +func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + location := fmt.Sprintf("defaults domain %s (%s)", macChromeDefaultsDomain, GenAIPolicyName) + + current, err := readMacPolicy() + if err == nil && current == "1" { + return PolicyResult{Applied: false, Location: location, Skipped: "already set to 1"}, nil + } + + if dryRun { + return PolicyResult{Applied: true, Location: location}, nil + } + + cmd := exec.Command("defaults", "write", macChromeDefaultsDomain, GenAIPolicyName, "-int", "1") + if out, err := cmd.CombinedOutput(); err != nil { + return PolicyResult{}, fmt.Errorf("defaults write failed: %w: %s", err, strings.TrimSpace(string(out))) + } + return PolicyResult{Applied: true, Location: location}, nil +} + +func readMacPolicy() (string, error) { + out, err := exec.Command("defaults", "read", macChromeDefaultsDomain, GenAIPolicyName).Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/chrome/policy_linux.go b/internal/chrome/policy_linux.go new file mode 100644 index 0000000..35c19a2 --- /dev/null +++ b/internal/chrome/policy_linux.go @@ -0,0 +1,62 @@ +//go:build linux + +package chrome + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// On Linux, Chrome reads managed policies from JSON files dropped into +// /etc/opt/chrome/policies/managed/ (system-wide). There is no user-level +// equivalent — the file must be readable by the Chrome process. +const linuxManagedPolicyDir = "/etc/opt/chrome/policies/managed" + +const linuxPolicyFileName = "go-chrome-ai.json" + +func policyStorageDescription() string { + return "Linux: " + linuxManagedPolicyDir + "/" + linuxPolicyFileName + " (requires sudo)" +} + +func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + target := filepath.Join(linuxManagedPolicyDir, linuxPolicyFileName) + + if existing, err := readLinuxPolicy(target); err == nil { + if v, ok := existing[GenAIPolicyName].(float64); ok && v == 1 { + return PolicyResult{Applied: false, Location: target, Skipped: "already set to 1"}, nil + } + } + + if dryRun { + return PolicyResult{Applied: true, Location: target}, nil + } + + if err := os.MkdirAll(linuxManagedPolicyDir, 0o755); err != nil { + return PolicyResult{}, fmt.Errorf("create %s failed (sudo required?): %w", linuxManagedPolicyDir, err) + } + + payload := map[string]any{GenAIPolicyName: 1} + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return PolicyResult{}, err + } + + if err := os.WriteFile(target, encoded, 0o644); err != nil { + return PolicyResult{}, fmt.Errorf("write %s failed (sudo required?): %w", target, err) + } + return PolicyResult{Applied: true, Location: target}, nil +} + +func readLinuxPolicy(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/chrome/policy_windows.go b/internal/chrome/policy_windows.go new file mode 100644 index 0000000..24e92b7 --- /dev/null +++ b/internal/chrome/policy_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package chrome + +import ( + "fmt" + "os/exec" + "strings" +) + +// On Windows, Chrome reads managed policies from +// HKLM\Software\Policies\Google\Chrome (machine-wide) or the same path under +// HKCU (per-user). We try HKCU first because it does not require an elevated +// process; HKLM is preferred when running elevated. +const winRegPath = `Software\Policies\Google\Chrome` + +func policyStorageDescription() string { + return `Windows: HKLM\` + winRegPath + `\` + GenAIPolicyName + " (REG_DWORD = 1)" +} + +func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + hive := pickWindowsHive() + target := fmt.Sprintf(`%s\%s\%s`, hive, winRegPath, GenAIPolicyName) + + if current, err := readWindowsPolicy(hive); err == nil && current == "0x1" { + return PolicyResult{Applied: false, Location: target, Skipped: "already set to 1"}, nil + } + + if dryRun { + return PolicyResult{Applied: true, Location: target}, nil + } + + cmd := exec.Command( + "reg", "add", fmt.Sprintf(`%s\%s`, hive, winRegPath), + "/v", GenAIPolicyName, + "/t", "REG_DWORD", + "/d", "1", + "/f", + ) + if out, err := cmd.CombinedOutput(); err != nil { + return PolicyResult{}, fmt.Errorf("reg add failed: %w: %s", err, strings.TrimSpace(string(out))) + } + return PolicyResult{Applied: true, Location: target}, nil +} + +func pickWindowsHive() string { + // HKLM if we can write to it (admin), otherwise HKCU. + probe := exec.Command("reg", "query", `HKLM\Software\Policies\Google\Chrome`) + if err := probe.Run(); err == nil { + return "HKLM" + } + return "HKCU" +} + +func readWindowsPolicy(hive string) (string, error) { + out, err := exec.Command( + "reg", "query", fmt.Sprintf(`%s\%s`, hive, winRegPath), + "/v", GenAIPolicyName, + ).Output() + if err != nil { + return "", err + } + text := string(out) + idx := strings.Index(text, "REG_DWORD") + if idx < 0 { + return "", fmt.Errorf("value not found") + } + return strings.TrimSpace(text[idx+len("REG_DWORD"):]), nil +} diff --git a/internal/chrome/runner.go b/internal/chrome/runner.go index 2258da9..d6fe568 100644 --- a/internal/chrome/runner.go +++ b/internal/chrome/runner.go @@ -8,8 +8,9 @@ import ( // Options controls runtime behavior. type Options struct { - DryRun bool - NoRestart bool + DryRun bool + NoRestart bool + DisableAIModelDownload bool } // Callbacks allows CLI/GUI to receive status updates. @@ -24,6 +25,8 @@ type Summary struct { PatchedInstallations int SkippedInstallations int RestartedExecutables int + PolicyApplied bool + PolicyPath string } func Run(opts Options, cb Callbacks) (Summary, error) { @@ -73,7 +76,9 @@ func Run(opts Options, cb Callbacks) (Summary, error) { continue } - result, err := PatchLocalState(install.UserDataPath, lastVersion, opts.DryRun) + result, err := PatchLocalState(install.UserDataPath, lastVersion, opts.DryRun, PatchOptions{ + DisableAIDownloadFlags: opts.DisableAIModelDownload, + }) if err != nil { logf(fmt.Sprintf(" Error: failed to patch Local State: %v", err)) summary.SkippedInstallations++ @@ -89,6 +94,9 @@ func Run(opts Options, cb Callbacks) (Summary, error) { if result.VariationsPermanentConsistencyCountryWasPatched { logf(" Patched variations_permanent_consistency_country") } + for _, name := range result.DisabledFlags { + logf(fmt.Sprintf(" Disabled chrome://flags/#%s", name)) + } if result.Modified { if opts.DryRun { @@ -102,6 +110,25 @@ func Run(opts Options, cb Callbacks) (Summary, error) { } } + if opts.DisableAIModelDownload { + policy, err := ApplyDisableAIDownloadPolicy(opts.DryRun) + switch { + case err != nil: + logf(fmt.Sprintf("Warning: failed to apply %s policy: %v", GenAIPolicyName, err)) + case policy.Applied: + summary.PolicyApplied = true + summary.PolicyPath = policy.Location + if opts.DryRun { + logf(fmt.Sprintf("Dry-run: would set %s=1 at %s", GenAIPolicyName, policy.Location)) + } else { + logf(fmt.Sprintf("Applied %s=1 at %s", GenAIPolicyName, policy.Location)) + } + default: + summary.PolicyPath = policy.Location + logf(fmt.Sprintf("%s already configured (%s)", GenAIPolicyName, policy.Skipped)) + } + } + progress(90) if !opts.NoRestart && !opts.DryRun && len(terminatedChrome) > 0 { RestartChrome(terminatedChrome) diff --git a/internal/guiapp/gui.go b/internal/guiapp/gui.go index 1e286ec..3600bb3 100644 --- a/internal/guiapp/gui.go +++ b/internal/guiapp/gui.go @@ -2,72 +2,143 @@ package guiapp import ( "fmt" + "net/url" "strings" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "github.com/itamaker/go-chrome-ai/internal/chrome" + "github.com/itamaker/go-chrome-ai/internal/meta" ) func Run() { a := app.New() w := a.NewWindow("go-chrome-ai") - w.Resize(fyne.NewSize(760, 560)) + w.Resize(fyne.NewSize(1040, 640)) + + // ---- header: title (left) + repo link (right) + repoURL, _ := url.Parse(meta.RepoURL) + header := container.NewBorder( + nil, nil, + widget.NewLabelWithStyle("go-chrome-ai", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + widget.NewHyperlink(meta.RepoURL, repoURL), + widget.NewLabel("Patch Chrome to enable Gemini / control on-device AI"), + ) - installs, err := chrome.DetectInstallations() - if err != nil { - dialog.ShowError(err, w) + // ---- installations card + installs, detectErr := chrome.DetectInstallations() + if detectErr != nil { + dialog.ShowError(detectErr, w) } - items := make([]string, 0, len(installs)) - for _, install := range installs { - items = append(items, fmt.Sprintf("%s - %s", install.Channel, install.UserDataPath)) + for _, ins := range installs { + items = append(items, fmt.Sprintf("%-7s %s", ins.Channel, ins.UserDataPath)) } if len(items) == 0 { - items = append(items, "No Chrome installation detected") + items = append(items, "(no Chrome installation detected)") } - list := widget.NewList( func() int { return len(items) }, - func() fyne.CanvasObject { return widget.NewLabel("") }, - func(i widget.ListItemID, o fyne.CanvasObject) { - o.(*widget.Label).SetText(items[i]) + func() fyne.CanvasObject { + lbl := widget.NewLabel("") + lbl.TextStyle = fyne.TextStyle{Monospace: true} + lbl.Wrapping = fyne.TextWrapBreak + return lbl }, + func(i widget.ListItemID, o fyne.CanvasObject) { o.(*widget.Label).SetText(items[i]) }, ) listScroll := container.NewVScroll(list) - listScroll.SetMinSize(fyne.NewSize(0, 140)) + listScroll.SetMinSize(fyne.NewSize(0, 110)) + installsCard := widget.NewCard("Chrome installations", "", listScroll) + + // ---- options card: disable-AI checkbox + structured action breakdown + disableAICheck := widget.NewCheck( + "Disable on-device AI model download (Gemini Nano)", nil) + disableAICheck.SetChecked(true) + + // wrapLabel creates a label that can wrap to its parent's width instead + // of forcing it (which would prevent the HSplit divider from being dragged). + wrapLabel := func(text string, style fyne.TextStyle) *widget.Label { + lbl := widget.NewLabelWithStyle(text, fyne.TextAlignLeading, style) + lbl.Wrapping = fyne.TextWrapWord + return lbl + } + + flagsHeader := wrapLabel("Local flag overrides (chrome://flags)", fyne.TextStyle{Bold: true}) + policyHeader := wrapLabel("Enterprise policy (chrome://policy)", fyne.TextStyle{Bold: true}) + + var flagRows, policyRows []fyne.CanvasObject + for _, action := range chrome.DisableAIDownloadActions() { + if action.EnterprisePolicy { + policyRows = append(policyRows, wrapLabel(" • "+action.Label, fyne.TextStyle{})) + if action.Detail != "" { + policyRows = append(policyRows, + wrapLabel(" "+action.Detail, fyne.TextStyle{Monospace: true})) + } + if action.PolicyNote != "" { + policyRows = append(policyRows, + wrapLabel(" ! "+action.PolicyNote, fyne.TextStyle{Italic: true})) + } + continue + } + flagRows = append(flagRows, wrapLabel(" • "+action.Label, fyne.TextStyle{})) + } + + actionBox := container.NewVBox( + flagsHeader, + container.NewVBox(flagRows...), + widget.NewSeparator(), + policyHeader, + container.NewVBox(policyRows...), + ) + + disableAICheck.OnChanged = func(checked bool) { + if checked { + actionBox.Show() + } else { + actionBox.Hide() + } + } + optionsCard := widget.NewCard("Options", "", + container.NewVBox(disableAICheck, widget.NewSeparator(), actionBox)) + // ---- run card: progress + Run button stacked, sits on the right column + progress := widget.NewProgressBar() + progress.SetValue(0) + runButton := widget.NewButton("Run go-chrome-ai", nil) + runButton.Importance = widget.HighImportance + + // ---- logs card (fills remaining space) logBox := widget.NewMultiLineEntry() logBox.SetPlaceHolder("Logs will appear here...") logBox.Disable() + logBox.TextStyle = fyne.TextStyle{Monospace: true} logScroll := container.NewVScroll(logBox) - - progress := widget.NewProgressBar() - progress.SetValue(0) + logsCard := widget.NewCard("Logs", "", logScroll) appendLog := func(message string) { fyne.Do(func() { if logBox.Text == "" { logBox.SetText(message) - return + } else { + logBox.SetText(logBox.Text + "\n" + message) } - logBox.SetText(logBox.Text + "\n" + message) }) } - runButton := widget.NewButton("Run go-chrome-ai", nil) runButton.OnTapped = func() { runButton.Disable() progress.SetValue(0) fyne.Do(func() { logBox.SetText("") }) + opts := chrome.Options{DisableAIModelDownload: disableAICheck.Checked} + go func() { - summary, runErr := chrome.Run(chrome.Options{}, chrome.Callbacks{ + summary, runErr := chrome.Run(opts, chrome.Callbacks{ Log: appendLog, Progress: func(percent int) { if percent < 0 { @@ -83,12 +154,9 @@ func Run() { fyne.Do(func() { runButton.Enable() }) if runErr != nil { appendLog(fmt.Sprintf("Error: %v", runErr)) - fyne.Do(func() { - dialog.ShowError(runErr, w) - }) + fyne.Do(func() { dialog.ShowError(runErr, w) }) return } - appendLog(fmt.Sprintf( "Done. detected=%d patched=%d skipped=%d restarted=%d", summary.DetectedInstallations, @@ -106,21 +174,30 @@ func Run() { runButton.Disable() appendLog("No available Chrome user-data path found.") } else { - appendLog(fmt.Sprintf("Detected %d Chrome installation(s): %s", len(installs), strings.Join(items, ", "))) + appendLog(fmt.Sprintf("Detected %d Chrome installation(s): %s", + len(installs), strings.Join(items, " | "))) } - top := container.NewVBox( - widget.NewLabelWithStyle("go-chrome-ai", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), - widget.NewLabel("Patch Local State to enable Gemini / AI features in Chrome."), - widget.NewSeparator(), - widget.NewLabel("Detected Chrome installations"), - listScroll, - progress, - container.New(layout.NewHBoxLayout(), layout.NewSpacer(), runButton, layout.NewSpacer()), - widget.NewLabel("Logs"), + // ---- left column: configuration (installations + options), scrollable + leftColumn := container.NewVScroll(container.NewVBox( + installsCard, + optionsCard, + )) + + // ---- right column: run controls on top, logs filling the rest + runRow := container.NewBorder(nil, nil, nil, runButton, progress) + runCard := widget.NewCard("Run", "", container.NewVBox(runRow)) + rightColumn := container.NewBorder(runCard, nil, nil, nil, logsCard) + + // ---- assemble: header on top, draggable HSplit underneath + split := container.NewHSplit(leftColumn, rightColumn) + split.SetOffset(0.5) + + content := container.NewBorder( + container.NewVBox(header, widget.NewSeparator()), + nil, nil, nil, + split, ) - content := container.NewBorder(top, nil, nil, nil, logScroll) - w.SetContent(content) w.ShowAndRun() } diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 0000000..073612c --- /dev/null +++ b/internal/meta/meta.go @@ -0,0 +1,4 @@ +package meta + +// RepoURL is the canonical source repository for this build. +const RepoURL = "https://github.com/itamaker/go-chrome-ai"