diff --git a/.github/workflows/expo-code-review-command.yml b/.github/workflows/expo-code-review-command.yml index f7fc62e403dfde..542c8269e4c8e1 100644 --- a/.github/workflows/expo-code-review-command.yml +++ b/.github/workflows/expo-code-review-command.yml @@ -12,10 +12,34 @@ name: AI code review (command) # This never changes configuration. CONTINUOUS review is configured in # expo-code-review.yml (the `pull_request` workflow) via the `review.trigger` # policy in .expo-code-review/config.jsonc and the `ai-review:skip` label. +# +# Also dispatchable manually (Actions → this workflow → Run workflow) with a PR +# number. Dispatch requires write access, so it carries the same maintainers-only +# gate as the comment path. The `config-from-checkout` input exists for PRs whose +# base commit predates .expo-code-review/ — `ecr ci` loads configuration from the +# PR's trusted base commit and fails closed when that commit has no config, so +# such PRs can only be reviewed by trusting this base-ref checkout's config +# (still never the PR head). on: issue_comment: types: [created] + workflow_dispatch: + inputs: + pr: + description: PR number to review + type: number + required: true + agents: + description: "Agent ids (comma-separated), 'all' for every agent, or empty to let the router pick" + type: string + required: false + default: '' + config-from-checkout: + description: Load reviewer config from this checkout instead of the PR's base commit (for PRs whose base predates .expo-code-review/) + type: boolean + required: false + default: false # Comment-only: read the repo, write PR comments (issue comments API). permissions: @@ -25,11 +49,12 @@ permissions: jobs: command: - # Only PR comments starting with /review, /expo-review, @expo-bot review, - # or @expo-bot check. + # Manual dispatch (write access required), or PR comments starting with + # /review, /expo-review, @expo-bot review, or @expo-bot check. # @ref LLP 0009#workflow-security-posture [implements] — gate controls who triggers, not what code runs if: >- - github.event.issue.pull_request != null && + github.event_name == 'workflow_dispatch' || + (github.event.issue.pull_request != null && github.event.comment.user.login != 'expo-bot' && (github.event.comment.body == '/review' || startsWith(github.event.comment.body, '/review ') || @@ -39,12 +64,12 @@ jobs: startsWith(github.event.comment.body, '@expo-bot review ') || github.event.comment.body == '@expo-bot check' || startsWith(github.event.comment.body, '@expo-bot check ')) && - contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) runs-on: ubuntu-latest # Job-level so a prose comment that the gate rejects cannot claim this # group at workflow creation and cancel a real review already in flight. concurrency: - group: ai-code-review-cmd-${{ github.event.issue.number }} + group: ai-code-review-cmd-${{ github.event.issue.number || inputs.pr }} cancel-in-progress: true # Bound the run so a slow/stalled review fails fast rather than hanging. Keep it # above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) + @@ -59,7 +84,26 @@ jobs: env: # Via env (never inline ${{ }}) so an untrusted comment can't inject shell. COMMENT: ${{ github.event.comment.body }} + DISPATCH: ${{ github.event_name == 'workflow_dispatch' }} + DISPATCH_AGENTS: ${{ inputs.agents }} run: | + # Manual dispatch: agent selection comes from inputs, with the same + # sanitization as the comment path below. + if [ "$DISPATCH" = "true" ]; then + agents="" + route=false + if [ -z "$DISPATCH_AGENTS" ]; then + route=true + elif [ "$DISPATCH_AGENTS" != "all" ]; then + agents=$(printf '%s' "$DISPATCH_AGENTS" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-') + fi + { + echo "run=true" + echo "agents=$agents" + echo "route=$route" + } >> "$GITHUB_OUTPUT" + exit 0 + fi line=$(printf '%s' "$COMMENT" | head -n1 | tr -d '\r') first=$(printf '%s' "$line" | awk '{print tolower($1)}') second=$(printf '%s' "$line" | awk '{print tolower($2)}') @@ -102,7 +146,8 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Acknowledge - if: steps.cmd.outputs.run == 'true' + # Comment path only — a manual dispatch has no comment to react to. + if: steps.cmd.outputs.run == 'true' && github.event_name == 'issue_comment' env: GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }} run: gh api -X POST "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes @@ -182,7 +227,7 @@ jobs: if: always() && steps.cmd.outputs.run == 'true' && steps.model-env.outcome == 'failure' env: GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }} - PR: ${{ github.event.issue.number }} + PR: ${{ github.event.issue.number || inputs.pr }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | # See the matching step in expo-code-review.yml: pick.sh writes this @@ -214,6 +259,9 @@ jobs: REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }} AGENTS: ${{ steps.cmd.outputs.agents }} ROUTE: ${{ steps.cmd.outputs.route }} + DISPATCH: ${{ github.event_name == 'workflow_dispatch' }} + PR_NUMBER: ${{ inputs.pr }} + CONFIG_FROM_CHECKOUT: ${{ inputs.config-from-checkout }} # NOTE: running via `issue_comment` makes this a manual review command, which # the CLI detects (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass # — it reviews even when the config trigger policy or an `ai-review:skip` label @@ -229,18 +277,39 @@ jobs: elif [ "$ROUTE" = "true" ]; then ARGS=(--route) fi + if [ "$DISPATCH" = "true" ]; then + # A dispatch's event payload has no PR number for the CLI to read, and + # the runner forbids overriding GITHUB_* defaults via step `env:` — so + # export the CLI's documented GITHUB_REF fallback from inside the + # script, where the runner can't overwrite it. + case "$PR_NUMBER" in + ''|*[!0-9]*) echo "invalid pr input: $PR_NUMBER" >&2; exit 1 ;; + esac + export GITHUB_REF="refs/pull/${PR_NUMBER}/merge" + # A dispatch is a manual command like /review, but the CLI only + # infers the trigger-gate bypass from GITHUB_EVENT_NAME=issue_comment, + # so pass --force explicitly. + ARGS+=(--force) + if [ "$CONFIG_FROM_CHECKOUT" = "true" ]; then + # Operator trust decision (absolute path): PRs whose base commit + # predates .expo-code-review/ have no trusted-base config to load, + # so trust this base-ref checkout's config — still never the PR head. + ARGS+=(--config-dir "$GITHUB_WORKSPACE/.expo-code-review") + fi + fi ./scripts/expo-code-review ecr ci "${ARGS[@]}" # Same ephemeral per-run log as the pull_request workflow — a review command # runs the full `ecr ci`, whose .expo-code-review/.runs/ log is gone when the # runner tears down. always() captures it even on error, gated on run=='true' # (an unrelated comment writes no log); issue.number IS the PR number here - # (issue_comment context has no pull_request.number). + # (issue_comment context has no pull_request.number), and inputs.pr covers + # the manual-dispatch path. - name: Upload review run log if: always() && steps.cmd.outputs.run == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: review-run-log-pr${{ github.event.issue.number }} + name: review-run-log-pr${{ github.event.issue.number || inputs.pr }} path: .expo-code-review/.runs/reviews.jsonl if-no-files-found: ignore retention-days: 14 diff --git a/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift b/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift index 60915a40ad4bce..a1a67222d3efb9 100644 --- a/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift +++ b/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift @@ -27,6 +27,15 @@ enum APIError: LocalizedError { } } + // Cancellation happens whenever SwiftUI tears down a refreshable or task modifier; it is + // never worth surfacing to the user. + var isCancellation: Bool { + if case .networkError(let error) = self { + return error is CancellationError || (error as? URLError)?.code == .cancelled + } + return false + } + var isAuthenticationError: Bool { if case .httpError(let statusCode, _) = self, statusCode == 401 { return true diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift b/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift index 1024c47b1b7581..10c814a71e7129 100644 --- a/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift +++ b/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift @@ -39,6 +39,7 @@ struct HomeRootView: View { self.viewModel = viewModel let shouldSkip = DevelopmentServerService.isSimulator || UserDefaults.standard.bool(forKey: DevelopmentServerService.networkPermissionGrantedKey) + || !UserDefaults.standard.bool(forKey: "ExpoGoOnboardingFinished") _hasCompletedPermissionFlow = State(initialValue: shouldSkip) } diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift b/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift index 37bfd44f6a0a3a..178185b2d5da35 100644 --- a/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift +++ b/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift @@ -24,6 +24,8 @@ struct HomeTabView: View { UpgradeWarningView() + NetworkPermissionBanner(serverService: viewModel.serverService) + DevServersSection() if !viewModel.recentlyOpenedApps.isEmpty { diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift b/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift index 34c5b4b6dd8f23..613f838e942a56 100644 --- a/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift +++ b/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift @@ -147,9 +147,14 @@ class HomeViewModel: ObservableObject { func addToRecentlyOpened(url: String, name: String, iconUrl: String? = nil) { let normalizedUrl = normalizeUrl(url) - if let existingIndex = recentlyOpenedApps.firstIndex(where: { + // Update permalinks are unique per published update, so entries for the same app are + // matched by name instead of URL to avoid one row per update. + let isDuplicate: (RecentlyOpenedApp) -> Bool = { normalizeUrl($0.url) == normalizedUrl - }) { + || (isUpdatePermalink($0.url) && isUpdatePermalink(url) && $0.name == name) + } + + if let existingIndex = recentlyOpenedApps.firstIndex(where: isDuplicate) { let existingApp = recentlyOpenedApps[existingIndex] if existingApp.name == name && iconUrl != nil && existingApp.iconUrl == nil { @@ -160,7 +165,7 @@ class HomeViewModel: ObservableObject { return } - recentlyOpenedApps.remove(at: existingIndex) + recentlyOpenedApps.removeAll(where: isDuplicate) } let newApp = RecentlyOpenedApp( diff --git a/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift b/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift index 73060250245d25..9b0572cd9a29c0 100644 --- a/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift +++ b/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift @@ -26,6 +26,8 @@ class DevelopmentServerService: ObservableObject { private var sessionSecret: String? private var remoteRefreshTask: Task? private var browser: NWBrowser? + private var probeListener: NWListener? + private let probeServiceName = "expo-go-permission-probe" private var pingTask: Task? private var isFetchingRemote = false @@ -59,7 +61,20 @@ class DevelopmentServerService: ObservableObject { permissionStatus = .granted } + func markNetworkPermissionDenied() { + UserDefaults.standard.set(false, forKey: Self.networkPermissionGrantedKey) + permissionStatus = .denied + } + func checkLocalNetworkAccess() async -> Bool { + let granted = await probeLocalNetworkAccess() + if granted { + markNetworkPermissionGranted() + } + return granted + } + + private func probeLocalNetworkAccess() async -> Bool { let serviceType = bonjourType let queue = DispatchQueue(label: "expo.go.permissioncheck") @@ -326,11 +341,11 @@ class DevelopmentServerService: ObservableObject { switch state { case .waiting(let error): if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied { - self.permissionStatus = .denied + self.markNetworkPermissionDenied() } case .failed(let error): if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied { - self.permissionStatus = .denied + self.markNetworkPermissionDenied() } default: break @@ -342,28 +357,48 @@ class DevelopmentServerService: ObservableObject { guard let self else { return } Task { @MainActor [weak self, results] in guard let self else { return } + // Results only flow once the permission is truly granted, so this is the reliable signal. self.markNetworkPermissionGranted() + self.probeListener?.cancel() + self.probeListener = nil self.pingTask?.cancel() self.pingTask = Task { defer { self.pingTask = nil } - await self.pingDiscoveryResults(results.map { result in - DiscoveryResult( - name: NetworkUtilities.getNWBrowserResultName(result), - endpoint: result.endpoint - ) + await self.pingDiscoveryResults(results.compactMap { result in + let name = NetworkUtilities.getNWBrowserResultName(result) + if name?.hasPrefix(self.probeServiceName) == true { + return nil + } + return DiscoveryResult(name: name, endpoint: result.endpoint) }) } } } + startProbeListener() browser?.start(queue: DispatchQueue(label: "expo.go.bonjour.discovery")) } + // Advertise our own service so a granted permission always produces at least one browse + // result, even when no dev servers are running. + private func startProbeListener() { + guard let listener = try? NWListener(using: .tcp, on: .any) else { + return + } + listener.service = NWListener.Service(name: probeServiceName, type: bonjourType) + listener.stateUpdateHandler = { _ in } + listener.newConnectionHandler = { $0.cancel() } + listener.start(queue: DispatchQueue(label: "expo.go.bonjour.probe")) + probeListener = listener + } + private func stopBonjourBrowser() { pingTask?.cancel() browser?.cancel() + probeListener?.cancel() pingTask = nil browser = nil + probeListener = nil } private func pingDiscoveryResults(_ results: [DiscoveryResult]) async { diff --git a/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift b/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift index 0eac35a7e3ccaa..3f69addeac5368 100644 --- a/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift +++ b/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift @@ -23,6 +23,13 @@ func normalizeUrl(_ url: String) -> String { return components.joined() } +func isUpdatePermalink(_ url: String) -> Bool { + guard let components = URLComponents(string: url) else { + return false + } + return components.host == "u.expo.dev" && components.path.hasPrefix("/update/") +} + func sanitizeUrlString(_ urlString: String) -> String? { var sanitizedUrl = urlString.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift index 1adf4a406ed69e..ba9b9bafb1ecbb 100644 --- a/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift +++ b/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift @@ -171,6 +171,9 @@ class BranchDetailsViewModel: ObservableObject { branch = response.data.app.byId.updateBranchByName hasLoadedRemote = true } catch { + if (error as? APIError)?.isCancellation == true { + return + } self.error = error } } diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift b/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift new file mode 100644 index 00000000000000..77177ddea5d1ab --- /dev/null +++ b/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift @@ -0,0 +1,52 @@ +// Copyright © 2025 650 Industries. All rights reserved. + +import SwiftUI + +struct NetworkPermissionBanner: View { + @ObservedObject var serverService: DevelopmentServerService + @State private var showingPermissionFlow = false + + var body: some View { + Group { + // `showingPermissionFlow` keeps the banner alive while its sheet is up: the grant is detected the + // moment the system prompt appears, and hiding the banner then would tear the sheet down with it. + if showingPermissionFlow + || (!DevelopmentServerService.isSimulator + && !serverService.hasGrantedNetworkPermission + && serverService.permissionStatus != .granted) { + Button { + showingPermissionFlow = true + } label: { + HStack { + Image(systemName: "wifi.exclamationmark") + .font(.title2) + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 4) { + Text("Local Network Access Needed") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text("Projects running on your computer can't be discovered. Tap to enable access.") + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + .padding() + } + .buttonStyle(PlainButtonStyle()) + .background(Color.expoSecondarySystemBackground) + .cornerRadius(18) + } + } + .sheet(isPresented: $showingPermissionFlow) { + LocalNetworkPermissionView(serverService: serverService) { + serverService.startDiscovery() + showingPermissionFlow = false + } + } + } +} diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift index b3fed8cc83019c..e4d82bfb3914d1 100644 --- a/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift +++ b/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift @@ -93,7 +93,6 @@ class ProjectsListViewModel: ObservableObject { func refresh() async { currentOffset = 0 - projects = [] await fetchProjects() } @@ -129,6 +128,9 @@ class ProjectsListViewModel: ObservableObject { hasMore = projects.count < totalCount } catch { + if (error as? APIError)?.isCancellation == true { + return + } self.error = error self.showingError = true } diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift index 8979f52ee577d0..25bb4f4644e6cd 100644 --- a/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift +++ b/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift @@ -92,7 +92,6 @@ class SnacksListViewModel: ObservableObject { func refresh() async { currentOffset = 0 - snacks = [] await fetchSnacks() } @@ -126,6 +125,9 @@ class SnacksListViewModel: ObservableObject { hasMore = newSnacks.count >= pageSize } catch { + if (error as? APIError)?.isCancellation == true { + return + } self.error = error self.showingError = true } diff --git a/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m b/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m index 645c7ee756fc4c..5af88838b35c43 100644 --- a/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m +++ b/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m @@ -33,6 +33,9 @@ - (void)openUrl:(NSString *)urlString isUniversalLink:(BOOL)isUniversalLink DDLogInfo(@"Tried to route invalid url: %@", urlString); return; } + // An external link means the user already has a project to open, so never gate them behind onboarding. + [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"ExpoGoOnboardingFinished"]; + EXKernelAppRegistry *appRegistry = [EXKernel sharedInstance].appRegistry; EXKernelAppRecord *destinationApp = nil; NSURL *urlToRoute = [[self class] uriTransformedForLinking:url isUniversalLink:isUniversalLink]; diff --git a/apps/native-component-list/src/screens/AgeRangeScreen.tsx b/apps/native-component-list/src/screens/AgeRangeScreen.tsx index bed2e58699b05b..1197a6151f856b 100644 --- a/apps/native-component-list/src/screens/AgeRangeScreen.tsx +++ b/apps/native-component-list/src/screens/AgeRangeScreen.tsx @@ -8,6 +8,29 @@ import HeadingText from '../components/HeadingText'; import MonoText from '../components/MonoText'; import Colors from '../constants/Colors'; +const FAKE_SIGNALS: Record = { + 'supervised 13 to 15 year old': { + ageSignalsStatus: 'SHARED', + lowerBound: 13, + upperBound: 15, + ageRangeSource: 'TIER_B', + significantChangeStatus: 'PENDING', + }, + adult: { + ageSignalsStatus: 'SHARED', + lowerBound: 18, + ageRangeSource: 'TIER_D', + }, + 'signals not shared': { + ageSignalsStatus: 'NOT_SHARED', + }, + // -4 is PLAY_SERVICES_NOT_FOUND. See + // https://developer.android.com/google/play/age-signals/handle-errors + 'error code -4': { + errorCode: -4, + }, +}; + export default function AgeRangeScreen() { const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -121,10 +144,36 @@ export default function AgeRangeScreen() { } }; + const applyFakeSignals = (name: string | null) => { + setError(null); + setResult(null); + + try { + AgeRange.setFakeAgeSignals(name === null ? null : FAKE_SIGNALS[name]); + } catch (err: any) { + setError(err.message || 'Unknown error occurred'); + Alert.alert('Error', err.message || 'Unknown error occurred'); + } + }; + return ( Age Range API + {result && ( + + Result: + {result} + + )} + + {error && ( + + Error: + {error} + + )} + Request the user's age range with directly configurable (iOS) thresholds. This example uses thresholds at 13, 16, and 18 years old. @@ -164,19 +213,26 @@ export default function AgeRangeScreen() { style={styles.button} /> - {result && ( - - Result: - {result} - - )} + Fake age signals (Android) - {error && ( - - Error: - {error} - - )} + + Play only reports age signals to accounts it has enabled, so pick a fake below to test the + buttons above against another age range. The requests do not change, only what they report. + + + {Object.keys(FAKE_SIGNALS).map((name) => ( +