← poker-tutor
"""Apply the Android (Skip Fuse) port recipe to a tutor repo. Text-anchored; asserts on every anchor."""
import sys, os, re, glob, shutil, subprocess
repo, module, appid, label, macapp = sys.argv[1:6] # e.g. ~/plo-tutor PLOTutorAndroid com.thedigitaladlab.plotutor "PLO Tutor" PLOTutorApp
os.chdir(repo)
pkg = ".".join(re.findall(r'[A-Z][a-z]*|[A-Z]+(?![a-z])', module)).lower() # PLOTutorAndroid -> plo.tutor.android
print("derived Android package:", pkg)
def edit(path, pairs, must=True):
s = open(path).read()
for a, b in pairs:
if a not in s:
if must: raise SystemExit(f"ANCHOR MISSING in {path}: {a[:80]!r}")
else: print(" (skipped, not present)", path, a[:50].replace("\n"," ")); continue
s = s.replace(a, b)
open(path, "w").write(s)
# ---------- Package.swift ----------
s = open("Package.swift").read()
if "ANDROID_HOME" not in s:
s = s.replace("import PackageDescription\n\nlet package = Package(", '''import PackageDescription
// Android. Skip (https://skip.dev) compiles this same Swift for Android and renders the SwiftUI
// through its own stand-in. Everything Android-only below is declared when the Android tooling is
// present — `skip android build` and the Gradle project in `Android/` both set ANDROID_HOME — and
// otherwise not at all: a Mac or iPhone build fetches nothing extra, links nothing extra, and is
// the build it always was.
let android = Context.environment["ANDROID_HOME"] != nil
|| (Context.environment["TARGET_OS_ANDROID"] ?? "0") != "0"
let package = Package(''', 1)
s = s.replace(" ],\n targets: [", ''' ],
dependencies: android ? [
.package(url: "https://github.com/skiptools/skip-fuse-ui.git", from: "1.0.0"),
.package(url: "https://github.com/skiptools/skip-fuse.git", from: "1.0.3"),
.package(url: "https://github.com/skiptools/skip.git", from: "1.9.8"),
] : [],
targets: [''', 1)
a = ''' .target(name: "PokerStore", dependencies: ["PokerSession"]),'''
assert a in s
s = s.replace(a, ''' .target(name: "PokerStore", dependencies: ["PokerSession"]
+ (android ? [.product(name: "SkipFuse", package: "skip-fuse")] : [])),''')
a = ''' .target(name: "PokerUI",
dependencies: ["PokerCore", "PokerAI", "PokerCoach", "PokerSession", "PokerStore"],
resources: [.process("Resources")]),'''
assert a in s
s = s.replace(a, ''' .target(name: "PokerUI",
dependencies: ["PokerCore", "PokerAI", "PokerCoach", "PokerSession", "PokerStore"]
+ (android ? [.product(name: "SkipFuseUI", package: "skip-fuse-ui")] : []),
resources: [.process("Resources")]),''')
s = s.rstrip("\n") + f'''
// The Android app itself: a shell around PokerUI that Skip's Kotlin side can start. Declared only
// for Android builds so that Xcode and `swift build` on the Mac never see it — see `android` above.
if android {{
package.products += [
.library(name: "{module}", type: .dynamic, targets: ["{module}"]),
]
package.targets += [
.target(name: "{module}",
dependencies: ["PokerUI", .product(name: "SkipFuseUI", package: "skip-fuse-ui")],
plugins: [.plugin(name: "skipstone", package: "skip")]),
]
}}
'''
open("Package.swift", "w").write(s)
# ---------- SubscriptionStore ----------
p = "Sources/PokerStore/SubscriptionStore.swift"; s = open(p).read()
if "canImport(StoreKit)" not in s:
head = "import Foundation\nimport StoreKit\nimport PokerSession\n"
assert s.startswith(head)
product = re.search(r'yearlyProductID = "([^"]+)"', s).group(1)
body = s[len(head):]
s = "import Foundation\nimport PokerSession\nimport Observation\n#if os(Android)\nimport SkipFuse\n#endif\n\n#if canImport(StoreKit)\nimport StoreKit\n" + body.rstrip("\n") + f'''
#else
/// The store on a device with no App Store — Android, for now.
///
/// The same shape as the Apple one above, so nothing that sits on top of it has to know which
/// device it is on. It never says anybody is subscribed, because it has nobody to ask: Google Play
/// billing is the piece still to be built, and until it exists this class answers "not subscribed"
/// and explains why when somebody tries to buy. `hasCheckedWithApple` keeps its name so the gate
/// above it reads the same on every device; here it means "checked, and there is nothing to check".
@Observable
@MainActor
public final class SubscriptionStore {{
public static let yearlyProductID = "{product}"
public enum Status: Equatable, Sendable {{
case idle
case working
case waitingForApproval
case failed(String)
}}
public private(set) var isSubscribed = false
public private(set) var hasCheckedWithApple = false
public private(set) var displayPrice: String?
public private(set) var status: Status = .idle
/// Called on the main actor after the answer changes. The app uses it to take a paywall
/// down the moment a purchase lands, wherever it landed.
@ObservationIgnored public var didChange: (@MainActor () -> Void)?
public init(productID: String = SubscriptionStore.yearlyProductID) {{}}
public static func stub(subscribed: Bool, price: String? = nil) -> SubscriptionStore {{
let store = SubscriptionStore()
store.isSubscribed = subscribed
store.hasCheckedWithApple = true
store.displayPrice = price
return store
}}
public func start() async {{ hasCheckedWithApple = true }}
public func refreshEntitlement() async {{ hasCheckedWithApple = true }}
public func purchase() async {{ status = .failed(Copy.notOnThisDevice) }}
public func restore() async {{ status = .failed(Copy.notOnThisDevice) }}
public func dismissMessage() {{
if case .failed = status {{ status = .idle }}
if case .waitingForApproval = status {{ status = .idle }}
}}
}}
enum Copy {{
static let notOnThisDevice =
"Subscriptions are not available on this device yet."
}}
#endif
'''
# the Apple class: ObservableObject -> @Observable
a = "@MainActor\npublic final class SubscriptionStore: ObservableObject {"
assert s.count(a) == 1
s = s.replace(a, "@Observable\n@MainActor\npublic final class SubscriptionStore {")
s = s.replace(" @Published public private(set) var", " public private(set) var")
hook = ''' /// Called on the main actor after Apple's answer changes. The app uses it to take a paywall
/// down the moment a purchase lands, wherever it landed.
@ObservationIgnored public var didChange: (@MainActor () -> Void)?
'''
a = " private let productID: String\n"; assert a in s
s = s.replace(a, hook + " @ObservationIgnored private let productID: String\n", 1)
a = " private var product: Product?\n private var listener: Task<Void, Never>?\n"; assert a in s
s = s.replace(a, " @ObservationIgnored private var product: Product?\n @ObservationIgnored private var listener: Task<Void, Never>?\n", 1)
a = " isSubscribed = owned\n hasCheckedWithApple = true\n"; assert a in s
s = s.replace(a, a + " didChange?()\n", 1)
open(p, "w").write(s)
# ---------- AppState ----------
p = "Sources/PokerUI/AppState.swift"; s = open(p).read()
if "@Observable" not in s:
edit(p, [
("import Foundation\nimport Combine\n", "import Foundation\nimport Observation\n#if os(Android)\nimport SkipFuse\n#endif\n"),
("@MainActor\npublic final class AppState: ObservableObject {", "@Observable\n@MainActor\npublic final class AppState {"),
(" session = AppState.ownSession()\n", " _session = AppState.ownSession()\n"),
(" self.session = session\n", " self._session = session\n"),
(" private var subscriptionWatch: AnyCancellable?\n\n", ""),
(""" subscriptionWatch = subscription.objectWillChange.sink { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.objectWillChange.send()
self.dismissPaywallIfPaid()
}
}
""", """ subscription.didChange = { [weak self] in self?.dismissPaywallIfPaid() }
"""),
(" public let session: TableSession\n", """ /// The session is a plain class that SwiftUI cannot watch. Every screen reaches it through
/// this property, which reads `generation` on the way — so bumping that counter (`redraw()`)
/// redraws every screen that read anything from the session. That is the same "something
/// moved" signal `ObservableObject` used to give for free, kept on purpose: the alternative
/// is teaching the session about SwiftUI, and it is the one part of the app that must not know.
public var session: TableSession { _ = generation; return _session }
private let _session: TableSession
private var generation = 0
/// Tell the screens the session moved.
private func redraw() { generation &+= 1 }
"""),
])
s = open(p).read()
s = re.sub(r"^(\s+)session = staged\n", r"\1_session = staged\n", s, flags=re.M)
s = s.replace(" @Published ", " ").replace("objectWillChange.send()", "redraw()")
open(p, "w").write(s)
# ---------- screens ----------
for f in glob.glob("Sources/PokerUI/*.swift"):
s = open(f).read(); o = s
s = s.replace("@EnvironmentObject var app: AppState", "@Environment(AppState.self) var app")
s = s.replace(".environmentObject(app)", ".environment(app)")
if s != o: open(f, "w").write(s)
edit("Sources/PokerUI/RootView.swift", [(" public var body: some View {\n GeometryReader { geo in",
" public var body: some View {\n @Bindable var app = app\n GeometryReader { geo in")], must=False)
for f in [f"Sources/{macapp}/{macapp}.swift"] + glob.glob("App/*/PhoneApp.swift"):
edit(f, [("@StateObject private var app = AppState()", "@State private var app = AppState()"),
(".environmentObject(app)", ".environment(app)")], must=False)
edit(f"Sources/{macapp}/{macapp}.swift", [(""" .windowStyle(.hiddenTitleBar)
.defaultSize(width: 1240, height: 820)
""", """ // Mac-only window furniture. Skip's Android packaging compiles every target for the
// iPhone first, this one included, and iPhones have no windows to style.
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1240, height: 820)
#endif
""")], must=False)
# ---------- Theme: imports + shims ----------
p = "Sources/PokerUI/Theme.swift"; s = open(p).read()
if "hoverHighlight" not in s:
a = "import Foundation\nimport SwiftUI\n"; assert s.startswith(a)
s = s.replace(a, """#if !os(Android)
import Foundation
#endif
// On Android, Skip's SwiftUI brings Foundation with it, and importing Foundation here as well
// leaves two `CGFloat`s in scope (Foundation's own and SwiftUI's stand-in) — nearly four hundred
// "ambiguous" errors, all from this one line. SwiftUI re-exports Foundation on Apple's platforms
// too, so nothing is lost either way.
import SwiftUI
""", 1)
s = s.replace(".onHover { hovering = $0 && enabled }", ".hoverHighlight { hovering = $0 && enabled }")
s = s.replace(".onHover { hovering = $0 }", ".hoverHighlight { hovering = $0 }")
s = s.replace(".monospacedDigit()", ".digitsAligned()")
s = s.rstrip("\n") + '''
// MARK: - Pointer-only niceties
extension View {
/// Hover feedback where there is a pointer to hover with. A phone has none, and Skip's Android
/// SwiftUI has no such modifier, so there it is a no-op rather than a build error.
@ViewBuilder func hoverHighlight(_ perform: @escaping (Bool) -> Void) -> some View {
#if os(Android)
self
#else
self.onHover(perform: perform)
#endif
}
/// A tooltip, on the platforms that draw them.
@ViewBuilder func tooltip(_ text: String) -> some View {
#if os(Android)
self
#else
self.help(text)
#endif
}
}
#if os(Android)
extension View {
/// Skip's SwiftUI has no `contentShape` yet. On Android every button is tappable across its
/// whole frame already, so this leaves the view as it is rather than failing the build.
func contentShape<S: Shape>(_ shape: S) -> some View { self }
}
#endif
// MARK: - Modifiers Skip's Android SwiftUI does not have yet
//
// Each of these is the real modifier on Apple's platforms and a no-op on Android, so a screen can
// ask for the nicety once and the build decides. When Skip grows the modifier, delete the shim.
extension View {
/// `menuStyle(.borderlessButton)`: a menu drawn as its label alone.
@ViewBuilder func plainMenuStyle() -> some View {
#if os(Android)
self
#else
self.menuStyle(.borderlessButton)
#endif
}
/// `controlSize(.mini)`: the smallest switch.
@ViewBuilder func miniControl() -> some View {
#if os(Android)
self
#else
self.controlSize(.mini)
#endif
}
/// `accessibilityElement(children: .ignore)`: read this view out as one thing.
@ViewBuilder func oneAccessibleElement() -> some View {
#if os(Android)
self
#else
self.accessibilityElement(children: .ignore)
#endif
}
}
extension Text {
/// `monospacedDigit()`: figures that line up in columns. Android's rounded face is close enough
/// until Skip offers the real thing.
func digitsAligned() -> Text {
#if os(Android)
self
#else
self.monospacedDigit()
#endif
}
}
'''
open(p, "w").write(s)
# ---------- the sites ----------
for f in glob.glob("Sources/PokerUI/*.swift"):
if f.endswith("Theme.swift"): continue
s = open(f).read(); o = s
s = s.replace(".onHover { pressed = $0 && enabled }", ".hoverHighlight { pressed = $0 && enabled }")
s = s.replace(".help(term.definition)", ".tooltip(term.definition)")
s = s.replace(".monospacedDigit()", ".digitsAligned()")
s = s.replace(".menuStyle(.borderlessButton)", ".plainMenuStyle()")
s = s.replace(".controlSize(.mini)", ".miniControl()")
s = s.replace(".accessibilityElement(children: .ignore)", ".oneAccessibleElement()")
s = s.replace(".accessibilityLabel(accessibilityText)", ".accessibilityLabel(Text(accessibilityText))")
if s != o: open(f, "w").write(s)
# FlowRow
p = "Sources/PokerUI/LadderView.swift"; s = open(p).read()
if "#if !os(Android)" not in s:
a = "private struct FlowRow: Layout {"; assert a in s
s = s.replace(a, "#if !os(Android)\n" + a, 1)
s = s.rstrip("\n") + '''
#else
/// Android has no `Layout` protocol to write a wrapping row against, so the cast goes into as many
/// columns as fit the width instead. Same chips, same words; they line up in rows.
private struct FlowRow<Content: View>: View {
var spacing: CGFloat = 6
@ViewBuilder let content: Content
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: spacing)],
alignment: .leading, spacing: spacing) { content }
}
}
#endif
'''
open(p, "w").write(s)
# Glossary prose
p = "Sources/PokerUI/GlossaryViews.swift"; s = open(p).read()
if "markedDown" not in s:
edit(p, [
(""" word.link = mention.term.link
word.foregroundColor = Theme.gold
word.underlineStyle = Text.LineStyle(pattern: .dot, color: Theme.gold.opacity(0.75))
""", """ word.link = mention.term.link
#if !os(Android)
// SwiftUI's own text attributes. Skip's Android text has no equivalent yet, so there
// the words are links in the usual link colour rather than dotted gold.
word.foregroundColor = Theme.gold
word.underlineStyle = Text.LineStyle(pattern: .dot, color: Theme.gold.opacity(0.75))
#endif
"""),
(""" var body: some View {
Text(marked)
.body(Theme.coachBase(size))""", """ var body: some View {
prose
.body(Theme.coachBase(size))"""),
(""" /// The writing, with the first appearance of each word turned into a tap target. Dotted rather
/// than solid, and in the palette's own gold: visibly offered, not shouted.
private var marked: AttributedString {""", """ /// The paragraph as a `Text`. Apple's SwiftUI takes attributed text; Skip's Android SwiftUI
/// cannot yet, but reads Markdown, and a Markdown link is the same tap target.
private var prose: Text {
#if os(Android)
Text(LocalizedStringKey(markedDown))
#else
Text(marked)
#endif
}
/// The writing as Markdown: each first mention becomes `[word](link)`, everything else escaped
/// so that a stray asterisk in the coach's prose cannot turn into emphasis.
private var markedDown: String {
func escaped(_ plain: Substring) -> String {
var out = ""
for ch in plain {
if "\\\\*_[]`<>#".contains(ch) { out.append("\\\\") }
out.append(ch)
}
return out
}
var out = ""
var cursor = text.startIndex
for mention in Glossary.mentions(in: text) {
out += escaped(text[cursor..<mention.range.lowerBound])
out += "[" + escaped(text[mention.range]) + "](" + mention.term.link.absoluteString + ")"
cursor = mention.range.upperBound
}
out += escaped(text[cursor...])
return out
}
/// The writing, with the first appearance of each word turned into a tap target. Dotted rather
/// than solid, and in the palette's own gold: visibly offered, not shouted.
private var marked: AttributedString {"""),
])
# Sound
p = "Sources/PokerUI/Sound.swift"; s = open(p).read()
if "canImport(AVFoundation)" not in s:
s = s.replace("import Foundation\nimport AVFoundation\n", "import Foundation\n#if canImport(AVFoundation)\nimport AVFoundation\n#endif\n", 1)
s = s.replace("/// The table sounds.\n", "#if canImport(AVFoundation)\n/// The table sounds.\n", 1)
s = s.rstrip("\n") + '''
#else
/// No sound on Android yet: the table plays silently. Wiring Skip's audio library is a later step,
/// and until then the setting still exists so the screens and the suite do not have to know.
@MainActor
final class Sound {
static let shared = Sound()
enum Cue: String, CaseIterable {
case chips, fold, knock, pot
}
var enabled: Bool {
didSet { UserDefaults.standard.set(enabled, forKey: Self.defaultsKey) }
}
private static let defaultsKey = "PokerTutor.soundEnabled"
private init() {
if UserDefaults.standard.object(forKey: Self.defaultsKey) == nil {
enabled = true
} else {
enabled = UserDefaults.standard.bool(forKey: Self.defaultsKey)
}
}
func play(_ cue: Cue) {}
func play(for applied: TableSession.AppliedAction) {}
}
#endif
'''
open(p, "w").write(s)
# ---------- Skip's plugin on the screens module, and what it demands ----------
import collections
os.makedirs("Sources/PokerUI/Skip", exist_ok=True)
open("Sources/PokerUI/Skip/skip.yml", "w").write("""# Skip configuration for the screens. A natively-compiled Skip Fuse module: the Swift is compiled
# for Android as it is. The Skip build plugin still has to see this module, because it generates
# the glue that lets Skip's SwiftUI draw each view here — without it every screen renders as
# nothing at all, silently.
skip:
mode: 'native'
""")
s = open("Package.swift").read()
a = """ + (android ? [.product(name: "SkipFuseUI", package: "skip-fuse-ui")] : []),
resources: [.process("Resources")]),"""
if a in s:
s = s.replace(a, """ + (android ? [.product(name: "SkipFuseUI", package: "skip-fuse-ui")] : []),
resources: [.process("Resources")],
plugins: android ? [.plugin(name: "skipstone", package: "skip")] : []),""")
open("Package.swift", "w").write(s)
# view state/environment properties and view types must not be private
pat = re.compile(r'(@(?:State|Binding|FocusState|Bindable|Namespace|GestureState|ScaledMetric|AppStorage\([^)]*\)|Environment\([^)]*\)|SceneStorage\([^)]*\)))\s+(?:private|fileprivate)\s+(var|let)\b')
for f in glob.glob("Sources/PokerUI/*.swift"):
t = open(f).read(); t2, k = pat.subn(r'\1 \2', t)
if k: open(f, "w").write(t2)
pat = re.compile(r'^(\s*)(?:private|fileprivate)\s+(struct\s+(\w+)(?:<[^>\n]*>)?\s*:\s*View\b)', re.M)
names = collections.defaultdict(list)
for f in glob.glob("Sources/PokerUI/*.swift"):
for m in pat.finditer(open(f).read()): names[m.group(3)].append(f)
dups = {k: v for k, v in names.items() if len(set(v)) > 1}
if dups: raise SystemExit(f"rename these private views first, they clash once internal: {dups}")
for f in glob.glob("Sources/PokerUI/*.swift"):
t = open(f).read(); t2, k = pat.subn(r'\1\2', t)
if k: open(f, "w").write(t2)
# public signatures cannot mention the bare `UserDefaults` (the plugin shadows it with a private alias)
p = "Sources/PokerUI/Theme.swift"; t = open(p).read()
for typ, fallback in (("CardColours", ".four"), ("TextScale", ".normal")):
a = f"public static func stored(in defaults: UserDefaults = .standard) -> {typ} {{"
if a in t:
t = t.replace(a, f"public static func stored(in defaults: Foundation.UserDefaults) -> {typ} {{")
t = re.sub(rf"public static func store\(_ (\w+): {typ}, in defaults: UserDefaults = \.standard\) \{{\n defaults\.set\(\1\.rawValue, forKey: storageKey\)\n \}}",
lambda m: f"public static func store(_ {m.group(1)}: {typ}, in defaults: Foundation.UserDefaults) {{\n defaults.set({m.group(1)}.rawValue, forKey: storageKey)\n }}\n\n static func stored() -> {typ} {{ stored(in: UserDefaults.standard) }}\n static func store(_ {m.group(1)}: {typ}) {{ store({m.group(1)}, in: UserDefaults.standard) }}", t)
open(p, "w").write(t)
print("plugin + visibility + settings applied")
# ---------- Android shell ----------
os.makedirs(f"Sources/{module}/Skip", exist_ok=True)
open(f"Sources/{module}/Skip/skip.yml", "w").write("""# Skip configuration for the Android app module. This is a natively-compiled Skip Fuse module:
# the Swift is compiled for Android as it is, and only this thin shell is bridged to Kotlin.
skip:
mode: 'native'
""")
open(f"Sources/{module}/{module}.swift", "w").write(f'''import Foundation
import SkipFuse
import SwiftUI
import PokerUI
/// The Android app. Same `RootView` as the Mac and the iPhone, which lays itself out for the space
/// it gets — this file is the whole of the platform-specific Swift, and `Android/` holds the
/// equally thin Kotlin that Android needs to start an app at all.
///
/// The `SKIP @bridge` marks are what let that Kotlin reach this Swift. Nothing else in the app is
/// bridged: every screen is compiled Swift talking to Skip's SwiftUI, which draws it with Compose.
/* SKIP @bridge */public struct {module}RootView : View {{
// Internal rather than private: Skip's bridge has to be able to see it.
@State var app = AppState()
/* SKIP @bridge */public init() {{
}}
public var body: some View {{
RootView()
.environment(app)
.preferredColorScheme(.dark)
// The free allowance and the store. Only the shipping apps switch this on: the suite
// and the command line play far more hands than anybody pays for.
.task {{ app.startCharging() }}
}}
}}
/// What Android tells the app about its own life. Nothing listens yet; the table pauses itself
/// when the screen changes and the history is written a hand at a time, so there is nothing to
/// save in a hurry when the app goes to the background.
/* SKIP @bridge */public final class {module}AppDelegate : Sendable {{
/* SKIP @bridge */public static let shared = {module}AppDelegate()
private init() {{
}}
/* SKIP @bridge */public func onInit() {{}}
/* SKIP @bridge */public func onLaunch() {{}}
/* SKIP @bridge */public func onResume() {{}}
/* SKIP @bridge */public func onPause() {{}}
/* SKIP @bridge */public func onStop() {{}}
/* SKIP @bridge */public func onDestroy() {{}}
/* SKIP @bridge */public func onLowMemory() {{}}
}}
''')
open("Skip.env", "w").write(f"""// The configuration file for the Android app (https://skip.dev).
// Read by Android/settings.gradle.kts and folded into the AndroidManifest.xml.
// PRODUCT_NAME must match the app's Swift module name (Sources/{module}).
PRODUCT_NAME = {module}
// The unique id of the app. The same as the iPhone app's bundle identifier.
PRODUCT_BUNDLE_IDENTIFIER = {appid}
// The version people see, and the build number behind it. Kept in step with the iPhone app.
MARKETING_VERSION = 1.0
CURRENT_PROJECT_VERSION = 1
// The Kotlin package for the Android entry point. Skip derives it from the Swift module name
// ({module} -> {pkg}) and the two MUST agree, or the app cannot find its module.
ANDROID_PACKAGE_NAME = {pkg}
""")
if not os.path.isdir("Android"):
subprocess.run(["rsync", "-a", "--exclude", "build", "--exclude", ".gradle", os.path.expanduser("~/poker-tutor/Android/"), "Android/"], check=True)
for f in ["Android/app/src/main/kotlin/Main.kt", "Android/app/src/main/AndroidManifest.xml", "Android/fastlane/Appfile", "Android/fastlane/Fastfile", "Android/fastlane/metadata/android/en-US/title.txt"]:
if not os.path.exists(f): continue
s = open(f).read()
s = s.replace("poker.tutor.android", pkg).replace("PokerTutorAndroid", module)
s = s.replace("com.thedigitaladlab.pokertutor", appid).replace('category = "PokerTutor"', f'category = "{label.replace(" ", "")}"')
s = s.replace('android:label="Poker Tutor"', f'android:label="{label}"').replace("Poker Tutor", label)
open(f, "w").write(s)
print("recipe applied to", repo)