Skip to content

Commit 9438534

Browse files
committed
Make the macOS display configuration also change display mode
This renames the `macos-color-profile` command to `macos-display-configuration` (as it is a CI command, it is reasonable to believe this is safe), and expands its scope. Along with color space, we now also attempt to make the smallest change possible to the display mode such that we end up on a 1x display mode.
1 parent 15cbfdb commit 9438534

File tree

6 files changed

+248
-51
lines changed

6 files changed

+248
-51
lines changed

.github/workflows/safari-wptrunner.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ jobs:
6868
REV: ${{ inputs.test-rev }}
6969
run: |-
7070
git switch --force --progress -d "$REV"
71-
- name: Set display color profile
71+
- name: Reconfigure the display(s)
7272
run: |-
73-
./wpt macos-color-profile
73+
./wpt macos-display-configuration
7474
- name: Set system caption style to ""
7575
run: |-
7676
defaults write com.apple.mediaaccessibility MACaptionActiveProfile -string ""

tools/ci/azure/color_profile.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
steps:
22
- script: |
3-
./wpt macos-color-profile
4-
displayName: 'Set display color profile'
3+
./wpt macos-display-configuration
4+
displayName: 'Reconfigure the display(s)'
55
condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin'))

tools/ci/commands.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
"help": "Output a hosts file to stdout",
1414
"virtualenv": false
1515
},
16-
"macos-color-profile": {
17-
"path": "macos_color_profile.py",
16+
"macos-display-configuration": {
17+
"path": "macos_display_configuration.py",
1818
"script": "run",
19-
"help": "Change the macOS color profile to sRGB",
19+
"parser": "create_parser",
20+
"help": "Configure macOS displays (scale, color profile)",
2021
"virtualenv": true,
2122
"requirements": [
22-
"requirements_macos_color_profile.txt"
23+
"requirements_macos_display_configuration.txt"
2324
]
2425
},
2526
"regen-certs": {

tools/ci/macos_color_profile.py

Lines changed: 0 additions & 43 deletions
This file was deleted.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import argparse
2+
import sys
3+
from typing import Any, NewType, Optional, Tuple
4+
5+
from Cocoa import NSURL
6+
from ColorSync import (
7+
CGDisplayCreateUUIDFromDisplayID,
8+
ColorSyncDeviceSetCustomProfiles,
9+
kColorSyncDeviceDefaultProfileID,
10+
kColorSyncDisplayDeviceClass,
11+
)
12+
from Quartz import (
13+
CGBeginDisplayConfiguration,
14+
CGCancelDisplayConfiguration,
15+
CGCompleteDisplayConfiguration,
16+
CGConfigureDisplayWithDisplayMode,
17+
CGDisplayCopyAllDisplayModes,
18+
CGDisplayCopyDisplayMode,
19+
CGDisplayModeGetHeight,
20+
CGDisplayModeGetIOFlags,
21+
CGDisplayModeGetPixelHeight,
22+
CGDisplayModeGetPixelWidth,
23+
CGDisplayModeGetRefreshRate,
24+
CGDisplayModeGetWidth,
25+
CGDisplayModeIsUsableForDesktopGUI,
26+
CGDisplayModeRef,
27+
CGGetOnlineDisplayList,
28+
kCGConfigurePermanently,
29+
kCGErrorSuccess,
30+
)
31+
32+
# Display mode flags
33+
kDisplayModeDefaultFlag = 0x00000004 # noqa: N816
34+
35+
# Create a new type for display IDs
36+
CGDirectDisplayID = NewType("CGDirectDisplayID", int)
37+
38+
39+
def get_pixel_size(mode: CGDisplayModeRef) -> Tuple[int, int]:
40+
return (CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode))
41+
42+
43+
def get_size(mode: CGDisplayModeRef) -> Tuple[int, int]:
44+
return (CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode))
45+
46+
47+
def calculate_mode_similarity_score(
48+
mode: CGDisplayModeRef, current_mode: CGDisplayModeRef
49+
) -> int:
50+
current_size = get_size(current_mode)
51+
current_pixel_size = get_pixel_size(current_mode)
52+
current_refresh_rate = CGDisplayModeGetRefreshRate(current_mode)
53+
current_flags = CGDisplayModeGetIOFlags(current_mode)
54+
55+
size = get_size(mode)
56+
pixel_size = get_pixel_size(mode)
57+
refresh_rate = CGDisplayModeGetRefreshRate(mode)
58+
flags = CGDisplayModeGetIOFlags(mode)
59+
60+
differences = 0
61+
62+
if size != current_size:
63+
differences += 1
64+
if pixel_size != current_pixel_size:
65+
differences += 1
66+
if refresh_rate != current_refresh_rate:
67+
differences += 1
68+
69+
# Count how many individual flags are changing (XOR then count bits)
70+
changed_flags = flags ^ current_flags
71+
if sys.version_info >= (3, 10):
72+
differences += changed_flags.bit_count()
73+
else:
74+
differences += bin(changed_flags).count("1")
75+
76+
return differences
77+
78+
79+
def find_best_unscaled_mode(display_id: CGDirectDisplayID) -> CGDisplayModeRef:
80+
current_mode: Optional[CGDisplayModeRef] = CGDisplayCopyDisplayMode(display_id)
81+
82+
# If we already have an unscaled mode, we're done.
83+
if current_mode and (
84+
get_size(current_mode) == get_pixel_size(current_mode)
85+
):
86+
return current_mode
87+
88+
all_modes = CGDisplayCopyAllDisplayModes(display_id, None)
89+
if not all_modes:
90+
raise Exception("No display modes")
91+
92+
# If we don't have a current mode, use the default mode instead.
93+
if not current_mode:
94+
default_modes = [
95+
m for m in all_modes if CGDisplayModeGetIOFlags(m) & kDisplayModeDefaultFlag
96+
]
97+
if not default_modes:
98+
raise Exception("No default display mode found")
99+
current_mode = default_modes[0]
100+
assert current_mode is not None
101+
102+
if get_size(current_mode) == get_pixel_size(current_mode):
103+
return current_mode
104+
105+
candidates = [
106+
m
107+
for m in all_modes
108+
if CGDisplayModeIsUsableForDesktopGUI(m) and get_size(m) == get_pixel_size(m)
109+
]
110+
if not candidates:
111+
raise Exception("No suitable display modes")
112+
113+
same_size_candidates = [
114+
m for m in candidates if get_size(m) == get_size(current_mode)
115+
]
116+
same_pixel_size_candidates = [
117+
m for m in candidates if get_pixel_size(m) == get_pixel_size(current_mode)
118+
]
119+
120+
if same_size_candidates:
121+
candidates = same_size_candidates
122+
elif same_pixel_size_candidates:
123+
candidates = same_pixel_size_candidates
124+
125+
return min(
126+
candidates,
127+
key=lambda m: calculate_mode_similarity_score(m, current_mode),
128+
)
129+
130+
131+
def set_color_profiles(profile_url: NSURL, *, dry_run: bool = False) -> bool:
132+
max_displays = 10
133+
134+
(err, display_ids, display_count) = CGGetOnlineDisplayList(max_displays, None, None)
135+
if err != kCGErrorSuccess:
136+
raise ValueError(err)
137+
138+
display_uuids = [CGDisplayCreateUUIDFromDisplayID(d) for d in display_ids]
139+
140+
for display_id, display_uuid in zip(display_ids, display_uuids):
141+
if dry_run:
142+
print(
143+
f"Would set color profile for display {display_id} to {profile_url.path()}"
144+
)
145+
else:
146+
profile_info = {kColorSyncDeviceDefaultProfileID: profile_url}
147+
success = ColorSyncDeviceSetCustomProfiles(
148+
kColorSyncDisplayDeviceClass,
149+
display_uuid,
150+
profile_info,
151+
)
152+
if not success:
153+
raise Exception(f"failed to set profile on {display_uuid}")
154+
print(f"Set color profile for display {display_id}")
155+
156+
return True
157+
158+
159+
def set_display_modes(*, dry_run: bool = False) -> bool:
160+
max_displays = 10
161+
162+
err, display_ids, display_count = CGGetOnlineDisplayList(max_displays, None, None)
163+
if err != kCGErrorSuccess:
164+
raise ValueError(err)
165+
166+
if dry_run:
167+
for display_id in display_ids:
168+
best_mode = find_best_unscaled_mode(display_id)
169+
best_size = get_size(best_mode)
170+
print(f"Would change display {display_id} to {best_size}")
171+
return True
172+
173+
err, config_ref = CGBeginDisplayConfiguration(None)
174+
if err != kCGErrorSuccess:
175+
raise Exception("Failed to begin display configuration")
176+
177+
try:
178+
for display_id in display_ids:
179+
best_mode = find_best_unscaled_mode(display_id)
180+
best_size = get_size(best_mode)
181+
182+
err = CGConfigureDisplayWithDisplayMode(
183+
config_ref, display_id, best_mode, None
184+
)
185+
if err != kCGErrorSuccess:
186+
raise Exception(
187+
f"Failed to configure mode for display {display_id}: {err}"
188+
)
189+
190+
print(f"Configured display {display_id} mode to {best_size}")
191+
192+
except Exception:
193+
CGCancelDisplayConfiguration(config_ref)
194+
raise
195+
196+
else:
197+
err = CGCompleteDisplayConfiguration(config_ref, kCGConfigurePermanently)
198+
if err != kCGErrorSuccess:
199+
raise Exception(f"Failed to complete display configuration: {err}")
200+
201+
print("Display configuration applied permanently")
202+
203+
return True
204+
205+
206+
def create_parser() -> argparse.ArgumentParser:
207+
parser = argparse.ArgumentParser()
208+
parser.add_argument(
209+
"--dry-run",
210+
action="store_true",
211+
help="Show what would be done without making changes",
212+
)
213+
parser.add_argument(
214+
"--no-color-profile",
215+
action="store_false",
216+
help="Don't set color profiles",
217+
)
218+
parser.add_argument(
219+
"--no-display-mode",
220+
action="store_false",
221+
help="Don't set display mode",
222+
)
223+
parser.add_argument(
224+
"--profile-path",
225+
default="/System/Library/ColorSync/Profiles/sRGB Profile.icc",
226+
help="Path to color profile to use (default: sRGB)",
227+
)
228+
return parser
229+
230+
231+
def run(venv: Any, **kwargs: Any) -> None:
232+
profile_url = NSURL.fileURLWithPath_(kwargs["profile_path"])
233+
dry_run = kwargs["dry_run"]
234+
235+
if not kwargs["no_color_profile"]:
236+
set_color_profiles(profile_url, dry_run=dry_run)
237+
238+
if not kwargs["no_display_mode"]:
239+
set_display_modes(dry_run=dry_run)

0 commit comments

Comments
 (0)