Compare commits
3 Commits
55999b7458
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0ad23f16 | ||
|
|
2d74b7d152 | ||
|
|
b65832bbd1 |
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
23
README.md
23
README.md
@@ -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.
|
||||||
|
|||||||
16
eas.json
16
eas.json
@@ -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
152
store/asc-fill.js
Normal 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
50
store/asc.js
Normal 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); });
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user