diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 421f9b4..cc5f25e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -61,6 +61,10 @@ jobs: run: | export ANDROID_HOME="$HOME/android-sdk" cd android + # build.gradle keeps literal versions so EAS (Play builds) can parse them; + # stamp the CI run number in for the Gitea/Obtainium APK. + sed -i "s/^\( *\)versionCode [0-9]*$/\1versionCode $VERSION_CODE/; s/^\( *\)versionName \".*\"$/\1versionName \"$VERSION_NAME\"/" app/build.gradle + grep -n "versionCode\|versionName" app/build.gradle echo "sdk.dir=$ANDROID_HOME" > local.properties ./gradlew assembleRelease --no-daemon --console=plain diff --git a/README.md b/README.md index 7f399e3..e7ae513 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,37 @@ npm test # node --test over src/lib/*.test.ts ## Release -Push to `main` → Gitea Actions on the desktop runner (`runs-on: desktop`, -never the VPS) builds a signed APK and publishes a Gitea release — install via -Obtainium. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets -`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). iOS runs via -`npx expo run:ios` from a Mac. +### Android APK (Gitea / Obtainium) + +Push to `main` → Gitea Actions builds a signed APK and publishes a Gitea +release — install via Obtainium. `android/app/build.gradle` keeps literal +`versionCode`/`versionName`; CI stamps the run number in with `sed` before +building. Keystore: `~/.android-keys/kegel-release.jks` (repo secrets +`KEYSTORE_B64`, `KEYSTORE_PASSWORD`; alias `kegel`). Beware: `expo prebuild -p android` +(used to regenerate launcher icons from `store/icons/make.js`) rewrites +`build.gradle` and drops the release `signingConfigs` block — restore it from git. + +### App Store / Google Play (EAS) + +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. + +```sh +# Apple: needs an App Store Connect API key for non-interactive auth +export EXPO_ASC_API_KEY_PATH=~/Downloads/AuthKey_L9Z6F5H5B7.p8 EXPO_ASC_KEY_ID=L9Z6F5H5B7 \ + EXPO_ASC_ISSUER_ID= EXPO_APPLE_TEAM_ID=HZ6XCKM95R EXPO_APPLE_TEAM_TYPE=INDIVIDUAL +eas build -p ios --profile production --non-interactive +eas submit -p ios --profile production --latest --non-interactive # creates the ASC record on first run + +# Android: needs a Play service-account JSON with release access +eas build -p android --profile production --non-interactive +eas submit -p android --profile production --latest --key --track internal +``` + +Listing copy, keyword rationale and screenshot generation live in `store/` +(`node store/screenshots/make.js` renders `store/screenshots/raw/*.png` into +store-sized slides). Privacy policy is served from +https://rehbock.xyz/kegel/privacy.html (source in `store/web/`). iOS dev runs +via `npx expo run:ios` from a Mac. diff --git a/android/app/build.gradle b/android/app/build.gradle index ff0f5d4..ce57fe8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,8 +92,8 @@ android { applicationId 'com.marcus.kegel' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger()) - versionName (System.getenv("VERSION_NAME") ?: "1.0-dev") + versionCode 1 + versionName "1.0.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png index 13b864f..3c4849c 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png index b17874f..018ba02 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png index 9bb764b..c954a74 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png index 8792f61..10f2f0e 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png index 8b97833..e8c7fd5 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 9509e7b..141d02c 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index c52483d..639bf24 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp index a70c279..51609fc 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 372254b..c9f9d98 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index b5cdbf1..23eba94 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index f402a71..e82ede6 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp index 0f12504..d31d46f 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 1cac01c..4a339b7 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 0295d23..00fae44 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index f8462b9..19c06b3 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp index 6b9a1e1..17edfe4 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 548cc42..1abd6db 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index a10506d..3a74f4f 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index ca4f05d..f2e77f7 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp index f462f99..3320d72 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 3c67b88..f925170 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index ad9a654..0e41126 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 010a6bf..6d42a7f 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp index 6146576..f66f101 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index ecf8b93..749361c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 8a55f84..c504b23 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #0F1115 - #0F1115 + #160B0B + #160B0B #023c69 - #0F1115 + #160B0B \ No newline at end of file diff --git a/app.json b/app.json index 603bdce..b01e9c7 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,11 @@ "userInterfaceStyle": "dark", "backgroundColor": "#160B0B", "ios": { - "bundleIdentifier": "com.marcus.kegel" + "bundleIdentifier": "com.marcus.kegel", + "supportsTablet": false, + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "adaptiveIcon": { @@ -34,6 +38,13 @@ "experiments": { "typedRoutes": true, "reactCompiler": true - } + }, + "extra": { + "router": {}, + "eas": { + "projectId": "0edd3f95-b270-4aef-aa1c-f25a9ee57d7f" + } + }, + "owner": "mrmen23" } } diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png index 2f6ef48..be04735 100644 Binary files a/assets/images/android-icon-foreground.png and b/assets/images/android-icon-foreground.png differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png index 6e24b82..ecfa868 100644 Binary files a/assets/images/android-icon-monochrome.png and b/assets/images/android-icon-monochrome.png differ diff --git a/assets/images/icon.png b/assets/images/icon.png index 4f87d29..2b46e03 100644 Binary files a/assets/images/icon.png and b/assets/images/icon.png differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png index e0f89be..02ee0ae 100644 Binary files a/assets/images/splash-icon.png and b/assets/images/splash-icon.png differ diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..514e69b --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 16.0.1", + "appVersionSource": "remote" + }, + "build": { + "simulator": { + "distribution": "internal", + "ios": { "simulator": true } + }, + "production": { + "autoIncrement": true, + "ios": { "resourceClass": "m-medium" } + } + }, + "submit": { + "production": { + "ios": {} + } + } +} diff --git a/store/icons/make.js b/store/icons/make.js new file mode 100644 index 0000000..e0904f2 --- /dev/null +++ b/store/icons/make.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Generate the app icon set from one SVG mark, rendered through headless Chrome. +// +// node store/icons/make.js +// +// Writes into assets/images/: +// icon.png 1024×1024 opaque (iOS + fallback) +// android-icon-foreground.png 1024×1024 alpha (adaptive foreground, mark in the 66% safe zone) +// android-icon-monochrome.png 1024×1024 alpha (themed icon, white mark) +// splash-icon.png 512×512 alpha (splash, mark only) +// +// The mark is the session ring from the app: a thick accent ring with a filled +// core — "squeeze". Colours from src/lib/theme.ts. After running, regenerate the +// committed Android res with `npx expo prebuild -p android --no-install`. + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const OUT = path.join(__dirname, "..", "..", "assets", "images"); +const TMP = path.join(__dirname, ".tmp"); + +const C = { bg: "#160B0B", bg2: "#2A1112", accent: "#E7402E", accentDeep: "#B8271A", text: "#F7ECEA" }; + +// size: canvas px; scale: fraction of canvas the mark spans; color overrides for monochrome. +function mark(size, scale, { ring = C.accent, core = C.accent, glow = true } = {}) { + const c = size / 2; + const R = (size * scale) / 2; // outer radius of the ring + const stroke = R * 0.3; + const ringR = R - stroke / 2; + const coreR = R * 0.32; + return ` + + + + + + + ${glow ? `` : ""} + + + `; +} + +function svg(size, body, bg) { + return ` + ${bg ? ` + + ` : ""} + ${body} + `; +} + +function render(svgText, size, out, transparent) { + fs.mkdirSync(TMP, { recursive: true }); + const file = path.join(TMP, path.basename(out, ".png") + ".html"); + fs.writeFileSync( + file, + `${svgText}`, + ); + const args = [ + "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1", + `--window-size=${size},${size}`, `--screenshot=${out}`, + ]; + if (transparent) args.push("--default-background-color=00000000"); + args.push("file://" + file); + execFileSync(CHROME, args, { stdio: "ignore" }); +} + +function main() { + // iOS: opaque, mark fills ~62% of the canvas (Apple masks the corners itself). + render(svg(1024, mark(1024, 0.62), true), 1024, path.join(OUT, "icon.png"), false); + // Android adaptive foreground: content must sit inside the central 66% safe zone. + render(svg(1024, mark(1024, 0.46, { glow: false })), 1024, path.join(OUT, "android-icon-foreground.png"), true); + // Android monochrome: single-colour silhouette, same safe zone. + render(svg(1024, mark(1024, 0.46, { ring: "#FFFFFF", core: "#FFFFFF", glow: false })), 1024, path.join(OUT, "android-icon-monochrome.png"), true); + // Splash: mark only, on transparent; app.json sets the background colour. + render(svg(512, mark(512, 0.8, { glow: false })), 512, path.join(OUT, "splash-icon.png"), true); + fs.rmSync(TMP, { recursive: true, force: true }); + console.log("wrote icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png"); +} + +main(); diff --git a/store/listing.md b/store/listing.md new file mode 100644 index 0000000..8353931 --- /dev/null +++ b/store/listing.md @@ -0,0 +1,68 @@ +# Store listing copy + +Keyword data (Google monthly volume, en-US, Sep 2026): "kegel exercises" 135k, +"kegel" 90k, "kegel exercises for men" 74k, "kegels for men" 15k, +"reverse kegel" 10k, "pelvic floor trainer" 3.6k, "kegel trainer" 1.9k. +Title stacks the three highest-volume phrases; subtitle/short description carry +the next tier. Never repeat a title word in the Apple keyword field. + +## Apple App Store + +- **Name** (30 max): `Him: Kegel Exercises for Men` + - 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` +- **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) +- **Age rating**: answer the questionnaire honestly — Medical/Treatment Info: Infrequent; everything else None → expect 12+ or 17+. Do not mention sex in the listing; keep it fitness-framed. +- **Privacy policy URL**: https://rehbock.xyz/kegel/privacy.html +- **App Privacy**: "Data Not Collected" +- **Support URL**: https://rehbock.xyz/kegel/privacy.html (or a GitHub/Gitea repo page) +- **Copyright**: 2026 Marcus Rehbock +- **Review notes**: `No account or sign-in. Tap Start on the home screen to run a full guided session (about 4 minutes). Haptics cue each rep. All data stays on device.` + +### Description (4000 max) + +``` +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. +``` + +## Google Play + +- **App name** (30 max): `Him: Kegel Exercises for Men` +- **Short description** (80 max): `Free pelvic floor trainer & timer. Guided kegels that get harder as you improve.` +- **Full description**: same as the Apple description above. +- **Category**: Health & Fitness +- **Data safety**: no data collected, no data shared, no encryption in transit needed (no network). +- **Content rating**: IARC questionnaire — no violence/sexual content/etc. Fitness app. +- **Contact email**: marcus@thepai.co +- **Privacy policy**: https://rehbock.xyz/kegel/privacy.html + +## Screenshots + +Generated by `store/screenshots/make.js` from simulator captures. Required sizes: + +- Apple iPhone 6.9" (1320×2868) — mandatory since 2024; Apple scales down for other iPhones. +- Google Play phone: 1080×1920 or larger, 16:9 to 9:16, plus a 1024×500 feature graphic. diff --git a/store/screenshots/make.js b/store/screenshots/make.js new file mode 100644 index 0000000..0921594 --- /dev/null +++ b/store/screenshots/make.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// Compose store screenshots from raw simulator captures. +// +// node store/screenshots/make.js +// +// Inputs: store/screenshots/raw/.png (iPhone 6.9" captures, 1320×2868) +// Outputs: store/screenshots/out/ios/.png 1320×2868 (App Store, 6.9") +// store/screenshots/out/play/.png 1080×1920 (Google Play phone) +// store/screenshots/out/play/feature.png 1024×500 (Play feature graphic) +// +// Renders HTML through headless Chrome so we can use real typography. Colours +// come from src/lib/theme.ts. + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const ROOT = __dirname; +const RAW = path.join(ROOT, "raw"); +const OUT = path.join(ROOT, "out"); +const TMP = path.join(ROOT, ".tmp"); + +const C = { + bg: "#160B0B", + surface: "#241213", + border: "#42201F", + text: "#F7ECEA", + muted: "#A98A86", + accent: "#E7402E", + push: "#FF9D3B", +}; + +const slides = [ + { + raw: "home.png", + h: "Kegels that get
harder as you do", + sub: "Holds grow automatically every 3 sessions", + }, + { + raw: "session-hold.png", + h: "Guided by timer
and haptics", + sub: "Train with the screen off or eyes closed", + }, + { + raw: "session-push.png", + h: "Squeezes and
reverse kegels", + sub: "Both sides of the pelvic floor, every session", + }, + { + raw: "streak.png", + h: "Build the habit", + sub: "Day streak, best streak, two-week history", + }, + { + raw: "settings.png", + h: "Free. Offline.
No account.", + sub: "Nothing leaves your phone. No ads, ever.", + }, +]; + +const dataUri = (file) => "data:image/png;base64," + fs.readFileSync(file).toString("base64"); + +function slideHtml({ w, h, cap, sub, img, phoneW, capSize, subSize, top, gap }) { + return `
+
+
${cap}
${sub}
+
+
`; +} + +function featureHtml(icon) { + return `
+
+ +
Him.
+
Kegel exercises for men.
Free, offline, no account.
+
`; +} + +function render(html, w, h, out) { + fs.mkdirSync(TMP, { recursive: true }); + const file = path.join(TMP, path.basename(out, ".png") + ".html"); + fs.writeFileSync(file, html); + execFileSync(CHROME, [ + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--force-device-scale-factor=1", + `--window-size=${w},${h}`, + `--screenshot=${out}`, + "file://" + file, + ], { stdio: "ignore" }); +} + +function main() { + for (const s of slides) { + const f = path.join(RAW, s.raw); + if (!fs.existsSync(f)) throw new Error(`missing raw capture: ${f}`); + } + fs.mkdirSync(path.join(OUT, "ios"), { recursive: true }); + fs.mkdirSync(path.join(OUT, "play"), { recursive: true }); + + slides.forEach((s, i) => { + const img = dataUri(path.join(RAW, s.raw)); + // App Store 6.9": caption then phone; phone overflows the bottom edge on purpose. + render( + slideHtml({ w: 1320, h: 2868, cap: s.h, sub: s.sub, img, phoneW: 1100, capSize: 104, subSize: 46, top: 150, gap: 96 }), + 1320, 2868, path.join(OUT, "ios", `${i + 1}.png`), + ); + // Play phone (16:9): same layout, smaller phone. + render( + slideHtml({ w: 1080, h: 1920, cap: s.h, sub: s.sub, img, phoneW: 720, capSize: 78, subSize: 36, top: 110, gap: 70 }), + 1080, 1920, path.join(OUT, "play", `${i + 1}.png`), + ); + }); + + const icon = dataUri(path.join(ROOT, "..", "..", "assets", "images", "icon.png")); + render(featureHtml(icon), 1024, 500, path.join(OUT, "play", "feature.png")); + fs.rmSync(TMP, { recursive: true, force: true }); + console.log(`wrote ${slides.length} iOS + ${slides.length} Play slides and feature.png to ${OUT}`); +} + +main(); diff --git a/store/screenshots/out/ios/1.png b/store/screenshots/out/ios/1.png new file mode 100644 index 0000000..ce67f6c Binary files /dev/null and b/store/screenshots/out/ios/1.png differ diff --git a/store/screenshots/out/ios/2.png b/store/screenshots/out/ios/2.png new file mode 100644 index 0000000..f2c17c7 Binary files /dev/null and b/store/screenshots/out/ios/2.png differ diff --git a/store/screenshots/out/ios/3.png b/store/screenshots/out/ios/3.png new file mode 100644 index 0000000..9102516 Binary files /dev/null and b/store/screenshots/out/ios/3.png differ diff --git a/store/screenshots/out/ios/4.png b/store/screenshots/out/ios/4.png new file mode 100644 index 0000000..810f592 Binary files /dev/null and b/store/screenshots/out/ios/4.png differ diff --git a/store/screenshots/out/ios/5.png b/store/screenshots/out/ios/5.png new file mode 100644 index 0000000..5e52757 Binary files /dev/null and b/store/screenshots/out/ios/5.png differ diff --git a/store/screenshots/out/play/1.png b/store/screenshots/out/play/1.png new file mode 100644 index 0000000..c484c2d Binary files /dev/null and b/store/screenshots/out/play/1.png differ diff --git a/store/screenshots/out/play/2.png b/store/screenshots/out/play/2.png new file mode 100644 index 0000000..83d9bee Binary files /dev/null and b/store/screenshots/out/play/2.png differ diff --git a/store/screenshots/out/play/3.png b/store/screenshots/out/play/3.png new file mode 100644 index 0000000..69311c0 Binary files /dev/null and b/store/screenshots/out/play/3.png differ diff --git a/store/screenshots/out/play/4.png b/store/screenshots/out/play/4.png new file mode 100644 index 0000000..fdecfd1 Binary files /dev/null and b/store/screenshots/out/play/4.png differ diff --git a/store/screenshots/out/play/5.png b/store/screenshots/out/play/5.png new file mode 100644 index 0000000..70b3111 Binary files /dev/null and b/store/screenshots/out/play/5.png differ diff --git a/store/screenshots/out/play/feature.png b/store/screenshots/out/play/feature.png new file mode 100644 index 0000000..6c0fe39 Binary files /dev/null and b/store/screenshots/out/play/feature.png differ diff --git a/store/screenshots/raw/home.png b/store/screenshots/raw/home.png new file mode 100644 index 0000000..50570b9 Binary files /dev/null and b/store/screenshots/raw/home.png differ diff --git a/store/screenshots/raw/session-hold.png b/store/screenshots/raw/session-hold.png new file mode 100644 index 0000000..518f349 Binary files /dev/null and b/store/screenshots/raw/session-hold.png differ diff --git a/store/screenshots/raw/session-push.png b/store/screenshots/raw/session-push.png new file mode 100644 index 0000000..28bc397 Binary files /dev/null and b/store/screenshots/raw/session-push.png differ diff --git a/store/screenshots/raw/settings.png b/store/screenshots/raw/settings.png new file mode 100644 index 0000000..2d9c168 Binary files /dev/null and b/store/screenshots/raw/settings.png differ diff --git a/store/screenshots/raw/streak.png b/store/screenshots/raw/streak.png new file mode 100644 index 0000000..50570b9 Binary files /dev/null and b/store/screenshots/raw/streak.png differ diff --git a/store/web/privacy.html b/store/web/privacy.html new file mode 100644 index 0000000..9b4d948 --- /dev/null +++ b/store/web/privacy.html @@ -0,0 +1,40 @@ + + + + + +Him: Kegel Exercises for Men – Privacy Policy + + + +

Him: Kegel Exercises for Men – Privacy Policy

+

Effective 7 September 2026

+ +

Him: Kegel Exercises for Men ("the app") is a free pelvic floor exercise timer. It is built to work entirely on your device.

+ +

Data we collect

+

None. The app does not collect, transmit, sell, or share any personal data. There is no account, no sign-up, no analytics, no advertising, and no crash reporting.

+ +

Data stored on your device

+

Your session count, difficulty level, and completion history are saved only in the app's local storage on your phone. This data never leaves your device. Deleting the app deletes it.

+ +

Network access

+

The app makes no network requests. It works fully offline.

+ +

Health disclaimer

+

The app is a general fitness timer, not a medical device, and provides no medical advice. Consult a healthcare professional before starting pelvic floor training if you have a medical condition, are pregnant, or have recently had surgery.

+ +

Children

+

The app is not directed at children under 17 and collects no data from anyone.

+ +

Changes

+

If this policy changes, the updated version will be posted at this address.

+ +

Contact

+

Marcus Rehbock – marcus@thepai.co

+ +