diff --git a/.github/ci/swift-test-skips.txt b/.github/ci/swift-test-skips.txt new file mode 100644 index 0000000..873d984 --- /dev/null +++ b/.github/ci/swift-test-skips.txt @@ -0,0 +1,5 @@ +# Tests CI leaves out of `swift test` (.github/workflows/ci.yml), one per line: +# +# Only for tests that depend on the runner rather than the code (real-time timing, devices). + +AnyPS5SharePlayTests.SharePlayOpusTests/testAViewerSayingHelloHearsStereoOpus Real-time WebRTC audio: the jitter buffer and packet rate it measures go past its limits on a shared runner. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..abc3450 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + name: swift test + # Liquid Glass (glassEffect) needs the macOS 26 SDK. + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # The newest Xcode on the runner (its SDK has Liquid Glass; the launcher must also build with Xcode 26's Swift 6.3). + - name: Select the newest Xcode + run: | + ls -d /Applications/Xcode*.app + xcode=$(ls -d /Applications/Xcode_*.app | grep -iv beta | sort -V | tail -1) + sudo xcode-select -s "$xcode" + swift --version + - uses: actions/cache@v6 + with: + path: .build + key: spm-debug-${{ runner.os }}-${{ hashFiles('Package.resolved') }}-${{ github.sha }} + restore-keys: spm-debug-${{ runner.os }}-${{ hashFiles('Package.resolved') }}- + - name: swift test (without the runner-dependent tests in .github/ci/swift-test-skips.txt) + run: | + skips=() + while read -r pattern _; do skips+=(--skip "$pattern"); done < <(grep -Ev '^(#|[[:space:]]*$)' .github/ci/swift-test-skips.txt) + echo "swift test ${skips[*]}" + swift test "${skips[@]}" + + release: + name: swift build -c release + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + # The newest Xcode on the runner (its SDK has Liquid Glass; the launcher must also build with Xcode 26's Swift 6.3). + - name: Select the newest Xcode + run: | + ls -d /Applications/Xcode*.app + xcode=$(ls -d /Applications/Xcode_*.app | grep -iv beta | sort -V | tail -1) + sudo xcode-select -s "$xcode" + swift --version + - uses: actions/cache@v6 + with: + path: .build + key: spm-release-${{ runner.os }}-${{ hashFiles('Package.resolved') }}-${{ github.sha }} + restore-keys: spm-release-${{ runner.os }}-${{ hashFiles('Package.resolved') }}- + - run: swift build -c release + + ci-ok: + name: ci-ok + if: always() + needs: [test, release] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + bad=$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result != "success" and .value.result != "skipped") | .key] | join(", ")') + if [ -n "$bad" ]; then echo "::error::Not passed: $bad"; exit 1; fi diff --git a/Sources/AnyPS5/AppModel.swift b/Sources/AnyPS5/AppModel.swift index 67fd23f..c988207 100644 --- a/Sources/AnyPS5/AppModel.swift +++ b/Sources/AnyPS5/AppModel.swift @@ -1417,7 +1417,7 @@ final class AppModel: ObservableObject { if !waiting { prepareTimer?.invalidate(); prepareTimer = nil; return } guard prepareTimer == nil else { return } prepareTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.stepPrepare() } + MainActor.assumeIsolated { _ = self?.stepPrepare() } } } diff --git a/Sources/AnyPS5/SocialPreviews.swift b/Sources/AnyPS5/SocialPreviews.swift index 160d7d7..a478c03 100644 --- a/Sources/AnyPS5/SocialPreviews.swift +++ b/Sources/AnyPS5/SocialPreviews.swift @@ -46,8 +46,8 @@ enum SocialPreviews { let person = { (id: String, name: String) in TogetherPerson(userId: id, handle: name.lowercased(), displayName: name, avatar: nil) } let names = ["Mira", "Kenji", "Sol", "Ines", "Theo", "Noor", "Lena", "Felix"] let top = names.enumerated().map { index, name in TogetherLeaderboard.Entry(rank: index + 1, user: person("p\(index)", name), value: 9_400 - index * 870) } - let me = TogetherLeaderboard.Entry(rank: 41, user: person("u1", "Robin"), value: 1_830) - let around = [TogetherLeaderboard.Entry(rank: 40, user: person("x1", "Lin"), value: 1_905), me, TogetherLeaderboard.Entry(rank: 42, user: person("x2", "Ada"), value: 1_790)] + let robin = TogetherLeaderboard.Entry(rank: 41, user: person("u1", "Robin"), value: 1_830) + let around = [TogetherLeaderboard.Entry(rank: 40, user: person("x1", "Lin"), value: 1_905), robin, TogetherLeaderboard.Entry(rank: 42, user: person("x2", "Ada"), value: 1_790)] render("leaderboard-game") { model in model.previewLeaderboard(TogetherLeaderboard(board: "points", scope: .global, game: nil, entries: Array(top.prefix(4)), me: .init(rank: 41, value: 1_830, listed: true), around: around, total: 312), game: found.first, index: 6) diff --git a/Sources/AnyPS5Core/Profiles/LauncherProfiles.swift b/Sources/AnyPS5Core/Profiles/LauncherProfiles.swift index 65bd9b2..2f03c0d 100644 --- a/Sources/AnyPS5Core/Profiles/LauncherProfiles.swift +++ b/Sources/AnyPS5Core/Profiles/LauncherProfiles.swift @@ -158,7 +158,7 @@ public final class ProfileStore: @unchecked Sendable { let used = Set(book.profiles.map(\.avatar.color)) let color = (0 ..< LauncherProfile.paletteSize).first { !used.contains($0) } ?? book.profiles.count % LauncherProfile.paletteSize let first = book.profiles.isEmpty - let made = id.flatMap { candidate in profile(candidate) == nil ? candidate : nil } + let made = id.flatMap { candidate in self.profile(candidate) == nil ? candidate : nil } ?? (first && book.legacyOwner == nil ? LauncherProfile.legacyID : UUID().uuidString.lowercased()) let profile = LauncherProfile(id: made, name: name, avatar: .init(color: color), mode: mode, account: account) book.profiles.append(profile) diff --git a/Tests/AnyPS5CoreTests/IssueDraftTests.swift b/Tests/AnyPS5CoreTests/IssueDraftTests.swift index 7a25f09..74e1821 100644 --- a/Tests/AnyPS5CoreTests/IssueDraftTests.swift +++ b/Tests/AnyPS5CoreTests/IssueDraftTests.swift @@ -80,7 +80,12 @@ final class IssueDraftTests: XCTestCase { // tools/macos/launcher/Tests/AnyPS5CoreTests → the repository root. let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - let template = try String(contentsOf: root.appendingPathComponent(".github/ISSUE_TEMPLATE/crash.yml"), encoding: .utf8) + let file = root.appendingPathComponent(".github/ISSUE_TEMPLATE/crash.yml") + // The issue forms are AnyPS5-ARM's; the standalone AnyLauncher repository has none to check. + guard FileManager.default.fileExists(atPath: file.path) else { + throw XCTSkip("no .github/ISSUE_TEMPLATE/crash.yml: run this in AnyPS5-ARM's tools/macos/launcher") + } + let template = try String(contentsOf: file, encoding: .utf8) for field in IssueDraft.crash(report()).fields { XCTAssertTrue(template.contains("id: \(field.id)\n"), "crash.yml has no field \(field.id)") }