Add a native SwiftUI macOS app that shares the iOS player.

The GTK Mac build stays; CI now also publishes maturita-macos-swift.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-20 18:40:44 +02:00
commit 58e3a75f21
33 changed files with 1415 additions and 128 deletions

View file

@ -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 = "<group>"; };
B201000000000000000002 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Platform.swift; sourceTree = "<group>"; };
B201000000000000000003 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
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 = "<group>"; };
B201000000000000000017 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B201000000000000000018 /* Maturita.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Maturita.entitlements; sourceTree = "<group>"; };
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 = "<group>";
};
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 = "<group>";
};
B501000000000000000003 /* Data */ = {
isa = PBXGroup;
children = (
B201000000000000000006 /* JSON.swift */,
B201000000000000000007 /* Content.swift */,
B201000000000000000008 /* Theme.swift */,
B201000000000000000009 /* Answers.swift */,
B201000000000000000010 /* ProgressStore.swift */,
);
name = Data;
sourceTree = "<group>";
};
B501000000000000000004 /* Update */ = {
isa = PBXGroup;
children = (
B201000000000000000003 /* Updater.swift */,
);
path = Update;
sourceTree = "<group>";
};
B501000000000000000005 /* UI */ = {
isa = PBXGroup;
children = (
B201000000000000000011 /* Components.swift */,
B201000000000000000012 /* Icons.swift */,
B201000000000000000013 /* Screens.swift */,
B201000000000000000014 /* Exercises.swift */,
B201000000000000000015 /* ExercisesMore.swift */,
);
name = UI;
sourceTree = "<group>";
};
B501000000000000000006 /* Products */ = {
isa = PBXGroup;
children = (
B301000000000000000001 /* Maturita.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.8">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeFlags = "YES"
shouldUseLaunchSchemeArgsEnv = "NO"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeFlags = "YES"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B601000000000000000001"
BuildableName = "Maturita.app"
BlueprintName = "Maturita"
ReferencedContainer = "container:Maturita.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

12
macos/Maturita/Info.plist Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>GITCommit</key>
<string>$(COMMIT)</string>
<key>UPDATE_BRANCH</key>
<string>builds</string>
<key>UPDATE_REPO</key>
<string>pepaskopec-blip/maturita.c</string>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -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)
}
}

View file

@ -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

View file

@ -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<String, Error> = .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<Void, Error> = .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)
}
}
}