| 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 | } |