diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 04a1ec3..b66b0ac 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -215,9 +215,62 @@ jobs:
name: maturita-ios
path: dist-ios/maturita-ios.ipa
+ macos-swift:
+ name: macOS Swift app
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Import Developer ID certificate
+ env:
+ MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ run: |
+ set -euo pipefail
+ if [ -z "${MACOS_CERTIFICATE:-}" ]; then
+ echo "No Developer ID certificate; the bundle will be ad-hoc signed."
+ exit 0
+ fi
+ cert="$RUNNER_TEMP/certificate.p12"
+ keychain="$RUNNER_TEMP/signing.keychain-db"
+ pass="$(openssl rand -base64 32)"
+ echo "$MACOS_CERTIFICATE" | base64 --decode > "$cert"
+ security create-keychain -p "$pass" "$keychain"
+ security set-keychain-settings -lut 21600 "$keychain"
+ security unlock-keychain -p "$pass" "$keychain"
+ security import "$cert" -k "$keychain" \
+ -P "$MACOS_CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign -T /usr/bin/security
+ security set-key-partition-list -S apple-tool:,apple: -s \
+ -k "$pass" "$keychain"
+ security list-keychains -d user -s "$keychain" \
+ $(security list-keychains -d user | tr -d '"')
+ rm -f "$cert"
+
+ - name: Build Swift .app zip
+ env:
+ COMMIT: ${{ github.sha }}
+ DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
+ MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
+ APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ run: |
+ chmod +x scripts/bundle-macos-swift.sh
+ ./scripts/bundle-macos-swift.sh
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: maturita-macos-swift
+ path: dist/maturita-macos-swift-*.zip
+
publish:
name: Publish to builds branch
- needs: [linux, windows, macos, android, ios]
+ needs: [linux, windows, macos, android, ios, macos-swift]
# One broken platform must not freeze the others: publish whatever was
# built and keep the previous package for anything that failed.
if: ${{ !cancelled() && contains(needs.*.result, 'success') }}
@@ -254,6 +307,7 @@ jobs:
# here, so a platform that failed this run is not offered as new.
[ -f fresh/maturita-android.apk ] && echo "${{ github.sha }}" > staged/VERSION-android
[ -f fresh/maturita-ios.ipa ] && echo "${{ github.sha }}" > staged/VERSION-ios
+ ls fresh/maturita-macos-swift-*.zip >/dev/null 2>&1 && echo "${{ github.sha }}" > staged/VERSION-macos-swift
ls fresh staged
# The branch is rewritten from scratch every time rather than added to,
diff --git a/.gitignore b/.gitignore
index b65f39d..62da60d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,5 +41,10 @@ ios/build/
ios/Payload/
ios/**/xcuserdata/
+# native macOS Swift
+macos/DerivedData/
+macos/build/
+macos/**/xcuserdata/
+
# Xcode workspace user state
*.xcworkspace/xcuserdata/
diff --git a/Makefile b/Makefile
index aee3d04..806d15b 100644
--- a/Makefile
+++ b/Makefile
@@ -124,4 +124,8 @@ ios:
chmod +x scripts/bundle-ios.sh
./scripts/bundle-ios.sh
-.PHONY: all clean run bundle installer-zips android ios
+macos:
+ chmod +x scripts/bundle-macos-swift.sh
+ ./scripts/bundle-macos-swift.sh
+
+.PHONY: all clean run bundle installer-zips android ios macos
diff --git a/Maturita.xcworkspace/contents.xcworkspacedata b/Maturita.xcworkspace/contents.xcworkspacedata
index 0cf4a51..ce429e1 100644
--- a/Maturita.xcworkspace/contents.xcworkspacedata
+++ b/Maturita.xcworkspace/contents.xcworkspacedata
@@ -4,4 +4,7 @@
+
+
diff --git a/README.md b/README.md
index e6b2fd8..3049df0 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ spolehlivější instalátor výše, nebo stažení přímo z té stránky větv
- [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage)
- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk)
- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa)
+- [maturita-macos-swift-arm64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-macos-swift-arm64.zip)
Postup a nastavení se ukládají vedle `.app` / složky / AppImage do `progress/`.
Na Androidu a iOS do úložiště aplikace (stejné statistiky, témata a jazyk).
@@ -141,6 +142,7 @@ make bundle # dist/maturita.exe jde spustit i z Průzkumník
src/ C zdroje a maturita.h
android/ Jetpack Compose přehrávač (stejný obsah)
ios/ SwiftUI přehrávač (stejný obsah)
+macos/ nativní SwiftUI aplikace pro Mac (stejný obsah)
data/ style.css, share/, podpisy, Windows .rc
assets/ zdrojová ikona a screenshoty
installers/ instalátory, které stáhnou aktuální build
@@ -175,9 +177,17 @@ open Maturita.xcworkspace # v kořeni repozitáře
# nebo: make ios # unsigned IPA v dist-ios/maturita-ios.ipa
```
-Workspace odkazuje na `ios/Maturita.xcodeproj`. Projekt nepřesouvejte – build
-čte `../src` a `../scripts` a v kořeni by `Maturita/` kolidovalo se
-zkompilovaným binárem `maturita`.
+Nativní macOS ze zdroje (Xcode 26, macOS 26+):
+
+```bash
+open Maturita.xcworkspace # scheme Maturita z macos/Maturita.xcodeproj
+# nebo: make macos # zip v dist/maturita-macos-swift-arm64.zip
+```
+
+Workspace odkazuje na `ios/Maturita.xcodeproj` a `macos/Maturita.xcodeproj`.
+Projekty nepřesouvejte – build čte `../src` a `../scripts` a v kořeni by
+`Maturita/` kolidovalo se zkompilovaným binárem `maturita`. Nativní Mac
+aplikace sdílí cvičení s iOS přehrávačem; GTK verze (`make bundle`) zůstává.
Licence: [GPL-3.0](LICENSE).
@@ -230,6 +240,7 @@ above, or download from that branch page:
- [maturita-linux-x86_64.AppImage](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-linux-x86_64.AppImage)
- [maturita-android.apk](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-android.apk)
- [maturita-ios.ipa](https://github.com/pepaskopec-blip/maturita.c/raw/refs/heads/builds/maturita-ios.ipa)
+- [maturita-macos-swift-arm64.zip](https://github.com/pepaskopec-blip/maturita.c/raw/builds/maturita-macos-swift-arm64.zip)
Progress and settings live in `progress/` next to the `.app` / folder /
AppImage. On Android and iOS they stay in app storage (same stats, themes, language).
@@ -319,6 +330,7 @@ make bundle # dist/maturita.exe can be double-clicked
src/ C sources and maturita.h
android/ Jetpack Compose player (same content)
ios/ SwiftUI player (same content)
+macos/ native SwiftUI Mac app (same content)
data/ style.css, share/, signing files, Windows .rc
assets/ source icon and screenshots
installers/ fetch-the-latest installers
@@ -353,8 +365,17 @@ open Maturita.xcworkspace # at the repo root
# or: make ios # unsigned IPA at dist-ios/maturita-ios.ipa
```
-The workspace points at `ios/Maturita.xcodeproj`. Do not move the project –
-the build reads `../src` and `../scripts`, and at the root `Maturita/` would
-collide with the compiled `maturita` binary.
+Native macOS from source (Xcode 26, macOS 26+):
+
+```bash
+open Maturita.xcworkspace # scheme Maturita from macos/Maturita.xcodeproj
+# or: make macos # zip at dist/maturita-macos-swift-arm64.zip
+```
+
+The workspace points at `ios/Maturita.xcodeproj` and `macos/Maturita.xcodeproj`.
+Do not move the projects – the build reads `../src` and `../scripts`, and at
+the root `Maturita/` would collide with the compiled `maturita` binary. The
+native Mac app shares exercises with the iOS player; the GTK build
+(`make bundle`) stays.
License: [GPL-3.0](LICENSE).
diff --git a/ios/Maturita/MaturitaApp.swift b/ios/Maturita/MaturitaApp.swift
index abb4510..357863d 100644
--- a/ios/Maturita/MaturitaApp.swift
+++ b/ios/Maturita/MaturitaApp.swift
@@ -85,93 +85,3 @@ private struct UpdateAccessory: View {
return vm.tr(vm.update.messageKey)
}
}
-
-struct RouteDestination: View {
- @ObservedObject var vm: AppModel
- let route: Route
-
- var body: some View {
- switch route {
- case .welcome, .subjects:
- PracticeHome(vm: vm)
- case .stats:
- StatsScreen(vm: vm)
- case .roadmap:
- RoadmapScreen(vm: vm)
- case .unitMap(let id):
- UnitMapScreen(vm: vm, unitId: id)
- case .germanEx(let unit, let ex):
- GermanExercise(vm: vm, unitId: unit, ex: ex)
- case .vocab(let id):
- VocabExercise(vm: vm, unitId: id)
- case .netYears:
- NetYearsScreen(vm: vm)
- case .netMap:
- LessonListScreen(
- title: vm.tr("net_year1"),
- subtitle: vm.tr("net_sub"),
- rows: vm.content.netLessons.map { l in
- let id = l.int("id")
- return LessonRow(
- id: "n\(id)",
- title: vm.tr(l.str("titleKey")),
- done: vm.progress.netDone(id),
- destination: .netLesson(id)
- )
- }
- )
- case .netLesson(let id):
- if let l = vm.content.netLesson(id) {
- SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
- }
- case .netEx(let id):
- NetQuizScreen(vm: vm, id: id)
- case .hwMap:
- LessonListScreen(
- title: vm.tr("Technické vybavení"),
- subtitle: vm.tr("hw_sub"),
- rows: vm.content.hw.map { l in
- let id = l.int("id")
- return LessonRow(
- id: "h\(id)",
- title: vm.tr(l.str("titleKey")),
- done: vm.progress.hwDone(id),
- destination: .hwLesson(id)
- )
- }
- )
- case .hwLesson(let id):
- if let l = vm.content.hwLesson(id) {
- SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
- }
- case .hwEx(let id):
- HwQuizScreen(vm: vm, id: id)
- case .czechMap:
- CzechMapScreen(vm: vm)
- case .mluvnice:
- LessonListScreen(
- title: vm.tr("Mluvnice"),
- subtitle: vm.tr("mluv_sub"),
- rows: vm.content.mluvnice.map { m in
- let n = m.int("id")
- return LessonRow(
- id: "m\(n)",
- title: m.str("name"),
- done: vm.progress.mluvDone(n),
- destination: .mluvEx(n)
- )
- }
- )
- case .mluvEx(let n):
- MluvExercise(vm: vm, n: n)
- case .readingList:
- BookListScreen(vm: vm)
- case .book(let id):
- BookScreen(vm: vm, id: id)
- case .bookQuiz(let id):
- LitQuizScreen(vm: vm, id: id)
- case .bookPlot(let id):
- PlotScreen(vm: vm, id: id)
- }
- }
-}
diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift
index 221f0da..592cca6 100644
--- a/ios/Maturita/UI/Components.swift
+++ b/ios/Maturita/UI/Components.swift
@@ -49,7 +49,11 @@ struct PrimaryButton: View {
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
+ #if os(iOS)
.buttonStyle(.glassProminent)
+ #else
+ .buttonStyle(.borderedProminent)
+ #endif
.controlSize(.large)
}
}
@@ -250,17 +254,39 @@ extension View {
/// Detail screens with their own bottom action hide the tab bar so the
/// primary button is never covered by it.
+ @ViewBuilder
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
+ #if os(iOS)
navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(subtitle)
.toolbar(.hidden, for: .tabBar)
+ #else
+ navigationTitle(title)
+ .navSubtitle(subtitle)
+ #endif
+ }
+
+ @ViewBuilder
+ func sheetDetents() -> some View {
+ #if os(iOS)
+ presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ #else
+ self
+ #endif
}
/// Full-width, leading-aligned content for exercise screens.
func exerciseContent() -> some View {
+ #if os(macOS)
+ frame(maxWidth: 720, alignment: .leading)
+ .padding(20)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ #else
frame(maxWidth: .infinity, alignment: .leading)
.padding()
+ #endif
}
}
diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift
index 752dac6..23e637d 100644
--- a/ios/Maturita/UI/ExercisesMore.swift
+++ b/ios/Maturita/UI/ExercisesMore.swift
@@ -304,7 +304,7 @@ private struct ComboLine: View {
if let word, idx < picks.count { picks[idx] = word }
open = false
}
- .presentationDetents([.medium, .large])
+ .sheetDetents()
}
if revealed, let meaning, !meaning.isEmpty {
Meaning(text: vm.tr(meaning), palette: p, visible: true)
@@ -334,8 +334,7 @@ struct WordPicker: View {
}
}
}
- .presentationDetents([.medium, .large])
- .presentationDragIndicator(.visible)
+ .sheetDetents()
}
}
@@ -781,7 +780,7 @@ private struct PrefixMenu: View {
if let word { value = word }
open = false
}
- .presentationDetents([.medium, .large])
+ .sheetDetents()
}
}
}
diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift
index 2bbda04..72b16c5 100644
--- a/ios/Maturita/UI/Screens.swift
+++ b/ios/Maturita/UI/Screens.swift
@@ -20,7 +20,7 @@ struct PracticeHome: View {
VStack(alignment: .leading, spacing: 10) {
Text(vm.tr("welcome_title"))
.font(.title.bold())
- Text(vm.tr("welcome_body_ios"))
+ Text(vm.tr(Self.welcomeBodyKey))
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView(value: pct)
@@ -50,7 +50,17 @@ struct PracticeHome: View {
}
}
.navigationTitle("Maturita")
+ #if os(iOS)
.navigationBarTitleDisplayMode(.large)
+ #endif
+ }
+
+ private static var welcomeBodyKey: String {
+ #if os(macOS)
+ "welcome_body_macos"
+ #else
+ "welcome_body_ios"
+ #endif
}
private func subjectLabel(_ s: J) -> some View {
@@ -458,32 +468,20 @@ struct SlidesScreen: View {
@State private var idx = 0
var body: some View {
- TabView(selection: $idx) {
- ForEach(slides.indices, id: \.self) { i in
- let slide = slides[i]
- ScrollView {
- VStack(alignment: .leading, spacing: 12) {
- Text(slide.str("kicker"))
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- Text(slide.str("title")).font(.title2.bold())
- if let tip = slide.strOrNull("tip") {
- GlassCard {
- Label(tip, systemImage: "lightbulb.fill")
- }
- }
- ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
- Text(line).padding(.vertical, 4)
- }
- }
- .padding()
- .padding(.bottom, 24)
- .frame(maxWidth: .infinity, alignment: .leading)
+ Group {
+ #if os(iOS)
+ TabView(selection: $idx) {
+ ForEach(slides.indices, id: \.self) { i in
+ slidePage(slides[i]).tag(i)
}
- .tag(i)
}
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ #else
+ if slides.indices.contains(idx) {
+ slidePage(slides[idx])
+ }
+ #endif
}
- .tabViewStyle(.page(indexDisplayMode: .never))
.detailScreen(title, subtitle: subtitle)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
@@ -503,7 +501,11 @@ struct SlidesScreen: View {
Image(systemName: "chevron.left")
.frame(minWidth: 24)
}
+ #if os(iOS)
.buttonStyle(.glass)
+ #else
+ .buttonStyle(.bordered)
+ #endif
.controlSize(.large)
}
if idx < slides.count - 1 {
@@ -523,6 +525,31 @@ struct SlidesScreen: View {
.background(.bar)
}
}
+
+ private func slidePage(_ slide: J) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(slide.str("kicker"))
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(slide.str("title")).font(.title2.bold())
+ if let tip = slide.strOrNull("tip") {
+ GlassCard {
+ Label(tip, systemImage: "lightbulb.fill")
+ }
+ }
+ ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
+ Text(line).padding(.vertical, 4)
+ }
+ }
+ .padding()
+ .padding(.bottom, 24)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ #if os(macOS)
+ .frame(maxWidth: 720, alignment: .leading)
+ #endif
+ }
+ }
}
struct CzechMapScreen: View {
@@ -607,3 +634,93 @@ struct BookScreen: View {
}
}
}
+
+struct RouteDestination: View {
+ @ObservedObject var vm: AppModel
+ let route: Route
+
+ var body: some View {
+ switch route {
+ case .welcome, .subjects:
+ PracticeHome(vm: vm)
+ case .stats:
+ StatsScreen(vm: vm)
+ case .roadmap:
+ RoadmapScreen(vm: vm)
+ case .unitMap(let id):
+ UnitMapScreen(vm: vm, unitId: id)
+ case .germanEx(let unit, let ex):
+ GermanExercise(vm: vm, unitId: unit, ex: ex)
+ case .vocab(let id):
+ VocabExercise(vm: vm, unitId: id)
+ case .netYears:
+ NetYearsScreen(vm: vm)
+ case .netMap:
+ LessonListScreen(
+ title: vm.tr("net_year1"),
+ subtitle: vm.tr("net_sub"),
+ rows: vm.content.netLessons.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "n\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.netDone(id),
+ destination: .netLesson(id)
+ )
+ }
+ )
+ case .netLesson(let id):
+ if let l = vm.content.netLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
+ }
+ case .netEx(let id):
+ NetQuizScreen(vm: vm, id: id)
+ case .hwMap:
+ LessonListScreen(
+ title: vm.tr("Technické vybavení"),
+ subtitle: vm.tr("hw_sub"),
+ rows: vm.content.hw.map { l in
+ let id = l.int("id")
+ return LessonRow(
+ id: "h\(id)",
+ title: vm.tr(l.str("titleKey")),
+ done: vm.progress.hwDone(id),
+ destination: .hwLesson(id)
+ )
+ }
+ )
+ case .hwLesson(let id):
+ if let l = vm.content.hwLesson(id) {
+ SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
+ }
+ case .hwEx(let id):
+ HwQuizScreen(vm: vm, id: id)
+ case .czechMap:
+ CzechMapScreen(vm: vm)
+ case .mluvnice:
+ LessonListScreen(
+ title: vm.tr("Mluvnice"),
+ subtitle: vm.tr("mluv_sub"),
+ rows: vm.content.mluvnice.map { m in
+ let n = m.int("id")
+ return LessonRow(
+ id: "m\(n)",
+ title: m.str("name"),
+ done: vm.progress.mluvDone(n),
+ destination: .mluvEx(n)
+ )
+ }
+ )
+ case .mluvEx(let n):
+ MluvExercise(vm: vm, n: n)
+ case .readingList:
+ BookListScreen(vm: vm)
+ case .book(let id):
+ BookScreen(vm: vm, id: id)
+ case .bookQuiz(let id):
+ LitQuizScreen(vm: vm, id: id)
+ case .bookPlot(let id):
+ PlotScreen(vm: vm, id: id)
+ }
+ }
+}
diff --git a/macos/Maturita.xcodeproj/project.pbxproj b/macos/Maturita.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..ec37b15
--- /dev/null
+++ b/macos/Maturita.xcodeproj/project.pbxproj
@@ -0,0 +1,429 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ B101000000000000000001 /* MaturitaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000001 /* MaturitaApp.swift */; };
+ B101000000000000000002 /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000002 /* Platform.swift */; };
+ B101000000000000000003 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000003 /* Updater.swift */; };
+ B101000000000000000004 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000004 /* AppModel.swift */; };
+ B101000000000000000005 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000005 /* Route.swift */; };
+ B101000000000000000006 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000006 /* JSON.swift */; };
+ B101000000000000000007 /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000007 /* Content.swift */; };
+ B101000000000000000008 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000008 /* Theme.swift */; };
+ B101000000000000000009 /* Answers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000009 /* Answers.swift */; };
+ B101000000000000000010 /* ProgressStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000010 /* ProgressStore.swift */; };
+ B101000000000000000011 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000011 /* Components.swift */; };
+ B101000000000000000012 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000012 /* Icons.swift */; };
+ B101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000013 /* Screens.swift */; };
+ B101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000014 /* Exercises.swift */; };
+ B101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B201000000000000000015 /* ExercisesMore.swift */; };
+ B101000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B201000000000000000016 /* Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ B201000000000000000001 /* MaturitaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaturitaApp.swift; sourceTree = ""; };
+ B201000000000000000002 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Platform.swift; sourceTree = ""; };
+ B201000000000000000003 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; };
+ B201000000000000000004 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppModel.swift; path = ../ios/Maturita/AppModel.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000005 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Route.swift; path = ../ios/Maturita/Route.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000006 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = JSON.swift; path = ../ios/Maturita/Data/JSON.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000007 /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Content.swift; path = ../ios/Maturita/Data/Content.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000008 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Theme.swift; path = ../ios/Maturita/Data/Theme.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000009 /* Answers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Answers.swift; path = ../ios/Maturita/Data/Answers.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000010 /* ProgressStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ProgressStore.swift; path = ../ios/Maturita/Data/ProgressStore.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000011 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Components.swift; path = ../ios/Maturita/UI/Components.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000012 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Icons.swift; path = ../ios/Maturita/UI/Icons.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Screens.swift; path = ../ios/Maturita/UI/Screens.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Exercises.swift; path = ../ios/Maturita/UI/Exercises.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExercisesMore.swift; path = ../ios/Maturita/UI/ExercisesMore.swift; sourceTree = SOURCE_ROOT; };
+ B201000000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ B201000000000000000018 /* Maturita.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Maturita.entitlements; sourceTree = ""; };
+ B301000000000000000001 /* Maturita.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maturita.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ B401000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ B501000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ B501000000000000000002 /* Maturita */,
+ B501000000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ B501000000000000000002 /* Maturita */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000001 /* MaturitaApp.swift */,
+ B201000000000000000002 /* Platform.swift */,
+ B201000000000000000004 /* AppModel.swift */,
+ B201000000000000000005 /* Route.swift */,
+ B501000000000000000003 /* Data */,
+ B501000000000000000004 /* Update */,
+ B501000000000000000005 /* UI */,
+ B201000000000000000016 /* Assets.xcassets */,
+ B201000000000000000017 /* Info.plist */,
+ B201000000000000000018 /* Maturita.entitlements */,
+ );
+ path = Maturita;
+ sourceTree = "";
+ };
+ B501000000000000000003 /* Data */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000006 /* JSON.swift */,
+ B201000000000000000007 /* Content.swift */,
+ B201000000000000000008 /* Theme.swift */,
+ B201000000000000000009 /* Answers.swift */,
+ B201000000000000000010 /* ProgressStore.swift */,
+ );
+ name = Data;
+ sourceTree = "";
+ };
+ B501000000000000000004 /* Update */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000003 /* Updater.swift */,
+ );
+ path = Update;
+ sourceTree = "";
+ };
+ B501000000000000000005 /* UI */ = {
+ isa = PBXGroup;
+ children = (
+ B201000000000000000011 /* Components.swift */,
+ B201000000000000000012 /* Icons.swift */,
+ B201000000000000000013 /* Screens.swift */,
+ B201000000000000000014 /* Exercises.swift */,
+ B201000000000000000015 /* ExercisesMore.swift */,
+ );
+ name = UI;
+ sourceTree = "";
+ };
+ B501000000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ B301000000000000000001 /* Maturita.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ B601000000000000000001 /* Maturita */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */;
+ buildPhases = (
+ B701000000000000000001 /* Sources */,
+ B401000000000000000001 /* Frameworks */,
+ B701000000000000000002 /* Resources */,
+ B701000000000000000004 /* Extract content */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Maturita;
+ productName = Maturita;
+ productReference = B301000000000000000001 /* Maturita.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ B001000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 2700;
+ TargetAttributes = {
+ B601000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ };
+ };
+ buildConfigurationList = B801000000000000000001 /* Build configuration list for PBXProject "Maturita" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = cs;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ cs,
+ Base,
+ );
+ mainGroup = B501000000000000000001;
+ productRefGroup = B501000000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ B601000000000000000001 /* Maturita */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ B701000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B101000000000000000016 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ B701000000000000000004 /* Extract content */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Extract content";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/content.json",
+ "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/content.json",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "# Pull the exercises out of the C sources and drop the JSON into the app\n# bundle. Nothing generated is kept in the repository, so a fresh clone opens\n# and runs in Xcode without extra steps.\nset -eu\nextract=\"${SRCROOT}/../scripts/extract-android-content.py\"\nif [ ! -f \"$extract\" ]; then\n echo \"error: ${extract} not found. Keep macos/ inside the maturita.c checkout.\" >&2\n exit 1\nfi\nout=\"${DERIVED_FILE_DIR}/content.json\"\nmkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"$extract\" --out \"$out\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"$out\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content.json\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ B701000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B101000000000000000001 /* MaturitaApp.swift in Sources */,
+ B101000000000000000002 /* Platform.swift in Sources */,
+ B101000000000000000003 /* Updater.swift in Sources */,
+ B101000000000000000004 /* AppModel.swift in Sources */,
+ B101000000000000000005 /* Route.swift in Sources */,
+ B101000000000000000006 /* JSON.swift in Sources */,
+ B101000000000000000007 /* Content.swift in Sources */,
+ B101000000000000000008 /* Theme.swift in Sources */,
+ B101000000000000000009 /* Answers.swift in Sources */,
+ B101000000000000000010 /* ProgressStore.swift in Sources */,
+ B101000000000000000011 /* Components.swift in Sources */,
+ B101000000000000000012 /* Icons.swift in Sources */,
+ B101000000000000000013 /* Screens.swift in Sources */,
+ B101000000000000000014 /* Exercises.swift in Sources */,
+ B101000000000000000015 /* ExercisesMore.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ B901000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ B901000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ B901000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Maturita/Maturita.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Maturita/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = maturita.c;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_NSHighResolutionCapable = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "GPL-3.0";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = "macos-1";
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita.macos;
+ PRODUCT_NAME = Maturita;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ B901000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Maturita/Maturita.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ COMMIT = dev;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = C2U9S337H8;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Maturita/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = maturita.c;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
+ INFOPLIST_KEY_NSHighResolutionCapable = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "GPL-3.0";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = "macos-1";
+ PRODUCT_BUNDLE_IDENTIFIER = com.bukovinafilip.maturita.macos;
+ PRODUCT_NAME = Maturita;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ B801000000000000000001 /* Build configuration list for PBXProject "Maturita" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B901000000000000000001 /* Debug */,
+ B901000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B801000000000000000002 /* Build configuration list for PBXNativeTarget "Maturita" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B901000000000000000003 /* Debug */,
+ B901000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = B001000000000000000001 /* Project object */;
+}
diff --git a/macos/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macos/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/macos/Maturita.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/macos/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme b/macos/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme
new file mode 100644
index 0000000..ef75b56
--- /dev/null
+++ b/macos/Maturita.xcodeproj/xcshareddata/xcschemes/Maturita.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/macos/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json b/macos/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/macos/Maturita/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..700d5cf
--- /dev/null
+++ b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
+ { "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
+ { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
+ { "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
+ { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
+ { "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
+ { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
+ { "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
+ { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
+ { "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644
index 0000000..e82185d
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644
index 0000000..6775872
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644
index 0000000..0aa6855
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644
index 0000000..1e26937
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644
index 0000000..6775872
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644
index 0000000..6e40e8f
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644
index 0000000..1e26937
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644
index 0000000..d3c7bc7
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644
index 0000000..6e40e8f
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644
index 0000000..80afb9d
Binary files /dev/null and b/macos/Maturita/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/macos/Maturita/Assets.xcassets/Contents.json b/macos/Maturita/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/macos/Maturita/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/macos/Maturita/Info.plist b/macos/Maturita/Info.plist
new file mode 100644
index 0000000..531c44a
--- /dev/null
+++ b/macos/Maturita/Info.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ GITCommit
+ $(COMMIT)
+ UPDATE_BRANCH
+ builds
+ UPDATE_REPO
+ pepaskopec-blip/maturita.c
+
+
diff --git a/macos/Maturita/Maturita.entitlements b/macos/Maturita/Maturita.entitlements
new file mode 100644
index 0000000..08ba3a3
--- /dev/null
+++ b/macos/Maturita/Maturita.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
diff --git a/macos/Maturita/MaturitaApp.swift b/macos/Maturita/MaturitaApp.swift
new file mode 100644
index 0000000..3257b41
--- /dev/null
+++ b/macos/Maturita/MaturitaApp.swift
@@ -0,0 +1,171 @@
+import SwiftUI
+
+@main
+struct MaturitaApp: App {
+ @StateObject private var vm = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ MacRoot(vm: vm)
+ }
+ .defaultSize(width: 1100, height: 720)
+ .commands {
+ CommandGroup(replacing: .newItem) {}
+ CommandMenu(vm.tr("search_placeholder")) {
+ Button(vm.tr("search_placeholder")) {
+ vm.tab = .search
+ }
+ .keyboardShortcut("k", modifiers: [.command])
+ }
+ CommandMenu(vm.tr("subjects_title")) {
+ Button(vm.tr("search_home")) {
+ vm.go(.subjects)
+ }
+ .keyboardShortcut("1", modifiers: [.command])
+ Button(vm.tr("stats")) {
+ vm.tab = .progress
+ }
+ .keyboardShortcut("2", modifiers: [.command])
+ Button(vm.tr("settings")) {
+ vm.tab = .settings
+ }
+ .keyboardShortcut("3", modifiers: [.command])
+ }
+ }
+
+ Settings {
+ SettingsScreen(vm: vm)
+ .frame(minWidth: 420, minHeight: 460)
+ .tint(vm.palette.tint)
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ }
+ }
+}
+
+struct MacRoot: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ NavigationSplitView {
+ MacSidebar(vm: vm)
+ .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 320)
+ } detail: {
+ switch vm.tab {
+ case .practice:
+ NavigationStack(path: $vm.practicePath) {
+ PracticeHome(vm: vm)
+ .navigationDestination(for: Route.self) { route in
+ RouteDestination(vm: vm, route: route)
+ }
+ }
+ case .progress:
+ NavigationStack {
+ StatsScreen(vm: vm)
+ }
+ case .search:
+ NavigationStack {
+ SearchScreen(vm: vm)
+ }
+ case .settings:
+ NavigationStack {
+ SettingsScreen(vm: vm)
+ }
+ }
+ }
+ .navigationSplitViewStyle(.balanced)
+ .tint(vm.palette.tint)
+ .preferredColorScheme(vm.mode == .dark ? .dark : .light)
+ .overlay(alignment: .top) {
+ if showsUpdateAccessory {
+ UpdateBanner(vm: vm)
+ .padding(12)
+ }
+ }
+ .onAppear { vm.checkUpdate(false) }
+ .frame(minWidth: 880, minHeight: 540)
+ }
+
+ private var showsUpdateAccessory: Bool {
+ vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged"
+ }
+}
+
+private struct MacSidebar: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ List(selection: $vm.tab) {
+ Section {
+ Label(vm.tr("search_home"), systemImage: "square.grid.2x2.fill")
+ .tag(AppTab.practice)
+ Label(vm.tr("stats"), systemImage: "chart.bar.fill")
+ .tag(AppTab.progress)
+ Label(vm.tr("search_placeholder"), systemImage: "magnifyingglass")
+ .tag(AppTab.search)
+ Label(vm.tr("settings"), systemImage: "gearshape.fill")
+ .tag(AppTab.settings)
+ }
+
+ Section(vm.tr("subjects_title")) {
+ ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
+ let open = s.bool("open")
+ let dest = s.strOrNull("target").flatMap(routeFromPage)
+ if open, let dest, dest != .subjects, dest != .welcome {
+ Button {
+ vm.openFromSearch(dest.page)
+ } label: {
+ subjectLabel(s)
+ }
+ .buttonStyle(.plain)
+ } else {
+ subjectLabel(s)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .listStyle(.sidebar)
+ .navigationTitle("maturita.c")
+ }
+
+ private func subjectLabel(_ s: J) -> some View {
+ Label {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(vm.tr(s.str("key")))
+ if !s.bool("open") {
+ Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ } icon: {
+ SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
+ }
+ }
+}
+
+private struct UpdateBanner: View {
+ @ObservedObject var vm: AppModel
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "arrow.down.app.fill")
+ Text(bannerText)
+ .font(.subheadline)
+ Spacer()
+ if vm.update.canInstall {
+ Button(vm.tr("update_install")) { vm.installUpdate() }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .shadow(color: .black.opacity(0.12), radius: 8, y: 2)
+ .frame(maxWidth: 640)
+ }
+
+ private var bannerText: String {
+ if let arg = vm.update.messageArg {
+ return vm.fmt(vm.update.messageKey, arg)
+ }
+ return vm.tr(vm.update.messageKey)
+ }
+}
diff --git a/macos/Maturita/Platform.swift b/macos/Maturita/Platform.swift
new file mode 100644
index 0000000..cc9c1e2
--- /dev/null
+++ b/macos/Maturita/Platform.swift
@@ -0,0 +1,38 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+extension NSColor {
+ static var tertiarySystemFill: NSColor {
+ NSColor.quaternaryLabelColor.withAlphaComponent(0.16)
+ }
+
+ static var secondarySystemGroupedBackground: NSColor {
+ .controlBackgroundColor
+ }
+
+ static var separator: NSColor { .separatorColor }
+}
+
+enum TextInputAutocapitalization {
+ case never
+}
+
+enum UIKeyboardType {
+ case decimalPad
+}
+
+enum MacNavBarTitleMode {
+ case large
+ case inline
+}
+
+extension View {
+ func textInputAutocapitalization(_ style: TextInputAutocapitalization) -> some View { self }
+
+ func keyboardType(_ type: UIKeyboardType) -> some View { self }
+
+ func navigationBarTitleDisplayMode(_ mode: MacNavBarTitleMode) -> some View { self }
+}
+
+#endif
diff --git a/macos/Maturita/Update/Updater.swift b/macos/Maturita/Update/Updater.swift
new file mode 100644
index 0000000..d03948f
--- /dev/null
+++ b/macos/Maturita/Update/Updater.swift
@@ -0,0 +1,233 @@
+import AppKit
+import Foundation
+
+struct UpdateState {
+ var status = "idle"
+ var remote: String?
+ var messageKey = "update_current"
+ var messageArg: String? = String(AppConfig.commit.prefix(7))
+ var canInstall = false
+}
+
+final class Updater {
+ private let repo = AppConfig.updateRepo
+ private let branch = AppConfig.updateBranch
+
+ func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
+ if AppConfig.commit == "dev" {
+ emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
+ return
+ }
+ emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let remote: String
+ if let tip = self.atomTip(atom),
+ let marker = try? self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION-macos-swift")
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ marker.count >= 7 {
+ remote = marker
+ } else if let sha = self.atomMainSha(atom) {
+ remote = sha
+ } else if let tip = self.atomTip(atom) {
+ remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ throw URLError(.cannotParseResponse)
+ }
+ let local = AppConfig.commit
+ if self.sameCommit(remote, local) {
+ self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
+ } else {
+ self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
+ }
+ }
+ }
+
+ func install(cb: @escaping (UpdateState) -> Void) {
+ emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
+ DispatchQueue.global(qos: .utility).async {
+ do {
+ let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
+ let tip = self.atomTip(atom) ?? ""
+ guard !tip.isEmpty else { throw URLError(.cannotParseResponse) }
+ let zipName = Self.assetName()
+ let url = "https://raw.githubusercontent.com/\(self.repo)/\(tip)/\(zipName)"
+ let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
+ let staging = cache.appendingPathComponent("maturita-update-\(ProcessInfo.processInfo.processIdentifier)")
+ try? FileManager.default.removeItem(at: staging)
+ try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
+ let archive = staging.appendingPathComponent(zipName)
+ try self.download(url, to: archive)
+ let unpacked = staging.appendingPathComponent("new")
+ try FileManager.default.createDirectory(at: unpacked, withIntermediateDirectories: true)
+ try self.run("/usr/bin/ditto", ["-x", "-k", archive.path, unpacked.path])
+ guard let source = self.findApp(in: unpacked) else {
+ throw URLError(.cannotParseResponse)
+ }
+ guard let target = Self.runningAppURL() else {
+ throw URLError(.cannotOpenFile)
+ }
+ let script = self.writeSwapScript()
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/bin/sh")
+ proc.arguments = [script.path, "\(ProcessInfo.processInfo.processIdentifier)", target.path, source.path, staging.path]
+ try proc.run()
+ self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_staged", messageArg: String(tip.prefix(7))))
+ DispatchQueue.main.async {
+ NSApp.terminate(nil)
+ }
+ } catch {
+ self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
+ }
+ }
+ }
+
+ private static func assetName() -> String {
+ #if arch(arm64)
+ return "maturita-macos-swift-arm64.zip"
+ #else
+ return "maturita-macos-swift-x86_64.zip"
+ #endif
+ }
+
+ private static func runningAppURL() -> URL? {
+ let bundle = Bundle.main.bundleURL
+ return bundle.pathExtension == "app" ? bundle : nil
+ }
+
+ private func findApp(in root: URL) -> URL? {
+ let keys: [URLResourceKey] = [.isDirectoryKey]
+ guard let en = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { return nil }
+ for case let url as URL in en where url.pathExtension == "app" {
+ return url
+ }
+ return nil
+ }
+
+ private func writeSwapScript() -> URL {
+ let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("maturita-swift-update.sh")
+ let body = """
+ #!/bin/sh
+ if [ -z "$MATURITA_UPDATE_DETACHED" ]; then
+ MATURITA_UPDATE_DETACHED=1
+ export MATURITA_UPDATE_DETACHED
+ nohup /bin/sh "$0" "$@" >/dev/null 2>&1 &
+ exit 0
+ fi
+ pid=$1; target=$2; source=$3; staging=$4
+ n=0
+ while kill -0 "$pid" 2>/dev/null && [ $n -lt 600 ]; do
+ sleep 0.2; n=$((n+1))
+ done
+ place=$target
+ if /usr/bin/ditto "$source" "$target" 2>/dev/null; then
+ xattr -cr "$target" 2>/dev/null
+ else
+ alt="$HOME/Applications/$(basename "$target")"
+ mkdir -p "$HOME/Applications"
+ if /usr/bin/ditto "$source" "$alt" 2>/dev/null; then
+ xattr -cr "$alt" 2>/dev/null
+ place=$alt
+ fi
+ fi
+ open -n "$place"
+ rm -rf "$staging"
+ rm -f "$0"
+ """
+ try? body.write(to: url, atomically: true, encoding: .utf8)
+ try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
+ return url
+ }
+
+ private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
+ DispatchQueue.main.async { cb(state) }
+ }
+
+ private func atomTip(_ atom: String) -> String? {
+ if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
+ return String(atom[m]).split(separator: "/").last.map(String.init)
+ }
+ return nil
+ }
+
+ private func atomMainSha(_ atom: String) -> String? {
+ guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
+ let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
+ let range = Range(match.range(at: 1), in: atom)
+ else { return nil }
+ return String(atom[range])
+ }
+
+ private func sameCommit(_ a: String, _ b: String) -> Bool {
+ let n = min(a.count, b.count)
+ return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
+ }
+
+ private func get(_ url: String) throws -> String {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ var req = URLRequest(url: u, timeoutInterval: 15)
+ req.setValue("maturita.c-macos", forHTTPHeaderField: "User-Agent")
+ req.setValue("*/*", forHTTPHeaderField: "Accept")
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ URLSession.shared.dataTask(with: req) { data, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let data, let text = String(data: data, encoding: .utf8) {
+ result = .success(text)
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }.resume()
+ _ = sem.wait(timeout: .now() + 20)
+ return try result.get()
+ }
+
+ private func download(_ url: String, to dest: URL) throws {
+ guard let u = URL(string: url) else { throw URLError(.badURL) }
+ let sem = DispatchSemaphore(value: 0)
+ var result: Result = .failure(URLError(.unknown))
+ let task = URLSession.shared.downloadTask(with: u) { file, resp, err in
+ if let err { result = .failure(err) }
+ else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ result = .failure(URLError(.badServerResponse))
+ } else if let file {
+ do {
+ try? FileManager.default.removeItem(at: dest)
+ try FileManager.default.moveItem(at: file, to: dest)
+ result = .success(())
+ } catch {
+ result = .failure(error)
+ }
+ } else {
+ result = .failure(URLError(.cannotDecodeContentData))
+ }
+ sem.signal()
+ }
+ task.resume()
+ _ = sem.wait(timeout: .now() + 1800)
+ try result.get()
+ }
+
+ private func run(_ launch: String, _ args: [String]) throws {
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: launch)
+ proc.arguments = args
+ try proc.run()
+ proc.waitUntilExit()
+ if proc.terminationStatus != 0 {
+ throw URLError(.cannotParseResponse)
+ }
+ }
+}
diff --git a/scripts/bundle-macos-swift.sh b/scripts/bundle-macos-swift.sh
new file mode 100755
index 0000000..f672efb
--- /dev/null
+++ b/scripts/bundle-macos-swift.sh
@@ -0,0 +1,128 @@
+#!/bin/sh
+# Build the native Swift macOS .app and zip it for the builds branch.
+
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
+cd "$root"
+
+export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
+if [ -z "${COMMIT:-}" ]; then
+ if [ ! -e "$root/.git" ] ||
+ ! COMMIT="$(git -C "$root" rev-parse --verify HEAD 2>/dev/null)"; then
+ echo "error: cannot determine the source commit; set COMMIT when bundling without Git metadata" >&2
+ exit 1
+ fi
+fi
+export COMMIT
+
+arch=$(uname -m)
+case "$arch" in
+ arm64|aarch64) arch_tag=arm64 ;;
+ x86_64) arch_tag=x86_64 ;;
+ *)
+ echo "Unsupported architecture: $arch" >&2
+ exit 1
+ ;;
+esac
+
+dd="$root/macos/DerivedData"
+out_zip="maturita-macos-swift-${arch_tag}.zip"
+mkdir -p "$dd" "$root/dist"
+
+xcodebuild \
+ -project macos/Maturita.xcodeproj \
+ -scheme Maturita \
+ -configuration Release \
+ -destination "generic/platform=macOS" \
+ -derivedDataPath "$dd" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ COMMIT="$COMMIT"
+
+app=$(find "$dd" -name 'Maturita.app' -path '*/Release/*' | head -n 1)
+if [ -z "$app" ]; then
+ echo "Maturita.app not found" >&2
+ exit 1
+fi
+
+dest="$root/dist/Maturita.app"
+rm -rf "$dest"
+cp -R "$app" "$dest"
+
+ENTITLEMENTS="$root/macos/Maturita/Maturita.entitlements"
+
+pick_sign_identity() {
+ if [ -n "${MACOS_SIGN_IDENTITY:-}" ]; then
+ printf '%s\n' "$MACOS_SIGN_IDENTITY"
+ return
+ fi
+ found="$(security find-identity -v -p codesigning 2>/dev/null \
+ | awk -F'"' '/Developer ID Application/ { print $2; exit }')"
+ if [ -n "$found" ]; then
+ printf '%s\n' "$found"
+ return
+ fi
+ printf '%s\n' "-"
+}
+
+IDENTITY="$(pick_sign_identity)"
+echo "==> Signing Maturita.app ($IDENTITY)"
+sign_args="--force --sign $IDENTITY"
+if [ "$IDENTITY" != "-" ]; then
+ sign_args="$sign_args --options runtime --timestamp"
+fi
+if [ -f "$ENTITLEMENTS" ]; then
+ # shellcheck disable=SC2086
+ codesign $sign_args --entitlements "$ENTITLEMENTS" "$dest"
+else
+ # shellcheck disable=SC2086
+ codesign $sign_args "$dest"
+fi
+codesign --verify --strict --verbose=2 "$dest"
+
+can_notarize() {
+ [ "$IDENTITY" != "-" ] || return 1
+ if [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_KEY_ID:-}" ] &&
+ [ -n "${APPLE_API_ISSUER:-}" ]; then
+ return 0
+ fi
+ if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] &&
+ [ -n "${APPLE_TEAM_ID:-}" ]; then
+ return 0
+ fi
+ return 1
+}
+
+if can_notarize; then
+ echo "==> Notarizing Maturita.app"
+ zip="$root/dist/.notarize-$out_zip"
+ rm -f "$zip"
+ ditto -c -k --keepParent "$dest" "$zip"
+ if [ -n "${APPLE_API_KEY:-}" ]; then
+ keyfile="$(mktemp)"
+ printf '%s\n' "$APPLE_API_KEY" > "$keyfile"
+ xcrun notarytool submit "$zip" --wait \
+ --key "$keyfile" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER"
+ rm -f "$keyfile"
+ else
+ xcrun notarytool submit "$zip" --wait \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_SPECIFIC_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID"
+ fi
+ rm -f "$zip"
+ xcrun stapler staple "$dest"
+else
+ if [ "$IDENTITY" != "-" ] && [ -n "${GITHUB_ACTIONS:-}${CI:-}" ]; then
+ echo "error: Developer ID is present but notarization secrets are missing." >&2
+ exit 1
+ fi
+fi
+
+rm -f "$root/dist/$out_zip"
+(
+ cd "$root/dist"
+ zip -qry "$out_zip" Maturita.app
+)
+echo "wrote $root/dist/$out_zip"
diff --git a/scripts/extract-android-content.py b/scripts/extract-android-content.py
index 12f11e9..433f5ed 100644
--- a/scripts/extract-android-content.py
+++ b/scripts/extract-android-content.py
@@ -10,8 +10,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
-# Default target is the Android asset; Xcode passes `--out` and copies the
-# file into the app bundle itself, so nothing generated lives under ios/.
+# Default target is the Android asset; Xcode (iOS / native macOS) passes
+# `--out` and copies the file into the app bundle itself, so nothing
+# generated lives under ios/ or macos/.
OUTS = [
ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json",
]
diff --git a/src/i18n.c b/src/i18n.c
index 5701117..7921bd9 100644
--- a/src/i18n.c
+++ b/src/i18n.c
@@ -66,6 +66,11 @@ const TrEntry tr_ui[] = {
"stejný obsah, rozhraní podle iOS.",
"Native exam practice on iPhone. Czech and English, "
"the same content, adapted to iOS."},
+ {"welcome_body_macos",
+ "Nativní procvičování na Macu. Česky i anglicky, "
+ "stejný obsah, rozhraní podle macOS.",
+ "Native exam practice on the Mac. Czech and English, "
+ "the same content, adapted to macOS."},
{"update_ios_sideload",
"Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).",
"A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."},