Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -67,6 +67,17 @@ It enables Chrome AI-related features (such as **Ask Gemini**) by patching local
- `variations_country` -> `"us"`
- `variations_permanent_consistency_country` -> `["<last_version>", "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)
Expand All @@ -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

Expand All @@ -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

Expand Down
16 changes: 15 additions & 1 deletion docs/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -65,6 +65,17 @@ xattr -d com.apple.quarantine $(which go-chrome-ai)
- 将 `variations_country` 设为 `"us"`
- 将 `variations_permanent_consistency_country` 设为 `["<last_version>", "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)
Expand All @@ -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

Expand All @@ -96,9 +108,11 @@ Linux 和 Windows 的预编译发布包默认只包含 CLI;如果需要 Fyne G
GUI 功能:

- 自动检测已安装的 Chrome 渠道
- 左右分栏布局(左侧参数配置,右侧执行控件 + 日志)
- 一键修补
- 进度条
- 实时日志
- 「Disable on-device AI model download」开关,会在执行前预览将要应用到 `chrome://flags` 与 `chrome://policy` 的具体改动

## 从源码构建

Expand Down
Binary file modified docs/images/go-chrome-ai-gui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 32 additions & 2 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@ 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 {
if stderr == nil {
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) {
Expand All @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions internal/chrome/flags.go
Original file line number Diff line number Diff line change
@@ -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
// `<flag-name>@<choice>` 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
}
94 changes: 94 additions & 0 deletions internal/chrome/flags_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
15 changes: 14 additions & 1 deletion internal/chrome/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading