Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 # Copyright 2015 The LUCI Authors. All rights reserved. | 1 # Copyright 2015 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
| 4 | 4 |
| 5 """OSX specific utility functions.""" | 5 """OSX specific utility functions.""" |
| 6 | 6 |
| 7 import cgi | 7 import cgi |
| 8 import ctypes | 8 import ctypes |
| 9 import logging | 9 import logging |
| 10 import os | 10 import os |
| 11 import platform | 11 import platform |
| 12 import plistlib | |
| 12 import re | 13 import re |
| 14 import struct | |
| 13 import subprocess | 15 import subprocess |
| 14 import time | 16 import time |
| 15 | 17 |
| 16 import plistlib | |
| 17 | |
| 18 from utils import tools | 18 from utils import tools |
| 19 | 19 |
| 20 import common | 20 import common |
| 21 import gpu | 21 import gpu |
| 22 | 22 |
| 23 | 23 |
| 24 ## Private stuff. | 24 ## Private stuff. |
| 25 | 25 |
| 26 | 26 |
| 27 iokit = ctypes.CDLL( | |
| 28 '/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit') | |
| 29 # https://developer.apple.com/documentation/iokit/1514274-ioconnectcallstructmet hod | |
| 30 iokit.IOConnectCallStructMethod.argtypes = [ | |
| 31 ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulonglong, | |
| 32 ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulonglong), | |
| 33 ] | |
| 34 iokit.IOConnectCallStructMethod.restype = ctypes.c_int | |
| 35 | |
| 36 # https://developer.apple.com/documentation/iokit/1514515-ioserviceopen | |
| 37 iokit.IOServiceOpen.argtypes = [ | |
| 38 ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), | |
| 39 ] | |
| 40 iokit.IOServiceOpen.restype = ctypes.c_int | |
| 41 | |
| 42 # https://developer.apple.com/documentation/iokit/1514687-ioservicematching | |
| 43 iokit.IOServiceMatching.restype = ctypes.c_void_p | |
| 44 | |
| 45 # https://developer.apple.com/documentation/iokit/1514494-ioservicegetmatchingse rvices | |
| 46 iokit.IOServiceGetMatchingServices.argtypes = [ | |
| 47 ctypes.c_uint, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint), | |
| 48 ] | |
| 49 iokit.IOServiceGetMatchingServices.restype = ctypes.c_int | |
| 50 | |
| 51 # https://developer.apple.com/documentation/iokit/1514741-ioiteratornext | |
| 52 iokit.IOIteratorNext.argtypes = [ctypes.c_uint] | |
| 53 iokit.IOIteratorNext.restype = ctypes.c_uint | |
| 54 | |
| 55 # https://developer.apple.com/documentation/iokit/1514627-ioobjectrelease | |
| 56 iokit.IOObjectRelease.argtypes = [ctypes.c_uint] | |
| 57 iokit.IOObjectRelease.restype = ctypes.c_int | |
| 58 | |
| 59 | |
| 60 libkern = ctypes.CDLL('/usr/lib/system/libsystem_kernel.dylib') | |
| 61 libkern.mach_task_self.restype = ctypes.c_uint | |
| 62 | |
| 63 | |
| 64 class _SMC_KeyDataVersion(ctypes.Structure): | |
| 65 _fields_ = [ | |
| 66 ('major', ctypes.c_uint8), | |
| 67 ('minor', ctypes.c_uint8), | |
| 68 ('build', ctypes.c_uint8), | |
| 69 ('reserved', ctypes.c_uint8), | |
| 70 ('release', ctypes.c_uint16), | |
| 71 ] | |
| 72 | |
| 73 | |
| 74 class _SMC_KeyDataLimits(ctypes.Structure): | |
| 75 _fields_ = [ | |
| 76 ('version', ctypes.c_uint16), | |
| 77 ('length', ctypes.c_uint16), | |
| 78 ('cpu', ctypes.c_uint32), | |
| 79 ('gpu', ctypes.c_uint32), | |
| 80 ('mem', ctypes.c_uint32), | |
| 81 ] | |
| 82 | |
| 83 | |
| 84 class _SMC_KeyDataInfo(ctypes.Structure): | |
| 85 _fields_ = [ | |
| 86 ('size', ctypes.c_uint32), | |
| 87 ('type', ctypes.c_uint32), | |
| 88 ('attributes', ctypes.c_uint8), | |
| 89 ] | |
| 90 | |
| 91 | |
| 92 class _SMC_KeyData(ctypes.Structure): | |
| 93 _fields_ = [ | |
| 94 ('key', ctypes.c_uint32), | |
| 95 ('version', _SMC_KeyDataVersion), | |
| 96 ('pLimitData', _SMC_KeyDataLimits), | |
| 97 ('keyInfo', _SMC_KeyDataInfo), | |
| 98 ('result', ctypes.c_uint8), | |
| 99 ('status', ctypes.c_uint8), | |
| 100 ('data8', ctypes.c_uint8), | |
| 101 ('data32', ctypes.c_uint32), | |
| 102 ('bytes', ctypes.c_ubyte * 32), | |
| 103 ] | |
| 104 | |
| 105 | |
| 106 class _SMC_Value(ctypes.Structure): | |
| 107 _fields_ = [ | |
| 108 ('key', (ctypes.c_ubyte * 5)), | |
| 109 ('size', ctypes.c_uint32), | |
| 110 ('type', (ctypes.c_ubyte * 5)), | |
| 111 ('bytes', ctypes.c_ubyte * 32), | |
| 112 ] | |
| 113 | |
| 114 | |
| 115 # http://bxr.su/OpenBSD/sys/dev/isa/asmc.c is a great list of sensors. | |
| 116 # | |
| 117 # The following other sensor were found on a MBP 2012 via brute forcing but | |
| 118 # their signification is unknown: | |
| 119 # TC0E, TC0F, TH0A, TH0B, TH0V, TP0P, TS0D, TS0P | |
| 120 _sensor_names = { | |
|
Vadim Sh.
2017/07/28 20:54:55
do we really need all that stuff?...
M-A Ruel
2017/07/28 20:58:46
No, I want to make it live then trim based on what
M-A Ruel
2017/07/28 21:03:43
Actually, VM *do* report temperatures. That's so a
| |
| 121 'TA0P': u'ambient', # 'hdd bay 1', | |
| 122 'TA0S': u'pci slot 1 pos 1', | |
| 123 'TA1S': u'pci slot 1 pos 2', | |
| 124 'TA3S': u'pci slot 2 pos 2', | |
| 125 'TB0T': u'enclosure bottom', | |
| 126 'TB2T': u'enclosure bottom 3', | |
| 127 'TC0D': u'cpu0 die core', | |
| 128 'TC0P': u'cpu0 proximity', | |
| 129 'TC1D': u'cpu1', | |
| 130 'TCAH': u'cpu0', | |
| 131 'TCDH': u'cpu3', | |
| 132 'TG0D': u'gpu0 diode', | |
| 133 'TG0P': u'gpu0 proximity', | |
| 134 'TG1H': u'gpu heatsink 2', | |
| 135 'TH0P': u'hdd bay 1', | |
| 136 'TH2P': u'hdd bay 3', | |
| 137 'TL0P': u'lcd proximity', | |
| 138 'TM0P': u'mem bank a1', | |
| 139 'TM1P': u'mem bank a2', | |
| 140 'TM2P': u'mem bank a3', | |
| 141 'TM3P': u'mem bank a4', | |
| 142 'TM4P': u'mem bank a5', | |
| 143 'TM5P': u'mem bank a6', | |
| 144 'TM6P': u'mem bank a7', | |
| 145 'TM7P': u'mem bank a8', | |
| 146 'TM8P': u'mem bank b1', | |
| 147 'TM9P': u'mem bank b2', | |
| 148 'TMA1': u'ram a1', | |
| 149 'TMA3': u'ram a3', | |
| 150 'TMAP': u'mem bank b3', | |
| 151 'TMB1': u'ram b1', | |
| 152 'TMB3': u'ram b3', | |
| 153 'TMBP': u'mem bank b4', | |
| 154 'TMCP': u'mem bank b5', | |
| 155 'TMDP': u'mem bank b6', | |
| 156 'TMEP': u'mem bank b7', | |
| 157 'TMFP': u'mem bank b8', | |
| 158 'TN0D': u'northbridge die core', | |
| 159 'TN0P': u'northbridge proximity', | |
| 160 'TO0P': u'optical drive', | |
| 161 'TW0P': u'wireless airport card', | |
| 162 'Th0H': u'main heatsink a', | |
| 163 'Th2H': u'main heatsink c', | |
| 164 'Tm0P': u'memory controller', | |
| 165 'Tp0C': u'power supply 1', | |
| 166 'Tp1C': u'power supply 2', | |
| 167 'Tp2P': u'power supply 3', | |
| 168 'Tp4P': u'power supply 5', | |
| 169 'TA1P': u'ambient 2', | |
| 170 'TA2S': u'pci slot 2 pos 1', | |
| 171 'TB1T': u'enclosure bottom 2', | |
| 172 'TB3T': u'enclosure bottom 4', | |
| 173 'TC0H': u'cpu0 heatsink', | |
| 174 'TC2D': u'cpu2', | |
| 175 'TC3D': u'cpu3', | |
| 176 'TCBH': u'cpu1', | |
| 177 'TCCH': u'cpu2', | |
| 178 'TG0H': u'gpu0 heatsink', | |
| 179 'TH1P': u'hdd bay 2', | |
| 180 'TH3P': u'hdd bay 4', | |
| 181 'TM0S': u'mem module a1', | |
| 182 'TM1S': u'mem module a2', | |
| 183 'TM2S': u'mem module a3', | |
| 184 'TM3S': u'mem module a4', | |
| 185 'TM4S': u'mem module a5', | |
| 186 'TM5S': u'mem module a6', | |
| 187 'TM6S': u'mem module a7', | |
| 188 'TM7S': u'mem module a8', | |
| 189 'TM8S': u'mem module b1', | |
| 190 'TM9S': u'mem module b2', | |
| 191 'TMA2': u'ram a2', | |
| 192 'TMA4': u'ram a4', | |
| 193 'TMAS': u'mem module b3', | |
| 194 'TMB2': u'ram b2', | |
| 195 'TMB4': u'ram b4', | |
| 196 'TMBS': u'mem module b4', | |
| 197 'TMCS': u'mem module b5', | |
| 198 'TMDS': u'mem module b6', | |
| 199 'TMES': u'mem module b7', | |
| 200 'TMFS': u'mem module b8', | |
| 201 'TN0H': u'northbridge', | |
| 202 'TN1P': u'northbridge 2', | |
| 203 'TS0C': u'expansion slots', | |
| 204 'Th1H': u'main heatsink b', | |
| 205 'Tp0P': u'power supply 1', | |
| 206 'Tp1P': u'power supply 2', | |
| 207 'Tp3P': u'power supply 4', | |
| 208 'Tp5P': u'power supply 6', | |
| 209 } | |
| 210 | |
| 211 # _sensor_found_cache is set on the first call to _SMC_get_values. | |
| 212 _sensor_found_cache = None | |
| 213 | |
| 214 | |
| 215 @tools.cached | |
| 216 def _SMC_open(): | |
| 217 """Opens the default SMC driver and returns the first device. | |
| 218 | |
| 219 It leaves the device handle open for the duration of the process. | |
| 220 """ | |
| 221 # There should be only one. | |
| 222 itr = ctypes.c_uint() | |
| 223 result = iokit.IOServiceGetMatchingServices( | |
| 224 0, iokit.IOServiceMatching('AppleSMC'), ctypes.byref(itr)) | |
| 225 if result: | |
| 226 logging.error('failed to get AppleSMC (%d)', result) | |
| 227 return None | |
| 228 dev = iokit.IOIteratorNext(itr) | |
| 229 iokit.IOObjectRelease(itr) | |
| 230 if not dev: | |
| 231 logging.error('no SMC found') | |
| 232 return None | |
| 233 conn = ctypes.c_uint() | |
| 234 if iokit.IOServiceOpen(dev, libkern.mach_task_self(), 0, ctypes.byref(conn)): | |
| 235 logging.error('failed to open AppleSMC (%d)', result) | |
| 236 return None | |
| 237 return conn | |
| 238 | |
| 239 | |
| 240 def _SMC_call(conn, index, indata, outdata): | |
| 241 """Executes a call to the SMC subsystem.""" | |
| 242 return iokit.IOConnectCallStructMethod( | |
| 243 conn, | |
| 244 ctypes.c_uint(index), | |
| 245 ctypes.cast(ctypes.pointer(indata), ctypes.c_void_p), | |
| 246 ctypes.c_ulonglong(ctypes.sizeof(_SMC_KeyData)), | |
| 247 ctypes.cast(ctypes.pointer(outdata), ctypes.c_void_p), | |
| 248 ctypes.pointer(ctypes.c_ulonglong(ctypes.sizeof(_SMC_KeyData)))) | |
| 249 | |
| 250 | |
| 251 def _SMC_read_key(conn, key): | |
| 252 """Retrieves an unprocessed key value.""" | |
| 253 KERNEL_INDEX_SMC = 2 | |
| 254 | |
| 255 # Call with SMC_CMD_READ_KEYINFO. | |
| 256 # TODO(maruel): Keep cache of result size. | |
| 257 indata = _SMC_KeyData(key=struct.unpack('>i', key)[0], data8=9) | |
| 258 outdata = _SMC_KeyData() | |
| 259 if _SMC_call(conn, KERNEL_INDEX_SMC, indata, outdata): | |
| 260 logging.error('SMC call to get key info failed') | |
| 261 return None | |
| 262 | |
| 263 # Call with SMC_CMD_READ_BYTES. | |
| 264 val = _SMC_Value(size=outdata.keyInfo.size) | |
| 265 for i, x in enumerate(struct.pack('>i', outdata.keyInfo.type)): | |
| 266 val.type[i] = ord(x) | |
| 267 # pylint: disable=attribute-defined-outside-init | |
| 268 indata.data8 = 5 | |
| 269 indata.keyInfo.size = val.size | |
| 270 if _SMC_call(conn, KERNEL_INDEX_SMC, indata, outdata): | |
| 271 logging.error('SMC call to get data info failed') | |
| 272 return None | |
| 273 val.bytes = outdata.bytes | |
| 274 return val | |
| 275 | |
| 276 | |
| 277 def _SMC_get_value(conn, key): | |
| 278 """Returns a processed measurement via AppleSMC for a specified key. | |
| 279 | |
| 280 Returns None on failure. | |
| 281 """ | |
| 282 val = _SMC_read_key(conn, key) | |
| 283 if not val or not val.size: | |
| 284 return None | |
| 285 t = ''.join(map(chr, val.type)) | |
| 286 if t == 'sp78\0' and val.size == 2: | |
| 287 # Format is first byte signed int8, second byte uint8 fractional. | |
| 288 return float(ctypes.c_int8(val.bytes[0]).value) + (val.bytes[1] / 256.) | |
| 289 if t == 'fpe2\0' and val.size == 2: | |
| 290 # Format is unsigned 14 bits big endian, 2 bits fractional. | |
| 291 return ( | |
| 292 float((val.bytes[0] << 6) + (val.bytes[1] >> 2)) + | |
| 293 (val.bytes[1] & 3) / 4.) | |
| 294 # TODO(maruel): Handler other formats like 64 bits long. This is used for fan | |
| 295 # speed. | |
| 296 logging.error( | |
| 297 '_SMC_get_value(%s) got unknown format: %s of %d bytes', key, t, val.size) | |
| 298 return None | |
| 299 | |
| 300 | |
| 27 @tools.cached | 301 @tools.cached |
| 28 def _get_system_profiler(data_type): | 302 def _get_system_profiler(data_type): |
| 29 """Returns an XML about the system display properties.""" | 303 """Returns an XML about the system display properties.""" |
| 30 sp = subprocess.check_output( | 304 sp = subprocess.check_output( |
| 31 ['system_profiler', data_type, '-xml']) | 305 ['system_profiler', data_type, '-xml']) |
| 32 return plistlib.readPlistFromString(sp)[0]['_items'] | 306 return plistlib.readPlistFromString(sp)[0]['_items'] |
| 33 | 307 |
| 34 | 308 |
| 35 @tools.cached | 309 @tools.cached |
| 36 def _get_libc(): | 310 def _get_libc(): |
| (...skipping 128 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 165 | 439 |
| 166 | 440 |
| 167 @tools.cached | 441 @tools.cached |
| 168 def get_hardware_model_string(): | 442 def get_hardware_model_string(): |
| 169 """Returns the Mac model string. | 443 """Returns the Mac model string. |
| 170 | 444 |
| 171 Returns: | 445 Returns: |
| 172 A string like Macmini5,3 or MacPro6,1. | 446 A string like Macmini5,3 or MacPro6,1. |
| 173 """ | 447 """ |
| 174 try: | 448 try: |
| 175 return subprocess.check_output(['sysctl', '-n', 'hw.model']).rstrip() | 449 return unicode( |
| 450 subprocess.check_output(['sysctl', '-n', 'hw.model']).rstrip()) | |
| 176 except (OSError, subprocess.CalledProcessError): | 451 except (OSError, subprocess.CalledProcessError): |
| 177 return None | 452 return None |
| 178 | 453 |
| 179 | 454 |
| 180 @tools.cached | 455 @tools.cached |
| 181 def get_os_version_number(): | 456 def get_os_version_number(): |
| 182 """Returns the normalized OS version number as a string. | 457 """Returns the normalized OS version number as a string. |
| 183 | 458 |
| 184 Returns: | 459 Returns: |
| 185 Version as a string like '10.12.4' | 460 Version as a string like '10.12.4' |
| (...skipping 67 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 253 u'model': [ | 528 u'model': [ |
| 254 int(values['machdep.cpu.family']), int(values['machdep.cpu.model']), | 529 int(values['machdep.cpu.family']), int(values['machdep.cpu.model']), |
| 255 int(values['machdep.cpu.stepping']), | 530 int(values['machdep.cpu.stepping']), |
| 256 int(values['machdep.cpu.microcode_version']), | 531 int(values['machdep.cpu.microcode_version']), |
| 257 ], | 532 ], |
| 258 u'name': values[u'machdep.cpu.brand_string'], | 533 u'name': values[u'machdep.cpu.brand_string'], |
| 259 u'vendor': values[u'machdep.cpu.vendor'], | 534 u'vendor': values[u'machdep.cpu.vendor'], |
| 260 } | 535 } |
| 261 | 536 |
| 262 | 537 |
| 538 def get_temperatures(): | |
| 539 """Returns the temperatures in Celsius.""" | |
| 540 global _sensor_found_cache | |
| 541 conn = _SMC_open() | |
| 542 if not conn: | |
| 543 return None | |
| 544 | |
| 545 out = {} | |
| 546 if _sensor_found_cache is None: | |
| 547 _sensor_found_cache = set() | |
| 548 # Populate the cache of the sensors found on this system, so that the next | |
| 549 # call can only get the actual sensors. | |
| 550 # Note: It is relatively fast to brute force all the possible names. | |
| 551 for key, name in _sensor_names.iteritems(): | |
| 552 value = _SMC_get_value(conn, key) | |
| 553 if value is not None: | |
| 554 _sensor_found_cache.add(key) | |
| 555 out[name] = value | |
| 556 return out | |
| 557 | |
| 558 for key in _sensor_found_cache: | |
|
Vadim Sh.
2017/07/28 20:54:55
nit: remove extra space after 'in'
M-A Ruel
2017/07/28 20:58:46
Done.
| |
| 559 value = _SMC_get_value(conn, key) | |
| 560 if value is not None: | |
| 561 out[_sensor_names[key]] = value | |
| 562 return out | |
| 563 | |
| 564 | |
| 263 @tools.cached | 565 @tools.cached |
| 264 def get_monitor_hidpi(): | 566 def get_monitor_hidpi(): |
| 265 """Returns True if the monitor is hidpi. | 567 """Returns True if the monitor is hidpi. |
| 266 | 568 |
| 267 On 10.12.3 and earlier, the following could be used to detect an hidpi | 569 On 10.12.3 and earlier, the following could be used to detect an hidpi |
| 268 display: | 570 display: |
| 269 <key>spdisplays_retina</key> | 571 <key>spdisplays_retina</key> |
| 270 <string>spdisplays_yes</string> | 572 <string>spdisplays_yes</string> |
| 271 | 573 |
| 272 On 10.12.4 and later, the key above doesn't exist anymore. Fall back to search | 574 On 10.12.4 and later, the key above doesn't exist anymore. Fall back to search |
| 273 for: | 575 for: |
| 274 <key>spdisplays_display_type</key> | 576 <key>spdisplays_display_type</key> |
| 275 <string>spdisplays_built-in_retinaLCD</string> | 577 <string>spdisplays_built-in_retinaLCD</string> |
| 276 """ | 578 """ |
| 277 def is_hidpi(displays): | 579 def is_hidpi(displays): |
| 278 return any( | 580 return any( |
| 279 d.get('spdisplays_retina') == 'spdisplays_yes' or | 581 d.get('spdisplays_retina') == 'spdisplays_yes' or |
| 280 'retina' in d.get('spdisplays_display_type', '').lower() | 582 'retina' in d.get('spdisplays_display_type', '').lower() |
| 281 for d in displays) | 583 for d in displays) |
| 282 | 584 |
| 283 hidpi = any( | 585 hidpi = any( |
| 284 is_hidpi(card['spdisplays_ndrvs']) | 586 is_hidpi(card['spdisplays_ndrvs']) |
| 285 for card in _get_system_profiler('SPDisplaysDataType') | 587 for card in _get_system_profiler('SPDisplaysDataType') |
| 286 if 'spdisplays_ndrvs' in card) | 588 if 'spdisplays_ndrvs' in card) |
| 287 return str(int(hidpi)) | 589 return unicode(int(hidpi)) |
| 288 | 590 |
| 289 | 591 |
| 290 def get_xcode_versions(): | 592 def get_xcode_versions(): |
| 291 """Returns all Xcode versions installed.""" | 593 """Returns all Xcode versions installed.""" |
| 292 return sorted(xcode['version'] for xcode in get_xcode_state().itervalues()) | 594 return sorted( |
| 595 unicode(xcode['version']) for xcode in get_xcode_state().itervalues()) | |
| 293 | 596 |
| 294 | 597 |
| 295 @tools.cached | 598 @tools.cached |
| 296 def get_physical_ram(): | 599 def get_physical_ram(): |
| 297 """Returns the amount of installed RAM in Mb, rounded to the nearest number. | 600 """Returns the amount of installed RAM in Mb, rounded to the nearest number. |
| 298 """ | 601 """ |
| 299 CTL_HW = 6 | 602 CTL_HW = 6 |
| 300 HW_MEMSIZE = 24 | 603 HW_MEMSIZE = 24 |
| 301 result = ctypes.c_uint64(0) | 604 result = ctypes.c_uint64(0) |
| 302 _sysctl(CTL_HW, HW_MEMSIZE, result) | 605 _sysctl(CTL_HW, HW_MEMSIZE, result) |
| (...skipping 86 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 389 header = ( | 692 header = ( |
| 390 '<?xml version="1.0" encoding="UTF-8"?>\n' | 693 '<?xml version="1.0" encoding="UTF-8"?>\n' |
| 391 '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' | 694 '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' |
| 392 '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' | 695 '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' |
| 393 '<plist version="1.0">\n' | 696 '<plist version="1.0">\n' |
| 394 ' <dict>\n' | 697 ' <dict>\n' |
| 395 + ''.join(' %s\n' % l for l in entries) + | 698 + ''.join(' %s\n' % l for l in entries) + |
| 396 ' </dict>\n' | 699 ' </dict>\n' |
| 397 '</plist>\n') | 700 '</plist>\n') |
| 398 return header | 701 return header |
| OLD | NEW |