1.2.3 · Dev Platform

How to Localize App Screenshots Without a Mac — Use Every Trick at Once

2026.07.12~5 min

00Overview

01Story

Situation

By 1.2.2, all the text — app and listing — is localized. What remains is the screenshots.

Complication

Properly, you'd boot an emulator on a Mac and automate screenshots across language switches. But as 1.1.2 established, there is no Mac at hand. Producing screenshots for fifty locales looked impossible.

Question

Without a Mac, how do you produce screenshots in dozens of languages?

02Solution

Criteria

  • Works without owning a Mac
  • Covers a large number of languages
  • Meets Apple's strict size requirements

Answer

Instead of forcing one method to do everything, use all four at once:

  • Design the UI to be as language-independent as possible — every word removed from an image is a word that needs neither translating nor generating.
  • Shoot major languages (Japanese, English, …) on the real device — switch the language, take real screenshots.
  • Batch-convert the rest with Flow — "make this image the X-language version," in bulk.
  • Draw annotations onto the English set and reuse it — with illustrations and callouts in place, the details don't need remaking per language.

The generated images are zipped and uploaded to Git, unpacked and arranged by Claude, then sent to App Store Connect by GitHub Actions (split, crop, resize, and up through the store's API).

Reason

Because only a combination clears the no-Mac constraint. Shoot everything on-device and the effort scales with the language count until it crushes you; generate everything with AI and the details smear while the size requirements slip.

Any one method fails.
Together, they clear it.

Flow deserves a special mention: it generates large batches at once, downloads them as a ZIP, and lets you queue the next batch while one is rendering. The throughput is a gift.

Options

  • Shoot everything on a real device — faithful, but the effort scales with every language. Not realistic.
  • Generate everything with AI — fast, but details break easily and the translation isn't accurate either.
  • Have Claude swap the images — Claude's weak spot is that it has no image-generation engine. What came out was awful, so I gave up on it.

03Result

Good

Multilingual screenshots, no Mac involved. The language-independent UI and the annotated-English reuse also shrank the sheer volume that needed generating. A mountain of dull work now turns over smoothly with AI in the mix. Flow generates a good number of images on the free tier, and being able to download them as a ZIP is a big plus.

Bad

Because the code isn't fed into the image generation (at translation time), the accuracy is poor. Run an image through the generation AI for fix after fix, and since the text is part of the image, it degrades further each pass. There's plenty of room for improvement.

Follow-up

With app, listing, and screenshots all localized, the product is ready for a worldwide release. Where to aim it — the idea that a narrow niche becomes a market when the world is the denominator — continues in 3.1.2 STP.

■ Reproduce it yourself

A note for anyone who wants to reproduce this setup. Not meant to be read straight through — the prompts show a few lines and scroll. Instructions are in English; the reference code (Gemfile / workflow / Fastfile) is the shared artifact and kept as-is.

First, build the mechanism. Paste the following in one copy (prompt + Gemfile + workflow + Fastfile together).

I want to build a mechanism that auto-uploads App Store Connect screenshots from
GitHub Actions using fastlane (calling spaceship directly).

Assumptions:
- The ASC API key is registered in GitHub Secrets as APPLE_API_KEY_ID /
  APPLE_API_KEY_ISSUER_ID / APPLE_API_KEY_P8 (three of them)
- The app is registered on ASC with SKU=<your SKU>, Bundle ID=<your BundleID>

What to build:
1. Gemfile ... pin the fastlane 2.228 line
2. Implement an upload_screenshots_direct lane in fastlane/Fastfile via direct spaceship
   - read apps/<SKU>/screenshots/<locale>/*.png
   - put iPhone files into the APP_IPHONE_67 set and iPad files into the
     APP_IPAD_PRO_3GEN_129 set, replacing and uploading
   - skip locales with no local images (= don't delete ones uploaded via another path)
3. One workflow_dispatch workflow for screenshots under .github/workflows/.
   Pass APP_SKU / APP_BUNDLE_ID and the three Secrets via env (use a long timeout)
4. apps/<SKU>/screenshots/<locale>/ for 50 locales (with .gitkeep)

Also, write a Python script that reads a ZIP placed in apps/<SKU>/, decides the
locale from the language tag in each filename, splits iPhone into 3 vertically /
iPad into 2 vertically -> center-crops -> resizes to Apple's recommended sizes
(iPhone 1320x2868 / iPad 2752x2064) and writes them out to
apps/<SKU>/screenshots/<locale>/. Make it print a "which file -> which locale"
mapping table before running.

source "https://rubygems.org"
gem "fastlane", "~> 2.228.0"


name: <SKU> — screenshots

on:
  workflow_dispatch:

jobs:
  upload:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true

      - name: Run fastlane upload_screenshots_direct
        env:
          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
          APPLE_API_KEY_ISSUER_ID: ${{ secrets.APPLE_API_KEY_ISSUER_ID }}
          APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
          APP_SKU: <SKU>
          APP_BUNDLE_ID: <BundleID>
          FASTLANE_SKIP_UPDATE_CHECK: "1"
        run: bundle exec fastlane ios upload_screenshots_direct


default_platform(:ios)

platform :ios do
  desc "Upload per-locale screenshots via spaceship (bypasses deliver's broken screenshot upload)"
  lane :upload_screenshots_direct do |options|
    sku       = options[:sku]       || ENV["APP_SKU"]
    bundle_id = options[:bundle_id] || ENV["APP_BUNDLE_ID"]
    UI.user_error!("sku is required")       if sku.nil? || sku.empty?
    UI.user_error!("bundle_id is required") if bundle_id.nil? || bundle_id.empty?

    screenshots_path = File.expand_path("../apps/#{sku}/screenshots", __dir__)
    UI.user_error!("screenshots not found: #{screenshots_path}") unless Dir.exist?(screenshots_path)

    require "spaceship"
    Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
      key_id:    ENV.fetch("APPLE_API_KEY_ID"),
      issuer_id: ENV.fetch("APPLE_API_KEY_ISSUER_ID"),
      key:       ENV.fetch("APPLE_API_KEY_P8"),
    )

    app = Spaceship::ConnectAPI::App.find(bundle_id)
    UI.user_error!("app not found for bundle_id=#{bundle_id}") if app.nil?

    version = app.get_edit_app_store_version
    UI.user_error!("no edit version found on App Store Connect") if version.nil?
    UI.message("Writing screenshots to version #{version.version_string} (#{version.app_store_state})")

    # 出力ファイル名 -> ASC のスクショ表示タイプ。
    # "iPhone" ファイルは 1320x2868 (iPhone 6.9")、Apple が 6.7"/6.9" 共用にする枠へ。
    # "iPad" ファイルは 2752x2064、iPad Pro 12.9" 3rd gen 横向き。
    device_of = ->(filename) {
      case filename
      when /iPhone/i then "APP_IPHONE_67"
      when /iPad/i   then "APP_IPAD_PRO_3GEN_129"
      else nil
      end
    }

    summary_lines = ["## Direct screenshot upload to #{version.version_string}", ""]
    updated = 0
    version.get_app_store_version_localizations.sort_by(&:locale).each do |loc|
      locale_dir = File.join(screenshots_path, loc.locale)
      unless Dir.exist?(locale_dir)
        summary_lines << "- **#{loc.locale}**: skipped (no local directory)"
        next
      end

      pngs = Dir.children(locale_dir).select { |f| f.downcase.end_with?(".png") }.sort
      if pngs.empty?
        summary_lines << "- **#{loc.locale}**: skipped (no PNGs)"
        next
      end

      groups = pngs.group_by { |f| device_of.call(f) }.reject { |k, _| k.nil? }
      if groups.empty?
        summary_lines << "- **#{loc.locale}**: skipped (no iPhone/iPad files)"
        next
      end

      existing_sets = loc.get_app_screenshot_sets.group_by(&:screenshot_display_type)
      groups.each do |display_type, files|
        set = existing_sets[display_type]&.first
        if set
          # 既存スクショを消してから新しいものに入れ替える。
          set.app_screenshots.each do |shot|
            shot.delete!
          end
        else
          set = loc.create_app_screenshot_set(attributes: {
            screenshotDisplayType: display_type,
          })
        end

        files.each do |filename|
          path = File.join(locale_dir, filename)
          UI.message("  #{loc.locale}/#{filename} -> #{display_type}")
          set.upload_screenshot(path: path, wait_for_processing: true)
        end
      end
      summary_lines << "- **#{loc.locale}**: uploaded #{pngs.length} screenshot(s)"
      updated += 1
    end

    summary_lines << ""
    summary_lines << "Updated #{updated} locale(s)."
    report = summary_lines.join("\n")
    UI.message(report)

    step_summary = ENV["GITHUB_STEP_SUMMARY"]
    File.write(step_summary, report) if step_summary && !step_summary.empty?
  end
end

Then, after placing a ZIP of per-language screenshots in apps/<app>/, paste the following.

I placed a ZIP of per-language screenshot images in apps/<app>/.
Process it for the App Store.

- decide the locale from the language tag in the filename (ja_01.png / en-US_... /
  韓国語.png etc.)
- split iPhone into 3 vertically / iPad into 2 vertically, center-crop and resize
  to Apple's recommended sizes (iPhone 6.9" = 1320x2868 / iPad 13" = 2752x2064)
- expand family copies like en-US -> en-AU / en-CA / en-GB
- auto-expand "other language" images to every unspecified locale

Show a "which file -> which locale" mapping table and let me confirm before running.
Once I say OK, generate into apps/<app>/screenshots/<locale>/.

Before uploading <app>'s screenshots, check first.
List every locale's screenshots folder, and print each locale's count and
filenames, so I can confirm you won't delete a locale I don't want touched.

If something's unclear, ask "what are you doing?" and it explains; when it works, ask it to "make this a skill" and it does. Don't /clear or start a new session until the skill is made — memory is lost, so building the skill within Claude's memory (same session) keeps the work stable.

You don't have to localize screenshots. The default-language images are used in common, so for something clear like a game, you can release with a single set that reads through illustration.