diff --git a/hosting/README.md b/hosting/README.md index 2b37b47..69c4c20 100644 --- a/hosting/README.md +++ b/hosting/README.md @@ -74,6 +74,13 @@ git pull sudo systemctl restart cyberhybridhub-api cyberhybridhub-web ``` +Quick redeploy (code only, no `pub get`, restarts services): + +```bash +git pull +./scripts/deploy-dev.sh +``` + ## Firebase authorized domains ```bash diff --git a/lib/config/api_config.dart b/lib/config/api_config.dart index 3eb4767..0b66909 100644 --- a/lib/config/api_config.dart +++ b/lib/config/api_config.dart @@ -1,11 +1,33 @@ -/// Base URL for the Cyber Hybrid Hub API (Postgres-backed). -/// -/// Override at build/run time: -/// flutter run --dart-define=API_BASE_URL=http://localhost:3000 -const String apiBaseUrl = String.fromEnvironment( +import 'package:flutter/foundation.dart' show kIsWeb; + +const String _apiBaseUrlFromEnvironment = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'http://localhost:3000', ); +bool _isLocalDevHost(String host) { + final String h = host.toLowerCase(); + return h == 'localhost' || h == '127.0.0.1'; +} + +/// Base URL for the Cyber Hybrid Hub API (Postgres-backed). +/// +/// Local: `flutter run --dart-define=API_BASE_URL=http://localhost:3000` +/// +/// Production web (https://cyberhybridhub.com): always [Uri.base.origin] so +/// /v1 and /hubs hit HAProxy on :443 — never :3000 over HTTP (mixed content). +String get apiBaseUrl { + if (!kIsWeb) { + return _apiBaseUrlFromEnvironment; + } + + final Uri page = Uri.base; + if (_isLocalDevHost(page.host)) { + return _apiBaseUrlFromEnvironment; + } + + return page.origin; +} + /// SignalR hub for real-time incoming questions. String get questionsHubUrl => '$apiBaseUrl/hubs/questions'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index f257a69..8b0ffe2 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -126,31 +126,34 @@ class HomeScreen extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - const Spacer(), - IconButton( - onPressed: hub.closeQuestionPanel, - tooltip: 'Close', - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 4), - Expanded( - child: SwipeQuestionTile( - key: ValueKey(question.id), - questionId: question.id, - busy: hub.questionActionBusy.value, - onSwipeRight: (num answer) => - hub.submitCurrentAnswer(answer: answer), - onSwipeLeft: hub.deferCurrentQuestion, + child: FocusScope( + autofocus: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + onPressed: hub.closeQuestionPanel, + tooltip: 'Close', + icon: const Icon(Icons.close), + ), + ], ), - ), - ], + const SizedBox(height: 4), + Expanded( + child: SwipeQuestionTile( + key: ValueKey(question.id), + questionId: question.id, + busy: hub.questionActionBusy.value, + onSwipeRight: (num answer) => + hub.submitCurrentAnswer(answer: answer), + onSwipeLeft: hub.deferCurrentQuestion, + ), + ), + ], + ), ), ), ), diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index 455cf66..d5a2afc 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../guid/guid_glyph_shape.dart'; +import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; const double _holdEdgeButtonWidth = 58; @@ -487,16 +488,29 @@ class _SwipeQuestionTileState extends State _stepSliderBy(scrollDy > 0 ? -1 : 1); } - /// Web/production fallback when [Focus.onKeyEvent] does not receive arrows. + /// True while the question panel is open and this tile should own arrow keys. + bool get _captureArrowKeys => + mounted && + !widget.busy && + !_acting && + QuestionsHubService.instance.questionPanelOpen.value; + + /// Primary path for web/production: does not require widget focus. bool _handleHardwareKey(KeyEvent event) { - if (!_focusNode.hasFocus) { + if (!_captureArrowKeys || !_isArrowKey(event.logicalKey)) { return false; } - return _onKeyEvent(_focusNode, event) == KeyEventResult.handled; + return _processArrowKey(event) == KeyEventResult.handled; } + static bool _isArrowKey(LogicalKeyboardKey key) => + key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight; + /// Arrow up/down step value; hold left/right to defer / save (edge buttons). - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + KeyEventResult _processArrowKey(KeyEvent event) { if (widget.busy || _acting) { return KeyEventResult.ignored; } @@ -630,7 +644,6 @@ class _SwipeQuestionTileState extends State focusNode: _focusNode, autofocus: true, descendantsAreFocusable: false, - onKeyEvent: _onKeyEvent, child: Stack( alignment: Alignment.center, clipBehavior: Clip.none, diff --git a/scripts/build-production-web.sh b/scripts/build-production-web.sh index cb256ae..c4eac00 100755 --- a/scripts/build-production-web.sh +++ b/scripts/build-production-web.sh @@ -34,23 +34,36 @@ _load_env_api_url() { API_BASE_URL="https://cyberhybridhub.com" -_server_api="$(_load_env_api_url "$ROOT/server/.env")" -if [ -n "$_server_api" ]; then - API_BASE_URL="$_server_api" -fi - +# hosting.env wins over server/.env for web builds (server may still say :3000). if [ -f "$ROOT/hosting/hosting.env" ]; then # shellcheck source=/dev/null source "$ROOT/hosting/hosting.env" API_BASE_URL="${API_PUBLIC_URL:-$API_BASE_URL}" fi +_server_api="$(_load_env_api_url "$ROOT/server/.env")" +if [ -n "$_server_api" ] && [ ! -f "$ROOT/hosting/hosting.env" ]; then + API_BASE_URL="$_server_api" +fi + if [ -n "$_CLI_OVERRIDE" ]; then API_BASE_URL="$_CLI_OVERRIDE" fi +if [[ "$API_BASE_URL" == http://* ]] || [[ "$API_BASE_URL" == *:3000* ]]; then + echo "ERROR: Production web must not use HTTP or port 3000 in API_BASE_URL." >&2 + echo " Got: $API_BASE_URL" >&2 + echo " Set API_PUBLIC_URL=https://cyberhybridhub.com in hosting/hosting.env" >&2 + exit 1 +fi + echo "Building release web with API_BASE_URL=$API_BASE_URL" -flutter build web --release \ +_flutter_pub_flag=() +if [ "${SKIP_PUB_GET:-0}" = "1" ]; then + _flutter_pub_flag=(--no-pub) + echo "SKIP_PUB_GET=1 — not running flutter pub get" +fi +flutter build web --release "${_flutter_pub_flag[@]}" \ --dart-define=API_BASE_URL="$API_BASE_URL" \ --dart-define=AUTH_VERBOSE_LOG=true @@ -58,4 +71,8 @@ if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then "$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true fi +if [ -x "$ROOT/scripts/stamp-web-cache-bust.sh" ]; then + "$ROOT/scripts/stamp-web-cache-bust.sh" +fi + echo "Done: $ROOT/build/web" diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh new file mode 100755 index 0000000..b310e49 --- /dev/null +++ b/scripts/deploy-dev.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Fast deploy: build release web (no pub get) and restart production services. +# Use after git pull when dependencies are already installed. +# +# ./scripts/deploy-dev.sh +# +# Full deploy with package refresh: ./scripts/deploy-production.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [ -f "$ROOT/scripts/flutter-env.sh" ]; then + # shellcheck source=/dev/null + source "$ROOT/scripts/flutter-env.sh" +fi + +echo "==> Flutter web (release, no pub get)" +SKIP_PUB_GET=1 "$ROOT/scripts/build-production-web.sh" + +if grep -qE 'http://cyberhybridhub\.com:3000|https://cyberhybridhub\.com:3000' \ + "$ROOT/build/web/main.dart.js" 2>/dev/null; then + echo "ERROR: build/web still bakes :3000 API URL — fix hosting/hosting.env and rebuild." >&2 + exit 1 +fi + +echo "==> Restart services" +if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl not found; skip service restart." >&2 + exit 0 +fi + +for unit in cyberhybridhub-api cyberhybridhub-web; do + if ! systemctl list-unit-files --type=service "$unit.service" >/dev/null 2>&1; then + echo "Warning: $unit.service not installed (run ./hosting/install-systemd.sh once)" >&2 + exit 1 + fi +done + +sudo systemctl restart cyberhybridhub-api cyberhybridhub-web +sudo systemctl --no-pager --full status cyberhybridhub-api cyberhybridhub-web || true + +echo "" +echo "Dev deploy done. Hard-refresh the browser if the UI looks stale." diff --git a/scripts/stamp-web-cache-bust.sh b/scripts/stamp-web-cache-bust.sh new file mode 100755 index 0000000..262bbf7 --- /dev/null +++ b/scripts/stamp-web-cache-bust.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# After flutter build web: bust CDN/browser cache for bootstrap + main.dart.js. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WEB="$ROOT/build/web" +INDEX="$WEB/index.html" +BOOT="$WEB/flutter_bootstrap.js" + +if [ ! -f "$INDEX" ]; then + echo "Missing $INDEX — run flutter build web first." >&2 + exit 1 +fi + +STAMP="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo nogit)-$(date +%Y%m%d%H%M%S)" +echo "Web cache-bust stamp: $STAMP" + +# index.html: versioned bootstrap URL (index.html is no-cache at origin). +if grep -q 'flutter_bootstrap.js' "$INDEX"; then + sed -i "s|flutter_bootstrap.js[^\"']*|flutter_bootstrap.js?v=${STAMP}|g" "$INDEX" +fi + +# flutter_bootstrap.js: versioned main.dart.js load path in buildConfig. +if [ -f "$BOOT" ]; then + sed -i "s|\"mainJsPath\":\"main.dart.js\"|\"mainJsPath\":\"main.dart.js?v=${STAMP}\"|g" "$BOOT" +fi + +printf '%s\n' "$STAMP" >"$WEB/BUILD_STAMP.txt" +echo "Stamped: $INDEX and flutter_bootstrap.js (if present)" diff --git a/scripts/web_static_server.dart b/scripts/web_static_server.dart index 554bcbe..62dc113 100644 --- a/scripts/web_static_server.dart +++ b/scripts/web_static_server.dart @@ -138,7 +138,12 @@ void _applyCacheHeaders(HttpResponse response, String filePath) { } final String lower = filePath.toLowerCase(); - if (lower.endsWith('.html') || lower.endsWith('.htm')) { + final String name = lower.split('/').last; + if (lower.endsWith('.html') || + lower.endsWith('.htm') || + name.startsWith('main.dart.js') || + name.startsWith('flutter_bootstrap.js') || + name == 'flutter.js') { response.headers.set( 'Cache-Control', 'no-cache, must-revalidate', diff --git a/web/index.html b/web/index.html index 298feed..dd6ffd5 100644 --- a/web/index.html +++ b/web/index.html @@ -36,6 +36,14 @@ + +