Loading files
8035964e1This file is pinned to the selected release. Download the original to open it in your design tool.
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../auth/auth_session.dart';
import '../core/config.dart';
import '../core/models.dart';
import '../logging/http_log.dart';
import 'access_token_source.dart';
import 'bearer_auth_interceptor.dart';
import 'multipart_body.dart';
/// Control-plane REST client.
///
/// In a desktop build every call goes to the LOCAL `harness` CLI (loopback, no credential — see
/// CLAUDE.md's naming/architecture notes for why), which proxies to the real backend using its own
/// saved SSO session, and this app never holds a bearer token itself. A viewer build has no CLI:
/// given [auth], the same calls go straight to the backend, signed with the session the app holds.
/// Terminal bytes ride the WS path either way.
class ApiClient {
final AppConfig config;
final AuthSession session;
final AccessTokenSource? auth;
late final Dio _dio = _buildDio();
ApiClient({required this.config, required this.session, this.auth});
Dio _buildDio() {
final dio = attachHttpLog(
Dio(
BaseOptions(
baseUrl: auth == null ? config.localCliBaseUrl : config.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
// Let the API wrapper turn HTTP failures into short, user-facing
// ApiExceptions. Transport failures still surface as DioExceptions.
validateStatus: (status) =>
status != null && status >= 200 && status < 600,
),
),
);
final source = auth;
if (source != null) {
dio.interceptors.add(
BearerAuthInterceptor(source, dio, autonomousEnv: config.autonomousEnv),
);
}
return dio;
}
// -- auth (proxied by the local CLI — no credential on this leg) --
Future<Map<String, dynamic>?> me() async {
final res = await _dio.get('/api/auth/me');
return unwrapApiResponse(res) as Map<String, dynamic>?;
}
// -- machines (control plane, proxied by the local CLI) --
Future<List<Machine>> machines() async {
final res = await _dio.get('/api/machines');
final data = unwrapApiResponse(res) as Map<String, dynamic>;
final list = data['machines'] as List<dynamic>? ?? [];
return list
.map((e) => Machine.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<String?> renameMachine({
required String machineId,
required String name,
}) async {
final res = await _dio.patch(
'/api/machines/$machineId',
data: {'name': name},
options: Options(headers: {'x-adapter-local': '1'}),
);
final data = unwrapApiResponse(res) as Map<String, dynamic>;
return data['name'] as String?;
}
Future<void> deleteMachine({required String machineId}) async {
final res = await _dio.delete(
'/api/machines/$machineId',
options: Options(headers: {'x-adapter-local': '1'}),
);
unwrapApiResponse(res);
}
// -- the desk: the account's tabs, the same on every computer --
/// `{revision, tabs}` as the backend holds it (its `routes/desk.ts`); null on
/// a backend that predates the desk (404) or a session it will not take
/// (401) — the phone then has no tabs to show and swipes the whole account,
/// as it did before the desk existed.
Future<Map<String, dynamic>?> desk() async {
final res = await _dio.get('/api/desk');
if (res.statusCode == 404 || res.statusCode == 401) return null;
return unwrapApiResponse(res) as Map<String, dynamic>?;
}
/// Apply [ops] to the desk; answers the desk as it is afterwards. Null under
/// the same two conditions as [desk].
Future<Map<String, dynamic>?> deskOps(List<Map<String, dynamic>> ops) async {
final res = await _dio.post(
'/api/desk/ops',
data: {'ops': ops},
options: Options(headers: {'x-adapter-local': '1'}),
);
if (res.statusCode == 404 || res.statusCode == 401) return null;
return unwrapApiResponse(res) as Map<String, dynamic>?;
}
// -- voice (backend only: the viewer's own SSO session signs it) --
/// The words in one WAV recording, in [lang] — `POST /api/voice/stt`, the
/// endpoint the dial's recordings reach through the CLI, called here with the
/// token this viewer already holds.
///
/// [lang] must be one the backend serves (`VOICE_WAV_LANGS` in the backend's
/// `lib/deepgramWav.ts`); anything else is transcribed as English there.
Future<String> transcribeVoice(Uint8List wav, {required String lang}) async {
final body = multipartFileBody(
field: 'file',
filename: 'voice.wav',
fileContentType: 'audio/wav',
file: wav,
boundary: 'harness-${DateTime.now().microsecondsSinceEpoch}',
);
final res = await _dio.post(
'/api/voice/stt',
queryParameters: {'lang': lang},
data: body.bytes,
options: Options(
contentType: body.contentType,
// A take can run five minutes — megabytes on a phone connection — and
// the backend waits on the transcription provider before it answers.
sendTimeout: const Duration(minutes: 2),
receiveTimeout: const Duration(minutes: 2),
),
);
final data = unwrapApiResponse(res) as Map<String, dynamic>;
return (data['transcript'] as String? ?? '').trim();
}
}
class ApiException implements Exception {
final String message;
final int? status;
ApiException(this.message, {this.status});
@override
String toString() => message;
}
bool isUnauthorizedError(Object error) =>
error is DioException && error.response?.statusCode == 401 ||
error is ApiException && error.status == 401;
/// Unwraps the backend's `{success, data, error}` envelope, which both legs
/// speak: the CLI's loopback server mirrors it, and the viewer's own auth calls
/// (`viewer/direct_auth_api.dart`) read it straight from the backend.
dynamic unwrapApiResponse(Response res) {
final body = res.data;
if (body is Map && body['success'] == true) {
return body['data'];
}
final error = body is Map ? body['error'] : null;
final serverMessage = error is Map ? error['message'] : null;
throw ApiException(
serverMessage is String && serverMessage.isNotEmpty
? serverMessage
: 'Request failed (${res.statusCode})',
status: res.statusCode,
);
}
/// The sentence a failed local-CLI call earns on an error strip. A raw
/// `DioException` is a paragraph about `RequestOptions.receiveTimeout` — true,
/// and useless to the person reading it: what they need is which leg failed.
/// The daemon not listening, the daemon not answering (it proxies to the
/// backend, so that is nearly always the backend being slow), or the backend
/// answering with a sentence of its own, which the daemon forwards verbatim.
String describeApiError(Object error) {
if (error is ApiException) return error.message;
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionError:
return 'the local Harness service is not answering on its port. '
'It usually restarts on its own; retry in a moment.';
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
final limit = error.requestOptions.receiveTimeout?.inSeconds;
return 'the local Harness service did not answer'
'${limit == null ? '' : ' within ${limit}s'} — the Harness '
'backend is probably slow right now. Retry in a moment.';
case DioExceptionType.badResponse:
return 'the local Harness service answered '
'${error.response?.statusCode ?? 'with an error'}.';
case DioExceptionType.badCertificate:
case DioExceptionType.cancel:
case DioExceptionType.transformTimeout:
case DioExceptionType.unknown:
return error.message ?? error.error?.toString() ?? 'request failed';
}
}
return '$error';
}