diff --git a/docs/cli.mdx b/docs/cli.mdx index ddc49d371..81dd024fb 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -39,6 +39,7 @@ description: "Build, deploy, and manage Server-Driven UI projects with the Stac | `status` | Show authentication status | No | | `init` | Initialize Stac in project | Yes | | `build` | Convert Dart widgets to JSON | No | +| `dev` | Run local widget development | No | | `deploy` | Build and deploy to Stac Cloud | Yes | | `project list` | List all cloud projects | Yes | | `project create` | Create new cloud project | Yes | @@ -128,7 +129,59 @@ stac build --verbose | `--validate` | Validate generated JSON | `true` | | `-v, --verbose` | Show detailed build output | `false` | -The build command converts Stac widget definitions from the `stac/` folder into JSON format in the `build/` folder. +The build command converts Stac widget definitions from the `stac/` folder into JSON format in `stac/.build/`. + +### Develop Locally + +Use `stac dev` to test `Stac(routeName: ...)` screens in a running app without deploying to Stac Cloud. It builds your local Stac DSL, serves the generated `stac/.build` JSON through cloud-compatible `/screens` and `/themes` endpoints, and rebuilds when files are saved. + +```bash +stac dev +``` + +Serve a specific project: + +```bash +stac dev --project /path/to/project +``` + +Use a custom host and port: + +```bash +stac dev --host 0.0.0.0 --port 45700 +``` + +Skip the initial build and serve existing build files: + +```bash +stac dev --skip-build +``` + +Then point your debug app at the local server: + +```dart +import 'package:flutter/foundation.dart'; + +await Stac.initialize( + options: defaultStacOptions.copyWith( + apiBaseUrl: kDebugMode + ? 'http://127.0.0.1:45700' + : defaultStacOptions.apiBaseUrl, + ), +); +``` + +`stac dev` prints ready-to-copy URLs for local/iOS Simulator/web, Android Emulator, and physical devices. For a physical device, start with `stac dev --host 0.0.0.0` and use the printed LAN URL as `StacOptions.apiBaseUrl`. + +#### Dev Options + +| Option | Description | Default | +| --------------- | ---------------------------------------- | ----------------- | +| `-p, --project` | Project directory path | Current directory | +| `--host` | Host address for the local server | `127.0.0.1` | +| `--port` | Port for the local server | `45700` | +| `--skip-build` | Serve existing files without first build | `false` | +| `--watch` | Rebuild local JSON when Stac files save | `true` | ### Deploy to Stac Cloud @@ -237,4 +290,4 @@ stac deploy | --------------- | ------------------------------ | | `-v, --verbose` | Show additional command output | | `--version` | Print tool version | -| `--help` | Print usage information | \ No newline at end of file +| `--help` | Print usage information | diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 8e5c74967..4b6735f71 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -133,6 +133,30 @@ StacWidget helloWorld() { Stac follows Flutter's conventions for building widgets. For example, to use Flutter's `Scaffold`, use the `StacScaffold` widget. +## Test Locally + +During development, you can test Stac screens in your running app without deploying to Stac Cloud: + +```bash +stac dev +``` + +Then point debug builds at the local server: + +```dart +import 'package:flutter/foundation.dart'; + +await Stac.initialize( + options: defaultStacOptions.copyWith( + apiBaseUrl: kDebugMode + ? 'http://127.0.0.1:45700' + : defaultStacOptions.apiBaseUrl, + ), +); +``` + +`stac dev` rebuilds local JSON when files in `stac/` are saved and prints ready-to-copy URLs for local/iOS Simulator/web, Android Emulator, and physical devices. For a physical device, start with `stac dev --host 0.0.0.0` and use the printed LAN URL. + ## Deploy Stac widgets Now that we have our widget built, we can deploy it to Stac Cloud. @@ -239,4 +263,4 @@ You're all set. Next, explore widgets and actions, or jump into the CLI guide: - [Actions](/actions/navigate) - [CLI](/cli) -Need help? Join the community on [Discord](https://discord.com/invite/vTGsVRK86V) or open an issue on [GitHub](https://github.com/StacDev/stac/issues). \ No newline at end of file +Need help? Join the community on [Discord](https://discord.com/invite/vTGsVRK86V) or open an issue on [GitHub](https://github.com/StacDev/stac/issues). diff --git a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf765..391a902b2 100644 --- a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist +++ b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/examples/movie_app/ios/Runner/AppDelegate.swift b/examples/movie_app/ios/Runner/AppDelegate.swift index 626664468..c30b367ec 100644 --- a/examples/movie_app/ios/Runner/AppDelegate.swift +++ b/examples/movie_app/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/examples/movie_app/ios/Runner/Info.plist b/examples/movie_app/ios/Runner/Info.plist index a257724c8..d3235fe44 100644 --- a/examples/movie_app/ios/Runner/Info.plist +++ b/examples/movie_app/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/examples/movie_app/lib/main.dart b/examples/movie_app/lib/main.dart index df21e93a0..3f2d240fe 100644 --- a/examples/movie_app/lib/main.dart +++ b/examples/movie_app/lib/main.dart @@ -19,7 +19,7 @@ void main() async { ); await Stac.initialize( - options: defaultStacOptions, + options: defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700'), dio: dio, parsers: [MovieCarouselParser()], ); diff --git a/examples/movie_app/stac/app_theme.dart b/examples/movie_app/stac/app_theme.dart index 9889386c6..388abb393 100644 --- a/examples/movie_app/stac/app_theme.dart +++ b/examples/movie_app/stac/app_theme.dart @@ -5,7 +5,7 @@ StacTheme get darkTheme => _buildTheme( brightness: StacBrightness.dark, colorScheme: StacColorScheme( brightness: StacBrightness.dark, - primary: '#95E183', + primary: '#212121', onPrimary: '#050608', secondary: '#95E183', onSecondary: '#FFFFFF', diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 283516a2a..03b0bbf30 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -20,7 +20,15 @@ class StacCloud { ), ); - static const String _baseUrl = 'https://api.stac.dev'; + static const String _defaultBaseUrl = 'https://api.stac.dev'; + + static String get _baseUrl { + final configuredBaseUrl = StacService.options?.apiBaseUrl.trim(); + if (configuredBaseUrl == null || configuredBaseUrl.isEmpty) { + return _defaultBaseUrl; + } + return configuredBaseUrl.replaceFirst(RegExp(r'/+$'), ''); + } /// Gets the fetch URL for a given artifact type. static String _getFetchUrl(StacArtifactType artifactType) { diff --git a/packages/stac_cli/README.md b/packages/stac_cli/README.md index f0ef2a5d1..c36ea392c 100644 --- a/packages/stac_cli/README.md +++ b/packages/stac_cli/README.md @@ -23,9 +23,15 @@ stac --version stac login stac init stac build +stac dev stac deploy ``` +Use `stac dev` during local development to build and serve Stac screens from +`stac/.build` without deploying to Stac Cloud. Point debug builds at the local +server with `defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700')`. +The command also prints local/iOS, Android Emulator, and physical-device URLs. + ## Environment The CLI reads credentials from: @@ -41,4 +47,3 @@ Required keys: - `STAC_FIREBASE_API_KEY` Set environment in code via `currentEnvironment` in `lib/src/config/env.dart`. - diff --git a/packages/stac_cli/bin/stac_cli.dart b/packages/stac_cli/bin/stac_cli.dart index aabbf704c..df9df2374 100644 --- a/packages/stac_cli/bin/stac_cli.dart +++ b/packages/stac_cli/bin/stac_cli.dart @@ -7,6 +7,7 @@ import 'package:stac_cli/src/commands/auth/logout_command.dart'; import 'package:stac_cli/src/commands/auth/status_command.dart'; import 'package:stac_cli/src/commands/build_command.dart'; import 'package:stac_cli/src/commands/deploy_command.dart'; +import 'package:stac_cli/src/commands/dev_command.dart'; import 'package:stac_cli/src/commands/init_command.dart'; import 'package:stac_cli/src/commands/project_command.dart'; import 'package:stac_cli/src/commands/upgrade_command.dart'; @@ -66,6 +67,7 @@ void main(List arguments) async { ..addCommand(InitCommand()) ..addCommand(ProjectCommand()) ..addCommand(BuildCommand()) + ..addCommand(DevCommand()) ..addCommand(DeployCommand()) ..addCommand(UpgradeCommand()); diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart index 5d294f164..75870dde9 100644 --- a/packages/stac_cli/lib/src/commands/deploy_command.dart +++ b/packages/stac_cli/lib/src/commands/deploy_command.dart @@ -6,7 +6,7 @@ import 'base_command.dart'; /// Command for deploying JSON files to the cloud class DeployCommand extends BaseCommand { final BuildService _buildService = BuildService(); - final DeployService _deployService = DeployService(); + late final DeployService _deployService = DeployService(); @override String get name => 'deploy'; diff --git a/packages/stac_cli/lib/src/commands/dev_command.dart b/packages/stac_cli/lib/src/commands/dev_command.dart new file mode 100644 index 000000000..b1b5d5c2c --- /dev/null +++ b/packages/stac_cli/lib/src/commands/dev_command.dart @@ -0,0 +1,76 @@ +import 'package:stac_cli/src/services/dev_service.dart'; +import 'package:stac_cli/src/utils/console_logger.dart'; + +import 'base_command.dart'; + +class DevCommand extends BaseCommand { + DevCommand({DevService? devService}) + : _devService = devService ?? DevService() { + argParser.addOption( + 'project', + abbr: 'p', + help: 'Project directory (defaults to current directory)', + ); + argParser.addOption( + 'host', + help: 'Host address for the local development server', + defaultsTo: '127.0.0.1', + ); + argParser.addOption( + 'port', + help: 'Port for the local development server', + defaultsTo: '45700', + ); + argParser.addFlag( + 'skip-build', + help: 'Use existing build files without running the initial build', + negatable: false, + ); + argParser.addFlag( + 'watch', + help: 'Watch Stac source files and rebuild on save', + defaultsTo: true, + ); + } + + final DevService _devService; + + @override + String get name => 'dev'; + + @override + String get description => + 'Run a local Stac development server for screens and themes'; + + @override + bool get requiresProject => true; + + @override + Future execute() async { + final projectPath = argResults?['project'] as String?; + final host = argResults?['host'] as String? ?? '127.0.0.1'; + final portValue = argResults?['port'] as String? ?? '45700'; + final port = int.tryParse(portValue); + final skipBuild = argResults?['skip-build'] as bool? ?? false; + final watch = argResults?['watch'] as bool? ?? true; + + if (port == null || port < 0 || port > 65535) { + ConsoleLogger.error('Invalid port: $portValue'); + return 1; + } + + try { + await _devService.serve( + projectPath: projectPath, + host: host, + port: port, + skipBuild: skipBuild, + watch: watch, + ); + return 0; + } catch (e) { + ConsoleLogger.error('Dev failed: $e'); + return 1; + } + } +} diff --git a/packages/stac_cli/lib/src/commands/init_command.dart b/packages/stac_cli/lib/src/commands/init_command.dart index 3b40a19e6..9a0f86098 100644 --- a/packages/stac_cli/lib/src/commands/init_command.dart +++ b/packages/stac_cli/lib/src/commands/init_command.dart @@ -11,7 +11,7 @@ import 'base_command.dart'; /// Command for initializing a Stac project from cloud projects class InitCommand extends BaseCommand { - final ProjectService _projectService = ProjectService(); + late final ProjectService _projectService = ProjectService(); @override String get name => 'init'; diff --git a/packages/stac_cli/lib/src/commands/project/create_command.dart b/packages/stac_cli/lib/src/commands/project/create_command.dart index 62724ee91..9ca8e6140 100644 --- a/packages/stac_cli/lib/src/commands/project/create_command.dart +++ b/packages/stac_cli/lib/src/commands/project/create_command.dart @@ -4,7 +4,7 @@ import '../../utils/console_logger.dart'; /// Command for creating a new project on the cloud class CreateCommand extends BaseCommand { - final ProjectService _projectService = ProjectService(); + late final ProjectService _projectService = ProjectService(); @override String get name => 'create'; diff --git a/packages/stac_cli/lib/src/commands/project/list_command.dart b/packages/stac_cli/lib/src/commands/project/list_command.dart index c2372cdea..fe97a448e 100644 --- a/packages/stac_cli/lib/src/commands/project/list_command.dart +++ b/packages/stac_cli/lib/src/commands/project/list_command.dart @@ -6,7 +6,7 @@ import '../base_command.dart'; /// Command for listing all cloud projects class ListCommand extends BaseCommand { - final ProjectService _projectService = ProjectService(); + late final ProjectService _projectService = ProjectService(); @override String get name => 'list'; diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart index c9bd20b66..92ac1a156 100644 --- a/packages/stac_cli/lib/src/services/build_service.dart +++ b/packages/stac_cli/lib/src/services/build_service.dart @@ -15,11 +15,10 @@ class BuildService { /// Build the project from Dart to JSON using analyzer + isolate execution Future build({String? projectPath}) async { // Determine project root (directory containing pubspec.yaml) - final projectDir = - projectPath ?? _findProjectRoot() ?? Directory.current.path; + final projectDir = resolveProjectDir(projectPath: projectPath); // Load build configuration from lib/default_stac_options.dart (with defaults) - final options = await _loadBuildConfigFromOptions(projectDir); + final options = await loadBuildConfigFromOptions(projectDir); ConsoleLogger.info('Building Stac project...'); ConsoleLogger.debug('Project directory: $projectDir'); @@ -163,8 +162,14 @@ class BuildService { await for (final entity in dir.list(recursive: true, followLinks: false)) { if (entity is File && entity.path.endsWith('.dart')) { - // Skip hidden directories and build directories - if (!entity.path.contains('/.') && !entity.path.contains('.build')) { + final relativePath = path.relative(entity.path, from: sourceDir); + final pathSegments = path.split(relativePath); + final isHiddenOrBuildFile = pathSegments.any( + (segment) => segment.startsWith('.') || segment == 'build', + ); + + // Skip hidden/build directories inside the Stac source directory. + if (!isHiddenOrBuildFile) { dartFiles.add(entity.path); } } @@ -174,7 +179,11 @@ class BuildService { } /// Load build configuration from lib/default_stac_options.dart with sensible defaults - Future _loadBuildConfigFromOptions(String projectDir) async { + String resolveProjectDir({String? projectPath}) { + return projectPath ?? _findProjectRoot() ?? Directory.current.path; + } + + Future loadBuildConfigFromOptions(String projectDir) async { final optionsPath = path.join( projectDir, 'lib', diff --git a/packages/stac_cli/lib/src/services/dev_service.dart b/packages/stac_cli/lib/src/services/dev_service.dart new file mode 100644 index 000000000..b2443b6b4 --- /dev/null +++ b/packages/stac_cli/lib/src/services/dev_service.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:stac_cli/src/services/build_service.dart'; +import 'package:stac_cli/src/utils/console_logger.dart'; + +class DevService { + DevService({BuildService? buildService}) + : _buildService = buildService ?? BuildService(); + + final BuildService _buildService; + + Future serve({ + String? projectPath, + String host = '127.0.0.1', + int port = 45700, + bool skipBuild = false, + bool watch = true, + }) async { + final projectDir = _buildService.resolveProjectDir( + projectPath: projectPath, + ); + final options = await _buildService.loadBuildConfigFromOptions(projectDir); + final outputDir = path.join(projectDir, options.outputDir); + final sourceDir = path.join(projectDir, options.sourceDir); + + var buildRunning = false; + var buildQueued = false; + + Future runBuild(String reason) async { + if (buildRunning) { + buildQueued = true; + return; + } + + buildRunning = true; + do { + buildQueued = false; + try { + ConsoleLogger.info('Building Stac project ($reason)...'); + await _buildService.build(projectPath: projectDir); + ConsoleLogger.success('Local Stac build updated.'); + } catch (e) { + ConsoleLogger.error('Local Stac build failed: $e'); + } + } while (buildQueued); + buildRunning = false; + } + + if (!skipBuild) { + await runBuild('initial'); + } + + final server = await HttpServer.bind(host, port); + final serverUrl = _localBaseUrl(server.address.host, server.port); + ConsoleLogger.success('Stac local server running at $serverUrl'); + ConsoleLogger.info('Screens: $serverUrl/screens?screenName='); + ConsoleLogger.info('Themes: $serverUrl/themes?themeName='); + await _printDeviceUrls( + requestedHost: host, + boundHost: server.address.host, + port: server.port, + ); + + StreamSubscription? watcher; + Timer? rebuildDebounce; + + void scheduleBuild(FileSystemEvent event) { + if (!event.path.endsWith('.dart')) { + return; + } + rebuildDebounce?.cancel(); + rebuildDebounce = Timer(const Duration(milliseconds: 350), () { + unawaited(runBuild(path.relative(event.path, from: projectDir))); + }); + } + + if (watch) { + final directory = Directory(sourceDir); + if (directory.existsSync()) { + try { + watcher = directory.watch(recursive: true).listen(scheduleBuild); + ConsoleLogger.info( + 'Watching ${path.relative(sourceDir, from: projectDir)} for changes.', + ); + } catch (e) { + ConsoleLogger.warning('File watching is not available: $e'); + } + } else { + ConsoleLogger.warning('Source directory not found: $sourceDir'); + } + } + + final done = Completer(); + final signalSubscriptions = >[]; + + Future shutdown() async { + if (done.isCompleted) { + return; + } + rebuildDebounce?.cancel(); + await watcher?.cancel(); + for (final subscription in signalSubscriptions) { + await subscription.cancel(); + } + await server.close(force: true); + ConsoleLogger.info('Stopped Stac local server.'); + done.complete(); + } + + if (!Platform.isWindows) { + signalSubscriptions.add( + ProcessSignal.sigint.watch().listen((_) => unawaited(shutdown())), + ); + signalSubscriptions.add( + ProcessSignal.sigterm.watch().listen((_) => unawaited(shutdown())), + ); + } + + server.listen( + (request) => + unawaited(_handleRequest(request: request, outputDir: outputDir)), + onError: (Object error, StackTrace stackTrace) { + ConsoleLogger.error('Server error: $error'); + }, + ); + + await done.future; + } + + Future _handleRequest({ + required HttpRequest request, + required String outputDir, + }) async { + final response = request.response; + _setCorsHeaders(response); + + if (request.method == 'OPTIONS') { + response.statusCode = HttpStatus.noContent; + await response.close(); + return; + } + + if (request.method != 'GET') { + await _writeJson(response, HttpStatus.methodNotAllowed, { + 'error': 'Only GET is supported by stac dev.', + }); + return; + } + + switch (request.uri.path) { + case '/': + case '/health': + await _writeJson(response, HttpStatus.ok, {'status': 'ok'}); + return; + case '/screens': + await _serveArtifact( + response: response, + outputDir: outputDir, + artifactDirName: 'screens', + artifactName: request.uri.queryParameters['screenName'], + missingNameMessage: 'Missing screenName query parameter.', + ); + return; + case '/themes': + await _serveArtifact( + response: response, + outputDir: outputDir, + artifactDirName: 'themes', + artifactName: request.uri.queryParameters['themeName'], + missingNameMessage: 'Missing themeName query parameter.', + ); + return; + default: + await _writeJson(response, HttpStatus.notFound, { + 'error': 'Unknown stac dev endpoint: ${request.uri.path}', + }); + } + } + + Future _serveArtifact({ + required HttpResponse response, + required String outputDir, + required String artifactDirName, + required String? artifactName, + required String missingNameMessage, + }) async { + if (artifactName == null || artifactName.trim().isEmpty) { + await _writeJson(response, HttpStatus.badRequest, { + 'error': missingNameMessage, + }); + return; + } + + final artifactFile = _resolveArtifactFile( + outputDir: outputDir, + artifactDirName: artifactDirName, + artifactName: artifactName, + ); + + if (artifactFile == null) { + await _writeJson(response, HttpStatus.badRequest, { + 'error': 'Artifact names cannot be absolute or contain "..".', + }); + return; + } + + if (!await artifactFile.exists()) { + await _writeJson(response, HttpStatus.notFound, { + 'error': 'Artifact not found: $artifactName', + }); + return; + } + + final stacJson = await artifactFile.readAsString(); + final stat = await artifactFile.stat(); + await _writeJson(response, HttpStatus.ok, { + 'name': artifactName, + 'stacJson': stacJson, + 'version': stat.modified.millisecondsSinceEpoch, + }); + } + + File? _resolveArtifactFile({ + required String outputDir, + required String artifactDirName, + required String artifactName, + }) { + final fileName = '$artifactName.json'; + if (path.isAbsolute(fileName) || path.split(fileName).contains('..')) { + return null; + } + return File(path.join(outputDir, artifactDirName, fileName)); + } + + Future _writeJson( + HttpResponse response, + int statusCode, + Map payload, + ) async { + response.statusCode = statusCode; + response.headers.contentType = ContentType.json; + response.write(jsonEncode(payload)); + await response.close(); + } + + void _setCorsHeaders(HttpResponse response) { + response.headers + ..set(HttpHeaders.accessControlAllowOriginHeader, '*') + ..set(HttpHeaders.accessControlAllowMethodsHeader, 'GET, OPTIONS') + ..set(HttpHeaders.accessControlAllowHeadersHeader, 'content-type'); + } + + Future _printDeviceUrls({ + required String requestedHost, + required String boundHost, + required int port, + }) async { + final localUrl = _localBaseUrl(boundHost, port); + final androidEmulatorUrl = _baseUrl('10.0.2.2', port); + final lanUrls = await _lanBaseUrls(port); + final acceptsPhysicalDevices = + _acceptsRemoteConnections(requestedHost) || + _acceptsRemoteConnections(boundHost); + + ConsoleLogger.info('Device URLs:'); + ConsoleLogger.info(' Local / iOS Simulator / Web: $localUrl'); + ConsoleLogger.info(' Android Emulator: $androidEmulatorUrl'); + + if (lanUrls.isEmpty) { + ConsoleLogger.warning(' Physical device: no LAN IPv4 address found.'); + _printApiBaseUrlHint(); + return; + } + + if (!acceptsPhysicalDevices) { + ConsoleLogger.warning( + ' Physical device: run "stac dev --host 0.0.0.0", then use ${lanUrls.first}', + ); + _printApiBaseUrlHint(); + return; + } + + ConsoleLogger.info(' Physical device: ${lanUrls.first}'); + for (final url in lanUrls.skip(1)) { + ConsoleLogger.info(' $url'); + } + _printApiBaseUrlHint(); + } + + void _printApiBaseUrlHint() { + ConsoleLogger.info( + 'Use the matching URL as StacOptions.apiBaseUrl in debug builds.', + ); + } + + Future> _lanBaseUrls(int port) async { + try { + final interfaces = await NetworkInterface.list( + type: InternetAddressType.IPv4, + includeLoopback: false, + includeLinkLocal: false, + ); + final urls = {}; + for (final interface in interfaces) { + for (final address in interface.addresses) { + if (_isAnyHost(address.address) || _isLoopbackHost(address.address)) { + continue; + } + urls.add(_baseUrl(address.address, port)); + } + } + return urls.toList()..sort(); + } catch (e) { + ConsoleLogger.debug('Could not discover LAN URLs: $e'); + return const []; + } + } + + String _localBaseUrl(String host, int port) { + if (_isAnyHost(host)) { + return _baseUrl('127.0.0.1', port); + } + return _baseUrl(host, port); + } + + String _baseUrl(String host, int port) { + final normalizedHost = _normalizeHost(host); + if (normalizedHost.contains(':')) { + return 'http://[$normalizedHost]:$port'; + } + return 'http://$normalizedHost:$port'; + } + + bool _acceptsRemoteConnections(String host) { + return _isAnyHost(host) || !_isLoopbackHost(host); + } + + bool _isAnyHost(String host) { + final normalizedHost = _normalizeHost(host); + return normalizedHost == '0.0.0.0' || normalizedHost == '::'; + } + + bool _isLoopbackHost(String host) { + final normalizedHost = _normalizeHost(host); + return normalizedHost == 'localhost' || + normalizedHost == '::1' || + normalizedHost.startsWith('127.'); + } + + String _normalizeHost(String host) { + final trimmedHost = host.trim().toLowerCase(); + if (trimmedHost.startsWith('[') && trimmedHost.endsWith(']')) { + return trimmedHost.substring(1, trimmedHost.length - 1); + } + return trimmedHost; + } +} diff --git a/packages/stac_core/lib/core/stac_options.dart b/packages/stac_core/lib/core/stac_options.dart index cfb37bb2f..33f2f12b3 100644 --- a/packages/stac_core/lib/core/stac_options.dart +++ b/packages/stac_core/lib/core/stac_options.dart @@ -8,6 +8,8 @@ /// const options = StacOptions( /// name: 'MyProject', /// projectId: 'my_project_id', +/// // Override the runtime API endpoint for local debugging: +/// // apiBaseUrl: 'http://127.0.0.1:45700', /// // apiKey: '...optional...', /// // Override paths if needed (absolute or relative to your project root): /// // sourceDir: '/stac/', @@ -20,6 +22,7 @@ class StacOptions { required this.name, this.description, required this.projectId, + this.apiBaseUrl = 'https://api.stac.dev', this.sourceDir = '/stac/', this.outputDir = '/stac/.build', }); @@ -33,6 +36,12 @@ class StacOptions { /// Unique identifier for the project, used by tooling and integrations. final String projectId; + /// Base URL used by the runtime when fetching screens and themes. + /// + /// Defaults to Stac Cloud. Point this at `stac dev` in debug builds to + /// preview local screens without deploying them. + final String apiBaseUrl; + /// Directory path where Stac source files are located. /// /// Can be absolute or relative to your project root. @@ -42,4 +51,23 @@ class StacOptions { /// /// Can be absolute or relative to your project root. final String outputDir; + + /// Creates a new [StacOptions] with selected fields replaced. + StacOptions copyWith({ + String? name, + String? description, + String? projectId, + String? apiBaseUrl, + String? sourceDir, + String? outputDir, + }) { + return StacOptions( + name: name ?? this.name, + description: description ?? this.description, + projectId: projectId ?? this.projectId, + apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl, + sourceDir: sourceDir ?? this.sourceDir, + outputDir: outputDir ?? this.outputDir, + ); + } }