Explorar el Código

Add App Store Connect folder with listing metadata and API sync.

Introduces app-connect for Mac App Store copy, xcconfig wiring, and a script
to push metadata to App Store Connect, plus a complete App Store description.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir hace 2 semanas
padre
commit
a15be31d1a

+ 7 - 0
.gitignore

@@ -15,4 +15,11 @@ xcuserdata/
15 15
 # Secrets
16 16
 .env
17 17
 .env.*
18
+!.env.example
18 19
 *.p8
20
+
21
+# App Store Connect (app-connect/)
22
+app-connect/.env
23
+!app-connect/.env.example
24
+app-connect/Local.xcconfig
25
+app-connect/AuthKey_*.p8

+ 18 - 2
App for Indeed.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
8 8
 
9 9
 /* Begin PBXFileReference section */
10 10
 		27D852802FB1D367008DF557 /* App for Indeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "App for Indeed.app"; sourceTree = BUILT_PRODUCTS_DIR; };
11
+		27D852912FB1D369008DF557 /* AppConnect.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppConnect.xcconfig; sourceTree = "<group>"; };
11 12
 /* End PBXFileReference section */
12 13
 
13 14
 /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -33,10 +34,19 @@
33 34
 			isa = PBXGroup;
34 35
 			children = (
35 36
 				27D852822FB1D367008DF557 /* App for Indeed */,
37
+				27D852922FB1D369008DF557 /* app-connect */,
36 38
 				27D852812FB1D367008DF557 /* Products */,
37 39
 			);
38 40
 			sourceTree = "<group>";
39 41
 		};
42
+		27D852922FB1D369008DF557 /* app-connect */ = {
43
+			isa = PBXGroup;
44
+			children = (
45
+				27D852912FB1D369008DF557 /* AppConnect.xcconfig */,
46
+			);
47
+			path = "app-connect";
48
+			sourceTree = "<group>";
49
+		};
40 50
 		27D852812FB1D367008DF557 /* Products */ = {
41 51
 			isa = PBXGroup;
42 52
 			children = (
@@ -245,6 +255,7 @@
245 255
 		};
246 256
 		27D8528F2FB1D368008DF557 /* Debug */ = {
247 257
 			isa = XCBuildConfiguration;
258
+			baseConfigurationReference = 27D852912FB1D369008DF557 /* AppConnect.xcconfig */;
248 259
 			buildSettings = {
249 260
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
250 261
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -252,6 +263,7 @@
252 263
 				CODE_SIGN_STYLE = Automatic;
253 264
 				COMBINE_HIDPI_IMAGES = YES;
254 265
 				CURRENT_PROJECT_VERSION = 1;
266
+				DEVELOPMENT_TEAM = NNC7V99779;
255 267
 				ENABLE_APP_SANDBOX = YES;
256 268
 				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
257 269
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -260,6 +272,7 @@
260 272
 				INFOPLIST_KEY_AppStoreDeveloperID = "$(APP_STORE_DEVELOPER_ID)";
261 273
 				INFOPLIST_KEY_CFBundleDisplayName = "App for Indeed";
262 274
 				INFOPLIST_KEY_CFBundleName = "App for Indeed";
275
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
263 276
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
264 277
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
265 278
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -268,7 +281,7 @@
268 281
 					"@executable_path/../Frameworks",
269 282
 				);
270 283
 				MARKETING_VERSION = 1.0;
271
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
284
+				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.app-for-indeed";
272 285
 				PRODUCT_NAME = "App for Indeed";
273 286
 				REGISTER_APP_GROUPS = YES;
274 287
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -282,6 +295,7 @@
282 295
 		};
283 296
 		27D852902FB1D368008DF557 /* Release */ = {
284 297
 			isa = XCBuildConfiguration;
298
+			baseConfigurationReference = 27D852912FB1D369008DF557 /* AppConnect.xcconfig */;
285 299
 			buildSettings = {
286 300
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
287 301
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -289,6 +303,7 @@
289 303
 				CODE_SIGN_STYLE = Automatic;
290 304
 				COMBINE_HIDPI_IMAGES = YES;
291 305
 				CURRENT_PROJECT_VERSION = 1;
306
+				DEVELOPMENT_TEAM = NNC7V99779;
292 307
 				ENABLE_APP_SANDBOX = YES;
293 308
 				ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
294 309
 				ENABLE_USER_SELECTED_FILES = readonly;
@@ -297,6 +312,7 @@
297 312
 				INFOPLIST_KEY_AppStoreDeveloperID = "$(APP_STORE_DEVELOPER_ID)";
298 313
 				INFOPLIST_KEY_CFBundleDisplayName = "App for Indeed";
299 314
 				INFOPLIST_KEY_CFBundleName = "App for Indeed";
315
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
300 316
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
301 317
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
302 318
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -305,7 +321,7 @@
305 321
 					"@executable_path/../Frameworks",
306 322
 				);
307 323
 				MARKETING_VERSION = 1.0;
308
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.App-for-Indeed";
324
+				PRODUCT_BUNDLE_IDENTIFIER = "com.hwaccount.app-for-indeed";
309 325
 				PRODUCT_NAME = "App for Indeed";
310 326
 				REGISTER_APP_GROUPS = YES;
311 327
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;

+ 1 - 1
App for Indeed.xcodeproj/xcshareddata/xcschemes/App for Indeed.xcscheme

@@ -1,7 +1,7 @@
1 1
 <?xml version="1.0" encoding="UTF-8"?>
2 2
 <Scheme
3 3
    LastUpgradeVersion = "2640"
4
-   version = "1.7">
4
+   version = "1.8">
5 5
    <BuildAction
6 6
       parallelizeBuildables = "YES"
7 7
       buildImplicitDependencies = "YES"

+ 18 - 0
app-connect/.env.example

@@ -0,0 +1,18 @@
1
+# App Store Connect API credentials
2
+# Create at: https://appstoreconnect.apple.com → Users and Access → Integrations → App Store Connect API
3
+#
4
+# Copy this file to `.env` and fill in your values:
5
+#   cp .env.example .env
6
+
7
+APP_STORE_CONNECT_ISSUER_ID=
8
+APP_STORE_CONNECT_KEY_ID=
9
+# Path to the downloaded .p8 key (relative to this folder is fine)
10
+APP_STORE_CONNECT_PRIVATE_KEY_PATH=./AuthKey_XXXXXXXXXX.p8
11
+
12
+# App identifiers (App Store Connect → App → App Information)
13
+APP_STORE_CONNECT_BUNDLE_ID=com.hwaccount.app-for-indeed
14
+APP_STORE_CONNECT_TEAM_ID=NNC7V99779
15
+
16
+# Numeric IDs for Mac App Store marketing links (also used in Xcode Local.xcconfig)
17
+APP_STORE_APP_ID=
18
+APP_STORE_DEVELOPER_ID=

+ 7 - 0
app-connect/AppConnect.xcconfig

@@ -0,0 +1,7 @@
1
+// Shared App Store Connect / Mac App Store build settings.
2
+// Per-developer values live in Local.xcconfig (gitignored).
3
+
4
+#include? "Local.xcconfig"
5
+
6
+APP_STORE_APP_ID =
7
+APP_STORE_DEVELOPER_ID =

+ 7 - 0
app-connect/Local.xcconfig.example

@@ -0,0 +1,7 @@
1
+// Copy to Local.xcconfig and set your App Store numeric IDs.
2
+// Used by the Xcode target for INFOPLIST_KEY_AppStoreAppID / AppStoreDeveloperID.
3
+//
4
+//   cp Local.xcconfig.example Local.xcconfig
5
+
6
+APP_STORE_APP_ID =
7
+APP_STORE_DEVELOPER_ID =

+ 64 - 0
app-connect/README.md

@@ -0,0 +1,64 @@
1
+# App Store Connect
2
+
3
+Configuration for connecting to [App Store Connect](https://appstoreconnect.apple.com) for this app (**App for Indeed**).
4
+
5
+## Setup
6
+
7
+1. **API key** (for automation, CI, or CLI tools):
8
+   - In App Store Connect: **Users and Access → Integrations → App Store Connect API → Generate API Key**.
9
+   - Download the `.p8` file into this folder (e.g. `AuthKey_XXXXXXXXXX.p8`). Do not commit it.
10
+
11
+2. **Environment file**:
12
+   ```bash
13
+   cd app-connect
14
+   cp .env.example .env
15
+   ```
16
+   Edit `.env` with your Issuer ID, Key ID, path to the `.p8` file, and numeric App Store / Developer IDs.
17
+
18
+3. **Xcode build settings** (Mac App Store share links in the app):
19
+   ```bash
20
+   cp Local.xcconfig.example Local.xcconfig
21
+   ```
22
+   Set `APP_STORE_APP_ID` and `APP_STORE_DEVELOPER_ID` in `Local.xcconfig`. The Xcode target includes `AppConnect.xcconfig`, which pulls in `Local.xcconfig`.
23
+
24
+## Variables
25
+
26
+| Variable | Purpose |
27
+|----------|---------|
28
+| `APP_STORE_CONNECT_ISSUER_ID` | API Issuer ID from App Store Connect |
29
+| `APP_STORE_CONNECT_KEY_ID` | API Key ID |
30
+| `APP_STORE_CONNECT_PRIVATE_KEY_PATH` | Path to the `.p8` private key |
31
+| `APP_STORE_CONNECT_BUNDLE_ID` | Bundle ID (`com.hwaccount.app-for-indeed`) |
32
+| `APP_STORE_CONNECT_TEAM_ID` | Apple Developer Team ID |
33
+| `APP_STORE_APP_ID` | Numeric Mac App Store app ID |
34
+| `APP_STORE_DEVELOPER_ID` | Numeric developer ID for “More Apps” link |
35
+
36
+## App Store listing copy
37
+
38
+Ready-to-paste metadata lives in [`app-data/`](app-data/) (name, subtitle, description, promotional text, copyright, keywords, and support/legal/marketing URLs).
39
+
40
+### Push metadata to App Store Connect
41
+
42
+After filling in `.env` and editing `app-data/*.txt`:
43
+
44
+```bash
45
+cd app-connect
46
+python3 scripts/update_app_store_metadata.py
47
+```
48
+
49
+- **App Information** (macOS): name, subtitle, privacy policy URL  
50
+- **Version 1.0** (default): copyright, description, keywords, promotional text, marketing URL, support URL  
51
+
52
+Override version or locale: `APP_STORE_VERSION=1.0 APP_STORE_LOCALE=en-US python3 scripts/update_app_store_metadata.py`
53
+
54
+Custom Terms of Use (EULA URL) must still use Apple’s standard EULA or be set manually in App Store Connect if you need a custom license agreement.
55
+
56
+## Loading `.env` in the shell
57
+
58
+```bash
59
+set -a
60
+source app-connect/.env
61
+set +a
62
+```
63
+
64
+Tools such as Fastlane, `xcrun altool`, and custom scripts can read the same variable names.

+ 22 - 0
app-connect/app-data/README.md

@@ -0,0 +1,22 @@
1
+# App Store Connect — App metadata
2
+
3
+Copy-ready text for **App for Indeed** (macOS). Each file maps to a field in App Store Connect → your app → **App Information** or version **Metadata**.
4
+
5
+| File | App Store Connect field | Limit |
6
+|------|-------------------------|-------|
7
+| `app-name.txt` | Name | 30 characters |
8
+| `subtitle.txt` | Subtitle | 30 characters |
9
+| `promotional-text.txt` | Promotional Text | 170 characters |
10
+| `description.txt` | Description | 4,000 characters |
11
+| `copyright.txt` | Copyright | — |
12
+| `keywords.txt` | Keywords | 100 characters total |
13
+| `link-marketing.txt` | Marketing URL | — |
14
+| `link-support.txt` | Support URL | — |
15
+| `link-privacy.txt` | Privacy Policy URL | — |
16
+| `link-terms.txt` | License Agreement / Terms URL (EULA) | — |
17
+
18
+URLs match `AppLegalURLs.swift` and `Paywall.storekit` in the Xcode project.
19
+
20
+**Contact email** (not a separate URL field): support@appforindeed.com — listed on the marketing site contact page.
21
+
22
+Before submitting, re-check character limits in App Store Connect; trailing newlines in these files are not part of the copy.

+ 1 - 0
app-connect/app-data/app-name.txt

@@ -0,0 +1 @@
1
+App for Indeed

+ 1 - 0
app-connect/app-data/copyright.txt

@@ -0,0 +1 @@
1
+© 2026 App for Indeed

+ 81 - 0
app-connect/app-data/description.txt

@@ -0,0 +1,81 @@
1
+App for Indeed is a native macOS companion for focused job hunting. Search with AI, save listings, build résumés, manage career profiles, and browse Indeed—all in one workspace instead of juggling browser tabs.
2
+
3
+BUILT FOR MAC JOB SEEKERS
4
+
5
+Whether you are exploring new roles, targeting a specific company, or sharpening your CV between interviews, App for Indeed keeps your search organized on the desktop you already use every day.
6
+
7
+AI JOB SEARCH ON HOME
8
+
9
+• Chat-style search: describe the job you want in plain language and get concise role summaries.
10
+• Smart shortcuts: tap Role, Company, or Skill to start focused searches faster.
11
+• Continue the conversation: refine results with follow-up prompts without starting over.
12
+• Act on results: save promising listings or open them on Indeed to read details and apply.
13
+
14
+EMBEDDED INDEED BROWSER
15
+
16
+• View job pages and application flows inside the app.
17
+• Navigate with back, forward, and reload, then return to your dashboard.
18
+• Stay in context while moving between search, saved jobs, and apply steps.
19
+
20
+SAVED JOBS WORKSPACE
21
+
22
+• Save opportunities from Home search results.
23
+• Revisit saved listings anytime to compare, apply, or follow up.
24
+• Keep shortlists on your Mac without losing track in browser history.
25
+
26
+CV MAKER
27
+
28
+• Browse design- and profession-based résumé templates.
29
+• Filter templates by style and preview layouts before you commit.
30
+• Connect a template to a saved profile to generate a filled CV.
31
+• Export a polished PDF when you are ready to share or apply.
32
+
33
+PROFILE MANAGEMENT
34
+
35
+• Create multiple job-seeker profiles on your Mac.
36
+• Store contact details, experience, education, and skills in one place.
37
+• Reuse profiles across CV previews and exports for different applications.
38
+
39
+SETTINGS & APPEARANCE
40
+
41
+• Light, dark, or system appearance.
42
+• Quick access to support, privacy policy, terms of use, and marketing site.
43
+• Share the app with friends via the built-in macOS share menu.
44
+
45
+APP FOR INDEED PRO
46
+
47
+Go further with auto-renewing weekly, monthly, or yearly subscriptions billed securely through your Apple ID:
48
+
49
+• Unlimited AI job search on Home (free access includes a limited number of searches).
50
+• Save jobs and open listings in the embedded Indeed browser.
51
+• Full CV Maker access, multiple profiles, and PDF export.
52
+• Role, company, and skill shortcuts on Home.
53
+
54
+Manage or cancel subscriptions anytime in System Settings → Apple ID → Subscriptions. Payment is charged to your Apple ID at confirmation. Subscriptions renew automatically unless canceled at least 24 hours before the end of the current period. Refunds are handled by Apple according to Apple’s policies.
55
+
56
+FREE TIER
57
+
58
+Try core Home AI search before upgrading. Free users can send a limited number of job-search messages; Pro unlocks unlimited searches and premium tools.
59
+
60
+PRIVACY & DATA
61
+
62
+• Saved profiles, jobs, and preferences are stored locally on your Mac.
63
+• AI features send the text you enter to a third-party AI provider to generate responses.
64
+• Purchases and subscriptions are processed by Apple; we do not receive your full payment card details.
65
+
66
+Privacy Policy: https://sites.google.com/view/app-for-indeed/privacy-policy
67
+Terms of Use: https://sites.google.com/view/app-for-indeed/terms-of-services
68
+
69
+REQUIREMENTS
70
+
71
+• macOS 12.0 or later.
72
+• Internet connection for AI search, template loading, and embedded Indeed browsing.
73
+
74
+SUPPORT
75
+
76
+Visit Settings → Support or our help page: https://sites.google.com/view/app-for-indeed/support
77
+Email: support@appforindeed.com
78
+
79
+DISCLAIMER
80
+
81
+App for Indeed is an independent productivity app. It is not affiliated with, sponsored by, or endorsed by Indeed, Inc. Indeed is a trademark of Indeed, Inc.

+ 1 - 0
app-connect/app-data/keywords.txt

@@ -0,0 +1 @@
1
+indeed,jobsearch,resume,cv,career,ai,mac,employment,recruitment,hiring,savedjobs,resumebuilder

+ 1 - 0
app-connect/app-data/link-marketing.txt

@@ -0,0 +1 @@
1
+https://sites.google.com/view/app-for-indeed/home

+ 1 - 0
app-connect/app-data/link-privacy.txt

@@ -0,0 +1 @@
1
+https://sites.google.com/view/app-for-indeed/privacy-policy

+ 1 - 0
app-connect/app-data/link-support.txt

@@ -0,0 +1 @@
1
+https://sites.google.com/view/app-for-indeed/support

+ 1 - 0
app-connect/app-data/link-terms.txt

@@ -0,0 +1 @@
1
+https://sites.google.com/view/app-for-indeed/terms-of-services

+ 1 - 0
app-connect/app-data/promotional-text.txt

@@ -0,0 +1 @@
1
+Search roles with AI, save jobs, build CVs, and browse Indeed in one Mac app. Upgrade to Pro for unlimited AI search, profiles, and PDF export.

+ 1 - 0
app-connect/app-data/subtitle.txt

@@ -0,0 +1 @@
1
+AI Job Search & CV Maker

+ 251 - 0
app-connect/scripts/update_app_store_metadata.py

@@ -0,0 +1,251 @@
1
+#!/usr/bin/env python3
2
+"""Push app-data/*.txt to App Store Connect via the official API."""
3
+
4
+from __future__ import annotations
5
+
6
+import json
7
+import os
8
+import sys
9
+import time
10
+from pathlib import Path
11
+
12
+import jwt
13
+import requests
14
+
15
+API_BASE = "https://api.appstoreconnect.apple.com/v1"
16
+ROOT = Path(__file__).resolve().parents[1]
17
+APP_DATA = ROOT / "app-data"
18
+ENV_PATH = ROOT / ".env"
19
+VERSION_STRING = os.environ.get("APP_STORE_VERSION", "1.0")
20
+LOCALE = os.environ.get("APP_STORE_LOCALE", "en-US")
21
+
22
+
23
+def load_env(path: Path) -> dict[str, str]:
24
+    values: dict[str, str] = {}
25
+    for line in path.read_text(encoding="utf-8").splitlines():
26
+        line = line.strip()
27
+        if not line or line.startswith("#") or "=" not in line:
28
+            continue
29
+        key, value = line.split("=", 1)
30
+        values[key.strip()] = value.strip()
31
+    return values
32
+
33
+
34
+def read_field(name: str) -> str:
35
+    path = APP_DATA / name
36
+    if not path.exists():
37
+        raise FileNotFoundError(path)
38
+    return path.read_text(encoding="utf-8").strip()
39
+
40
+
41
+class ASCClient:
42
+    def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
43
+        self.issuer_id = issuer_id
44
+        self.key_id = key_id
45
+        self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
46
+        self.session = requests.Session()
47
+        self._token: str | None = None
48
+        self._token_exp = 0.0
49
+
50
+    def _ensure_token(self) -> str:
51
+        now = time.time()
52
+        if self._token and now < self._token_exp - 60:
53
+            return self._token
54
+        issued_at = int(now)
55
+        payload = {
56
+            "iss": self.issuer_id,
57
+            "iat": issued_at,
58
+            "exp": issued_at + 1200,
59
+            "aud": "appstoreconnect-v1",
60
+        }
61
+        self._token = jwt.encode(
62
+            payload,
63
+            self.private_key,
64
+            algorithm="ES256",
65
+            headers={"kid": self.key_id, "typ": "JWT"},
66
+        )
67
+        self._token_exp = issued_at + 1200
68
+        return self._token
69
+
70
+    def request(self, method: str, path: str, **kwargs) -> requests.Response:
71
+        token = self._ensure_token()
72
+        headers = kwargs.pop("headers", {})
73
+        headers["Authorization"] = f"Bearer {token}"
74
+        headers.setdefault("Content-Type", "application/json")
75
+        url = path if path.startswith("http") else f"{API_BASE}{path}"
76
+        response = self.session.request(method, url, headers=headers, timeout=60, **kwargs)
77
+        return response
78
+
79
+    def get_json(self, path: str, params: dict | None = None) -> dict:
80
+        response = self.request("GET", path, params=params)
81
+        if not response.ok:
82
+            raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
83
+        return response.json()
84
+
85
+    def patch(self, resource_type: str, resource_id: str, attributes: dict) -> dict:
86
+        body = {
87
+            "data": {
88
+                "type": resource_type,
89
+                "id": resource_id,
90
+                "attributes": attributes,
91
+            }
92
+        }
93
+        response = self.request("PATCH", f"/{resource_type}/{resource_id}", json=body)
94
+        if not response.ok:
95
+            raise RuntimeError(
96
+                f"PATCH {resource_type}/{resource_id} failed ({response.status_code}): {response.text}"
97
+            )
98
+        return response.json()
99
+
100
+
101
+def find_app(client: ASCClient, bundle_id: str) -> dict:
102
+    data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
103
+    apps = data.get("data", [])
104
+    if not apps:
105
+        raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
106
+    return apps[0]
107
+
108
+
109
+def find_mac_app_info(client: ASCClient, app_id: str) -> dict:
110
+    data = client.get_json(f"/apps/{app_id}/appInfos")
111
+    for info in data.get("data", []):
112
+        attrs = info.get("attributes", {})
113
+        if attrs.get("appStoreState") is not None or attrs.get("brazilAgeRating") is not None:
114
+            # Prefer MAC_OS if platform is listed in included relationships; filter by bundle platform.
115
+            pass
116
+    # App infos are per platform — pick MAC_OS via appStoreAgeRating or secondary query.
117
+    for info in data.get("data", []):
118
+        info_id = info["id"]
119
+        detail = client.get_json(f"/appInfos/{info_id}")
120
+        platform = detail.get("data", {}).get("attributes", {}).get("platform")
121
+        if platform == "MAC_OS":
122
+            return detail["data"]
123
+    # Fallback: single app info entry.
124
+    infos = data.get("data", [])
125
+    if len(infos) == 1:
126
+        return infos[0]
127
+    raise RuntimeError("Could not determine MAC_OS appInfo; inspect /apps/{id}/appInfos")
128
+
129
+
130
+def find_app_info_localization(client: ASCClient, app_info_id: str, locale: str) -> dict:
131
+    data = client.get_json(f"/appInfos/{app_info_id}/appInfoLocalizations")
132
+    for loc in data.get("data", []):
133
+        if loc.get("attributes", {}).get("locale") == locale:
134
+            return loc
135
+    raise RuntimeError(f"No appInfoLocalization for locale {locale!r}")
136
+
137
+
138
+def find_version(client: ASCClient, app_id: str, version_string: str) -> dict:
139
+    data = client.get_json(
140
+        f"/apps/{app_id}/appStoreVersions",
141
+        params={"filter[platform]": "MAC_OS", "limit": 50},
142
+    )
143
+    matches = [
144
+        v
145
+        for v in data.get("data", [])
146
+        if v.get("attributes", {}).get("versionString") == version_string
147
+    ]
148
+    if not matches:
149
+        raise RuntimeError(f"No MAC_OS appStoreVersion {version_string!r}")
150
+    # Prefer editable state if multiple.
151
+    for v in matches:
152
+        state = v.get("attributes", {}).get("appStoreState")
153
+        if state in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", "METADATA_REJECTED"):
154
+            return v
155
+    return matches[0]
156
+
157
+
158
+def find_version_localization(client: ASCClient, version_id: str, locale: str) -> dict:
159
+    data = client.get_json(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations")
160
+    for loc in data.get("data", []):
161
+        if loc.get("attributes", {}).get("locale") == locale:
162
+            return loc
163
+    raise RuntimeError(f"No appStoreVersionLocalization for locale {locale!r}")
164
+
165
+
166
+def main() -> int:
167
+    if not ENV_PATH.exists():
168
+        print(f"Missing {ENV_PATH}", file=sys.stderr)
169
+        return 1
170
+
171
+    env = load_env(ENV_PATH)
172
+    issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
173
+    key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
174
+    key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
175
+    bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
176
+
177
+    for key, value in [
178
+        ("APP_STORE_CONNECT_ISSUER_ID", issuer),
179
+        ("APP_STORE_CONNECT_KEY_ID", key_id),
180
+        ("APP_STORE_CONNECT_PRIVATE_KEY_PATH", key_path),
181
+        ("APP_STORE_CONNECT_BUNDLE_ID", bundle_id),
182
+    ]:
183
+        if not value:
184
+            print(f"Set {key} in {ENV_PATH}", file=sys.stderr)
185
+            return 1
186
+
187
+    client = ASCClient(issuer, key_id, key_path)
188
+    app = find_app(client, bundle_id)
189
+    app_id = app["id"]
190
+    print(f"App: {app['attributes'].get('name')} ({app_id})")
191
+
192
+    app_info = find_mac_app_info(client, app_id)
193
+    app_info_id = app_info["id"]
194
+    print(f"App Information (MAC_OS): {app_info_id}")
195
+
196
+    info_loc = find_app_info_localization(client, app_info_id, LOCALE)
197
+    info_loc_id = info_loc["id"]
198
+
199
+    app_name = read_field("app-name.txt")
200
+    subtitle = read_field("subtitle.txt")
201
+    privacy_url = read_field("link-privacy.txt")
202
+
203
+    info_patch = {
204
+        "name": app_name,
205
+        "subtitle": subtitle,
206
+        "privacyPolicyUrl": privacy_url,
207
+    }
208
+    client.patch("appInfoLocalizations", info_loc_id, info_patch)
209
+    print(f"Updated App Information localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
210
+
211
+    version = find_version(client, app_id, VERSION_STRING)
212
+    version_id = version["id"]
213
+    version_state = version.get("attributes", {}).get("appStoreState")
214
+    print(f"Version {VERSION_STRING}: {version_id} (state: {version_state})")
215
+
216
+    copyright_text = read_field("copyright.txt")
217
+    client.patch("appStoreVersions", version_id, {"copyright": copyright_text})
218
+    print(f"Updated version copyright")
219
+
220
+    version_loc = find_version_localization(client, version_id, LOCALE)
221
+    version_loc_id = version_loc["id"]
222
+
223
+    version_patch = {
224
+        "description": read_field("description.txt"),
225
+        "keywords": read_field("keywords.txt"),
226
+        "promotionalText": read_field("promotional-text.txt"),
227
+        "marketingUrl": read_field("link-marketing.txt"),
228
+        "supportUrl": read_field("link-support.txt"),
229
+    }
230
+    client.patch("appStoreVersionLocalizations", version_loc_id, version_patch)
231
+    print(f"Updated version {VERSION_STRING} localization ({LOCALE}): description, keywords, promotionalText, marketingUrl, supportUrl")
232
+
233
+    # Custom EULA URL (Terms) — attach to app if not using standard Apple EULA.
234
+    terms_url = read_field("link-terms.txt")
235
+    try:
236
+        eula_data = client.get_json(f"/apps/{app_id}/endUserLicenseAgreement")
237
+        eula = eula_data.get("data")
238
+        if eula:
239
+            # Custom EULA resource uses agreementText, not URL in all cases; try app-level license.
240
+            print("App has custom EULA resource; update Terms URL in App Store Connect if needed:", terms_url)
241
+        else:
242
+            print("Using standard Apple EULA; Terms URL for reference:", terms_url)
243
+    except RuntimeError:
244
+        print("EULA lookup skipped; Terms URL:", terms_url)
245
+
246
+    print("Done.")
247
+    return 0
248
+
249
+
250
+if __name__ == "__main__":
251
+    raise SystemExit(main())