Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .pr_agent.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
[config]
# https://openrouter.ai/openrouter/free, reached through LiteLLM's openrouter/
# prefix, so the id doubles up: openrouter/ + openrouter/free.
#
# This is a router over whatever is free right now, not a fixed model, which is
# the point. Pinned free ids rotate out: the two this file was first written
# against (qwen-2.5-coder-32b, deepseek-chat-v3) were already gone by the time
# it landed. The router keeps resolving without anyone editing this line.
model = "openrouter/openrouter/free"
# The router alias isn't in PR-Agent's built-in MAX_TOKENS table, so without
# this it refuses to call the model at all ("not defined in MAX_TOKENS and no
# custom_model_max_tokens is set"). 32000 matches this repo's PR sizes with
# room to spare; found by actually running a review and reading the failure,
# not from the docs.
custom_model_max_tokens = 32000
# PR-Agent's own default fallback is gpt-5.6-terra over a direct OpenAI call,
# which always fails here (no OPENAI_KEY secret, so it authenticates with a
# literal "dummy_key"). Fall back to the same OpenRouter router instead, since
# a second call may land on a different backing model than the first.
fallback_models = ["openrouter/openrouter/free"]

enable_auto_review = true
enable_auto_describe = false
# Off deliberately: free models are rate limited to roughly 20 requests a minute,
# and code suggestions are the request-hungry tool. Ask for them per PR with
# /improve when a change actually wants them.
enable_auto_improve = false

ignore_bot_prs = true
require_ready_for_review = true

[pr_reviewer]
# The repo's own rules, so the review flags what a human reviewer here would.
extra_instructions = """
This is a Flutter app (Dart) for an FRC team's pit inventory and scouting.
When reviewing:
- No emojis anywhere: code, comments, docs, commit messages, UI strings.
Material Icons glyphs are the way to draw an affordance.
- No AI co-author trailers in commits.
- A top-level dart:ffi import reachable from main.dart breaks the wasm build,
which CI gates. Platform-specific code belongs behind a conditional import,
and the right condition for FFI is dart.library.ffi, not dart.library.io.
- Colors, radii and fonts come from PitPalette and app_theme.dart per
DESIGN.md: 8px corners, 12px for sheets and dialogs, depth as one tonal step
plus a 1px Outline border rather than a shadow, violet accent for action and
selection only, a status hue always paired with a label. Flag hardcoded hex
colors, radii and font families.
- A paragraph-long comment justifying a workaround means the code is wrong.
"""

[github_action_config]
auto_review = true
auto_describe = false
auto_improve = false

[ignore]
glob = [
'pubspec.lock',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'**/*.g.dart',
'**/*.freezed.dart',
'**/generated_plugin_registrant.*',
'**/GeneratedPluginRegistrant.*',
'build/**',
'ios/Pods/**',
'macos/Pods/**',
'**/*.min.js',
'**/*.svg',
'**/*.lock',
]
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ FRC pit logistics for Spectrum 3847. One app that keeps the team running at an e

Built with Flutter for iOS, Android, and desktop (Windows, macOS, Linux).

## Install

Builds are attached to [this repo's releases](https://github.com/Spectrum3847/spectrum-pit/releases). They are unsigned, so each platform needs a step or two.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Publish integrity metadata for unsigned artifacts.

Lines 13, 28, and 32-36 tell users to install unsigned files or bypass SmartScreen and Gatekeeper. The installation flow provides no checksum or other artifact-integrity check.

Publish a release-generated SHA-256 manifest and add a verification step before asking users to override platform warnings.

Also applies to: 28-28, 32-36

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 13, Update the release artifact documentation around the
release links and platform installation instructions to publish a
release-generated SHA-256 manifest and direct users to verify downloaded files
against it before bypassing SmartScreen, Gatekeeper, or other unsigned-artifact
warnings.


### iOS (AltStore, SideStore, LiveContainer)

Open Sources, add a source, and paste one of these URLs:

- Stable: `https://spectrumpit-stable.web.app/stable.json`
- Nightly: `https://spectrumpit-nightly.web.app/nightly.json`

Spectrum Pit then shows up as an app you can install, and later builds arrive as updates. The installer re-signs the IPA on device with a free Apple ID, so no paid developer account is needed. Free signing expires after 7 days, so let AltStore or SideStore refresh weekly. SideStore can refresh on device, without a computer.

The stable source tracks releases; the nightly source rebuilds every night from the latest source.

### Android

Download the APK from a release and install it. Android will ask you to allow installs from this source the first time.

### Desktop

Each release carries a Linux AppImage, a Windows ZIP, and a zipped macOS `.app`. None are code-signed:

- Linux: `chmod +x` the AppImage and run it.
- Windows: unzip, then choose "More info" then "Run anyway" at the SmartScreen prompt.
- macOS: unzip, then right-click the app and choose Open, since Gatekeeper blocks a double-click on an unsigned app.

## About this repository

This is the public mirror of Spectrum Pit. The team develops in a private repository; each published release is synced here as a single squashed commit, so this repo always holds the source of the latest release without internal history.
Expand Down
10 changes: 10 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import 'src/services/desktop_map_diagram_sync_service.dart';
import 'src/services/desktop_map_location_sync_service.dart';
import 'src/services/desktop_packing_sync_service.dart';
import 'src/services/desktop_pit_shift_sync_service.dart';
import 'src/services/desktop_firestore_cache_stub.dart'
if (dart.library.io) 'src/services/desktop_firestore_cache_io.dart'
as firestore_cache_factory;
import 'src/services/desktop_user_role_service.dart';
import 'src/services/inventory_sync_service.dart';
import 'src/services/local_only_services.dart';
Expand Down Expand Up @@ -95,12 +98,19 @@ Future<void> main() async {
firebaseApiKey: DefaultFirebaseOptions.web.apiKey,
launch: (url) => launchUrl(url, mode: LaunchMode.externalApplication),
);

final restFirestore = fc.Firestore(
projectId: DefaultFirebaseOptions.web.projectId,
idTokenProvider: desktopAuth.idToken,

httpClient: TimeoutHttpClient(),
cache: await firestore_cache_factory.createDesktopFirestoreCache(
() => desktopAuth.currentUser?.uid,
),
);

desktopAuth.onSessionEnded =
firestore_cache_factory.clearDesktopFirestoreCacheFor;
authService = desktopAuth;
roleService = DesktopUserRoleService(firestore: restFirestore);
inventorySyncService = DesktopInventorySyncService(
Expand Down
114 changes: 102 additions & 12 deletions lib/src/services/desktop_auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';

import 'package:firestore_client/firestore_client.dart' as fc;
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

import 'spectrum_auth_service.dart';
Expand All @@ -29,6 +30,8 @@ class DesktopAuthService implements SpectrumAuthService {

static const String _prefsKey = 'desktop_auth_session_v1';

Future<void> Function(String uid)? onSessionEnded;

final String clientId;
final String firebaseApiKey;

Expand All @@ -38,6 +41,14 @@ class DesktopAuthService implements SpectrumAuthService {
final Future<SharedPreferences> Function() _prefsLoader;
late final Future<fc.GoogleTokens> Function() _signInFlow;

StreamSubscription<fc.FirebaseUser?>? _authStateSub;

Future<void>? _listenerSetup;

bool _endingSession = false;

Future<void>? _teardown;

final StreamController<SpectrumAuthSnapshot> _controller =
StreamController<SpectrumAuthSnapshot>.broadcast();
SpectrumAuthSnapshot _snapshot = const SpectrumAuthSnapshot(
Expand All @@ -54,18 +65,59 @@ class DesktopAuthService implements SpectrumAuthService {
SpectrumUser? get currentUser => _snapshot.user;

@override
Future<String?> idToken() => _session.getIdToken();
Future<String?> idToken() async {
try {
return await _session.getIdToken();
} on SocketException {
return null;
} on TimeoutException {
return null;
} on http.ClientException {
return null;
} on HttpException {
return null;
}
}

Future<void> _ensureAuthStateListener() {
final previous = _listenerSetup;
final next = () async {
await previous;
await _authStateSub?.cancel();
_authStateSub = _session.authStateChanges.listen((user) {
if (user == null) unawaited(_handleSessionRevoked());
});
}();
_listenerSetup = next;
return next;
}

@override
Future<void> initialize() async {
SharedPreferences? prefs;
await _ensureAuthStateListener();
try {
prefs = await _prefsLoader();
final prefs = await _prefsLoader();
final stored = prefs.getString(_prefsKey);
if (stored == null) return;
final user = await _session.restore(
(jsonDecode(stored) as Map).cast<String, dynamic>(),
);
final Map<String, dynamic> payload;
try {
payload = (jsonDecode(stored) as Map).cast<String, dynamic>();
} catch (_) {
await prefs.remove(_prefsKey);
return;
}

final uid = payload['uid'];
final refreshToken = payload['refreshToken'];
if (uid is! String ||
uid.isEmpty ||
refreshToken is! String ||
refreshToken.isEmpty) {
await prefs.remove(_prefsKey);
if (uid is String && uid.isNotEmpty) await _endSession(uid);
return;
}
final user = await _session.restore(payload);
if (user != null) {
_emit(
SpectrumAuthSnapshot(
Expand All @@ -75,16 +127,17 @@ class DesktopAuthService implements SpectrumAuthService {
);
} else {
await prefs.remove(_prefsKey);
final revokedUid = payload['uid'];
if (revokedUid is String && revokedUid.isNotEmpty) {
await _endSession(revokedUid);
}
}
} catch (_) {
try {
await prefs?.remove(_prefsKey);
} catch (_) {}
}
} catch (_) {}
}

@override
Future<void> signIn() async {
await _teardown;
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signingIn));
try {
final tokens = await _signInFlow();
Expand All @@ -110,16 +163,53 @@ class DesktopAuthService implements SpectrumAuthService {

@override
Future<void> signOut() async {
_endingSession = true;
final departingUid = currentUser?.uid;
await _session.signOut();
await _runTeardown(departingUid);
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
}
Comment on lines 164 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

signOut() leaves the service permanently stuck if _session.signOut() throws.

Line 166 sets _endingSession = true. Line 168 then awaits _session.signOut(). _endingSession is only reset in the finally inside _runTeardown at line 188. If line 168 throws, line 169 never runs, so the flag is never reset.

Two consequences follow:

  1. _handleSessionRevoked returns early forever at line 174. Mid-session revocation is no longer detected for the life of the service.
  2. No signedOut snapshot is emitted, and the stored session is never removed. The UI still shows the user as signed in, and the departing user's cached documents stay on disk.

_session.signOut() performs a network call. A sign-out attempted with no network is a realistic trigger. This is the offline path the PR targets.

🐛 Proposed fix
   `@override`
   Future<void> signOut() async {
     _endingSession = true;
     final departingUid = currentUser?.uid;
-    await _session.signOut();
+    try {
+      await _session.signOut();
+    } catch (_) {
+      // The local session still has to be torn down when the revoke call
+      // cannot reach the network.
+    }
     await _runTeardown(departingUid);
     _emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
   }

Add a test that makes _session.signOut() throw a SocketException and asserts that the state becomes signedOut, that onSessionEnded fires, and that the persisted key is removed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@override
Future<void> signOut() async {
_endingSession = true;
final departingUid = currentUser?.uid;
await _session.signOut();
await _runTeardown(departingUid);
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
}
`@override`
Future<void> signOut() async {
_endingSession = true;
final departingUid = currentUser?.uid;
try {
await _session.signOut();
} catch (_) {
// The local session still has to be torn down when the revoke call
// cannot reach the network.
}
await _runTeardown(departingUid);
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/services/desktop_auth_service.dart` around lines 164 - 171, Update
signOut() to guarantee teardown runs even when _session.signOut() throws, using
the existing _runTeardown(departingUid) flow so _endingSession is reset,
persisted session data is removed, and signedOut is emitted. Add a test covering
a SocketException from _session.signOut() that verifies the signed-out state,
onSessionEnded callback, and persisted-key removal.


Future<void> _handleSessionRevoked() async {
if (_endingSession) return;
if (_snapshot.state != SpectrumAuthState.signedIn) return;
final departingUid = currentUser?.uid;
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
await _runTeardown(departingUid);
}

Future<void> _runTeardown(String? uid) {
_endingSession = true;
final done = () async {
try {
await _forgetStoredSession();
if (uid != null) await _endSession(uid);
} finally {
_endingSession = false;
}
}();
_teardown = done;
return done;
}

Future<void> _forgetStoredSession() async {
try {
final prefs = await _prefsLoader();
await prefs.remove(_prefsKey);
} catch (_) {}
_emit(const SpectrumAuthSnapshot(state: SpectrumAuthState.signedOut));
}

Future<void> _endSession(String uid) async {
try {
await onSessionEnded?.call(uid);
} catch (_) {}
}

@override
Future<void> dispose() async {
await _listenerSetup;
await _authStateSub?.cancel();
_authStateSub = null;
await _controller.close();
_session.close();
}
Expand Down
32 changes: 32 additions & 0 deletions lib/src/services/desktop_firestore_cache_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import 'dart:io';

import 'package:firestore_client/firestore_client.dart' as fc;
import 'package:path_provider/path_provider.dart';

import 'user_scoped_firestore_cache.dart';

Future<fc.FirestoreCache?> createDesktopFirestoreCache(
String? Function() currentUid,
) async {
final root = await _cacheRoot();
if (root == null) return null;
return UserScopedFirestoreCache(root: root, currentUid: currentUid);
}

Future<void> clearDesktopFirestoreCacheFor(String uid) async {
final root = await _cacheRoot();
if (root == null) return;
await UserScopedFirestoreCache(
root: root,
currentUid: () => uid,
).clearForUid(uid);
}

Future<Directory?> _cacheRoot() async {
try {
final support = await getApplicationSupportDirectory();
return Directory('${support.path}${Platform.pathSeparator}firestore_cache');
} on Exception {
return null;
}
}
7 changes: 7 additions & 0 deletions lib/src/services/desktop_firestore_cache_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:firestore_client/firestore_client.dart' as fc;

Future<fc.FirestoreCache?> createDesktopFirestoreCache(
String? Function() currentUid,
) async => null;

Future<void> clearDesktopFirestoreCacheFor(String uid) async {}
10 changes: 6 additions & 4 deletions lib/src/services/desktop_launcher_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ class DesktopLauncherService {
}

static String desktopEntry(String appImagePath, {String? iconPath}) {
final exec = appImagePath.replaceAllMapped(
RegExp(r'["`$\\]'),
(m) => '\\${m[0]}',
);
final exec = appImagePath.replaceAllMapped(RegExp(r'[%"`$\\]'), (m) {
final char = m[0]!;
if (char == '%') return '%%';
if (char == r'\') return r'\\\\';
return '\\\\$char';
});
final icon = (iconPath ?? 'spectrumpit')
.replaceAll('\\', r'\\')
.replaceAll('\n', r'\n');
Expand Down
Loading