main
rs 418 lines 13.6 KB
Raw
1 //! Google Play listing operations shared by `asc play` and the MCP tools.
2 //!
3 //! Play does everything through edits: open one, change things, `validate`,
4 //! then `commit`. Every function here opens exactly one edit and closes it
5 //! again, so a failure or a dry run leaves nothing behind. Reads open an edit
6 //! too (the API has no edit-less read for listings) and delete it after.
7
8 use std::path::{Path, PathBuf};
9
10 use serde::Serialize;
11 use sha2::{Digest, Sha256};
12 use smbcloud_ascapi_play::{
13 png_dimensions, Image, ImageType, Listing, ListingFields, PlayClient, ServiceAccount, Track,
14 };
15
16 /// Resolve the Play client. `path` wins; then `PLAY_SERVICE_ACCOUNT_JSON_PATH`;
17 /// then the key contents inline in `PLAY_SERVICE_ACCOUNT_JSON`. Per call, like
18 /// the App Store Connect credentials, so an unconfigured MCP server still
19 /// lists its tools. The key itself never appears in an error.
20 pub fn play_client(path: Option<&Path>) -> Result<PlayClient, String> {
21 let account = if let Some(path) = path {
22 ServiceAccount::from_json_file(path)
23 } else if let Ok(path) = std::env::var("PLAY_SERVICE_ACCOUNT_JSON_PATH") {
24 ServiceAccount::from_json_file(path)
25 } else if let Ok(json) = std::env::var("PLAY_SERVICE_ACCOUNT_JSON") {
26 ServiceAccount::from_json(&json)
27 } else {
28 return Err(
29 "no Google Play credentials: pass --play-service-account, or set \
30 PLAY_SERVICE_ACCOUNT_JSON_PATH (or PLAY_SERVICE_ACCOUNT_JSON)"
31 .to_string(),
32 );
33 };
34 account.map(PlayClient::new).map_err(|e| e.to_string())
35 }
36
37 /// Parse an image bucket name given as `phone-screenshots` or
38 /// `phoneScreenshots`.
39 pub fn image_type_from_str(value: &str) -> Result<ImageType, String> {
40 match value.replace('-', "").to_ascii_lowercase().as_str() {
41 "phonescreenshots" => Ok(ImageType::PhoneScreenshots),
42 "seveninchscreenshots" => Ok(ImageType::SevenInchScreenshots),
43 "teninchscreenshots" => Ok(ImageType::TenInchScreenshots),
44 "featuregraphic" => Ok(ImageType::FeatureGraphic),
45 "icon" => Ok(ImageType::Icon),
46 other => Err(format!(
47 "unknown image type {other:?}; use phone-screenshots, seven-inch-screenshots, \
48 ten-inch-screenshots, feature-graphic or icon"
49 )),
50 }
51 }
52
53 fn err(error: impl std::fmt::Display) -> String {
54 error.to_string()
55 }
56
57 /// Open an edit, run the body inside it, then commit (after `validate`) when
58 /// `commit` is set, or delete the edit otherwise. A failure anywhere deletes
59 /// the edit and returns the first error. A macro rather than a function so the
60 /// body can borrow from the caller's scope.
61 macro_rules! in_edit {
62 ($client:expr, $package:expr, $commit:expr, |$c:ident, $id:ident| $body:expr) => {{
63 let $c: &PlayClient = $client;
64 let edit = $c.insert_edit($package).await.map_err(err)?;
65 let $id: &str = edit.id.as_str();
66 let outcome: Result<_, String> = $body.await;
67 finish($c, $package, $id, $commit, outcome).await
68 }};
69 }
70
71 async fn finish<T>(
72 client: &PlayClient,
73 package: &str,
74 edit_id: &str,
75 commit: bool,
76 outcome: Result<T, String>,
77 ) -> Result<T, String> {
78 let outcome = match outcome {
79 Ok(value) if commit => {
80 async {
81 client.validate_edit(package, edit_id).await.map_err(err)?;
82 client.commit_edit(package, edit_id).await.map_err(err)?;
83 Ok(value)
84 }
85 .await
86 }
87 other => other,
88 };
89 if !(commit && outcome.is_ok()) {
90 // Best effort: an edit that fails to delete simply expires.
91 let _ = client.delete_edit(package, edit_id).await;
92 }
93 outcome
94 }
95
96 pub async fn read_listings(client: &PlayClient, package: &str) -> Result<Vec<Listing>, String> {
97 in_edit!(client, package, false, |c, id| {
98 Box::pin(async move { c.list_listings(package, id).await.map_err(err) })
99 })
100 }
101
102 pub async fn read_images(
103 client: &PlayClient,
104 package: &str,
105 locale: &str,
106 image_type: ImageType,
107 ) -> Result<Vec<Image>, String> {
108 in_edit!(client, package, false, |c, id| {
109 Box::pin(async move {
110 c.list_images(package, id, locale, image_type)
111 .await
112 .map_err(err)
113 })
114 })
115 }
116
117 pub async fn read_track(client: &PlayClient, package: &str, track: &str) -> Result<Track, String> {
118 in_edit!(client, package, false, |c, id| {
119 Box::pin(async move { c.get_track(package, id, track).await.map_err(err) })
120 })
121 }
122
123 /// The result of a listing update.
124 #[derive(Debug, Serialize)]
125 #[serde(rename_all = "camelCase")]
126 pub struct ListingUpdate {
127 pub dry_run: bool,
128 /// What was (or, on a dry run, would be) sent.
129 pub sent: ListingFields,
130 /// The listing before the change, if the locale existed.
131 pub before: Option<Listing>,
132 /// The listing read back after commit. `None` on a dry run.
133 pub after: Option<Listing>,
134 }
135
136 pub async fn update_listing(
137 client: &PlayClient,
138 package: &str,
139 locale: &str,
140 fields: ListingFields,
141 dry_run: bool,
142 ) -> Result<ListingUpdate, String> {
143 fields.validate()?;
144 if fields.is_empty() {
145 return Err("nothing to update: pass at least one listing field".to_string());
146 }
147 let sent = fields.clone();
148 let before = in_edit!(client, package, !dry_run, |c, id| {
149 Box::pin(async move {
150 let before = c.get_listing(package, id, locale).await.ok();
151 if !dry_run {
152 c.patch_listing(package, id, locale, &fields)
153 .await
154 .map_err(err)?;
155 }
156 Ok(before)
157 })
158 })?;
159 let after = if dry_run {
160 None
161 } else {
162 let stored = in_edit!(client, package, false, |c, id| {
163 Box::pin(async move { c.get_listing(package, id, locale).await.map_err(err) })
164 })?;
165 let wrong = sent.diff(&stored);
166 if !wrong.is_empty() {
167 return Err(format!(
168 "committed, but the read-back differs in: {}",
169 wrong.join(", ")
170 ));
171 }
172 Some(stored)
173 };
174 Ok(ListingUpdate {
175 dry_run,
176 sent,
177 before,
178 after,
179 })
180 }
181
182 /// One local file picked for upload.
183 #[derive(Debug, Clone, Serialize)]
184 #[serde(rename_all = "camelCase")]
185 pub struct ImageFile {
186 pub path: PathBuf,
187 pub bytes: usize,
188 pub sha256: String,
189 pub width: Option<u32>,
190 pub height: Option<u32>,
191 #[serde(skip)]
192 content_type: &'static str,
193 #[serde(skip)]
194 data: Vec<u8>,
195 }
196
197 /// Read and lint a local image: PNG or JPEG, and for PNGs the pixel size
198 /// against the bucket's rules.
199 pub fn load_image(path: &Path, image_type: ImageType) -> Result<ImageFile, String> {
200 let data = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
201 let extension = path
202 .extension()
203 .and_then(|e| e.to_str())
204 .map(str::to_ascii_lowercase);
205 let content_type = match extension.as_deref() {
206 Some("png") => "image/png",
207 Some("jpg") | Some("jpeg") => "image/jpeg",
208 _ => return Err(format!("{}: Play accepts only PNG or JPEG", path.display())),
209 };
210 let dims = png_dimensions(&data);
211 if let Some((w, h)) = dims {
212 image_type
213 .check_dimensions(w, h)
214 .map_err(|m| format!("{}: {m}", path.display()))?;
215 } else if content_type == "image/png" {
216 return Err(format!("{}: not a valid PNG", path.display()));
217 }
218 Ok(ImageFile {
219 path: path.to_path_buf(),
220 bytes: data.len(),
221 sha256: hex(&Sha256::digest(&data)),
222 width: dims.map(|d| d.0),
223 height: dims.map(|d| d.1),
224 content_type,
225 data,
226 })
227 }
228
229 fn hex(bytes: &[u8]) -> String {
230 bytes.iter().map(|b| format!("{b:02x}")).collect()
231 }
232
233 #[derive(Debug, Serialize)]
234 #[serde(rename_all = "camelCase")]
235 pub struct ImagesReplaced {
236 pub dry_run: bool,
237 pub image_type: String,
238 pub locale: String,
239 pub files: Vec<ImageFile>,
240 /// How many images the bucket held before the replace.
241 pub previously: usize,
242 /// The bucket read back after commit. `None` on a dry run.
243 pub after: Option<Vec<Image>>,
244 }
245
246 /// Delete the bucket's images and upload `paths` in order, inside one edit,
247 /// so a failed upload leaves the old set in place.
248 pub async fn replace_images(
249 client: &PlayClient,
250 package: &str,
251 locale: &str,
252 image_type: ImageType,
253 paths: &[PathBuf],
254 dry_run: bool,
255 ) -> Result<ImagesReplaced, String> {
256 if paths.is_empty() {
257 return Err("no image files given".to_string());
258 }
259 let files = paths
260 .iter()
261 .map(|p| load_image(p, image_type))
262 .collect::<Result<Vec<_>, _>>()?;
263 let previously = in_edit!(client, package, !dry_run, |c, id| {
264 let files = &files;
265 Box::pin(async move {
266 let previously = c
267 .list_images(package, id, locale, image_type)
268 .await
269 .map_err(err)?
270 .len();
271 if !dry_run {
272 c.delete_all_images(package, id, locale, image_type)
273 .await
274 .map_err(err)?;
275 for file in files {
276 c.upload_image(
277 package,
278 id,
279 locale,
280 image_type,
281 file.content_type,
282 file.data.clone(),
283 )
284 .await
285 .map_err(|e| format!("{}: {e}", file.path.display()))?;
286 }
287 }
288 Ok(previously)
289 })
290 })?;
291 let after = if dry_run {
292 None
293 } else {
294 let images = read_images(client, package, locale, image_type).await?;
295 if images.len() != files.len() {
296 return Err(format!(
297 "committed, but the bucket holds {} images; expected {}",
298 images.len(),
299 files.len()
300 ));
301 }
302 for (image, file) in images.iter().zip(&files) {
303 if image.sha256.as_deref().is_some_and(|s| s != file.sha256) {
304 return Err(format!(
305 "committed, but the stored copy of {} has a different sha256",
306 file.path.display()
307 ));
308 }
309 }
310 Some(images)
311 };
312 Ok(ImagesReplaced {
313 dry_run,
314 image_type: image_type.api_name().to_string(),
315 locale: locale.to_string(),
316 files,
317 previously,
318 after,
319 })
320 }
321
322 #[derive(Debug, Serialize)]
323 #[serde(rename_all = "camelCase")]
324 pub struct NotesSet {
325 pub dry_run: bool,
326 /// The track as it will be (dry run) or is (read back after commit).
327 pub track: Track,
328 }
329
330 pub async fn set_track_notes(
331 client: &PlayClient,
332 package: &str,
333 track_name: &str,
334 version_code: &str,
335 locale: &str,
336 notes: &str,
337 dry_run: bool,
338 ) -> Result<NotesSet, String> {
339 let planned = in_edit!(client, package, !dry_run, |c, id| {
340 Box::pin(async move {
341 let mut track = c.get_track(package, id, track_name).await.map_err(err)?;
342 track.set_release_notes(version_code, locale, notes)?;
343 if !dry_run {
344 c.update_track(package, id, &track).await.map_err(err)?;
345 }
346 Ok(track)
347 })
348 })?;
349 if dry_run {
350 return Ok(NotesSet {
351 dry_run,
352 track: planned,
353 });
354 }
355 let track = read_track(client, package, track_name).await?;
356 let stored = track
357 .releases
358 .iter()
359 .filter(|r| r.version_codes.iter().any(|c| c == version_code))
360 .flat_map(|r| &r.release_notes)
361 .any(|n| n.language == locale && n.text == notes);
362 if !stored {
363 return Err("committed, but the read-back release notes differ".to_string());
364 }
365 Ok(NotesSet { dry_run, track })
366 }
367
368 #[cfg(test)]
369 mod tests {
370 use super::*;
371
372 #[test]
373 fn image_types_parse_in_both_spellings() {
374 assert_eq!(
375 image_type_from_str("phone-screenshots"),
376 Ok(ImageType::PhoneScreenshots)
377 );
378 assert_eq!(
379 image_type_from_str("featureGraphic"),
380 Ok(ImageType::FeatureGraphic)
381 );
382 assert!(image_type_from_str("tv").is_err());
383 }
384
385 #[test]
386 fn load_image_rejects_other_formats() {
387 let dir = std::env::temp_dir();
388 let path = dir.join("ascapi-play-test.gif");
389 std::fs::write(&path, b"GIF89a").unwrap();
390 let error = load_image(&path, ImageType::PhoneScreenshots).unwrap_err();
391 assert!(error.contains("PNG or JPEG"));
392 let _ = std::fs::remove_file(path);
393 }
394
395 #[test]
396 fn load_image_lints_png_size_for_the_bucket() {
397 let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
398 png.extend_from_slice(b"IHDR");
399 png.extend_from_slice(&1000u32.to_be_bytes());
400 png.extend_from_slice(&500u32.to_be_bytes());
401 let path = std::env::temp_dir().join("ascapi-play-test.png");
402 std::fs::write(&path, &png).unwrap();
403 assert!(load_image(&path, ImageType::FeatureGraphic).is_err());
404 assert!(load_image(&path, ImageType::PhoneScreenshots).is_ok());
405 let _ = std::fs::remove_file(path);
406 }
407
408 #[test]
409 fn missing_credentials_name_the_options_without_a_key() {
410 // Only meaningful when the variables are unset, as in CI.
411 if std::env::var_os("PLAY_SERVICE_ACCOUNT_JSON_PATH").is_none()
412 && std::env::var_os("PLAY_SERVICE_ACCOUNT_JSON").is_none()
413 {
414 let error = play_client(None).err().unwrap();
415 assert!(error.contains("PLAY_SERVICE_ACCOUNT_JSON_PATH"));
416 }
417 }
418 }