diff --git a/cli/device.go b/cli/device.go index 248c2d4..32e68f1 100644 --- a/cli/device.go +++ b/cli/device.go @@ -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, @@ -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) }, } @@ -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'") } diff --git a/commands/settings.go b/commands/settings.go index 3d1f381..dfaaacb 100644 --- a/commands/settings.go +++ b/commands/settings.go @@ -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) } @@ -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) @@ -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 +} diff --git a/devices/android.go b/devices/android.go index 8e81733..cbf4c5b 100644 --- a/devices/android.go +++ b/devices/android.go @@ -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 { diff --git a/devices/appearance_test.go b/devices/appearance_test.go new file mode 100644 index 0000000..0f3d26d --- /dev/null +++ b/devices/appearance_test.go @@ -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) + } + } +} diff --git a/devices/common.go b/devices/common.go index c127e7b..5319525 100644 --- a/devices/common.go +++ b/devices/common.go @@ -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) diff --git a/devices/devicekit/appearance.go b/devices/devicekit/appearance.go new file mode 100644 index 0000000..cc50bea --- /dev/null +++ b/devices/devicekit/appearance.go @@ -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 +} diff --git a/devices/ios.go b/devices/ios.go index 07e76da..4d83462 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -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"` diff --git a/devices/remote.go b/devices/remote.go index d5fef53..8b4af9e 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -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{}) } diff --git a/devices/simulator.go b/devices/simulator.go index 6d76ce8..0d1b963 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -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) { diff --git a/docs/openrpc.json b/docs/openrpc.json index 2ae523e..df0207c 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -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", diff --git a/server/server.go b/server/server.go index 6a1701e..00b4c17 100644 --- a/server/server.go +++ b/server/server.go @@ -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 { @@ -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)