Skip to content

Commit cffa247

Browse files
committed
feat: add screen recording permission reminder after install
Show a contextual reminder when users install screen-sharing apps (Zoom, Teams, OBS, Loom, Feishu, Lark) and haven't granted macOS screen recording permission. Users can open System Settings, skip, or permanently dismiss the reminder.
1 parent 5b8ea18 commit cffa247

8 files changed

Lines changed: 588 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ var remoteHTTPClient = &http.Client{
2020
//go:embed data/presets.yaml
2121
var presetsYAML embed.FS
2222

23+
//go:embed data/screen-recording-packages.yaml
24+
var screenRecordingYAML embed.FS
25+
2326
type Config struct {
2427
Version string
2528
Preset string
@@ -150,3 +153,21 @@ func FetchRemoteConfig(userSlug string, token string) (*RemoteConfig, error) {
150153

151154
return &rc, nil
152155
}
156+
157+
type screenRecordingData struct {
158+
Packages []string `yaml:"packages"`
159+
}
160+
161+
func GetScreenRecordingPackages() []string {
162+
data, err := screenRecordingYAML.ReadFile("data/screen-recording-packages.yaml")
163+
if err != nil {
164+
return []string{}
165+
}
166+
167+
var srd screenRecordingData
168+
if err := yaml.Unmarshal(data, &srd); err != nil {
169+
return []string{}
170+
}
171+
172+
return srd.Packages
173+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Packages whose core function requires macOS Screen Recording permission.
2+
# Used to trigger a post-install reminder for users.
3+
# Only includes apps where screen sharing/recording is a primary use case.
4+
# Deliberately excluded: browsers, chat apps, design tools.
5+
packages:
6+
- zoom
7+
- microsoft-teams
8+
- obs
9+
- loom
10+
- feishu
11+
- lark

internal/installer/installer.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"github.com/openbootdotdev/openboot/internal/dotfiles"
1111
"github.com/openbootdotdev/openboot/internal/macos"
1212
"github.com/openbootdotdev/openboot/internal/npm"
13+
"github.com/openbootdotdev/openboot/internal/permissions"
1314
"github.com/openbootdotdev/openboot/internal/shell"
15+
"github.com/openbootdotdev/openboot/internal/state"
1416
"github.com/openbootdotdev/openboot/internal/system"
1517
"github.com/openbootdotdev/openboot/internal/ui"
1618
)
@@ -670,6 +672,8 @@ func showCompletion(cfg *config.Config) {
670672
}
671673
fmt.Println()
672674

675+
showScreenRecordingReminder(cfg)
676+
673677
ui.Info("Next steps:")
674678
ui.Info(" - Restart your terminal to apply changes")
675679
ui.Info(" - Run 'brew doctor' to verify Homebrew health")
@@ -809,3 +813,82 @@ func categorizeSelectedPackages(cfg *config.Config) categorizedPackages {
809813
}
810814
return result
811815
}
816+
817+
func findMatchingPackages(cfg *config.Config, triggerPkgs []string) []string {
818+
triggerSet := make(map[string]bool, len(triggerPkgs))
819+
for _, p := range triggerPkgs {
820+
triggerSet[p] = true
821+
}
822+
823+
var matched []string
824+
for pkg := range cfg.SelectedPkgs {
825+
if triggerSet[pkg] {
826+
matched = append(matched, pkg)
827+
}
828+
}
829+
for _, pkg := range cfg.OnlinePkgs {
830+
if triggerSet[pkg.Name] {
831+
matched = append(matched, pkg.Name)
832+
}
833+
}
834+
return matched
835+
}
836+
837+
func showScreenRecordingReminder(cfg *config.Config) {
838+
if cfg.DryRun || cfg.Silent {
839+
return
840+
}
841+
842+
statePath := state.DefaultStatePath()
843+
reminderState, err := state.LoadState(statePath)
844+
if err != nil {
845+
return
846+
}
847+
848+
if !state.ShouldShowReminder(reminderState) {
849+
return
850+
}
851+
852+
if permissions.HasScreenRecordingPermission() {
853+
return
854+
}
855+
856+
triggerPkgs := config.GetScreenRecordingPackages()
857+
matchingPkgs := findMatchingPackages(cfg, triggerPkgs)
858+
if len(matchingPkgs) == 0 {
859+
return
860+
}
861+
862+
fmt.Println()
863+
ui.Header("Screen Recording Permission")
864+
fmt.Println()
865+
ui.Info(fmt.Sprintf("You installed: %s", strings.Join(matchingPkgs, ", ")))
866+
ui.Info("These apps need Screen Recording permission for screen sharing.")
867+
fmt.Println()
868+
869+
choice, err := ui.SelectOption("What would you like to do?", []string{
870+
"Open System Settings",
871+
"Remind me next time",
872+
"Don't remind again",
873+
})
874+
if err != nil {
875+
state.MarkSkipped(reminderState)
876+
_ = state.SaveState(statePath, reminderState)
877+
return
878+
}
879+
880+
switch choice {
881+
case "Open System Settings":
882+
if err := permissions.OpenScreenRecordingSettings(); err != nil {
883+
ui.Warn("Could not open System Settings")
884+
}
885+
state.MarkSkipped(reminderState)
886+
case "Remind me next time":
887+
state.MarkSkipped(reminderState)
888+
case "Don't remind again":
889+
state.MarkDismissed(reminderState)
890+
}
891+
892+
_ = state.SaveState(statePath, reminderState)
893+
fmt.Println()
894+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package installer
2+
3+
import (
4+
"testing"
5+
6+
"github.com/openbootdotdev/openboot/internal/config"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestFindMatchingPackages(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
selectedPkgs map[string]bool
14+
onlinePkgs []config.Package
15+
triggerPkgs []string
16+
wantCount int
17+
wantContains []string
18+
}{
19+
{
20+
name: "zoom installed matches trigger",
21+
selectedPkgs: map[string]bool{"zoom": true, "git": true, "curl": true},
22+
triggerPkgs: []string{"zoom", "microsoft-teams", "obs", "loom", "feishu", "lark"},
23+
wantCount: 1,
24+
wantContains: []string{"zoom"},
25+
},
26+
{
27+
name: "multiple matches",
28+
selectedPkgs: map[string]bool{"zoom": true, "obs": true, "git": true},
29+
triggerPkgs: []string{"zoom", "microsoft-teams", "obs", "loom", "feishu", "lark"},
30+
wantCount: 2,
31+
wantContains: []string{"zoom", "obs"},
32+
},
33+
{
34+
name: "no matches - only non-trigger packages",
35+
selectedPkgs: map[string]bool{"git": true, "curl": true, "google-chrome": true},
36+
triggerPkgs: []string{"zoom", "microsoft-teams", "obs", "loom", "feishu", "lark"},
37+
wantCount: 0,
38+
},
39+
{
40+
name: "empty selected packages",
41+
selectedPkgs: map[string]bool{},
42+
triggerPkgs: []string{"zoom", "microsoft-teams"},
43+
wantCount: 0,
44+
},
45+
{
46+
name: "empty trigger list",
47+
selectedPkgs: map[string]bool{"zoom": true},
48+
triggerPkgs: []string{},
49+
wantCount: 0,
50+
},
51+
{
52+
name: "nil selected packages",
53+
selectedPkgs: nil,
54+
triggerPkgs: []string{"zoom"},
55+
wantCount: 0,
56+
},
57+
{
58+
name: "feishu matches",
59+
selectedPkgs: map[string]bool{"feishu": true},
60+
triggerPkgs: []string{"zoom", "microsoft-teams", "obs", "loom", "feishu", "lark"},
61+
wantCount: 1,
62+
wantContains: []string{"feishu"},
63+
},
64+
{
65+
name: "lark matches",
66+
selectedPkgs: map[string]bool{"lark": true, "node": true},
67+
triggerPkgs: []string{"zoom", "microsoft-teams", "obs", "loom", "feishu", "lark"},
68+
wantCount: 1,
69+
wantContains: []string{"lark"},
70+
},
71+
{
72+
name: "online packages match",
73+
selectedPkgs: map[string]bool{"git": true},
74+
onlinePkgs: []config.Package{{Name: "zoom", IsCask: true}},
75+
triggerPkgs: []string{"zoom", "microsoft-teams"},
76+
wantCount: 1,
77+
wantContains: []string{"zoom"},
78+
},
79+
{
80+
name: "both selected and online match",
81+
selectedPkgs: map[string]bool{"zoom": true},
82+
onlinePkgs: []config.Package{{Name: "loom", IsCask: true}},
83+
triggerPkgs: []string{"zoom", "loom"},
84+
wantCount: 2,
85+
wantContains: []string{"zoom", "loom"},
86+
},
87+
}
88+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
cfg := &config.Config{
92+
SelectedPkgs: tt.selectedPkgs,
93+
OnlinePkgs: tt.onlinePkgs,
94+
}
95+
result := findMatchingPackages(cfg, tt.triggerPkgs)
96+
assert.Len(t, result, tt.wantCount)
97+
for _, want := range tt.wantContains {
98+
assert.Contains(t, result, want)
99+
}
100+
})
101+
}
102+
}
103+
104+
func TestGetScreenRecordingPackages(t *testing.T) {
105+
pkgs := config.GetScreenRecordingPackages()
106+
assert.NotEmpty(t, pkgs)
107+
assert.Contains(t, pkgs, "zoom")
108+
assert.Contains(t, pkgs, "microsoft-teams")
109+
assert.Contains(t, pkgs, "obs")
110+
assert.Contains(t, pkgs, "loom")
111+
assert.Contains(t, pkgs, "feishu")
112+
assert.Contains(t, pkgs, "lark")
113+
// Verify excluded packages are NOT in the list
114+
assert.NotContains(t, pkgs, "google-chrome")
115+
assert.NotContains(t, pkgs, "slack")
116+
assert.NotContains(t, pkgs, "discord")
117+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package permissions
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
)
7+
8+
/*
9+
#cgo LDFLAGS: -framework CoreGraphics
10+
#include <CoreGraphics/CoreGraphics.h>
11+
*/
12+
import "C"
13+
14+
// HasScreenRecordingPermission checks if the application has screen recording permission.
15+
// It uses CGPreflightScreenCaptureAccess() from the macOS CoreGraphics framework.
16+
// This function works on macOS 10.15+ (all supported macOS versions for this project).
17+
func HasScreenRecordingPermission() bool {
18+
return bool(C.CGPreflightScreenCaptureAccess())
19+
}
20+
21+
// OpenScreenRecordingSettings opens the macOS System Preferences screen recording settings.
22+
// It uses the x-apple.systempreferences URL scheme to navigate to the Privacy > Screen Recording settings.
23+
func OpenScreenRecordingSettings() error {
24+
cmd := exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")
25+
if err := cmd.Run(); err != nil {
26+
return fmt.Errorf("open screen recording settings: %w", err)
27+
}
28+
return nil
29+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package permissions
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// TestHasScreenRecordingPermission_Returns verifies that HasScreenRecordingPermission
10+
// returns a boolean value without panicking. This is an actual system call to the
11+
// macOS CoreGraphics framework, so the result depends on the system state.
12+
func TestHasScreenRecordingPermission_Returns(t *testing.T) {
13+
result := HasScreenRecordingPermission()
14+
assert.IsType(t, true, result)
15+
}
16+
17+
// TestOpenScreenRecordingSettings_NoError verifies that OpenScreenRecordingSettings
18+
// executes without panicking and returns an error type (even if nil).
19+
func TestOpenScreenRecordingSettings_NoError(t *testing.T) {
20+
err := OpenScreenRecordingSettings()
21+
// The function should return an error type (may be nil or non-nil depending on system state)
22+
assert.IsType(t, (*error)(nil), &err)
23+
}

0 commit comments

Comments
 (0)