Skip to content

Commit c09bd15

Browse files
committed
hop
1 parent c23213b commit c09bd15

9 files changed

Lines changed: 116 additions & 38 deletions

File tree

‎dist/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ <h3>Why Oncle Bob?</h3>
8484
</ul>
8585
</section>
8686
<p>
87-
<strong>Currently in Early Access (v0.0.2).</strong><br />
87+
<strong>Currently in Early Access (v0.0.3).</strong><br />
8888
Hosting is provided by <a href="https://xmit.co">xmit.co</a>.
8989
Open to supporting alternatives!
9090
<br />

‎installer.iss‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
; Non-commercial use only
44

55
#define MyAppName "Oncle Bob"
6-
#define MyAppVersion "0.0.2"
6+
#define MyAppVersion "0.0.3"
77
#define MyAppPublisher "xmit.dev"
88
#define MyAppURL "https://xmit.dev/"
99
#define MyAppExeName "bob.exe"

‎lib/main.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import 'providers/project_provider.dart';
66
import 'screens/home_screen.dart';
77
import 'services/preferences_service.dart';
88

9-
const String appVersion = '0.0.2';
9+
const String appVersion = '0.0.3';
1010

1111
// Global reference for cleanup on app exit
1212
ProjectProvider? _projectProvider;

‎lib/services/key_request_service.dart‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import 'package:cbor/cbor.dart';
77
/// Implements the key request protocol defined in xmitd/xhttpd/cli.go
88
class KeyRequestService {
99
final http.Client _httpClient = http.Client();
10+
http.Client? _pollClient;
1011
static const Duration _httpTimeout = Duration(seconds: 30);
1112
static const Duration _pollTimeout = Duration(seconds: 90);
13+
bool _cancelled = false;
1214

1315
/// Normalize service domain to full URL with https://
1416
String _normalizeServiceUrl(String service) {
@@ -75,6 +77,7 @@ class KeyRequestService {
7577
final browserUrl = (decoded[CborSmallInt(5)] as CborString?)?.toString() ?? '';
7678
final pollUrl = (decoded[CborSmallInt(6)] as CborString?)?.toString() ?? '';
7779
final secret = (decoded[CborSmallInt(7)] as CborString?)?.toString() ?? '';
80+
final requestId = (decoded[CborSmallInt(8)] as CborString?)?.toString() ?? '';
7881

7982
if (browserUrl.isEmpty || pollUrl.isEmpty || secret.isEmpty) {
8083
throw Exception('Invalid response: missing required fields');
@@ -85,6 +88,7 @@ class KeyRequestService {
8588
pollUrl: pollUrl,
8689
secret: secret,
8790
baseUrl: url,
91+
requestId: requestId,
8892
);
8993
} catch (e) {
9094
throw Exception('Failed to request key: $e');
@@ -98,9 +102,12 @@ class KeyRequestService {
98102
}) async {
99103
final pollUrl = '${keyRequest.baseUrl}${keyRequest.pollUrl}?secret=${Uri.encodeComponent(keyRequest.secret)}';
100104

105+
// Use dedicated poll client so it can be cancelled
106+
_pollClient = http.Client();
107+
101108
try {
102109
// Make long-polling request (server will wait up to 90s)
103-
final response = await _httpClient
110+
final response = await _pollClient!
104111
.get(Uri.parse(pollUrl))
105112
.timeout(_pollTimeout);
106113

@@ -117,40 +124,65 @@ class KeyRequestService {
117124
throw Exception('Failed to get key: ${response.statusCode}');
118125
}
119126
} catch (e) {
127+
if (_cancelled) {
128+
throw Exception('Request cancelled');
129+
}
120130
if (e is TimeoutException) {
121131
throw Exception('Poll timeout - user may not have approved yet');
122132
}
123133
rethrow;
134+
} finally {
135+
_pollClient = null;
124136
}
125137
}
126138

139+
/// Cancel any ongoing key request polling
140+
void cancelRequest() {
141+
_cancelled = true;
142+
_pollClient?.close();
143+
_pollClient = null;
144+
}
145+
127146
/// Request and wait for API key with polling
128147
/// This is a convenience method that combines requestKey and awaitKey
129-
/// The onPollStart callback is called with the browser URL before polling starts
148+
/// The onPollStart callback is called with the browser URL and request ID before polling starts
130149
Future<String> requestAndAwaitKey({
131150
required String serviceUrl,
132151
String? applicationName,
133-
required void Function(String browserUrl) onPollStart,
152+
required void Function(String browserUrl, String requestId) onPollStart,
134153
Duration? pollInterval,
135154
}) async {
155+
_cancelled = false;
156+
136157
// Request key
137158
final keyRequest = await requestKey(
138159
serviceUrl: serviceUrl,
139160
applicationName: applicationName,
140161
);
141162

163+
if (_cancelled) {
164+
throw Exception('Request cancelled');
165+
}
166+
142167
// Notify caller to open browser
143-
onPollStart(keyRequest.browserUrl);
168+
onPollStart(keyRequest.browserUrl, keyRequest.requestId);
144169

145170
// Poll with retries (server uses long-polling, so we retry on timeout)
146171
final interval = pollInterval ?? const Duration(seconds: 2);
147172
final maxAttempts = 30; // 30 attempts * 90s each = up to 45 minutes total
148173

149174
for (var attempt = 0; attempt < maxAttempts; attempt++) {
175+
if (_cancelled) {
176+
throw Exception('Request cancelled');
177+
}
178+
150179
try {
151180
final apiKey = await awaitKey(keyRequest: keyRequest);
152181
return apiKey;
153182
} catch (e) {
183+
if (_cancelled) {
184+
throw Exception('Request cancelled');
185+
}
154186
// If timeout, retry after interval
155187
if (e.toString().contains('timeout') && attempt < maxAttempts - 1) {
156188
await Future.delayed(interval);
@@ -188,11 +220,13 @@ class KeyRequestResponse {
188220
final String pollUrl;
189221
final String secret;
190222
final String baseUrl;
223+
final String requestId;
191224

192225
KeyRequestResponse({
193226
required this.browserUrl,
194227
required this.pollUrl,
195228
required this.secret,
196229
required this.baseUrl,
230+
required this.requestId,
197231
});
198232
}

‎lib/widgets/settings_form.dart‎

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -146,43 +146,79 @@ class _SettingsFormState extends State<SettingsForm> {
146146
_requestingKeys[service] = true;
147147
});
148148

149+
bool cancelled = false;
150+
149151
try {
150152
// Request and await key with browser opening
151153
final apiKey = await _keyRequestService.requestAndAwaitKey(
152154
serviceUrl: service,
153155
applicationName: 'Oncle Bob',
154-
onPollStart: (browserUrl) {
156+
onPollStart: (browserUrl, requestId) {
155157
// Copy browser URL to clipboard
156158
Clipboard.setData(ClipboardData(text: browserUrl));
157159

158160
// Show dialog with instructions
159161
if (mounted) {
160162
showDialog(
161163
context: context,
162-
barrierDismissible: false,
163-
builder: (context) => AlertDialog(
164-
title: const Text('Approve Key Request'),
165-
content: Column(
166-
mainAxisSize: MainAxisSize.min,
167-
crossAxisAlignment: CrossAxisAlignment.start,
168-
children: [
169-
const Text('Opening your browser to approve the API key request...'),
170-
const SizedBox(height: 16),
171-
const Text('If the browser doesn\'t open automatically, visit:'),
172-
const SizedBox(height: 8),
173-
SelectableText(
174-
browserUrl,
175-
style: const TextStyle(
176-
fontFamily: 'monospace',
177-
fontSize: 12,
164+
barrierDismissible: true,
165+
builder: (context) => PopScope(
166+
canPop: true,
167+
onPopInvokedWithResult: (didPop, result) {
168+
if (didPop) {
169+
cancelled = true;
170+
_keyRequestService.cancelRequest();
171+
}
172+
},
173+
child: AlertDialog(
174+
title: const Text('Approve Key Request'),
175+
content: Column(
176+
mainAxisSize: MainAxisSize.min,
177+
crossAxisAlignment: CrossAxisAlignment.start,
178+
children: [
179+
const Text('Opening your browser to approve the API key request...'),
180+
if (requestId.isNotEmpty) ...[
181+
const SizedBox(height: 16),
182+
Row(
183+
children: [
184+
const Text('Request ID: '),
185+
SelectableText(
186+
requestId,
187+
style: const TextStyle(
188+
fontFamily: 'monospace',
189+
fontWeight: FontWeight.bold,
190+
),
191+
),
192+
],
193+
),
194+
],
195+
const SizedBox(height: 16),
196+
const Text('If the browser doesn\'t open automatically, visit:'),
197+
const SizedBox(height: 8),
198+
SelectableText(
199+
browserUrl,
200+
style: const TextStyle(
201+
fontFamily: 'monospace',
202+
fontSize: 12,
203+
),
178204
),
205+
const SizedBox(height: 8),
206+
const Text('(URL copied to clipboard)'),
207+
const SizedBox(height: 16),
208+
const Text('Waiting for approval...'),
209+
const SizedBox(height: 16),
210+
const Center(child: CircularProgressIndicator()),
211+
],
212+
),
213+
actions: [
214+
TextButton(
215+
onPressed: () {
216+
cancelled = true;
217+
_keyRequestService.cancelRequest();
218+
Navigator.of(context).pop();
219+
},
220+
child: const Text('Cancel'),
179221
),
180-
const SizedBox(height: 8),
181-
const Text('(URL copied to clipboard)'),
182-
const SizedBox(height: 16),
183-
const Text('Waiting for approval...'),
184-
const SizedBox(height: 16),
185-
const Center(child: CircularProgressIndicator()),
186222
],
187223
),
188224
),
@@ -197,8 +233,16 @@ class _SettingsFormState extends State<SettingsForm> {
197233
},
198234
);
199235

236+
// Check if user cancelled while waiting
237+
if (cancelled) {
238+
setState(() {
239+
_requestingKeys.remove(service);
240+
});
241+
return;
242+
}
243+
200244
// Close the waiting dialog
201-
if (mounted) {
245+
if (mounted && Navigator.of(context).canPop()) {
202246
Navigator.of(context).pop();
203247
}
204248

@@ -227,8 +271,8 @@ class _SettingsFormState extends State<SettingsForm> {
227271
_requestingKeys.remove(service);
228272
});
229273

230-
// Show error message
231-
if (mounted) {
274+
// Show error message only if not cancelled
275+
if (mounted && !cancelled) {
232276
ScaffoldMessenger.of(context).showSnackBar(
233277
SnackBar(
234278
content: Text(e.toString().replaceFirst('Exception: ', '')),

‎linux/dev.xmit.bob.metainfo.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<binary>bob</binary>
1616
</provides>
1717
<releases>
18-
<release version="0.0.2" date="2024-11-18"/>
18+
<release version="0.0.3" date="2024-11-18"/>
1919
</releases>
2020
<content_rating type="oars-1.1"/>
2121
</component>

‎linux/runner/my_application.cc‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ static void my_application_activate(GApplication* application) {
4646
if (use_header_bar) {
4747
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
4848
gtk_widget_show(GTK_WIDGET(header_bar));
49-
gtk_header_bar_set_title(header_bar, "bob 0.0.2");
49+
gtk_header_bar_set_title(header_bar, "Oncle Bob 0.0.3");
5050
gtk_header_bar_set_show_close_button(header_bar, TRUE);
5151
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
5252
} else {
53-
gtk_window_set_title(window, "bob 0.0.2");
53+
gtk_window_set_title(window, "Oncle Bob 0.0.3");
5454
}
5555

5656
// Minimum width: leftMinPaneWidth (300) + separator (8) + rightMinPaneWidth (600) = 908

‎pubspec.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: bob
22
description: "Oncle Bob builds and launches web apps."
33
publish_to: 'none'
4-
version: 0.0.2
4+
version: 0.0.3
55

66
environment:
77
sdk: ^3.9.2

‎windows/runner/main.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
2727
FlutterWindow window(project);
2828
Win32Window::Point origin(10, 10);
2929
Win32Window::Size size(908, 620);
30-
if (!window.Create(L"Oncle Bob 0.0.2", origin, size)) {
30+
if (!window.Create(L"Oncle Bob 0.0.3", origin, size)) {
3131
return EXIT_FAILURE;
3232
}
3333
window.SetQuitOnClose(true);

0 commit comments

Comments
 (0)