diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0540b14..bf9dce83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,13 +40,13 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: "22" - - name: Validate shared Microsoft Store and Google Play descriptions + - name: Validate shared Microsoft Store and Google Play listings and images run: | node --test scripts/tests/store_listings.test.mjs scripts/tests/google_play_listings.test.mjs node scripts/store_listings.mjs > store-listings-preview.json - uses: actions/upload-artifact@v7.0.1 with: - name: store-listings-en-vi + name: store-listings path: store-listings-preview.json # Generate release notes from commits @@ -455,7 +455,7 @@ jobs: name: windows-msix path: ${{ env.PROJECT_DIR }}/build/windows/msix/*.msix - - name: Create Partner Center submission archive + - name: Locate MSIX for Partner Center submission shell: pwsh run: | $version = "${{ needs.generate-release-notes.outputs.version_name }}" @@ -465,20 +465,11 @@ jobs: throw "MSIX package not found in ${{ env.PROJECT_DIR }}/build/windows/msix" } - $stageDir = Join-Path $env:RUNNER_TEMP "msix-store-submit" - New-Item -ItemType Directory -Force -Path $stageDir | Out-Null + # partner_center_msix.mjs builds the submission ZIP (package + listing images). + "PARTNER_CENTER_PACKAGE_PATH=$($source.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "PARTNER_CENTER_PACKAGE_FILE_NAME=CBFileHub-$version-$msixVersion.msix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - $packageFileName = "CBFileHub-$version-$msixVersion.msix" - $storePackagePath = Join-Path $stageDir $packageFileName - Copy-Item -LiteralPath $source.FullName -Destination $storePackagePath -Force - - $archivePath = Join-Path $env:RUNNER_TEMP "CBFileHub-$version-$msixVersion-store.zip" - Compress-Archive -LiteralPath $storePackagePath -DestinationPath $archivePath -Force - - "PARTNER_CENTER_PACKAGE_ARCHIVE=$archivePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "PARTNER_CENTER_PACKAGE_FILE_NAME=$packageFileName" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Submit MSIX to Microsoft Store for automatic publishing + - name: Submit MSIX, listings and images to Microsoft Store for automatic publishing shell: bash env: PARTNER_CENTER_TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }} @@ -490,6 +481,7 @@ jobs: PARTNER_CENTER_COMMIT_SUBMISSION: "true" PARTNER_CENTER_TARGET_PUBLISH_MODE: "Immediate" run: | + # Waits for Partner Center to finish processing the commit and fails on CommitFailed. SUBMISSION_ID="$(node scripts/partner_center_msix.mjs submit)" echo "Partner Center submission committed for automatic publishing after certification: $SUBMISSION_ID" diff --git a/README.md b/README.md index c6205dd0..101f3a49 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,7 @@ Generate promotional artwork from the captured screenshots: python scripts\make_promo_images.py ``` -The generated artwork is written to `screenshots/promo/desktop/`, `screenshots/promo/mobile/`, `screenshots/promo/tablet7/`, and the matching Vietnamese folders under `screenshots/promo/vi/`. +The generated artwork is written to `screenshots/promo/desktop/`, `screenshots/promo/mobile/`, `screenshots/promo/tablet7/`, the matching Vietnamese folders under `screenshots/promo/vi/`, Turkish and Chinese desktop promos under `screenshots/promo/tr/` and `screenshots/promo/zh/`, and the text-free Microsoft Store hero art in `screenshots/promo/store/`. Use `--desktop-only --languages tr,zh` to regenerate only some languages. Render the English/Vietnamese Torrent dialog images with sample data from `cb_file_manager/`: @@ -459,7 +459,7 @@ This opt-in widget capture writes `screenshots/features/torrents/en/` and `vi/`. ### Microsoft Store and Google Play descriptions -Both stores use the same concise English/Vietnamese descriptions in [`store/listings/`](store/listings/). Edit each language's `.txt` once; its bullet points also generate Microsoft Store features. The sibling `.json` holds the short description and Google Play language code. CI checks character limits and uploads a `store-listings-en-vi` preview. MSIX submissions apply the text in Partner Center; AAB submissions include it in the same Google Play edit as the app bundle. See the [content guide](store/README.md). +Both stores use the same concise descriptions in [`store/listings/`](store/listings/), one per app language (English, Vietnamese, Turkish, Simplified Chinese). Edit each language's `.txt` once; its bullet points also generate Microsoft Store features. The sibling `.json` holds the short description, Google Play language code and the Microsoft Store screenshots/hero art. CI checks text limits and image sizes and uploads a `store-listings` preview. MSIX submissions apply the text and images in Partner Center; AAB submissions include the text in the same Google Play edit as the app bundle. See the [content guide](store/README.md). ## Testing diff --git a/README.vi.md b/README.vi.md index f38a0107..b2107709 100644 --- a/README.vi.md +++ b/README.vi.md @@ -141,4 +141,4 @@ flutter test --no-pub tool/capture_torrent_screenshots_test.dart --dart-define=C Ảnh được ghi vào `screenshots/features/torrents/en/` và `vi/`; luồng này dựng widget, không mở ứng dụng desktop hay đọc thư viện torrent của người dùng. -Microsoft Store và Google Play dùng chung mô tả ngắn gọn tiếng Anh/Việt tại [`store/listings/`](store/listings/). Chỉ cần sửa file `.txt` của từng ngôn ngữ; các dòng gạch đầu dòng cũng tạo danh sách tính năng cho Microsoft Store. File `.json` bên cạnh chứa mô tả ngắn và mã ngôn ngữ Google Play. CI kiểm tra giới hạn ký tự, tạo artifact `store-listings-en-vi`, rồi cập nhật nội dung khi submit MSIX/AAB. Xem [hướng dẫn nội dung](store/README.md). +Microsoft Store và Google Play dùng chung mô tả ngắn gọn tại [`store/listings/`](store/listings/), mỗi ngôn ngữ của app một bộ (Anh, Việt, Thổ Nhĩ Kỳ, Trung giản thể). Chỉ cần sửa file `.txt` của từng ngôn ngữ; các dòng gạch đầu dòng cũng tạo danh sách tính năng cho Microsoft Store. File `.json` bên cạnh chứa mô tả ngắn, mã ngôn ngữ Google Play và danh sách ảnh chụp/hero art cho Microsoft Store. CI kiểm tra giới hạn ký tự và kích thước ảnh, tạo artifact `store-listings`, rồi cập nhật nội dung (và ảnh với MSIX) khi submit MSIX/AAB. Xem [hướng dẫn nội dung](store/README.md). diff --git a/cb_file_manager/pubspec.yaml b/cb_file_manager/pubspec.yaml index c0cedd94..3a6b4830 100644 --- a/cb_file_manager/pubspec.yaml +++ b/cb_file_manager/pubspec.yaml @@ -214,3 +214,5 @@ msix_config: capabilities: internetClient, privateNetworkClientServer file_extension: .torrent protocol_activation: magnet + # UI languages; must match the Store listings in store/listings/. + languages: en-us, vi-vn, tr-tr, zh-cn diff --git a/docs/guides/02-microsoft-store-submission-guide.md b/docs/guides/02-microsoft-store-submission-guide.md index eccb03a7..b9789ee4 100644 --- a/docs/guides/02-microsoft-store-submission-guide.md +++ b/docs/guides/02-microsoft-store-submission-guide.md @@ -55,12 +55,13 @@ Push a release tag or run the release workflow manually. The pipeline will: -1. Validate the checked-in English/Vietnamese listing text and run mocked submission tests -2. Upload `store-listings-en-vi`, a shared JSON preview of the exact text for Microsoft Store and Google Play -3. Restore the `.pfx` from `MSIX_CERT_BASE64`, build and sign the Windows MSIX -4. Upload the `.msix` as the `windows-msix` artifact and prepare its submission archive -5. Update the Store draft's `en-us` and `vi-vn` descriptions/features and upload the package -6. Commit for certification with `targetPublishMode: Immediate`; Microsoft Store publishes automatically after approval +1. Validate the checked-in listing text and images (`en-us`, `vi-vn`, `tr-tr`, `zh-cn`) and run mocked submission tests +2. Upload `store-listings`, a shared JSON preview of the exact text and images for Microsoft Store and Google Play +3. Restore the `.pfx` from `MSIX_CERT_BASE64`, build and sign the Windows MSIX (its manifest declares the same four languages) +4. Upload the `.msix` as the `windows-msix` artifact +5. Update every listing's description, features, screenshots and hero art, then upload one ZIP with the package and any changed images +6. Commit for certification with `targetPublishMode: Immediate` and wait until Partner Center has processed the commit; the job fails on `CommitFailed` and prints Partner Center's errors/warnings +7. Microsoft Store publishes automatically after certification (this can take up to a few business days; the public listing keeps the old text and images until then) The release workflow always sets `PARTNER_CENTER_COMMIT_SUBMISSION=true` and `PARTNER_CENTER_TARGET_PUBLISH_MODE=Immediate`. Repository variables with those @@ -102,36 +103,55 @@ For a local MSIX build submitted manually in Partner Center: 4. Complete store listing fields 5. Submit for certification -## 6. Maintain English and Vietnamese Descriptions +## 6. Maintain Listing Text and Images -Microsoft Store and Google Play share these version-controlled descriptions: +Microsoft Store and Google Play share these version-controlled listings, one per +app UI language: -| Language | Full description and feature bullets | Short description / locale metadata | +| Language | Full description and feature bullets | Short description / locale / images | |---|---|---| | English (`en-us`) | [`store/listings/en-us.txt`](../../store/listings/en-us.txt) | [`en-us.json`](../../store/listings/en-us.json) | | Vietnamese (`vi-vn`) | [`store/listings/vi-vn.txt`](../../store/listings/vi-vn.txt) | [`vi-vn.json`](../../store/listings/vi-vn.json) | +| Turkish (`tr-tr`) | [`store/listings/tr-tr.txt`](../../store/listings/tr-tr.txt) | [`tr-tr.json`](../../store/listings/tr-tr.json) | +| Simplified Chinese (`zh-cn`) | [`store/listings/zh-cn.txt`](../../store/listings/zh-cn.txt) | [`zh-cn.json`](../../store/listings/zh-cn.json) | -Keep both languages concise and aligned. Lines beginning with `- ` generate +Keep all languages concise and aligned. Lines beginning with `- ` generate Microsoft Store's features automatically, so feature text is maintained once. -The `.json` files hold `descriptionFile`, `shortDescription` and -`googlePlayLanguage`; product capabilities and platform limits live in the `.txt`. +The `.json` files hold `descriptionFile`, `shortDescription`, +`googlePlayLanguage` and `microsoftStoreImages`; product capabilities and +platform limits live in the `.txt`. The same locales are declared in the MSIX +manifest (`msix_config.languages`), so Partner Center lists them as supported +package languages; a test keeps both lists identical. `scripts/store_listings.mjs` validates required locales, shared description length -(1–4,000 characters), short descriptions (1–80 characters), and feature bullets -(1–20 entries, at most 200 characters each). The shared limit fits -[Google Play](https://support.google.com/googleplay/android-developer/answer/9859152?hl=en) -and Microsoft Store. `scripts/partner_center_msix.mjs submit` validates the text before -authenticating or modifying a draft, then merges only `description` and `features` -into `listings[locale].baseListing`. The data shape follows the +(1–4,000 characters), short descriptions (1–80 characters), feature bullets +(1–20 entries, at most 200 characters each) and images (PNG type, dimensions per +image type, count, 50 MB, 200-character captions). `scripts/partner_center_msix.mjs submit` +validates everything before authenticating or modifying a draft, then merges +`description`, `features` and the managed `images` into +`listings[locale].baseListing`. The data shape follows the [Microsoft Store submission API](https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions). -Existing titles, images, policy/support URLs, terms, release notes, platform -overrides and unrelated languages remain intact. If Vietnamese is new, its -listing is seeded from the existing English Store metadata before applying the -Vietnamese text. An initial Store listing with a registered title must already +Images are managed per image type: for each type listed in a locale's +`microsoftStoreImages` (by default `Screenshot` and `PromotionalArt16x9`), the +existing images of that type are marked `PendingDelete` and the checked-in ones +are added as `PendingUpload`, in order. Uploaded file names contain a content +hash, so when Partner Center still reports the same files nothing is re-uploaded. +Image types not listed (for example a manually uploaded `Icon` or Xbox art) stay +untouched. Set `PARTNER_CENTER_UPDATE_IMAGES=false` to update only text. + +Existing titles, policy/support URLs, terms, release notes, platform overrides +and unrelated languages remain intact. A new language listing is seeded from the +existing English Store metadata (without English images) before applying its +text and images. An initial Store listing with a registered title must already exist. `PARTNER_CENTER_LISTINGS_DIR` can select another content directory; release CI explicitly uses `store/listings`. +After the commit, the script polls the submission status +(`PARTNER_CENTER_COMMIT_POLL_SECONDS`, default 30; +`PARTNER_CENTER_COMMIT_TIMEOUT_SECONDS`, default 1800). `CommitFailed` fails the +job with Partner Center's error list; a timeout only emits a warning. + Preview and test locally from the repository root without Store credentials or network requests: @@ -141,8 +161,8 @@ node --test scripts/tests/store_listings.test.mjs scripts/tests/google_play_list ``` PR/push CI validates the content. Release jobs wait for the same checks and -upload a `store-listings-en-vi` preview containing payloads for both stores. -Google Play's AAB job prepares an uncommitted edit with both descriptions and +upload a `store-listings` preview containing payloads for both stores. +Google Play's AAB job prepares an uncommitted edit with every description and passes its ID to `upload-google-play`; that action uploads the AAB and commits the descriptions with the release. Failed updates/uploads discard the edit. Internal app sharing skips listing updates because it has no store release edit. @@ -167,4 +187,4 @@ flutter test --no-pub tool/capture_torrent_screenshots_test.dart --dart-define=C This is an opt-in widget renderer. It does not launch the Windows app, scan user data, contact trackers or download payloads. README screenshots are independent -of existing Store image assets, which this description update preserves. +of the Store images, which come from `screenshots/promo/` (see section 6). diff --git a/screenshots/promo/desktop/11_torrent_library.png b/screenshots/promo/desktop/11_torrent_library.png new file mode 100644 index 00000000..271a7752 Binary files /dev/null and b/screenshots/promo/desktop/11_torrent_library.png differ diff --git a/screenshots/promo/desktop/12_tag_assignment.png b/screenshots/promo/desktop/12_tag_assignment.png new file mode 100644 index 00000000..1c821c53 Binary files /dev/null and b/screenshots/promo/desktop/12_tag_assignment.png differ diff --git a/screenshots/promo/store/hero_16x9.png b/screenshots/promo/store/hero_16x9.png new file mode 100644 index 00000000..a4eb932f Binary files /dev/null and b/screenshots/promo/store/hero_16x9.png differ diff --git a/screenshots/promo/tr/desktop/01_dosya_tarayici.png b/screenshots/promo/tr/desktop/01_dosya_tarayici.png new file mode 100644 index 00000000..711de8a7 Binary files /dev/null and b/screenshots/promo/tr/desktop/01_dosya_tarayici.png differ diff --git a/screenshots/promo/tr/desktop/02_galeri_merkezi.png b/screenshots/promo/tr/desktop/02_galeri_merkezi.png new file mode 100644 index 00000000..2d729d8b Binary files /dev/null and b/screenshots/promo/tr/desktop/02_galeri_merkezi.png differ diff --git a/screenshots/promo/tr/desktop/03_album_ayrintisi.png b/screenshots/promo/tr/desktop/03_album_ayrintisi.png new file mode 100644 index 00000000..eb1dd3cb Binary files /dev/null and b/screenshots/promo/tr/desktop/03_album_ayrintisi.png differ diff --git a/screenshots/promo/tr/desktop/04_yapay_zeka_arama.png b/screenshots/promo/tr/desktop/04_yapay_zeka_arama.png new file mode 100644 index 00000000..c447c8eb Binary files /dev/null and b/screenshots/promo/tr/desktop/04_yapay_zeka_arama.png differ diff --git a/screenshots/promo/tr/desktop/05_yapay_zeka_onayi.png b/screenshots/promo/tr/desktop/05_yapay_zeka_onayi.png new file mode 100644 index 00000000..1ba8fba5 Binary files /dev/null and b/screenshots/promo/tr/desktop/05_yapay_zeka_onayi.png differ diff --git a/screenshots/promo/tr/desktop/06_yapay_zeka_sohbet.png b/screenshots/promo/tr/desktop/06_yapay_zeka_sohbet.png new file mode 100644 index 00000000..f2a807c7 Binary files /dev/null and b/screenshots/promo/tr/desktop/06_yapay_zeka_sohbet.png differ diff --git a/screenshots/promo/tr/desktop/07_etiket_yonetimi.png b/screenshots/promo/tr/desktop/07_etiket_yonetimi.png new file mode 100644 index 00000000..706dcefb Binary files /dev/null and b/screenshots/promo/tr/desktop/07_etiket_yonetimi.png differ diff --git a/screenshots/promo/tr/desktop/08_etiket_agaci.png b/screenshots/promo/tr/desktop/08_etiket_agaci.png new file mode 100644 index 00000000..d7a5e5c6 Binary files /dev/null and b/screenshots/promo/tr/desktop/08_etiket_agaci.png differ diff --git a/screenshots/promo/tr/desktop/10_disk_temizleyici.png b/screenshots/promo/tr/desktop/10_disk_temizleyici.png new file mode 100644 index 00000000..123c3c15 Binary files /dev/null and b/screenshots/promo/tr/desktop/10_disk_temizleyici.png differ diff --git a/screenshots/promo/tr/desktop/11_torrent_kitapligi.png b/screenshots/promo/tr/desktop/11_torrent_kitapligi.png new file mode 100644 index 00000000..0490024c Binary files /dev/null and b/screenshots/promo/tr/desktop/11_torrent_kitapligi.png differ diff --git a/screenshots/promo/tr/desktop/12_etiketleme.png b/screenshots/promo/tr/desktop/12_etiketleme.png new file mode 100644 index 00000000..6f0e1f6c Binary files /dev/null and b/screenshots/promo/tr/desktop/12_etiketleme.png differ diff --git a/screenshots/promo/vi/desktop/11_thu_vien_torrent.png b/screenshots/promo/vi/desktop/11_thu_vien_torrent.png new file mode 100644 index 00000000..94755f5a Binary files /dev/null and b/screenshots/promo/vi/desktop/11_thu_vien_torrent.png differ diff --git a/screenshots/promo/vi/desktop/12_gan_tag.png b/screenshots/promo/vi/desktop/12_gan_tag.png new file mode 100644 index 00000000..a3f14bb8 Binary files /dev/null and b/screenshots/promo/vi/desktop/12_gan_tag.png differ diff --git a/screenshots/promo/zh/desktop/01_liulan_wenjian.png b/screenshots/promo/zh/desktop/01_liulan_wenjian.png new file mode 100644 index 00000000..205cf17c Binary files /dev/null and b/screenshots/promo/zh/desktop/01_liulan_wenjian.png differ diff --git a/screenshots/promo/zh/desktop/02_tuku.png b/screenshots/promo/zh/desktop/02_tuku.png new file mode 100644 index 00000000..30ee21f8 Binary files /dev/null and b/screenshots/promo/zh/desktop/02_tuku.png differ diff --git a/screenshots/promo/zh/desktop/03_xiangce_xiangqing.png b/screenshots/promo/zh/desktop/03_xiangce_xiangqing.png new file mode 100644 index 00000000..b23a2e31 Binary files /dev/null and b/screenshots/promo/zh/desktop/03_xiangce_xiangqing.png differ diff --git a/screenshots/promo/zh/desktop/04_ai_sousuo.png b/screenshots/promo/zh/desktop/04_ai_sousuo.png new file mode 100644 index 00000000..892c961b Binary files /dev/null and b/screenshots/promo/zh/desktop/04_ai_sousuo.png differ diff --git a/screenshots/promo/zh/desktop/05_ai_shenhe.png b/screenshots/promo/zh/desktop/05_ai_shenhe.png new file mode 100644 index 00000000..a22a271d Binary files /dev/null and b/screenshots/promo/zh/desktop/05_ai_shenhe.png differ diff --git a/screenshots/promo/zh/desktop/06_ai_duihua.png b/screenshots/promo/zh/desktop/06_ai_duihua.png new file mode 100644 index 00000000..be6f30c7 Binary files /dev/null and b/screenshots/promo/zh/desktop/06_ai_duihua.png differ diff --git a/screenshots/promo/zh/desktop/07_biaoqian_guanli.png b/screenshots/promo/zh/desktop/07_biaoqian_guanli.png new file mode 100644 index 00000000..2dd7a549 Binary files /dev/null and b/screenshots/promo/zh/desktop/07_biaoqian_guanli.png differ diff --git a/screenshots/promo/zh/desktop/08_biaoqian_shu.png b/screenshots/promo/zh/desktop/08_biaoqian_shu.png new file mode 100644 index 00000000..8f6bbf1b Binary files /dev/null and b/screenshots/promo/zh/desktop/08_biaoqian_shu.png differ diff --git a/screenshots/promo/zh/desktop/10_cipan_qingli.png b/screenshots/promo/zh/desktop/10_cipan_qingli.png new file mode 100644 index 00000000..7276ae56 Binary files /dev/null and b/screenshots/promo/zh/desktop/10_cipan_qingli.png differ diff --git a/screenshots/promo/zh/desktop/11_zhongzi_ku.png b/screenshots/promo/zh/desktop/11_zhongzi_ku.png new file mode 100644 index 00000000..cafec7f9 Binary files /dev/null and b/screenshots/promo/zh/desktop/11_zhongzi_ku.png differ diff --git a/screenshots/promo/zh/desktop/12_biaoqian_fenpei.png b/screenshots/promo/zh/desktop/12_biaoqian_fenpei.png new file mode 100644 index 00000000..800ef99a Binary files /dev/null and b/screenshots/promo/zh/desktop/12_biaoqian_fenpei.png differ diff --git a/scripts/make_promo_images.py b/scripts/make_promo_images.py index d9047f2e..17a53163 100644 --- a/scripts/make_promo_images.py +++ b/scripts/make_promo_images.py @@ -5,6 +5,7 @@ import argparse import math +import re from dataclasses import dataclass from pathlib import Path @@ -21,6 +22,8 @@ MOBILE_SIZE = (1440, 2560) TABLET7_SIZE = (1200, 1920) FEATURE_SIZE = (1024, 500) +STORE_HERO_SIZE = (1920, 1080) +LOGO_PATH = ROOT / "cb_file_manager" / "assets" / "images" / "logo_circle.png" DESKTOP_SOURCE_NAMES = [ "002_showcase_file_browser_result.png", @@ -114,8 +117,96 @@ class PromoSpec: ] -def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: - candidates = [ +DESKTOP_SPECS_TR = [ + PromoSpec("dosya_tarayici", "Her çalışma alanına göz atın", "Yerel dosyalar, sekmeler, etiketler ve zengin önizlemeler tek bir masaüstü görünümünde.", (54, 166, 255)), + PromoSpec("galeri_merkezi", "Büyük kitaplıklar için galeri", "Albümleri, medya klasörlerini ve görsel koleksiyonları bağlamı kaybetmeden tarayın.", (250, 169, 64)), + PromoSpec("album_ayrintisi", "Hızlı medya ayrıntıları", "Albümleri açın ve akıcı küçük resim ızgaralarıyla gezinmeye devam edin.", (75, 204, 143)), + PromoSpec("yapay_zeka_arama", "Yapay zekâ destekli arama", "Entegre asistan paneliyle dosyaları bulun, inceleyin ve işlem yapın.", (138, 112, 255)), + PromoSpec("yapay_zeka_onayi", "Kontrol sizde", "Dosyalarınızda bir şey değişmeden önce asistanın işlemlerini gözden geçirin.", (255, 107, 107)), + PromoSpec("yapay_zeka_sohbet", "Dosyalarınız hakkında sorun", "Çalışma alanınızın yanında dosyaları anlayan bir sohbet sürdürün.", (102, 217, 232)), + PromoSpec("etiket_yonetimi", "Etiketleyin ve düzenleyin", "Projeler, kaynaklar ve medya koleksiyonları için hafif sistemler kurun.", (255, 214, 102)), + PromoSpec("etiket_agaci", "Etiket ağaçlarında gezinin", "Üst-alt etiket hiyerarşileri oluşturun ve ihtiyacınız olan dalı açın.", (92, 219, 149)), + PromoSpec("disk_temizleyici", "Güvenle temizleyin", "Büyük klasörleri tarayın, gereksiz dosyaları inceleyin ve silmeden önce CB Agent'a sorun.", (255, 149, 0)), +] + +DESKTOP_SPECS_ZH = [ + PromoSpec("liulan_wenjian", "浏览每一个工作区", "本地文件、标签页、标签和丰富预览,尽在一个专注的桌面视图中。", (54, 166, 255)), + PromoSpec("tuku", "为大型图库而生", "浏览相册、媒体文件夹和图片收藏,始终不丢失上下文。", (250, 169, 64)), + PromoSpec("xiangce_xiangqing", "快速查看媒体详情", "打开相册,在流畅的缩略图网格中继续浏览。", (75, 204, 143)), + PromoSpec("ai_sousuo", "AI 辅助搜索", "通过集成的助手侧边栏查找、查看并处理文件。", (138, 112, 255)), + PromoSpec("ai_shenhe", "一切尽在掌控", "在文件发生任何更改之前,先审核助手的操作。", (255, 107, 107)), + PromoSpec("ai_duihua", "询问你的文件", "在当前工作区旁,与理解文件的助手持续对话。", (102, 217, 232)), + PromoSpec("biaoqian_guanli", "标签与整理", "为项目、参考资料和媒体收藏建立轻量的整理体系。", (255, 214, 102)), + PromoSpec("biaoqian_shu", "浏览标签树", "建立父子标签层级,展开你需要的那一个分支。", (92, 219, 149)), + PromoSpec("cipan_qingli", "放心清理", "扫描占用空间的文件夹,检查可清理的文件,删除前先询问 CB Agent。", (255, 149, 0)), +] + +# Desktop-only languages that reuse the shared source screenshots. Output goes +# to screenshots/promo//desktop and feeds the Microsoft Store listings. +EXTRA_DESKTOP_LANGUAGES = { + "tr": DESKTOP_SPECS_TR, + "zh": DESKTOP_SPECS_ZH, +} +DISK_CLEANER_STEMS = {"disk_cleaner", "don_dep_o_dia", "disk_temizleyici", "cipan_qingli"} + + +@dataclass(frozen=True) +class ExtraPromo: + """Desktop promo with its own source and fixed slot, so existing promo filenames stay stable.""" + + slot: int + # "desktop:" in --desktop-source, or "torrent:" rendered by + # cb_file_manager/tool/capture_torrent_screenshots_test.dart (en/vi; other + # languages fall back to English). + source: str + specs: dict[str, PromoSpec] + # Optional (left, top, right, bottom) crop of the source, e.g. to trim the + # empty canvas around a rendered dialog. + crop: tuple[int, int, int, int] | None = None + + +TORRENT_SOURCE = ROOT / "screenshots" / "features" / "torrents" + +EXTRA_DESKTOP_PROMOS = [ + ExtraPromo(11, "torrent:qbittorrent", { + "en": PromoSpec("torrent_library", "Your torrent library", "Import qBittorrent backups and keep .torrent files and magnet links in one searchable library.", (75, 204, 143)), + "vi": PromoSpec("thu_vien_torrent", "Thư viện torrent của bạn", "Nhập bản sao lưu qBittorrent, gom file .torrent và magnet vào một thư viện có tìm kiếm.", (75, 204, 143)), + "tr": PromoSpec("torrent_kitapligi", "Torrent kitaplığınız", "qBittorrent yedeklerini içe aktarın; .torrent dosyalarını ve magnet bağlantılarını aranabilir tek kitaplıkta tutun.", (75, 204, 143)), + "zh": PromoSpec("zhongzi_ku", "你的种子库", "导入 qBittorrent 备份,将 .torrent 文件和磁力链接集中到一个可搜索的库中。", (75, 204, 143)), + }, crop=(200, 40, 1240, 860)), + ExtraPromo(12, "desktop:020_showcase_tag_assignment_result.png", { + "en": PromoSpec("tag_assignment", "Tag files in seconds", "Pick popular or recent tags, or browse the tag tree, without leaving your files.", (255, 214, 102)), + "vi": PromoSpec("gan_tag", "Gắn tag trong vài giây", "Chọn tag phổ biến, tag gần đây hoặc duyệt cây tag ngay bên cạnh tập tin.", (255, 214, 102)), + "tr": PromoSpec("etiketleme", "Saniyeler içinde etiketleyin", "Popüler veya son etiketleri seçin ya da dosyalarınızdan ayrılmadan etiket ağacına göz atın.", (255, 214, 102)), + "zh": PromoSpec("biaoqian_fenpei", "几秒钟完成标签", "选择常用或最近的标签,或直接浏览标签树,无需离开文件。", (255, 214, 102)), + }), +] + + +def extra_promo_source(extra: ExtraPromo, language: str, desktop_source: Path) -> Path: + kind, name = extra.source.split(":", 1) + if kind == "desktop": + source = desktop_source / name + else: + localized = TORRENT_SOURCE / language / f"{name}.png" + source = localized if localized.exists() else TORRENT_SOURCE / "en" / f"{name}.png" + if not source.exists(): + raise SystemExit(f"Missing promo source: {source}") + return source +CJK_CLOSING_PUNCTUATION = ",。、;:!?)」』" + + +def contains_cjk(text: str) -> bool: + return any("\u3000" <= char <= "\u9fff" or "\uff00" <= char <= "\uffef" for char in text) + + +def load_font(size: int, bold: bool = False, cjk: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + cjk_candidates = [ + Path("C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc"), + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc" if bold else "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path("/System/Library/Fonts/PingFang.ttc"), + ] + candidates = (cjk_candidates if cjk else []) + [ Path("C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf"), Path("C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf"), Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), @@ -189,8 +280,9 @@ def create_background(size: tuple[int, int], accent: tuple[int, int, int]) -> Im def draw_text_block(draw: ImageDraw.ImageDraw, xy: tuple[int, int], title: str, subtitle: str, width: int, title_size: int) -> None: - title_font = load_font(title_size, bold=True) - subtitle_font = load_font(round(title_size * 0.34)) + cjk = contains_cjk(title + subtitle) + title_font = load_font(title_size, bold=True, cjk=cjk) + subtitle_font = load_font(round(title_size * 0.34), cjk=cjk) eyebrow_font = load_font(round(title_size * 0.24), bold=True) x, y = xy draw.text((x, y), "CB FILE HUB", font=eyebrow_font, fill=(147, 197, 253, 255)) @@ -204,6 +296,8 @@ def draw_text_block(draw: ImageDraw.ImageDraw, xy: tuple[int, int], title: str, def wrap_text(text: str, font: ImageFont.ImageFont, max_width: int) -> str: + if contains_cjk(text): + return wrap_cjk_text(text, font, max_width) words = text.split() lines: list[str] = [] current = "" @@ -220,6 +314,27 @@ def wrap_text(text: str, font: ImageFont.ImageFont, max_width: int) -> str: return "\n".join(lines) +def wrap_cjk_text(text: str, font: ImageFont.ImageFont, max_width: int) -> str: + # CJK text has no spaces: break between characters, keep Latin words such + # as "CB Agent" whole and never start a line with closing punctuation. + tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9.+'\-]*|\s+|.", text) + lines: list[str] = [] + current = "" + for token in tokens: + if token.isspace() and not current: + continue + candidate = current + token + fits = font.getlength(candidate.rstrip()) <= max_width + if fits or not current.strip() or token in CJK_CLOSING_PUNCTUATION: + current = candidate + continue + lines.append(current.rstrip()) + current = "" if token.isspace() else token + if current.strip(): + lines.append(current.rstrip()) + return "\n".join(lines) + + def draw_desktop_frame(base: Image.Image, screenshot: Image.Image, spec: PromoSpec) -> None: frame_x, frame_y = 760, 260 frame_w, frame_h = 1490, 820 @@ -339,8 +454,10 @@ def create_feature_graphic(sources: list[Path], output: Path, title: str, subtit image.convert("RGB").save(output, optimize=True) -def create_desktop_promo(source: Path, output: Path, spec: PromoSpec) -> None: +def create_desktop_promo(source: Path, output: Path, spec: PromoSpec, crop: tuple[int, int, int, int] | None = None) -> None: screenshot = Image.open(source).convert("RGBA") + if crop: + screenshot = screenshot.crop(crop) image = create_background(DESKTOP_SIZE, spec.accent) draw_desktop_frame(image, screenshot, spec) output.parent.mkdir(parents=True, exist_ok=True) @@ -363,8 +480,43 @@ def create_tablet7_promo(source: Path, output: Path, spec: PromoSpec) -> None: image.convert("RGB").save(output, optimize=True) +def create_store_hero(output: Path) -> None: + """Text-free 16:9 Microsoft Store hero art: no UI, key content above the bottom third.""" + width, height = STORE_HERO_SIZE + image = create_background(STORE_HERO_SIZE, (54, 166, 255)) + overlay = Image.new("RGBA", STORE_HERO_SIZE, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + center_x, center_y = width // 2, round(height * 0.40) + draw.ellipse((center_x - 520, center_y - 420, center_x + 520, center_y + 420), fill=(54, 166, 255, 34)) + draw.ellipse((center_x - 340, center_y - 280, center_x + 340, center_y + 280), fill=(92, 219, 149, 26)) + image = Image.alpha_composite(image, overlay) + # Abstract file cards around the logo, in the promo accent colors. + cards = [ + (-560, -170, 210, 150, (54, 166, 255), -8), + (-470, 150, 180, 128, (250, 169, 64), 6), + (370, -230, 190, 136, (138, 112, 255), 7), + (420, 110, 220, 156, (92, 219, 149), -5), + (-250, -330, 130, 94, (255, 214, 102), 4), + (190, 250, 140, 100, (255, 107, 107), -7), + ] + for dx, dy, card_w, card_h, color, tilt in cards: + card = Image.new("RGBA", (card_w + 60, card_h + 60), (0, 0, 0, 0)) + card_draw = ImageDraw.Draw(card) + card_draw.rounded_rectangle((30, 30, 30 + card_w, 30 + card_h), radius=22, fill=(*color, 70), outline=(*color, 150), width=3) + card_draw.rounded_rectangle((30, 30, 30 + card_w // 2, 30 + card_h // 5), radius=10, fill=(*color, 120)) + card = card.rotate(tilt, resample=Image.Resampling.BICUBIC, expand=True) + image.alpha_composite(card, (center_x + dx - card.width // 2, center_y + dy - card.height // 2)) + + logo = contain_resize(Image.open(LOGO_PATH).convert("RGBA"), (440, 440)) + logo_x, logo_y = center_x - logo.width // 2, center_y - logo.height // 2 + draw_shadow(image, (logo_x + 30, logo_y + 40, logo_x + logo.width - 30, logo_y + logo.height + 10), logo.width // 2, 40, 140) + image.alpha_composite(logo, (logo_x, logo_y)) + output.parent.mkdir(parents=True, exist_ok=True) + image.convert("RGB").save(output, optimize=True) + + def desktop_promo_filename(index: int, spec: PromoSpec) -> str: - if spec.stem in {"disk_cleaner", "don_dep_o_dia"}: + if spec.stem in DISK_CLEANER_STEMS: return f"10_{spec.stem}.png" return f"{index + 1:02d}_{spec.stem}.png" @@ -395,7 +547,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--desktop-source", type=Path, default=DEFAULT_DESKTOP_SOURCE) parser.add_argument("--mobile-source", type=Path, default=DEFAULT_MOBILE_SOURCE) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--desktop-only", action="store_true", help="Regenerate only English and Vietnamese desktop promos.") + parser.add_argument("--desktop-only", action="store_true", help="Regenerate only desktop promos and the Store hero art.") + parser.add_argument( + "--languages", + default=",".join(["en", "vi", *EXTRA_DESKTOP_LANGUAGES]), + help="Comma-separated desktop promo languages to (re)generate, e.g. tr,zh.", + ) return parser @@ -416,15 +573,33 @@ def main() -> None: mobile_vi_output = args.output / "vi" / "mobile" tablet7_vi_output = args.output / "vi" / "tablet7" - for index, source in enumerate(desktop_sources): - spec = DESKTOP_SPECS[index % len(DESKTOP_SPECS)] - create_desktop_promo(source, desktop_output / desktop_promo_filename(index, spec), spec) - vi_spec = DESKTOP_SPECS_VI[index % len(DESKTOP_SPECS_VI)] - create_desktop_promo(source, desktop_vi_output / desktop_promo_filename(index, vi_spec), vi_spec) + languages = {code.strip() for code in args.languages.split(",") if code.strip()} + unknown = languages - {"en", "vi", *EXTRA_DESKTOP_LANGUAGES} + if unknown: + raise SystemExit(f"Unknown promo languages: {', '.join(sorted(unknown))}") + desktop_targets = [] + if "en" in languages: + desktop_targets.append(("en", DESKTOP_SPECS, desktop_output)) + if "vi" in languages: + desktop_targets.append(("vi", DESKTOP_SPECS_VI, desktop_vi_output)) + for code, specs in EXTRA_DESKTOP_LANGUAGES.items(): + if code in languages: + desktop_targets.append((code, specs, args.output / code / "desktop")) + + for code, specs, output in desktop_targets: + for index, source in enumerate(desktop_sources): + spec = specs[index % len(specs)] + create_desktop_promo(source, output / desktop_promo_filename(index, spec), spec) + for extra in EXTRA_DESKTOP_PROMOS: + spec = extra.specs[code] + source = extra_promo_source(extra, code, args.desktop_source) + create_desktop_promo(source, output / f"{extra.slot:02d}_{spec.stem}.png", spec, extra.crop) + print(f"Created {len(desktop_sources) + len(EXTRA_DESKTOP_PROMOS)} desktop promo images in {output}") + + create_store_hero(args.output / "store" / "hero_16x9.png") + print(f"Created Microsoft Store hero art in {args.output / 'store'}") if args.desktop_only: - print(f"Created {len(desktop_sources)} desktop promo images in {desktop_output}") - print(f"Created {len(desktop_sources)} Vietnamese desktop promo images in {desktop_vi_output}") return for index, source in enumerate(mobile_sources): @@ -452,10 +627,8 @@ def main() -> None: "Tab, tag, thumbnail, SMB và FTP trong một ứng dụng gọn gàng.", ) - print(f"Created {len(desktop_sources)} desktop promo images in {desktop_output}") print(f"Created {len(mobile_sources)} mobile promo images in {mobile_output}") print(f"Created {len(mobile_sources)} 7-inch tablet promo images in {tablet7_output}") - print(f"Created {len(desktop_sources)} Vietnamese desktop promo images in {desktop_vi_output}") print(f"Created {len(mobile_sources)} Vietnamese mobile promo images in {mobile_vi_output}") print(f"Created {len(mobile_sources)} Vietnamese 7-inch tablet promo images in {tablet7_vi_output}") print(f"Created feature graphics in {args.output} and {args.output / 'vi'}") diff --git a/scripts/partner_center_msix.mjs b/scripts/partner_center_msix.mjs index f0106c16..11b8dced 100644 --- a/scripts/partner_center_msix.mjs +++ b/scripts/partner_center_msix.mjs @@ -1,6 +1,8 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { applyStoreListings, loadStoreListings, defaultListingsDirectory } from './store_listings.mjs'; +import { writeStoredZip } from './stored_zip.mjs'; const TOKEN_RESOURCE = 'https://manage.devcenter.microsoft.com'; const API_ROOT = 'https://manage.devcenter.microsoft.com/v1.0/my'; @@ -237,6 +239,62 @@ function packageForDeletion(appPackage) { }; } +function buildSubmissionArchive(submissionId, packagePath, packageFileName, imageUploads) { + const archivePath = path.join( + optionalEnv('RUNNER_TEMP') || os.tmpdir(), + `partner-center-${submissionId}.zip`, + ); + writeStoredZip(archivePath, [ + { name: packageFileName, source: packagePath }, + ...imageUploads.map((image) => ({ name: image.fileName, source: image.source })), + ]); + return archivePath; +} + +function describeIssues(issues) { + return (issues || []) + .map((issue) => `${issue.code}: ${issue.details}`) + .join('; '); +} + +// Commit only starts asynchronous processing. Wait until Partner Center +// accepts or rejects the submission so CI fails on CommitFailed instead of +// reporting success while the Store never receives the update. +async function waitForCommit(accessToken, appId, submissionId) { + const timeoutSeconds = Number(optionalEnv('PARTNER_CENTER_COMMIT_TIMEOUT_SECONDS') || 1800); + const pollSeconds = Number(optionalEnv('PARTNER_CENTER_COMMIT_POLL_SECONDS') || 30); + const deadline = Date.now() + timeoutSeconds * 1000; + for (;;) { + const status = await apiRequest( + accessToken, + 'GET', + `/applications/${encodeURIComponent(appId)}/submissions/${encodeURIComponent( + submissionId, + )}/status`, + ); + const warnings = describeIssues(status?.statusDetails?.warnings); + if (status?.status !== 'CommitStarted') { + if (warnings) console.error(`Partner Center warnings: ${warnings}`); + if (status?.status === 'CommitFailed') { + throw new Error( + `Partner Center submission ${submissionId} failed to commit: ${ + describeIssues(status.statusDetails?.errors) || formatError(status) + }`, + ); + } + console.error(`Partner Center submission ${submissionId} status: ${status?.status}`); + return status; + } + if (Date.now() >= deadline) { + console.error( + `::warning::Partner Center submission ${submissionId} is still committing after ${timeoutSeconds}s; check its status in Partner Center.`, + ); + return status; + } + await sleep(pollSeconds * 1000); + } +} + async function uploadSubmissionArchive(sasUrl, archivePath) { const stat = await fs.promises.stat(archivePath); const body = fs.createReadStream(archivePath); @@ -258,17 +316,19 @@ async function uploadSubmissionArchive(sasUrl, archivePath) { async function submitPackage() { const appId = requiredEnv('PARTNER_CENTER_APP_ID'); - const archivePath = path.resolve(requiredEnv('PARTNER_CENTER_PACKAGE_ARCHIVE')); + const packagePath = path.resolve(requiredEnv('PARTNER_CENTER_PACKAGE_PATH')); const packageFileName = - optionalEnv('PARTNER_CENTER_PACKAGE_FILE_NAME') || path.basename(archivePath); + optionalEnv('PARTNER_CENTER_PACKAGE_FILE_NAME') || path.basename(packagePath); - if (!fs.existsSync(archivePath)) { - throw new Error(`Package archive not found: ${archivePath}`); + if (!fs.existsSync(packagePath)) { + throw new Error(`Package not found: ${packagePath}`); } - // Validate checked-in English/Vietnamese copy before changing a Store draft. + // Validate checked-in listing text and images before changing a Store draft. + const updateImages = optionalEnv('PARTNER_CENTER_UPDATE_IMAGES') !== 'false'; const listingContent = loadStoreListings( optionalEnv('PARTNER_CENTER_LISTINGS_DIR') || defaultListingsDirectory, + { images: updateImages }, ); const accessToken = await getAccessToken(); @@ -373,8 +433,16 @@ async function submitPackage() { } console.error(`Partner Center targetPublishMode: ${publishMode}`); - submission = applyStoreListings(submission, listingContent); + const listingUpdate = applyStoreListings(submission, listingContent); + submission = listingUpdate.submission; console.error(`Partner Center listing text updated: ${Object.keys(listingContent).join(', ')}`); + if (!updateImages) { + console.error('Partner Center listing images left unchanged (PARTNER_CENTER_UPDATE_IMAGES=false)'); + } else if (listingUpdate.uploads.length > 0) { + console.error(`Partner Center listing images queued for upload: ${listingUpdate.uploads.length}`); + } else { + console.error('Partner Center listing images already up to date'); + } submission.applicationPackages = [ ...(submission.applicationPackages || []).map(packageForDeletion), @@ -394,7 +462,17 @@ async function submitPackage() { )}`, submission, ); - await uploadSubmissionArchive(submission.fileUploadUrl, archivePath); + const archivePath = buildSubmissionArchive( + submission.id, + packagePath, + packageFileName, + listingUpdate.uploads, + ); + try { + await uploadSubmissionArchive(submission.fileUploadUrl, archivePath); + } finally { + fs.rmSync(archivePath, { force: true }); + } if (optionalEnv('PARTNER_CENTER_COMMIT_SUBMISSION') === 'true') { await apiRequest( accessToken, @@ -404,6 +482,7 @@ async function submitPackage() { )}/commit`, ); console.error(`Partner Center submission committed: ${submission.id}`); + await waitForCommit(accessToken, appId, submission.id); } else { console.error( `Partner Center draft prepared without commit: ${submission.id}`, diff --git a/scripts/store_listings.mjs b/scripts/store_listings.mjs index 0697c420..6068ecaf 100644 --- a/scripts/store_listings.mjs +++ b/scripts/store_listings.mjs @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -6,6 +7,89 @@ export const defaultListingsDirectory = fileURLToPath( new URL('../store/listings/', import.meta.url), ); +// Every listing must exist; these are the languages the app UI supports and +// the MSIX manifest declares (cb_file_manager/pubspec.yaml msix_config.languages). +export const requiredStoreLocales = ['en-us', 'vi-vn', 'tr-tr', 'zh-cn']; + +const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const maxImageBytes = 50 * 1024 * 1024; + +// Partner Center image rules for the image types CI manages. +// https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/screenshots-and-images +const microsoftImageRules = { + Screenshot: { + max: 10, + size: 'at least 1366x768 and at most 3840x2160', + fits: (width, height) => Math.max(width, height) >= 1366 && Math.min(width, height) >= 768 && + Math.max(width, height) <= 3840 && Math.min(width, height) <= 2160, + }, + PromotionalArt16x9: { + max: 1, + size: '1920x1080 or 3840x2160', + fits: (width, height) => (width === 1920 && height === 1080) || (width === 3840 && height === 2160), + }, + Icon: { + max: 1, + size: '300x300', + fits: (width, height) => width === 300 && height === 300, + }, +}; + +function readPngSize(buffer) { + if (buffer.length < 24 || !buffer.subarray(0, 8).equals(pngSignature) || + buffer.toString('ascii', 12, 16) !== 'IHDR') { + return null; + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +function loadMicrosoftImages(locale, directory, entries) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error(`${locale}: microsoftStoreImages must list at least one image`); + } + const counts = {}; + const images = entries.map((entry, index) => { + const label = `${locale}: microsoftStoreImages[${index}]`; + const rule = microsoftImageRules[entry?.imageType]; + if (!rule) { + throw new Error(`${label}: imageType must be one of ${Object.keys(microsoftImageRules).join(', ')}`); + } + if (typeof entry.file !== 'string' || !entry.file.endsWith('.png')) { + throw new Error(`${label}: file must be a .png path relative to ${directory}`); + } + const description = entry.description?.trim() ?? ''; + if (description.length > 200) { + throw new Error(`${label}: description must contain at most 200 characters`); + } + const source = path.resolve(directory, entry.file); + if (!fs.existsSync(source)) throw new Error(`${label}: ${entry.file} not found`); + const data = fs.readFileSync(source); + const size = readPngSize(data); + if (!size) throw new Error(`${label}: ${entry.file} is not a PNG image`); + if (data.length > maxImageBytes) throw new Error(`${label}: ${entry.file} exceeds 50 MB`); + if (!rule.fits(size.width, size.height)) { + throw new Error(`${label}: ${entry.imageType} must be ${rule.size}, got ${size.width}x${size.height}`); + } + counts[entry.imageType] = (counts[entry.imageType] ?? 0) + 1; + const hash = crypto.createHash('sha256').update(data).digest('hex').slice(0, 12); + return { + source, + // Content-addressed: an unchanged image keeps its name, so a later + // submission can keep the uploaded copy instead of re-uploading it. + fileName: `store-${locale}-${String(index + 1).padStart(2, '0')}-${hash}.png`, + imageType: entry.imageType, + description, + }; + }); + for (const [imageType, count] of Object.entries(counts)) { + if (count > microsoftImageRules[imageType].max) { + throw new Error(`${locale}: at most ${microsoftImageRules[imageType].max} ${imageType} images are allowed`); + } + } + if (!counts.Screenshot) throw new Error(`${locale}: provide at least one Screenshot image`); + return images; +} + function loadListingContent(directory = defaultListingsDirectory) { const listings = {}; const playLanguages = new Set(); @@ -19,7 +103,8 @@ function loadListingContent(directory = defaultListingsDirectory) { !/^[a-z0-9-]+\.txt$/.test(entry.descriptionFile)) { throw new Error(`${locale}: descriptionFile must name a sibling .txt file`); } - const description = fs.readFileSync(path.join(directory, entry.descriptionFile), 'utf8').trim(); + const description = fs.readFileSync(path.join(directory, entry.descriptionFile), 'utf8') + .replace(/\r\n/g, '\n').trim(); if (!description || description.length > 4000) { throw new Error(`${locale}: shared description must contain 1–4000 characters`); } @@ -33,7 +118,7 @@ function loadListingContent(directory = defaultListingsDirectory) { throw new Error(`${locale}: provide a unique Google Play language code`); } playLanguages.add(entry.googlePlayLanguage); - const features = description.split(/\r?\n/) + const features = description.split('\n') .filter((line) => line.startsWith('- ')).map((line) => line.slice(2).trim()); if (features.length < 1 || features.length > 20 || features.some((feature) => !feature || feature.length > 200)) { @@ -41,18 +126,22 @@ function loadListingContent(directory = defaultListingsDirectory) { } listings[locale] = { description, shortDescription, googlePlayLanguage: entry.googlePlayLanguage, - features, + features, images: loadMicrosoftImages(locale, directory, entry.microsoftStoreImages), }; } - for (const locale of ['en-us', 'vi-vn']) { + for (const locale of requiredStoreLocales) { if (!listings[locale]) throw new Error(`Missing required Store listing: ${locale}`); } return listings; } -export function loadStoreListings(directory = defaultListingsDirectory) { +export function loadStoreListings(directory = defaultListingsDirectory, { images = true } = {}) { return Object.fromEntries(Object.entries(loadListingContent(directory)) - .map(([locale, { description, features }]) => [locale, { description, features }])); + .map(([locale, listing]) => [locale, { + description: listing.description, + features: listing.features, + ...(images ? { images: listing.images } : {}), + }])); } export function loadGooglePlayListings(directory = defaultListingsDirectory) { @@ -61,32 +150,85 @@ export function loadGooglePlayListings(directory = defaultListingsDirectory) { [googlePlayLanguage, { shortDescription, fullDescription: description }])); } -// Update only repository-owned marketing text; retain Store images, titles, -// release notes, policy URLs, platform overrides and all unrelated languages. +// Replace the listing's images of the managed types (the types present in the +// plan). Other image types stay. When the uploaded images already match the +// plan by content-addressed name and order, nothing is re-uploaded. +function planListingImages(existingImages, planned) { + const managed = new Set(planned.map((image) => image.imageType)); + const current = existingImages.filter((image) => + managed.has(image.imageType) && image.fileStatus !== 'PendingDelete'); + const unchanged = current.length === planned.length && current.every((image, index) => + image.fileStatus === 'Uploaded' && image.fileName === planned[index].fileName && + image.imageType === planned[index].imageType); + if (unchanged) { + let next = 0; + return { + images: existingImages.map((image) => + current.includes(image) ? { ...image, description: planned[next++].description } : image), + uploads: [], + }; + } + const images = []; + for (const image of existingImages) { + if (!managed.has(image.imageType)) images.push(image); + // Images without an id were never processed (a failed earlier draft) and can be dropped. + else if (image.id) images.push({ ...image, fileStatus: 'PendingDelete' }); + } + for (const image of planned) { + images.push({ + fileName: image.fileName, + fileStatus: 'PendingUpload', + imageType: image.imageType, + description: image.description, + }); + } + return { images, uploads: planned }; +} + +// Update only repository-owned marketing content (description, features and, +// when provided, the managed image types); retain titles, other images, +// release notes, policy URLs, platform overrides and unrelated languages. +// Returns the new submission and the image files that must be in the upload ZIP. export function applyStoreListings(submission, content) { const result = structuredClone(submission); result.listings ??= {}; const fallback = result.listings['en-us']?.baseListing ?? Object.values(result.listings).find((listing) => listing.baseListing?.title)?.baseListing; + const uploads = []; for (const [locale, listing] of Object.entries(content)) { const existing = result.listings[locale]; - const base = existing?.baseListing ?? fallback; + // A new language is seeded from existing metadata, without the other + // language's images: Partner Center stores images per listing. + const base = existing?.baseListing ?? (fallback && { ...structuredClone(fallback), images: [] }); if (!base?.title) { throw new Error(`No existing Store title/metadata to seed ${locale}; prepare the initial listing in Partner Center first`); } - result.listings[locale] = { - ...(existing ?? { platformOverrides: {} }), - baseListing: { ...structuredClone(base), ...structuredClone(listing) }, + const baseListing = { + ...structuredClone(base), + description: listing.description, + features: [...listing.features], }; + if (listing.images) { + const plan = planListingImages(baseListing.images ?? [], listing.images); + baseListing.images = plan.images; + uploads.push(...plan.uploads); + } + result.listings[locale] = { ...(existing ?? { platformOverrides: {} }), baseListing }; } - return result; + return { submission: result, uploads }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const directory = process.argv[2] || defaultListingsDirectory; + const microsoft = loadStoreListings(directory); + for (const listing of Object.values(microsoft)) { + listing.images = listing.images.map(({ source, ...image }) => ({ + ...image, source: path.relative(process.cwd(), source).replaceAll('\\', '/'), + })); + } console.log(JSON.stringify({ - microsoft: loadStoreListings(directory), + microsoft, googlePlay: loadGooglePlayListings(directory), }, null, 2)); } catch (error) { diff --git a/scripts/stored_zip.mjs b/scripts/stored_zip.mjs new file mode 100644 index 00000000..e182b885 --- /dev/null +++ b/scripts/stored_zip.mjs @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import zlib from 'node:zlib'; + +// Minimal ZIP writer for Partner Center submission archives. Entries are +// stored uncompressed: MSIX packages and PNG images are already compressed. +// No ZIP64 support, so the archive must stay below 4 GB. + +const chunkSize = 1024 * 1024; +const maxZip32 = 0xffffffff; +const dosDate = (1 << 5) | 1; // 1980-01-01, keeps archives reproducible. + +function crc32File(filePath) { + if (typeof zlib.crc32 !== 'function') { + throw new Error('Node.js 22.2 or newer is required (zlib.crc32)'); + } + const fd = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.allocUnsafe(chunkSize); + let crc = 0; + let bytesRead; + while ((bytesRead = fs.readSync(fd, buffer, 0, chunkSize, null)) > 0) { + crc = zlib.crc32(buffer.subarray(0, bytesRead), crc); + } + return crc; + } finally { + fs.closeSync(fd); + } +} + +function copyInto(outFd, filePath) { + const inFd = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.allocUnsafe(chunkSize); + let bytesRead; + while ((bytesRead = fs.readSync(inFd, buffer, 0, chunkSize, null)) > 0) { + fs.writeSync(outFd, buffer, 0, bytesRead); + } + } finally { + fs.closeSync(inFd); + } +} + +/** + * Writes `entries` ([{ name, source }]) to a new ZIP archive at `zipPath`. + * Names are archive-relative; duplicates are rejected. + */ +export function writeStoredZip(zipPath, entries) { + const names = new Set(); + const central = []; + let offset = 0; + const outFd = fs.openSync(zipPath, 'w'); + try { + for (const { name, source } of entries) { + if (!name || names.has(name)) throw new Error(`Invalid or duplicate ZIP entry name: ${name}`); + names.add(name); + const nameBytes = Buffer.from(name, 'utf8'); + const size = fs.statSync(source).size; + const crc = crc32File(source); + if (size > maxZip32 || offset > maxZip32) throw new Error('ZIP archive exceeds 4 GB'); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0x0800, 6); // UTF-8 names + local.writeUInt16LE(0, 8); // stored + local.writeUInt16LE(0, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(size, 18); + local.writeUInt32LE(size, 22); + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); + fs.writeSync(outFd, local); + fs.writeSync(outFd, nameBytes); + copyInto(outFd, source); + + const header = Buffer.alloc(46); + header.writeUInt32LE(0x02014b50, 0); + header.writeUInt16LE(20, 4); // version made by + header.writeUInt16LE(20, 6); // version needed + header.writeUInt16LE(0x0800, 8); + header.writeUInt16LE(0, 10); + header.writeUInt16LE(0, 12); + header.writeUInt16LE(dosDate, 14); + header.writeUInt32LE(crc, 16); + header.writeUInt32LE(size, 20); + header.writeUInt32LE(size, 24); + header.writeUInt16LE(nameBytes.length, 28); + header.writeUInt32LE(offset, 42); + central.push(header, nameBytes); + offset += local.length + nameBytes.length + size; + } + const directory = Buffer.concat(central); + if (offset + directory.length > maxZip32) throw new Error('ZIP archive exceeds 4 GB'); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + fs.writeSync(outFd, directory); + fs.writeSync(outFd, end); + } finally { + fs.closeSync(outFd); + } +} diff --git a/scripts/tests/google_play_listings.test.mjs b/scripts/tests/google_play_listings.test.mjs index 844ca2b0..bc4272a3 100644 --- a/scripts/tests/google_play_listings.test.mjs +++ b/scripts/tests/google_play_listings.test.mjs @@ -33,31 +33,34 @@ function mockApi({ listings = [english], failLanguage, missingId = false } = {}) return { calls, fetchImpl }; } -test('prepares both descriptions in one uncommitted edit, preserving titles/video', async () => { +test('prepares every description in one uncommitted edit, preserving titles/video', async () => { const vietnamese = { language: 'vi', title: 'Tên hiện có', video: 'https://example.test/vi' }; - const unrelated = { language: 'tr-TR', title: 'Keep Turkish' }; + const unrelated = { language: 'ja-JP', title: 'Keep Japanese' }; const current = [english, vietnamese, unrelated]; const before = structuredClone(current); const content = loadGooglePlayListings(); const mock = mockApi({ listings: current }); assert.equal(await prepareGooglePlayListings({ ...options, ...mock, content }), 'edit 123'); - assert.deepEqual(mock.calls.map((call) => call.method), ['POST', 'GET', 'PUT', 'PUT']); - for (const [index, language] of ['en-US', 'vi'].entries()) { + assert.deepEqual(mock.calls.map((call) => call.method), ['POST', 'GET', 'PUT', 'PUT', 'PUT', 'PUT']); + for (const [index, language] of ['en-US', 'tr-TR', 'vi', 'zh-CN'].entries()) { const call = mock.calls[index + 2]; assert.ok(call.url.endsWith(`/edits/edit%20123/listings/${language}`)); - assert.deepEqual(call.body, { ...current[index], ...content[language] }); + const existing = current.find((entry) => entry.language === language); + assert.deepEqual(call.body, { ...existing, language, title: existing?.title ?? english.title, ...content[language] }); } assert.deepEqual(current, before); assert.ok(mock.calls.every((call) => !call.url.includes(':commit') && !call.url.includes('images'))); }); -test('new Vietnamese listing inherits only the existing product title', async () => { +test('new language listings inherit only the existing product title', async () => { const mock = mockApi(); await prepareGooglePlayListings({ ...options, ...mock, content: loadGooglePlayListings() }); - const vi = mock.calls.at(-1).body; - assert.equal(vi.title, english.title); - assert.equal(vi.language, 'vi'); - assert.equal(vi.video, undefined); + for (const language of ['tr-TR', 'vi', 'zh-CN']) { + const body = mock.calls.find((call) => call.url.endsWith(`/listings/${language}`)).body; + assert.equal(body.title, english.title); + assert.equal(body.language, language); + assert.equal(body.video, undefined); + } }); test('failed listing update discards the whole edit instead of leaving partial changes', async () => { @@ -102,7 +105,7 @@ test('prepare CLI validates copy before auth and produces an edit id with mocked if (String(url) === 'https://oauth2.googleapis.com/token') return Response.json({ access_token: 'fake-token' }); if (options.method === 'POST' && String(url).endsWith('/edits')) return Response.json({ id: 'prepared' }); if (options.method === 'GET' && String(url).endsWith('/listings')) return Response.json({ listings: [${JSON.stringify(english)}] }); - if (options.method === 'PUT' && /\\/listings\\/(en-US|vi)$/.test(String(url))) return Response.json({}); + if (options.method === 'PUT' && /\\/listings\\/(en-US|tr-TR|vi|zh-CN)$/.test(String(url))) return Response.json({}); throw new Error('Unexpected mocked request: ' + url); }; `); @@ -120,7 +123,7 @@ test('prepare CLI validates copy before auth and produces an edit id with mocked assert.equal(prepared.status, 0, prepared.stderr); assert.equal(prepared.stdout.trim(), 'prepared'); const calls = JSON.parse(fs.readFileSync(requestsPath, 'utf8')); - assert.deepEqual(calls.map((call) => call.method), ['POST', 'POST', 'GET', 'PUT', 'PUT']); + assert.deepEqual(calls.map((call) => call.method), ['POST', 'POST', 'GET', 'PUT', 'PUT', 'PUT', 'PUT']); fs.unlinkSync(requestsPath); fs.mkdirSync(path.join(directory, 'invalid')); diff --git a/scripts/tests/store_listings.test.mjs b/scripts/tests/store_listings.test.mjs index bb40703e..eccb36ba 100644 --- a/scripts/tests/store_listings.test.mjs +++ b/scripts/tests/store_listings.test.mjs @@ -2,24 +2,64 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import zlib from 'node:zlib'; import { spawnSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; -import { loadStoreListings, loadGooglePlayListings, applyStoreListings } from '../store_listings.mjs'; +import { + loadStoreListings, loadGooglePlayListings, applyStoreListings, requiredStoreLocales, +} from '../store_listings.mjs'; +import { writeStoredZip } from '../stored_zip.mjs'; const repo = fileURLToPath(new URL('../../', import.meta.url)); const englishBase = { title: 'CB FileHub', description: 'Old copy', features: ['Old feature'], privacyPolicy: 'https://example.test/privacy', licenseTerms: 'Existing terms', supportContact: 'support@example.test', releaseNotes: 'Current release notes', - images: [{ id: 'keep-image', fileName: 'browser.png', fileStatus: 'Uploaded', imageType: 'Screenshot' }], + images: [ + { id: 'old-shot', fileName: 'browser.png', fileStatus: 'Uploaded', imageType: 'Screenshot' }, + { id: 'keep-icon', fileName: 'icon.png', fileStatus: 'Uploaded', imageType: 'Icon' }, + ], }; -test('both checked-in listings cover the app and meet Store text limits', () => { +function makePng(width, height) { + const png = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png); + png.writeUInt32BE(13, 8); + png.write('IHDR', 12, 'ascii'); + png.writeUInt32BE(width, 16); + png.writeUInt32BE(height, 20); + return png; +} + +function readZip(file) { + const data = fs.readFileSync(file); + const end = data.length - 22; + assert.equal(data.readUInt32LE(end), 0x06054b50); + const count = data.readUInt16LE(end + 10); + let offset = data.readUInt32LE(end + 16); + const entries = {}; + for (let index = 0; index < count; index += 1) { + assert.equal(data.readUInt32LE(offset), 0x02014b50); + const crc = data.readUInt32LE(offset + 16); + const size = data.readUInt32LE(offset + 24); + const nameLength = data.readUInt16LE(offset + 28); + const local = data.readUInt32LE(offset + 42); + const name = data.toString('utf8', offset + 46, offset + 46 + nameLength); + const start = local + 30 + data.readUInt16LE(local + 26); + const content = data.subarray(start, start + size); + assert.equal(zlib.crc32(content), crc, name); + entries[name] = content; + offset += 46 + nameLength; + } + return entries; +} + +test('checked-in listings cover every app language and meet Store text limits', () => { const content = loadStoreListings(); const play = loadGooglePlayListings(); - assert.deepEqual(Object.keys(content), ['en-us', 'vi-vn']); - assert.deepEqual(Object.keys(play), ['en-US', 'vi']); + assert.deepEqual(Object.keys(content).sort(), [...requiredStoreLocales].sort()); + assert.deepEqual(Object.keys(play).sort(), ['en-US', 'tr-TR', 'vi', 'zh-CN']); assert.equal(play['en-US'].fullDescription, content['en-us'].description); assert.equal(play.vi.fullDescription, content['vi-vn'].description); for (const listing of Object.values(content)) { @@ -28,57 +68,140 @@ test('both checked-in listings cover the app and meet Store text limits', () => assert.ok(listing.description.includes(capability), capability); } assert.equal(listing.features.length, 12); - assert.deepEqual(listing.features, listing.description.split(/\r?\n/) + assert.ok(!listing.description.includes('\r')); + assert.deepEqual(listing.features, listing.description.split('\n') .filter((line) => line.startsWith('- ')).map((line) => line.slice(2))); assert.ok(listing.description.length < 2000, 'Keep the checked-in copy concise'); } }); -test('updates both descriptions, preserves Store assets/legal fields and unrelated locales', () => { +test('MSIX manifest declares exactly the Store listing languages', () => { + const pubspec = fs.readFileSync(path.join(repo, 'cb_file_manager/pubspec.yaml'), 'utf8'); + const line = pubspec.match(/^msix_config:[\s\S]*?^\s+languages:\s*(.+)$/m); + assert.ok(line, 'msix_config.languages is missing'); + const languages = line[1].split(',').map((language) => language.trim()).sort(); + assert.deepEqual(languages, [...requiredStoreLocales].sort()); +}); + +test('every listing ships localized screenshots and the shared hero art', () => { + const content = loadStoreListings(); + for (const [locale, { images }] of Object.entries(content)) { + const screenshots = images.filter((image) => image.imageType === 'Screenshot'); + assert.equal(screenshots.length, 10, locale); + assert.ok(screenshots.every((image) => image.description.length > 0), locale); + const names = screenshots.map((image) => path.basename(image.source)); + assert.ok(names.some((name) => name.startsWith('11_')), `${locale} shows the torrent library`); + assert.ok(names.some((name) => name.startsWith('12_')), `${locale} shows tag assignment`); + assert.deepEqual(images.filter((image) => image.imageType === 'PromotionalArt16x9') + .map((image) => path.basename(image.source)), ['hero_16x9.png']); + for (const image of images) { + assert.match(image.fileName, new RegExp(`^store-${locale}-\\d{2}-[0-9a-f]{12}\\.png$`)); + } + } + const english = content['en-us'].images.map((image) => image.source); + for (const locale of ['vi-vn', 'tr-tr', 'zh-cn']) { + const localized = content[locale].images.filter((image) => image.imageType === 'Screenshot'); + assert.ok(localized.every((image) => !english.includes(image.source)), `${locale} uses its own screenshots`); + } +}); + +test('updates text, replaces managed images and preserves other Store data', () => { const input = { id: 'draft', targetPublishMode: 'Manual', listings: { 'en-us': { baseListing: englishBase, platformOverrides: { Windows81: { title: 'Platform title' } } }, 'vi-vn': { baseListing: { ...englishBase, title: 'Existing Vietnamese title', privacyPolicy: 'https://example.test/vi' }, platformOverrides: {} }, - 'tr-tr': { baseListing: { title: 'Turkish', description: 'Keep this' } }, + 'ja-jp': { baseListing: { title: 'Japanese', description: 'Keep this' } }, }, }; const before = structuredClone(input); const content = loadStoreListings(); - const output = applyStoreListings(input, content); + const { submission: output, uploads } = applyStoreListings(input, content); assert.deepEqual(input, before); for (const locale of ['en-us', 'vi-vn']) { - assert.equal(output.listings[locale].baseListing.description, content[locale].description); - assert.deepEqual(output.listings[locale].baseListing.features, content[locale].features); - for (const key of ['title', 'images', 'privacyPolicy', 'licenseTerms', 'supportContact', 'releaseNotes']) { - assert.deepEqual(output.listings[locale].baseListing[key], input.listings[locale].baseListing[key]); + const listing = output.listings[locale].baseListing; + assert.equal(listing.description, content[locale].description); + assert.deepEqual(listing.features, content[locale].features); + for (const key of ['title', 'privacyPolicy', 'licenseTerms', 'supportContact', 'releaseNotes']) { + assert.deepEqual(listing[key], input.listings[locale].baseListing[key]); } assert.deepEqual(output.listings[locale].platformOverrides, input.listings[locale].platformOverrides); + assert.deepEqual(listing.images.slice(0, 2), [ + { ...englishBase.images[0], fileStatus: 'PendingDelete' }, + englishBase.images[1], + ]); + assert.deepEqual(listing.images.slice(2), content[locale].images.map((image) => ({ + fileName: image.fileName, fileStatus: 'PendingUpload', + imageType: image.imageType, description: image.description, + }))); + } + assert.deepEqual(output.listings['ja-jp'], input.listings['ja-jp']); + const expectedUploads = Object.values(content).flatMap((listing) => listing.images); + assert.deepEqual(uploads, expectedUploads); +}); + +test('keeps already uploaded images when their content-addressed names match', () => { + const content = { 'en-us': loadStoreListings()['en-us'] }; + const uploaded = content['en-us'].images.map((image, index) => ({ + id: `img-${index}`, fileName: image.fileName, fileStatus: 'Uploaded', + imageType: image.imageType, description: 'Old caption', + })); + const input = { listings: { 'en-us': { baseListing: { ...englishBase, images: [englishBase.images[1], ...uploaded] } } } }; + const { submission, uploads } = applyStoreListings(input, content); + assert.deepEqual(uploads, []); + const images = submission.listings['en-us'].baseListing.images; + assert.deepEqual(images[0], englishBase.images[1]); + assert.deepEqual(images.slice(1).map((image) => [image.id, image.fileStatus, image.description]), + content['en-us'].images.map((image, index) => [`img-${index}`, 'Uploaded', image.description])); + + // A draft whose upload never finished must upload again. + const pending = structuredClone(input); + pending.listings['en-us'].baseListing.images[1].fileStatus = 'PendingUpload'; + assert.equal(applyStoreListings(pending, content).uploads.length, content['en-us'].images.length); +}); + +test('seeds new languages from existing metadata without copying other listings images', () => { + const content = loadStoreListings(); + const { submission } = applyStoreListings({ listings: { 'en-us': { baseListing: englishBase } } }, content); + for (const locale of ['vi-vn', 'tr-tr', 'zh-cn']) { + const listing = submission.listings[locale].baseListing; + assert.equal(listing.title, englishBase.title); + assert.equal(listing.description, content[locale].description); + assert.ok(listing.images.every((image) => image.fileStatus === 'PendingUpload' && !image.id)); + assert.equal(listing.images.length, content[locale].images.length); } - assert.deepEqual(output.listings['tr-tr'], input.listings['tr-tr']); + assert.throws(() => applyStoreListings({ listings: {} }, content), /initial listing/); }); -test('seeds a new Vietnamese listing from existing Store metadata', () => { - const output = applyStoreListings({ listings: { 'en-us': { baseListing: englishBase } } }, loadStoreListings()); - assert.equal(output.listings['vi-vn'].baseListing.title, englishBase.title); - assert.deepEqual(output.listings['vi-vn'].baseListing.images, englishBase.images); - assert.notEqual(output.listings['vi-vn'].baseListing.description, englishBase.description); - assert.throws(() => applyStoreListings({ listings: {} }, loadStoreListings()), /initial listing/); +test('leaves Store images untouched when image updates are disabled', () => { + const content = loadStoreListings(undefined, { images: false }); + assert.ok(Object.values(content).every((listing) => !('images' in listing))); + const { submission, uploads } = applyStoreListings({ listings: { 'en-us': { baseListing: englishBase } } }, content); + assert.deepEqual(uploads, []); + assert.deepEqual(submission.listings['en-us'].baseListing.images, englishBase.images); + assert.deepEqual(submission.listings['vi-vn'].baseListing.images, []); }); -test('rejects missing locales, overlong text/features and unsafe description paths', (t) => { +test('rejects missing locales, overlong text/features, unsafe paths and invalid images', (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-store-copy-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + fs.writeFileSync(path.join(directory, 'shot.png'), makePng(2400, 1350)); + fs.writeFileSync(path.join(directory, 'small.png'), makePng(800, 600)); + fs.writeFileSync(path.join(directory, 'hero.png'), makePng(1920, 1080)); + fs.writeFileSync(path.join(directory, 'fake.png'), 'not a png'); + const playCodes = { 'en-us': 'en-US', 'vi-vn': 'vi', 'tr-tr': 'tr-TR', 'zh-cn': 'zh-CN' }; + const shot = { file: 'shot.png', imageType: 'Screenshot', description: 'Caption' }; const write = (locale, overrides = {}) => { fs.writeFileSync(path.join(directory, `${locale}.txt`), 'Description\n\n- Feature'); fs.writeFileSync(path.join(directory, `${locale}.json`), JSON.stringify({ descriptionFile: `${locale}.txt`, shortDescription: 'Short description', - googlePlayLanguage: locale === 'en-us' ? 'en-US' : 'vi', ...overrides, + googlePlayLanguage: playCodes[locale], microsoftStoreImages: [shot], ...overrides, })); }; write('en-us'); - assert.throws(() => loadStoreListings(directory), /vi-vn/); - write('vi-vn'); + assert.throws(() => loadStoreListings(directory), /Missing required Store listing/); + for (const locale of requiredStoreLocales) write(locale); + assert.equal(Object.keys(loadStoreListings(directory)).length, 4); write('en-us', { descriptionFile: '../secret.txt' }); assert.throws(() => loadStoreListings(directory), /sibling/); write('en-us'); @@ -86,7 +209,6 @@ test('rejects missing locales, overlong text/features and unsafe description pat assert.throws(() => loadStoreListings(directory), /1–20 features/); fs.writeFileSync(path.join(directory, 'en-us.txt'), `- ${'x'.repeat(201)}`); assert.throws(() => loadStoreListings(directory), /1–200 characters/); - write('en-us'); write('en-us', { shortDescription: 'x'.repeat(81) }); assert.throws(() => loadStoreListings(directory), /1–80 characters/); write('en-us', { googlePlayLanguage: 'vi' }); @@ -96,18 +218,58 @@ test('rejects missing locales, overlong text/features and unsafe description pat assert.throws(() => loadStoreListings(directory), /1–4000 characters/); fs.writeFileSync(path.join(directory, 'en-us.txt'), `${'x'.repeat(3990)}\n- Feature`); assert.equal(loadGooglePlayListings(directory)['en-US'].fullDescription.length, 4000); + + const imageCases = [ + [[], /at least one image/], + [[{ ...shot, imageType: 'Poster' }], /imageType must be one of/], + [[{ ...shot, file: 'missing.png' }], /not found/], + [[{ ...shot, file: 'fake.png' }], /not a PNG/], + [[{ ...shot, file: 'small.png' }], /at least 1366x768/], + [[shot, { file: 'shot.png', imageType: 'PromotionalArt16x9' }], /1920x1080 or 3840x2160/], + [[{ file: 'hero.png', imageType: 'PromotionalArt16x9' }], /at least one Screenshot/], + [Array(11).fill(shot), /at most 10 Screenshot/], + [[{ ...shot, description: 'x'.repeat(201) }], /at most 200 characters/], + ]; + for (const [images, error] of imageCases) { + write('en-us', { microsoftStoreImages: images }); + assert.throws(() => loadStoreListings(directory), error); + } + write('en-us', { microsoftStoreImages: [shot, { file: 'hero.png', imageType: 'PromotionalArt16x9' }] }); + assert.equal(loadStoreListings(directory)['en-us'].images.length, 2); }); -test('submit CLI sends bilingual copy in its PUT and retains package/commit behavior without network', (t) => { +test('stored ZIP round-trips entries with valid CRCs', (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-store-zip-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + fs.writeFileSync(path.join(directory, 'a.bin'), Buffer.alloc(3 * 1024 * 1024 + 7, 0xab)); + fs.writeFileSync(path.join(directory, 'b.txt'), 'hello'); + const zip = path.join(directory, 'out.zip'); + writeStoredZip(zip, [ + { name: 'package.msix', source: path.join(directory, 'a.bin') }, + { name: 'store-vi-vn-01-abc.png', source: path.join(directory, 'b.txt') }, + ]); + const entries = readZip(zip); + assert.deepEqual(Object.keys(entries), ['package.msix', 'store-vi-vn-01-abc.png']); + assert.equal(entries['package.msix'].length, 3 * 1024 * 1024 + 7); + assert.equal(entries['store-vi-vn-01-abc.png'].toString(), 'hello'); + assert.throws(() => writeStoredZip(zip, [ + { name: 'x', source: path.join(directory, 'b.txt') }, + { name: 'x', source: path.join(directory, 'b.txt') }, + ]), /duplicate/); +}); + +function runSubmit(t, { commitStatus = 'PreProcessing', statusDetails = {} } = {}) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-store-submit-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); - const archive = path.join(directory, 'package.zip'); + const msix = path.join(directory, 'package.msix'); const result = path.join(directory, 'requests.json'); + const uploaded = path.join(directory, 'uploaded.zip'); const mock = path.join(directory, 'mock.mjs'); - fs.writeFileSync(archive, 'test archive'); + fs.writeFileSync(msix, 'test package'); fs.writeFileSync(mock, ` import fs from 'node:fs'; const calls = []; + let statusPolls = 0; globalThis.fetch = async (url, options = {}) => { const method = options.method || 'GET'; const request = { url: String(url), method }; @@ -124,10 +286,18 @@ test('submit CLI sends bilingual copy in its PUT and retains package/commit beha }; else if (method === 'PUT' && String(url).endsWith('/submissions/draft')) response = {}; else if (method === 'PUT' && String(url) === 'https://example.test/upload') { - for await (const chunk of options.body) {}; + const chunks = []; + for await (const chunk of options.body) chunks.push(chunk); + fs.writeFileSync(process.env.TEST_UPLOADED, Buffer.concat(chunks)); response = {}; } - else if (method === 'POST' && String(url).endsWith('/commit')) response = {}; + else if (method === 'POST' && String(url).endsWith('/commit')) response = { status: 'CommitStarted' }; + else if (method === 'GET' && String(url).endsWith('/submissions/draft/status')) { + statusPolls += 1; + response = statusPolls === 1 + ? { status: 'CommitStarted' } + : { status: ${JSON.stringify(commitStatus)}, statusDetails: ${JSON.stringify(statusDetails)} }; + } else throw new Error('Unexpected mock request: ' + method + ' ' + url); return new Response(JSON.stringify(response), { status: 200 }); }; @@ -137,21 +307,58 @@ test('submit CLI sends bilingual copy in its PUT and retains package/commit beha env: { ...process.env, PARTNER_CENTER_TENANT_ID: 'tenant', PARTNER_CENTER_CLIENT_ID: 'client', PARTNER_CENTER_CLIENT_SECRET: 'test-only', PARTNER_CENTER_APP_ID: 'app', - PARTNER_CENTER_PACKAGE_ARCHIVE: archive, PARTNER_CENTER_PACKAGE_FILE_NAME: 'new.msix', + PARTNER_CENTER_PACKAGE_PATH: msix, PARTNER_CENTER_PACKAGE_FILE_NAME: 'new.msix', PARTNER_CENTER_LISTINGS_DIR: path.join(repo, 'store/listings'), PARTNER_CENTER_COMMIT_SUBMISSION: 'true', PARTNER_CENTER_TARGET_PUBLISH_MODE: 'Immediate', - TEST_REQUESTS: result, + PARTNER_CENTER_COMMIT_POLL_SECONDS: '0', RUNNER_TEMP: directory, + TEST_REQUESTS: result, TEST_UPLOADED: uploaded, }, }); + return { child, calls: JSON.parse(fs.readFileSync(result, 'utf8')), uploaded, directory }; +} + +test('submit CLI sends listings, uploads package and images in one ZIP, then waits for commit', (t) => { + const { child, calls, uploaded, directory } = runSubmit(t, { + statusDetails: { warnings: [{ code: 'ListingOptInWarning', details: 'Added listings' }] }, + }); assert.equal(child.status, 0, child.stderr); assert.equal(child.stdout.trim(), 'draft'); - const calls = JSON.parse(fs.readFileSync(result, 'utf8')); + assert.match(child.stderr, /ListingOptInWarning/); + assert.match(child.stderr, /status: PreProcessing/); const payload = calls.find((call) => call.method === 'PUT' && call.url.endsWith('/submissions/draft')).body; assert.equal(payload.targetPublishMode, 'Immediate'); assert.equal(payload.applicationPackages[0].fileStatus, 'PendingDelete'); assert.equal(payload.applicationPackages[1].fileName, 'new.msix'); assert.equal(payload.applicationPackages[1].fileStatus, 'PendingUpload'); - assert.equal(payload.listings['vi-vn'].baseListing.description, loadStoreListings()['vi-vn'].description); - assert.deepEqual(payload.listings['en-us'].baseListing.images, englishBase.images); + const content = loadStoreListings(); + for (const locale of requiredStoreLocales) { + assert.equal(payload.listings[locale].baseListing.description, content[locale].description); + } + assert.deepEqual(payload.listings['en-us'].baseListing.images.slice(0, 2), [ + { ...englishBase.images[0], fileStatus: 'PendingDelete' }, englishBase.images[1], + ]); + + const entries = readZip(uploaded); + const expected = ['new.msix', ...Object.values(content).flatMap((listing) => listing.images.map((image) => image.fileName))]; + assert.deepEqual(Object.keys(entries), expected); + assert.equal(entries['new.msix'].toString(), 'test package'); + const firstImage = content['zh-cn'].images[0]; + assert.ok(entries[firstImage.fileName].equals(fs.readFileSync(firstImage.source))); + for (const listing of Object.values(payload.listings)) { + for (const image of listing.baseListing.images.filter((entry) => entry.fileStatus === 'PendingUpload')) { + assert.ok(entries[image.fileName], image.fileName); + } + } assert.ok(calls.some((call) => call.url.endsWith('/commit'))); + assert.equal(calls.filter((call) => call.url.endsWith('/status')).length, 2); + assert.ok(!fs.readdirSync(directory).some((name) => name.startsWith('partner-center-')), 'temporary ZIP is removed'); +}); + +test('submit CLI fails when Partner Center rejects the commit', (t) => { + const { child } = runSubmit(t, { + commitStatus: 'CommitFailed', + statusDetails: { errors: [{ code: 'InvalidParameterValue', details: 'Image size is invalid' }] }, + }); + assert.equal(child.status, 1); + assert.match(child.stderr, /failed to commit: InvalidParameterValue: Image size is invalid/); }); diff --git a/store/README.md b/store/README.md index 4f598ff6..c787b0d6 100644 --- a/store/README.md +++ b/store/README.md @@ -1,31 +1,68 @@ -# Shared app descriptions +# Shared app listings -Microsoft Store and Google Play use one description per language: +Microsoft Store and Google Play use one description per app language: -| Language | Description and feature bullets | Short description and locale | +| Language | Description and feature bullets | Short description, locale and Store images | |---|---|---| | English | [listings/en-us.txt](listings/en-us.txt) | [listings/en-us.json](listings/en-us.json) | | Vietnamese | [listings/vi-vn.txt](listings/vi-vn.txt) | [listings/vi-vn.json](listings/vi-vn.json) | +| Turkish | [listings/tr-tr.txt](listings/tr-tr.txt) | [listings/tr-tr.json](listings/tr-tr.json) | +| Simplified Chinese | [listings/zh-cn.txt](listings/zh-cn.txt) | [listings/zh-cn.json](listings/zh-cn.json) | + +These match the app's UI languages and the MSIX manifest +(`msix_config.languages` in `cb_file_manager/pubspec.yaml`); a test fails if +they drift apart. To add a language, add its `.txt`/`.json`, its promo images, +the locale to `requiredStoreLocales` in `scripts/store_listings.mjs` and to +`msix_config.languages`. Edit the `.txt` files to describe new features. Keep the introduction brief and use one `- ` bullet per feature group. Microsoft Store's feature list comes from these bullets; Google Play's full description uses the same complete text. -The `.json` files contain the short description, text filename and Play language -code (`en-US` / `vi`). Microsoft locales remain `en-us` / `vi-vn`. + +Each `.json` file contains: + +- `descriptionFile`: the sibling `.txt`. +- `shortDescription`: Google Play short description. +- `googlePlayLanguage`: Play language code (`en-US`, `vi`, `tr-TR`, `zh-CN`). + Microsoft locales are the file names (`en-us`, `vi-vn`, `tr-tr`, `zh-cn`). +- `microsoftStoreImages`: ordered Microsoft Store images, each with `file` + (PNG path relative to `store/listings/`), `imageType` and an optional + `description` (screenshot caption). The validator enforces: - Full description: 1–4,000 characters, shared by both stores. - Short description: 1–80 characters, used by Google Play. - Feature bullets: 1–20, each at most 200 characters, used by Microsoft Store. +- Images: PNG, at most 50 MB, captions at most 200 characters, at least one + `Screenshot`. Supported types: `Screenshot` (up to 10, 1366x768 to + 3840x2160), `PromotionalArt16x9` (16:9 super hero art, exactly 1920x1080 or + 3840x2160, no text) and `Icon` (300x300). The checked-in copy is about 1,400 characters per language. Describe what users can do in plain language and mark features limited to desktop or Windows. Do not paste the full README into a listing. Limits and payloads follow [Google Play's listing guidance](https://support.google.com/googleplay/android-developer/answer/9859152?hl=en), -the [Play listing API](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.listings/update) -and the [Microsoft submission API](https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions). +the [Play listing API](https://developers.google.com/android-publisher/api-ref/rest/v3/edits.listings/update), +the [Microsoft submission API](https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-app-submissions) +and [Microsoft Store image requirements](https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/screenshots-and-images). + +## Store images + +Screenshots are the desktop promo images from `scripts/make_promo_images.py` +(`screenshots/promo/desktop/`, `vi/desktop/`, `tr/desktop/`, `zh/desktop/`), +captioned with each promo's subtitle. Each listing uses the Store maximum of 10: +file browser, gallery, torrent library (`11_*`, the qBittorrent import dialog +from `screenshots/features/torrents/`), three CB Agent scenes, tag assignment +(`12_*`), tag management, tag tree and disk cleaner. The album detail promo is +kept for the README but left out of the Store. Every language also uses the +text-free hero art `screenshots/promo/store/hero_16x9.png`. Regenerate with: + +```bash +python scripts/make_promo_images.py --desktop-only # all languages +python scripts/make_promo_images.py --desktop-only --languages tr,zh +``` ## Preview and checks @@ -37,23 +74,27 @@ node --test scripts/tests/store_listings.test.mjs scripts/tests/google_play_list ``` CI checks this on pushes and pull requests. The release workflow also uploads -`store-listings-en-vi`, a JSON preview for both stores, before their build jobs. +`store-listings`, a JSON preview for both stores, before their build jobs. ## Release integration -- MSIX: `partner_center_msix.mjs submit` updates English/Vietnamese descriptions - and features in the package submission. Existing metadata and image assets stay - in place. See the [Microsoft Store guide](../docs/guides/02-microsoft-store-submission-guide.md). -- AAB: `google_play_listings.mjs prepare` creates one edit and updates both Play - descriptions. The upload action receives `existingEditId`, adds the bundle and +- MSIX: `partner_center_msix.mjs submit` updates every language's description, + features and images in the package submission, then waits for Partner Center + to finish processing the commit (the job fails on `CommitFailed`). Titles, + image types not listed in the `.json` (for example a manually uploaded + `Icon`) and other metadata stay in place. See the + [Microsoft Store guide](../docs/guides/02-microsoft-store-submission-guide.md). +- AAB: `google_play_listings.mjs prepare` creates one edit and updates every Play + description. The upload action receives `existingEditId`, adds the bundle and commits the combined release. Failed listing updates or uploads discard the - uncommitted edit. `internalsharing` uploads skip listing updates. + uncommitted edit. `internalsharing` uploads skip listing updates. Play + graphics are not uploaded. Google Play uses the existing `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` and `GOOGLE_PLAY_PACKAGE_NAME` configuration. The service account must be allowed to manage the app's store presence as well as release bundles. An initial listing with a product title must exist. Existing localized titles and videos are -preserved; a new Vietnamese listing takes the existing product title. Images, +preserved; a new language listing takes the existing product title. Images, contact details, release notes and unrelated locales are not replaced. `STORE_LISTINGS_DIR` selects another directory for the Google Play script; diff --git a/store/listings/en-us.json b/store/listings/en-us.json index c08b87b8..88105b57 100644 --- a/store/listings/en-us.json +++ b/store/listings/en-us.json @@ -1,5 +1,61 @@ { "descriptionFile": "en-us.txt", "shortDescription": "Manage files, photos, videos, torrents and remote storage in one app.", - "googlePlayLanguage": "en-US" + "googlePlayLanguage": "en-US", + "microsoftStoreImages": [ + { + "file": "../../screenshots/promo/desktop/01_file_browser.png", + "imageType": "Screenshot", + "description": "Local files, tabs, tags, and rich previews in one focused desktop view." + }, + { + "file": "../../screenshots/promo/desktop/02_gallery_hub.png", + "imageType": "Screenshot", + "description": "Scan albums, media folders, and visual collections without losing context." + }, + { + "file": "../../screenshots/promo/desktop/11_torrent_library.png", + "imageType": "Screenshot", + "description": "Import qBittorrent backups and keep .torrent files and magnet links in one searchable library." + }, + { + "file": "../../screenshots/promo/desktop/04_ai_search.png", + "imageType": "Screenshot", + "description": "Find, review, and act on files with an integrated assistant side panel." + }, + { + "file": "../../screenshots/promo/desktop/05_ai_approval.png", + "imageType": "Screenshot", + "description": "Review assistant actions before anything changes in your files." + }, + { + "file": "../../screenshots/promo/desktop/06_ai_conversation.png", + "imageType": "Screenshot", + "description": "Keep a file-aware conversation beside the workspace you are using." + }, + { + "file": "../../screenshots/promo/desktop/12_tag_assignment.png", + "imageType": "Screenshot", + "description": "Pick popular or recent tags, or browse the tag tree, without leaving your files." + }, + { + "file": "../../screenshots/promo/desktop/07_tag_management.png", + "imageType": "Screenshot", + "description": "Build lightweight systems for projects, references, and media collections." + }, + { + "file": "../../screenshots/promo/desktop/08_tagged_files.png", + "imageType": "Screenshot", + "description": "Build parent-child tag hierarchies and expand the exact branch you need." + }, + { + "file": "../../screenshots/promo/desktop/10_disk_cleaner.png", + "imageType": "Screenshot", + "description": "Scan bulky folders, review junk candidates, and ask CB Agent before deleting." + }, + { + "file": "../../screenshots/promo/store/hero_16x9.png", + "imageType": "PromotionalArt16x9" + } + ] } diff --git a/store/listings/tr-tr.json b/store/listings/tr-tr.json new file mode 100644 index 00000000..2726ee34 --- /dev/null +++ b/store/listings/tr-tr.json @@ -0,0 +1,61 @@ +{ + "descriptionFile": "tr-tr.txt", + "shortDescription": "Dosya, fotoğraf, video, torrent ve uzak depolamayı tek uygulamada yönetin.", + "googlePlayLanguage": "tr-TR", + "microsoftStoreImages": [ + { + "file": "../../screenshots/promo/tr/desktop/01_dosya_tarayici.png", + "imageType": "Screenshot", + "description": "Yerel dosyalar, sekmeler, etiketler ve zengin önizlemeler tek bir masaüstü görünümünde." + }, + { + "file": "../../screenshots/promo/tr/desktop/02_galeri_merkezi.png", + "imageType": "Screenshot", + "description": "Albümleri, medya klasörlerini ve görsel koleksiyonları bağlamı kaybetmeden tarayın." + }, + { + "file": "../../screenshots/promo/tr/desktop/11_torrent_kitapligi.png", + "imageType": "Screenshot", + "description": "qBittorrent yedeklerini içe aktarın; .torrent dosyalarını ve magnet bağlantılarını aranabilir tek kitaplıkta tutun." + }, + { + "file": "../../screenshots/promo/tr/desktop/04_yapay_zeka_arama.png", + "imageType": "Screenshot", + "description": "Entegre asistan paneliyle dosyaları bulun, inceleyin ve işlem yapın." + }, + { + "file": "../../screenshots/promo/tr/desktop/05_yapay_zeka_onayi.png", + "imageType": "Screenshot", + "description": "Dosyalarınızda bir şey değişmeden önce asistanın işlemlerini gözden geçirin." + }, + { + "file": "../../screenshots/promo/tr/desktop/06_yapay_zeka_sohbet.png", + "imageType": "Screenshot", + "description": "Çalışma alanınızın yanında dosyaları anlayan bir sohbet sürdürün." + }, + { + "file": "../../screenshots/promo/tr/desktop/12_etiketleme.png", + "imageType": "Screenshot", + "description": "Popüler veya son etiketleri seçin ya da dosyalarınızdan ayrılmadan etiket ağacına göz atın." + }, + { + "file": "../../screenshots/promo/tr/desktop/07_etiket_yonetimi.png", + "imageType": "Screenshot", + "description": "Projeler, kaynaklar ve medya koleksiyonları için hafif sistemler kurun." + }, + { + "file": "../../screenshots/promo/tr/desktop/08_etiket_agaci.png", + "imageType": "Screenshot", + "description": "Üst-alt etiket hiyerarşileri oluşturun ve ihtiyacınız olan dalı açın." + }, + { + "file": "../../screenshots/promo/tr/desktop/10_disk_temizleyici.png", + "imageType": "Screenshot", + "description": "Büyük klasörleri tarayın, gereksiz dosyaları inceleyin ve silmeden önce CB Agent'a sorun." + }, + { + "file": "../../screenshots/promo/store/hero_16x9.png", + "imageType": "PromotionalArt16x9" + } + ] +} diff --git a/store/listings/tr-tr.txt b/store/listings/tr-tr.txt new file mode 100644 index 00000000..3098d765 --- /dev/null +++ b/store/listings/tr-tr.txt @@ -0,0 +1,16 @@ +CB File Hub dosyalarınızı, medyanızı ve uzak depolamanızı tek bir uygulamada bir araya getirir. + +- Klasörlerde sekmelerle gezinin, bölünmüş görünüm kullanın, konumları sabitleyin ve çalışma alanınızı geri yükleyin. +- Liste, ızgara, ayrıntılar, ağaç veya sütun görünümünü seçin. Arayın, sıralayın ve birden çok dosya seçin; büyük klasörler isteğe bağlı yüklenir. +- Fotoğrafları görüntüleyin, resimleri düzenleyin ve video kitaplıklarına göz atın. Medyayı altyazı ve ses parçalarıyla oynatın; masaüstünde resim içinde resim kullanın. +- Koleksiyonları etiketler, albümler ve otomatik albüm kurallarıyla düzenleyin. +- SMB, FTP, SFTP ve WebDAV depolamaya bağlanın. SSH bağlantılarını yönetin ve yerleşik terminali kullanın. +- .torrent dosyalarını ve magnet bağlantılarını aranabilir tek bir kitaplıkta tutun. Yinelenenleri birleştirin, magnet bağlantılarını kopyalayın ve dosyaların nerede saklanacağını seçin. +- Masaüstünde torrent indirin, hedef klasör seçin, duraklatın ve sürdürün. Windows'ta qBittorrent yedekleri dahil diğer istemcilerdeki kayıtlı torrentleri içe aktarın. +- Dosyaları bulmak ve düzenlemek için CB Agent'tan yardım isteyin. Uyumlu yerel GGUF modelleri masaüstünde isteğe bağlıdır. +- Windows disk temizleyicisiyle gereksiz dosyaları inceleyin ve kaldırın. +- ZIP ve desteklenen diğer arşivlere göz atın ve bunları çıkarın. Ek 7z/RAR araçları Windows'ta yüklü 7-Zip'i kullanır. +- Ayarları ve etiketleri yedekleyin; Google Drive, Dropbox ve OneDrive entegrasyonları kullanılabilir. +- Açık veya koyu tema, vurgu rengi ve arayüz dilinizi seçin. + +Özellikler platforma göre değişir. Ağ, bulut yedekleme ve yapay zekâ hizmetleri hesap veya ek kurulum gerektirebilir. diff --git a/store/listings/vi-vn.json b/store/listings/vi-vn.json index 6d8a3ca1..43874c1c 100644 --- a/store/listings/vi-vn.json +++ b/store/listings/vi-vn.json @@ -1,5 +1,61 @@ { "descriptionFile": "vi-vn.txt", "shortDescription": "Quản lý tập tin, ảnh, video, torrent và lưu trữ mạng trong một ứng dụng.", - "googlePlayLanguage": "vi" + "googlePlayLanguage": "vi", + "microsoftStoreImages": [ + { + "file": "../../screenshots/promo/vi/desktop/01_duyet_tap_tin.png", + "imageType": "Screenshot", + "description": "Tập tin cục bộ, tab, tag và xem trước nhanh trong một không gian gọn gàng." + }, + { + "file": "../../screenshots/promo/vi/desktop/02_thu_vien_anh.png", + "imageType": "Screenshot", + "description": "Quét album, thư mục media và bộ sưu tập hình ảnh mà không mất ngữ cảnh." + }, + { + "file": "../../screenshots/promo/vi/desktop/11_thu_vien_torrent.png", + "imageType": "Screenshot", + "description": "Nhập bản sao lưu qBittorrent, gom file .torrent và magnet vào một thư viện có tìm kiếm." + }, + { + "file": "../../screenshots/promo/vi/desktop/04_tim_kiem_ai.png", + "imageType": "Screenshot", + "description": "Tìm, xem lại và thao tác với tập tin bằng bảng trợ lý tích hợp." + }, + { + "file": "../../screenshots/promo/vi/desktop/05_duyet_thao_tac_ai.png", + "imageType": "Screenshot", + "description": "Xem lại hành động của trợ lý trước khi bất kỳ thay đổi nào diễn ra." + }, + { + "file": "../../screenshots/promo/vi/desktop/06_hoi_dap_ai.png", + "imageType": "Screenshot", + "description": "Trò chuyện với trợ lý hiểu ngữ cảnh ngay bên cạnh workspace." + }, + { + "file": "../../screenshots/promo/vi/desktop/12_gan_tag.png", + "imageType": "Screenshot", + "description": "Chọn tag phổ biến, tag gần đây hoặc duyệt cây tag ngay bên cạnh tập tin." + }, + { + "file": "../../screenshots/promo/vi/desktop/07_quan_ly_tag.png", + "imageType": "Screenshot", + "description": "Tạo hệ thống nhẹ cho dự án, tài liệu tham khảo và bộ sưu tập media." + }, + { + "file": "../../screenshots/promo/vi/desktop/08_tap_tin_theo_tag.png", + "imageType": "Screenshot", + "description": "Tạo phân cấp tag cha con và mở đúng nhánh bạn đang cần." + }, + { + "file": "../../screenshots/promo/vi/desktop/10_don_dep_o_dia.png", + "imageType": "Screenshot", + "description": "Quét thư mục nặng, xem lại tệp rác và hỏi CB Agent trước khi xóa." + }, + { + "file": "../../screenshots/promo/store/hero_16x9.png", + "imageType": "PromotionalArt16x9" + } + ] } diff --git a/store/listings/zh-cn.json b/store/listings/zh-cn.json new file mode 100644 index 00000000..0683503e --- /dev/null +++ b/store/listings/zh-cn.json @@ -0,0 +1,61 @@ +{ + "descriptionFile": "zh-cn.txt", + "shortDescription": "在一个应用中管理文件、照片、视频、种子和远程存储。", + "googlePlayLanguage": "zh-CN", + "microsoftStoreImages": [ + { + "file": "../../screenshots/promo/zh/desktop/01_liulan_wenjian.png", + "imageType": "Screenshot", + "description": "本地文件、标签页、标签和丰富预览,尽在一个专注的桌面视图中。" + }, + { + "file": "../../screenshots/promo/zh/desktop/02_tuku.png", + "imageType": "Screenshot", + "description": "浏览相册、媒体文件夹和图片收藏,始终不丢失上下文。" + }, + { + "file": "../../screenshots/promo/zh/desktop/11_zhongzi_ku.png", + "imageType": "Screenshot", + "description": "导入 qBittorrent 备份,将 .torrent 文件和磁力链接集中到一个可搜索的库中。" + }, + { + "file": "../../screenshots/promo/zh/desktop/04_ai_sousuo.png", + "imageType": "Screenshot", + "description": "通过集成的助手侧边栏查找、查看并处理文件。" + }, + { + "file": "../../screenshots/promo/zh/desktop/05_ai_shenhe.png", + "imageType": "Screenshot", + "description": "在文件发生任何更改之前,先审核助手的操作。" + }, + { + "file": "../../screenshots/promo/zh/desktop/06_ai_duihua.png", + "imageType": "Screenshot", + "description": "在当前工作区旁,与理解文件的助手持续对话。" + }, + { + "file": "../../screenshots/promo/zh/desktop/12_biaoqian_fenpei.png", + "imageType": "Screenshot", + "description": "选择常用或最近的标签,或直接浏览标签树,无需离开文件。" + }, + { + "file": "../../screenshots/promo/zh/desktop/07_biaoqian_guanli.png", + "imageType": "Screenshot", + "description": "为项目、参考资料和媒体收藏建立轻量的整理体系。" + }, + { + "file": "../../screenshots/promo/zh/desktop/08_biaoqian_shu.png", + "imageType": "Screenshot", + "description": "建立父子标签层级,展开你需要的那一个分支。" + }, + { + "file": "../../screenshots/promo/zh/desktop/10_cipan_qingli.png", + "imageType": "Screenshot", + "description": "扫描占用空间的文件夹,检查可清理的文件,删除前先询问 CB Agent。" + }, + { + "file": "../../screenshots/promo/store/hero_16x9.png", + "imageType": "PromotionalArt16x9" + } + ] +} diff --git a/store/listings/zh-cn.txt b/store/listings/zh-cn.txt new file mode 100644 index 00000000..917ca38f --- /dev/null +++ b/store/listings/zh-cn.txt @@ -0,0 +1,16 @@ +CB File Hub 将你的文件、媒体和远程存储汇集到一个应用中。 + +- 使用标签页浏览文件夹,支持分屏视图、固定常用位置并恢复工作区。 +- 可选列表、网格、详细信息、树形或分栏视图。支持搜索、排序和多选文件,大型文件夹按需加载。 +- 查看照片、编辑图片并浏览视频库。播放媒体时支持字幕和音轨;桌面端支持画中画。 +- 使用标签、相册和自动相册规则整理收藏。 +- 连接 SMB、FTP、SFTP 和 WebDAV 存储。管理 SSH 连接并使用内置终端。 +- 将 .torrent 文件和磁力链接集中到一个可搜索的库中。合并重复项、复制磁力链接并选择文件的存储位置。 +- 在桌面端下载种子,选择保存位置,暂停和继续。从其他客户端导入已保存的种子,包括 Windows 上的 qBittorrent 备份。 +- 让 CB Agent 帮你查找和整理文件。桌面端可选使用兼容的本地 GGUF 模型。 +- 使用 Windows 磁盘清理工具检查并删除不需要的文件。 +- 浏览并解压 ZIP 及其他受支持的压缩包。额外的 7z/RAR 功能使用 Windows 上已安装的 7-Zip。 +- 备份设置和标签,可使用 Google Drive、Dropbox 和 OneDrive 集成。 +- 选择浅色或深色主题、强调色以及界面语言。 + +功能因平台而异。网络、云备份和 AI 服务可能需要账户或额外设置。