diff --git a/README.md b/README.md index 2381348..8b603c4 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 +sudo /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 bea5427..c1e5123 100644 --- a/main.go +++ b/main.go @@ -14,46 +14,65 @@ 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", + 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().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.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 + var plistPath string - // 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") + } + + userInfo, err := client.LookupUserInfo(targetUser) + if err != nil { + return err + } + + plistPath = userInfo.LaunchServicesPlistPath() + 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") + } } - c, err := client.NewClient() + c, err := client.NewClient(opts...) if err != nil { return err } - err = launchservices.ModifyLS(c, identifier, noRescanLaunchServices) - if err != nil { + if err := launchservices.ModifyLS(c, identifier, noRescanLaunchServices); err != nil { return err } + + if os.Geteuid() == 0 { + if err := client.FixPlistOwnership(targetUser, c.PlistLocation); err != nil { + return err + } + } + return nil } diff --git a/pkg/client/client.go b/pkg/client/client.go index 2becf38..f049fec 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -42,7 +42,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 = userInfo.LaunchServicesPlistPath() } return c, nil diff --git a/pkg/client/userinfo.go b/pkg/client/userinfo.go new file mode 100644 index 0000000..a95ca16 --- /dev/null +++ b/pkg/client/userinfo.go @@ -0,0 +1,57 @@ +package client + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strconv" +) + +type UserInfo struct { + Username string + UID int + 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 { + 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 diff --git a/pkg/client/userinfo_test.go b/pkg/client/userinfo_test.go new file mode 100644 index 0000000..dbc6ae5 --- /dev/null +++ b/pkg/client/userinfo_test.go @@ -0,0 +1,65 @@ +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 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("fakeuser") + 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") +}