Эх сурвалжийг харах

Add US App Store Connect listing metadata and sync script.

Store Mac App Store copy for the en-US locale as one file per field under
appconnect/US/ (app name, subtitle, promotional text, description, keywords,
what’s new, copyright, and urls.json for support/marketing/privacy/terms).
This layout matches App Store Connect fields and is easy to edit and review
before release.

Add scripts/update_appstore_connect_us.py to push those files to App Store
Connect via the API using credentials from .env (issuer ID, key ID, and .p8
path). The script updates app information (name, subtitle, privacy policy
URL), version copyright, and version localization (description, keywords,
promotional text, support and marketing URLs), and skips what’s new when
Apple locks it for a first release.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir 2 сар өмнө
parent
commit
332f8ad1fb

+ 1 - 0
appconnect/US/app-name.txt

@@ -0,0 +1 @@
+Companion AI for Zoom

+ 1 - 0
appconnect/US/copyright.txt

@@ -0,0 +1 @@
+© 2026 Companion AI for Zoom

+ 36 - 0
appconnect/US/description.txt

@@ -0,0 +1,36 @@
+Companion AI for Zoom is your complete meeting hub on macOS—built for people who live in Zoom and want one calm place to plan, join, and follow up without juggling browser tabs.
+
+Sign in with your Zoom account to see scheduled meetings on Home and Meetings, open a calendar-style Scheduler to plan your week, and start or join sessions from the menu bar or inside the app. Join by meeting ID or link, schedule new meetings with timezone-aware details, and stay in flow with embedded Zoom meetings that use your Mac’s camera and microphone.
+
+DESKTOP WIDGETS
+Pin floating widgets on your desktop for your next meetings and quick actions—always visible while you work.
+
+SMART REMINDERS
+Never miss a call. Turn on notifications for 1 day, 12 hours, or 1 hour before meetings (macOS notification permission required).
+
+AI COMPANION
+When you choose to record, meeting audio is saved locally on your Mac in the app’s secure sandbox—not in the cloud by default. After a meeting you can:
+• Transcribe on your device with Apple Speech Recognition
+• Generate structured meeting notes (summary, decisions, action items) when you request them
+• Review Zoom AI Companion meeting summaries from your account when available through Zoom’s API
+
+Optional Google sign-in can support scheduling workflows tied to your calendar.
+
+PREMIUM
+Unlock the full experience with Premium (weekly, monthly, or yearly subscription, or a one-time lifetime option). Premium includes access to Meetings, Scheduler, meeting reminders, and desktop widgets. Subscriptions renew automatically unless cancelled at least 24 hours before the end of the current period in your Apple ID account settings. A free trial may apply to eligible yearly plans where offered.
+
+PRIVACY & PERMISSIONS
+Most data stays on your Mac. The app may request camera, microphone, screen/system audio capture, speech recognition, and notifications only for features you use. Generating AI notes sends transcript text to OpenAI only when you tap to create notes. See our Privacy Policy for details.
+
+  Privacy Policy Url: "https://sites.google.com/view/aicompanionforzoom/privacy-policy",
+  Terms Of Service Url: "https://sites.google.com/view/aicompanionforzoom/terms-and-condition"
+
+This application is subject to Apple’s Standard End User License Agreement.
+
+REQUIREMENTS
+• macOS 12.4 or later
+• A Zoom account for meeting list, scheduling, join, and embedded meetings
+• Internet connection
+
+DISCLAIMER
+Companion AI for Zoom is an independent product and is not affiliated with, endorsed by, or sponsored by Zoom Video Communications, Inc. “Zoom” is a trademark of Zoom Video Communications, Inc.

+ 1 - 0
appconnect/US/keywords.txt

@@ -0,0 +1 @@
+zoom,meetings,scheduler,transcription,notes,widget,reminder,calendar,ai,mac,video,companion

+ 1 - 0
appconnect/US/promotional-text.txt

@@ -0,0 +1 @@
+Run Zoom from one Mac app: see upcoming meetings, join in-app, get reminders, pin desktop widgets, and turn recordings into transcripts and AI notes.

+ 1 - 0
appconnect/US/subtitle.txt

@@ -0,0 +1 @@
+AI notes & Zoom Meetings

+ 6 - 0
appconnect/US/urls.json

@@ -0,0 +1,6 @@
+{
+  "supportUrl": "https://sites.google.com/view/aicompanionforzoom/get-support",
+  "marketingUrl": null,
+  "privacyPolicyUrl": "https://sites.google.com/view/aicompanionforzoom/privacy-policy",
+  "termsOfServiceUrl": "https://sites.google.com/view/aicompanionforzoom/terms-and-condition"
+}

+ 9 - 0
appconnect/US/whats-new.txt

@@ -0,0 +1,9 @@
+Welcome to Companion AI for Zoom 1.0.
+
+• Sign in with Zoom and manage upcoming meetings
+• Schedule and join meetings, including embedded in-app Zoom
+• Calendar Scheduler and desktop widgets
+• Meeting reminders (1 day, 12 hours, 1 hour)
+• AI Companion: local recording, on-device transcription, and AI meeting notes
+• View Zoom AI meeting summaries when available in your account
+• Menu bar quick actions and dark mode support

+ 265 - 0
scripts/update_appstore_connect_us.py

@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Push appconnect/US metadata to App Store Connect (en-US)."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from pathlib import Path
+
+import jwt
+import requests
+from cryptography.hazmat.primitives import serialization
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+US_DIR = REPO_ROOT / "appconnect" / "US"
+API_BASE = "https://api.appstoreconnect.apple.com/v1"
+LOCALE = "en-US"
+
+
+def load_env() -> dict[str, str]:
+    env_path = REPO_ROOT / ".env"
+    values: dict[str, str] = {}
+    if not env_path.exists():
+        return values
+    for line in env_path.read_text(encoding="utf-8").splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, _, value = line.partition("=")
+        values[key.strip()] = value.strip()
+    return values
+
+
+def read_text(name: str) -> str:
+    path = US_DIR / name
+    return path.read_text(encoding="utf-8").strip()
+
+
+def make_token(issuer_id: str, key_id: str, key_path: Path) -> str:
+    private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
+    headers = {"alg": "ES256", "kid": key_id, "typ": "JWT"}
+    payload = {
+        "iss": issuer_id,
+        "iat": int(time.time()),
+        "exp": int(time.time()) + 1200,
+        "aud": "appstoreconnect-v1",
+    }
+    return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
+
+
+class ASCClient:
+    def __init__(self, token: str) -> None:
+        self.session = requests.Session()
+        self.session.headers.update(
+            {
+                "Authorization": f"Bearer {token}",
+                "Content-Type": "application/json",
+            }
+        )
+
+    def get(self, path: str, params: dict | None = None) -> dict:
+        response = self.session.get(f"{API_BASE}{path}", params=params, timeout=60)
+        if not response.ok:
+            raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
+        return response.json()
+
+    def patch(self, path: str, body: dict) -> dict:
+        response = self.session.patch(f"{API_BASE}{path}", json=body, timeout=60)
+        if not response.ok:
+            raise RuntimeError(f"PATCH {path} failed ({response.status_code}): {response.text}")
+        return response.json()
+
+
+def main() -> int:
+    env = {**os.environ, **load_env()}
+    issuer_id = env.get("APP_STORE_CONNECT_ISSUER_ID")
+    key_id = env.get("APP_STORE_CONNECT_KEY_ID")
+    key_path_raw = env.get("APP_STORE_CONNECT_API_KEY_PATH")
+    bundle_id = env.get("PRODUCT_BUNDLE_IDENTIFIER", "com.hwaccount.zoom-app")
+
+    if not issuer_id or not key_id or not key_path_raw:
+        print("Missing APP_STORE_CONNECT_ISSUER_ID, KEY_ID, or API_KEY_PATH in .env", file=sys.stderr)
+        return 1
+
+    key_path = Path(key_path_raw).expanduser()
+    if not key_path.is_file():
+        print(f"API key file not found: {key_path}", file=sys.stderr)
+        return 1
+
+    urls = json.loads((US_DIR / "urls.json").read_text(encoding="utf-8"))
+    metadata = {
+        "name": read_text("app-name.txt"),
+        "subtitle": read_text("subtitle.txt"),
+        "promotionalText": read_text("promotional-text.txt"),
+        "description": read_text("description.txt"),
+        "keywords": read_text("keywords.txt"),
+        "whatsNew": read_text("whats-new.txt"),
+        "copyright": read_text("copyright.txt"),
+        "supportUrl": urls.get("supportUrl"),
+        "marketingUrl": urls.get("marketingUrl"),
+        "privacyPolicyUrl": urls.get("privacyPolicyUrl"),
+    }
+
+    token = make_token(issuer_id, key_id, key_path)
+    client = ASCClient(token)
+
+    apps = client.get("/apps", {"filter[bundleId]": bundle_id, "limit": 1})
+    app_list = apps.get("data", [])
+    if not app_list:
+        print(f"No app found for bundle id {bundle_id}", file=sys.stderr)
+        return 1
+    app_id = app_list[0]["id"]
+    print(f"App: {app_id} ({bundle_id})")
+
+    # --- App Information (name, subtitle, privacy policy URL) ---
+    app_infos = client.get(f"/apps/{app_id}/appInfos", {"limit": 10})
+    app_info_id = None
+    for info in app_infos.get("data", []):
+        if info.get("attributes", {}).get("appStoreState") in (
+            "PREPARE_FOR_SUBMISSION",
+            "READY_FOR_SALE",
+            "DEVELOPER_REJECTED",
+            "PENDING_DEVELOPER_RELEASE",
+            "IN_REVIEW",
+            "WAITING_FOR_REVIEW",
+            "ACCEPTED",
+        ) or app_info_id is None:
+            app_info_id = info["id"]
+    if not app_info_id:
+        print("No appInfo resource found", file=sys.stderr)
+        return 1
+
+    info_locs = client.get(
+        f"/appInfos/{app_info_id}/appInfoLocalizations",
+        {"filter[locale]": LOCALE, "limit": 1},
+    )
+    info_loc_list = info_locs.get("data", [])
+    if not info_loc_list:
+        print(f"No appInfoLocalization for {LOCALE}", file=sys.stderr)
+        return 1
+    info_loc_id = info_loc_list[0]["id"]
+
+    info_attrs: dict[str, str] = {
+        "name": metadata["name"],
+        "subtitle": metadata["subtitle"],
+    }
+    if metadata.get("privacyPolicyUrl"):
+        info_attrs["privacyPolicyUrl"] = metadata["privacyPolicyUrl"]
+
+    client.patch(
+        f"/appInfoLocalizations/{info_loc_id}",
+        {
+            "data": {
+                "type": "appInfoLocalizations",
+                "id": info_loc_id,
+                "attributes": info_attrs,
+            }
+        },
+    )
+    print(f"Updated app info localization ({LOCALE}): name, subtitle, privacyPolicyUrl")
+
+    # --- App Store version (copyright) + version localization ---
+    versions = client.get(
+        f"/apps/{app_id}/appStoreVersions",
+        {
+            "filter[platform]": "MAC_OS",
+            "limit": 10,
+        },
+    )
+    version_id = None
+    preferred_states = {
+        "PREPARE_FOR_SUBMISSION",
+        "DEVELOPER_REJECTED",
+        "WAITING_FOR_REVIEW",
+        "IN_REVIEW",
+        "PENDING_DEVELOPER_RELEASE",
+    }
+    for version in versions.get("data", []):
+        state = version.get("attributes", {}).get("appStoreState")
+        if state in preferred_states:
+            version_id = version["id"]
+            break
+        if version_id is None:
+            version_id = version["id"]
+
+    if not version_id:
+        print("No macOS app store version found", file=sys.stderr)
+        return 1
+
+    client.patch(
+        f"/appStoreVersions/{version_id}",
+        {
+            "data": {
+                "type": "appStoreVersions",
+                "id": version_id,
+                "attributes": {"copyright": metadata["copyright"]},
+            }
+        },
+    )
+    print(f"Updated app store version copyright: {metadata['copyright']}")
+
+    version_locs = client.get(
+        f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
+        {"filter[locale]": LOCALE, "limit": 1},
+    )
+    version_loc_list = version_locs.get("data", [])
+    if not version_loc_list:
+        print(f"No appStoreVersionLocalization for {LOCALE}", file=sys.stderr)
+        return 1
+    version_loc_id = version_loc_list[0]["id"]
+
+    version_attrs: dict[str, str] = {
+        "description": metadata["description"],
+        "keywords": metadata["keywords"],
+        "promotionalText": metadata["promotionalText"],
+    }
+    if metadata.get("supportUrl"):
+        version_attrs["supportUrl"] = metadata["supportUrl"]
+    if metadata.get("marketingUrl"):
+        version_attrs["marketingUrl"] = metadata["marketingUrl"]
+
+    client.patch(
+        f"/appStoreVersionLocalizations/{version_loc_id}",
+        {
+            "data": {
+                "type": "appStoreVersionLocalizations",
+                "id": version_loc_id,
+                "attributes": version_attrs,
+            }
+        },
+    )
+    print(
+        f"Updated version localization ({LOCALE}): description, keywords, "
+        "promotionalText, supportUrl, marketingUrl"
+    )
+
+    try:
+        client.patch(
+            f"/appStoreVersionLocalizations/{version_loc_id}",
+            {
+                "data": {
+                    "type": "appStoreVersionLocalizations",
+                    "id": version_loc_id,
+                    "attributes": {"whatsNew": metadata["whatsNew"]},
+                }
+            },
+        )
+        print(f"Updated whatsNew ({LOCALE})")
+    except RuntimeError as error:
+        if "whatsNew" in str(error) and "409" in str(error):
+            print(
+                "Skipped whatsNew: not editable for this version state "
+                "(common for first release; set it when you submit an update)."
+            )
+        else:
+            raise
+
+    print("Done. Review changes in App Store Connect.")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())