crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 8793ab064ba240a07f857b77af9730365590d944
parent 5401155b14fba730c8b6216bab2190d0d951c5ab
Author: Michael Camilleri <[email protected]>
Date:   Wed,  6 May 2026 13:23:01 +0900

Add initial iPad layout

This adds a basic layout the iPad. It is... (wait for it) oriented
around landscape use.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++--
MCrossmate/Models/PlayerSession.swift | 26++++++++++++++++++++++++++
MCrossmate/Views/ClueList.swift | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MCrossmate/Views/KeyboardView.swift | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
MCrossmate/Views/PuzzleView.swift | 215++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mproject.yml | 2+-
6 files changed, 541 insertions(+), 83 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -677,7 +677,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -695,7 +695,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -144,6 +144,22 @@ final class PlayerSession { moveClue(by: -1) } + func goToNextWord() { + moveWord(by: +1) + } + + func goToPreviousWord() { + moveWord(by: -1) + } + + func goToNextLetter() { + advance() + } + + func goToPreviousLetter() { + retreat() + } + private func moveClue(by offset: Int) { // Walk every clue in order: all acrosses, then all downs. Stepping past // the last across rolls into the first down (and vice versa going @@ -165,6 +181,16 @@ final class PlayerSession { moveToClueStart(number: newClue.number) } + private func moveWord(by offset: Int) { + let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues + guard !clues.isEmpty else { return } + let currentNumber = currentClueNumber() + let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0 + let count = clues.count + let nextIndex = ((currentIndex + offset) % count + count) % count + moveToClueStart(number: clues[nextIndex].number) + } + // MARK: - Check / Reveal / Clear // // These translate the player's cursor into a set of cells (or the whole diff --git a/Crossmate/Views/ClueList.swift b/Crossmate/Views/ClueList.swift @@ -2,46 +2,150 @@ import SwiftUI struct ClueList: View { @Bindable var session: PlayerSession + var presentation: Presentation = .sheet @Environment(PlayerPreferences.self) private var preferences @Environment(\.dismiss) private var dismiss + enum Presentation { + case sheet + case sidebar + } + var body: some View { + switch presentation { + case .sheet: + NavigationStack { + clueList + .navigationTitle("Clues") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + case .sidebar: + clueList + } + } + + @ViewBuilder + private var clueList: some View { let current = session.currentClue() let currentDirection = session.direction let currentID = current.map { rowID(direction: currentDirection, number: $0.number) } - NavigationStack { - ScrollViewReader { proxy in - List { - Section("Across") { - ForEach(session.puzzle.acrossClues) { clue in - row(for: clue, direction: .across, current: current, currentDirection: currentDirection) - .id(rowID(direction: .across, number: clue.number)) - } - } - Section("Down") { - ForEach(session.puzzle.downClues) { clue in - row(for: clue, direction: .down, current: current, currentDirection: currentDirection) - .id(rowID(direction: .down, number: clue.number)) - } + if presentation == .sheet { + list( + current: current, + currentDirection: currentDirection, + currentID: currentID + ) + .listStyle(.insetGrouped) + } else { + sidebarList( + current: current, + currentDirection: currentDirection, + currentID: currentID + ) + } + } + + private func list( + current: Puzzle.Clue?, + currentDirection: Puzzle.Direction, + currentID: String? + ) -> some View { + ScrollViewReader { proxy in + List { + Section("Across") { + ForEach(session.puzzle.acrossClues) { clue in + row(for: clue, direction: .across, current: current, currentDirection: currentDirection) + .id(rowID(direction: .across, number: clue.number)) } } - .listStyle(.insetGrouped) - .onAppear { - guard let currentID else { return } - proxy.scrollTo(currentID, anchor: .center) + Section("Down") { + ForEach(session.puzzle.downClues) { clue in + row(for: clue, direction: .down, current: current, currentDirection: currentDirection) + .id(rowID(direction: .down, number: clue.number)) + } } } - .navigationTitle("Clues") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } + .onAppear { + guard let currentID else { return } + proxy.scrollTo(currentID, anchor: .center) + } + } + } + + private func sidebarList( + current: Puzzle.Clue?, + currentDirection: Puzzle.Direction, + currentID: String? + ) -> some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + sidebarSectionHeader("Across") + ForEach(session.puzzle.acrossClues) { clue in + sidebarRow(for: clue, direction: .across, current: current, currentDirection: currentDirection) + .id(rowID(direction: .across, number: clue.number)) + } + + sidebarSectionHeader("Down") + .padding(.top, 12) + ForEach(session.puzzle.downClues) { clue in + sidebarRow(for: clue, direction: .down, current: current, currentDirection: currentDirection) + .id(rowID(direction: .down, number: clue.number)) + } } + .padding(.vertical, 10) + } + .onAppear { + guard let currentID else { return } + proxy.scrollTo(currentID, anchor: .center) } } } + private func sidebarSectionHeader(_ title: String) -> some View { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 6) + } + + private func sidebarRow( + for clue: Puzzle.Clue, + direction: Puzzle.Direction, + current: Puzzle.Clue?, + currentDirection: Puzzle.Direction + ) -> some View { + let isCurrent = current?.number == clue.number && currentDirection == direction + return Button { + session.selectClue(direction: direction, number: clue.number) + } label: { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(clue.number)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 28, alignment: .trailing) + Text(clue.text) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 18) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isCurrent ? preferences.color.highlightFill : Color.clear) + } + private func rowID(direction: Puzzle.Direction, number: Int) -> String { "\(direction == .across ? "A" : "D")-\(number)" } @@ -56,7 +160,9 @@ struct ClueList: View { let isCurrent = current?.number == clue.number && currentDirection == direction Button { session.selectClue(direction: direction, number: clue.number) - dismiss() + if presentation == .sheet { + dismiss() + } } label: { HStack(alignment: .firstTextBaseline, spacing: 10) { Text("\(clue.number)") diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/KeyboardView.swift @@ -5,6 +5,7 @@ import SwiftUI /// stay glued to the bottom of the screen alongside the grid. struct KeyboardView: View { @Bindable var session: PlayerSession + var showsNavigationKeys = false @State private var showingOverflow = false private let topRow = Array("QWERTYUIOP").map(String.init) @@ -13,64 +14,100 @@ struct KeyboardView: View { private let spacing: CGFloat = 6 private let keyHeight: CGFloat = 46 + private let metaKeyWidthMultiplier: CGFloat = 1.5 static let standardHeight: CGFloat = 170 var body: some View { VStack(spacing: spacing) { - KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { + KeyboardRow( + referenceColumns: showsNavigationKeys ? 12 : 10, + spacing: spacing, + keyHeight: keyHeight + ) { + if showsNavigationKeys { + actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") { + session.goToPreviousWord() + } + .fillsExtraKeyboardSpace() + } ForEach(topRow, id: \.self) { letter in letterKey(letter) } + if showsNavigationKeys { + actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") { + session.goToNextWord() + } + .fillsExtraKeyboardSpace() + } } - KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { + KeyboardRow( + referenceColumns: showsNavigationKeys ? 12 : 10, + spacing: spacing, + keyHeight: keyHeight + ) { + if showsNavigationKeys { + actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") { + session.goToPreviousLetter() + } + .fillsExtraKeyboardSpace() + } ForEach(middleRow, id: \.self) { letter in letterKey(letter) } + if showsNavigationKeys { + actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") { + session.goToNextLetter() + } + .fillsExtraKeyboardSpace() + } } - KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { - if session.isRebusActive { - actionKey(text: "Done", background: .blue, foreground: .white) { - session.commitRebus() + KeyboardRow( + referenceColumns: showsNavigationKeys ? 12 : 10, + spacing: spacing, + keyHeight: keyHeight + ) { + if showsNavigationKeys { + rebusKey + .disablesKeyboardMetaAnimations() + .fillsExtraKeyboardSpace() + + actionKey( + systemImage: "pencil", + accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft", + background: session.isPencilMode ? .blue : Color(.systemFill), + foreground: session.isPencilMode ? .white : .primary + ) { + session.togglePencil() } - .keyWidthMultiplier(1.5) + .disablesKeyboardMetaAnimations() + .fillsExtraKeyboardSpace() } else { - actionKey(systemImage: "ellipsis") { - showingOverflow = true - } - .keyWidthMultiplier(1.5) - .popover(isPresented: $showingOverflow) { - VStack(alignment: .leading, spacing: 0) { - Button { - showingOverflow = false - session.togglePencil() - } label: { - Text("Toggle Draft") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .buttonStyle(.plain) - - Button { - showingOverflow = false - session.startRebus() - } label: { - Text("Enter Rebus") - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .buttonStyle(.plain) + if session.isRebusActive { + actionKey(text: "Done", background: .blue, foreground: .white) { + session.commitRebus() } - .frame(minWidth: 160) - .presentationCompactAdaptation(.popover) - .presentationBackground(.thinMaterial) + .keyWidthMultiplier(metaKeyWidthMultiplier) + } else { + overflowKey } } + ForEach(bottomLetters, id: \.self) { letter in letterKey(letter) } + + if showsNavigationKeys { + actionKey( + systemImage: "arrow.2.squarepath", + accessibilityLabel: "Switch Direction" + ) { + session.toggleDirection() + } + .disablesKeyboardMetaAnimations() + .fillsExtraKeyboardSpace() + } + actionKey(systemImage: "delete.left") { if session.isRebusActive { session.deleteRebusLetter() @@ -78,7 +115,9 @@ struct KeyboardView: View { session.deleteBackward() } } - .keyWidthMultiplier(1.5) + .disablesKeyboardMetaAnimations() + .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier) + .fillsExtraKeyboardSpace(showsNavigationKeys) } } .padding(.horizontal, 4) @@ -113,19 +152,72 @@ struct KeyboardView: View { .buttonStyle(.plain) } + private var rebusKey: some View { + Group { + if session.isRebusActive { + actionKey(text: "Done", background: .blue, foreground: .white) { + session.commitRebus() + } + } else { + actionKey(text: "Rebus") { + session.startRebus() + } + } + } + } + + private var overflowKey: some View { + actionKey(systemImage: "ellipsis") { + showingOverflow = true + } + .keyWidthMultiplier(metaKeyWidthMultiplier) + .popover(isPresented: $showingOverflow) { + VStack(alignment: .leading, spacing: 0) { + Button { + showingOverflow = false + session.togglePencil() + } label: { + Text("Toggle Draft") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + + Button { + showingOverflow = false + session.startRebus() + } label: { + Text("Enter Rebus") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + .frame(minWidth: 160) + .presentationCompactAdaptation(.popover) + .presentationBackground(.thinMaterial) + } + } + private func actionKey( systemImage: String, + accessibilityLabel: String? = nil, + background: Color = Color(.systemFill), + foreground: Color = .primary, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: 18, weight: .medium)) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemFill)) - .foregroundStyle(.primary) + .background(background) + .foregroundStyle(foreground) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel ?? systemImage) } private func actionKey( @@ -152,7 +244,8 @@ struct KeyboardView: View { /// Lays out a row of keys at a fixed key height. Every key is sized as a /// fraction of `referenceColumns`, so a row containing fewer keys ends up /// narrower than the reference row and is centered. Action keys can opt into -/// a wider width via `keyWidthMultiplier`. +/// a wider width via `keyWidthMultiplier`, and selected keys can split any +/// leftover row width via `fillsExtraKeyboardSpace`. private struct KeyboardRow: Layout { let referenceColumns: Int let spacing: CGFloat @@ -176,17 +269,12 @@ private struct KeyboardRow: Layout { let columns = CGFloat(referenceColumns) let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns - var totalWidth: CGFloat = 0 - for (index, subview) in subviews.enumerated() { - let multiplier = subview[KeyWidthMultiplier.self] - totalWidth += baseKeyWidth * multiplier - if index > 0 { totalWidth += spacing } - } + let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth) + let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1)) var x = bounds.minX + (containerWidth - totalWidth) / 2 - for subview in subviews { - let multiplier = subview[KeyWidthMultiplier.self] - let width = baseKeyWidth * multiplier + for (index, subview) in subviews.enumerated() { + let width = widths[index] subview.place( at: CGPoint(x: x, y: bounds.minY), anchor: .topLeading, @@ -195,14 +283,49 @@ private struct KeyboardRow: Layout { x += width + spacing } } + + private func measuredWidths( + for subviews: Subviews, + baseKeyWidth: CGFloat, + containerWidth: CGFloat + ) -> [CGFloat] { + var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] } + let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1)) + let totalWidth = widths.reduce(0, +) + fixedSpacing + let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] } + guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else { + return widths + } + + let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count) + for index in flexibleIndexes { + widths[index] += extraWidth + } + return widths + } } private struct KeyWidthMultiplier: LayoutValueKey { static let defaultValue: CGFloat = 1.0 } +private struct FillsExtraKeyboardSpace: LayoutValueKey { + static let defaultValue = false +} + private extension View { func keyWidthMultiplier(_ multiplier: CGFloat) -> some View { layoutValue(key: KeyWidthMultiplier.self, value: multiplier) } + + func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View { + layoutValue(key: FillsExtraKeyboardSpace.self, value: fills) + } + + func disablesKeyboardMetaAnimations() -> some View { + transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } } diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -20,6 +20,7 @@ struct PuzzleView: View { @State private var isRevokedBannerDismissed = false @State private var isShowingShareSheet = false @State private var hasSolved = false + @State private var isLandscapePad = false private func swatchImage(for color: PlayerColor) -> Image { let tint = UIColor(color.tint) @@ -47,9 +48,12 @@ struct PuzzleView: View { private var isSolved: Bool { hasSolved } var body: some View { - VStack(spacing: 0) { - puzzleArea - controlsArea + Group { + if isLandscapePad { + landscapePadLayout + } else { + phoneLayout + } } .overlay(alignment: .top) { if session.mutator.isAccessRevoked && !isRevokedBannerDismissed { @@ -95,6 +99,57 @@ struct PuzzleView: View { performDelete: performDelete, leaveSharedGame: leaveSharedGame )) + .onAppear(perform: updateLayoutTrait) + .onReceive(NotificationCenter.default.publisher( + for: UIDevice.orientationDidChangeNotification + )) { _ in + updateLayoutTrait() + } + } + + private var phoneLayout: some View { + VStack(spacing: 0) { + puzzleArea + controlsArea(showClueBar: true) + } + } + + private var landscapePadLayout: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + VStack(spacing: 0) { + PuzzleScoreboard(session: session, roster: roster) + + Divider() + + ClueList(session: session, presentation: .sidebar) + } + .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) + .background(Color(.secondarySystemBackground)) + + Divider() + + puzzleArea + .padding(.bottom, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + controlsArea(showClueBar: false) + } + } + + private func updateLayoutTrait() { + isLandscapePad = UIDevice.current.userInterfaceIdiom == .pad + && currentInterfaceOrientation?.isLandscape == true + } + + private var currentInterfaceOrientation: UIInterfaceOrientation? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .effectiveGeometry + .interfaceOrientation } private func performResign() { @@ -157,15 +212,17 @@ struct PuzzleView: View { } } - private var controlsArea: some View { + private func controlsArea(showClueBar: Bool) -> some View { VStack(spacing: 0) { - ClueBarSlot(session: session) + if showClueBar { + ClueBarSlot(session: session) + } ZStack { if isSolved { SuccessPanel(session: session, roster: roster) .transition(.move(edge: .bottom)) } else { - KeyboardView(session: session) + KeyboardView(session: session, showsNavigationKeys: isLandscapePad) .transition(.move(edge: .bottom)) } } @@ -190,6 +247,152 @@ struct PuzzleView: View { } } +private struct PuzzleScoreboard: View { + @Bindable var session: PlayerSession + let roster: PlayerRoster? + @Environment(PlayerPreferences.self) private var preferences + + private struct Score: Identifiable { + let authorID: String? + let name: String + let color: PlayerColor? + let filledCount: Int + + var id: String { authorID ?? "unattributed" } + } + + private var fillableCellCount: Int { + session.puzzle.cells.reduce(0) { count, row in + count + row.filter { !$0.isBlock }.count + } + } + + private var filledCellCount: Int { + var count = 0 + for r in 0..<session.puzzle.height { + for c in 0..<session.puzzle.width { + guard !session.puzzle.cells[r][c].isBlock else { continue } + if !session.game.squares[r][c].entry.isEmpty { + count += 1 + } + } + } + return count + } + + private var remainingCount: Int { + max(0, fillableCellCount - filledCellCount) + } + + private var remainingText: String { + switch remainingCount { + case 0: + return "No letters to go" + case 1: + return "1 letter to go" + default: + return "\(remainingCount) letters to go" + } + } + + private var scores: [Score] { + var counts: [String?: Int] = [:] + for r in 0..<session.puzzle.height { + for c in 0..<session.puzzle.width { + guard !session.puzzle.cells[r][c].isBlock else { continue } + let square = session.game.squares[r][c] + guard !square.entry.isEmpty else { continue } + counts[square.letterAuthorID, default: 0] += 1 + } + } + + let entries = roster?.entries ?? [] + let usesLocalFallback = entries.isEmpty + let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) + let rosterAuthorIDs = Set(entries.map(\.authorID)) + + let rosterScores: [Score] + if usesLocalFallback { + rosterScores = [ + Score( + authorID: nil, + name: preferences.name, + color: preferences.color, + filledCount: counts[nil] ?? 0 + ) + ] + } else { + rosterScores = entries.map { entry in + Score( + authorID: entry.authorID, + name: entry.name, + color: entry.color, + filledCount: counts[entry.authorID] ?? 0 + ) + } + } + + let extraScores = counts.compactMap { authorID, count -> Score? in + if let authorID, rosterAuthorIDs.contains(authorID) { + return nil + } + if authorID == nil && usesLocalFallback { + return nil + } + if let authorID, let entry = entryByAuthorID[authorID] { + return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) + } + if authorID == nil { + return Score(authorID: nil, name: "Unattributed", color: nil, filledCount: count) + } + return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) + } + + return (rosterScores + extraScores) + .sorted { + if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } + return $0.name < $1.name + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Players") + .font(.headline) + + VStack(alignment: .leading, spacing: 6) { + ForEach(scores) { score in + scoreRow(score) + } + + Text(remainingText) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 10) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func scoreRow(_ score: Score) -> some View { + HStack(spacing: 8) { + Circle() + .fill(score.color?.tint ?? Color.secondary) + .frame(width: 8, height: 8) + Text(score.name) + .font(.subheadline) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(score.filledCount)") + .font(.subheadline.monospacedDigit().weight(.semibold)) + } + .accessibilityElement(children: .combine) + } +} + private struct PuzzleToolbarModifier: ViewModifier { let session: PlayerSession let roster: PlayerRoster? diff --git a/project.yml b/project.yml @@ -66,7 +66,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate INFOPLIST_FILE: Crossmate/Info.plist CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_STYLE: Automatic Crossmate Unit Tests: