diff --git a/doc/user_guide/PROXY_SERVER.md b/doc/user_guide/PROXY_SERVER.md new file mode 100644 index 000000000..2c5d74c82 --- /dev/null +++ b/doc/user_guide/PROXY_SERVER.md @@ -0,0 +1,59 @@ +# Proxy Server for API Dash + +API Dash (Web) runs directly in the browser, which means it is subject to Cross-Origin Resource Sharing (CORS) restrictions. Many APIs (like CoinMarketCap, Google, etc.) block requests from browser-based applications unless they are configured to allow them. + +To bypass these restrictions, you can use a **CORS Proxy Server**. The proxy forwards your request to the target API and returns the response with the necessary CORS headers to satisfy the browser. + +## How to Configure in API Dash + +1. Go to **Settings**. +2. Look for the **Proxy URL** field (only visible on Web). +3. Enter the URL of your proxy server (e.g., `https://my-proxy.vercel.app/`). +4. API Dash will now prepend this URL to your API requests. + +Example: +- Proxy URL: `https://my-proxy.com/` +- Target API: `https://api.example.com/v1/users` +- Actual Request: `https://my-proxy.com/https://api.example.com/v1/users` + +## Hosting Your Own Proxy + +API Dash does not provide a public proxy server. You should self-host one to ensure privacy and security. + +### Node.js (Express + cors-anywhere) + +You can easily deploy a proxy using [cors-anywhere](https://github.com/Rob--W/cors-anywhere). + +1. Create a new directory and initialize a project: + ```bash + mkdir my-proxy + cd my-proxy + npm init -y + npm install cors-anywhere + ``` + +2. Create `server.js`: + ```javascript + const host = process.env.HOST || '0.0.0.0'; + const port = process.env.PORT || 8080; + + const cors_proxy = require('cors-anywhere'); + cors_proxy.createServer({ + originWhitelist: [], // Allow all origins + requireHeader: ['origin', 'x-requested-with'], + removeHeaders: ['cookie', 'cookie2'] + }).listen(port, host, function() { + console.log('Running CORS Anywhere on ' + host + ':' + port); + }); + ``` + +3. Run it: + ```bash + node server.js + ``` + +### Deploying to Vercel/Cloudflare + +There are many open-source templates for deploying CORS proxies to serverless platforms like Vercel or Cloudflare Workers. + +> **Security Note:** If you deploy a public proxy, anyone can use it to mask their IP. It is recommended to restrict the `originWhitelist` to your API Dash deployment domain (or `localhost` for testing). diff --git a/lib/app.dart b/lib/app.dart index 3cdbe150a..666de190e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,100 +4,23 @@ import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart' hide WindowCaption; -import 'widgets/widgets.dart' show WindowCaption, WorkspaceSelector; + +import 'widgets/widgets.dart' + show WindowCaption, WorkspaceSelector, WindowListenerWrapper; import 'providers/providers.dart'; import 'services/services.dart'; import 'screens/screens.dart'; import 'consts.dart'; -class App extends ConsumerStatefulWidget { +class App extends ConsumerWidget { const App({super.key}); @override - ConsumerState createState() => _AppState(); -} - -class _AppState extends ConsumerState with WindowListener { - @override - void initState() { - super.initState(); - windowManager.addListener(this); - _init(); - } - - @override - void dispose() { - windowManager.removeListener(this); - super.dispose(); - } - - void _init() async { - // Add this line to override the default close handler - await windowManager.setPreventClose(true); - setState(() {}); - } - - @override - void onWindowResized() { - windowManager.getSize().then((value) { - ref.read(settingsProvider.notifier).update(size: value); - }); - windowManager.getPosition().then((value) { - ref.read(settingsProvider.notifier).update(offset: value); - }); - } - - @override - void onWindowMoved() { - windowManager.getPosition().then((value) { - ref.read(settingsProvider.notifier).update(offset: value); - }); - } - - @override - void onWindowClose() async { - bool isPreventClose = await windowManager.isPreventClose(); - if (isPreventClose) { - if (ref.watch( - settingsProvider.select((value) => value.promptBeforeClosing)) && - ref.watch(hasUnsavedChangesProvider)) { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Save Changes'), - content: - const Text('Want to save changes before you close API Dash?'), - actions: [ - OutlinedButton( - child: const Text('No'), - onPressed: () async { - Navigator.of(context).pop(); - await windowManager.destroy(); - }, - ), - FilledButton( - child: const Text('Save'), - onPressed: () async { - await ref - .read(collectionStateNotifierProvider.notifier) - .saveData(); - Navigator.of(context).pop(); - await windowManager.destroy(); - }, - ), - ], - ), - ); - } else { - await windowManager.destroy(); - } - } - } - - @override - Widget build(BuildContext context) { - return context.isMediumWindow ? const MobileDashboard() : const Dashboard(); + Widget build(BuildContext context, WidgetRef ref) { + return WindowListenerWrapper( + child: + context.isMediumWindow ? const MobileDashboard() : const Dashboard(), + ); } } @@ -128,7 +51,7 @@ class DashApp extends ConsumerWidget { }, onCancel: () async { try { - await windowManager.destroy(); + await destroyWindow(); } catch (e) { debugPrint(e.toString()); } diff --git a/lib/models/settings_model.dart b/lib/models/settings_model.dart index b3222bf05..f1a5839f3 100644 --- a/lib/models/settings_model.dart +++ b/lib/models/settings_model.dart @@ -20,6 +20,7 @@ class SettingsModel { this.isSSLDisabled = false, this.isDashBotEnabled = true, this.defaultAIModel, + this.proxyUrl, }); final bool isDark; @@ -36,6 +37,7 @@ class SettingsModel { final bool isSSLDisabled; final bool isDashBotEnabled; final Map? defaultAIModel; + final String? proxyUrl; SettingsModel copyWith({ bool? isDark, @@ -52,6 +54,7 @@ class SettingsModel { bool? isSSLDisabled, bool? isDashBotEnabled, Map? defaultAIModel, + String? proxyUrl, }) { return SettingsModel( isDark: isDark ?? this.isDark, @@ -70,6 +73,7 @@ class SettingsModel { isSSLDisabled: isSSLDisabled ?? this.isSSLDisabled, isDashBotEnabled: isDashBotEnabled ?? this.isDashBotEnabled, defaultAIModel: defaultAIModel ?? this.defaultAIModel, + proxyUrl: proxyUrl ?? this.proxyUrl, ); } @@ -91,6 +95,7 @@ class SettingsModel { isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, + proxyUrl: proxyUrl, ); } @@ -149,6 +154,7 @@ class SettingsModel { final defaultAIModel = data["defaultAIModel"] == null ? null : Map.from(data["defaultAIModel"]); + final proxyUrl = data["proxyUrl"] as String?; const sm = SettingsModel(); return sm.copyWith( @@ -167,6 +173,7 @@ class SettingsModel { isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, + proxyUrl: proxyUrl, ); } @@ -188,6 +195,7 @@ class SettingsModel { "isSSLDisabled": isSSLDisabled, "isDashBotEnabled": isDashBotEnabled, "defaultAIModel": defaultAIModel, + "proxyUrl": proxyUrl, }; } @@ -214,7 +222,8 @@ class SettingsModel { other.workspaceFolderPath == workspaceFolderPath && other.isSSLDisabled == isSSLDisabled && other.isDashBotEnabled == isDashBotEnabled && - mapEquals(other.defaultAIModel, defaultAIModel); + mapEquals(other.defaultAIModel, defaultAIModel) && + other.proxyUrl == proxyUrl; } @override @@ -235,6 +244,7 @@ class SettingsModel { isSSLDisabled, isDashBotEnabled, defaultAIModel, + proxyUrl, ); } } diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 68c45cfd2..9d1b1370b 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -387,12 +388,16 @@ class CollectionStateNotifier }; bool streamingMode = true; //Default: Streaming First + final String? proxyUrl = + kIsWeb ? ref.read(settingsProvider).proxyUrl : null; + final stream = await streamHttpRequest( requestId, apiType, substitutedHttpRequestModel, defaultUriScheme: defaultUriScheme, noSSL: noSSL, + proxyUrl: proxyUrl, ); HttpResponseModel? httpResponseModel; @@ -472,13 +477,21 @@ class CollectionStateNotifier final (response, duration, errorMessage) = await completer.future; if (response == null) { + String? msg = errorMessage; + if (kIsWeb && + (errorMessage?.contains("XMLHttpRequest") == true || + errorMessage?.contains("ClientException") == true)) { + msg = + "$errorMessage\n\nError can be due to CORS restrictions. Check if the API supports CORS or use a Proxy (Settings > Proxy URL)"; + } + newRequestModel = newRequestModel.copyWith( responseStatus: -1, - message: errorMessage, + message: msg, isWorking: false, isStreaming: false, ); - terminal.failNetwork(logId, errorMessage ?? 'Unknown error'); + terminal.failNetwork(logId, msg ?? 'Unknown error'); } else { final statusCode = response.statusCode; httpResponseModel = baseHttpResponseModel.fromResponse( diff --git a/lib/providers/js_runtime_notifier.dart b/lib/providers/js_runtime_notifier.dart index c09958f5a..3f23e39a9 100644 --- a/lib/providers/js_runtime_notifier.dart +++ b/lib/providers/js_runtime_notifier.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_js/flutter_js.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/js_runtime/app_js_runtime.dart'; import '../models/models.dart'; import '../utils/utils.dart'; import '../providers/terminal_providers.dart'; @@ -41,12 +41,12 @@ class JsRuntimeNotifier extends StateNotifier { JsRuntimeNotifier(this.ref) : super(const JsRuntimeState()); final Ref ref; - late final JavascriptRuntime _runtime; + late final AppJsRuntime _runtime; String? _currentRequestId; void _initialize() { if (state.initialized) return; - _runtime = getJavascriptRuntime(); + _runtime = getAppJavascriptRuntime(); _setupJsBridge(); state = state.copyWith(initialized: true); } @@ -64,7 +64,7 @@ class JsRuntimeNotifier extends StateNotifier { super.dispose(); } - JsEvalResult evaluate(String code) { + AppJsEvalResult evaluate(String code) { // If disposed, prevent usage if (!mounted) { throw StateError('JsRuntimeNotifier used after dispose'); diff --git a/lib/providers/settings_providers.dart b/lib/providers/settings_providers.dart index be09cc6e7..da06fc63b 100644 --- a/lib/providers/settings_providers.dart +++ b/lib/providers/settings_providers.dart @@ -35,6 +35,7 @@ class ThemeStateNotifier extends StateNotifier { bool? isSSLDisabled, bool? isDashBotEnabled, Map? defaultAIModel, + String? proxyUrl, }) async { state = state.copyWith( isDark: isDark, @@ -51,6 +52,7 @@ class ThemeStateNotifier extends StateNotifier { isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, + proxyUrl: proxyUrl, ); await setSettingsToSharedPrefs(state); } diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index 4da2a48f4..5ab069b22 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -104,6 +104,35 @@ class SettingsPage extends ConsumerWidget { }, ) : kSizedBoxEmpty, + kIsWeb + ? ListTile( + hoverColor: kColorTransparent, + title: const Text('Proxy URL'), + subtitle: const Text( + 'Enter the URL of the CORS proxy server to bypass CORS restrictions on the web.'), + trailing: SizedBox( + width: 300, + child: TextField( + onChanged: (value) { + ref + .read(settingsProvider.notifier) + .update(proxyUrl: value); + }, + controller: + TextEditingController(text: settings.proxyUrl), + decoration: const InputDecoration( + hintText: 'https://proxy.com/', + isDense: false, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + ), + ), + ), + ) + : kSizedBoxEmpty, ListTile( hoverColor: kColorTransparent, title: const Text('Default Code Generator'), diff --git a/lib/services/hive_services.dart b/lib/services/hive_services.dart index 3afba422c..703f5304e 100644 --- a/lib/services/hive_services.dart +++ b/lib/services/hive_services.dart @@ -112,7 +112,8 @@ class HiveHandler { dashBotBox = Hive.lazyBox(kDashBotBox); } - dynamic getIds() => dataBox.get(kKeyDataBoxIds); + List? getIds() => + (dataBox.get(kKeyDataBoxIds) as List?)?.cast(); Future setIds(List? ids) => dataBox.put(kKeyDataBoxIds, ids); dynamic getRequestModel(String id) => dataBox.get(id); @@ -122,7 +123,8 @@ class HiveHandler { void delete(String key) => dataBox.delete(key); - dynamic getEnvironmentIds() => environmentBox.get(kKeyEnvironmentBoxIds); + List? getEnvironmentIds() => + (environmentBox.get(kKeyEnvironmentBoxIds) as List?)?.cast(); Future setEnvironmentIds(List? ids) => environmentBox.put(kKeyEnvironmentBoxIds, ids); @@ -133,7 +135,8 @@ class HiveHandler { Future deleteEnvironment(String id) => environmentBox.delete(id); - dynamic getHistoryIds() => historyMetaBox.get(kHistoryBoxIds); + List? getHistoryIds() => + (historyMetaBox.get(kHistoryBoxIds) as List?)?.cast(); Future setHistoryIds(List? ids) => historyMetaBox.put(kHistoryBoxIds, ids); @@ -173,7 +176,6 @@ class HiveHandler { Future removeUnused() async { var ids = getIds(); if (ids != null) { - ids = ids as List; for (var key in dataBox.keys.toList()) { if (key != kKeyDataBoxIds && !ids.contains(key)) { await dataBox.delete(key); @@ -182,7 +184,6 @@ class HiveHandler { } var environmentIds = getEnvironmentIds(); if (environmentIds != null) { - environmentIds = environmentIds as List; for (var key in environmentBox.keys.toList()) { if (key != kKeyEnvironmentBoxIds && !environmentIds.contains(key)) { await environmentBox.delete(key); diff --git a/lib/services/js_runtime/app_js_runtime.dart b/lib/services/js_runtime/app_js_runtime.dart new file mode 100644 index 000000000..f755b110b --- /dev/null +++ b/lib/services/js_runtime/app_js_runtime.dart @@ -0,0 +1,26 @@ +export 'app_js_runtime_stub.dart' + if (dart.library.io) 'app_js_runtime_native.dart' + if (dart.library.js_interop) 'app_js_runtime_web.dart'; + +abstract class AppJsRuntime { + AppJsEvalResult evaluate(String code); + + void onMessage(String channel, void Function(dynamic args) callback); + + void dispose(); +} + +class AppJsEvalResult { + final String stringResult; + final bool isError; + + AppJsEvalResult(this.stringResult, {this.isError = false}); + + @override + String toString() => stringResult; +} + +AppJsRuntime getAppJavascriptRuntime() { + throw UnimplementedError( + 'getAppJavascriptRuntime() has not been implemented.'); +} diff --git a/lib/services/js_runtime/app_js_runtime_native.dart b/lib/services/js_runtime/app_js_runtime_native.dart new file mode 100644 index 000000000..331edc30f --- /dev/null +++ b/lib/services/js_runtime/app_js_runtime_native.dart @@ -0,0 +1,33 @@ +import 'package:flutter_js/flutter_js.dart' as flutter_js; +import 'app_js_runtime.dart'; + +class AppJsRuntimeNative implements AppJsRuntime { + late final flutter_js.JavascriptRuntime _runtime; + + AppJsRuntimeNative() { + _runtime = flutter_js.getJavascriptRuntime(); + } + + @override + AppJsEvalResult evaluate(String code) { + final res = _runtime.evaluate(code); + return AppJsEvalResult( + res.stringResult, + isError: res.isError, + ); + } + + @override + void onMessage(String channel, void Function(dynamic args) callback) { + _runtime.onMessage(channel, callback); + } + + @override + void dispose() { + _runtime.dispose(); + } +} + +AppJsRuntime getAppJavascriptRuntime() { + return AppJsRuntimeNative(); +} diff --git a/lib/services/js_runtime/app_js_runtime_stub.dart b/lib/services/js_runtime/app_js_runtime_stub.dart new file mode 100644 index 000000000..f6ab24682 --- /dev/null +++ b/lib/services/js_runtime/app_js_runtime_stub.dart @@ -0,0 +1,5 @@ +import 'app_js_runtime.dart'; + +AppJsRuntime getAppJavascriptRuntime() { + throw UnimplementedError('AppJsRuntime is not available on this platform.'); +} diff --git a/lib/services/js_runtime/app_js_runtime_web.dart b/lib/services/js_runtime/app_js_runtime_web.dart new file mode 100644 index 000000000..4327f431e --- /dev/null +++ b/lib/services/js_runtime/app_js_runtime_web.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'app_js_runtime.dart'; + +class AppJsRuntimeWeb implements AppJsRuntime { + AppJsRuntimeWeb() { + debugPrint('AppJsRuntimeWeb initialized (limited functionality)'); + } + + @override + AppJsEvalResult evaluate(String code) { + return AppJsEvalResult( + '{"status": "ok", "message": "JS Runtime not fully supported on Web yet"}', + ); + } + + @override + void onMessage(String channel, void Function(dynamic args) callback) {} + + @override + void dispose() {} +} + +AppJsRuntime getAppJavascriptRuntime() { + return AppJsRuntimeWeb(); +} diff --git a/lib/services/window_services.dart b/lib/services/window_services.dart index 0b430e53c..25bfb2f74 100644 --- a/lib/services/window_services.dart +++ b/lib/services/window_services.dart @@ -73,3 +73,9 @@ Future setupWindow({Size? sz, Offset? off, bool center = false}) async { }); } } + +Future destroyWindow() async { + if (kIsDesktop) { + await windowManager.destroy(); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index e4e9c0496..9f80bc10c 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -76,4 +76,5 @@ export 'uint8_audio_player.dart'; export 'widget_not_sent.dart'; export 'widget_sending.dart'; export 'window_caption.dart'; +export 'window_listener/window_listener_wrapper.dart'; export 'workspace_selector.dart'; diff --git a/lib/widgets/window_caption.dart b/lib/widgets/window_caption.dart index f26e7539f..f09ad01ba 100644 --- a/lib/widgets/window_caption.dart +++ b/lib/widgets/window_caption.dart @@ -1,98 +1,2 @@ -import 'package:flutter/material.dart'; - -import 'package:window_manager/window_manager.dart'; - -const double kWindowCaptionHeight = 30; - -class WindowCaption extends StatefulWidget { - const WindowCaption({ - super.key, - this.backgroundColor, - this.brightness, - }); - - final Color? backgroundColor; - final Brightness? brightness; - - @override - State createState() => _WindowCaptionState(); -} - -class _WindowCaptionState extends State with WindowListener { - @override - void initState() { - windowManager.addListener(this); - super.initState(); - } - - @override - void dispose() { - windowManager.removeListener(this); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanStart: (details) { - windowManager.startDragging(); - }, - child: const SizedBox( - height: double.infinity, - ), - ), - ), - WindowCaptionButton.minimize( - brightness: widget.brightness, - onPressed: () async { - bool isMinimized = await windowManager.isMinimized(); - if (isMinimized) { - windowManager.restore(); - } else { - windowManager.minimize(); - } - }, - ), - FutureBuilder( - future: windowManager.isMaximized(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.data == true) { - return WindowCaptionButton.unmaximize( - brightness: widget.brightness, - onPressed: () { - windowManager.unmaximize(); - }, - ); - } - return WindowCaptionButton.maximize( - brightness: widget.brightness, - onPressed: () { - windowManager.maximize(); - }, - ); - }, - ), - WindowCaptionButton.close( - brightness: widget.brightness, - onPressed: () { - windowManager.close(); - }, - ), - ], - ); - } - - @override - void onWindowMaximize() { - setState(() {}); - } - - @override - void onWindowUnmaximize() { - setState(() {}); - } -} +export 'window_caption_stub.dart' + if (dart.library.io) 'window_caption_native.dart'; diff --git a/lib/widgets/window_caption_native.dart b/lib/widgets/window_caption_native.dart new file mode 100644 index 000000000..f26e7539f --- /dev/null +++ b/lib/widgets/window_caption_native.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:window_manager/window_manager.dart'; + +const double kWindowCaptionHeight = 30; + +class WindowCaption extends StatefulWidget { + const WindowCaption({ + super.key, + this.backgroundColor, + this.brightness, + }); + + final Color? backgroundColor; + final Brightness? brightness; + + @override + State createState() => _WindowCaptionState(); +} + +class _WindowCaptionState extends State with WindowListener { + @override + void initState() { + windowManager.addListener(this); + super.initState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (details) { + windowManager.startDragging(); + }, + child: const SizedBox( + height: double.infinity, + ), + ), + ), + WindowCaptionButton.minimize( + brightness: widget.brightness, + onPressed: () async { + bool isMinimized = await windowManager.isMinimized(); + if (isMinimized) { + windowManager.restore(); + } else { + windowManager.minimize(); + } + }, + ), + FutureBuilder( + future: windowManager.isMaximized(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.data == true) { + return WindowCaptionButton.unmaximize( + brightness: widget.brightness, + onPressed: () { + windowManager.unmaximize(); + }, + ); + } + return WindowCaptionButton.maximize( + brightness: widget.brightness, + onPressed: () { + windowManager.maximize(); + }, + ); + }, + ), + WindowCaptionButton.close( + brightness: widget.brightness, + onPressed: () { + windowManager.close(); + }, + ), + ], + ); + } + + @override + void onWindowMaximize() { + setState(() {}); + } + + @override + void onWindowUnmaximize() { + setState(() {}); + } +} diff --git a/lib/widgets/window_caption_stub.dart b/lib/widgets/window_caption_stub.dart new file mode 100644 index 000000000..67bc0f19d --- /dev/null +++ b/lib/widgets/window_caption_stub.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class WindowCaption extends StatelessWidget { + const WindowCaption({ + super.key, + this.backgroundColor, + this.brightness, + }); + + final Color? backgroundColor; + final Brightness? brightness; + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/widgets/window_listener/window_listener_wrapper.dart b/lib/widgets/window_listener/window_listener_wrapper.dart new file mode 100644 index 000000000..efbc4c700 --- /dev/null +++ b/lib/widgets/window_listener/window_listener_wrapper.dart @@ -0,0 +1,2 @@ +export 'window_listener_wrapper_stub.dart' + if (dart.library.io) 'window_listener_wrapper_native.dart'; diff --git a/lib/widgets/window_listener/window_listener_wrapper_native.dart b/lib/widgets/window_listener/window_listener_wrapper_native.dart new file mode 100644 index 000000000..ee64db017 --- /dev/null +++ b/lib/widgets/window_listener/window_listener_wrapper_native.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; +import '../../providers/providers.dart'; + +class WindowListenerWrapper extends ConsumerStatefulWidget { + const WindowListenerWrapper({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => + _WindowListenerWrapperState(); +} + +class _WindowListenerWrapperState extends ConsumerState + with WindowListener { + @override + void initState() { + super.initState(); + windowManager.addListener(this); + _init(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + void _init() async { + await windowManager.setPreventClose(true); + if (mounted) setState(() {}); + } + + @override + void onWindowResized() { + windowManager.getSize().then((value) { + ref.read(settingsProvider.notifier).update(size: value); + }); + windowManager.getPosition().then((value) { + ref.read(settingsProvider.notifier).update(offset: value); + }); + } + + @override + void onWindowMoved() { + windowManager.getPosition().then((value) { + ref.read(settingsProvider.notifier).update(offset: value); + }); + } + + @override + void onWindowClose() async { + bool isPreventClose = await windowManager.isPreventClose(); + if (isPreventClose) { + if (ref.watch( + settingsProvider.select((value) => value.promptBeforeClosing)) && + ref.watch(hasUnsavedChangesProvider)) { + if (!mounted) return; + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Save Changes'), + content: + const Text('Want to save changes before you close API Dash?'), + actions: [ + OutlinedButton( + child: const Text('No'), + onPressed: () async { + Navigator.of(context).pop(); + await windowManager.destroy(); + }, + ), + FilledButton( + child: const Text('Save'), + onPressed: () async { + await ref + .read(collectionStateNotifierProvider.notifier) + .saveData(); + if (context.mounted) Navigator.of(context).pop(); + await windowManager.destroy(); + }, + ), + ], + ), + ); + } else { + await windowManager.destroy(); + } + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/widgets/window_listener/window_listener_wrapper_stub.dart b/lib/widgets/window_listener/window_listener_wrapper_stub.dart new file mode 100644 index 000000000..c51bfc2c3 --- /dev/null +++ b/lib/widgets/window_listener/window_listener_wrapper_stub.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class WindowListenerWrapper extends StatelessWidget { + const WindowListenerWrapper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return child; + } +} diff --git a/packages/better_networking/lib/services/http_service.dart b/packages/better_networking/lib/services/http_service.dart index 78ae2349d..9a52a6d39 100644 --- a/packages/better_networking/lib/services/http_service.dart +++ b/packages/better_networking/lib/services/http_service.dart @@ -26,6 +26,7 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequestV1( HttpRequestModel requestModel, { SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, bool noSSL = false, + String? proxyUrl, }) async { final authData = requestModel.authModel; if (httpClientManager.wasRequestCancelled(requestId)) { @@ -51,6 +52,10 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequestV1( if (uriRec.$1 != null) { Uri requestUrl = uriRec.$1!; + if (proxyUrl != null && proxyUrl.isNotEmpty) { + requestUrl = Uri.parse("$proxyUrl$requestUrl"); + } + Map headers = authenticatedRequestModel.enabledHeadersMap; bool overrideContentType = false; HttpResponse? response; @@ -161,6 +166,7 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequest( HttpRequestModel requestModel, { SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, bool noSSL = false, + String? proxyUrl, }) async { final stream = await streamHttpRequest( requestId, @@ -168,6 +174,7 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequest( requestModel, defaultUriScheme: defaultUriScheme, noSSL: noSSL, + proxyUrl: proxyUrl, ); final output = await stream.first; return (output?.$2, output?.$3, output?.$4); @@ -186,16 +193,16 @@ http.Request prepareHttpRequest({ }) { var request = http.Request(method, url); if (headers.getValueContentType() != null) { - request.headers[HttpHeaders.contentTypeHeader] = headers - .getValueContentType()!; + request.headers[HttpHeaders.contentTypeHeader] = + headers.getValueContentType()!; if (!overrideContentType) { headers.removeKeyContentType(); } } if (body != null) { request.body = body; - headers[HttpHeaders.contentLengthHeader] = request.bodyBytes.length - .toString(); + headers[HttpHeaders.contentLengthHeader] = + request.bodyBytes.length.toString(); } request.headers.addAll(headers); return request; @@ -207,6 +214,7 @@ Future> streamHttpRequest( HttpRequestModel httpRequestModel, { SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, bool noSSL = false, + String? proxyUrl, }) async { final authData = httpRequestModel.authModel; final controller = StreamController(); @@ -276,10 +284,15 @@ Future> streamHttpRequest( return controller.stream; } + Uri effectiveUri = uri; + if (proxyUrl != null && proxyUrl.isNotEmpty) { + effectiveUri = Uri.parse("$proxyUrl$effectiveUri"); + } + try { final streamedResponse = await makeStreamedRequest( client: client, - uri: uri, + uri: effectiveUri, requestModel: authenticatedHttpRequestModel, apiType: apiType, );