diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aabc95..f5a5da3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,45 @@ jobs: - name: Run tests with coverage run: pytest --cov=llm_quest_benchmark --cov-report=term-missing -v + + ios-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.2 + + - name: Set up Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "pnpm" + + - name: Install Node dependencies + env: + NODE_OPTIONS: --openssl-legacy-provider + run: | + pnpm install --frozen-lockfile + cd space-rangers-quest && npm install --legacy-peer-deps && npm run build + + - name: Build static site play assets + run: pnpm run build + + - name: Show Xcode version + run: | + xcodebuild -version + xcodebuild -showsdks + + - name: Build iOS app for simulator + run: | + xcodebuild build \ + -project ios/LLMQuest.xcodeproj \ + -scheme LLMQuest \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/docs/IOS_TESTFLIGHT.md b/docs/IOS_TESTFLIGHT.md new file mode 100644 index 0000000..080ce5f --- /dev/null +++ b/docs/IOS_TESTFLIGHT.md @@ -0,0 +1,142 @@ +# iOS TestFlight Build + +This repo ships the Play experience as a small native iOS app in `ios/LLMQuest.xcodeproj`. +The app uses `WKWebView` and serves a staged `site/` bundle through the local +`lqb://app/` scheme, so the same static quest UI, quest engine, AI answer panels, and +media paths are used on iOS. + +## Prerequisites + +- Full Xcode 16 or later, not Command Line Tools only. +- Apple Developer Program membership with App Store Connect access. +- A unique bundle id, for example `com.example.llmquest`. +- An App Store Connect app record that uses the same bundle id. +- Xcode signed in to the Apple Developer account, or App Store Connect API + credentials available for Transporter-based uploads. +- At least one supported physical iPhone or iPad for release testing. + +Apple documents App Store Connect as the distribution method for TestFlight and the +App Store: + +- https://help.apple.com/xcode/mac/current/en.lproj/dev31de635e5.html +- https://developer.apple.com/help/app-store-connect/manage-builds/upload-builds/ +- https://help.apple.com/xcode/mac/current/en.lproj/dev2539d985f.html +- https://developer.apple.com/help/app-store-connect/test-a-beta-version/provide-test-information/ +- https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy/ +- https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers/ + +## Build Site Assets + +```sh +pnpm run build +``` + +The Xcode target then stages only the Play runtime payload into the app bundle: +`play.html`, compiled JavaScript, quest archives, generated cohort JSON, frame +media, and vendored runtime files. It does not bundle benchmark trace pages, +ignored local trace artifacts, or source-only files such as `app.jsx` and +`engine-entry.ts`. The staging script copies from +`ios/LLMQuest/StageSiteInputs.xcfilelist`, which declares the exact inputs for +Xcode's script-phase dependency tracking and sandboxed file access. + +## Pull Request Build Gate + +The `ios-build` CI job builds `ios/LLMQuest.xcodeproj` on a macOS runner for the +iOS Simulator after rebuilding the bundled static site assets. A passing PR build +proves the Xcode project compiles and can bundle the current Play payload before +manual archive/signing work starts. + +## Archive for TestFlight + +Set the signing values for your account: + +```sh +export APPLE_TEAM_ID=YOURTEAMID +export IOS_BUNDLE_ID=com.example.llmquest +``` + +The project starts at marketing version 1.0 and build number 1. Before each +upload, bump the Xcode build number (`CURRENT_PROJECT_VERSION`) so App Store +Connect can distinguish the new build for the same `MARKETING_VERSION`. +The app declares `ITSAppUsesNonExemptEncryption` as false because it does not +ship custom or non-exempt encryption code. +The app also bundles `PrivacyInfo.xcprivacy`, declaring no tracking, no collected +data, and the required-reason file timestamp access used while serving bundled +local quest assets. +The local browser runtime files under `site/play/vendor/` are included in the +iOS app bundle for offline TestFlight execution; keep their `NOTICE.md` file with +the vendored assets. +`ios/export/ExportOptions.plist.template` uploads debug symbols with +`uploadSymbols=true` and strips Swift symbols with `stripSwiftSymbols=true` for +the App Store Connect export. + +Before inviting testers, complete the App Store Connect metadata that is not stored +in the Xcode project: + +- App Privacy: set the privacy policy URL for the iOS app. Apple requires a + privacy policy URL for all apps. The current app-side privacy manifest declares + no tracking and no collected data; keep the App Store Connect privacy responses + aligned with that behavior unless the app starts collecting data. +- Export Compliance: the app declares `ITSAppUsesNonExemptEncryption=false` in + `Info.plist`, which should prevent each TestFlight build from being marked as + missing compliance. If App Store Connect still prompts for export compliance, + answer that the app does not use encryption or is exempt from documentation. +- Beta App Review Information: provide the required review contact name, phone + number, and email address in App Store Connect. This app does not require + sign-in or a demo account; mention that in the review notes if the field is + available. + +Archive the app: + +```sh +xcodebuild archive \ + -project ios/LLMQuest.xcodeproj \ + -scheme LLMQuest \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/LLMQuest.xcarchive \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + PRODUCT_BUNDLE_IDENTIFIER="$IOS_BUNDLE_ID" \ + -allowProvisioningUpdates +``` + +Prepare local export options with your Apple team id, then upload to App Store +Connect. Keep the generated `build/ExportOptions.plist` out of git. + +```sh +cp ios/export/ExportOptions.plist.template build/ExportOptions.plist +/usr/libexec/PlistBuddy -c "Add :teamID string $APPLE_TEAM_ID" build/ExportOptions.plist 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Set :teamID $APPLE_TEAM_ID" build/ExportOptions.plist +``` + +```sh +xcodebuild -exportArchive \ + -archivePath build/LLMQuest.xcarchive \ + -exportPath build/LLMQuest-export \ + -exportOptionsPlist build/ExportOptions.plist \ + -allowProvisioningUpdates +``` + +After Apple finishes processing the uploaded build, review the build details and +metadata in App Store Connect, then enable it for internal or external TestFlight +testing. External testing requires Beta App Review. + +For external TestFlight testing, fill in App Store Connect > TestFlight > +Test Information before inviting testers: + +- Beta App Description: `LLM Quest is a beta iOS wrapper for the LLM Quest + Benchmark Play experience. Please load the quest list, start a quest, make at + least two choices, and confirm the AI answer panels, quest text, and media load + correctly.` +- Feedback Email: use the project support address for TestFlight replies. Do not + commit personal App Store Connect contact details to the repo. +- What to Test: `Open the quest list, start Bad Day or another short quest, make + at least two choices, and report whether quest text, images, audio, and AI + answer panels load correctly on your device.` +- Review Notes: `No sign-in or demo account is required. The app is a bundled + WKWebView wrapper around the static LLM Quest Benchmark Play experience. Start + from the quest list, choose Bad Day, and make two choices to reach the AI answer + comparison panel.` + +Apple recommends testing on the physical devices and OS versions you support before +distribution; simulator-only testing is not enough for TestFlight readiness. diff --git a/ios/LLMQuest.xcodeproj/project.pbxproj b/ios/LLMQuest.xcodeproj/project.pbxproj new file mode 100644 index 0000000..27b4cd5 --- /dev/null +++ b/ios/LLMQuest.xcodeproj/project.pbxproj @@ -0,0 +1,365 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 4B0000010000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000011 /* AppDelegate.swift */; }; + 4B0000010000000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000012 /* SceneDelegate.swift */; }; + 4B0000010000000000000003 /* QuestWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000013 /* QuestWebViewController.swift */; }; + 4B0000010000000000000004 /* LocalSiteSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000014 /* LocalSiteSchemeHandler.swift */; }; + 4B0000010000000000000005 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000016 /* Assets.xcassets */; }; + 4B0000010000000000000007 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4B0000010000000000000018 /* PrivacyInfo.xcprivacy */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 4B0000010000000000000010 /* LLMQuest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LLMQuest.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B0000010000000000000011 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMQuest/AppDelegate.swift; sourceTree = ""; }; + 4B0000010000000000000012 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMQuest/SceneDelegate.swift; sourceTree = ""; }; + 4B0000010000000000000013 /* QuestWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMQuest/QuestWebViewController.swift; sourceTree = ""; }; + 4B0000010000000000000014 /* LocalSiteSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMQuest/LocalSiteSchemeHandler.swift; sourceTree = ""; }; + 4B0000010000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = LLMQuest/Info.plist; sourceTree = ""; }; + 4B0000010000000000000016 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = LLMQuest/Assets.xcassets; sourceTree = ""; }; + 4B0000010000000000000017 /* site */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ../site; sourceTree = ""; }; + 4B0000010000000000000018 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = LLMQuest/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 4B0000010000000000000019 /* stage_site.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = scripts/stage_site.sh; sourceTree = ""; }; + 4B000001000000000000001A /* StageSiteInputs.xcfilelist */ = {isa = PBXFileReference; lastKnownFileType = text.xcfilelist; path = LLMQuest/StageSiteInputs.xcfilelist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4B0000010000000000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4B0000010000000000000030 = { + isa = PBXGroup; + children = ( + 4B0000010000000000000031 /* LLMQuest */, + 4B0000010000000000000017 /* site */, + 4B0000010000000000000032 /* Products */, + ); + sourceTree = ""; + }; + 4B0000010000000000000031 /* LLMQuest */ = { + isa = PBXGroup; + children = ( + 4B0000010000000000000011 /* AppDelegate.swift */, + 4B0000010000000000000012 /* SceneDelegate.swift */, + 4B0000010000000000000013 /* QuestWebViewController.swift */, + 4B0000010000000000000014 /* LocalSiteSchemeHandler.swift */, + 4B0000010000000000000015 /* Info.plist */, + 4B0000010000000000000018 /* PrivacyInfo.xcprivacy */, + 4B0000010000000000000016 /* Assets.xcassets */, + 4B000001000000000000001A /* StageSiteInputs.xcfilelist */, + 4B0000010000000000000019 /* stage_site.sh */, + ); + path = ""; + sourceTree = ""; + }; + 4B0000010000000000000032 /* Products */ = { + isa = PBXGroup; + children = ( + 4B0000010000000000000010 /* LLMQuest.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4B0000010000000000000040 /* LLMQuest */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B0000010000000000000070 /* Build configuration list for PBXNativeTarget "LLMQuest" */; + buildPhases = ( + 4B0000010000000000000050 /* Sources */, + 4B0000010000000000000020 /* Frameworks */, + 4B0000010000000000000051 /* Resources */, + 4B0000010000000000000052 /* Stage Play Site */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LLMQuest; + productName = LLMQuest; + productReference = 4B0000010000000000000010 /* LLMQuest.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4B0000010000000000000060 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 4B0000010000000000000040 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 4B0000010000000000000061 /* Build configuration list for PBXProject "LLMQuest" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4B0000010000000000000030; + productRefGroup = 4B0000010000000000000032 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4B0000010000000000000040 /* LLMQuest */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4B0000010000000000000051 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B0000010000000000000005 /* Assets.xcassets in Resources */, + 4B0000010000000000000007 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 4B0000010000000000000052 /* Stage Play Site */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + inputFileListPaths = ( + "$(SRCROOT)/LLMQuest/StageSiteInputs.xcfilelist", + ); + name = "Stage Play Site"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/site", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/scripts/stage_site.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4B0000010000000000000050 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B0000010000000000000001 /* AppDelegate.swift in Sources */, + 4B0000010000000000000002 /* SceneDelegate.swift in Sources */, + 4B0000010000000000000003 /* QuestWebViewController.swift in Sources */, + 4B0000010000000000000004 /* LocalSiteSchemeHandler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4B0000010000000000000080 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_OBJC_ROOT_CLASS = YES_ERROR; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + 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 = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 4B0000010000000000000081 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = 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_OBJC_ROOT_CLASS = YES_ERROR; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4B0000010000000000000082 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LLMQuest/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.yourconscience.llmquestbenchmark; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B0000010000000000000083 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LLMQuest/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.yourconscience.llmquestbenchmark; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4B0000010000000000000061 /* Build configuration list for PBXProject "LLMQuest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B0000010000000000000080 /* Debug */, + 4B0000010000000000000081 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4B0000010000000000000070 /* Build configuration list for PBXNativeTarget "LLMQuest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B0000010000000000000082 /* Debug */, + 4B0000010000000000000083 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4B0000010000000000000060 /* Project object */; +} diff --git a/ios/LLMQuest.xcodeproj/xcshareddata/xcschemes/LLMQuest.xcscheme b/ios/LLMQuest.xcodeproj/xcshareddata/xcschemes/LLMQuest.xcscheme new file mode 100644 index 0000000..c01ef77 --- /dev/null +++ b/ios/LLMQuest.xcodeproj/xcshareddata/xcschemes/LLMQuest.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/LLMQuest/AppDelegate.swift b/ios/LLMQuest/AppDelegate.swift new file mode 100644 index 0000000..66854de --- /dev/null +++ b/ios/LLMQuest/AppDelegate.swift @@ -0,0 +1,19 @@ +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } +} diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..71f0e44 --- /dev/null +++ b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images": [ + { + "filename": "icon-40.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "icon-60.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "icon-58.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "icon-87.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "icon-80.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "icon-120.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "icon-120.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "icon-180.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "icon-20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "icon-40.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "icon-29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "icon-58.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "icon-40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "icon-80.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "icon-76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "icon-152.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "icon-167.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "icon-1024.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..bfdbef8 Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-120.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-120.png new file mode 100644 index 0000000..d3cad8e Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-120.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-152.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-152.png new file mode 100644 index 0000000..5372b1c Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-152.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-167.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-167.png new file mode 100644 index 0000000..7a8f06d Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-167.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-180.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-180.png new file mode 100644 index 0000000..927c5b5 Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-180.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-20.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-20.png new file mode 100644 index 0000000..d3a7ac6 Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-20.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-29.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-29.png new file mode 100644 index 0000000..e6a0cfe Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-40.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-40.png new file mode 100644 index 0000000..02d01e9 Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-58.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-58.png new file mode 100644 index 0000000..f14f76c Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-58.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-60.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-60.png new file mode 100644 index 0000000..5af34de Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-60.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-76.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-76.png new file mode 100644 index 0000000..2028f78 Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-80.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-80.png new file mode 100644 index 0000000..2dbf77a Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-80.png differ diff --git a/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-87.png b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-87.png new file mode 100644 index 0000000..5e98b1c Binary files /dev/null and b/ios/LLMQuest/Assets.xcassets/AppIcon.appiconset/icon-87.png differ diff --git a/ios/LLMQuest/Assets.xcassets/Contents.json b/ios/LLMQuest/Assets.xcassets/Contents.json new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/ios/LLMQuest/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/LLMQuest/Info.plist b/ios/LLMQuest/Info.plist new file mode 100644 index 0000000..4147d84 --- /dev/null +++ b/ios/LLMQuest/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LLM Quest + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + ITSAppUsesNonExemptEncryption + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/LLMQuest/LocalSiteSchemeHandler.swift b/ios/LLMQuest/LocalSiteSchemeHandler.swift new file mode 100644 index 0000000..07692f8 --- /dev/null +++ b/ios/LLMQuest/LocalSiteSchemeHandler.swift @@ -0,0 +1,100 @@ +import Foundation +import UniformTypeIdentifiers +import WebKit + +final class LocalSiteSchemeHandler: NSObject, WKURLSchemeHandler { + private let siteRoot: URL + + override init() { + guard let siteRoot = Bundle.main.resourceURL?.appendingPathComponent("site", isDirectory: true) else { + fatalError("Unable to locate app bundle resources") + } + self.siteRoot = siteRoot + super.init() + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let requestURL = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(LocalSiteError.invalidURL) + return + } + + let requestedPath = requestURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let relativePath = requestedPath.isEmpty ? "play.html" : requestedPath + + guard !relativePath.split(separator: "/").contains("..") else { + urlSchemeTask.didFailWithError(LocalSiteError.invalidURL) + return + } + + let fileURL = siteRoot.appendingPathComponent(relativePath, isDirectory: false).standardizedFileURL + let siteRootPath = siteRoot.standardizedFileURL.path + + guard fileURL.path == siteRootPath || fileURL.path.hasPrefix(siteRootPath + "/") else { + urlSchemeTask.didFailWithError(LocalSiteError.invalidURL) + return + } + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + guard let response = HTTPURLResponse( + url: requestURL, + statusCode: 404, + httpVersion: nil, + headerFields: nil + ) else { + urlSchemeTask.didFailWithError(LocalSiteError.invalidURL) + return + } + urlSchemeTask.didReceive(response) + urlSchemeTask.didFinish() + return + } + + do { + let data = try Data(contentsOf: fileURL) + let response = URLResponse( + url: requestURL, + mimeType: mimeType(for: fileURL), + expectedContentLength: data.count, + textEncodingName: nil + ) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } catch { + urlSchemeTask.didFailWithError(error) + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + + private func mimeType(for fileURL: URL) -> String { + let ext = fileURL.pathExtension.lowercased() + switch ext { + case "html": + return "text/html" + case "js": + return "application/javascript" + case "css": + return "text/css" + case "json": + return "application/json" + case "gz": + return "application/gzip" + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "svg": + return "image/svg+xml" + case "mp3": + return "audio/mpeg" + default: + return UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream" + } + } +} + +enum LocalSiteError: Error { + case invalidURL +} diff --git a/ios/LLMQuest/PrivacyInfo.xcprivacy b/ios/LLMQuest/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..1cbcec2 --- /dev/null +++ b/ios/LLMQuest/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/LLMQuest/QuestWebViewController.swift b/ios/LLMQuest/QuestWebViewController.swift new file mode 100644 index 0000000..c9d1325 --- /dev/null +++ b/ios/LLMQuest/QuestWebViewController.swift @@ -0,0 +1,117 @@ +import UIKit +import WebKit + +final class QuestWebViewController: UIViewController { + private var webView: WKWebView! + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(red: 0.05, green: 0.07, blue: 0.09, alpha: 1) + + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(LocalSiteSchemeHandler(), forURLScheme: "lqb") + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = [] + + let contentController = configuration.userContentController + contentController.add(self, name: "shareFile") + + webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + + view.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + loadPlayPage() + } + + private func loadPlayPage() { + guard let url = URL(string: "lqb://app/play.html") else { + assertionFailure("Invalid bundled play URL") + return + } + webView.load(URLRequest(url: url)) + } + + private func presentShareSheet(items: [Any]) { + let vc = UIActivityViewController(activityItems: items, applicationActivities: nil) + vc.popoverPresentationController?.sourceView = webView + vc.popoverPresentationController?.sourceRect = CGRect( + x: webView.bounds.midX, y: webView.bounds.maxY - 40, + width: 1, height: 1 + ) + present(vc, animated: true) + } +} + +// MARK: - WKScriptMessageHandler + +extension QuestWebViewController: WKScriptMessageHandler { + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.name == "shareFile", + let body = message.body as? [String: Any], + let content = body["content"] as? String, + let filename = body["filename"] as? String + else { return } + + let mimeType = body["mimeType"] as? String ?? "application/octet-stream" + let tmpDir = FileManager.default.temporaryDirectory + let fileURL = tmpDir.appendingPathComponent(filename) + + let data: Data? + if mimeType.hasPrefix("image/"), let b64 = content.split(separator: ",").last { + data = Data(base64Encoded: String(b64)) + } else { + data = content.data(using: .utf8) + } + + guard let fileData = data else { return } + + do { + try fileData.write(to: fileURL, options: .atomic) + presentShareSheet(items: [fileURL]) + } catch { + // File write failed — fall through silently. + } + } +} + +// MARK: - WKNavigationDelegate + +extension QuestWebViewController: WKNavigationDelegate { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + + if url.scheme == "lqb" { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + UIApplication.shared.open(url) + } + + decisionHandler(.cancel) + } +} diff --git a/ios/LLMQuest/SceneDelegate.swift b/ios/LLMQuest/SceneDelegate.swift new file mode 100644 index 0000000..5eb3487 --- /dev/null +++ b/ios/LLMQuest/SceneDelegate.swift @@ -0,0 +1,20 @@ +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = QuestWebViewController() + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/ios/LLMQuest/StageSiteInputs.xcfilelist b/ios/LLMQuest/StageSiteInputs.xcfilelist new file mode 100644 index 0000000..7639293 --- /dev/null +++ b/ios/LLMQuest/StageSiteInputs.xcfilelist @@ -0,0 +1,81 @@ +$(SRCROOT)/scripts/stage_site.sh +$(SRCROOT)/../site/play.html +$(SRCROOT)/../site/play/Badday_eng.json +$(SRCROOT)/../site/play/Banket_eng.json +$(SRCROOT)/../site/play/Borzukhan_eng.json +$(SRCROOT)/../site/play/Codebox_eng.json +$(SRCROOT)/../site/play/Depth_eng.json +$(SRCROOT)/../site/play/Disk_eng.json +$(SRCROOT)/../site/play/Driver_eng.json +$(SRCROOT)/../site/play/Edelweiss_eng.json +$(SRCROOT)/../site/play/Election_eng.json +$(SRCROOT)/../site/play/Foncers_eng.json +$(SRCROOT)/../site/play/Leonardo_eng.json +$(SRCROOT)/../site/play/Ministry_eng.json +$(SRCROOT)/../site/play/Pilot_eng.json +$(SRCROOT)/../site/play/Pizza_eng.json +$(SRCROOT)/../site/play/Player_eng.json +$(SRCROOT)/../site/play/Robots_eng.json +$(SRCROOT)/../site/play/Shashki_eng.json +$(SRCROOT)/../site/play/Ski_eng.json +$(SRCROOT)/../site/play/Sortirovka1_eng.json +$(SRCROOT)/../site/play/app.js +$(SRCROOT)/../site/play/qmengine.js +$(SRCROOT)/../site/play/qmengine.js.LICENSE.txt +$(SRCROOT)/../site/play/quest-index.json +$(SRCROOT)/../site/play/questplay/background.jpg +$(SRCROOT)/../site/play/questplay/frame-bottom.png +$(SRCROOT)/../site/play/questplay/frame-left-bottom-2.png +$(SRCROOT)/../site/play/questplay/frame-left-bottom.png +$(SRCROOT)/../site/play/questplay/frame-left-top-2.png +$(SRCROOT)/../site/play/questplay/frame-left-top.png +$(SRCROOT)/../site/play/questplay/frame-left.png +$(SRCROOT)/../site/play/questplay/frame-right-bottom-2.png +$(SRCROOT)/../site/play/questplay/frame-right-bottom.png +$(SRCROOT)/../site/play/questplay/frame-right-top-2.png +$(SRCROOT)/../site/play/questplay/frame-right-top.png +$(SRCROOT)/../site/play/questplay/frame-right.png +$(SRCROOT)/../site/play/questplay/frame-top.png +$(SRCROOT)/../site/play/questplay/frame.png +$(SRCROOT)/../site/play/quests/Badday_eng.qm.gz +$(SRCROOT)/../site/play/quests/Badday_ru.qm.gz +$(SRCROOT)/../site/play/quests/Banket_eng.qm.gz +$(SRCROOT)/../site/play/quests/Banket_ru.qm.gz +$(SRCROOT)/../site/play/quests/Borzukhan_eng.qm.gz +$(SRCROOT)/../site/play/quests/Borzukhan_ru.qm.gz +$(SRCROOT)/../site/play/quests/Codebox_eng.qm.gz +$(SRCROOT)/../site/play/quests/Codebox_ru.qm.gz +$(SRCROOT)/../site/play/quests/Depth_eng.qm.gz +$(SRCROOT)/../site/play/quests/Depth_ru.qm.gz +$(SRCROOT)/../site/play/quests/Disk_eng.qm.gz +$(SRCROOT)/../site/play/quests/Driver_eng.qm.gz +$(SRCROOT)/../site/play/quests/Driver_ru.qm.gz +$(SRCROOT)/../site/play/quests/Edelweiss_eng.qm.gz +$(SRCROOT)/../site/play/quests/Edelweiss_ru.qm.gz +$(SRCROOT)/../site/play/quests/Election_eng.qm.gz +$(SRCROOT)/../site/play/quests/Election_ru.qm.gz +$(SRCROOT)/../site/play/quests/Foncers_eng.qm.gz +$(SRCROOT)/../site/play/quests/Foncers_ru.qm.gz +$(SRCROOT)/../site/play/quests/Leonardo_eng.qm.gz +$(SRCROOT)/../site/play/quests/Leonardo_ru.qm.gz +$(SRCROOT)/../site/play/quests/Ministry_eng.qm.gz +$(SRCROOT)/../site/play/quests/Ministry_ru.qm.gz +$(SRCROOT)/../site/play/quests/Pilot_eng.qm.gz +$(SRCROOT)/../site/play/quests/Pilot_ru.qm.gz +$(SRCROOT)/../site/play/quests/Pizza_eng.qm.gz +$(SRCROOT)/../site/play/quests/Pizza_ru.qm.gz +$(SRCROOT)/../site/play/quests/Player_eng.qm.gz +$(SRCROOT)/../site/play/quests/Player_ru.qm.gz +$(SRCROOT)/../site/play/quests/Robots_eng.qm.gz +$(SRCROOT)/../site/play/quests/Robots_ru.qm.gz +$(SRCROOT)/../site/play/quests/Shashki_eng.qm.gz +$(SRCROOT)/../site/play/quests/Shashki_ru.qm.gz +$(SRCROOT)/../site/play/quests/Ski_eng.qm.gz +$(SRCROOT)/../site/play/quests/Ski_ru.qm.gz +$(SRCROOT)/../site/play/quests/Sortirovka1_eng.qm.gz +$(SRCROOT)/../site/play/quests/Sortirovka1_ru.qm.gz +$(SRCROOT)/../site/play/vendor/NOTICE.md +$(SRCROOT)/../site/play/vendor/bootstrap-5.3.3.min.css +$(SRCROOT)/../site/play/vendor/pako-2.1.0.min.js +$(SRCROOT)/../site/play/vendor/react-18.3.1.production.min.js +$(SRCROOT)/../site/play/vendor/react-dom-18.3.1.production.min.js diff --git a/ios/export/ExportOptions.plist.template b/ios/export/ExportOptions.plist.template new file mode 100644 index 0000000..dd1294e --- /dev/null +++ b/ios/export/ExportOptions.plist.template @@ -0,0 +1,16 @@ + + + + + method + app-store-connect + destination + upload + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + + diff --git a/ios/scripts/stage_site.sh b/ios/scripts/stage_site.sh new file mode 100755 index 0000000..d60d15e --- /dev/null +++ b/ios/scripts/stage_site.sh @@ -0,0 +1,70 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) + +if [ -z "${SRCROOT:-}" ]; then + SRCROOT=$(CDPATH= cd "${SCRIPT_DIR}/.." && pwd) +fi + +if [ -z "${SOURCE_SITE:-}" ]; then + SOURCE_SITE="${SRCROOT}/../site" +fi + +if [ -z "${STAGE_SITE_INPUTS:-}" ]; then + STAGE_SITE_INPUTS="${SRCROOT}/LLMQuest/StageSiteInputs.xcfilelist" +fi + +if [ -z "${DEST_SITE:-}" ]; then + if [ -z "${TARGET_BUILD_DIR:-}" ] || [ -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" ]; then + echo "DEST_SITE or Xcode build destination variables are required." >&2 + exit 1 + fi + DEST_SITE="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/site" +fi + +SOURCE_SITE=$(CDPATH= cd "${SOURCE_SITE}" && pwd) + +if [ ! -f "${SOURCE_SITE}/play.html" ]; then + echo "Missing ${SOURCE_SITE}/play.html. Run pnpm run build before archiving." >&2 + exit 1 +fi + +if [ ! -f "${STAGE_SITE_INPUTS}" ]; then + echo "Missing ${STAGE_SITE_INPUTS}." >&2 + exit 1 +fi + +mkdir -p "$(dirname "${DEST_SITE}")" +rm -rf "${DEST_SITE}" +mkdir -p "${DEST_SITE}" + +while IFS= read -r input || [ -n "${input}" ]; do + case "${input}" in + ""|\#*) continue ;; + esac + + source_path=$(printf "%s\n" "${input}" | awk -v srcroot="${SRCROOT}" '{gsub(/[$][(]SRCROOT[)]/, srcroot); print}') + source_path=$(CDPATH= cd "$(dirname "${source_path}")" && pwd)/$(basename "${source_path}") + case "${source_path}" in + "${SOURCE_SITE}"/*) + relative_path=${source_path#"${SOURCE_SITE}/"} + mkdir -p "$(dirname "${DEST_SITE}/${relative_path}")" + cp "${source_path}" "${DEST_SITE}/${relative_path}" + ;; + esac +done < "${STAGE_SITE_INPUTS}" + +for required in \ + play.html \ + play/app.js \ + play/qmengine.js \ + play/quest-index.json \ + play/questplay/background.jpg \ + play/vendor/NOTICE.md +do + if [ ! -e "${DEST_SITE}/${required}" ]; then + echo "iOS site staging missed required asset: ${required}" >&2 + exit 1 + fi +done diff --git a/llm_quest_benchmark/tests/core/test_analyzer.py b/llm_quest_benchmark/tests/core/test_analyzer.py index 306fd2d..57e623c 100644 --- a/llm_quest_benchmark/tests/core/test_analyzer.py +++ b/llm_quest_benchmark/tests/core/test_analyzer.py @@ -1,9 +1,12 @@ """Tests for quest run analyzer""" import json +import os import sqlite3 +from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path +from tempfile import TemporaryDirectory from typer.testing import CliRunner @@ -11,6 +14,17 @@ from llm_quest_benchmark.executors.cli.commands import app +@contextmanager +def isolated_filesystem(): + previous_cwd = Path.cwd() + with TemporaryDirectory() as temp_dir: + os.chdir(temp_dir) + try: + yield Path(temp_dir) + finally: + os.chdir(previous_cwd) + + def setup_test_db(db_path: Path): """Set up test database with sample data""" conn = sqlite3.connect(db_path) @@ -132,7 +146,7 @@ def test_analyze_quest_run(tmp_path): # Test CLI command runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--quest", "test1.qm", "--db", str(db_path), "--debug"]) assert result.exit_code == 0 @@ -155,7 +169,7 @@ def test_analyze_benchmark(tmp_path): # Test CLI command for all benchmarks runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--benchmark", "baseline", "--db", str(db_path)]) assert result.exit_code == 0 @@ -183,7 +197,7 @@ def test_analyze_specific_benchmark(tmp_path): # Test CLI command for specific benchmark runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--benchmark", "experimental", "--db", str(db_path)]) assert result.exit_code == 0 @@ -220,7 +234,7 @@ def test_analyze_metrics(tmp_path): # Test CLI command runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--quest", "test1.qm", "--db", str(db_path)]) assert result.exit_code == 0 assert "Quest Run Summary" in result.stdout @@ -233,7 +247,7 @@ def test_analyze_metrics(tmp_path): def test_analyze_no_metrics_dir(tmp_path): """Test analyze command with non-existent metrics directory""" runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--quest", "test1.qm"]) assert result.exit_code == 1 assert "Database not found" in result.output @@ -261,7 +275,7 @@ def test_analyze_empty_metrics_dir(tmp_path): conn.close() runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--quest", "test1.qm", "--db", str(db_path)]) assert result.exit_code == 1 assert "No runs found for quest" in result.output @@ -275,7 +289,7 @@ def test_analyze_invalid_file(tmp_path): f.write("invalid data") runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--quest", "test1.qm", "--db", str(db_path)]) assert result.exit_code == 1 assert "Error analyzing quest run" in result.output @@ -288,7 +302,7 @@ def test_analyze_invalid_benchmark_file(tmp_path): setup_test_db(db_path) runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--benchmark", "nonexistent", "--db", str(db_path)]) assert result.exit_code == 1 assert "No benchmark data found for nonexistent" in result.output @@ -337,7 +351,7 @@ def test_analyze_benchmark_directory(tmp_path): # Test CLI command runner = CliRunner() - with runner.isolated_filesystem(): + with isolated_filesystem(): result = runner.invoke(app, ["analyze", "--benchmark", "benchmark1", "--db", str(db_path)]) assert result.exit_code == 0 diff --git a/llm_quest_benchmark/tests/test_ios_testflight.py b/llm_quest_benchmark/tests/test_ios_testflight.py new file mode 100644 index 0000000..88aefca --- /dev/null +++ b/llm_quest_benchmark/tests/test_ios_testflight.py @@ -0,0 +1,335 @@ +import json +import os +import plistlib +import re +import subprocess +from html.parser import HTMLParser +from pathlib import Path +from xml.etree import ElementTree + +REPO_ROOT = Path(__file__).resolve().parents[2] +IOS_DIR = REPO_ROOT / "ios" +SITE_DIR = REPO_ROOT / "site" +PLAY_PAGE = SITE_DIR / "play.html" +PLAY_DIR = SITE_DIR / "play" +PLAY_QUESTS_DIR = PLAY_DIR / "quests" +CI_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml" +PROJECT_FILE = IOS_DIR / "LLMQuest.xcodeproj" / "project.pbxproj" +SCHEME_FILE = IOS_DIR / "LLMQuest.xcodeproj" / "xcshareddata" / "xcschemes" / "LLMQuest.xcscheme" +INFO_PLIST = IOS_DIR / "LLMQuest" / "Info.plist" +PRIVACY_MANIFEST = IOS_DIR / "LLMQuest" / "PrivacyInfo.xcprivacy" +STAGE_SITE_INPUTS = IOS_DIR / "LLMQuest" / "StageSiteInputs.xcfilelist" +STAGE_SITE_SCRIPT = IOS_DIR / "scripts" / "stage_site.sh" +APP_ICON_SET = IOS_DIR / "LLMQuest" / "Assets.xcassets" / "AppIcon.appiconset" +EXPORT_OPTIONS = IOS_DIR / "export" / "ExportOptions.plist.template" +TESTFLIGHT_DOC = REPO_ROOT / "docs" / "IOS_TESTFLIGHT.md" + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +class AssetHTMLParser(HTMLParser): + def __init__(self): + super().__init__() + self.assets = [] + + def handle_starttag(self, tag, attrs): + for attr, value in attrs: + if attr in {"href", "src"} and value: + self.assets.append(value) + + +def test_ios_project_bundles_static_site_and_swift_sources(): + project = read_text(PROJECT_FILE) + + assert "LLMQuest.app" in project + assert "AppDelegate.swift in Sources" in project + assert "SceneDelegate.swift in Sources" in project + assert "QuestWebViewController.swift in Sources" in project + assert "LocalSiteSchemeHandler.swift in Sources" in project + assert "PrivacyInfo.xcprivacy in Resources" in project + assert "Stage Play Site" in project + assert "alwaysOutOfDate = 1;" in project + assert "$(SRCROOT)/LLMQuest/StageSiteInputs.xcfilelist" in project + assert '"$(SRCROOT)/../site/play",' not in project + assert '"$(SRCROOT)/../site/play.html",' not in project + assert 'shellScript = "\\"${SRCROOT}/scripts/stage_site.sh\\"\\n";' in project + assert "path = ../site;" in project + assert "site in Resources" not in project + assert "PRODUCT_BUNDLE_IDENTIFIER = io.github.yourconscience.llmquestbenchmark;" in project + assert "IPHONEOS_DEPLOYMENT_TARGET = 16.0;" in project + assert 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";' in project + assert 'TARGETED_DEVICE_FAMILY = "1,2";' in project + + +def test_ios_app_serves_play_page_from_bundle_scheme(): + controller = read_text(IOS_DIR / "LLMQuest" / "QuestWebViewController.swift") + scheme_handler = read_text(IOS_DIR / "LLMQuest" / "LocalSiteSchemeHandler.swift") + + assert 'setURLSchemeHandler(LocalSiteSchemeHandler(), forURLScheme: "lqb")' in controller + assert 'URL(string: "lqb://app/play.html")' in controller + assert "allowsInlineMediaPlayback = true" in controller + assert "mediaTypesRequiringUserActionForPlayback = []" in controller + assert 'url.scheme == "lqb"' in controller + assert 'url.host == "yourconscience.github.io"' not in controller + assert "UIApplication.shared.open(url)" in controller + assert 'appendingPathComponent("site", isDirectory: true)' in scheme_handler + assert 'let relativePath = requestedPath.isEmpty ? "play.html" : requestedPath' in scheme_handler + assert "statusCode: 404" in scheme_handler + assert "standardizedFileURL" in scheme_handler + assert "fileURL.path.hasPrefix(siteRootPath + " in scheme_handler + assert '"application/gzip"' in scheme_handler + assert '"audio/mpeg"' in scheme_handler + + +def test_ios_metadata_and_export_options_are_valid(): + project = read_text(PROJECT_FILE) + info = plistlib.loads(INFO_PLIST.read_bytes()) + privacy = plistlib.loads(PRIVACY_MANIFEST.read_bytes()) + export = plistlib.loads(EXPORT_OPTIONS.read_bytes()) + scheme = ElementTree.parse(SCHEME_FILE).getroot() + json.loads((IOS_DIR / "LLMQuest" / "Assets.xcassets" / "Contents.json").read_text()) + icons = json.loads((APP_ICON_SET / "Contents.json").read_text()) + + build_action_entry = scheme.find("./BuildAction/BuildActionEntries/BuildActionEntry") + archive_action = scheme.find("./ArchiveAction") + assert build_action_entry is not None + assert build_action_entry.attrib["buildForArchiving"] == "YES" + assert archive_action is not None + assert archive_action.attrib["buildConfiguration"] == "Release" + + assert info["CFBundleDisplayName"] == "LLM Quest" + assert info["CFBundleIdentifier"] == "$(PRODUCT_BUNDLE_IDENTIFIER)" + assert info["CFBundleShortVersionString"] == "$(MARKETING_VERSION)" + assert info["CFBundleVersion"] == "$(CURRENT_PROJECT_VERSION)" + assert info["LSRequiresIPhoneOS"] is True + assert info["ITSAppUsesNonExemptEncryption"] is False + assert "UILaunchScreen" in info + assert info["UISupportedInterfaceOrientations"] == [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ] + assert info["UISupportedInterfaceOrientations~ipad"] == [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ] + assert export["method"] == "app-store-connect" + assert export["destination"] == "upload" + assert export["signingStyle"] == "automatic" + assert export["stripSwiftSymbols"] is True + assert export["uploadSymbols"] is True + assert any(icon.get("idiom") == "ios-marketing" for icon in icons["images"]) + assert project.count("MARKETING_VERSION = 1.0;") == 2 + assert project.count("CURRENT_PROJECT_VERSION = 1;") == 2 + assert privacy["NSPrivacyCollectedDataTypes"] == [] + assert privacy["NSPrivacyTracking"] is False + assert privacy["NSPrivacyTrackingDomains"] == [] + assert privacy["NSPrivacyAccessedAPITypes"] == [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": ["C617.1"], + } + ] + + +def test_ios_bundled_play_page_assets_are_present_after_site_build(): + page = read_text(PLAY_PAGE) + parser = AssetHTMLParser() + parser.feed(page) + + local_assets = [asset for asset in parser.assets if not asset.startswith(("http://", "https://", "#"))] + local_runtime_assets = [asset for asset in local_assets if asset.startswith("play/")] + + assert "play/qmengine.js" in local_runtime_assets + assert "play/app.js" in local_runtime_assets + assert "play/vendor/bootstrap-5.3.3.min.css" in local_runtime_assets + assert "play/vendor/react-18.3.1.production.min.js" in local_runtime_assets + assert "play/vendor/react-dom-18.3.1.production.min.js" in local_runtime_assets + assert "play/vendor/pako-2.1.0.min.js" in local_runtime_assets + assert "play/questplay/background.jpg" in page + assert "https://cdn.jsdelivr.net" not in page + assert "https://unpkg.com" not in page + + for asset in [*local_assets, "play/questplay/background.jpg"]: + assert (SITE_DIR / asset).exists(), asset + + +def test_ios_site_staging_input_file_list_covers_copied_assets(): + entries = set(STAGE_SITE_INPUTS.read_text(encoding="utf-8").splitlines()) + script = read_text(STAGE_SITE_SCRIPT) + expected = { + "$(SRCROOT)/scripts/stage_site.sh", + "$(SRCROOT)/../site/play.html", + "$(SRCROOT)/../site/play/app.js", + "$(SRCROOT)/../site/play/qmengine.js", + "$(SRCROOT)/../site/play/qmengine.js.LICENSE.txt", + } + + expected.update(f"$(SRCROOT)/../{path.relative_to(REPO_ROOT)}" for path in PLAY_DIR.glob("*.json")) + for directory in ("questplay", "quests", "vendor"): + expected.update( + f"$(SRCROOT)/../{path.relative_to(REPO_ROOT)}" + for path in (PLAY_DIR / directory).rglob("*") + if path.is_file() + ) + + assert expected <= entries + assert "$(SRCROOT)/../site/play/app.jsx" not in entries + assert "$(SRCROOT)/../site/play/engine-entry.ts" not in entries + assert "-exec cp" not in script + assert "cp -R" not in script + + +def test_ios_site_staging_includes_only_play_runtime_payload(tmp_path): + destination = tmp_path / "bundle" / "site" + + subprocess.run( + ["sh", str(STAGE_SITE_SCRIPT)], + check=True, + env={ + **os.environ, + "SOURCE_SITE": str(SITE_DIR), + "DEST_SITE": str(destination), + }, + ) + + assert (destination / "play.html").exists() + assert (destination / "play" / "app.js").exists() + assert (destination / "play" / "qmengine.js").exists() + assert (destination / "play" / "quest-index.json").exists() + assert (destination / "play" / "quests" / "Badday_eng.qm.gz").exists() + assert (destination / "play" / "questplay" / "background.jpg").exists() + assert (destination / "play" / "vendor" / "NOTICE.md").exists() + + staged_files = {path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file()} + expected_files = { + entry.replace("$(SRCROOT)/../site/", "") + for entry in STAGE_SITE_INPUTS.read_text(encoding="utf-8").splitlines() + if entry.startswith("$(SRCROOT)/../site/") + } + + assert staged_files == expected_files + assert not (destination / "index.html").exists() + assert not (destination / "about.html").exists() + assert not (destination / "traces.html").exists() + assert not (destination / "traces").exists() + assert not (destination / "play" / "app.jsx").exists() + assert not (destination / "play" / "engine-entry.ts").exists() + + +def test_ios_bundled_vendor_runtime_assets_include_license_notice(): + notice = read_text(PLAY_DIR / "vendor" / "NOTICE.md") + + assert "Bootstrap 5.3.3" in notice + assert "React 18.3.1" in notice + assert "React DOM 18.3.1" in notice + assert "pako 2.1.0" in notice + assert "MIT" in notice + assert "Zlib" in notice + + +def test_ios_bundled_quest_archives_cover_play_index_after_site_build(): + index = json.loads((PLAY_DIR / "quest-index.json").read_text(encoding="utf-8")) + quest_ids = {quest["id"] for quest in index["quests"]} + canonical_ids = {quest["canonical_id"] for quest in index["quests"] if quest.get("canonical_id")} + + assert quest_ids + assert canonical_ids <= quest_ids + + for quest_id in quest_ids: + archive = PLAY_QUESTS_DIR / f"{quest_id}.qm.gz" + assert archive.exists(), quest_id + assert archive.stat().st_size > 0, quest_id + + for canonical_id in canonical_ids: + cohort = PLAY_DIR / f"{canonical_id}.json" + assert cohort.exists(), canonical_id + assert cohort.stat().st_size > 0, canonical_id + + +def test_ios_app_icon_files_match_declared_sizes(): + icons = json.loads((APP_ICON_SET / "Contents.json").read_text())["images"] + + for icon in icons: + filename = icon["filename"] + expected_size = int(float(icon["size"].split("x", 1)[0]) * int(icon["scale"].rstrip("x"))) + path = APP_ICON_SET / filename + assert path.exists(), filename + header = path.read_bytes()[:26] + assert header[:8] == b"\x89PNG\r\n\x1a\n", filename + width = int.from_bytes(header[16:20], "big") + height = int.from_bytes(header[20:24], "big") + color_type = header[25] + assert width == expected_size, filename + assert height == expected_size, filename + assert color_type == 2, f"{filename} should be RGB PNG without alpha" + + +def test_ios_testflight_docs_include_archive_and_upload_commands(): + doc = read_text(TESTFLIGHT_DOC) + + assert "Full Xcode 16 or later, not Command Line Tools only." in doc + assert "physical iPhone or iPad" in doc + assert "ios-build" in doc + assert "iOS Simulator" in doc + assert "APPLE_TEAM_ID" in doc + assert "IOS_BUNDLE_ID" in doc + assert "App Store Connect app record" in doc + assert "App Store Connect API" in doc + assert "Transporter-based uploads" in doc + assert "CURRENT_PROJECT_VERSION" in doc + assert "MARKETING_VERSION" in doc + assert "starts at marketing version 1.0 and build number 1" in doc + assert "uploadSymbols" in doc + assert "stripSwiftSymbols" in doc + assert "ITSAppUsesNonExemptEncryption" in doc + assert "PrivacyInfo.xcprivacy" in doc + assert "stages only the Play runtime payload" in doc + assert "source-only files" in doc + assert "site/play/vendor/" in doc + assert "NOTICE.md" in doc + assert "App Privacy" in doc + assert "privacy policy URL" in doc + assert "no tracking" in doc + assert "no collected" in doc + assert "Export Compliance" in doc + assert "missing compliance" in doc + assert "Beta App Review Information" in doc + assert "review contact name" in doc + assert "phone" in doc + assert "No sign-in or demo account is required." in doc + assert re.search(r"xcodebuild archive\s+\\", doc) + assert "-project ios/LLMQuest.xcodeproj" in doc + assert "-scheme LLMQuest" in doc + assert "-destination 'generic/platform=iOS'" in doc + assert "build/ExportOptions.plist" in doc + assert "PlistBuddy" in doc + assert "teamID" in doc + assert "Keep the generated `build/ExportOptions.plist` out of git." in doc + assert "xcodebuild -exportArchive" in doc + assert "-exportOptionsPlist build/ExportOptions.plist" in doc + assert "Test Information" in doc + assert "Beta App Description" in doc + assert "Feedback Email" in doc + assert "What to Test" in doc + assert "Review Notes" in doc + assert "project support address" in doc + assert "External testing requires Beta App Review." in doc + assert "simulator-only testing is not enough" in doc + + +def test_ci_builds_ios_project_on_macos_for_prs(): + workflow = read_text(CI_WORKFLOW) + + assert "ios-build:" in workflow + assert "runs-on: macos-15" in workflow + assert "pnpm run build" in workflow + assert "xcodebuild build" in workflow + assert "-project ios/LLMQuest.xcodeproj" in workflow + assert "-scheme LLMQuest" in workflow + assert "-destination 'generic/platform=iOS Simulator'" in workflow + assert "CODE_SIGNING_ALLOWED=NO" in workflow diff --git a/llm_quest_benchmark/tests/test_play_share_social.py b/llm_quest_benchmark/tests/test_play_share_social.py index ac8ab4a..188e8ae 100644 --- a/llm_quest_benchmark/tests/test_play_share_social.py +++ b/llm_quest_benchmark/tests/test_play_share_social.py @@ -35,3 +35,18 @@ def test_play_history_does_not_claim_ai_agreement_with_limited_data(): assert "if (aiAgreeRate != null)" in source assert "Limited AI data: " in source assert "Limited AI data" in source + + +def test_play_surface_keeps_mobile_ai_and_media_support(): + source = APP_SOURCE.read_text(encoding="utf-8") + + assert "function CurrentDecisionInsight" in source + assert "AI answers" in source + assert "function MediaStage" in source + assert "gameState && gameState.imageName" in source + assert "gameState && gameState.trackName" in source + assert "gameState && gameState.soundName" in source + assert "const MEDIA_BASE = 'play/media/';" in source + assert "lower.includes('.') ? '' : ext" in source + assert 'preload="auto"' in source + assert "setAudioEnabled(v => !v)" in source diff --git a/package.json b/package.json index 250b5f1..a81f767 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,10 @@ "private": true, "packageManager": "pnpm@10.33.2", "scripts": { - "build": "pnpm run build:engine && pnpm run build:play-jsx", + "build": "pnpm run build:engine && pnpm run build:play-jsx && pnpm run build:play-static", "build:engine": "NODE_OPTIONS=--openssl-legacy-provider webpack --config webpack.engine.js", "build:play-jsx": "babel --presets @babel/preset-react site/play/app.jsx -o site/play/app.js", + "build:play-static": "mkdir -p site/play/questplay && cp space-rangers-quest/built-web/questplay/* site/play/questplay/", "build:play-assets": "mkdir -p site/play/quests && for q in Banket_eng Badday_eng Pizza_eng Borzukhan_eng Ski_eng Election_eng Robots_eng Leonardo_eng Depth_eng Edelweiss_eng Ministry_eng Foncers_eng Driver_eng Codebox_eng Pilot_eng Disk_eng Sortirovka1_eng Shashki_eng Player_eng; do gzip -c quests/sr_2_1_2121_eng/$q.qm > site/play/quests/$q.qm.gz; done && for q in Banket Borzukhan Ski Election Robots Depth Ministry Foncers Driver Pilot Shashki Pizza Player; do gzip -c quests/sr_2_dominators_ru/$q.qmm > site/play/quests/${q}_ru.qm.gz; done && for q in Badday Leonardo Edelweiss Codebox Sortirovka1; do gzip -c quests/sr_2_reboot_ru/$q.qmm > site/play/quests/${q}_ru.qm.gz; done" }, "dependencies": { diff --git a/scripts/import_human_trace.py b/scripts/import_human_trace.py index 4c6e011..46c6e09 100644 --- a/scripts/import_human_trace.py +++ b/scripts/import_human_trace.py @@ -5,7 +5,7 @@ import argparse import json -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -42,7 +42,7 @@ def make_run_id(trace: dict[str, Any]) -> str: if exported_at: safe_time = "".join(ch for ch in str(exported_at) if ch.isdigit())[:14] else: - safe_time = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + safe_time = datetime.now(UTC).strftime("%Y%m%d%H%M%S") safe_quest = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in quest_id) return f"human_web_{safe_quest}_{safe_time}" diff --git a/site/play.html b/site/play.html index 60d990f..4385f24 100644 --- a/site/play.html +++ b/site/play.html @@ -10,25 +10,35 @@ Play - LLM-Quest Benchmark - - - - + + + + @@ -129,6 +205,14 @@
+ diff --git a/site/play/app.js b/site/play/app.js index 83acadc..b04f631 100644 --- a/site/play/app.js +++ b/site/play/app.js @@ -188,6 +188,16 @@ function sortQuests(quests) { const PLAY_URL = 'https://yourconscience.github.io/llm_quest_benchmark/play.html'; const SHARE_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'; const MIN_COHORT_LOCATION_RUNS = 3; +const MEDIA_BASE = 'play/media/'; +function mediaUrl(name, kind) { + if (!name) return null; + const raw = String(name); + const lower = raw.toLowerCase(); + if (lower.startsWith('http://') || lower.startsWith('https://')) return raw; + const ext = kind === 'img' ? '.jpg' : '.mp3'; + const folder = kind === 'img' ? 'img' : kind; + return MEDIA_BASE + folder + '/' + lower + (lower.includes('.') ? '' : ext); +} function drawTextLine(ctx, text, x, y, maxWidth) { ctx.fillText(text, x, y, maxWidth); } @@ -270,6 +280,95 @@ function CohortBars({ }))); } +// ---- Media ---- + +function QuestImage({ + src +}) { + const [failedSrc, setFailedSrc] = useState(null); + useEffect(() => { + setFailedSrc(null); + }, [src]); + if (!src || failedSrc === src) { + return /*#__PURE__*/React.createElement("div", { + className: "media-placeholder" + }, "Space Rangers"); + } + return /*#__PURE__*/React.createElement("img", { + src: src, + alt: "", + onError: () => setFailedSrc(src) + }); +} +function QuestAudio({ + gameState, + audioEnabled +}) { + const trackRef = useRef(null); + const soundRef = useRef(null); + const trackSrc = mediaUrl(gameState && gameState.trackName, 'track'); + const soundSrc = mediaUrl(gameState && gameState.soundName, 'sound'); + useEffect(() => { + if (!audioEnabled || !trackRef.current) return; + trackRef.current.volume = 0.55; + trackRef.current.play().catch(() => {}); + }, [audioEnabled, trackSrc]); + useEffect(() => { + if (!audioEnabled || !soundRef.current) return; + soundRef.current.volume = 0.75; + soundRef.current.play().catch(() => {}); + }, [audioEnabled, soundSrc]); + if (!audioEnabled) return null; + return /*#__PURE__*/React.createElement(React.Fragment, null, trackSrc && /*#__PURE__*/React.createElement("audio", { + key: trackSrc, + ref: trackRef, + src: trackSrc, + loop: true, + preload: "auto" + }), soundSrc && /*#__PURE__*/React.createElement("audio", { + key: soundSrc, + ref: soundRef, + src: soundSrc, + preload: "auto" + })); +} +function MediaStage({ + gameState, + audioEnabled +}) { + const imageSrc = mediaUrl(gameState && gameState.imageName, 'img'); + return /*#__PURE__*/React.createElement("div", { + className: "media-stage" + }, /*#__PURE__*/React.createElement(QuestAudio, { + gameState: gameState, + audioEnabled: audioEnabled + }), /*#__PURE__*/React.createElement("div", { + className: "media-frame" + }, /*#__PURE__*/React.createElement(QuestImage, { + src: imageSrc + }))); +} +function CurrentDecisionInsight({ + cohortLoc, + isBranching, + activeFamily, + onFamilyChange, + families +}) { + if (!isBranching) return null; + return /*#__PURE__*/React.createElement("div", { + className: "cohort-panel" + }, /*#__PURE__*/React.createElement("div", { + className: "panel-label" + }, "AI answers"), /*#__PURE__*/React.createElement(CohortBars, { + cohortLoc: cohortLoc, + playerChoiceNorm: null, + activeFamily: activeFamily, + onFamilyChange: onFamilyChange, + families: families + })); +} + // ---- DecisionHistory ---- function DecisionHistory({ @@ -440,13 +539,30 @@ function makeShareText(questTitle, outcomeLabel) { return 'I ' + verb + ' "' + questTitle + '" on LLM-Quest Benchmark. Can you do better?'; } function downloadCanvas(canvas, filename) { + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.shareFile) { + window.webkit.messageHandlers.shareFile.postMessage({ + content: canvas.toDataURL('image/png'), + filename: filename, + mimeType: 'image/png' + }); + return; + } const link = document.createElement('a'); link.download = filename; link.href = canvas.toDataURL('image/png'); link.click(); } function downloadJson(data, filename) { - const blob = new Blob([JSON.stringify(data, null, 2) + '\n'], { + const content = JSON.stringify(data, null, 2) + '\n'; + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.shareFile) { + window.webkit.messageHandlers.shareFile.postMessage({ + content: content, + filename: filename, + mimeType: 'application/json' + }); + return; + } + const blob = new Blob([content], { type: 'application/json' }); const link = document.createElement('a'); @@ -568,6 +684,8 @@ function EndScreen({ path, quest, endText, + mediaState, + audioEnabled, families, traceSteps, startedAt, @@ -654,7 +772,10 @@ function EndScreen({ color: 'var(--muted)', marginBottom: '1.5rem' } - }, "AI cohort: ", Math.round(cohortWinRate * 100), "% won this quest.")), endText && /*#__PURE__*/React.createElement("div", { + }, "AI cohort: ", Math.round(cohortWinRate * 100), "% won this quest.")), endText && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(MediaStage, { + gameState: mediaState, + audioEnabled: audioEnabled + }), /*#__PURE__*/React.createElement("div", { className: "card-table", style: { marginBottom: '1.5rem', @@ -664,7 +785,7 @@ function EndScreen({ } }, /*#__PURE__*/React.createElement(QuestTags, { str: endText - })), /*#__PURE__*/React.createElement(DecisionHistory, { + }))), /*#__PURE__*/React.createElement(DecisionHistory, { path: path, families: families }), /*#__PURE__*/React.createElement("div", { @@ -715,7 +836,10 @@ function QuestPlay({ const [traceSteps, setTraceSteps] = useState([]); const [ended, setEnded] = useState(null); const [endText, setEndText] = useState(''); + const [endMediaState, setEndMediaState] = useState(null); const [obsKey, setObsKey] = useState(0); + const [audioEnabled, setAudioEnabled] = useState(false); + const [activeFamily, setActiveFamily] = useState('all'); const startedAtRef = useRef(new Date().toISOString()); useEffect(() => { const controller = new AbortController(); @@ -817,6 +941,7 @@ function QuestPlay({ if (isTerminal) { setEnded(gs); setEndText(nextState.text || ''); + setEndMediaState(nextState); } else { setGameState(player.getState()); setStepNum(n => n + 1); @@ -837,6 +962,7 @@ function QuestPlay({ setTraceSteps(prev => prev.slice(0, -1)); setObsKey(k => k + 1); setEnded(null); + setEndMediaState(null); } if (loading) { return /*#__PURE__*/React.createElement("div", { @@ -873,6 +999,8 @@ function QuestPlay({ path: path, quest: quest, endText: endText, + mediaState: endMediaState, + audioEnabled: audioEnabled, families: cohortData && cohortData.model_families || [], traceSteps: traceSteps, startedAt: startedAtRef.current, @@ -886,6 +1014,7 @@ function QuestPlay({ setStepHistory([]); setEnded(null); setEndText(''); + setEndMediaState(null); startedAtRef.current = new Date().toISOString(); setObsKey(k => k + 1); }, @@ -896,61 +1025,76 @@ function QuestPlay({ const choices = gameState.choices || []; const activeChoices = choices.filter(c => c.active); const params = (gameState.paramsState || []).filter(p => p && p.trim()); + const currentLocationId = canonicalPlayer ? canonicalPlayer.getSaving().locationId : player.getSaving().locationId; + const currentCohortLoc = getCohortLoc(currentLocationId); + const isCurrentBranching = activeChoices.length >= 2; return /*#__PURE__*/React.createElement("div", { - className: "container py-4", - style: { - maxWidth: 800 - } + className: "play-shell" }, /*#__PURE__*/React.createElement("div", { - style: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: '1rem' - } - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("h4", { - style: { - marginBottom: 0 - } - }, quest.title), /*#__PURE__*/React.createElement("span", { - style: { - color: 'var(--muted)', - fontSize: '0.85rem' - } + className: "play-wrap" + }, /*#__PURE__*/React.createElement("div", { + className: "play-topbar" + }, /*#__PURE__*/React.createElement("div", { + className: "play-title" + }, /*#__PURE__*/React.createElement("h1", null, quest.title), /*#__PURE__*/React.createElement("div", { + className: "play-meta" }, "Step ", stepNum)), /*#__PURE__*/React.createElement("div", { - style: { - display: 'flex', - gap: '0.5rem' - } + className: "play-actions" }, stepHistory.length > 0 && /*#__PURE__*/React.createElement("button", { - className: "btn btn-sm btn-outline-secondary", - onClick: handleBack - }, "\u2190 Back"), /*#__PURE__*/React.createElement("button", { - className: "btn btn-sm btn-outline-secondary", - onClick: onQuit - }, "Quit"))), params.length > 0 && /*#__PURE__*/React.createElement("div", { + className: "play-action", + onClick: handleBack, + "aria-label": "Back", + title: "Back" + }, "\u2190"), /*#__PURE__*/React.createElement("button", { + className: "play-action", + onClick: () => setAudioEnabled(v => !v), + "aria-label": audioEnabled ? 'Mute sound' : 'Enable sound', + title: audioEnabled ? 'Mute sound' : 'Enable sound' + }, audioEnabled ? '\u266a' : '\u266b'), /*#__PURE__*/React.createElement("button", { + className: "play-action", + onClick: onQuit, + "aria-label": "Quit", + title: "Quit" + }, "\u2715"))), /*#__PURE__*/React.createElement("div", { + className: "play-layout" + }, /*#__PURE__*/React.createElement("main", { + className: "story-column" + }, params.length > 0 && /*#__PURE__*/React.createElement("div", { className: "params-strip" }, params.map((p, i) => /*#__PURE__*/React.createElement("span", { key: i, className: "param-pill" }, /*#__PURE__*/React.createElement(QuestTags, { str: p - })))), /*#__PURE__*/React.createElement("div", { + })))), /*#__PURE__*/React.createElement(MediaStage, { + gameState: gameState, + audioEnabled: audioEnabled + }), /*#__PURE__*/React.createElement("div", { key: obsKey, className: "obs-panel" }, /*#__PURE__*/React.createElement(QuestTags, { str: gameState.text || '' - })), /*#__PURE__*/React.createElement("div", null, choices.map((choice, i) => /*#__PURE__*/React.createElement("button", { + })), /*#__PURE__*/React.createElement("div", { + className: "choices-panel" + }, choices.map((choice, i) => /*#__PURE__*/React.createElement("button", { key: i, className: 'choice-btn' + (choice.active ? '' : ' inactive'), disabled: !choice.active, onClick: () => choice.active && handleChoice(choice, activeChoices) }, /*#__PURE__*/React.createElement(QuestTags, { str: choice.text || '' - })))), /*#__PURE__*/React.createElement(DecisionHistory, { + }))))), /*#__PURE__*/React.createElement("aside", { + className: "ai-column" + }, /*#__PURE__*/React.createElement(CurrentDecisionInsight, { + cohortLoc: currentCohortLoc, + isBranching: isCurrentBranching, + activeFamily: activeFamily, + onFamilyChange: setActiveFamily, + families: families + }), /*#__PURE__*/React.createElement(DecisionHistory, { path: path, families: families - })); + }))))); } // ---- Language helpers ---- diff --git a/site/play/app.jsx b/site/play/app.jsx index dad1237..c2394e7 100644 --- a/site/play/app.jsx +++ b/site/play/app.jsx @@ -154,6 +154,17 @@ function sortQuests(quests) { const PLAY_URL = 'https://yourconscience.github.io/llm_quest_benchmark/play.html'; const SHARE_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'; const MIN_COHORT_LOCATION_RUNS = 3; +const MEDIA_BASE = 'play/media/'; + +function mediaUrl(name, kind) { + if (!name) return null; + const raw = String(name); + const lower = raw.toLowerCase(); + if (lower.startsWith('http://') || lower.startsWith('https://')) return raw; + const ext = kind === 'img' ? '.jpg' : '.mp3'; + const folder = kind === 'img' ? 'img' : kind; + return MEDIA_BASE + folder + '/' + lower + (lower.includes('.') ? '' : ext); +} function drawTextLine(ctx, text, x, y, maxWidth) { ctx.fillText(text, x, y, maxWidth); @@ -223,6 +234,79 @@ function CohortBars({ cohortLoc, playerChoiceNorm, activeFamily, onFamilyChange, ); } +// ---- Media ---- + +function QuestImage({ src }) { + const [failedSrc, setFailedSrc] = useState(null); + + useEffect(() => { + setFailedSrc(null); + }, [src]); + + if (!src || failedSrc === src) { + return
Space Rangers
; + } + + return setFailedSrc(src)} />; +} + +function QuestAudio({ gameState, audioEnabled }) { + const trackRef = useRef(null); + const soundRef = useRef(null); + const trackSrc = mediaUrl(gameState && gameState.trackName, 'track'); + const soundSrc = mediaUrl(gameState && gameState.soundName, 'sound'); + + useEffect(() => { + if (!audioEnabled || !trackRef.current) return; + trackRef.current.volume = 0.55; + trackRef.current.play().catch(() => {}); + }, [audioEnabled, trackSrc]); + + useEffect(() => { + if (!audioEnabled || !soundRef.current) return; + soundRef.current.volume = 0.75; + soundRef.current.play().catch(() => {}); + }, [audioEnabled, soundSrc]); + + if (!audioEnabled) return null; + + return ( + <> + {trackSrc &&