diff --git a/store/asc-fill.js b/store/asc-fill.js new file mode 100644 index 0000000..5fd6241 --- /dev/null +++ b/store/asc-fill.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Push the App Store listing from store/listing.md into App Store Connect. +// Idempotent: re-running overwrites text fields and replaces the screenshot set. +// +// node store/asc-fill.js + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { api, APP_ID } = require("./asc"); + +const NAME = "Him: Kegel Exercises for Men"; +const SUBTITLE = "Pelvic Floor Trainer & Timer"; +const PRIVACY_URL = "https://rehbock.xyz/kegel/privacy.html"; +const SUPPORT_URL = "https://rehbock.xyz/kegel/privacy.html"; +const KEYWORDS = "kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided"; +const PROMO = "Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger."; +const DESCRIPTION = `A free pelvic floor trainer for men. No account, no ads, no subscription, no data collection. Everything runs on your phone. + +Each session is a guided, four-block routine driven by a step timer with haptic cues so you can train with the screen off or your eyes closed: + +• Holds — 8 slow squeeze-and-hold reps. The core of the workout. Holds start at 5 seconds and automatically grow by 1 second every 3 completed sessions, up to 30 seconds. +• Pushes — 5 reverse kegels (gently bear down) to balance tension and relaxation. +• Quick squeezes — 10 one-second burst contractions. +• Quick pushes — 10 one-second burst releases. + +Progressive overload is built in. Finish sessions and the holds get longer on their own. Want to go faster or slower? Adjust hold time manually in Settings. + +Stay consistent with a day streak, your best streak, and a two-week activity strip on the home screen. + +Why kegels? +Pelvic floor exercises strengthen the muscles that support the bladder and bowel and are widely recommended for bladder control, post-prostate-surgery recovery, and general pelvic health. Like any muscle, they respond to regular, progressively harder training. + +What this app is not +It is a timer and progression tracker, not a medical device, and gives no medical advice. If you have a medical condition or recent surgery, talk to a clinician first. + +Privacy +Zero network access. Your session count, difficulty and history are stored only on your device. Delete the app and the data is gone.`; + +const SHOTS_DIR = path.join(__dirname, "screenshots", "out", "ios"); +const DISPLAY_TYPE = "APP_IPHONE_67"; // 6.9" / 6.7" iPhones (1320×2868 accepted) + +async function first(p) { + const r = await api("GET", p); + return r.data[0]; +} + +async function main() { + const appInfo = await first(`/v1/apps/${APP_ID}/appInfos`); + const infoLoc = await first(`/v1/appInfos/${appInfo.id}/appInfoLocalizations`); + const version = await first(`/v1/apps/${APP_ID}/appStoreVersions?filter[platform]=IOS`); + const verLoc = await first(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations`); + console.log(`appInfo ${appInfo.id} / version ${version.attributes.versionString} (${version.attributes.appStoreState})`); + + // 1. Name, subtitle, privacy policy + await api("PATCH", `/v1/appInfoLocalizations/${infoLoc.id}`, { + data: { type: "appInfoLocalizations", id: infoLoc.id, attributes: { name: NAME, subtitle: SUBTITLE, privacyPolicyUrl: PRIVACY_URL } }, + }); + console.log("✔ name / subtitle / privacy URL"); + + // 2. Description, keywords, promo, support URL + await api("PATCH", `/v1/appStoreVersionLocalizations/${verLoc.id}`, { + data: { + type: "appStoreVersionLocalizations", + id: verLoc.id, + attributes: { description: DESCRIPTION, keywords: KEYWORDS, promotionalText: PROMO, supportUrl: SUPPORT_URL }, + }, + }); + console.log("✔ description / keywords / promo / support URL"); + + // 3. Categories + await api("PATCH", `/v1/appInfos/${appInfo.id}`, { + data: { + type: "appInfos", + id: appInfo.id, + relationships: { + primaryCategory: { data: { type: "appCategories", id: "HEALTH_AND_FITNESS" } }, + secondaryCategory: { data: { type: "appCategories", id: "MEDICAL" } }, + }, + }, + }); + console.log("✔ categories"); + + // 4. Age rating: fitness timer, health topic yes, mild medical info, nothing else + const none = "NONE"; + await api("PATCH", `/v1/ageRatingDeclarations/${appInfo.id}`, { + data: { + type: "ageRatingDeclarations", + id: appInfo.id, + attributes: { + alcoholTobaccoOrDrugUseOrReferences: none, + contests: none, + gamblingSimulated: none, + gunsOrOtherWeapons: none, + horrorOrFearThemes: none, + matureOrSuggestiveThemes: none, + medicalOrTreatmentInformation: "INFREQUENT_OR_MILD", + profanityOrCrudeHumor: none, + sexualContentGraphicAndNudity: none, + sexualContentOrNudity: none, + violenceCartoonOrFantasy: none, + violenceRealistic: none, + violenceRealisticProlongedGraphicOrSadistic: none, + advertising: false, + gambling: false, + healthOrWellnessTopics: true, + lootBox: false, + messagingAndChat: false, + parentalControls: false, + socialMedia: false, + unrestrictedWebAccess: false, + userGeneratedContent: false, + ageAssurance: false, + }, + }, + }); + console.log("✔ age rating declaration"); + + // 5. Screenshots: replace the iPhone 6.9" set + const sets = await api("GET", `/v1/appStoreVersionLocalizations/${verLoc.id}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}`); + for (const s of sets.data) await api("DELETE", `/v1/appScreenshotSets/${s.id}`); + const set = await api("POST", "/v1/appScreenshotSets", { + data: { + type: "appScreenshotSets", + attributes: { screenshotDisplayType: DISPLAY_TYPE }, + relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: verLoc.id } } }, + }, + }); + const files = fs.readdirSync(SHOTS_DIR).filter((f) => /^\d+\.png$/.test(f)).sort((a, b) => parseInt(a) - parseInt(b)); + for (const f of files) { + const buf = fs.readFileSync(path.join(SHOTS_DIR, f)); + const shot = await api("POST", "/v1/appScreenshots", { + data: { + type: "appScreenshots", + attributes: { fileName: f, fileSize: buf.length }, + relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: set.data.id } } }, + }, + }); + for (const op of shot.data.attributes.uploadOperations) { + const headers = Object.fromEntries(op.requestHeaders.map((h) => [h.name, h.value])); + const res = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) }); + if (!res.ok) throw new Error(`upload chunk failed ${res.status} for ${f}`); + } + await api("PATCH", `/v1/appScreenshots/${shot.data.id}`, { + data: { type: "appScreenshots", id: shot.data.id, attributes: { uploaded: true, sourceFileChecksum: crypto.createHash("md5").update(buf).digest("hex") } }, + }); + console.log(`✔ screenshot ${f}`); + } + console.log("done"); +} + +main().catch((e) => { console.error(e.message); process.exit(1); }); diff --git a/store/asc.js b/store/asc.js new file mode 100644 index 0000000..aa7f30b --- /dev/null +++ b/store/asc.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Minimal App Store Connect API client (ES256 JWT via node:crypto, no deps). +// +// node store/asc.js get /v1/apps/6809350427 +// node store/asc.js patch /v1/appInfos/ID '{"data":{...}}' +// +// Reads the key from eas.json submit.production.ios (ascApiKeyPath/Id/IssuerId). + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const eas = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "eas.json"), "utf8")); +const cfg = eas.submit.production.ios; +const APP_ID = cfg.ascAppId; +const BASE = "https://api.appstoreconnect.apple.com"; + +function b64url(buf) { + return Buffer.from(buf).toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function token() { + const now = Math.floor(Date.now() / 1000); + const header = b64url(JSON.stringify({ alg: "ES256", kid: cfg.ascApiKeyId, typ: "JWT" })); + const payload = b64url(JSON.stringify({ iss: cfg.ascApiKeyIssuerId, iat: now, exp: now + 19 * 60, aud: "appstoreconnect-v1" })); + const key = fs.readFileSync(cfg.ascApiKeyPath, "utf8"); + const sig = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" }); + return `${header}.${payload}.${b64url(sig)}`; +} + +async function api(method, p, body, extraHeaders = {}) { + const res = await fetch(p.startsWith("http") ? p : BASE + p, { + method, + headers: { Authorization: `Bearer ${token()}`, "Content-Type": "application/json", ...extraHeaders }, + body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { json = text ? JSON.parse(text) : null; } catch { json = text; } + if (!res.ok) throw new Error(`${method} ${p} -> ${res.status}\n${JSON.stringify(json, null, 2)}`); + return json; +} + +module.exports = { api, APP_ID, token }; + +if (require.main === module) { + const [, , method, p, body] = process.argv; + if (!method || !p) { console.error("usage: asc.js [json]"); process.exit(1); } + api(method.toUpperCase(), p, body).then((r) => console.log(JSON.stringify(r, null, 2))).catch((e) => { console.error(e.message); process.exit(1); }); +} diff --git a/store/listing.md b/store/listing.md index 8353931..bc6f39b 100644 --- a/store/listing.md +++ b/store/listing.md @@ -12,7 +12,7 @@ the next tier. Never repeat a title word in the Apple keyword field. - Fallback if taken: `Him: Kegel Exercises for Men` is 28 chars; if Apple rejects, drop to `Him: Kegel Exercises` - **Subtitle** (30 max): `Pelvic Floor Trainer & Timer` - **Keywords** (100 max, comma-separated, no spaces): - `kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,routine,guided` + `kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided` - **Promotional text** (170 max): `Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.` - **Category**: Health & Fitness (secondary: Medical)