diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 0000000..ae20c41 --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,295 @@ +name: Upload iOS to TestFlight + +on: + workflow_dispatch: + inputs: + release_ref: + description: main or an existing vMAJOR.MINOR.PATCH tag on main + required: true + default: main + type: string + build_number: + description: Optional positive integer for CFBundleVersion + required: false + type: string + +permissions: + contents: read + +concurrency: + group: testflight + cancel-in-progress: false + +env: + BUILD_NUMBER: ${{ inputs.build_number || github.run_number }} + # 1Password item references. Update these when the vault layout changes. + OP_CERTIFICATE: op://Letsuno CI/Apple Distribution/Cert_Ethan.p12 + OP_CERTIFICATE_PASSWORD: op://Letsuno CI/Apple Distribution/password + OP_ASC_KEY: op://Letsuno CI/App Store Connect/AuthKey_73V7PMGVS5.p8 + OP_ASC_KEY_ID: op://Letsuno CI/App Store Connect/key_id + OP_ASC_ISSUER_ID: op://Letsuno CI/App Store Connect/issuer_id + APPLE_TEAM_ID: W65332NA65 + +jobs: + upload: + name: Build and upload + if: github.ref == 'refs/heads/main' + runs-on: macos-26 + timeout-minutes: 60 + environment: testflight + steps: + - name: Check out release tooling + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + path: .release-tools + persist-credentials: false + ref: ${{ github.sha }} + + - name: Check out release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + path: source + persist-credentials: false + ref: ${{ inputs.release_ref }} + + - name: Show runner context + run: | + xcodebuild -version + swift --version + + - name: Install 1Password CLI + uses: 1password/install-cli-action@1a3160d5e9de1ae0803eaa08a88746f5ae3daa50 # v4.1.0 + + - name: Validate release source + shell: bash + working-directory: source + env: + RELEASE_REF: ${{ inputs.release_ref }} + run: | + set -euo pipefail + + source_sha=$(git rev-parse 'HEAD^{commit}') + main_sha=$(git rev-parse 'refs/remotes/origin/main^{commit}') + + if [[ "${RELEASE_REF}" == "main" ]]; then + if [[ "${source_sha}" != "${main_sha}" ]]; then + echo "::error::Checked-out main is not the current origin/main commit." + exit 1 + fi + elif [[ "${RELEASE_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + tag_ref="refs/tags/${RELEASE_REF}" + if ! git show-ref --verify --quiet "${tag_ref}"; then + echo "::error::Release tag ${RELEASE_REF} does not exist." + exit 1 + fi + tag_sha=$(git rev-parse "${tag_ref}^{commit}") + if [[ "${source_sha}" != "${tag_sha}" ]]; then + echo "::error::Checkout did not resolve to release tag ${RELEASE_REF}." + exit 1 + fi + if ! git merge-base --is-ancestor "${source_sha}" "${main_sha}"; then + echo "::error::Release tag ${RELEASE_REF} is not on main." + exit 1 + fi + + configured_version=$(make \ + -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" \ + release-version) + if [[ "${RELEASE_REF}" != "v${configured_version}" ]]; then + echo "::error::Tag ${RELEASE_REF} does not match MARKETING_VERSION ${configured_version}." + exit 1 + fi + else + echo "::error::release_ref must be main or vMAJOR.MINOR.PATCH." + exit 1 + fi + + echo "SOURCE_SHA=${source_sha}" >> "${GITHUB_ENV}" + + - name: Resolve app settings + shell: bash + working-directory: source + run: | + set -euo pipefail + + if [[ ! "${BUILD_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Build number must be a positive integer." + exit 1 + fi + + settings=$(make \ + -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" \ + show-settings 2>/dev/null) + bundle_id=$(awk -F ' = ' \ + '/^[[:space:]]+PRODUCT_BUNDLE_IDENTIFIER = / { print $2; exit }' \ + <<< "${settings}") + marketing_version=$(awk -F ' = ' \ + '/^[[:space:]]+MARKETING_VERSION = / { print $2; exit }' \ + <<< "${settings}") + + if [[ -z "${bundle_id}" || -z "${marketing_version}" ]]; then + echo "::error::Could not resolve the app bundle identifier and version." + exit 1 + fi + + echo "IOS_BUNDLE_ID=${bundle_id}" >> "${GITHUB_ENV}" + echo "MARKETING_VERSION=${marketing_version}" >> "${GITHUB_ENV}" + + - name: Install publishing credentials + shell: bash + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + run: | + set -euo pipefail + + if [[ -z "${OP_SERVICE_ACCOUNT_TOKEN}" ]]; then + echo "::error::Actions secret OP_SERVICE_ACCOUNT_TOKEN is not configured." + exit 1 + fi + + certificate_path="${RUNNER_TEMP}/distribution.p12" + asc_key_path="${RUNNER_TEMP}/AuthKey.p8" + keychain_path="${RUNNER_TEMP}/app-signing.keychain-db" + keychain_password=$(openssl rand -hex 32) + + op read "${OP_CERTIFICATE}" --out-file "${certificate_path}" + certificate_password=$(op read "${OP_CERTIFICATE_PASSWORD}") + asc_key_id=$(op read "${OP_ASC_KEY_ID}") + asc_issuer_id=$(op read "${OP_ASC_ISSUER_ID}") + op read "${OP_ASC_KEY}" --out-file "${asc_key_path}" + chmod 600 "${asc_key_path}" + + echo "::add-mask::${certificate_password}" + echo "::add-mask::${asc_key_id}" + echo "::add-mask::${asc_issuer_id}" + + security create-keychain -p "${keychain_password}" "${keychain_path}" + security set-keychain-settings -lut 21600 "${keychain_path}" + security unlock-keychain -p "${keychain_password}" "${keychain_path}" + security import "${certificate_path}" \ + -P "${certificate_password}" \ + -t cert \ + -f pkcs12 \ + -k "${keychain_path}" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "${keychain_password}" \ + "${keychain_path}" + security list-keychains \ + -d user \ + -s "${keychain_path}" "${HOME}/Library/Keychains/login.keychain-db" + + security find-identity -v -p codesigning "${keychain_path}" + + echo "ASC_KEY_PATH=${asc_key_path}" >> "${GITHUB_ENV}" + echo "ASC_KEY_ID=${asc_key_id}" >> "${GITHUB_ENV}" + echo "ASC_ISSUER_ID=${asc_issuer_id}" >> "${GITHUB_ENV}" + + - name: Archive iOS app + shell: bash + working-directory: source + run: | + set -euo pipefail + + make -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" archive \ + DERIVED_DATA_PATH="${RUNNER_TEMP}/DerivedData" \ + ARCHIVE_PATH="${RUNNER_TEMP}/UnoClient.xcarchive" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="${APPLE_TEAM_ID}" \ + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ + ALLOW_PROVISIONING_UPDATES=YES \ + AUTHENTICATION_KEY_PATH="${ASC_KEY_PATH}" \ + AUTHENTICATION_KEY_ID="${ASC_KEY_ID}" \ + AUTHENTICATION_KEY_ISSUER_ID="${ASC_ISSUER_ID}" + + - name: Export IPA + id: export + shell: bash + working-directory: source + run: | + set -euo pipefail + + options_plist="${RUNNER_TEMP}/ExportOptions.plist" + jq -n \ + --arg team "${APPLE_TEAM_ID}" \ + '{ + method: "app-store-connect", + destination: "export", + signingStyle: "automatic", + teamID: $team, + stripSwiftSymbols: true, + uploadSymbols: true, + manageAppVersionAndBuildNumber: false + }' | plutil -convert xml1 -o "${options_plist}" - + + plutil -lint "${options_plist}" + plutil -p "${options_plist}" + + make -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" export \ + ARCHIVE_PATH="${RUNNER_TEMP}/UnoClient.xcarchive" \ + EXPORT_PATH="${RUNNER_TEMP}/export" \ + EXPORT_OPTIONS_PLIST="${options_plist}" \ + ALLOW_PROVISIONING_UPDATES=YES \ + AUTHENTICATION_KEY_PATH="${ASC_KEY_PATH}" \ + AUTHENTICATION_KEY_ID="${ASC_KEY_ID}" \ + AUTHENTICATION_KEY_ISSUER_ID="${ASC_ISSUER_ID}" + + ipa_path=$(find "${RUNNER_TEMP}/export" \ + -maxdepth 1 \ + -type f \ + -name '*.ipa' \ + -print \ + -quit) + if [[ -z "${ipa_path}" ]]; then + echo "::error::Archive export did not produce an IPA." + find "${RUNNER_TEMP}/export" -maxdepth 2 -print + exit 1 + fi + + echo "ipa_path=${ipa_path}" >> "${GITHUB_OUTPUT}" + + - name: Validate and upload to TestFlight + shell: bash + env: + IPA_PATH: ${{ steps.export.outputs.ipa_path }} + run: | + set -euo pipefail + + xcrun altool \ + --validate-app \ + --file "${IPA_PATH}" \ + --type ios \ + --api-key "${ASC_KEY_ID}" \ + --api-issuer "${ASC_ISSUER_ID}" \ + --p8-file-path "${ASC_KEY_PATH}" \ + --output-format json + + xcrun altool \ + --upload-app \ + --file "${IPA_PATH}" \ + --type ios \ + --api-key "${ASC_KEY_ID}" \ + --api-issuer "${ASC_ISSUER_ID}" \ + --p8-file-path "${ASC_KEY_PATH}" \ + --output-format json + + { + echo "### TestFlight upload" + echo + echo "Uploaded ${MARKETING_VERSION} (${BUILD_NUMBER}) from ${SOURCE_SHA} to App Store Connect." + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Clean up publishing credentials + if: ${{ always() }} + shell: bash + run: | + security delete-keychain \ + "${RUNNER_TEMP}/app-signing.keychain-db" 2>/dev/null || true + + rm -f \ + "${RUNNER_TEMP}/distribution.p12" \ + "${RUNNER_TEMP}/AuthKey.p8" diff --git a/.gitignore b/.gitignore index f5e5fa7..4a95942 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,14 @@ # macOS / editors .DS_Store .idea/ +/RELEASING.md ## User settings xcuserdata/ ## Build products DerivedData/ +build/ *.xcresult ## Obj-C/Swift specific diff --git a/Configuration/Shared.xcconfig b/Configuration/Shared.xcconfig index 6343ba0..2a3c760 100644 --- a/Configuration/Shared.xcconfig +++ b/Configuration/Shared.xcconfig @@ -1,5 +1,5 @@ CURRENT_PROJECT_VERSION = 1 -MARKETING_VERSION = 0.1.0 +MARKETING_VERSION = 0.1.1 SWIFT_STRICT_CONCURRENCY = complete SWIFT_TREAT_WARNINGS_AS_ERRORS = YES SWIFT_VERSION = 6 diff --git a/Makefile b/Makefile index 2abae92..fbc4aa7 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,39 @@ TEST_CONFIGURATION ?= Debug TEST_DESTINATION ?= platform=iOS Simulator,name=iPhone 17 Pro VERSION_CONFIGURATION := Configuration/Shared.xcconfig +ARCHIVE_PATH ?= $(CURDIR)/build/UnoClient.xcarchive +EXPORT_PATH ?= $(CURDIR)/build/export +EXPORT_OPTIONS_PLIST ?= $(CURDIR)/build/ExportOptions.plist + +# Signing stays unset by default so a local archive keeps the project's automatic +# signing. Distribution builds pass the identity in from the environment holding +# the certificate. +CODE_SIGN_STYLE ?= +CODE_SIGN_IDENTITY ?= +DEVELOPMENT_TEAM ?= +PROVISIONING_PROFILE_SPECIFIER ?= +CURRENT_PROJECT_VERSION ?= +ALLOW_PROVISIONING_UPDATES ?= +AUTHENTICATION_KEY_PATH ?= +AUTHENTICATION_KEY_ID ?= +AUTHENTICATION_KEY_ISSUER_ID ?= + +ARCHIVE_SETTINGS = \ + $(if $(CODE_SIGN_STYLE),CODE_SIGN_STYLE="$(CODE_SIGN_STYLE)") \ + $(if $(CODE_SIGN_IDENTITY),CODE_SIGN_IDENTITY="$(CODE_SIGN_IDENTITY)") \ + $(if $(DEVELOPMENT_TEAM),DEVELOPMENT_TEAM="$(DEVELOPMENT_TEAM)") \ + $(if $(PROVISIONING_PROFILE_SPECIFIER),PROVISIONING_PROFILE_SPECIFIER="$(PROVISIONING_PROFILE_SPECIFIER)") \ + $(if $(CURRENT_PROJECT_VERSION),CURRENT_PROJECT_VERSION="$(CURRENT_PROJECT_VERSION)") + +PROVISIONING_ARGUMENTS = \ + $(if $(filter YES,$(ALLOW_PROVISIONING_UPDATES)),-allowProvisioningUpdates) \ + $(if $(AUTHENTICATION_KEY_PATH),-authenticationKeyPath "$(AUTHENTICATION_KEY_PATH)") \ + $(if $(AUTHENTICATION_KEY_ID),-authenticationKeyID "$(AUTHENTICATION_KEY_ID)") \ + $(if $(AUTHENTICATION_KEY_ISSUER_ID),-authenticationKeyIssuerID "$(AUTHENTICATION_KEY_ISSUER_ID)") + RESULT_BUNDLE_ARGUMENT = $(if $(RESULT_BUNDLE_PATH),-resultBundlePath "$(RESULT_BUNDLE_PATH)") -.PHONY: analyze build format-check project-check quality release-version show-settings test +.PHONY: analyze archive build export format-check ipa project-check quality release-version show-settings test quality: format-check project-check @@ -79,6 +109,27 @@ analyze: -derivedDataPath "$(DERIVED_DATA_PATH)" \ CODE_SIGNING_ALLOWED=NO +archive: + xcodebuild clean archive \ + -project "$(PROJECT)" \ + -scheme "$(SCHEME)" \ + -configuration "$(BUILD_CONFIGURATION)" \ + -destination '$(BUILD_DESTINATION)' \ + -derivedDataPath "$(DERIVED_DATA_PATH)" \ + -archivePath "$(ARCHIVE_PATH)" \ + $(PROVISIONING_ARGUMENTS) \ + $(ARCHIVE_SETTINGS) + +export: + xcodebuild -exportArchive \ + -archivePath "$(ARCHIVE_PATH)" \ + -exportPath "$(EXPORT_PATH)" \ + -exportOptionsPlist "$(EXPORT_OPTIONS_PLIST)" \ + $(PROVISIONING_ARGUMENTS) + +ipa: archive + $(MAKE) export + test: xcodebuild test \ -project "$(PROJECT)" \ diff --git a/Support/Info.plist b/Support/Info.plist index a7e4532..eaf9e54 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -2,6 +2,8 @@ + ITSAppUsesNonExemptEncryption + NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/UnoClient.xcodeproj/project.pbxproj b/UnoClient.xcodeproj/project.pbxproj index cb2945b..7ee2a78 100644 --- a/UnoClient.xcodeproj/project.pbxproj +++ b/UnoClient.xcodeproj/project.pbxproj @@ -342,7 +342,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = UnoClient/UnoClient.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Support/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -355,8 +356,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = cn.aunly.UnoClient; + PRODUCT_BUNDLE_IDENTIFIER = com.tacrolimus.letsuno; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -369,7 +371,8 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = UnoClient/UnoClient.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Support/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -382,8 +385,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = cn.aunly.UnoClient; + PRODUCT_BUNDLE_IDENTIFIER = com.tacrolimus.letsuno; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/UnoClient/App/RootView.swift b/UnoClient/App/RootView.swift index e9fe8e8..25a1b27 100644 --- a/UnoClient/App/RootView.swift +++ b/UnoClient/App/RootView.swift @@ -3,35 +3,150 @@ import SwiftUI struct RootView: View { @Environment(SessionStore.self) private var session + /// Which screen the session state adds up to. Named separately from `Stage` because + /// one stage — `.online` — covers three of them, and because the transition needs a + /// single value to animate against. + private enum Destination: Hashable { + case landing + case login + case lobby + case room(String) + case game(String) + } + + /// An occurrence of a screen, not just the screen's semantic destination. Reusing + /// `.login` as a SwiftUI identity lets a recently removed login view be reinserted + /// by reversing its removal transition, which makes it return from the leading edge. + private struct Presentation: Identifiable { + let id = UUID() + let destination: Destination + } + + /// Where to keep showing while the socket comes up. Connecting is not a screen of + /// its own — making it one split "join a server" into two pushes, where entering a + /// room or a game is a single one. + @State private var settledDestination = Destination.landing + + /// Screen presentation is deliberately separate from session state. A second + /// destination can arrive while the first transition is still moving (most easily + /// by tapping Connect and Back quickly); changing the rendered destination again + /// would make SwiftUI reverse the in-flight animation from its presentation state. + @State private var presentation = Presentation(destination: .landing) + @State private var pendingDestination: Destination? + @State private var isScreenTransitioning = false + + private static let screenAnimation = Animation.smooth(duration: 0.4) + var body: some View { + // The safe area is left to the system. Trimming it was tried and reverted: in + // landscape the ~59pt inset is the sensor housing physically covering the + // display, and the remaining edges reserve at most the home indicator's ~21pt, + // so there is nothing worth reclaiming. Screens keep their margins modest + // through `screenInsets()` instead. + let destination = destination ZStack { UnoBackground() - switch session.stage { - case .landing: - ConnectView() - case .login: - LoginView() - case .connecting: - ProgressView("Connecting…") - .controlSize(.large) - case .online: - if let room = session.room { - if let game = room.game { - GameView(game: game) - } else { - RoomView(room: room) - } - } else { - LobbyView() - } - } + screen(presentation.destination) + // The identity boundary must sit inside the transition modifier. + // Replacing the outer `_IDView` then inserts/removes exactly the node + // carrying this transition, independent of each screen's root type. + .id(presentation.id) + // Spelled out rather than `.push(from:)`: that transition pairs its + // own insertion and removal internally, and across two different + // screen types the pairing was not stable — the same change slid in + // from either side run to run. Both halves are pinned here. + // + // One direction for every change, forward and back alike. A + // depth-aware version is not expressible this way: the outgoing + // screen animates with the transition it declared on its own last + // render, so going back slid both screens the same way. + .transition( + .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + ) + ) } + .onChange(of: destination) { _, new in + settledDestination = new + present(new) + } + .overlay { connectingVeil } .overlay(alignment: .top) { connectionBanner } .toastOverlay(session) .preferredColorScheme(.dark) } + private var destination: Destination { + switch session.stage { + case .landing: return .landing + case .login: return .login + case .connecting: return settledDestination + case .online: + guard let room = session.room else { return .lobby } + return room.game == nil ? .room(room.roomCode) : .game(room.roomCode) + } + } + + /// Serializes root-screen changes. State may still advance for non-UI reasons while + /// a transition is running, so retain the latest target and present it afterwards. + private func present(_ destination: Destination) { + guard !isScreenTransitioning else { + pendingDestination = destination == presentation.destination ? nil : destination + return + } + guard presentation.destination != destination else { return } + + isScreenTransitioning = true + withAnimation(Self.screenAnimation, completionCriteria: .removed) { + presentation = Presentation(destination: destination) + } completion: { + isScreenTransitioning = false + guard let queuedDestination = pendingDestination else { return } + pendingDestination = nil + present(queuedDestination) + } + } + + @ViewBuilder + private func screen(_ destination: Destination) -> some View { + switch destination { + case .landing: + ConnectView() + case .login: + LoginView() + case .lobby: + LobbyView() + case .room: + if let room = session.room { + RoomView(room: room) + } + case .game: + if let game = session.room?.game { + GameView(game: game) + } + } + } + + /// Scoped to its own overlay so its fade never becomes the animation the screen + /// transition inherits. + private var connectingVeil: some View { + ZStack { + if session.stage == .connecting { + Rectangle() + .fill(.black.opacity(0.4)) + .ignoresSafeArea() + ProgressView("Connecting…") + .controlSize(.large) + .padding(24) + .glassEffect(.regular, in: .rect(cornerRadius: 20)) + } + } + .transition(.opacity) + .animation(.easeInOut(duration: 0.25), value: session.stage) + } + @ViewBuilder private var connectionBanner: some View { if session.stage == .online, case .reconnecting(let attempt) = session.connectionStatus { diff --git a/UnoClient/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/UnoClient/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..360adce Binary files /dev/null and b/UnoClient/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/UnoClient/Assets.xcassets/AppIcon.appiconset/Contents.json b/UnoClient/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..cefcc87 100644 --- a/UnoClient/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/UnoClient/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/UnoClient/Core/Duration+Milliseconds.swift b/UnoClient/Core/Duration+Milliseconds.swift new file mode 100644 index 0000000..8d1d53b --- /dev/null +++ b/UnoClient/Core/Duration+Milliseconds.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Duration { + /// Whole milliseconds. `components.attoseconds` only carries the sub-second part, + /// so a round trip past one second has to add the seconds back in. + var milliseconds: Int { + let (seconds, attoseconds) = components + return Int(seconds * 1000 + attoseconds / 1_000_000_000_000_000) + } +} diff --git a/UnoClient/Core/RestClient.swift b/UnoClient/Core/RestClient.swift index d0e1c32..7c95c5a 100644 --- a/UnoClient/Core/RestClient.swift +++ b/UnoClient/Core/RestClient.swift @@ -58,6 +58,23 @@ struct RestClient: Sendable { try await get("/server/info") } + /// Ask a candidate server who it is, retrying its plain-http variant when the user + /// typed no scheme: bare-host input defaults to https, but LAN/dev servers speak http. + /// Returns the endpoint that actually answered, which may be the insecure one. + static func reach( + _ endpoint: ServerEndpoint, + allowingInsecureFallback: Bool + ) async throws -> (endpoint: ServerEndpoint, info: ServerInfo) { + do { + return (endpoint, try await RestClient(endpoint: endpoint).serverInfo()) + } catch { + guard allowingInsecureFallback, let insecure = endpoint.insecureVariant, + let info = try? await RestClient(endpoint: insecure).serverInfo() + else { throw error } + return (insecure, info) + } + } + func authConfig() async throws -> AuthConfig { try await get("/auth/config") } @@ -103,14 +120,70 @@ struct RestClient: Sendable { } func passkeyRegisterVerify(credential: JSONValue, name: String, token: String) async throws { - let _: PasskeyMutationResult = try await post( + let _: MutationResult = try await post( "/auth/passkey/register-verify", body: JSONValue.object(["credential": credential, "name": .string(name)]), token: token ) } - private struct PasskeyMutationResult: Decodable { let success: Bool } + private struct MutationResult: Decodable { let success: Bool } + + // MARK: - Profile + + /// Production servers back this with the database; a dev server answers from the + /// token and registers no write routes at all. + func profile(token: String) async throws -> Profile { + let response: ProfileResponse = try await get("/profile", token: token) + return response.user + } + + func updateProfile(nickname: String?, username: String?, token: String) async throws { + var body: [String: String] = [:] + if let nickname { body["nickname"] = nickname } + if let username { body["username"] = username } + let _: MutationResult = try await send("PATCH", "/profile", body: body, token: token) + } + + /// An empty string clears the avatar; anything else must be a `data:image/…;base64,…` + /// URI, which the server re-encodes to a 256px WebP. + @discardableResult + func setAvatar(_ dataURI: String, token: String) async throws -> String? { + let result: AvatarUpdate = try await post( + "/profile/avatar", body: ["avatar": dataURI], token: token + ) + return result.avatarUrl + } + + /// Sets or replaces the account password. GitHub-created accounts start without + /// one, so this is also how they gain a password login. + func setPassword(_ password: String, token: String) async throws { + let _: MutationResult = try await post( + "/auth/set-password", body: ["password": password], token: token + ) + } + + // MARK: - Credential management + + func passkeys(token: String) async throws -> [PasskeyInfo] { + try await get("/auth/passkey/list", token: token) + } + + func deletePasskey(id: String, token: String) async throws { + let _: MutationResult = try await send("DELETE", "/auth/passkey/\(id)", token: token) + } + + func apiKeys(token: String) async throws -> [ApiKeyInfo] { + try await get("/api-keys", token: token) + } + + func createApiKey(name: String, token: String) async throws -> CreatedApiKey { + try await post("/api-keys", body: ["name": name], token: token) + } + + func deleteApiKey(id: String, token: String) async throws { + let _: MutationResult = try await send("DELETE", "/api-keys/\(id)", token: token) + } // MARK: - Plumbing @@ -127,15 +200,32 @@ struct RestClient: Sendable { private func post( _ path: String, body: Body, token: String? = nil ) async throws -> T { - var request = URLRequest(url: endpoint.api(path)) - request.httpMethod = "POST" - request.timeoutInterval = Self.postTimeout + try await send("POST", path, body: body, token: token) + } + + private func send( + _ method: String, _ path: String, body: Body, token: String? = nil + ) async throws -> T { + var request = mutation(method, path, token: token) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } request.httpBody = try JSONEncoder().encode(body) return try await run(request) } + private func send( + _ method: String, _ path: String, token: String? = nil + ) async throws -> T { + try await run(mutation(method, path, token: token)) + } + + private func mutation(_ method: String, _ path: String, token: String?) -> URLRequest { + var request = URLRequest(url: endpoint.api(path)) + request.httpMethod = method + request.timeoutInterval = Self.postTimeout + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + return request + } + private func run(_ request: URLRequest) async throws -> T { let (data, response) = try await transport(request) guard let http = response as? HTTPURLResponse else { diff --git a/UnoClient/Core/ServerEndpoint.swift b/UnoClient/Core/ServerEndpoint.swift index fdf4d8c..4624d21 100644 --- a/UnoClient/Core/ServerEndpoint.swift +++ b/UnoClient/Core/ServerEndpoint.swift @@ -3,6 +3,11 @@ import Foundation /// A normalized server link. The web client stores a bare authority and derives the /// scheme from the page; a native client defaults to https unless the user is explicit. struct ServerEndpoint: Equatable, Hashable, Codable, Sendable { + /// The bundled public server: always one tap away on the landing screen, and for that + /// reason deliberately kept out of the recent-server history. + static let `default` = ServerEndpoint(baseURL: URL(string: "https://uno.aunly.cn")!) + static var defaultAddress: String { Self.default.baseURL.absoluteString } + let baseURL: URL init?(userInput: String) { @@ -29,6 +34,8 @@ struct ServerEndpoint: Equatable, Hashable, Codable, Sendable { return "\(baseURL.scheme ?? "https")://\(host)\(port)" } + var isDefault: Bool { storageKey == Self.default.storageKey } + var displayName: String { let host = baseURL.host() ?? baseURL.absoluteString let port = baseURL.port.map { ":\($0)" } ?? "" diff --git a/UnoClient/Localizable.xcstrings b/UnoClient/Localizable.xcstrings index 09c0adc..84c41e7 100644 --- a/UnoClient/Localizable.xcstrings +++ b/UnoClient/Localizable.xcstrings @@ -106,31 +106,31 @@ "★" : { }, - "1 min" : { + "1m" : { }, - "2 cards" : { + "2" : { }, - "2 min" : { + "2m" : { }, - "3 cards" : { + "3" : { }, - "3 min" : { + "3+ same numbers in a row: everyone else draws 1" : { }, - "3+ same numbers in a row: everyone else draws 1" : { + "3m" : { }, - "4 cards" : { + "4" : { }, - "5 min" : { + "5m" : { }, - "6 cards" : { + "6" : { }, "15" : { @@ -157,7 +157,25 @@ "Add bot here" : { }, - "Allow spectators" : { + "Add passkey" : { + + }, + "AI" : { + + }, + "AI engine" : { + + }, + "AI engine…" : { + + }, + "API key copied" : { + + }, + "API keys" : { + + }, + "At least 8 characters, with letters and digits." : { }, "Autopilot" : { @@ -181,7 +199,7 @@ "Blind draw" : { }, - "Blitz time limit" : { + "Blitz limit" : { }, "Blue" : { @@ -207,6 +225,9 @@ }, "Cannot win on Skip/Reverse/+2" : { + }, + "Cards" : { + }, "Catch %@!" : { @@ -222,6 +243,9 @@ }, "Challenge!" : { + }, + "Change" : { + }, "Change difficulty" : { @@ -237,6 +261,9 @@ }, "Choose a player to swap hands with" : { + }, + "Classic" : { + }, "Close" : { @@ -255,6 +282,9 @@ }, "Copy" : { + }, + "Copy %@ now — it is never shown again" : { + }, "Could not create room" : { @@ -265,13 +295,13 @@ "Could not rejoin room" : { }, - "Create" : { + "Crazy" : { }, - "Create account" : { + "Create" : { }, - "Create Passkey" : { + "Create account" : { }, "Create Room" : { @@ -282,12 +312,21 @@ }, "Decline" : { + }, + "Default" : { + + }, + "Delete %@" : { + }, "Development server — any username signs in." : { }, "Disable autopilot" : { + }, + "Done" : { + }, "Double score" : { @@ -319,7 +358,7 @@ "End turn" : { }, - "Endgame & scoring" : { + "Endgame" : { }, "Enter" : { @@ -330,6 +369,9 @@ }, "Everyone agreed — press again to start" : { + }, + "Fair" : { + }, "Fast mode" : { @@ -354,6 +396,9 @@ }, "Hide hands" : { + }, + "Identity" : { + }, "Illegal play attempts cost a drawn card" : { @@ -372,6 +417,12 @@ }, "Keep drawing until you can play" : { + }, + "Key name must be 1–50 characters" : { + + }, + "Keys sign in without a password — MCP clients and bots use them." : { + }, "Kick" : { @@ -399,9 +450,6 @@ }, "Lost connection to server" : { - }, - "Match" : { - }, "Message" : { @@ -429,24 +477,42 @@ }, "NEW" : { + }, + "New key name" : { + + }, + "New password" : { + }, "New Room" : { }, "Nickname" : { + }, + "Nickname must be 1–20 characters" : { + + }, + "Nickname needs at least one letter or digit" : { + }, "No +4 challenges" : { }, "No action-card finish" : { + }, + "No AI engine fits this room's player count and house rules." : { + }, "No games in progress. Create a room to get one going." : { }, "No hints" : { + }, + "No passkeys yet. Add one to sign in without a password." : { + }, "No players" : { @@ -462,30 +528,57 @@ }, "Off" : { + }, + "Official server" : { + + }, + "Offline" : { + }, "Online multiplayer client" : { }, "Only the host can change settings" : { + }, + "Other players see your old name and avatar until you sign in again." : { + + }, + "Other server" : { + }, "Ownership transfers soon…" : { }, "Pace" : { + }, + "Party" : { + }, "Pass" : { }, "Passkey created" : { + }, + "Passkeys" : { + }, "Password" : { + }, + "Password must be 8–128 characters" : { + }, "Password needs at least 8 characters with letters and digits. Nickname 1–20 characters." : { + }, + "Password needs both letters and digits" : { + + }, + "Password updated" : { + }, "Pick a swap target" : { @@ -520,7 +613,13 @@ "Playing a 7 swaps hands with a chosen player" : { }, - "Preset" : { + "Privileged" : { + + }, + "Profile" : { + + }, + "Profile updated" : { }, "Queued for next round" : { @@ -543,6 +642,9 @@ }, "Red" : { + }, + "Remove" : { + }, "Remove %@" : { @@ -552,6 +654,9 @@ }, "Removed from room" : { + }, + "Repeat password" : { + }, "Request seat swap?" : { @@ -606,12 +711,24 @@ }, "Round points count twice" : { + }, + "Round trip" : { + + }, + "Rules" : { + }, "Save" : { + }, + "Save changes" : { + }, "Seat %lld" : { + }, + "Sees all cards" : { + }, "Sending too fast" : { @@ -624,6 +741,9 @@ }, "Session expired, sign in again" : { + }, + "Set password" : { + }, "Seven swaps hands" : { @@ -672,9 +792,6 @@ }, "SPEC" : { - }, - "Special cards" : { - }, "Spectate" : { @@ -687,6 +804,9 @@ }, "Spectator view" : { + }, + "Spectators" : { + }, "Stack +2" : { @@ -694,7 +814,7 @@ "Stack +4" : { }, - "Stacking & deflection" : { + "Stacking" : { }, "Strict UNO call" : { @@ -712,17 +832,26 @@ "T%lld" : { }, - "Target score" : { + "Target" : { }, "Team mode" : { + }, + "That image could not be read" : { + }, "The host closed the room" : { + }, + "The two passwords do not match" : { + }, "This server has browser human-verification (Turnstile) enabled — password sign-in may be rejected from a native client." : { + }, + "This server runs in development mode — profile changes are disabled." : { + }, "Throw item" : { @@ -730,14 +859,11 @@ "Transfer ownership" : { }, - "Turn time limit" : { + "Turn time" : { }, "UNO" : { - }, - "UNO calls" : { - }, "UNO calls are not announced to others" : { @@ -753,6 +879,12 @@ }, "Username" : { + }, + "Username may only use letters, digits and underscore" : { + + }, + "Username must be 3–20 characters" : { + }, "Waiting for game state…" : { diff --git a/UnoClient/Models/GameModels.swift b/UnoClient/Models/GameModels.swift index 2f6ab35..3f46213 100644 --- a/UnoClient/Models/GameModels.swift +++ b/UnoClient/Models/GameModels.swift @@ -18,8 +18,14 @@ enum DrawSide: String, Codable, Sendable { case left, right } -enum BotDifficulty: String, Codable, CaseIterable, Sendable { - case novice, easy, normal, hard +/// `rl` is not a rung on the rule-bot ladder — it marks a bot driven by an AI engine, +/// named by `BotConfig.aiProviderId`. It still arrives through the same wire field, so +/// leaving it out breaks decoding of every seat in a room that has one. +enum BotDifficulty: String, Codable, Sendable { + case novice, easy, normal, hard, rl + + /// The difficulties a rule bot can be set to. + static let ruleCases: [BotDifficulty] = [.novice, .easy, .normal, .hard] var localizedName: String { switch self { @@ -27,6 +33,7 @@ enum BotDifficulty: String, Codable, CaseIterable, Sendable { case .easy: return String(localized: "Easy") case .normal: return String(localized: "Normal") case .hard: return String(localized: "Hard") + case .rl: return String(localized: "AI") } } } @@ -38,6 +45,34 @@ enum BotPersonality: String, Codable, Sendable { struct BotConfig: Codable, Equatable, Sendable { var difficulty: BotDifficulty var personality: BotPersonality? + var aiProviderId: String? +} + +/// One entry of `room:list_ai_providers`. The server filters the list by seat count and +/// house rules, so it is only valid for the intent it was fetched with. +struct AiProvider: Decodable, Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let fairness: Fairness + + /// How much of the hidden state the engine is allowed to see. + enum Fairness: String, Decodable, Sendable { + case fair, privileged, cheat + + var localizedName: String { + switch self { + case .fair: return String(localized: "Fair") + case .privileged: return String(localized: "Privileged") + case .cheat: return String(localized: "Sees all cards") + } + } + } +} + +/// The provider list is filtered against the resulting player count, which differs +/// between adding a new bot and re-engining one that is already seated. +enum AiProviderIntent: String, Sendable { + case add, `switch` } /// Wire `GameAction` is a union with per-variant fields; decoded flat and leniently. diff --git a/UnoClient/Models/ProfileModels.swift b/UnoClient/Models/ProfileModels.swift new file mode 100644 index 0000000..7242a33 --- /dev/null +++ b/UnoClient/Models/ProfileModels.swift @@ -0,0 +1,42 @@ +import Foundation + +/// `GET /api/profile` — the database-backed account view. Richer than the JWT-derived +/// `User`: it carries the GitHub binding, and its nickname/avatar are current rather +/// than whatever was minted into the token. +struct ProfileResponse: Decodable, Sendable { + let user: Profile +} + +struct Profile: Codable, Equatable, Sendable { + let id: String + let username: String + let nickname: String + let avatarUrl: String? + let githubId: String? + let role: String? +} + +struct AvatarUpdate: Decodable, Sendable { + let avatarUrl: String? +} + +struct PasskeyInfo: Decodable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let createdAt: String? +} + +struct ApiKeyInfo: Decodable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let keyPreview: String + let createdAt: String? + let lastUsedAt: String? +} + +/// The plaintext key exists only in the create response — the server keeps a hash. +struct CreatedApiKey: Decodable, Identifiable, Sendable { + let id: String + let key: String + let name: String +} diff --git a/UnoClient/Stores/GameStore.swift b/UnoClient/Stores/GameStore.swift index 0200771..c92282d 100644 --- a/UnoClient/Stores/GameStore.swift +++ b/UnoClient/Stores/GameStore.swift @@ -91,6 +91,11 @@ final class GameStore { var isSpectator: Bool { view?.viewerId == "__spectator__" || room.isSpectator } + /// Holds a seat in the running game. Autopilot, the action bar and the hand all + /// hang off this one predicate — a spectator has no player row server-side, so + /// anything gated on it would be rejected anyway. + var isSeatedPlayer: Bool { !isSpectator && me != nil } + private func resolveMe(in view: PlayerView) -> PlayerViewPlayer? { guard !isSpectator else { return nil } return view.players.first { $0.id == view.viewerId } @@ -334,11 +339,16 @@ final class GameStore { await session.perform("game:leave_to_spectate") } + /// Guarded here too, not just in the menu: the server burns the 3s toggle cooldown + /// before it checks whether the caller holds a seat, so a spectator's rejected tap + /// would lock the toggle for the first seconds after they sit down. func toggleAutopilot() async { + guard isSeatedPlayer else { return } await session.perform("player:toggle-autopilot") } func autopilotOnce() async { + guard isSeatedPlayer else { return } await session.perform("game:autopilot_once") } } diff --git a/UnoClient/Stores/ProfileStore.swift b/UnoClient/Stores/ProfileStore.swift new file mode 100644 index 0000000..bdb3b83 --- /dev/null +++ b/UnoClient/Stores/ProfileStore.swift @@ -0,0 +1,191 @@ +import Foundation +import Observation +import UIKit + +/// Account self-service: profile, passkeys and API keys. Kept out of `SessionStore`, +/// which owns the connection lifecycle and has no business holding credential lists. +@MainActor +@Observable +final class ProfileStore { + private let session: SessionStore + private let client: RestClient + private let token: String + + private(set) var profile: Profile? + private(set) var passkeys: [PasskeyInfo] = [] + private(set) var apiKeys: [ApiKeyInfo] = [] + /// Plaintext key from the last create call; the server never returns it again. + var revealedKey: CreatedApiKey? + private(set) var isLoading = false + private(set) var isBusy = false + + init?(session: SessionStore) { + guard let endpoint = session.endpoint, let token = session.authToken else { return nil } + self.session = session + self.client = RestClient(endpoint: endpoint) + self.token = token + } + + /// Dev servers expose a read-only `/profile` and register no write routes at all, + /// so editing has to disappear rather than fail at the network. + var isEditable: Bool { session.authConfig?.devMode != true } + + var passkeysEnabled: Bool { session.authConfig?.passkeyEnabled == true } + + var avatarURL: URL? { client.endpoint.resolveAvatar(profile?.avatarUrl) } + + func load() async { + isLoading = true + defer { isLoading = false } + profile = try? await client.profile(token: token) + if passkeysEnabled { + passkeys = (try? await client.passkeys(token: token)) ?? [] + } + apiKeys = (try? await client.apiKeys(token: token)) ?? [] + } + + // MARK: - Identity + + func save(nickname rawNickname: String, username rawUsername: String) async { + let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) + let username = rawUsername.trimmingCharacters(in: .whitespacesAndNewlines) + guard let profile else { return } + + if let complaint = Self.nicknameComplaint(nickname) ?? Self.usernameComplaint(username) { + session.showToast(complaint) + return + } + + let newNickname = nickname == profile.nickname ? nil : nickname + let newUsername = username == profile.username ? nil : username + guard newNickname != nil || newUsername != nil else { return } + + await mutate { + try await self.client.updateProfile( + nickname: newNickname, username: newUsername, token: self.token + ) + self.session.showToast(String(localized: "Profile updated"), isError: false) + } + } + + /// Mirrors the server's rules so a typo costs no round trip. + static func nicknameComplaint(_ nickname: String) -> String? { + let cleaned = nickname.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } + guard !cleaned.isEmpty, cleaned.count <= 20 else { + return String(localized: "Nickname must be 1–20 characters") + } + guard cleaned.contains(where: { CharacterSet.alphanumerics.contains($0) }) else { + return String(localized: "Nickname needs at least one letter or digit") + } + return nil + } + + static func usernameComplaint(_ username: String) -> String? { + guard (3...20).contains(username.count) else { + return String(localized: "Username must be 3–20 characters") + } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) + guard username.unicodeScalars.allSatisfy(allowed.contains) else { + return String(localized: "Username may only use letters, digits and underscore") + } + return nil + } + + // MARK: - Password + + func setPassword(_ password: String, confirmation: String) async { + if let complaint = Self.passwordComplaint(password) { + session.showToast(complaint) + return + } + guard password == confirmation else { + session.showToast(String(localized: "The two passwords do not match")) + return + } + await mutate { + try await self.client.setPassword(password, token: self.token) + self.session.showToast(String(localized: "Password updated"), isError: false) + } + } + + static func passwordComplaint(_ password: String) -> String? { + guard (8...128).contains(password.count) else { + return String(localized: "Password must be 8–128 characters") + } + guard password.contains(where: \.isLetter), password.contains(where: \.isNumber) else { + return String(localized: "Password needs both letters and digits") + } + return nil + } + + // MARK: - Avatar + + func setAvatar(imageData: Data) async { + let encoded = await Task.detached(priority: .userInitiated) { + Self.dataURI(from: imageData) + }.value + guard let dataURI = encoded else { + session.showToast(String(localized: "That image could not be read")) + return + } + await changeAvatar(to: dataURI) + } + + func removeAvatar() async { + await changeAvatar(to: "") + } + + /// No cache eviction needed: the server stamps the avatar URL with the row's + /// `updatedAt`, so every upload produces a URL nothing has fetched before. + private func changeAvatar(to dataURI: String) async { + await mutate { try await self.client.setAvatar(dataURI, token: self.token) } + } + + /// The server re-crops to 256px, so uploading more than a screen-sized JPEG is + /// wasted bandwidth against a 10 MB body limit. + private nonisolated static func dataURI(from data: Data) -> String? { + guard let image = UIImage(data: data) else { return nil } + let longestSide = max(image.size.width, image.size.height) + let scale = min(1, 512 / max(longestSide, 1)) + let target = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let rendered = UIGraphicsImageRenderer(size: target).image { _ in + image.draw(in: CGRect(origin: .zero, size: target)) + } + guard let jpeg = rendered.jpegData(compressionQuality: 0.85) else { return nil } + return "data:image/jpeg;base64," + jpeg.base64EncodedString() + } + + // MARK: - Credentials + + func deletePasskey(id: String) async { + await mutate { try await self.client.deletePasskey(id: id, token: self.token) } + } + + func createApiKey(name rawName: String) async { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name.count <= 50 else { + session.showToast(String(localized: "Key name must be 1–50 characters")) + return + } + await mutate { self.revealedKey = try await self.client.createApiKey(name: name, token: self.token) } + } + + func deleteApiKey(id: String) async { + await mutate { try await self.client.deleteApiKey(id: id, token: self.token) } + } + + /// Every mutation ends the same way: surface the failure, then re-read the server's + /// state rather than patching the local copy and hoping it matches. + private func mutate(_ perform: () async throws -> Void) async { + isBusy = true + defer { isBusy = false } + do { + try await perform() + } catch { + session.showToast(error.localizedDescription) + return + } + await load() + await session.refreshUser() + } +} diff --git a/UnoClient/Stores/RoomStore.swift b/UnoClient/Stores/RoomStore.swift index d744a0b..c301f96 100644 --- a/UnoClient/Stores/RoomStore.swift +++ b/UnoClient/Stores/RoomStore.swift @@ -137,6 +137,17 @@ final class RoomStore { await session.perform("room:add_bot", .object(payload)) } + /// An AI bot is added through the same event, but the server rejects the payload + /// unless `rl` comes with an engine and the rule difficulties come without one. + func addAiBot(providerId: String, seatIndex: Int?) async { + var payload: [String: JSONValue] = [ + "difficulty": .string(BotDifficulty.rl.rawValue), + "aiProviderId": .string(providerId), + ] + if let seatIndex { payload["seatIndex"] = .number(Double(seatIndex)) } + await session.perform("room:add_bot", .object(payload)) + } + func removeBot(botId: String) async { await session.perform("room:remove_bot", .object(["botId": .string(botId)])) } @@ -148,6 +159,31 @@ final class RoomStore { ) } + func setBotAi(botId: String, providerId: String) async { + await session.perform( + "room:set_bot_ai", + .object(["botId": .string(botId), "providerId": .string(providerId)]) + ) + } + + /// Fetched per use rather than cached: the server filters by the seat count the + /// action would produce, so a stale list stops matching after anyone sits down. + func aiProviders(intent: AiProviderIntent) async -> [AiProvider] { + do { + let ack: AiProviderListAck = try await session.ack( + "room:list_ai_providers", .object(["intent": .string(intent.rawValue)]) + ) + guard ack.success else { + if let error = ack.error { session.showToast(error) } + return [] + } + return ack.providers ?? [] + } catch { + session.showToast(error.localizedDescription) + return [] + } + } + func startGame() async { await session.perform("game:start") } @@ -341,4 +377,10 @@ final class RoomStore { let queued: Bool? let error: String? } + + private struct AiProviderListAck: Decodable { + let success: Bool + let providers: [AiProvider]? + let error: String? + } } diff --git a/UnoClient/Stores/ServerProbe.swift b/UnoClient/Stores/ServerProbe.swift new file mode 100644 index 0000000..4d9bee2 --- /dev/null +++ b/UnoClient/Stores/ServerProbe.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation + +/// Landing-screen reachability: no socket exists yet, so liveness and round-trip time +/// come from timing `GET /api/server/info` per candidate address. +@MainActor +@Observable +final class ServerProbe { + enum Reading: Equatable { + case probing + case reachable(latencyMs: Int) + case unreachable + } + + private(set) var readings: [String: Reading] = [:] + private var tasks: [String: Task] = [:] + + func reading(for address: String) -> Reading? { + readings[address] + } + + /// Idempotent per address: a row that reappears keeps its last result instead of + /// flashing back to `.probing`. + func measure(_ address: String) { + guard tasks[address] == nil else { return } + guard let endpoint = ServerEndpoint(userInput: address) else { + readings[address] = .unreachable + return + } + if readings[address] == nil { readings[address] = .probing } + + tasks[address] = Task { [weak self] in + let reading = await Self.measure(endpoint, allowingInsecureFallback: !address.contains("://")) + guard let self, !Task.isCancelled else { return } + readings[address] = reading + tasks[address] = nil + } + } + + func cancelAll() { + for task in tasks.values { task.cancel() } + tasks.removeAll() + } + + /// The first request pays DNS and TLS setup, so it only resolves the endpoint and + /// warms the connection; the second one is the number worth showing. + private static func measure( + _ endpoint: ServerEndpoint, + allowingInsecureFallback: Bool + ) async -> Reading { + guard + let resolved = try? await RestClient.reach( + endpoint, allowingInsecureFallback: allowingInsecureFallback + ).endpoint + else { return .unreachable } + + let start = ContinuousClock.now + guard (try? await RestClient(endpoint: resolved).serverInfo()) != nil else { + return .unreachable + } + return .reachable(latencyMs: (ContinuousClock.now - start).milliseconds) + } +} diff --git a/UnoClient/Stores/SessionStore.swift b/UnoClient/Stores/SessionStore.swift index 8717ff6..c85f893 100644 --- a/UnoClient/Stores/SessionStore.swift +++ b/UnoClient/Stores/SessionStore.swift @@ -39,13 +39,27 @@ final class SessionStore { var isBusy = false var toast: ToastMessage? + /// Credential for authenticated REST calls made outside this store, such as the + /// profile screen. Minting and clearing it stays here. + var authToken: String? { token } + var savedAddress: String { - get { UserDefaults.standard.string(forKey: "serverAddress") ?? "" } + get { + guard let address = UserDefaults.standard.string(forKey: "serverAddress"), + !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return ServerEndpoint.defaultAddress } + return address + } set { UserDefaults.standard.set(newValue, forKey: "serverAddress") } } + /// Custom servers only — the default one has its own entry on the landing screen. + /// Filtering on read also purges entries stored before that rule existed. var recentServers: [String] { - get { UserDefaults.standard.stringArray(forKey: "recentServers") ?? [] } + get { + (UserDefaults.standard.stringArray(forKey: "recentServers") ?? []) + .filter { ServerEndpoint(userInput: $0)?.isDefault == false } + } set { UserDefaults.standard.set(newValue, forKey: "recentServers") } } @@ -88,22 +102,15 @@ final class SessionStore { return } - var resolved = candidate - var info: ServerInfo + let resolved: ServerEndpoint + let info: ServerInfo do { - info = try await RestClient(endpoint: candidate).serverInfo() + (resolved, info) = try await RestClient.reach( + candidate, allowingInsecureFallback: !address.contains("://") + ) } catch { - // Bare-host input defaults to https; LAN/dev servers are often plain http. - let hasExplicitScheme = address.contains("://") - if !hasExplicitScheme, let insecure = candidate.insecureVariant, - let fallback = try? await RestClient(endpoint: insecure).serverInfo() - { - resolved = insecure - info = fallback - } else { - showToast(String(localized: "Cannot reach server: \(error.localizedDescription)")) - return - } + showToast(String(localized: "Cannot reach server: \(error.localizedDescription)")) + return } do { @@ -112,7 +119,7 @@ final class SessionStore { serverInfo = info authConfig = config savedAddress = address - rememberServer(address) + rememberServer(address, endpoint: resolved) } catch { showToast(String(localized: "Server rejected auth config request: \(error.localizedDescription)")) return @@ -133,7 +140,8 @@ final class SessionStore { stage = .login } - private func rememberServer(_ address: String) { + private func rememberServer(_ address: String, endpoint: ServerEndpoint) { + guard !endpoint.isDefault else { return } var list = recentServers.filter { $0 != address } list.insert(address, at: 0) recentServers = Array(list.prefix(8)) @@ -245,6 +253,28 @@ final class SessionStore { } } + /// Resolves the avatar for a player as seen in a room, seat grid or scoreboard. + /// + /// The server stamps those rows from the JWT, and neither a profile edit nor an + /// avatar upload reissues it — so our own row carries whatever was true at sign-in + /// (nothing at all, for an account that had no avatar then). For ourselves the + /// freshly-read `/auth/me` value wins; other players can only be as current as + /// their own token, which is the server's behaviour to fix, not ours. + func avatarURL(playerId: String, serverValue: String?) -> URL? { + guard let endpoint else { return nil } + let resolved = playerId == user?.id ? (user?.avatarUrl ?? serverValue) : serverValue + return endpoint.resolveAvatar(resolved) + } + + /// Profile edits change database rows, not the JWT, so the copy that arrived with + /// the token goes stale. Re-read `/auth/me` to catch up. + func refreshUser() async { + guard let endpoint, let token else { return } + if let refreshed = try? await RestClient(endpoint: endpoint).me(token: token) { + user = refreshed + } + } + private func authenticate(_ perform: (RestClient) async throws -> AuthResponse) async { guard let endpoint else { return } isBusy = true @@ -380,8 +410,7 @@ final class SessionStore { if let socket = self.socket, (try? await socket.emitWithAck("ping:latency", [], timeout: 8)) != nil { - let elapsed = ContinuousClock.now - start - self.latencyMs = Int(Double(elapsed.components.attoseconds) / 1e15) + self.latencyMs = (ContinuousClock.now - start).milliseconds } try? await Task.sleep(for: .seconds(30)) } diff --git a/UnoClient/Views/Connect/ConnectView.swift b/UnoClient/Views/Connect/ConnectView.swift index ed19b5a..aba1363 100644 --- a/UnoClient/Views/Connect/ConnectView.swift +++ b/UnoClient/Views/Connect/ConnectView.swift @@ -5,62 +5,87 @@ struct ConnectView: View { @Environment(SessionStore.self) private var session @State private var address = "" @State private var didPrefill = false + @State private var isCustomServer = false + @State private var probe = ServerProbe() + @FocusState private var isAddressFieldFocused: Bool private var trimmedAddress: String { address.trimmingCharacters(in: .whitespacesAndNewlines) } + /// The default server needs no input, so only the custom branch can be empty. + private var connectTarget: String { + isCustomServer ? trimmedAddress : ServerEndpoint.defaultAddress + } + var body: some View { - ScrollView { - HStack(alignment: .top, spacing: 32) { + // Centred with spacers rather than a `GeometryReader`-driven `minHeight`. The + // geometry reader anchored its content to the top-left and reported a size that + // is not settled on the first frame, which made this screen enter and leave a + // transition from an inconsistent position. Every other screen is a fixed layout + // with scrolling confined to the one list that can outgrow it; this matches. + VStack(spacing: 0) { + Spacer(minLength: 0) + HStack(alignment: .center, spacing: 32) { hero .frame(maxWidth: .infinity) - VStack(spacing: 20) { - connectPanel - if !session.recentServers.isEmpty { - recentPanel - } - Text("Bare hosts default to https. Plain-http LAN servers are detected automatically.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity) + connectPanel + .frame(maxWidth: .infinity) } - .frame(maxWidth: 860) - .padding(28) - .frame(maxWidth: .infinity) + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() + Spacer(minLength: 0) } - .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { guard !didPrefill else { return } didPrefill = true - address = session.savedAddress + let saved = session.savedAddress + guard ServerEndpoint(userInput: saved)?.isDefault != true else { return } + address = saved + isCustomServer = true + } + .onChange(of: isCustomServer) { _, isCustom in + if isCustom { + isAddressFieldFocused = true + } } + .onDisappear { probe.cancelAll() } } // MARK: - Hero private var hero: some View { - VStack(spacing: 10) { + VStack(spacing: 14) { HStack(spacing: 2) { - wordmarkLetter("U", color: Color(red: 0.90, green: 0.22, blue: 0.27)) - wordmarkLetter("N", color: Color(red: 0.98, green: 0.75, blue: 0.14)) - wordmarkLetter("O", color: Color(red: 0.22, green: 0.70, blue: 0.40)) - wordmarkLetter("!", color: Color(red: 0.20, green: 0.45, blue: 0.95)) + ForEach(Self.wordmark, id: \.letter) { card in + wordmarkLetter(card.letter, color: card.color) + } } Text("Online multiplayer client") .font(.subheadline) .foregroundStyle(.secondary) } - .padding(.top, 40) } + private static let wordmark: [(letter: String, color: Color)] = [ + ("U", Color(red: 0.90, green: 0.22, blue: 0.27)), + ("N", Color(red: 0.98, green: 0.75, blue: 0.14)), + ("O", Color(red: 0.22, green: 0.70, blue: 0.40)), + ("!", Color(red: 0.20, green: 0.45, blue: 0.95)), + ] + + /// `Text` cannot take `glassEffect` — that API needs a `Shape`. So the glass is a + /// tinted slab masked down to the glyph: the letter itself refracts the backdrop + /// instead of sitting on a card. private func wordmarkLetter(_ letter: String, color: Color) -> some View { - Text(letter) - .font(.system(size: 72, weight: .black, design: .rounded)) - .foregroundStyle(color.gradient) + let glyph = Text(letter) + .font(.system(size: 76, weight: .black, design: .rounded)) + + return Color.clear + .frame(width: letter == "!" ? 34 : 62, height: 92) + .glassEffect(.regular.tint(color.opacity(0.75)), in: .rect) + .mask { glyph } .shadow(color: color.opacity(0.45), radius: 14, y: 4) } @@ -72,39 +97,113 @@ struct ConnectView: View { Label("Server", systemImage: "server.rack") .font(.headline) - TextField("play.example.com or http://192.168.1.10:3001", text: $address) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .textContentType(.URL) - .submitLabel(.go) - .onSubmit(connect) - .padding(.horizontal, 14) - .padding(.vertical, 11) - .glassEffect(.regular, in: .rect(cornerRadius: 14)) - - Button(action: connect) { - HStack(spacing: 8) { - if session.isBusy { - ProgressView() - } else { - Image(systemName: "bolt.horizontal.fill") + if isCustomServer { + customServerControls + } else { + defaultServerRow + } + + // Connect and its escape hatch share one row: two capped buttons read as + // controls, where one full-width button per line reads as a banner. + HStack(spacing: 10) { + Button(action: connect) { + HStack(spacing: 8) { + if session.isBusy { + ProgressView() + } else { + Image(systemName: "bolt.horizontal.fill") + } + Text("Connect") + .fontWeight(.semibold) } - Text("Connect") - .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) + .buttonStyle(.glassProminent) + .disabled(connectTarget.isEmpty || session.isBusy) + + Button { + withAnimation(.snappy) { isCustomServer.toggle() } + } label: { + Label( + isCustomServer ? "Default" : "Other server", + systemImage: isCustomServer ? "arrow.uturn.backward" : "chevron.down" + ) + .font(.subheadline) + .padding(.vertical, 6) + } + .buttonStyle(.glass) + .disabled(session.isBusy) } - .buttonStyle(.glassProminent) - .disabled(trimmedAddress.isEmpty || session.isBusy) + } + } + } + + private var defaultServerRow: some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.tint) + Text("Official server") + .lineLimit(1) + Spacer(minLength: 0) + probeReadout(for: ServerEndpoint.defaultAddress) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Server") + .accessibilityValue("Official server") + .task { probe.measure(ServerEndpoint.defaultAddress) } + } + + /// Liveness for one candidate server, measured before any socket exists. + @ViewBuilder + private func probeReadout(for address: String) -> some View { + switch probe.reading(for: address) { + case .reachable(let latencyMs): + LatencyLabel(milliseconds: latencyMs) + case .unreachable: + Label("Offline", systemImage: "exclamationmark.triangle.fill") + .font(.caption2.weight(.medium)) + .foregroundStyle(.orange) + .labelStyle(.titleAndIcon) + case .probing, nil: + ProgressView() + .controlSize(.mini) + } + } + + private var customServerControls: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("play.example.com or http://192.168.1.10:3001", text: $address) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .textContentType(.URL) + .submitLabel(.go) + .focused($isAddressFieldFocused) + .onSubmit(connect) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + + Text("Bare hosts default to https. Plain-http LAN servers are detected automatically.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + if !session.recentServers.isEmpty { + recentPanel } } } private func connect() { - let target = trimmedAddress + let target = connectTarget guard !target.isEmpty, !session.isBusy else { return } + isAddressFieldFocused = false Task { await session.connect(address: target) } } @@ -113,16 +212,22 @@ struct ConnectView: View { private var recentPanel: some View { VStack(alignment: .leading, spacing: 10) { Text("Recent servers") - .font(.headline) - .padding(.horizontal, 6) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) - GlassEffectContainer(spacing: 10) { - VStack(spacing: 10) { - ForEach(session.recentServers, id: \.self) { server in - recentRow(server) + // The only part of this screen whose height is unbounded, so it is the only + // part that scrolls. + ScrollView { + GlassEffectContainer(spacing: 10) { + VStack(spacing: 10) { + ForEach(session.recentServers, id: \.self) { server in + recentRow(server) + } } } } + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: 160) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -140,12 +245,14 @@ struct ConnectView: View { .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) + probeReadout(for: server) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(session.isBusy) + .task { probe.measure(server) } Button { session.recentServers = session.recentServers.filter { $0 != server } diff --git a/UnoClient/Views/Connect/LoginView.swift b/UnoClient/Views/Connect/LoginView.swift index 66ab5d6..102e40a 100644 --- a/UnoClient/Views/Connect/LoginView.swift +++ b/UnoClient/Views/Connect/LoginView.swift @@ -21,17 +21,22 @@ struct LoginView: View { var body: some View { ScrollView { HStack(alignment: .top, spacing: 32) { + // The API key panel lives beside the server card rather than under the + // credentials form: it is a rarely used alternative, and stacking it on + // the right pushed the primary sign-in buttons off a landscape screen. VStack(spacing: 16) { header serverCard + if !isDevMode { + apiKeyPanel + } } .frame(maxWidth: .infinity) VStack(spacing: 20) { - if session.authConfig?.devMode == true { + if isDevMode { devPanel } else { credentialsPanel - apiKeyPanel if session.authConfig?.turnstileSiteKey != nil { turnstileWarning } @@ -39,14 +44,18 @@ struct LoginView: View { } .frame(maxWidth: .infinity) } - .frame(maxWidth: 860) - .padding(28) + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) .scrollDismissesKeyboard(.interactively) } + private var isDevMode: Bool { + session.authConfig?.devMode == true + } + // MARK: - Header private var header: some View { @@ -83,11 +92,16 @@ struct LoginView: View { .foregroundStyle(.secondary) } - Label(session.savedAddress, systemImage: "link") - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) + // The default server is unambiguous from its name; only a self-hosted + // address is worth spelling out, so the user can confirm which box + // they reached. + if let endpoint = session.endpoint, !endpoint.isDefault { + Label(session.savedAddress, systemImage: "link") + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } } } } @@ -290,6 +304,7 @@ struct LoginView: View { .padding(.vertical, 6) } .buttonStyle(.glassProminent) + .actionWidth() .disabled(!enabled || session.isBusy) } } diff --git a/UnoClient/Views/Game/ChatSheet.swift b/UnoClient/Views/Game/ChatSheet.swift index e6cd0ef..c30ec2f 100644 --- a/UnoClient/Views/Game/ChatSheet.swift +++ b/UnoClient/Views/Game/ChatSheet.swift @@ -29,6 +29,7 @@ struct ChatSheet: View { } inputBar } + .unoBackdrop() .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } diff --git a/UnoClient/Views/Game/GameHUDBar.swift b/UnoClient/Views/Game/GameHUDBar.swift index ae753c9..3e58f8c 100644 --- a/UnoClient/Views/Game/GameHUDBar.swift +++ b/UnoClient/Views/Game/GameHUDBar.swift @@ -108,16 +108,17 @@ struct GameHUDBar: View { private var menuButton: some View { Menu { - Button("Autopilot once", systemImage: "wand.and.stars") { - Task { await game.autopilotOnce() } - } - Button( - game.me?.autopilot == true ? "Disable autopilot" : "Enable autopilot", - systemImage: "cpu" - ) { - Task { await game.toggleAutopilot() } - } - if !game.isSpectator { + // Autopilot delegates *your* turns, so it only exists for a seated player. + if game.isSeatedPlayer { + Button("Autopilot once", systemImage: "wand.and.stars") { + Task { await game.autopilotOnce() } + } + Button( + game.me?.autopilot == true ? "Disable autopilot" : "Enable autopilot", + systemImage: "cpu" + ) { + Task { await game.toggleAutopilot() } + } Button("Move to spectators", systemImage: "eye") { Task { await game.leaveToSpectate() } } diff --git a/UnoClient/Views/Game/GameView.swift b/UnoClient/Views/Game/GameView.swift index ecb0cbc..e72c6e9 100644 --- a/UnoClient/Views/Game/GameView.swift +++ b/UnoClient/Views/Game/GameView.swift @@ -47,7 +47,7 @@ struct GameView: View { // autopilot toggles (no seat recompute) while leaving it in the upper region // rather than centered over the whole screen. let auto = game.me?.autopilot == true - let isPlayer = !game.isSpectator && game.me != nil + let isPlayer = game.isSeatedPlayer return VStack(spacing: 0) { GameHUDBar(game: game, confirmLeave: $confirmLeave) TableView(game: game) diff --git a/UnoClient/Views/Game/PlayerSeatNode.swift b/UnoClient/Views/Game/PlayerSeatNode.swift index e6a5a44..3d84bfc 100644 --- a/UnoClient/Views/Game/PlayerSeatNode.swift +++ b/UnoClient/Views/Game/PlayerSeatNode.swift @@ -54,7 +54,7 @@ struct PlayerSeatNode: View { TurnPulseGlow(size: 42) } AvatarView( - url: game.session.endpoint?.resolveAvatar(player.avatarUrl), + url: game.session.avatarURL(playerId: player.id, serverValue: player.avatarUrl), name: player.name, size: 38 ) diff --git a/UnoClient/Views/Game/ScoreBoardSheet.swift b/UnoClient/Views/Game/ScoreBoardSheet.swift index c594a76..c299297 100644 --- a/UnoClient/Views/Game/ScoreBoardSheet.swift +++ b/UnoClient/Views/Game/ScoreBoardSheet.swift @@ -51,7 +51,7 @@ struct ScoreBoardSheet: View { HStack(spacing: 10) { rankBadge(rank) AvatarView( - url: game.session.endpoint?.resolveAvatar(player.avatarUrl), + url: game.session.avatarURL(playerId: player.id, serverValue: player.avatarUrl), name: player.name, size: 32 ) diff --git a/UnoClient/Views/Lobby/CreateRoomSheet.swift b/UnoClient/Views/Lobby/CreateRoomSheet.swift index 11d06dc..e67f2f9 100644 --- a/UnoClient/Views/Lobby/CreateRoomSheet.swift +++ b/UnoClient/Views/Lobby/CreateRoomSheet.swift @@ -10,6 +10,7 @@ struct CreateRoomSheet: View { var body: some View { NavigationStack { RoomSettingsEditor(settings: $settings, isEditable: true) + .unoBackdrop() .navigationTitle("New Room") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/UnoClient/Views/Lobby/LobbyView.swift b/UnoClient/Views/Lobby/LobbyView.swift index 2acb48e..8fb0801 100644 --- a/UnoClient/Views/Lobby/LobbyView.swift +++ b/UnoClient/Views/Lobby/LobbyView.swift @@ -1,5 +1,4 @@ import SwiftUI -import UIKit /// Post-login home screen: player header, room creation / joining, live games list. struct LobbyView: View { @@ -7,41 +6,42 @@ struct LobbyView: View { @State private var joinCode = "" @State private var showCreateSheet = false + @State private var showProfileSheet = false var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 16) { - Text(session.serverInfo?.name ?? "Lobby") - .font(.largeTitle.weight(.bold)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - HStack(alignment: .top, spacing: 24) { - VStack(spacing: 16) { - header - actions - } - .frame(maxWidth: .infinity) - liveGames - .frame(maxWidth: .infinity) + // The page itself does not scroll: the account column is fixed and only the + // live-games list, whose length is unbounded, scrolls inside its own column. + VStack(alignment: .leading, spacing: 12) { + Text(session.serverInfo?.name ?? "Lobby") + .font(.title2.weight(.bold)) + .lineLimit(1) + .padding(.trailing, 44) + + HStack(alignment: .top, spacing: 20) { + VStack(spacing: 14) { + header + actions + Spacer(minLength: 0) } + .frame(maxWidth: .infinity) + liveGames + .frame(maxWidth: .infinity) } - .padding(.horizontal, 16) - .padding(.vertical, 8) } + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() + .frame(maxWidth: .infinity) .scrollDismissesKeyboard(.interactively) - .background(Color.clear) + .unoBackdrop() .overlay(alignment: .topTrailing) { Menu { - if session.authConfig?.passkeyEnabled == true { - Button { - Task { await session.registerPasskey(name: UIDevice.current.name) } - } label: { - Label("Create Passkey", systemImage: "person.badge.key.fill") - } - .disabled(session.isBusy) - Divider() + Button { + showProfileSheet = true + } label: { + Label("Profile", systemImage: "person.crop.circle") } + Divider() Button(role: .destructive) { session.logout() } label: { @@ -57,13 +57,15 @@ struct LobbyView: View { .font(.title3) } .buttonStyle(.glass) - .padding(.horizontal, 20) - .padding(.top, 8) + .screenInsets() } .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showCreateSheet) { CreateRoomSheet() } + .sheet(isPresented: $showProfileSheet) { + ProfileView() + } } } @@ -89,7 +91,8 @@ struct LobbyView: View { } } Spacer(minLength: 0) - latencyChip + LatencyLabel(milliseconds: session.latencyMs) + .glassChip(horizontal: 9, vertical: 5) } if let info = session.serverInfo { HStack(spacing: 14) { @@ -103,25 +106,6 @@ struct LobbyView: View { } } - private var latencyChip: some View { - HStack(spacing: 5) { - Circle() - .fill(latencyColor) - .frame(width: 7, height: 7) - Text(session.latencyMs.map { "\($0) ms" } ?? "-- ms") - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) - } - .glassChip(horizontal: 9, vertical: 5) - } - - private var latencyColor: Color { - guard let ms = session.latencyMs else { return .gray } - if ms < 50 { return .green } - if ms <= 150 { return .yellow } - return .red - } - // MARK: - Primary actions private var actions: some View { @@ -135,6 +119,8 @@ struct LobbyView: View { .padding(.vertical, 8) } .buttonStyle(.glassProminent) + .actionWidth() + .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 10) { TextField("Room code or link", text: $joinCode) @@ -167,8 +153,8 @@ struct LobbyView: View { private var liveGames: some View { VStack(alignment: .leading, spacing: 10) { Text("Live games") - .font(.headline) - .padding(.horizontal, 4) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) if session.lobbyRooms.isEmpty { GlassPanel { @@ -178,10 +164,16 @@ struct LobbyView: View { .frame(maxWidth: .infinity, alignment: .center) } } else { - ForEach(session.lobbyRooms) { info in - LiveGameCard(info: info) + ScrollView { + LazyVStack(spacing: 10) { + ForEach(session.lobbyRooms) { info in + LiveGameCard(info: info) + } + } } + .scrollBounceBehavior(.basedOnSize) } + Spacer(minLength: 0) } } } diff --git a/UnoClient/Views/Lobby/RoomSettingsEditor.swift b/UnoClient/Views/Lobby/RoomSettingsEditor.swift index 2d50b4a..9bde966 100644 --- a/UnoClient/Views/Lobby/RoomSettingsEditor.swift +++ b/UnoClient/Views/Lobby/RoomSettingsEditor.swift @@ -1,269 +1,346 @@ import SwiftUI -/// Full room settings form. Used editable in `CreateRoomSheet` and read-only -/// inside the room screen — the `settings` binding + `isEditable` signature is -/// a contract with those callers. +/// Full room settings. Used editable in `CreateRoomSheet` and read-only inside the room +/// screen — the `settings` binding + `isEditable` signature is a contract with those callers. +/// +/// The 35 house rules do not fit a landscape screen as one list, and a `Form` gave every +/// rule the full width with nothing in the middle. Presets and match settings stay pinned; +/// the rules below them are split into sections, one shown at a time in an adaptive grid, +/// so a section fits without scrolling on all but the shortest screens. struct RoomSettingsEditor: View { @Binding var settings: RoomSettings let isEditable: Bool + @State private var section: RuleSection = .stacking + + private let columns = [GridItem(.adaptive(minimum: UnoLayout.columnWidth), spacing: 10)] + var body: some View { - Form { - presetSection - matchSection - stackingSection - specialCardsSection - drawingSection - unoCallsSection - paceSection - endgameSection + VStack(spacing: 10) { + controlStrip + + Picker("Rules", selection: $section) { + ForEach(RuleSection.allCases) { section in + Text(section.title).tag(section) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + ScrollView { + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { + rules(for: section) + } + .padding(.bottom, 4) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: .infinity) } - .scrollContentBackground(.hidden) + .frame(maxWidth: UnoLayout.contentWidth) + .frame(maxWidth: .infinity) + .screenInsets() .disabled(!isEditable) } - // MARK: - Preset + // MARK: - Presets and match - private var presetSection: some View { - Section("Preset") { - HStack(spacing: 10) { + /// One row, not two panels: a landscape sheet is only ~360pt tall, and the match + /// settings previously ate nearly half of it before a single rule was visible. + /// Wide choices use menu pickers — a 4-way segmented control truncates "1,000". + private var controlStrip: some View { + HStack(alignment: .center, spacing: 14) { + HStack(spacing: 6) { presetButton("Classic", rules: .default) presetButton("Party", rules: .party) presetButton("Crazy", rules: .crazy) } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) + + Divider().frame(height: 26) + + labelled("Turn time") { + Picker("Turn time", selection: $settings.turnTimeLimit) { + ForEach(RoomSettings.turnTimeLimitOptions, id: \.self) { seconds in + Text("\(seconds)s").tag(seconds) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 150) + } + + labelled("Target") { + Picker("Target", selection: $settings.targetScore) { + ForEach(RoomSettings.targetScoreOptions, id: \.self) { score in + Text("\(score)").tag(score) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + + labelled("Spectators") { + HStack(spacing: 6) { + Toggle("Spectators", isOn: $settings.allowSpectators) + .labelsHidden() + Picker("Spectator view", selection: $settings.spectatorMode) { + Text("Show hands").tag(SpectatorMode.full) + Text("Hide hands").tag(SpectatorMode.hidden) + } + .pickerStyle(.menu) + .labelsHidden() + .disabled(!settings.allowSpectators) + } + } + + Spacer(minLength: 0) } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .glassEffect(.regular, in: .rect(cornerRadius: 18)) } @ViewBuilder - private func presetButton(_ title: String, rules: HouseRules) -> some View { + private func presetButton(_ title: LocalizedStringKey, rules: HouseRules) -> some View { let isActive = settings.houseRules == rules if isActive { Button(title) { settings.houseRules = rules } .buttonStyle(.glassProminent) + .controlSize(.small) } else { Button(title) { settings.houseRules = rules } .buttonStyle(.glass) + .controlSize(.small) } } - // MARK: - Match - - private var matchSection: some View { - Section("Match") { - Picker("Turn time limit", selection: $settings.turnTimeLimit) { - ForEach(RoomSettings.turnTimeLimitOptions, id: \.self) { seconds in - Text("\(seconds)s").tag(seconds) - } - } - Picker("Target score", selection: $settings.targetScore) { - ForEach(RoomSettings.targetScoreOptions, id: \.self) { score in - Text("\(score)").tag(score) - } - } - Toggle("Allow spectators", isOn: $settings.allowSpectators) - Picker("Spectator view", selection: $settings.spectatorMode) { - Text("Show hands").tag(SpectatorMode.full) - Text("Hide hands").tag(SpectatorMode.hidden) - } + private func labelled( + _ title: LocalizedStringKey, @ViewBuilder control: () -> Control + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + control() } } // MARK: - House rules - private var stackingSection: some View { - Section("Stacking & deflection") { - RuleToggle("Stack +2", isOn: $settings.houseRules.stackDrawTwo) - RuleToggle("Stack +4", isOn: $settings.houseRules.stackDrawFour) - RuleToggle( - "Cross stack", - detail: "+2 and +4 can stack on each other", - isOn: $settings.houseRules.crossStack - ) - RuleToggle( - "Reverse deflects +2", - detail: "Play Reverse to bounce a +2 back", - isOn: $settings.houseRules.reverseDeflectDrawTwo - ) - RuleToggle( - "Reverse deflects +4", - detail: "Play Reverse to bounce a +4 back", - isOn: $settings.houseRules.reverseDeflectDrawFour - ) - RuleToggle( - "Skip deflect", - detail: "Play Skip to pass a draw stack onward", - isOn: $settings.houseRules.skipDeflect - ) - RuleToggle( - "Revenge mode", - detail: "Draw victims get a payback opportunity", - isOn: $settings.houseRules.revengeMode - ) + private enum RuleSection: String, CaseIterable, Identifiable { + case stacking, special, drawing, unoCalls, pace, endgame + + var id: Self { self } + + var title: LocalizedStringKey { + switch self { + case .stacking: return "Stacking" + case .special: return "Cards" + case .drawing: return "Drawing" + case .unoCalls: return "UNO" + case .pace: return "Pace" + case .endgame: return "Endgame" + } } } - private var specialCardsSection: some View { - Section("Special cards") { - RuleToggle( - "Zero rotates hands", - detail: "Playing a 0 rotates all hands in play direction", - isOn: $settings.houseRules.zeroRotateHands - ) - RuleToggle( - "Seven swaps hands", - detail: "Playing a 7 swaps hands with a chosen player", - isOn: $settings.houseRules.sevenSwapHands - ) - RuleToggle( - "Jump-in", - detail: "Play an identical card out of turn", - isOn: $settings.houseRules.jumpIn - ) - RuleToggle( - "Play multiple same numbers", - detail: "Drop several cards of the same number at once", - isOn: $settings.houseRules.multiplePlaySameNumber - ) - RuleToggle( - "Bomb card", - detail: "3+ same numbers in a row: everyone else draws 1", - isOn: $settings.houseRules.bombCard - ) - RuleToggle( - "Wild first turn", - detail: "Wilds may open the game", - isOn: $settings.houseRules.wildFirstTurn - ) + @ViewBuilder + private func rules(for section: RuleSection) -> some View { + switch section { + case .stacking: stackingRules + case .special: specialCardRules + case .drawing: drawingRules + case .unoCalls: unoCallRules + case .pace: paceRules + case .endgame: endgameRules } } - private var drawingSection: some View { - Section("Drawing") { - RuleToggle( - "Draw until playable", - detail: "Keep drawing until you can play", - isOn: $settings.houseRules.drawUntilPlayable - ) - RuleToggle( - "Forced play after draw", - detail: "A playable drawn card must be played", - isOn: $settings.houseRules.forcedPlayAfterDraw - ) - RuleToggle( - "Forced play", - detail: "You must play if you hold a playable card", - isOn: $settings.houseRules.forcedPlay - ) - RuleToggle( - "Blind draw", - detail: "Drawn cards stay hidden until your next turn", - isOn: $settings.houseRules.blindDraw - ) - Picker("Hand limit", selection: $settings.houseRules.handLimit) { - Text("Off").tag(nil as Int?) - Text("15").tag(15 as Int?) - Text("20").tag(20 as Int?) - Text("25").tag(25 as Int?) - } + @ViewBuilder + private var stackingRules: some View { + RuleToggle("Stack +2", isOn: $settings.houseRules.stackDrawTwo) + RuleToggle("Stack +4", isOn: $settings.houseRules.stackDrawFour) + RuleToggle( + "Cross stack", + detail: "+2 and +4 can stack on each other", + isOn: $settings.houseRules.crossStack + ) + RuleToggle( + "Reverse deflects +2", + detail: "Play Reverse to bounce a +2 back", + isOn: $settings.houseRules.reverseDeflectDrawTwo + ) + RuleToggle( + "Reverse deflects +4", + detail: "Play Reverse to bounce a +4 back", + isOn: $settings.houseRules.reverseDeflectDrawFour + ) + RuleToggle( + "Skip deflect", + detail: "Play Skip to pass a draw stack onward", + isOn: $settings.houseRules.skipDeflect + ) + RuleToggle( + "Revenge mode", + detail: "Draw victims get a payback opportunity", + isOn: $settings.houseRules.revengeMode + ) + } + + @ViewBuilder + private var specialCardRules: some View { + RuleToggle( + "Zero rotates hands", + detail: "Playing a 0 rotates all hands in play direction", + isOn: $settings.houseRules.zeroRotateHands + ) + RuleToggle( + "Seven swaps hands", + detail: "Playing a 7 swaps hands with a chosen player", + isOn: $settings.houseRules.sevenSwapHands + ) + RuleToggle( + "Jump-in", + detail: "Play an identical card out of turn", + isOn: $settings.houseRules.jumpIn + ) + RuleToggle( + "Play multiple same numbers", + detail: "Drop several cards of the same number at once", + isOn: $settings.houseRules.multiplePlaySameNumber + ) + RuleToggle( + "Bomb card", + detail: "3+ same numbers in a row: everyone else draws 1", + isOn: $settings.houseRules.bombCard + ) + RuleToggle( + "Wild first turn", + detail: "Wilds may open the game", + isOn: $settings.houseRules.wildFirstTurn + ) + } + + @ViewBuilder + private var drawingRules: some View { + RuleToggle( + "Draw until playable", + detail: "Keep drawing until you can play", + isOn: $settings.houseRules.drawUntilPlayable + ) + RuleToggle( + "Forced play after draw", + detail: "A playable drawn card must be played", + isOn: $settings.houseRules.forcedPlayAfterDraw + ) + RuleToggle( + "Forced play", + detail: "You must play if you hold a playable card", + isOn: $settings.houseRules.forcedPlay + ) + RuleToggle( + "Blind draw", + detail: "Drawn cards stay hidden until your next turn", + isOn: $settings.houseRules.blindDraw + ) + RulePicker("Hand limit", selection: $settings.houseRules.handLimit) { + Text("Off").tag(nil as Int?) + Text("15").tag(15 as Int?) + Text("20").tag(20 as Int?) + Text("25").tag(25 as Int?) } } - private var unoCallsSection: some View { - Section("UNO calls") { - Picker("Missed-UNO penalty", selection: $settings.houseRules.unoPenaltyCount) { - Text("2 cards").tag(2) - Text("4 cards").tag(4) - Text("6 cards").tag(6) - } - RuleToggle( - "Strict UNO call", - detail: "Must call UNO before the second-to-last card lands", - isOn: $settings.houseRules.strictUnoCall - ) - RuleToggle( - "Silent UNO", - detail: "UNO calls are not announced to others", - isOn: $settings.houseRules.silentUno - ) + @ViewBuilder + private var unoCallRules: some View { + RulePicker("Missed-UNO penalty", selection: $settings.houseRules.unoPenaltyCount) { + Text("2").tag(2) + Text("4").tag(4) + Text("6").tag(6) } + RuleToggle( + "Strict UNO call", + detail: "Must call UNO before the second-to-last card lands", + isOn: $settings.houseRules.strictUnoCall + ) + RuleToggle( + "Silent UNO", + detail: "UNO calls are not announced to others", + isOn: $settings.houseRules.silentUno + ) } - private var paceSection: some View { - Section("Pace") { - RuleToggle( - "Fast mode", - detail: "Shorter animations and snappier turns", - isOn: $settings.houseRules.fastMode - ) - RuleToggle( - "No hints", - detail: "Playable cards are not highlighted", - isOn: $settings.houseRules.noHints - ) - Picker("Blitz time limit", selection: $settings.houseRules.blitzTimeLimit) { - Text("Off").tag(nil as Int?) - Text("1 min").tag(60 as Int?) - Text("2 min").tag(120 as Int?) - Text("3 min").tag(180 as Int?) - Text("5 min").tag(300 as Int?) - } - RuleToggle( - "Misplay penalty", - detail: "Illegal play attempts cost a drawn card", - isOn: $settings.houseRules.misplayPenalty - ) + @ViewBuilder + private var paceRules: some View { + RuleToggle( + "Fast mode", + detail: "Shorter animations and snappier turns", + isOn: $settings.houseRules.fastMode + ) + RuleToggle( + "No hints", + detail: "Playable cards are not highlighted", + isOn: $settings.houseRules.noHints + ) + RuleToggle( + "Misplay penalty", + detail: "Illegal play attempts cost a drawn card", + isOn: $settings.houseRules.misplayPenalty + ) + RulePicker("Blitz limit", selection: $settings.houseRules.blitzTimeLimit) { + Text("Off").tag(nil as Int?) + Text("1m").tag(60 as Int?) + Text("2m").tag(120 as Int?) + Text("3m").tag(180 as Int?) + Text("5m").tag(300 as Int?) } } - private var endgameSection: some View { - Section("Endgame & scoring") { - RuleToggle( - "Elimination", - detail: "Players over the score cap drop out", - isOn: $settings.houseRules.elimination - ) - RuleToggle( - "Team mode", - detail: "Needs an even player count", - isOn: $settings.houseRules.teamMode - ) - RuleToggle( - "No action-card finish", - detail: "Cannot win on Skip/Reverse/+2", - isOn: $settings.houseRules.noFunctionCardFinish - ) - RuleToggle( - "No wild finish", - detail: "Cannot win on a Wild card", - isOn: $settings.houseRules.noWildFinish - ) - RuleToggle( - "Double score", - detail: "Round points count twice", - isOn: $settings.houseRules.doubleScore - ) - RuleToggle( - "No +4 challenges", - detail: "Wild +4 can always be played, never challenged", - isOn: $settings.houseRules.noChallengeWildFour - ) - Picker("Reveal small hands", selection: $settings.houseRules.handRevealThreshold) { - Text("Off").tag(nil as Int?) - Text("2 cards").tag(2 as Int?) - Text("3 cards").tag(3 as Int?) - } - RuleToggle( - "Shuffle seats", - detail: "Random seating each round", - isOn: $settings.houseRules.shuffleSeats - ) + @ViewBuilder + private var endgameRules: some View { + RuleToggle( + "Elimination", + detail: "Players over the score cap drop out", + isOn: $settings.houseRules.elimination + ) + RuleToggle( + "Team mode", + detail: "Needs an even player count", + isOn: $settings.houseRules.teamMode + ) + RuleToggle( + "No action-card finish", + detail: "Cannot win on Skip/Reverse/+2", + isOn: $settings.houseRules.noFunctionCardFinish + ) + RuleToggle( + "No wild finish", + detail: "Cannot win on a Wild card", + isOn: $settings.houseRules.noWildFinish + ) + RuleToggle( + "Double score", + detail: "Round points count twice", + isOn: $settings.houseRules.doubleScore + ) + RuleToggle( + "No +4 challenges", + detail: "Wild +4 can always be played, never challenged", + isOn: $settings.houseRules.noChallengeWildFour + ) + RuleToggle( + "Shuffle seats", + detail: "Random seating each round", + isOn: $settings.houseRules.shuffleSeats + ) + RulePicker("Reveal small hands", selection: $settings.houseRules.handRevealThreshold) { + Text("Off").tag(nil as Int?) + Text("2").tag(2 as Int?) + Text("3").tag(3 as Int?) } } } -/// Toggle row with an optional footnote description. +/// One rule as a self-contained card, sized to a grid column instead of a full row. private struct RuleToggle: View { let title: LocalizedStringKey let detail: LocalizedStringKey? @@ -279,12 +356,53 @@ private struct RuleToggle: View { Toggle(isOn: $isOn) { VStack(alignment: .leading, spacing: 2) { Text(title) + .font(.subheadline.weight(.medium)) if let detail { Text(detail) - .font(.footnote) + .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } } + .ruleCard() + } +} + +/// A multiple-choice rule, styled to sit in the same grid as the toggles. +private struct RulePicker: View { + let title: LocalizedStringKey + @Binding var selection: Value + @ViewBuilder let options: () -> Options + + init( + _ title: LocalizedStringKey, + selection: Binding, + @ViewBuilder options: @escaping () -> Options + ) { + self.title = title + self._selection = selection + self.options = options + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.subheadline.weight(.medium)) + Picker(title, selection: $selection, content: options) + .pickerStyle(.segmented) + .labelsHidden() + } + .ruleCard() + } +} + +extension View { + fileprivate func ruleCard() -> some View { + padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) } } diff --git a/UnoClient/Views/Profile/ProfileView.swift b/UnoClient/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..f3c2d99 --- /dev/null +++ b/UnoClient/Views/Profile/ProfileView.swift @@ -0,0 +1,358 @@ +import PhotosUI +import SwiftUI +import UIKit + +/// Account self-service sheet: avatar, display identity, passkeys and API keys. +/// Everything here needs a signed-in session; the lobby is its only entry point. +struct ProfileView: View { + @Environment(SessionStore.self) private var session + @Environment(\.dismiss) private var dismiss + + @State private var store: ProfileStore? + @State private var nickname = "" + @State private var username = "" + @State private var pickedPhoto: PhotosPickerItem? + @State private var newKeyName = "" + @State private var newPassword = "" + @State private var passwordConfirmation = "" + + var body: some View { + NavigationStack { + ScrollView { + if let store { + // Two columns: the account itself on the left, the credentials that + // reach it on the right. One column would scroll past the screen. + HStack(alignment: .top, spacing: 16) { + VStack(spacing: 16) { + avatarPanel(store) + identityPanel(store) + if store.isEditable { + passwordPanel(store) + } + } + .frame(maxWidth: .infinity) + VStack(spacing: 16) { + if store.passkeysEnabled { + passkeyPanel(store) + } + apiKeyPanel(store) + } + .frame(maxWidth: .infinity) + } + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() + .frame(maxWidth: .infinity) + } else { + ProgressView() + .controlSize(.large) + .padding(40) + } + } + .scrollDismissesKeyboard(.interactively) + .unoBackdrop() + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { + guard store == nil, let created = ProfileStore(session: session) else { return } + store = created + await created.load() + nickname = created.profile?.nickname ?? session.user?.nickname ?? "" + username = created.profile?.username ?? session.user?.username ?? "" + } + .onChange(of: pickedPhoto) { _, item in + guard let item, let store else { return } + Task { + defer { pickedPhoto = nil } + guard let data = try? await item.loadTransferable(type: Data.self) else { + session.showToast(String(localized: "That image could not be read")) + return + } + await store.setAvatar(imageData: data) + } + } + } + + // MARK: - Avatar + + private func avatarPanel(_ store: ProfileStore) -> some View { + GlassPanel { + HStack(spacing: 16) { + AvatarView( + url: store.avatarURL, + name: store.profile?.nickname ?? session.user?.nickname ?? "?", + size: 76 + ) + VStack(alignment: .leading, spacing: 8) { + Text(store.profile?.nickname ?? "—") + .font(.title3.weight(.semibold)) + if store.isEditable { + HStack(spacing: 10) { + PhotosPicker(selection: $pickedPhoto, matching: .images) { + Label("Change", systemImage: "photo") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.glass) + .disabled(store.isBusy) + + if store.profile?.avatarUrl != nil { + Button(role: .destructive) { + Task { await store.removeAvatar() } + } label: { + Label("Remove", systemImage: "trash") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.glass) + .disabled(store.isBusy) + } + } + } else { + Text("This server runs in development mode — profile changes are disabled.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + } + } + + // MARK: - Identity + + private func identityPanel(_ store: ProfileStore) -> some View { + GlassPanel { + VStack(alignment: .leading, spacing: 14) { + Label("Identity", systemImage: "person.text.rectangle") + .font(.headline) + + labelledField("Nickname", text: $nickname, editable: store.isEditable) + labelledField("Username", text: $username, editable: store.isEditable) + + if store.isEditable { + Button { + Task { await store.save(nickname: nickname, username: username) } + } label: { + Text("Save changes") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.glassProminent) + .actionWidth() + .disabled(store.isBusy || !hasIdentityChanges(store)) + } + + // Room seats and chat read the JWT, which a profile edit does not reissue. + Text("Other players see your old name and avatar until you sign in again.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private func hasIdentityChanges(_ store: ProfileStore) -> Bool { + guard let profile = store.profile else { return false } + let trimmedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedNickname != profile.nickname || trimmedUsername != profile.username + } + + private func labelledField(_ title: LocalizedStringKey, text: Binding, editable: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + TextField(title, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(!editable) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + } + } + + // MARK: - Password + + private func passwordPanel(_ store: ProfileStore) -> some View { + GlassPanel { + VStack(alignment: .leading, spacing: 14) { + Label("Password", systemImage: "lock.fill") + .font(.headline) + + secureField("New password", text: $newPassword) + secureField("Repeat password", text: $passwordConfirmation) + + Text("At least 8 characters, with letters and digits.") + .font(.footnote) + .foregroundStyle(.secondary) + + Button { + Task { + await store.setPassword(newPassword, confirmation: passwordConfirmation) + newPassword = "" + passwordConfirmation = "" + } + } label: { + Text("Set password") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.glassProminent) + .actionWidth() + .disabled(store.isBusy || newPassword.isEmpty || passwordConfirmation.isEmpty) + } + } + } + + private func secureField(_ title: LocalizedStringKey, text: Binding) -> some View { + SecureField(title, text: text) + .textContentType(.newPassword) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + } + + // MARK: - Passkeys + + private func passkeyPanel(_ store: ProfileStore) -> some View { + GlassPanel { + VStack(alignment: .leading, spacing: 12) { + Label("Passkeys", systemImage: "person.badge.key.fill") + .font(.headline) + + if store.passkeys.isEmpty { + Text("No passkeys yet. Add one to sign in without a password.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + ForEach(store.passkeys) { passkey in + credentialRow(title: passkey.name, subtitle: nil) { + Task { await store.deletePasskey(id: passkey.id) } + } + } + + Button { + Task { + await session.registerPasskey(name: UIDevice.current.name) + await store.load() + } + } label: { + Label("Add passkey", systemImage: "plus") + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glass) + .disabled(store.isBusy || session.isBusy) + } + } + } + + // MARK: - API keys + + private func apiKeyPanel(_ store: ProfileStore) -> some View { + GlassPanel { + VStack(alignment: .leading, spacing: 12) { + Label("API keys", systemImage: "key.fill") + .font(.headline) + + Text("Keys sign in without a password — MCP clients and bots use them.") + .font(.footnote) + .foregroundStyle(.secondary) + + ForEach(store.apiKeys) { key in + credentialRow(title: key.name, subtitle: key.keyPreview) { + Task { await store.deleteApiKey(id: key.id) } + } + } + + if let revealed = store.revealedKey { + revealedKeyRow(revealed) { store.revealedKey = nil } + } + + HStack(spacing: 10) { + TextField("New key name", text: $newKeyName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + + Button { + Task { + await store.createApiKey(name: newKeyName) + newKeyName = "" + } + } label: { + Label("Create", systemImage: "plus") + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.glass) + .disabled(store.isBusy || newKeyName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + /// The plaintext key is shown once; copying is the only way to keep it. + private func revealedKeyRow(_ key: CreatedApiKey, dismiss: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label("Copy \(key.name) now — it is never shown again", systemImage: "exclamationmark.triangle.fill") + .font(.footnote.weight(.medium)) + .foregroundStyle(.orange) + HStack(spacing: 10) { + Text(key.key) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Button { + UIPasteboard.general.string = key.key + session.showToast(String(localized: "API key copied"), isError: false) + dismiss() + } label: { + Label("Copy", systemImage: "doc.on.doc") + .labelStyle(.iconOnly) + } + .buttonStyle(.glass) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .glassEffect(.regular.tint(.orange.opacity(0.25)), in: .rect(cornerRadius: 16)) + } + + private func credentialRow( + title: String, subtitle: String?, delete: @escaping () -> Void + ) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.medium)) + if let subtitle { + Text(subtitle) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + Button(role: .destructive, action: delete) { + Image(systemName: "trash") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel("Delete \(title)") + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } +} diff --git a/UnoClient/Views/Room/AiEnginePicker.swift b/UnoClient/Views/Room/AiEnginePicker.swift new file mode 100644 index 0000000..2b39165 --- /dev/null +++ b/UnoClient/Views/Room/AiEnginePicker.swift @@ -0,0 +1,118 @@ +import SwiftUI + +/// Picks the AI engine behind a bot. The list is server-filtered by seat count and +/// house rules, so it is fetched when the sheet opens rather than cached in the room. +struct AiEnginePicker: View { + enum Target: Equatable, Identifiable { + /// Seat the new bot at a specific index, or let the server choose. + case add(seatIndex: Int?) + case change(botId: String) + + var id: String { + switch self { + case .add(let seatIndex): return "add-\(seatIndex.map(String.init) ?? "any")" + case .change(let botId): return "change-\(botId)" + } + } + + var intent: AiProviderIntent { + switch self { + case .add: return .add + case .change: return .switch + } + } + } + + let room: RoomStore + let target: Target + + @Environment(\.dismiss) private var dismiss + @State private var providers: [AiProvider] = [] + @State private var isLoading = true + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: UnoLayout.columnWidth), spacing: 12)], + spacing: 12 + ) { + if isLoading { + ProgressView() + .controlSize(.large) + .padding(40) + } else if providers.isEmpty { + Text("No AI engine fits this room's player count and house rules.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, 40) + } else { + ForEach(providers) { provider in + row(provider) + } + } + } + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() + .frame(maxWidth: .infinity) + } + .unoBackdrop() + .navigationTitle("AI engine") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { + providers = await room.aiProviders(intent: target.intent) + isLoading = false + } + } + + private func row(_ provider: AiProvider) -> some View { + Button { + Task { + switch target { + case .add(let seatIndex): + await room.addAiBot(providerId: provider.id, seatIndex: seatIndex) + case .change(let botId): + await room.setBotAi(botId: botId, providerId: provider.id) + } + dismiss() + } + } label: { + HStack(spacing: 12) { + Image(systemName: "brain") + .foregroundStyle(.purple) + VStack(alignment: .leading, spacing: 3) { + Text(provider.displayName) + .font(.subheadline.weight(.semibold)) + Text(provider.fairness.localizedName) + .font(.caption) + .foregroundStyle(fairnessColor(provider.fairness)) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } + .buttonStyle(.plain) + } + + private func fairnessColor(_ fairness: AiProvider.Fairness) -> Color { + switch fairness { + case .fair: return .green + case .privileged: return .yellow + case .cheat: return .orange + } + } +} diff --git a/UnoClient/Views/Room/RoomSettingsSheet.swift b/UnoClient/Views/Room/RoomSettingsSheet.swift index 64908df..dd04a72 100644 --- a/UnoClient/Views/Room/RoomSettingsSheet.swift +++ b/UnoClient/Views/Room/RoomSettingsSheet.swift @@ -25,6 +25,7 @@ struct RoomSettingsSheet: View { .padding(.bottom, 8) } } + .unoBackdrop() .navigationTitle("Room Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/UnoClient/Views/Room/RoomView.swift b/UnoClient/Views/Room/RoomView.swift index 4fe3271..2c87e6f 100644 --- a/UnoClient/Views/Room/RoomView.swift +++ b/UnoClient/Views/Room/RoomView.swift @@ -7,6 +7,7 @@ struct RoomView: View { @State private var showSettingsSheet = false @State private var showLeaveDialog = false + @State private var aiEngineTarget: AiEnginePicker.Target? var body: some View { NavigationStack { @@ -31,10 +32,10 @@ struct RoomView: View { } .frame(maxWidth: .infinity) } - .padding(20) - // Landscape home-indicator inflates the bottom safe area; extend into - // it so the visual bottom margin equals the (≈0-inset) top margin. - .ignoresSafeArea(.container, edges: .bottom) + .frame(maxWidth: UnoLayout.contentWidth) + .screenInsets() + .frame(maxWidth: .infinity) + .unoBackdrop() .overlay(alignment: .topTrailing) { Button { showSettingsSheet = true @@ -43,12 +44,17 @@ struct RoomView: View { .font(.title3) } .buttonStyle(.glass) - .padding(.trailing, 20) + // Matches `screenInsets` so the button lines up with the content + // it floats over instead of hugging the display edge. + .screenInsets() } .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showSettingsSheet) { RoomSettingsSheet(room: room) } + .sheet(item: $aiEngineTarget) { target in + AiEnginePicker(room: room, target: target) + } .confirmationDialog("Leave this room?", isPresented: $showLeaveDialog, titleVisibility: .visible) { Button("Leave", role: .destructive) { Task { await room.leaveRoom() } @@ -111,9 +117,13 @@ struct RoomView: View { } } + /// A self-hosted server is worth spelling out so the invite is clickable; the default + /// server's address stays out of the UI, so its invite carries the room code alone. private var shareText: String { - let base = room.session.endpoint?.baseURL.absoluteString ?? "" - return "Join my UNO room \(room.roomCode): \(base)/room/\(room.roomCode)" + guard let endpoint = room.session.endpoint, !endpoint.isDefault else { + return "Join my UNO room \(room.roomCode)" + } + return "Join my UNO room \(room.roomCode): \(endpoint.baseURL.absoluteString)/room/\(room.roomCode)" } // MARK: - Settings summary @@ -199,17 +209,19 @@ struct RoomView: View { Button { Task { await room.startGame() } } label: { - barLabel("Start game", "play.fill") + barLabel("Start", "play.fill") } .buttonStyle(.glassProminent) .disabled(!room.canStartGame) Menu { - ForEach(BotDifficulty.allCases, id: \.self) { difficulty in + ForEach(BotDifficulty.ruleCases, id: \.self) { difficulty in Button(difficulty.localizedName) { Task { await room.addBot(difficulty: difficulty, seatIndex: nil) } } } + Divider() + Button("AI engine…") { aiEngineTarget = .add(seatIndex: nil) } } label: { barLabel("Add bot", "cpu") } @@ -226,10 +238,13 @@ struct RoomView: View { .padding(.bottom, 4) } - /// Uniform control-bar button label: equal width per row, equal height. + /// Uniform control-bar button label: equal width per row, equal height. Three + /// buttons share a half-screen column, so the label has to shrink rather than wrap. private func barLabel(_ title: String, _ icon: String) -> some View { Label(title, systemImage: icon) .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) .frame(maxWidth: .infinity) .padding(.vertical, 8) } diff --git a/UnoClient/Views/Room/SeatGridView.swift b/UnoClient/Views/Room/SeatGridView.swift index 9a97da9..80f059f 100644 --- a/UnoClient/Views/Room/SeatGridView.swift +++ b/UnoClient/Views/Room/SeatGridView.swift @@ -5,19 +5,27 @@ struct SeatGridView: View { let room: RoomStore @State private var swapTarget: RoomSeatPlayer? + @State private var aiEngineTarget: AiEnginePicker.Target? private let columns = [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)] var body: some View { LazyVGrid(columns: columns, spacing: 12) { ForEach(0.. RoomSeatPlayer? { @@ -44,12 +55,11 @@ struct SeatGridView: View { let isMe = player.userId == room.myUserId let isRoomOwner = player.userId == room.room?.ownerId - return VStack(spacing: 8) { - AvatarView( - url: room.session.endpoint?.resolveAvatar(player.avatarUrl), - name: player.nickname, - size: 44 - ) + // The avatar is the card's backdrop, not a badge on it: the seat grid is the + // one place with room for a picture, and dropping the circle buys the label + // the whole cell width. + return VStack(alignment: .leading, spacing: 6) { + Spacer(minLength: 0) HStack(spacing: 4) { if isRoomOwner { Image(systemName: "crown.fill") @@ -59,6 +69,7 @@ struct SeatGridView: View { Text(player.nickname) .font(.subheadline.weight(.semibold)) .lineLimit(1) + .minimumScaleFactor(0.85) } HStack(spacing: 6) { if player.isBot { @@ -70,18 +81,26 @@ struct SeatGridView: View { } if player.ready { chip(text: "READY", icon: "checkmark", color: .green) + .transition(.scale.combined(with: .opacity)) } if !player.connected { Image(systemName: "wifi.slash") .font(.caption2) .foregroundStyle(.orange) } + Spacer(minLength: 0) } .frame(minHeight: 18) } - .padding(.vertical, 12) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, minHeight: 108) + .padding(12) + .frame(maxWidth: .infinity, minHeight: 108, alignment: .bottomLeading) + .background { + AvatarBackdrop( + url: room.session.avatarURL(playerId: player.userId, serverValue: player.avatarUrl), + name: player.nickname + ) + .clipShape(.rect(cornerRadius: 20)) + } .modifier(SeatGlass(isMine: isMe)) .opacity(player.connected ? 1 : 0.5) .contentShape(.rect(cornerRadius: 20)) @@ -95,7 +114,7 @@ struct SeatGridView: View { if room.isOwner { if player.isBot { Menu("Change difficulty") { - ForEach(BotDifficulty.allCases, id: \.self) { difficulty in + ForEach(BotDifficulty.ruleCases, id: \.self) { difficulty in Button(difficulty.localizedName) { Task { await room.setBotDifficulty( @@ -106,6 +125,7 @@ struct SeatGridView: View { } } } + Button("AI engine…") { aiEngineTarget = .change(botId: player.userId) } Button("Remove bot", role: .destructive) { Task { await room.removeBot(botId: player.userId) } } @@ -124,6 +144,8 @@ struct SeatGridView: View { private func chip(text: String, icon: String, color: Color) -> some View { Label(text, systemImage: icon) .font(.system(size: 9, weight: .bold)) + .lineLimit(1) + .fixedSize() .foregroundStyle(color) .padding(.horizontal, 7) .padding(.vertical, 3) @@ -158,11 +180,13 @@ struct SeatGridView: View { .contextMenu { if room.isOwner { Menu("Add bot here") { - ForEach(BotDifficulty.allCases, id: \.self) { difficulty in + ForEach(BotDifficulty.ruleCases, id: \.self) { difficulty in Button(difficulty.localizedName) { Task { await room.addBot(difficulty: difficulty, seatIndex: index) } } } + Divider() + Button("AI engine…") { aiEngineTarget = .add(seatIndex: index) } } } } diff --git a/UnoClient/Views/Shared/AvatarView.swift b/UnoClient/Views/Shared/AvatarView.swift index db2741e..28dbc11 100644 --- a/UnoClient/Views/Shared/AvatarView.swift +++ b/UnoClient/Views/Shared/AvatarView.swift @@ -36,14 +36,90 @@ struct AvatarView: View { } } - private var initials: String { + private var initials: String { AvatarPalette.initials(name) } + + private var fallbackColor: Color { AvatarPalette.color(for: name) } +} + +/// Stand-in identity for a player with no avatar image. Shared so the circular avatar +/// and the seat backdrop agree on the same colour for the same person. +enum AvatarPalette { + private static let colors: [Color] = [.blue, .purple, .pink, .orange, .teal, .indigo, .mint] + + static func color(for name: String) -> Color { + colors[abs(name.hashValue) % colors.count] + } + + static func initials(_ name: String) -> String { String(name.trimmingCharacters(in: .whitespaces).prefix(2)).uppercased() } +} + +/// The avatar as a card's backdrop rather than a badge: blurred to stay behind the text +/// and faded from the top-left corner to the bottom-right so the label side of the card +/// keeps enough contrast for names and status chips. +struct AvatarBackdrop: View { + let url: URL? + let name: String + + @State private var image: UIImage? + + var body: some View { + // `Color.clear` takes the host cell's size; a bare `scaledToFill` image inside a + // `.background` would size the container to the image and spill past the card. + Color.clear + .overlay { + ZStack { + artwork + // Second copy, blurred, revealed from the top-left corner onward: + // the picture reads sharp where it starts and dissolves into the + // card where the name and chips sit. + artwork + .blur(radius: 14) + .mask(diagonal(from: .clear, to: .white)) + } + } + .clipped() + .mask( + diagonal( + stops: [ + .init(color: .white, location: 0), + .init(color: .white.opacity(0.5), location: 0.5), + .init(color: .clear, location: 1), + ] + ) + ) + .allowsHitTesting(false) + .task(id: url) { + image = nil + guard let url else { return } + // A seat card is ~360pt wide at @3x. Decoding smaller and letting + // `scaledToFill` stretch it produced visible blocky steps. + image = await AvatarImageCache.shared.image(url, pixelSize: 560) + } + } + + /// No initials here: the nickname sits right on top of this, and a giant repeat of + /// its first two letters is noise. A player without a picture just gets their colour. + @ViewBuilder + private var artwork: some View { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Rectangle() + .fill(AvatarPalette.color(for: name).gradient) + .opacity(0.4) + } + } + + private func diagonal(from start: Color, to end: Color) -> LinearGradient { + diagonal(stops: [.init(color: start, location: 0), .init(color: end, location: 1)]) + } - private var fallbackColor: Color { - let palette: [Color] = [.blue, .purple, .pink, .orange, .teal, .indigo, .mint] - let index = abs(name.hashValue) % palette.count - return palette[index] + private func diagonal(stops: [Gradient.Stop]) -> LinearGradient { + LinearGradient(stops: stops, startPoint: .topLeading, endPoint: .bottomTrailing) } } diff --git a/UnoClient/Views/Shared/Theme.swift b/UnoClient/Views/Shared/Theme.swift index b27c4b9..0792032 100644 --- a/UnoClient/Views/Shared/Theme.swift +++ b/UnoClient/Views/Shared/Theme.swift @@ -14,7 +14,32 @@ enum UnoPalette { static let emerald = Color(red: 0.20, green: 0.80, blue: 0.40) } +/// Shared measurements for the landscape-first layout. iPhone is locked to landscape, +/// so screens are wide and short: content is laid out in columns, and controls are +/// capped rather than stretched across the full width. +enum UnoLayout { + /// Primary buttons stop growing here — stretched across a landscape screen a button + /// reads as a banner, not a control. + static let actionWidth: CGFloat = 280 + /// Widest a screen's content grows before it stops tracking the display. + static let contentWidth: CGFloat = 1000 + /// Narrowest a content column may become before the grid drops to fewer columns. + static let columnWidth: CGFloat = 260 +} + extension View { + /// Uniform inset for a screen's content, on top of the half safe-area margin + /// `RootView` already applies — hence the modest values. + func screenInsets() -> some View { + padding(.horizontal, 14) + .padding(.vertical, 10) + } + + /// Caps a primary action so it stays a button on a 1000pt-wide screen. + func actionWidth() -> some View { + frame(maxWidth: UnoLayout.actionWidth) + } + /// Compact "chip" surface: symmetric padding on a regular-glass capsule. /// Replaces the padding-plus-`glassEffect(.regular, in: .capsule)` chain that /// was hand-repeated across the HUD, lobby and room views. @@ -25,6 +50,32 @@ extension View { } } +/// Round-trip indicator: a health-colored dot next to the measurement. Used both for +/// the live socket ping in the lobby and for landing-screen server probes. +struct LatencyLabel: View { + let milliseconds: Int? + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + Text(milliseconds.map { "\($0) ms" } ?? "-- ms") + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Round trip") + } + + private var color: Color { + guard let milliseconds else { return .gray } + if milliseconds < 50 { return .green } + if milliseconds <= 150 { return .yellow } + return .red + } +} + /// Shared dark table backdrop: deep neutral base with soft color glows so the /// Liquid Glass layers above have something to refract. struct UnoBackground: View { @@ -54,6 +105,15 @@ struct UnoBackground: View { } } +extension View { + /// `NavigationStack` and sheet presentations paint their own opaque container + /// background, which hides the one `RootView` puts behind everything. Screens living + /// inside such a container repaint the same backdrop so every surface matches. + func unoBackdrop() -> some View { + background(UnoBackground()) + } +} + /// Section container used across screens: content on a glass slab. struct GlassPanel: View { var cornerRadius: CGFloat = 24 diff --git a/UnoClientTests/EncodingAndRulesTests.swift b/UnoClientTests/EncodingAndRulesTests.swift index e2b0a93..9fb8c1b 100644 --- a/UnoClientTests/EncodingAndRulesTests.swift +++ b/UnoClientTests/EncodingAndRulesTests.swift @@ -28,6 +28,14 @@ struct WireEncodingTests { let data = try JSONEncoder().encode(value) #expect(try JSONDecoder().decode(JSONValue.self, from: data) == value) } + + @Test("Latency milliseconds survive the one-second boundary") + func durationMilliseconds() { + #expect(Duration.milliseconds(42).milliseconds == 42) + #expect(Duration.milliseconds(999).milliseconds == 999) + #expect(Duration.milliseconds(1450).milliseconds == 1450) + #expect(Duration.seconds(8).milliseconds == 8000) + } } @Suite("Card rules") diff --git a/UnoClientTests/ServerEndpointTests.swift b/UnoClientTests/ServerEndpointTests.swift index f9164a6..8f74500 100644 --- a/UnoClientTests/ServerEndpointTests.swift +++ b/UnoClientTests/ServerEndpointTests.swift @@ -5,6 +5,29 @@ import Testing @Suite("Server endpoint") struct ServerEndpointTests { + @Test("Default server uses the production endpoint") + func defaultServer() throws { + let endpoint = try #require(ServerEndpoint(userInput: ServerEndpoint.defaultAddress)) + + #expect(endpoint.baseURL.absoluteString == "https://uno.aunly.cn") + #expect(endpoint.isDefault) + } + + @Test( + "The default server is recognized from any equivalent spelling", + arguments: ["uno.aunly.cn", "uno.aunly.cn/", "https://uno.aunly.cn/"]) + func recognizesDefault(address: String) throws { + let endpoint = try #require(ServerEndpoint(userInput: address)) + + #expect(endpoint.isDefault) + } + + @Test("Other servers are never mistaken for the default") + func nonDefaultServers() throws { + #expect(try #require(ServerEndpoint(userInput: "play.example.com")).isDefault == false) + #expect(try #require(ServerEndpoint(userInput: "http://localhost:3001")).isDefault == false) + } + @Test("Bare public hosts default to HTTPS") func publicHostDefaultsToHTTPS() throws { let endpoint = try #require(ServerEndpoint(userInput: "play.example.com/"))