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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ Thumbs.db

# Logs (stray flutter run/build logs)
*.log

# Example desktop/web runners (regenerate with `flutter create .`)
example/linux/
example/macos/
example/windows/
example/web/
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the package follows [Semantic Versioning](https://semver.org/). From `1.0.0`
onwards, breaking changes require a **major version bump**.

## [0.10.1] - 2026-07-03

### Added

- **Cross-platform capability gating (`PlatformSupport`).** Attachment and
avatar UI now degrade gracefully on platforms whose plugins do not cover
every target: camera capture and image crop are offered on mobile (crop on
mobile only), while downloaded files open natively on mobile and fall back to
the OS default handler via `url_launcher` on desktop. Derived from `kIsWeb` +
`defaultTargetPlatform` (never `dart:io`), so it resolves on web too, hiding
controls a platform cannot honour instead of surfacing ones that silently
fail.
- **Example app now builds for desktop and web** (Linux, macOS, Windows, web)
in addition to Android and iOS.

### Changed

- **`ChatConfig` URL validation exempts loopback hosts from the release-mode
HTTPS requirement.** `http://` to `localhost`, `127.0.0.0/8`, or `::1` stays
allowed in release builds — loopback traffic never leaves the device and
every platform treats it as a secure context — while every other host still
requires `https://` (pentest M-10). The `127.` match is anchored to an IPv4
literal so a DNS host such as `127.evil.com` is not mistaken for loopback.

## [0.10.0] - 2026-06-17

### Added
Expand Down
12 changes: 6 additions & 6 deletions example/.metadata
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.

version:
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
channel: "stable"

project_type: app
Expand All @@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
- platform: ios
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: web
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42

# User provided section

Expand Down
33 changes: 25 additions & 8 deletions example/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ class _NomaChatExampleAppState extends State<NomaChatExampleApp> {

Future<void> _bootstrap() async {
var stored = await _storage.load();
// Defensive migration: an earlier build used the SDK's generic ws/sse
// paths (`/ws`, `/events`). Real CHT routes are `/v1/ws` and `/v1/events`.
if (stored.wsPath == '/ws' || stored.ssePath == '/events') {
// Defensive migration: older builds persisted the SDK generic `/ws` or a
// stale SSE path (`/events`, `/v1/events`). CHT serves WS at `/v1/ws` and
// SSE at `/eventsource` (NRTE); refresh stale persisted paths to current.
if (stored.wsPath == '/ws' ||
stored.ssePath == '/events' ||
stored.ssePath == '/v1/events') {
const fresh = ExampleSettings();
stored = stored.copyWith(wsPath: fresh.wsPath, ssePath: fresh.ssePath);
await _storage.save(stored);
Expand Down Expand Up @@ -72,13 +75,27 @@ class _NomaChatExampleAppState extends State<NomaChatExampleApp> {
if (stored == const ExampleSettings()) {
s = stored.copyWith(mode: chatModeFromEnv());
}
s = applySseEnvOverride(s);
setState(() => _settings = s);

// Demo-app philosophy: every cold start lands on the onboarding
// (mock/real segmented control). The previous "auto-restore CHT
// session" path was dropped 2026-05-25 — keeping you on the last
// screen made sense for a chat app, but this is a docs / lab /
// demo, not your production chat client.
// Session persistence: if the user logged in on a previous run (and did
// not log out — logout clears the saved username), restore that session on
// cold start so they stay logged in. On failure (e.g. backend unreachable)
// fall through to the onboarding, pre-filled, so they can retry.
if (s.username.isNotEmpty &&
s.baseUrl.isNotEmpty &&
s.realtimeUrl.isNotEmpty) {
final outcome = await openChatSession(s, onAuthFailure: _onAuthFailure);
if (!mounted) return;
if (outcome is LoginSuccess) {
_wireRoomRemovedSnackbar(outcome.chat);
setState(() {
_chat = outcome.chat;
_bootstrapping = false;
});
return;
}
}
if (mounted) setState(() => _bootstrapping = false);
}

Expand Down
18 changes: 8 additions & 10 deletions example/lib/chat_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ String _sseUrlOverride() =>
String _ssePathOverride() =>
const String.fromEnvironment('SSE_PATH', defaultValue: '');

ExampleSettings applySseEnvOverride(ExampleSettings s) => s.copyWith(
sseUrl: s.sseUrl ?? (_sseUrlOverride().isNotEmpty ? _sseUrlOverride() : null),
ssePath: _ssePathOverride().isNotEmpty ? _ssePathOverride() : s.ssePath,
);

/// ChatResult of a register-or-login attempt.
sealed class LoginOutcome {
const LoginOutcome();
Expand Down Expand Up @@ -135,6 +140,7 @@ Future<LoginOutcome> openChatSession(
maxRooms: 100,
);

final resolved = applySseEnvOverride(settings);
final config = ChatConfig.withAuthInterceptor(
baseUrl: settings.baseUrl,
realtimeUrl: settings.realtimeUrl,
Expand All @@ -152,17 +158,9 @@ Future<LoginOutcome> openChatSession(
// the example app can route to the login flow autonomously.
onAuthFailure: onAuthFailure,
),
// The harness pre-fills SSE_URL / SSE_PATH dart-defines (CHT
// routes SSE through NRTE on :2082/eventsource, not the REST
// strip on :8077). User-typed settings take priority over the
// dart-define so manual onboarding still wins.
sseUrl:
settings.sseUrl ??
(_sseUrlOverride().isNotEmpty ? _sseUrlOverride() : null),
sseUrl: resolved.sseUrl,
wsPath: settings.wsPath,
ssePath: _ssePathOverride().isNotEmpty
? _ssePathOverride()
: settings.ssePath,
ssePath: resolved.ssePath,
requestTimeout: Duration(seconds: settings.requestTimeoutSeconds),
wsReconnectDelay: Duration(seconds: settings.wsReconnectDelaySeconds),
maxReconnectAttempts: settings.maxReconnectAttempts,
Expand Down
6 changes: 3 additions & 3 deletions example/lib/onboarding_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
final (wsBase, wsPath) = _splitUrl(_wsUrl.text, defaultPath: '/v1/ws');
final (sseBase, ssePath) = _splitUrl(
_sseUrl.text,
defaultPath: '/v1/events',
defaultPath: '/eventsource',
);
final sseUrlOverride = sseBase.isEmpty || sseBase == wsBase
? null
Expand Down Expand Up @@ -245,7 +245,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
constraints: const BoxConstraints(maxWidth: double.infinity),
// Column splits the screen in two: a scrollable upper area
// for the form and a fixed footer that always carries the
// CTA. Lets long forms (CHT mode) scroll without ever
Expand Down Expand Up @@ -691,7 +691,7 @@ class _BackendSection extends StatelessWidget {
enabled: enabled,
decoration: InputDecoration(
labelText: strings.sseUrlLabel,
hintText: 'http://localhost:8077/v1/events',
hintText: 'http://localhost:2082/eventsource',
border: const OutlineInputBorder(),
isDense: true,
),
Expand Down
9 changes: 4 additions & 5 deletions example/lib/settings/example_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,12 @@ class ExampleSettings {
// cache (instant startup, offline read, offline queue).
this.requestTimeoutSeconds = 30,
this.wsReconnectDelaySeconds = 2,
// Tier 3 — Very advanced. Defaults match CHT user_client mount points
// (see `apps/user_client/src/user_client.erl` cowboy routes). The SDK
// ChatConfig fallback (`/ws`, `/events`) targets a generic backend; for
// CHT we override here so the example connects out-of-the-box.
// Tier 3 — Very advanced. wsPath overrides the SDK generic `/ws` to
// CHT's `/v1/ws` mount; ssePath matches the SDK default `/eventsource`
// (CHT's NRTE mount). SSE host/port still needs config — see env_gen.
this.sseUrl,
this.wsPath = '/v1/ws',
this.ssePath = '/v1/events',
this.ssePath = '/eventsource',
this.maxReconnectAttempts,
this.eventBufferSize = 20,
this.languageCode,
Expand Down
2 changes: 1 addition & 1 deletion example/test/settings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ void main() {
expect(s.wsReconnectDelaySeconds, 2);
expect(s.sseUrl, isNull);
expect(s.wsPath, '/v1/ws');
expect(s.ssePath, '/v1/events');
expect(s.ssePath, '/eventsource');
expect(s.maxReconnectAttempts, isNull);
expect(s.eventBufferSize, 20);
});
Expand Down
2 changes: 1 addition & 1 deletion lib/src/_internal/http/rest_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import 'retry_interceptor.dart';
/// if the two drift, so bumping the package version forces an update here
/// too. A future build_runner-generated constant can drop in without
/// changing the call sites.
const String nomaChatSdkVersion = '0.10.0';
const String nomaChatSdkVersion = '0.10.1';

const String _requestIdExtraKey = 'requestId';
const Uuid _uuid = Uuid();
Expand Down
25 changes: 21 additions & 4 deletions lib/src/config/chat_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -527,17 +527,34 @@ class ChatConfig {
}

// Plain http:// is allowed only during local development. Release builds
// must always use https:// — chat traffic carries JWTs and message
// bodies that we cannot afford to send in clear. See pentest M-10.
if (scheme == 'http' && isReleaseMode) {
// must use https:// for any host the traffic actually leaves the device
// to reach — chat traffic carries JWTs and message bodies we cannot send
// in clear. See pentest M-10. Loopback (localhost / 127.0.0.0/8 / ::1) is
// exempt: that traffic never reaches a wire, and every platform treats
// localhost as a secure context, so http to loopback stays allowed.
if (scheme == 'http' && isReleaseMode && !_isLoopbackHost(value)) {
throw ArgumentError.value(
value,
field,
'http:// is not allowed in release builds; use https://.',
'http:// is not allowed in release builds for non-loopback hosts; '
'use https://.',
);
}
}
}

/// True when [url]'s host is a loopback address — `localhost`, any
/// `127.0.0.0/8` address, or IPv6 `::1`. Loopback traffic never leaves the
/// device, so it is exempt from the release-mode https requirement enforced
/// in [validateUrls]. The `127.` match is anchored to an IPv4 literal so a
/// DNS host like `127.evil.com` is not mistaken for loopback.
static bool _isLoopbackHost(String url) {
final host = Uri.parse(url).host.toLowerCase();
if (host == 'localhost' || host == '::1' || host == '0:0:0:0:0:0:0:1') {
return true;
}
return RegExp(r'^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$').hasMatch(host);
}

void log(String level, String message) => logger?.call(level, message);
}
14 changes: 11 additions & 3 deletions lib/src/ui/services/attachment_pickers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart' as ip;

import '../models/attachment_policy.dart';
import '../utils/platform_support.dart';

/// ChatResult of an attachment picker call.
///
Expand Down Expand Up @@ -47,12 +48,19 @@ class AttachmentPickers {
AttachmentPolicy policy = AttachmentPolicy.unrestricted,
void Function(String level, String message)? logger,
}) async {
if (!PlatformSupport.supportsCameraCapture) {
logger?.call(
'warn',
'pickImageFromCamera unsupported on this platform; ignoring',
);
return null;
}
try {
final file = await _imagePicker.pickImage(
source: ip.ImageSource.camera,
imageQuality: imageQuality,
);
return _xfileToValidatedResult(file, policy, logger);
return await _xfileToValidatedResult(file, policy, logger);
} on Object catch (e) {
logger?.call('warn', 'pickImageFromCamera failed: $e');
return null;
Expand All @@ -69,7 +77,7 @@ class AttachmentPickers {
source: ip.ImageSource.gallery,
imageQuality: imageQuality,
);
return _xfileToValidatedResult(file, policy, logger);
return await _xfileToValidatedResult(file, policy, logger);
} on Object catch (e) {
logger?.call('warn', 'pickImageFromGallery failed: $e');
return null;
Expand All @@ -86,7 +94,7 @@ class AttachmentPickers {
source: ip.ImageSource.gallery,
maxDuration: maxDuration,
);
return _xfileToValidatedResult(
return await _xfileToValidatedResult(
file,
policy,
logger,
Expand Down
38 changes: 32 additions & 6 deletions lib/src/ui/utils/attachment_opener.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import 'dart:io';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';

import '../../client/chat_client.dart';
import '../../core/result.dart';
import 'platform_support.dart';

/// Default "open this attachment" handler the SDK wires when the consumer
/// does not supply its own `onTapFile` / `onTapDoc`.
Expand All @@ -24,6 +27,16 @@ Future<void> openAttachmentFile({
String? mimeType,
void Function(String level, String message)? logger,
}) async {
if (kIsWeb) {
// Web has no local filesystem to stage a temp file in; hand the URL to the
// browser (new tab), which previews or downloads it natively.
try {
await launchUrl(Uri.parse(url), webOnlyWindowName: '_blank');
} catch (e) {
logger?.call('warn', 'open attachment (web) threw: $e');
}
return;
}
try {
final result = await client.attachments.downloadFromUrl(url);
switch (result) {
Expand All @@ -35,12 +48,25 @@ Future<void> openAttachmentFile({
final name = _tempFileName(fileName: fileName, mimeType: mimeType);
final file = File('${dir.path}/$name');
await file.writeAsBytes(data, flush: true);
final open = await OpenFilex.open(file.path);
if (open.type != ResultType.done) {
logger?.call(
'warn',
'open attachment failed: ${open.type.name} ${open.message}',
);
if (PlatformSupport.opensFilesNatively) {
final open = await OpenFilex.open(file.path);
if (open.type != ResultType.done) {
logger?.call(
'warn',
'open attachment failed: ${open.type.name} ${open.message}',
);
}
} else {
// open_filex is android/ios only — on desktop hand the file to the
// OS default handler via url_launcher (NSWorkspace / ShellExecute /
// xdg-open).
final opened = await launchUrl(Uri.file(file.path));
if (!opened) {
logger?.call(
'warn',
'open attachment: no OS handler for ${file.path}',
);
}
}
}
} catch (e) {
Expand Down
Loading
Loading