Coverage for icloudpy / cmdline.py: 62%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-06-18 19:11 +0000

1#! /usr/bin/env python # noqa:EXE001 

2""" 

3A Command Line Wrapper to allow easy use of iCloudPy for 

4command line scripts, and related. 

5""" 

6 

7# from builtins import input 

8import argparse 

9import pickle 

10import sys 

11 

12from click import confirm 

13 

14from icloudpy import ICloudPyService, utils 

15from icloudpy.exceptions import ICloudPyFailedLoginException 

16 

17DEVICE_ERROR = "Please use the --device switch to indicate which device to use." 

18 

19 

20def create_pickled_data(idevice, filename): 

21 """ 

22 This helper will output the idevice to a pickled file named 

23 after the passed filename. 

24 

25 This allows the data to be used without resorting to screen / pipe 

26 scrapping. 

27 """ 

28 pickle_file = open(filename, "wb") 

29 pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) 

30 pickle_file.close() 

31 

32 

33def main(args=None): 

34 """Main commandline entrypoint.""" 

35 if args is None: 

36 args = sys.argv[1:] 

37 

38 parser = argparse.ArgumentParser(description="Find My iPhone CommandLine Tool") 

39 

40 parser.add_argument( 

41 "--username", 

42 action="store", 

43 dest="username", 

44 default="", 

45 help="Apple ID to Use", 

46 ) 

47 parser.add_argument( 

48 "--password", 

49 action="store", 

50 dest="password", 

51 default="", 

52 help=("Apple ID Password to Use; if unspecified, password will be " "fetched from the system keyring."), 

53 ) 

54 parser.add_argument( 

55 "-n", 

56 "--non-interactive", 

57 action="store_false", 

58 dest="interactive", 

59 default=True, 

60 help="Disable interactive prompts.", 

61 ) 

62 parser.add_argument( 

63 "--delete-from-keyring", 

64 action="store_true", 

65 dest="delete_from_keyring", 

66 default=False, 

67 help="Delete stored password in system keyring for this username.", 

68 ) 

69 parser.add_argument( 

70 "--list", 

71 action="store_true", 

72 dest="list", 

73 default=False, 

74 help="Short Listings for Device(s) associated with account", 

75 ) 

76 parser.add_argument( 

77 "--llist", 

78 action="store_true", 

79 dest="longlist", 

80 default=False, 

81 help="Detailed Listings for Device(s) associated with account", 

82 ) 

83 parser.add_argument( 

84 "--locate", 

85 action="store_true", 

86 dest="locate", 

87 default=False, 

88 help="Retrieve Location for the iDevice (non-exclusive).", 

89 ) 

90 

91 # Restrict actions to a specific devices UID / DID 

92 parser.add_argument( 

93 "--device", 

94 action="store", 

95 dest="device_id", 

96 default=False, 

97 help="Only effect this device", 

98 ) 

99 

100 # Trigger Sound Alert 

101 parser.add_argument( 

102 "--sound", 

103 action="store_true", 

104 dest="sound", 

105 default=False, 

106 help="Play a sound on the device", 

107 ) 

108 

109 # Trigger Message w/Sound Alert 

110 parser.add_argument( 

111 "--message", 

112 action="store", 

113 dest="message", 

114 default=False, 

115 help="Optional Text Message to display with a sound", 

116 ) 

117 

118 # Trigger Message (without Sound) Alert 

119 parser.add_argument( 

120 "--silentmessage", 

121 action="store", 

122 dest="silentmessage", 

123 default=False, 

124 help="Optional Text Message to display with no sounds", 

125 ) 

126 

127 # Lost Mode 

128 parser.add_argument( 

129 "--lostmode", 

130 action="store_true", 

131 dest="lostmode", 

132 default=False, 

133 help="Enable Lost mode for the device", 

134 ) 

135 parser.add_argument( 

136 "--lostphone", 

137 action="store", 

138 dest="lost_phone", 

139 default=False, 

140 help="Phone Number allowed to call when lost mode is enabled", 

141 ) 

142 parser.add_argument( 

143 "--lostpassword", 

144 action="store", 

145 dest="lost_password", 

146 default=False, 

147 help="Forcibly active this passcode on the idevice", 

148 ) 

149 parser.add_argument( 

150 "--lostmessage", 

151 action="store", 

152 dest="lost_message", 

153 default="", 

154 help="Forcibly display this message when activating lost mode.", 

155 ) 

156 

157 # Output device data to an pickle file 

158 parser.add_argument( 

159 "--outputfile", 

160 action="store_true", 

161 dest="output_to_file", 

162 default="", 

163 help="Save device data to a file in the current directory.", 

164 ) 

165 

166 # Path to session directory 

167 parser.add_argument( 

168 "--session-directory", 

169 action="store", 

170 dest="session_directory", 

171 default=None, 

172 help="Path to save session information", 

173 ) 

174 

175 # Server region - global or china 

176 parser.add_argument( 

177 "--region", 

178 action="store", 

179 dest="region", 

180 default="global", 

181 help="Server region - global or china", 

182 ) 

183 

184 command_line = parser.parse_args(args) 

185 

186 username = command_line.username 

187 password = command_line.password 

188 session_directory = command_line.session_directory 

189 server_region = command_line.region 

190 

191 if username and command_line.delete_from_keyring: 

192 utils.delete_password_in_keyring(username) 

193 

194 failure_count = 0 

195 while True: 

196 # Which password we use is determined by your username, so we 

197 # do need to check for this first and separately. 

198 if not username: 

199 parser.error("No username supplied") 

200 

201 if not password: 

202 password = utils.get_password( 

203 username, 

204 interactive=command_line.interactive, 

205 ) 

206 

207 if not password: 

208 parser.error("No password supplied") 

209 

210 try: 

211 api = ( 

212 ICloudPyService( 

213 apple_id=username.strip(), 

214 password=password.strip(), 

215 cookie_directory=session_directory, 

216 home_endpoint="https://www.icloud.com.cn", 

217 setup_endpoint="https://setup.icloud.com.cn/setup/ws/1", 

218 ) 

219 if server_region == "china" 

220 else ICloudPyService( 

221 apple_id=username.strip(), 

222 password=password.strip(), 

223 cookie_directory=session_directory, 

224 ) 

225 ) 

226 

227 if ( 

228 not utils.password_exists_in_keyring(username) 

229 and command_line.interactive 

230 and confirm("Save password in keyring?") 

231 ): 

232 utils.store_password_in_keyring(username, password) 

233 

234 if api.requires_2fa: 

235 # Apple's auth flow (2026+) requires an explicit PUT to push 

236 # the code to trusted devices. Without this, the user is 

237 # prompted but no code is ever delivered. 

238 if not api.trigger_2fa_push_notification(): 

239 print( 

240 "(Could not trigger push notification — " 

241 "a code may still arrive via SMS or another path.)", 

242 ) 

243 

244 # fmt: off 

245 print( 

246 "\nTwo-step authentication required.", 

247 "\nPlease enter validation code", 

248 ) 

249 # fmt: on 

250 

251 code = input("(string) --> ") 

252 if not api.validate_2fa_code(code): 

253 print("Failed to verify verification code") 

254 sys.exit(1) 

255 

256 print("") 

257 

258 elif api.requires_2sa: 

259 # fmt: off 

260 print( 

261 "\nTwo-step authentication required.", 

262 "\nYour trusted devices are:", 

263 ) 

264 # fmt: on 

265 

266 devices = api.trusted_devices 

267 for i, device in enumerate(devices): 

268 print( 

269 f' {i}: {device.get("deviceName", "SMS to " + device.get("phoneNumber"))}', 

270 ) 

271 

272 print("\nWhich device would you like to use?") 

273 device = int(input("(number) --> ")) 

274 device = devices[device] 

275 if not api.send_verification_code(device): 

276 print("Failed to send verification code") 

277 sys.exit(1) 

278 

279 print("\nPlease enter validation code") 

280 code = input("(string) --> ") 

281 if not api.validate_verification_code(device, code): 

282 print("Failed to verify verification code") 

283 sys.exit(1) 

284 

285 print("") 

286 break 

287 except ICloudPyFailedLoginException as error: 

288 # If they have a stored password; we just used it and 

289 # it did not work; let's delete it if there is one. 

290 if utils.password_exists_in_keyring(username): 

291 utils.delete_password_in_keyring(username) 

292 

293 message = f"Bad username or password for {username}" 

294 password = None 

295 

296 failure_count += 1 

297 if failure_count >= 3: 

298 raise RuntimeError(message) from error 

299 

300 print(message, file=sys.stderr) 

301 

302 for dev in api.devices: 

303 if not command_line.device_id or (command_line.device_id.strip().lower() == dev.content["id"].strip().lower()): 

304 # List device(s) 

305 if command_line.locate: 

306 dev.location() 

307 

308 if command_line.output_to_file: 

309 create_pickled_data( 

310 dev, 

311 filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"), 

312 ) 

313 

314 contents = dev.content 

315 if command_line.longlist: 

316 print("-" * 30) 

317 print(contents["name"]) 

318 for key in contents: 

319 print(f"{key} - {contents[key]}") 

320 elif command_line.list: 

321 print("-" * 30) 

322 print(f"Name - {contents['name']}") 

323 print(f"Display Name - {contents['deviceDisplayName']}") 

324 print(f"Location - {contents['location']}") 

325 print(f"Battery Level - {contents['batteryLevel']}") 

326 print(f"Battery Status- {contents['batteryStatus']}") 

327 print(f"Device Class - {contents['deviceClass']}") 

328 print(f"Device Model - {contents['deviceModel']}") 

329 

330 # Play a Sound on a device 

331 if command_line.sound: 

332 if command_line.device_id: 

333 dev.play_sound() 

334 else: 

335 raise RuntimeError( 

336 f"\n\n\t\tSounds can only be played on a singular device. {DEVICE_ERROR}\n\n", 

337 ) 

338 

339 # Display a Message on the device 

340 if command_line.message: 

341 if command_line.device_id: 

342 dev.display_message( 

343 subject="A Message", 

344 message=command_line.message, 

345 sounds=True, 

346 ) 

347 else: 

348 raise RuntimeError( 

349 f"Messages can only be played on a singular device. {DEVICE_ERROR}", 

350 ) 

351 

352 # Display a Silent Message on the device 

353 if command_line.silentmessage: 

354 if command_line.device_id: 

355 dev.display_message( 

356 subject="A Silent Message", 

357 message=command_line.silentmessage, 

358 sounds=False, 

359 ) 

360 else: 

361 raise RuntimeError( 

362 f"Silent Messages can only be played on a singular device. {DEVICE_ERROR}", 

363 ) 

364 

365 # Enable Lost mode 

366 if command_line.lostmode: 

367 if command_line.device_id: 

368 dev.lost_device( 

369 number=command_line.lost_phone.strip(), 

370 text=command_line.lost_message.strip(), 

371 newpasscode=command_line.lost_password.strip(), 

372 ) 

373 else: 

374 raise RuntimeError( 

375 f"Lost Mode can only be activated on a singular device. {DEVICE_ERROR}", 

376 ) 

377 sys.exit(0) 

378 

379 

380if __name__ == "__main__": 

381 main()