Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 32 additions & 13 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions pkg/client/userinfo.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions pkg/client/userinfo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package client_test

Check failure on line 1 in pkg/client/userinfo_test.go

View workflow job for this annotation

GitHub Actions / lint

: # github.com/macadmins/default-browser/pkg/client_test [github.com/macadmins/default-browser/pkg/client.test]

import (
"os"
"os/user"
"path/filepath"

Check failure on line 6 in pkg/client/userinfo_test.go

View workflow job for this annotation

GitHub Actions / test-coverage

"path/filepath" imported and not used
"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)

Check failure on line 59 in pkg/client/userinfo_test.go

View workflow job for this annotation

GitHub Actions / test-coverage

undefined: 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")
}
Loading