Skip to content
Merged
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
8 changes: 7 additions & 1 deletion cli/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,12 @@ var settingsCmd = &cobra.Command{
}

var settingsAnimations string
var settingsAppearance string

var settingsApplyCmd = &cobra.Command{
Use: "apply",
Short: "Apply device settings",
Long: `Apply device-level settings. Example: mobilecli device settings apply --animations=off`,
Long: `Apply device-level settings. Example: mobilecli device settings apply --animations=off --appearance=dark`,
RunE: func(cmd *cobra.Command, args []string) error {
req := commands.ApplySettingsRequest{
DeviceID: deviceId,
Expand All @@ -114,6 +115,10 @@ var settingsApplyCmd = &cobra.Command{
req.Animations = &settingsAnimations
}

if cmd.Flags().Changed("appearance") {
req.Appearance = &settingsAppearance
}

return runViaDaemon("cli.device.settings.apply", req)
},
}
Expand Down Expand Up @@ -145,4 +150,5 @@ func init() {
orientationSetCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to set orientation on")
settingsApplyCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to apply settings to")
settingsApplyCmd.Flags().StringVar(&settingsAnimations, "animations", "", "Toggle system animations: 'on' or 'off'")
settingsApplyCmd.Flags().StringVar(&settingsAppearance, "appearance", "", "System appearance: 'light' or 'dark'")
}
39 changes: 38 additions & 1 deletion commands/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import (
type ApplySettingsRequest struct {
DeviceID string `json:"deviceId"`
Animations *string `json:"animations,omitempty"` // "on" or "off"
Appearance *string `json:"appearance,omitempty"` // "light" or "dark"
}

// ApplySettingsCommand applies the provided device settings. Settings that a
// platform cannot honor are skipped with a debug log and never fail the call.
func ApplySettingsCommand(req ApplySettingsRequest) *CommandResponse {
device, err := FindDeviceOrAutoSelect(req.DeviceID)
device, err := findDeviceForSettings(req)
if err != nil {
return NewErrorResponse(err)
}
Expand All @@ -30,9 +31,26 @@ func ApplySettingsCommand(req ApplySettingsRequest) *CommandResponse {
}
}

if req.Appearance != nil {
err = applyAppearance(device, *req.Appearance)
if err != nil {
return NewErrorResponse(err)
}
}

return NewSuccessResponse(OK)
}

// findDeviceForSettings starts the device agent only when a setting needs it:
// appearance goes through DeviceKit on iOS, while animations are plain adb.
func findDeviceForSettings(req ApplySettingsRequest) (devices.ControllableDevice, error) {
if req.Appearance != nil {
return FindDeviceWithAgent(req.DeviceID)
}

return FindDeviceOrAutoSelect(req.DeviceID)
}

func applyAnimations(device devices.ControllableDevice, animations string) error {
if animations != "on" && animations != "off" {
return fmt.Errorf("invalid value for animations '%s', must be 'on' or 'off'", animations)
Expand All @@ -51,3 +69,22 @@ func applyAnimations(device devices.ControllableDevice, animations string) error

return nil
}

func applyAppearance(device devices.ControllableDevice, appearance string) error {
if appearance != "light" && appearance != "dark" {
return fmt.Errorf("invalid value for appearance '%s', must be 'light' or 'dark'", appearance)
}

configurable, ok := device.(devices.AppearanceConfigurable)
if !ok {
utils.Verbose("appearance not supported on %s (%s), skipping", device.ID(), device.Platform())
return nil
}

err := configurable.SetAppearance(appearance)
if err != nil {
return fmt.Errorf("failed to apply appearance setting: %v", err)
}

return nil
}
15 changes: 15 additions & 0 deletions devices/android.go
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,21 @@ func (d *AndroidDevice) SetAnimationsEnabled(enabled bool) error {
return nil
}

// SetAppearance switches the system-wide night mode (Android 10+).
func (d *AndroidDevice) SetAppearance(appearance string) error {
night := "no"
if appearance == "dark" {
night = "yes"
}

_, err := d.runAdbCommand("shell", "cmd", "uimode", "night", night)
if err != nil {
return fmt.Errorf("failed to set appearance: %v", err)
}

return nil
}

func (d *AndroidDevice) getCrashLog() (string, error) {
output, err := d.runAdbCommand("logcat", "-b", "crash", "-d", "-v", "year")
if err != nil {
Expand Down
21 changes: 21 additions & 0 deletions devices/appearance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package devices

import "testing"

// Appearance is applied via adb on Android and via DeviceKit on iOS and the
// simulator, and forwarded over RPC for remote devices. Callers rely on the
// AppearanceConfigurable type assertion, so pin down who satisfies it.
func TestAppearanceConfigurableImplementers(t *testing.T) {
implementers := map[string]any{
"AndroidDevice": (*AndroidDevice)(nil),
"IOSDevice": (*IOSDevice)(nil),
"SimulatorDevice": SimulatorDevice{},
"RemoteDevice": (*RemoteDevice)(nil),
}

for name, device := range implementers {
if _, ok := device.(AppearanceConfigurable); !ok {
t.Errorf("%s should implement AppearanceConfigurable", name)
}
}
}
6 changes: 6 additions & 0 deletions devices/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ type AnimationConfigurable interface {
SetAnimationsEnabled(enabled bool) error
}

// AppearanceConfigurable is implemented by devices that can switch the system
// appearance between light and dark mode.
type AppearanceConfigurable interface {
SetAppearance(appearance string) error
}

// WebViewable is implemented by devices that support webview inspection and control.
type WebViewable interface {
ListWebViews() ([]WebViewInfo, error)
Expand Down
10 changes: 10 additions & 0 deletions devices/devicekit/appearance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package devicekit

func (c *DeviceKitClient) SetAppearance(appearance string) error {
params := map[string]string{
"appearance": appearance,
}

_, err := c.CallRPC("device.settings.apply", params)
return err
}
5 changes: 5 additions & 0 deletions devices/ios.go
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,11 @@ func (d *IOSDevice) SetOrientation(orientation string) error {
return d.deviceKitClient.SetOrientation(orientation)
}

// SetAppearance switches the device between light and dark mode
func (d *IOSDevice) SetAppearance(appearance string) error {
return d.deviceKitClient.SetAppearance(appearance)
}

// DeviceKitInfo contains information about the started DeviceKit session
type DeviceKitInfo struct {
HTTPPort int `json:"httpPort"`
Expand Down
4 changes: 4 additions & 0 deletions devices/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ func (r *RemoteDevice) SetOrientation(orientation string) error {
return r.fireRPC("device.io.orientation.set", params{"orientation": orientation})
}

func (r *RemoteDevice) SetAppearance(appearance string) error {
return r.fireRPC("device.settings.apply", params{"appearance": appearance})
}

func (r *RemoteDevice) Info() (*FullDeviceInfo, error) {
return rpcCall[*FullDeviceInfo](r, "device.info", params{})
}
Expand Down
5 changes: 5 additions & 0 deletions devices/simulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,11 @@ func (s SimulatorDevice) SetOrientation(orientation string) error {
return s.deviceKitClient.SetOrientation(orientation)
}

// SetAppearance switches the simulator between light and dark mode
func (s SimulatorDevice) SetAppearance(appearance string) error {
return s.deviceKitClient.SetAppearance(appearance)
}

var diagnosticReportsDir = filepath.Join(os.Getenv("HOME"), "Library", "Logs", "DiagnosticReports")

func (s SimulatorDevice) ListCrashReports() ([]CrashReport, error) {
Expand Down
40 changes: 40 additions & 0 deletions docs/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,46 @@
}
}
},
{
"name": "device.settings.apply",
"summary": "Apply device settings",
"description": "Applies device-level settings. Only the settings provided are changed",
"params": [
{
"name": "deviceId",
"description": "ID of the target device",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "animations",
"description": "Toggle system animations (Android only)",
"required": false,
"schema": {
"type": "string",
"enum": ["on", "off"]
}
},
{
"name": "appearance",
"description": "System appearance",
"required": false,
"schema": {
"type": "string",
"enum": ["light", "dark"]
}
}
],
"result": {
"name": "success",
"description": "Operation result",
"schema": {
"$ref": "#/components/schemas/SuccessResult"
}
}
},
{
"name": "device.dump.ui",
"summary": "Dump UI hierarchy",
Expand Down
4 changes: 3 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ type LocationClearParams struct {
type DeviceSettingsApplyParams struct {
DeviceID string `json:"deviceId"`
Animations *string `json:"animations,omitempty"` // "on" or "off"
Appearance *string `json:"appearance,omitempty"` // "light" or "dark"
}

type DeviceBootParams struct {
Expand Down Expand Up @@ -989,12 +990,13 @@ func handleSettingsApply(params json.RawMessage) (any, error) {

var settingsParams DeviceSettingsApplyParams
if err := json.Unmarshal(params, &settingsParams); err != nil {
return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, animations", err)
return nil, fmt.Errorf("invalid parameters: %w. Expected fields: deviceId, animations, appearance", err)
}

req := commands.ApplySettingsRequest{
DeviceID: settingsParams.DeviceID,
Animations: settingsParams.Animations,
Appearance: settingsParams.Appearance,
}

response := commands.ApplySettingsCommand(req)
Expand Down
Loading