Compare commits

..

3 Commits

Author SHA1 Message Date
marcuspaico
de0ad23f16 docs: MIT license, open-source README
Some checks failed
Build & Release APK / build (push) Failing after 55m38s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 17:17:09 +10:00
marcuspaico
2d74b7d152 chore(store): App Store Connect API client and listing push script
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:56:15 +10:00
marcuspaico
b65832bbd1 chore: pin App Store Connect app id and API key for non-interactive submit
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:53:56 +10:00
6 changed files with 256 additions and 8 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Marcus Rehbock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,7 +1,14 @@
# Kegel # Kegel — "Him: Kegel Exercises for Men"
Pelvic floor trainer for sexual performance — a free, local-only take on what Free, open-source pelvic floor trainer. A local-only take on what Stamina was
Stamina was before it went paid. Expo / React Native, Android + iOS. before it went paid: no account, no ads, no analytics, no network. Expo /
React Native, Android + iOS.
- **App Store**: https://apps.apple.com/app/id6809350427 (once approved)
- **Android APK**: releases on this repo, installable via Obtainium
- **Google Play**: coming
- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html
- **License**: MIT — see `LICENSE`. Fork it, ship your own, no strings.
## How it works ## How it works
@@ -36,6 +43,12 @@ npm run typecheck
npm test # node --test over src/lib/*.test.ts npm test # node --test over src/lib/*.test.ts
``` ```
## Contributing
Issues and PRs welcome. Keep the app offline-only and dependency-light; the
whole point is that it never phones home. Run `npm run typecheck && npm test`
before opening a PR.
## Release ## Release
### Android APK (Gitea / Obtainium) ### Android APK (Gitea / Obtainium)
@@ -50,7 +63,9 @@ building. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
### App Store / Google Play (EAS) ### App Store / Google Play (EAS)
Store builds run on EAS (project `@mrmen23/kegel`, profile `production`, Only the maintainer can publish to the stores; `eas.json` references an App
Store Connect API key by key ID / issuer ID, which are identifiers, not
secrets — the `.p8` private key is never committed. Store builds run on EAS (project `@mrmen23/kegel`, profile `production`,
version source remote with auto-increment). The EAS-managed Android keystore version source remote with auto-increment). The EAS-managed Android keystore
differs from the Gitea one, so Play and Obtainium installs can't update over differs from the Gitea one, so Play and Obtainium installs can't update over
each other. each other.

View File

@@ -6,16 +6,26 @@
"build": { "build": {
"simulator": { "simulator": {
"distribution": "internal", "distribution": "internal",
"ios": { "simulator": true } "ios": {
"simulator": true
}
}, },
"production": { "production": {
"autoIncrement": true, "autoIncrement": true,
"ios": { "resourceClass": "m-medium" } "ios": {
"resourceClass": "m-medium"
}
} }
}, },
"submit": { "submit": {
"production": { "production": {
"ios": {} "ios": {
"ascApiKeyPath": "/Users/marcus/Downloads/AuthKey_L9Z6F5H5B7.p8",
"ascApiKeyId": "L9Z6F5H5B7",
"ascApiKeyIssuerId": "52ab69b8-cabe-48a1-8168-30bfc25cb5dd",
"appleTeamId": "HZ6XCKM95R",
"ascAppId": "6809350427"
}
} }
} }
} }

152
store/asc-fill.js Normal file
View File

@@ -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); });

50
store/asc.js Normal file
View File

@@ -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 <get|post|patch|delete> <path> [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); });
}

View File

@@ -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` - 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` - **Subtitle** (30 max): `Pelvic Floor Trainer & Timer`
- **Keywords** (100 max, comma-separated, no spaces): - **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): - **Promotional text** (170 max):
`Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.` `Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.`
- **Category**: Health & Fitness (secondary: Medical) - **Category**: Health & Fitness (secondary: Medical)