From 060427728c0d65e1c6a91196b31c8a4e31456f73 Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 15:56:48 -0500 Subject: [PATCH 1/6] wip --- main.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index bea5427..72eeb59 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,9 @@ package main import ( "fmt" "os" + "os/user" + "path/filepath" + "strconv" "github.com/macadmins/default-browser/pkg/client" "github.com/macadmins/default-browser/pkg/launchservices" @@ -14,39 +17,53 @@ var version string func main() { var identifier string var noRescanLaunchServices bool + var targetUser string var rootCmd = &cobra.Command{ Use: "default-browser", Short: "A cli tool to set the default browser on macOS", RunE: func(cmd *cobra.Command, args []string) error { - return setDefault(identifier, noRescanLaunchServices) + return setDefault(identifier, noRescanLaunchServices, targetUser) }, } - rootCmd.Flags().StringVar(&identifier, "identifier", "com.google.chrome", "An identifier for the application") - rootCmd.Flags().BoolVar(&noRescanLaunchServices, "no-rescan-launchservices", false, "Do not rescan launch services. Only use if you are experiencing issues with System Settings not displaying correctly after a reboot.") + rootCmd.Flags().StringVar(&identifier, "identifier", "", "An identifier for the application") + rootCmd.Flags().BoolVar(&noRescanLaunchServices, "no-rescan-launchservices", false, "Do not rescan launch services.") rootCmd.Flags().BoolVar(&noRescanLaunchServices, "no-rebuild-launchservices", false, "Legacy: same as --no-rescan-launchservices") + rootCmd.Flags().StringVar(&targetUser, "user", "", "Username to operate on (only allowed when run as root)") + rootCmd.MarkFlagRequired("identifier") rootCmd.Version = version rootCmd.SetVersionTemplate("default-browser version {{.Version}}\n") if err := rootCmd.Execute(); err != nil { - fmt.Println(err) os.Exit(1) } } -func setDefault(identifier string, noRescanLaunchServices bool) error { - if identifier == "" { - return fmt.Errorf("identifier cannot be empty") - } +func setDefault(identifier string, noRescanLaunchServices bool, targetUser string) error { + var opts []client.Option - // Todo: actually run as the logged in user if run as root. For now just bail if os.Geteuid() == 0 { - return fmt.Errorf("this tool must be run as the logged in user") + if targetUser == "" { + return fmt.Errorf("--user must be specified when running as root") + } + + // Look up the specified user and construct the correct plist path + u, err := user.Lookup(targetUser) + if err != nil { + return fmt.Errorf("unknown user %s", targetUser) + } + + plistPath := filepath.Join(u.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") + opts = append(opts, client.WithCurrentUser(targetUser), client.WithPlistLocation(plistPath)) + } else { + if targetUser != "" { + return fmt.Errorf("--user can only be used when running as root") + } } - c, err := client.NewClient() + c, err := client.NewClient(opts...) if err != nil { return err } @@ -55,5 +72,27 @@ func setDefault(identifier string, noRescanLaunchServices bool) error { if err != nil { return err } + + // Fix ownership if specifying --user + if os.Geteuid() == 0 { + uid, err := parseUID(u) + if err != nil { + return err + } + + err = os.Chown(c.PlistLocation, uid, 20) // gid 20 = staff + if err != nil { + return fmt.Errorf("failed to chown plist: %v", err) + } + } + return nil } + +func parseUID(u *user.User) (int, error) { + uid, err := strconv.Atoi(u.Uid) + if err != nil { + return 0, fmt.Errorf("invalid UID for user %s: %v", u.Username, err) + } + return uid, nil +} From 9a50734a8c80627d45e12a7bc8dbcdc2b472fb6c Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 16:18:56 -0500 Subject: [PATCH 2/6] add --user flag --- main.go | 36 ++++++++--------------------- pkg/client/client.go | 7 +++++- pkg/client/userinfo.go | 52 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 pkg/client/userinfo.go diff --git a/main.go b/main.go index 72eeb59..e139dd9 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,7 @@ package main import ( "fmt" "os" - "os/user" "path/filepath" - "strconv" "github.com/macadmins/default-browser/pkg/client" "github.com/macadmins/default-browser/pkg/launchservices" @@ -21,7 +19,7 @@ func main() { var rootCmd = &cobra.Command{ Use: "default-browser", - Short: "A cli tool to set the default browser on macOS", + Short: "A CLI tool to set the default browser on macOS", RunE: func(cmd *cobra.Command, args []string) error { return setDefault(identifier, noRescanLaunchServices, targetUser) }, @@ -43,20 +41,20 @@ func main() { func setDefault(identifier string, noRescanLaunchServices bool, targetUser string) error { var opts []client.Option + var plistPath string if os.Geteuid() == 0 { if targetUser == "" { return fmt.Errorf("--user must be specified when running as root") } - - // Look up the specified user and construct the correct plist path - u, err := user.Lookup(targetUser) + + userInfo, err := client.LookupUserInfo(targetUser) if err != nil { - return fmt.Errorf("unknown user %s", targetUser) + return err } - plistPath := filepath.Join(u.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") - opts = append(opts, client.WithCurrentUser(targetUser), client.WithPlistLocation(plistPath)) + plistPath = filepath.Join(userInfo.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") + opts = append(opts, client.WithCurrentUser(userInfo.Username), client.WithPlistLocation(plistPath)) } else { if targetUser != "" { return fmt.Errorf("--user can only be used when running as root") @@ -68,31 +66,15 @@ func setDefault(identifier string, noRescanLaunchServices bool, targetUser strin return err } - err = launchservices.ModifyLS(c, identifier, noRescanLaunchServices) - if err != nil { + if err := launchservices.ModifyLS(c, identifier, noRescanLaunchServices); err != nil { return err } - // Fix ownership if specifying --user if os.Geteuid() == 0 { - uid, err := parseUID(u) - if err != nil { + if err := client.FixPlistOwnership(targetUser, c.PlistLocation); err != nil { return err } - - err = os.Chown(c.PlistLocation, uid, 20) // gid 20 = staff - if err != nil { - return fmt.Errorf("failed to chown plist: %v", err) - } } return nil } - -func parseUID(u *user.User) (int, error) { - uid, err := strconv.Atoi(u.Uid) - if err != nil { - return 0, fmt.Errorf("invalid UID for user %s: %v", u.Username, err) - } - return uid, nil -} diff --git a/pkg/client/client.go b/pkg/client/client.go index 2becf38..4574de4 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -2,6 +2,7 @@ package client import ( "os/user" + "path/filepath" osq "github.com/macadmins/osquery-extension/pkg/utils" ) @@ -42,7 +43,11 @@ func NewClient(opts ...Option) (Client, error) { } if c.PlistLocation == "" { - c.PlistLocation = "/Users/" + c.CurrentUser + "/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" + userInfo, err := LookupUserInfo(c.CurrentUser) + if err != nil { + return c, err + } + c.PlistLocation = filepath.Join(userInfo.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") } return c, nil diff --git a/pkg/client/userinfo.go b/pkg/client/userinfo.go new file mode 100644 index 0000000..9f2bd79 --- /dev/null +++ b/pkg/client/userinfo.go @@ -0,0 +1,52 @@ +package client + +import ( + "fmt" + "os" + "os/user" + "strconv" +) + +type UserInfo struct { + Username string + UID int + HomeDir string +} + +func LookupUserInfo(username string) (*UserInfo, error) { + u, err := user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("unknown user %s", username) + } + + uid, err := strconv.Atoi(u.Uid) + if err != nil { + return nil, fmt.Errorf("invalid UID for user %s: %v", u.Username, err) + } + + return &UserInfo{ + Username: u.Username, + UID: uid, + HomeDir: u.HomeDir, + }, nil +} + +func FixPlistOwnership(username, plistPath string) error { + userInfo, err := LookupUserInfo(username) + if err != nil { + return err + } + + // Use default group staff (GID 20) + const staffGID = 20 + + if err := os.Chown(plistPath, userInfo.UID, staffGID); err != nil { + return fmt.Errorf("failed to chown plist: %v", err) + } + + if err := os.Chmod(plistPath, 0644); err != nil { + return fmt.Errorf("failed to chmod plist: %v", err) + } + + return nil +} \ No newline at end of file From 4f0568851440c6b931557dd80adbf2229b706369 Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 16:22:43 -0500 Subject: [PATCH 3/6] tests --- pkg/client/userinfo_test.go | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 pkg/client/userinfo_test.go diff --git a/pkg/client/userinfo_test.go b/pkg/client/userinfo_test.go new file mode 100644 index 0000000..5b093b6 --- /dev/null +++ b/pkg/client/userinfo_test.go @@ -0,0 +1,54 @@ +package client_test + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + + "github.com/macadmins/default-browser/pkg/client" + "github.com/stretchr/testify/assert" +) + +func TestLookupUserInfo(t *testing.T) { + currentUser, err := user.Current() + assert.NoError(t, err, "user.Current should not return an error") + + info, err := client.LookupUserInfo(currentUser.Username) + assert.NoError(t, err, "LookupUserInfo should not return an error") + assert.Equal(t, currentUser.Username, info.Username, "Username should match") + assert.Equal(t, currentUser.HomeDir, info.HomeDir, "HomeDir should match") + assert.Greater(t, info.UID, 0, "UID should be greater than 0") +} + +func TestLookupUserInfo_InvalidUser(t *testing.T) { + _, err := client.LookupUserInfo("not-a-real-user") + assert.Error(t, err, "LookupUserInfo should return an error for a non-existent user") +} + +func TestFixPlistOwnership(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("TestFixPlistOwnership requires root privileges") + } + + currentUser, err := user.Current() + assert.NoError(t, err, "user.Current should not return an error") + + tmpfile, err := os.CreateTemp("", "test.plist") + assert.NoError(t, err, "CreateTemp should not return an error") + defer os.Remove(tmpfile.Name()) + + err = client.FixPlistOwnership(currentUser.Username, tmpfile.Name()) + assert.NoError(t, err, "FixPlistOwnership should not return an error") + + info, err := os.Stat(tmpfile.Name()) + assert.NoError(t, err, "Stat should not return an error") + + stat := info.Sys().(*os.FileStat) + assert.Equal(t, uint32(0644), info.Mode().Perm(), "File mode should be 0644") + + // Convert UID to string for comparison with currentUser.Uid + fileUID := strconv.Itoa(int(stat.Uid)) + assert.Equal(t, currentUser.Uid, fileUID, "File owner UID should match current user") +} From d49770df5b66f5cf83172b6c7232abab9bbecd92 Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 16:37:28 -0500 Subject: [PATCH 4/6] readme --- README.md | 6 ++++++ main.go | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2381348..558473b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ To set other browsers as the default, use the following identifiers: - Firefox: `org.mozilla.firefox` - MS Edge: `com.microsoft.edgemac` +To set the default browser for another user, run within a root context and specify `--user`. The user account must exist. + +```shell +/opt/macadmins/bin/default-browser --identifier com.google.chrome --user tim.apple +``` + ## Known issues ### System Settings may not work correctly diff --git a/main.go b/main.go index e139dd9..d384139 100644 --- a/main.go +++ b/main.go @@ -25,12 +25,11 @@ func main() { }, } - rootCmd.Flags().StringVar(&identifier, "identifier", "", "An identifier for the application") + rootCmd.Flags().StringVar(&identifier, "identifier", "com.google.chrome", "An identifier for the application") rootCmd.Flags().BoolVar(&noRescanLaunchServices, "no-rescan-launchservices", false, "Do not rescan launch services.") rootCmd.Flags().BoolVar(&noRescanLaunchServices, "no-rebuild-launchservices", false, "Legacy: same as --no-rescan-launchservices") rootCmd.Flags().StringVar(&targetUser, "user", "", "Username to operate on (only allowed when run as root)") - rootCmd.MarkFlagRequired("identifier") rootCmd.Version = version rootCmd.SetVersionTemplate("default-browser version {{.Version}}\n") From 944450450d1df017f79e3d56f93385c5f822e3a1 Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 16:41:24 -0500 Subject: [PATCH 5/6] sudo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 558473b..8b603c4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To set other browsers as the default, use the following identifiers: To set the default browser for another user, run within a root context and specify `--user`. The user account must exist. ```shell -/opt/macadmins/bin/default-browser --identifier com.google.chrome --user tim.apple +sudo /opt/macadmins/bin/default-browser --identifier com.google.chrome --user tim.apple ``` ## Known issues From 40ab86bed6febe8e5d037ad285b8e4a46f415b97 Mon Sep 17 00:00:00 2001 From: Nathnaniel Strauss Date: Wed, 30 Jul 2025 17:02:34 -0500 Subject: [PATCH 6/6] abstract plist path --- main.go | 3 +-- pkg/client/client.go | 3 +-- pkg/client/userinfo.go | 5 +++++ pkg/client/userinfo_test.go | 13 ++++++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index d384139..c1e5123 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "path/filepath" "github.com/macadmins/default-browser/pkg/client" "github.com/macadmins/default-browser/pkg/launchservices" @@ -52,7 +51,7 @@ func setDefault(identifier string, noRescanLaunchServices bool, targetUser strin return err } - plistPath = filepath.Join(userInfo.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") + plistPath = userInfo.LaunchServicesPlistPath() opts = append(opts, client.WithCurrentUser(userInfo.Username), client.WithPlistLocation(plistPath)) } else { if targetUser != "" { diff --git a/pkg/client/client.go b/pkg/client/client.go index 4574de4..f049fec 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -2,7 +2,6 @@ package client import ( "os/user" - "path/filepath" osq "github.com/macadmins/osquery-extension/pkg/utils" ) @@ -47,7 +46,7 @@ func NewClient(opts ...Option) (Client, error) { if err != nil { return c, err } - c.PlistLocation = filepath.Join(userInfo.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") + c.PlistLocation = userInfo.LaunchServicesPlistPath() } return c, nil diff --git a/pkg/client/userinfo.go b/pkg/client/userinfo.go index 9f2bd79..a95ca16 100644 --- a/pkg/client/userinfo.go +++ b/pkg/client/userinfo.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/user" + "path/filepath" "strconv" ) @@ -13,6 +14,10 @@ type UserInfo struct { HomeDir string } +func (u *UserInfo) LaunchServicesPlistPath() string { + return filepath.Join(u.HomeDir, "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist") +} + func LookupUserInfo(username string) (*UserInfo, error) { u, err := user.Lookup(username) if err != nil { diff --git a/pkg/client/userinfo_test.go b/pkg/client/userinfo_test.go index 5b093b6..dbc6ae5 100644 --- a/pkg/client/userinfo_test.go +++ b/pkg/client/userinfo_test.go @@ -22,8 +22,19 @@ func TestLookupUserInfo(t *testing.T) { assert.Greater(t, info.UID, 0, "UID should be greater than 0") } +func TestLaunchServicesPlistPath(t *testing.T) { + userInfo := &client.UserInfo{ + Username: "fakeuser", + UID: 501, + HomeDir: "/Users/fakeuser", + } + + expected := "/Users/fakeuser/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" + assert.Equal(t, expected, userInfo.LaunchServicesPlistPath(), "LaunchServicesPlistPath should construct the correct path") +} + func TestLookupUserInfo_InvalidUser(t *testing.T) { - _, err := client.LookupUserInfo("not-a-real-user") + _, err := client.LookupUserInfo("fakeuser") assert.Error(t, err, "LookupUserInfo should return an error for a non-existent user") }