Skip to main content

smbcloud_ascapi_frontend/
play.rs

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
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use sha2::{Digest, Sha256};
12use 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.
20pub 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`.
39pub 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
53fn 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.
61macro_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
71async 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
96pub 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
102pub 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
117pub 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")]
126pub 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
136pub 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")]
185pub 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.
199pub 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
229fn hex(bytes: &[u8]) -> String {
230    bytes.iter().map(|b| format!("{b:02x}")).collect()
231}
232
233#[derive(Debug, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub 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.
248pub 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")]
324pub 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
330pub 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)]
369mod 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}