diff --git a/README.md b/README.md index 07c2f58..b23536d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ It can also **block on-device AI model downloads** (Gemini Nano), which is the d - 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. +Because the third change is an Enterprise policy, Chrome will display the "managed by your organization" banner afterwards. All three are independently selectable options — the two `chrome://flags` entries (CLI: `-disable-flag`, GUI: per-flag checkboxes) and the `chrome://policy` write (CLI: `-disable-ai-policy`, GUI: policy checkbox) — or all three at once via the "select all" option (CLI: `-disable-ai-download`, GUI: "Select all" checkbox, both default on). Turn off `-disable-ai-download` (CLI) or "Select all" (GUI) to pick and choose, e.g. to disable the flags without triggering the Enterprise-policy banner, or vice versa. + +Every run fully syncs Chrome to the current selection, in both directions: a selected item is applied (flag forced to Disabled / policy written), and a **deselected item is actively reverted** — its `chrome://flags` override is removed (back to Chrome's default) and the `chrome://policy` entry is deleted from the OS managed-policy store, if either was previously set by this tool. Unchecking an option is not a no-op; it undoes that option's effect on the next run. ## Screenshot @@ -88,7 +90,9 @@ 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. +- `-disable-ai-download` (default `true`): "select all" — block on-device AI model downloads by disabling every known `chrome://flags` entry and writing `GenAILocalFoundationalModelSettings=1` to the OS managed-policy store. Use `-disable-ai-download=false` to pick individually with `-disable-flag` and/or `-disable-ai-policy` instead. +- `-disable-flag ` (repeatable): disable one specific `chrome://flags` entry by name (e.g. `optimization-guide-on-device-model`, `prompt-api-for-gemini-nano`). Only takes effect when `-disable-ai-download=false`. +- `-disable-ai-policy` (default `true`): write the `GenAILocalFoundationalModelSettings` Enterprise policy, independent of which flags are selected. Only takes effect when `-disable-ai-download=false`. ## Run GUI @@ -105,7 +109,7 @@ The GUI includes: - 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 +- a checkbox per `chrome://flags` entry, a checkbox for the `chrome://policy` write, and a "Select all" master switch over both (checking it selects and locks everything; uncheck it to pick items individually), with a live preview of the exact changes before you press Run ## Build From Source diff --git a/cmd/go-chrome-ai/main.go b/cmd/go-chrome-ai/main.go index 617807c..ebed35b 100644 --- a/cmd/go-chrome-ai/main.go +++ b/cmd/go-chrome-ai/main.go @@ -33,6 +33,17 @@ func printUsage() { fmt.Println(" go-chrome-ai cli [flags]") fmt.Println("") fmt.Println("Flags:") - fmt.Println(" -dry-run Show what would change without modifying files") - fmt.Println(" -no-restart Do not restart Chrome after patching") + fmt.Println(" -dry-run Show what would change without modifying files") + fmt.Println(" -no-restart Do not restart Chrome after patching") + fmt.Println(" -disable-ai-download Block on-device Gemini Nano download by disabling all") + fmt.Println(" known chrome://flags entries plus the OS policy (default true)") + fmt.Println(" -disable-flag Individual chrome://flags entry to disable (repeatable);") + fmt.Println(" only takes effect when -disable-ai-download=false") + fmt.Println(" -disable-ai-policy Write the GenAILocalFoundationalModelSettings Enterprise") + fmt.Println(" policy (chrome://policy), independent of -disable-flag;") + fmt.Println(" only takes effect when -disable-ai-download=false (default true)") + fmt.Println("") + fmt.Println("Every run syncs to the current selection: a deselected flag or policy is") + fmt.Println("actively reverted (chrome://flags reset to default, chrome://policy entry") + fmt.Println("removed) if this tool previously set it.") } diff --git a/internal/app/cli.go b/internal/app/cli.go index d61e965..88d743c 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/itamaker/go-chrome-ai/internal/chrome" "github.com/itamaker/go-chrome-ai/internal/meta" @@ -21,10 +22,26 @@ func RunCLI(args []string, stderr io.Writer) int { fs := flag.NewFlagSet("go-chrome-ai", flag.ContinueOnError) fs.SetOutput(stderr) + availableFlagNames := chrome.AllAIDownloadFlagNames() + 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)") + selectAllAIFlags := fs.Bool("disable-ai-download", true, + "Block on-device Gemini Nano download by disabling all known chrome://flags entries "+ + "("+strings.Join(availableFlagNames, ", ")+") plus writing the OS Enterprise policy. "+ + "Use -disable-ai-download=false to pick individual flags with -disable-flag and/or "+ + "the policy with -disable-ai-policy instead.") + var selectedFlagNames []string + fs.Func("disable-flag", + "Individual chrome://flags entry to disable (repeatable); only takes effect when -disable-ai-download=false", + func(v string) error { + selectedFlagNames = append(selectedFlagNames, v) + return nil + }) + applyPolicy := fs.Bool("disable-ai-policy", true, + "Write the "+chrome.GenAIPolicyName+" Enterprise policy (chrome://policy) that also blocks "+ + "on-device AI downloads; causes Chrome to show the \"managed by your organization\" banner. "+ + "Independent of which chrome://flags are disabled. Only takes effect when -disable-ai-download=false.") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -33,20 +50,45 @@ 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 + var aiDownloadFlags []string + var aiDownloadPolicy bool + if *selectAllAIFlags { + aiDownloadFlags = availableFlagNames + aiDownloadPolicy = true + } else { + known := make(map[string]bool, len(availableFlagNames)) + for _, name := range availableFlagNames { + known[name] = true + } + for _, name := range selectedFlagNames { + if !known[name] { + fmt.Fprintf(stderr, "Error: unknown -disable-flag %q (known: %s)\n", + name, strings.Join(availableFlagNames, ", ")) + return 2 } + } + aiDownloadFlags = selectedFlagNames + aiDownloadPolicy = *applyPolicy + } + + actions := chrome.DisableAIDownloadActions(aiDownloadFlags, aiDownloadPolicy) + applyFlags, revertFlags, policyActions := chrome.GroupDisableAIDownloadActions(actions) + fmt.Println("Chrome AI-download configuration:") + if len(applyFlags) > 0 { + fmt.Println(" Local flag overrides (chrome://flags):") + for _, action := range applyFlags { + fmt.Println(" - " + action.Label) + } + } + if len(revertFlags) > 0 { + fmt.Println(" Local flag resets (chrome://flags):") + for _, action := range revertFlags { fmt.Println(" - " + action.Label) } + } + if len(policyActions) > 0 { fmt.Println(" Enterprise policy (chrome://policy):") - for _, action := range chrome.DisableAIDownloadActions() { - if !action.EnterprisePolicy { - continue - } + for _, action := range policyActions { fmt.Println(" - " + action.Label) if action.Detail != "" { fmt.Println(" " + action.Detail) @@ -58,9 +100,10 @@ func RunCLI(args []string, stderr io.Writer) int { } summary, err := chrome.Run(chrome.Options{ - DryRun: *dryRun, - NoRestart: *noRestart, - DisableAIModelDownload: *disableAI, + DryRun: *dryRun, + NoRestart: *noRestart, + AIDownloadFlags: aiDownloadFlags, + AIDownloadPolicy: aiDownloadPolicy, }, chrome.Callbacks{ Log: func(message string) { fmt.Println(message) diff --git a/internal/chrome/flags.go b/internal/chrome/flags.go index 907173d..12d0270 100644 --- a/internal/chrome/flags.go +++ b/internal/chrome/flags.go @@ -7,47 +7,108 @@ import "strings" // `@` where 0=Default, 1=Enabled, 2=Disabled. const flagDisabledSuffix = "@2" -// AIDownloadFlagNames are the chrome://flags entries this tool forces to +// AIDownloadFlag describes one chrome://flags entry this tool can force 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", +type AIDownloadFlag struct { + Name string // chrome://flags entry name } -// DisableAIDownloadAction describes one transform applied by the +// AvailableAIDownloadFlags lists every chrome://flags entry this tool knows +// how to disable. Callers (CLI/GUI) present each one as an individually +// selectable option, plus a "select all" convenience over this same list. +var AvailableAIDownloadFlags = []AIDownloadFlag{ + {Name: "optimization-guide-on-device-model"}, + {Name: "prompt-api-for-gemini-nano"}, +} + +// AllAIDownloadFlagNames returns the names of every available AI-download +// flag, i.e. the set selected by a "select all" option. +func AllAIDownloadFlagNames() []string { + names := make([]string, len(AvailableAIDownloadFlags)) + for i, f := range AvailableAIDownloadFlags { + names[i] = f.Name + } + return names +} + +// DisableAIDownloadAction describes one transform previewed/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 + EnterprisePolicy bool // true if the action reads/writes the managed Chrome policy PolicyNote string // extra warning shown when EnterprisePolicy is true + Revert bool // true if this action undoes a previous change instead of applying one } -// 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 { +// DisableAIDownloadActions returns one action per managed item — every entry +// in AvailableAIDownloadFlags plus the Enterprise policy — describing +// whether it will be applied (selected / includePolicy) or reverted to +// Chrome's default (not selected / !includePolicy). The policy is +// independent of which flags are selected. +func DisableAIDownloadActions(selectedFlags []string, includePolicy bool) []DisableAIDownloadAction { + selected := make(map[string]bool, len(selectedFlags)) + for _, name := range selectedFlags { + selected[name] = true + } + + actions := make([]DisableAIDownloadAction, 0, len(AvailableAIDownloadFlags)+1) + for _, f := range AvailableAIDownloadFlags { + if selected[f.Name] { + actions = append(actions, DisableAIDownloadAction{ + Label: "chrome://flags/#" + f.Name + " -> Disabled", + }) + continue + } actions = append(actions, DisableAIDownloadAction{ - Label: "chrome://flags/#" + name + " -> Disabled", + Label: "chrome://flags/#" + f.Name + " -> Default (reset)", + Revert: true, + }) + } + + if includePolicy { + actions = append(actions, DisableAIDownloadAction{ + Label: GenAIPolicyName + " = 1 (Disabled)", + Detail: policyStorageDescription(true), + EnterprisePolicy: true, + PolicyNote: `Chrome will show the "managed by your organization" banner`, + }) + } else { + actions = append(actions, DisableAIDownloadAction{ + Label: GenAIPolicyName + " removed (reset to default)", + Detail: policyStorageDescription(false), + EnterprisePolicy: true, + PolicyNote: `Removes the "managed by your organization" banner, if shown`, + Revert: true, }) } - actions = append(actions, DisableAIDownloadAction{ - Label: GenAIPolicyName + " = 1 (Disabled)", - Detail: policyStorageDescription(), - EnterprisePolicy: true, - PolicyNote: `Chrome will show the "managed by your organization" banner`, - }) return actions } +// GroupDisableAIDownloadActions splits actions (as returned by +// DisableAIDownloadActions) into three buckets for display: chrome://flags +// entries to apply, chrome://flags entries to revert, and the Enterprise +// policy action (apply or revert). +func GroupDisableAIDownloadActions(actions []DisableAIDownloadAction) (applyFlags, revertFlags, policy []DisableAIDownloadAction) { + for _, a := range actions { + switch { + case a.EnterprisePolicy: + policy = append(policy, a) + case a.Revert: + revertFlags = append(revertFlags, a) + default: + applyFlags = append(applyFlags, a) + } + } + return applyFlags, revertFlags, policy +} -// 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 { +// syncManagedFlags rewrites browser.enabled_labs_experiments so every flag +// in managed is Disabled (@2) when it also appears in selected, or has any +// existing override removed (reverted to Chrome's default) when it does +// not. Flags outside managed are left untouched. Returns the flags that +// were newly disabled and the flags whose override was removed. +func syncManagedFlags(localState map[string]any, managed, selected []string) (disabled, reverted []string) { browser, _ := localState["browser"].(map[string]any) if browser == nil { browser = map[string]any{} @@ -62,38 +123,51 @@ func setFlagsDisabled(localState map[string]any, flags []string) []string { } } - targets := make(map[string]bool, len(flags)) - for _, name := range flags { - targets[name] = true + isManaged := make(map[string]bool, len(managed)) + for _, name := range managed { + isManaged[name] = true + } + isSelected := make(map[string]bool, len(selected)) + for _, name := range selected { + isSelected[name] = true } kept := make([]string, 0, len(existing)) - alreadyDisabled := make(map[string]bool, len(flags)) + alreadyDisabled := make(map[string]bool, len(selected)) + wasPresent := make(map[string]bool, len(managed)) 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) - } + if !isManaged[name] { + kept = append(kept, entry) continue } - kept = append(kept, entry) + wasPresent[name] = true + if !isSelected[name] { + continue // revert: drop the existing override entirely + } + if entry == name+flagDisabledSuffix && !alreadyDisabled[name] { + alreadyDisabled[name] = true + kept = append(kept, entry) + } } - changed := make([]string, 0, len(flags)) - for _, name := range flags { + for _, name := range selected { if !alreadyDisabled[name] { kept = append(kept, name+flagDisabledSuffix) - changed = append(changed, name) + disabled = append(disabled, name) + } + } + for _, name := range managed { + if !isSelected[name] && wasPresent[name] { + reverted = append(reverted, name) } } - if len(changed) == 0 && len(kept) == len(existing) { - return nil + if len(disabled) == 0 && len(reverted) == 0 && len(kept) == len(existing) { + return nil, nil } next := make([]any, len(kept)) @@ -101,5 +175,5 @@ func setFlagsDisabled(localState map[string]any, flags []string) []string { next[i] = s } browser["enabled_labs_experiments"] = next - return changed + return disabled, reverted } diff --git a/internal/chrome/flags_test.go b/internal/chrome/flags_test.go index 8387912..7f0fc86 100644 --- a/internal/chrome/flags_test.go +++ b/internal/chrome/flags_test.go @@ -6,21 +6,24 @@ import ( "testing" ) -func TestSetFlagsDisabled(t *testing.T) { +func TestSyncManagedFlags(t *testing.T) { cases := []struct { - name string - input map[string]any - flags []string - want []string - wantList []any - wantNil bool + name string + input map[string]any + managed []string + selected []string + wantDisabled []string + wantReverted []string + wantList []any + wantNilBoth bool }{ { - name: "fresh local state, no browser key", - input: map[string]any{}, - flags: []string{"foo"}, - want: []string{"foo"}, - wantList: []any{"foo@2"}, + name: "fresh local state, no browser key", + input: map[string]any{}, + managed: []string{"foo"}, + selected: []string{"foo"}, + wantDisabled: []string{"foo"}, + wantList: []any{"foo@2"}, }, { name: "flag already disabled is left alone", @@ -29,9 +32,10 @@ func TestSetFlagsDisabled(t *testing.T) { "enabled_labs_experiments": []any{"foo@2"}, }, }, - flags: []string{"foo"}, - wantNil: true, - wantList: []any{"foo@2"}, + managed: []string{"foo"}, + selected: []string{"foo"}, + wantNilBoth: true, + wantList: []any{"foo@2"}, }, { name: "flag previously enabled is flipped to disabled", @@ -40,9 +44,10 @@ func TestSetFlagsDisabled(t *testing.T) { "enabled_labs_experiments": []any{"foo@1", "bar"}, }, }, - flags: []string{"foo"}, - want: []string{"foo"}, - wantList: []any{"bar", "foo@2"}, + managed: []string{"foo"}, + selected: []string{"foo"}, + wantDisabled: []string{"foo"}, + wantList: []any{"bar", "foo@2"}, }, { name: "duplicates are coalesced", @@ -51,7 +56,8 @@ func TestSetFlagsDisabled(t *testing.T) { "enabled_labs_experiments": []any{"foo@1", "foo@2"}, }, }, - flags: []string{"foo"}, + managed: []string{"foo"}, + selected: []string{"foo"}, wantList: []any{"foo@2"}, }, { @@ -61,24 +67,69 @@ func TestSetFlagsDisabled(t *testing.T) { "enabled_labs_experiments": []any{"keep"}, }, }, - flags: []string{"foo", "bar"}, - want: []string{"foo", "bar"}, - wantList: []any{"keep", "foo@2", "bar@2"}, + managed: []string{"foo", "bar"}, + selected: []string{"foo", "bar"}, + wantDisabled: []string{"foo", "bar"}, + wantList: []any{"keep", "foo@2", "bar@2"}, + }, + { + name: "managed flag not selected is reverted", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"foo@2", "other"}, + }, + }, + managed: []string{"foo"}, + selected: nil, + wantReverted: []string{"foo"}, + wantList: []any{"other"}, + }, + { + name: "managed flag not selected and not present is a no-op", + input: map[string]any{}, + managed: []string{"foo"}, + selected: nil, + wantNilBoth: true, + wantList: []any{}, + }, + { + name: "mixed: one applied, one reverted, unmanaged flag untouched", + input: map[string]any{ + "browser": map[string]any{ + "enabled_labs_experiments": []any{"foo@2", "bar@1", "unmanaged@1"}, + }, + }, + managed: []string{"foo", "bar"}, + selected: []string{"bar"}, + wantReverted: []string{"foo"}, + wantDisabled: []string{"bar"}, + wantList: []any{"unmanaged@1", "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) + gotDisabled, gotReverted := syncManagedFlags(tc.input, tc.managed, tc.selected) + if tc.wantNilBoth { + if gotDisabled != nil || gotReverted != nil { + t.Fatalf("expected nil disabled/reverted, got disabled=%v reverted=%v", gotDisabled, gotReverted) + } + } else { + if len(tc.wantDisabled) > 0 { + sort.Strings(gotDisabled) + want := append([]string(nil), tc.wantDisabled...) + sort.Strings(want) + if !reflect.DeepEqual(gotDisabled, want) { + t.Fatalf("disabled list mismatch: got %v want %v", gotDisabled, want) + } + } + if len(tc.wantReverted) > 0 { + sort.Strings(gotReverted) + want := append([]string(nil), tc.wantReverted...) + sort.Strings(want) + if !reflect.DeepEqual(gotReverted, want) { + t.Fatalf("reverted list mismatch: got %v want %v", gotReverted, want) + } } } browser, _ := tc.input["browser"].(map[string]any) @@ -86,6 +137,9 @@ func TestSetFlagsDisabled(t *testing.T) { t.Fatalf("expected browser key to exist") } gotList, _ := browser["enabled_labs_experiments"].([]any) + if len(gotList) == 0 && len(tc.wantList) == 0 { + return + } 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 4a0bfb4..4863254 100644 --- a/internal/chrome/patch.go +++ b/internal/chrome/patch.go @@ -15,11 +15,16 @@ type PatchResult struct { VariationsCountryPatched bool VariationsPermanentConsistencyCountryWasPatched bool DisabledFlags []string + RevertedFlags []string } // PatchOptions controls which transforms PatchLocalState applies. type PatchOptions struct { - DisableAIDownloadFlags bool + // AIDownloadFlags is the set of chrome://flags entry names (from + // AvailableAIDownloadFlags) to force to Disabled. Any managed flag not + // in this set has its override removed (reverted to Chrome's default) + // if present. + AIDownloadFlags []string } func ReadLastVersion(userDataPath string) (string, error) { @@ -70,11 +75,10 @@ func PatchLocalState(userDataPath, lastVersion string, dryRun bool, opts PatchOp } } - if opts.DisableAIDownloadFlags { - if changed := setFlagsDisabled(localState, AIDownloadFlagNames); len(changed) > 0 { - result.DisabledFlags = changed - result.Modified = true - } + if disabled, reverted := syncManagedFlags(localState, AllAIDownloadFlagNames(), opts.AIDownloadFlags); len(disabled) > 0 || len(reverted) > 0 { + result.DisabledFlags = disabled + result.RevertedFlags = reverted + result.Modified = true } if !result.Modified || dryRun { diff --git a/internal/chrome/policy.go b/internal/chrome/policy.go index 0a5ea30..698e253 100644 --- a/internal/chrome/policy.go +++ b/internal/chrome/policy.go @@ -20,3 +20,11 @@ type PolicyResult struct { func ApplyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { return applyDisableAIDownloadPolicy(dryRun) } + +// RemoveDisableAIDownloadPolicy removes GenAILocalFoundationalModelSettings +// from the platform's Chrome managed-policy store, reverting Chrome to its +// unmanaged default (no "managed by your organization" banner from this +// policy). +func RemoveDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + return removeDisableAIDownloadPolicy(dryRun) +} diff --git a/internal/chrome/policy_darwin.go b/internal/chrome/policy_darwin.go index d662514..71f98e7 100644 --- a/internal/chrome/policy_darwin.go +++ b/internal/chrome/policy_darwin.go @@ -13,8 +13,11 @@ import ( // 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 policyStorageDescription(applying bool) string { + if applying { + return "macOS: defaults write " + macChromeDefaultsDomain + " " + GenAIPolicyName + " -int 1" + } + return "macOS: defaults delete " + macChromeDefaultsDomain + " " + GenAIPolicyName } func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { @@ -36,6 +39,24 @@ func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { return PolicyResult{Applied: true, Location: location}, nil } +func removeDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + location := fmt.Sprintf("defaults domain %s (%s)", macChromeDefaultsDomain, GenAIPolicyName) + + if _, err := readMacPolicy(); err != nil { + return PolicyResult{Applied: false, Location: location, Skipped: "not set"}, nil + } + + if dryRun { + return PolicyResult{Applied: true, Location: location}, nil + } + + cmd := exec.Command("defaults", "delete", macChromeDefaultsDomain, GenAIPolicyName) + if out, err := cmd.CombinedOutput(); err != nil { + return PolicyResult{}, fmt.Errorf("defaults delete 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 { diff --git a/internal/chrome/policy_linux.go b/internal/chrome/policy_linux.go index 35c19a2..ac7ff62 100644 --- a/internal/chrome/policy_linux.go +++ b/internal/chrome/policy_linux.go @@ -16,8 +16,12 @@ const linuxManagedPolicyDir = "/etc/opt/chrome/policies/managed" const linuxPolicyFileName = "go-chrome-ai.json" -func policyStorageDescription() string { - return "Linux: " + linuxManagedPolicyDir + "/" + linuxPolicyFileName + " (requires sudo)" +func policyStorageDescription(applying bool) string { + verb := "write" + if !applying { + verb = "remove" + } + return "Linux: " + verb + " " + linuxManagedPolicyDir + "/" + linuxPolicyFileName + " (requires sudo)" } func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { @@ -49,6 +53,23 @@ func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { return PolicyResult{Applied: true, Location: target}, nil } +func removeDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + target := filepath.Join(linuxManagedPolicyDir, linuxPolicyFileName) + + if _, err := os.Stat(target); err != nil { + return PolicyResult{Applied: false, Location: target, Skipped: "not set"}, nil + } + + if dryRun { + return PolicyResult{Applied: true, Location: target}, nil + } + + if err := os.Remove(target); err != nil { + return PolicyResult{}, fmt.Errorf("remove %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 { diff --git a/internal/chrome/policy_windows.go b/internal/chrome/policy_windows.go index 24e92b7..2f3be06 100644 --- a/internal/chrome/policy_windows.go +++ b/internal/chrome/policy_windows.go @@ -14,8 +14,11 @@ import ( // 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 policyStorageDescription(applying bool) string { + if applying { + return `Windows: HKLM\` + winRegPath + `\` + GenAIPolicyName + " (REG_DWORD = 1)" + } + return `Windows: delete HKLM\` + winRegPath + `\` + GenAIPolicyName } func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { @@ -43,6 +46,29 @@ func applyDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { return PolicyResult{Applied: true, Location: target}, nil } +func removeDisableAIDownloadPolicy(dryRun bool) (PolicyResult, error) { + hive := pickWindowsHive() + target := fmt.Sprintf(`%s\%s\%s`, hive, winRegPath, GenAIPolicyName) + + if _, err := readWindowsPolicy(hive); err != nil { + return PolicyResult{Applied: false, Location: target, Skipped: "not set"}, nil + } + + if dryRun { + return PolicyResult{Applied: true, Location: target}, nil + } + + cmd := exec.Command( + "reg", "delete", fmt.Sprintf(`%s\%s`, hive, winRegPath), + "/v", GenAIPolicyName, + "/f", + ) + if out, err := cmd.CombinedOutput(); err != nil { + return PolicyResult{}, fmt.Errorf("reg delete 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`) diff --git a/internal/chrome/runner.go b/internal/chrome/runner.go index d6fe568..2f90dc8 100644 --- a/internal/chrome/runner.go +++ b/internal/chrome/runner.go @@ -8,9 +8,18 @@ import ( // Options controls runtime behavior. type Options struct { - DryRun bool - NoRestart bool - DisableAIModelDownload bool + DryRun bool + NoRestart bool + // AIDownloadFlags is the set of chrome://flags entries (from + // AvailableAIDownloadFlags) to force Disabled. Any managed flag not in + // this set has its override removed (reverted to Chrome's default) if + // present. + AIDownloadFlags []string + // AIDownloadPolicy, when true, writes the + // GenAILocalFoundationalModelSettings Enterprise policy; when false, it + // removes the policy if present (reverting to Chrome's unmanaged + // default). Independent of AIDownloadFlags. + AIDownloadPolicy bool } // Callbacks allows CLI/GUI to receive status updates. @@ -26,6 +35,7 @@ type Summary struct { SkippedInstallations int RestartedExecutables int PolicyApplied bool + PolicyReverted bool PolicyPath string } @@ -77,7 +87,7 @@ func Run(opts Options, cb Callbacks) (Summary, error) { } result, err := PatchLocalState(install.UserDataPath, lastVersion, opts.DryRun, PatchOptions{ - DisableAIDownloadFlags: opts.DisableAIModelDownload, + AIDownloadFlags: opts.AIDownloadFlags, }) if err != nil { logf(fmt.Sprintf(" Error: failed to patch Local State: %v", err)) @@ -97,6 +107,9 @@ func Run(opts Options, cb Callbacks) (Summary, error) { for _, name := range result.DisabledFlags { logf(fmt.Sprintf(" Disabled chrome://flags/#%s", name)) } + for _, name := range result.RevertedFlags { + logf(fmt.Sprintf(" Reverted chrome://flags/#%s to default", name)) + } if result.Modified { if opts.DryRun { @@ -110,7 +123,7 @@ func Run(opts Options, cb Callbacks) (Summary, error) { } } - if opts.DisableAIModelDownload { + if opts.AIDownloadPolicy { policy, err := ApplyDisableAIDownloadPolicy(opts.DryRun) switch { case err != nil: @@ -127,6 +140,23 @@ func Run(opts Options, cb Callbacks) (Summary, error) { summary.PolicyPath = policy.Location logf(fmt.Sprintf("%s already configured (%s)", GenAIPolicyName, policy.Skipped)) } + } else { + policy, err := RemoveDisableAIDownloadPolicy(opts.DryRun) + switch { + case err != nil: + logf(fmt.Sprintf("Warning: failed to remove %s policy: %v", GenAIPolicyName, err)) + case policy.Applied: + summary.PolicyReverted = true + summary.PolicyPath = policy.Location + if opts.DryRun { + logf(fmt.Sprintf("Dry-run: would remove %s from %s", GenAIPolicyName, policy.Location)) + } else { + logf(fmt.Sprintf("Removed %s from %s", GenAIPolicyName, policy.Location)) + } + default: + summary.PolicyPath = policy.Location + logf(fmt.Sprintf("%s already absent (%s)", GenAIPolicyName, policy.Skipped)) + } } progress(90) diff --git a/internal/guiapp/gui.go b/internal/guiapp/gui.go index 3600bb3..f0f5e9b 100644 --- a/internal/guiapp/gui.go +++ b/internal/guiapp/gui.go @@ -55,10 +55,8 @@ func Run() { 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) + // ---- options card: one checkbox per chrome://flags entry, a "select + // all" master switch, and a live preview of the resulting changes. // 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). @@ -68,43 +66,96 @@ func Run() { return lbl } - flagsHeader := wrapLabel("Local flag overrides (chrome://flags)", fyne.TextStyle{Bold: true}) - policyHeader := wrapLabel("Enterprise policy (chrome://policy)", fyne.TextStyle{Bold: true}) + selectAllCheck := widget.NewCheck("Select all", nil) - 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})) + flagChecks := make([]*widget.Check, len(chrome.AvailableAIDownloadFlags)) + for i, f := range chrome.AvailableAIDownloadFlags { + flagChecks[i] = widget.NewCheck(f.Name, nil) + } + policyCheck := widget.NewCheck(chrome.GenAIPolicyName, nil) + + actionBox := container.NewVBox() + + selectedFlagNames := func() []string { + var names []string + for i, c := range flagChecks { + if c.Checked { + names = append(names, chrome.AvailableAIDownloadFlags[i].Name) } - continue } - flagRows = append(flagRows, wrapLabel(" • "+action.Label, fyne.TextStyle{})) + return names } - actionBox := container.NewVBox( - flagsHeader, - container.NewVBox(flagRows...), - widget.NewSeparator(), - policyHeader, - container.NewVBox(policyRows...), - ) + updateActionBox := func() { + actions := chrome.DisableAIDownloadActions(selectedFlagNames(), policyCheck.Checked) + applyFlags, revertFlags, policyActions := chrome.GroupDisableAIDownloadActions(actions) + + var objs []fyne.CanvasObject + addSection := func(title string, items []chrome.DisableAIDownloadAction) { + if len(items) == 0 { + return + } + if len(objs) > 0 { + objs = append(objs, widget.NewSeparator()) + } + objs = append(objs, wrapLabel(title, fyne.TextStyle{Bold: true})) + for _, action := range items { + objs = append(objs, wrapLabel(" • "+action.Label, fyne.TextStyle{})) + if action.Detail != "" { + objs = append(objs, wrapLabel(" "+action.Detail, fyne.TextStyle{Monospace: true})) + } + if action.PolicyNote != "" { + objs = append(objs, wrapLabel(" ! "+action.PolicyNote, fyne.TextStyle{Italic: true})) + } + } + } + addSection("Local flag overrides (chrome://flags)", applyFlags) + addSection("Local flag resets (chrome://flags)", revertFlags) + addSection("Enterprise policy (chrome://policy)", policyActions) - disableAICheck.OnChanged = func(checked bool) { + actionBox.Objects = objs + actionBox.Refresh() + } + + // select-all is the master switch: checking it selects every flag plus + // the policy and locks their checkboxes; unchecking it hands control + // back so each can be toggled independently. + selectAllCheck.OnChanged = func(checked bool) { + for _, c := range flagChecks { + if checked { + c.SetChecked(true) + c.Disable() + } else { + c.Enable() + } + } if checked { - actionBox.Show() + policyCheck.SetChecked(true) + policyCheck.Disable() } else { - actionBox.Hide() + policyCheck.Enable() } + updateActionBox() + } + for _, c := range flagChecks { + c.OnChanged = func(bool) { updateActionBox() } } - optionsCard := widget.NewCard("Options", "", - container.NewVBox(disableAICheck, widget.NewSeparator(), actionBox)) + policyCheck.OnChanged = func(bool) { updateActionBox() } + selectAllCheck.SetChecked(true) + updateActionBox() + + flagsBox := container.NewVBox() + for _, c := range flagChecks { + flagsBox.Add(c) + } + flagsBox.Add(policyCheck) + + optionsCard := widget.NewCard("Options", "", container.NewVBox( + selectAllCheck, + flagsBox, + widget.NewSeparator(), + actionBox, + )) // ---- run card: progress + Run button stacked, sits on the right column progress := widget.NewProgressBar() @@ -135,7 +186,10 @@ func Run() { progress.SetValue(0) fyne.Do(func() { logBox.SetText("") }) - opts := chrome.Options{DisableAIModelDownload: disableAICheck.Checked} + opts := chrome.Options{ + AIDownloadFlags: selectedFlagNames(), + AIDownloadPolicy: policyCheck.Checked, + } go func() { summary, runErr := chrome.Run(opts, chrome.Callbacks{