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
|
||||
Stamina was before it went paid. Expo / React Native, Android + iOS.
|
||||
Free, open-source pelvic floor trainer. A local-only take on what Stamina was
|
||||
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
|
||||
|
||||
@@ -36,6 +43,12 @@ npm run typecheck
|
||||
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
|
||||
|
||||
### Android APK (Gitea / Obtainium)
|
||||
@@ -50,7 +63,9 @@ building. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets
|
||||
|
||||
### 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
|
||||
differs from the Gitea one, so Play and Obtainium installs can't update over
|
||||
each other.
|
||||
|
||||
16
eas.json
16
eas.json
@@ -6,16 +6,26 @@
|
||||
"build": {
|
||||
"simulator": {
|
||||
"distribution": "internal",
|
||||
"ios": { "simulator": true }
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": { "resourceClass": "m-medium" }
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"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`
|
||||
- **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)
|
||||
|
||||
Reference in New Issue
Block a user