From f2dee92b1a3ddeda54ea57e7a0856447f6f66e14 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 14 Sep 2026 21:27:25 +0200 Subject: [PATCH] feat: device settings apply --appearance=light|dark Switches the system appearance. Android uses 'cmd uimode night', iOS and simulators go through DeviceKit's device.settings.apply, remote devices forward the RPC. Starts the agent only when appearance is requested, since animations remain plain adb. --- cli/device.go | 8 ++++++- commands/settings.go | 39 +++++++++++++++++++++++++++++++- devices/android.go | 15 +++++++++++++ devices/appearance_test.go | 21 +++++++++++++++++ devices/common.go | 6 +++++ devices/devicekit/appearance.go | 10 +++++++++ devices/ios.go | 5 +++++ devices/remote.go | 4 ++++ devices/simulator.go | 5 +++++ docs/openrpc.json | 40 +++++++++++++++++++++++++++++++++ server/server.go | 4 +++- 11 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 devices/appearance_test.go create mode 100644 devices/devicekit/appearance.go diff --git a/cli/device.go b/cli/device.go index 248c2d42..32e68f19 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 3d1f3816..dfaaacb3 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 8e817334..cbf4c5b7 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 00000000..0f3d26d0 --- /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 c127e7b4..5319525a 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 00000000..cc50bea8 --- /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 07e76da3..4d83462c 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 d5fef530..8b4af9ee 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 6d76ce83..0d1b9633 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 2ae523ee..df0207cd 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 6a1701ea..00b4c173 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)