offline_web_proxy 0.9.0 copy "offline_web_proxy: ^0.9.0" to clipboard
offline_web_proxy: ^0.9.0 copied to clipboard

Offline-capable local proxy server for Flutter WebView. Provides seamless online/offline operation when converting existing web systems to mobile apps.

example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:offline_web_proxy/offline_web_proxy.dart';
import 'package:webview_flutter/webview_flutter.dart';

/// offline_web_proxy の WebView delegate API を体験するサンプルアプリです。
void main() {
  runApp(const ProxyExampleApp());
}

/// offline_web_proxy のデモアプリです。
class ProxyExampleApp extends StatelessWidget {
  /// デモアプリを生成します。
  const ProxyExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'offline_web_proxy demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF0A6C74),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
      ),
      home: const ProxyExampleHomePage(),
    );
  }
}

/// WebView delegate API と連携例を表示するホーム画面です。
class ProxyExampleHomePage extends StatefulWidget {
  /// ホーム画面を生成します。
  const ProxyExampleHomePage({super.key});

  @override
  State<ProxyExampleHomePage> createState() => _ProxyExampleHomePageState();
}

class _ProxyExampleHomePageState extends State<ProxyExampleHomePage> {
  final OfflineWebProxy _proxy = OfflineWebProxy();
  final List<String> _eventLogs = <String>[];

  StreamSubscription<ProxyEvent>? _eventSubscription;
  HttpServer? _upstreamServer;
  WebViewController? _controller;

  ProxyLifecycleGuard? _lifecycleGuard;

  int? _proxyPort;
  String? _upstreamOrigin;
  String? _homeProxyUrl;
  String? _currentPageUrl;
  String? _statusText;

  /// 利用者へ表示する案内文です。文言はアプリ側の責務として持ちます。
  String? _recoveryNoticeText;
  bool _isReady = false;

  @override
  void initState() {
    super.initState();
    unawaited(_initializeDemo());
  }

  @override
  void dispose() {
    final lifecycleGuard = _lifecycleGuard;
    if (lifecycleGuard != null) {
      WidgetsBinding.instance.removeObserver(lifecycleGuard);
      _lifecycleGuard = null;
    }
    unawaited(_eventSubscription?.cancel());
    if (_proxy.isRunning) {
      unawaited(_proxy.stop());
    }
    unawaited(_upstreamServer?.close(force: true));
    super.dispose();
  }

  Future<void> _initializeDemo() async {
    try {
      _upstreamServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
      _upstreamOrigin = 'http://127.0.0.1:${_upstreamServer!.port}';
      _startMockUpstream(_upstreamServer!, _upstreamOrigin!);

      _proxyPort = await _proxy.start(
        config: ProxyConfig(
          origin: _upstreamOrigin!,
          // ポートを固定しておくと再起動後も WebView が保持する URL が有効なままになる
          preferredPort: 8787,
        ),
      );
      _homeProxyUrl = 'http://127.0.0.1:$_proxyPort/app';
      _currentPageUrl = _homeProxyUrl;

      _eventSubscription = _proxy.events.listen(_handleProxyEvent);

      // 復帰時の稼働確認と自動復旧をアプリのライフサイクルに連動させる
      final lifecycleGuard = ProxyLifecycleGuard(
        proxy: _proxy,
        currentUrlProvider: () => _currentPageUrl,
        onRecovered: _handleProxyRecovered,
        onFailed: _handleProxyRecoveryFailed,
      );
      WidgetsBinding.instance.addObserver(lifecycleGuard);
      _lifecycleGuard = lifecycleGuard;
      _controller = _createController();
      await _controller!.loadRequest(Uri.parse(_homeProxyUrl!));

      if (!mounted) {
        return;
      }
      setState(() {
        _isReady = true;
        _statusText = 'proxy 起動完了: $_homeProxyUrl';
      });
    } catch (error) {
      if (!mounted) {
        return;
      }
      setState(() {
        _statusText = '初期化に失敗しました: $error';
      });
    }
  }

  void _startMockUpstream(HttpServer server, String upstreamOrigin) {
    unawaited(() async {
      await for (final HttpRequest request in server) {
        try {
          final html = _buildHtmlResponse(request.uri.path, upstreamOrigin);
          request.response
            ..statusCode = HttpStatus.ok
            ..headers.contentType = ContentType.html
            ..write(html);
          await request.response.close();
        } catch (_) {
          try {
            request.response
              ..statusCode = HttpStatus.internalServerError
              ..write('error');
            await request.response.close();
          } catch (_) {
            // ignore
          }
        }
      }
    }());
  }

  WebViewController _createController() {
    return WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: _handleNavigationRequest,
          onPageStarted: (String url) {
            if (!mounted) {
              return;
            }
            setState(() {
              _currentPageUrl = url;
              _statusText = '読込中: $url';
            });
          },
          onPageFinished: (String url) {
            if (!mounted) {
              return;
            }
            setState(() {
              _currentPageUrl = url;
              _statusText = '表示中: $url';
            });
          },
          onWebResourceError: (WebResourceError error) {
            if (!mounted) {
              return;
            }
            setState(() {
              _statusText = 'WebView エラー: ${error.description}';
            });
            // 端末が表示する生のエラー画面に頼らず proxy の復旧を試みる
            unawaited(_handleWebResourceError(error));
          },
        ),
      );
  }

  NavigationDecision _handleNavigationRequest(NavigationRequest request) {
    final recommendation = _proxy.recommendMainFrameNavigation(
      targetUrl: request.url,
      sourceUrl: _currentPageUrl,
    );
    final targetUri = recommendation.externalUri ??
        recommendation.webViewUri ??
        recommendation.resolution.normalizedTargetUri;
    _appendLog(
      'nav ${recommendation.action.name} ${recommendation.resolution.reason.name} -> $targetUri',
    );

    switch (recommendation.action) {
      case ProxyWebViewNavigationAction.allow:
        return NavigationDecision.navigate;
      case ProxyWebViewNavigationAction.cancel:
        _showMessage('遷移をキャンセル: ${recommendation.resolution.reason.name}');
        return NavigationDecision.prevent;
      case ProxyWebViewNavigationAction.loadProxyUrl:
        final webViewUri = recommendation.webViewUri;
        if (webViewUri != null) {
          unawaited(_controller?.loadRequest(webViewUri));
        }
        return NavigationDecision.prevent;
      case ProxyWebViewNavigationAction.launchExternal:
        _showMessage('外部委譲候補: ${recommendation.externalUri}');
        return NavigationDecision.prevent;
    }
  }

  void _handleProxyEvent(ProxyEvent event) {
    if (event.type == ProxyEventType.serverRecovered ||
        event.type == ProxyEventType.serverUnavailable) {
      _appendLog(
        'event ${event.type.name} cause=${event.data['cause']} '
        'port=${event.data['newPort'] ?? event.data['previousPort']}',
      );
      return;
    }

    if (event.type != ProxyEventType.requestReceived) {
      return;
    }

    final resolvedUpstreamUrl = event.data['resolvedUpstreamUrl'] as String?;
    final disposition = event.data['navigationDisposition'];
    final reason = event.data['navigationReason'];
    _appendLog(
      'event requestReceived $disposition/$reason upstream=${resolvedUpstreamUrl ?? '-'}',
    );
  }

  /// 再バインドが発生した場合に再読込して案内を消します。
  void _handleProxyRecovered(ProxyRecoveryResult result) {
    _appendLog(
      'recovered ${result.cause.name} port=${result.port} '
      'reload=${result.reloadUri ?? '-'}',
    );
    unawaited(_reloadAfterRecovery(result));
  }

  /// 復旧できなかった場合にアプリ側の文言で案内します。
  void _handleProxyRecoveryFailed(ProxyRecoveryResult result) {
    _appendLog('recovery failed ${result.cause.name} ${result.error ?? ''}');
    _showRecoveryNotice('通信の準備中に問題が発生しました。しばらくしてから再読み込みしてください。');
  }

  /// WebView のリソースエラーを proxy の復旧処理へつなぎます。
  Future<void> _handleWebResourceError(WebResourceError error) async {
    _appendLog('web error ${error.errorCode} ${error.description}');

    final result = await _proxy.recoverFromWebResourceError(
      errorCode: error.errorCode,
      failingUrl: error.url,
      isMainFrame: error.isForMainFrame ?? true,
    );

    switch (result.cause) {
      case ProxyRecoveryCause.unrelated:
        // proxy 以外の失敗はアプリ側の判断で案内する
        _showRecoveryNotice('ページを表示できませんでした。通信状態を確認してください。');
      case ProxyRecoveryCause.recoveryFailed:
        _handleProxyRecoveryFailed(result);
      case ProxyRecoveryCause.notStarted:
        _showRecoveryNotice('proxy が停止しています。アプリを再起動してください。');
      case ProxyRecoveryCause.healthy:
      case ProxyRecoveryCause.socketDead:
      case ProxyRecoveryCause.stalePort:
        await _reloadAfterRecovery(result);
    }
  }

  /// 復旧結果に応じて WebView を再読込します。
  Future<void> _reloadAfterRecovery(ProxyRecoveryResult result) async {
    final controller = _controller;
    if (controller == null) {
      return;
    }

    final reloadUri = result.reloadUri;
    if (reloadUri != null) {
      await controller.loadRequest(reloadUri);
    } else {
      await controller.reload();
    }

    if (!mounted) {
      return;
    }
    setState(() {
      _recoveryNoticeText = null;
    });
  }

  /// 案内表示からの再試行を行います。
  Future<void> _retryRecovery() async {
    final result = await _proxy.ensureRunning();
    if (result.cause == ProxyRecoveryCause.recoveryFailed) {
      _handleProxyRecoveryFailed(result);
      return;
    }

    await _reloadAfterRecovery(result);
  }

  /// 利用者への案内文を表示します。
  void _showRecoveryNotice(String text) {
    if (!mounted) {
      return;
    }
    setState(() {
      _recoveryNoticeText = text;
    });
  }

  Future<void> _reloadHome() async {
    final homeProxyUrl = _homeProxyUrl;
    final controller = _controller;
    if (homeProxyUrl == null || controller == null) {
      return;
    }

    await controller.loadRequest(Uri.parse(homeProxyUrl));
  }

  void _simulateExternalTarget() {
    final recommendation = _proxy.recommendMainFrameNavigation(
      targetUrl: 'tel:+81012345678',
      sourceUrl: _currentPageUrl,
    );
    _appendLog(
      'simulate external ${recommendation.action.name} ${recommendation.resolution.reason.name} ${recommendation.externalUri ?? recommendation.resolution.normalizedTargetUri}',
    );
    _showMessage('外部委譲候補: ${recommendation.externalUri}');
  }

  void _simulateNewWindowTarget() {
    final recommendation = _proxy.recommendNewWindowNavigation(
      targetUrl:
          'https://www.google.com/maps/search/?api=1&query=Tokyo+Station',
      sourceUrl: _currentPageUrl,
    );
    _appendLog(
      'simulate new-window ${recommendation.action.name} ${recommendation.resolution.reason.name} ${recommendation.externalUri ?? recommendation.webViewUri ?? recommendation.resolution.normalizedTargetUri}',
    );
    _showMessage('target=_blank 相当の推奨: ${recommendation.action.name}');
  }

  void _appendLog(String message) {
    if (!mounted) {
      return;
    }

    setState(() {
      _eventLogs.insert(0, message);
      if (_eventLogs.length > 12) {
        _eventLogs.removeRange(12, _eventLogs.length);
      }
    });
  }

  void _showMessage(String text) {
    if (!mounted) {
      return;
    }
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(text)),
    );
  }

  String _buildHtmlResponse(String path, String upstreamOrigin) {
    final title = switch (path) {
      '/app/orders/detail' => 'Order Detail',
      '/app/absolute' => 'Absolute Same-Origin',
      _ => 'Proxy Demo Home',
    };
    final body = switch (path) {
      '/app/orders/detail' => '''
<p>相対リンクと外部委譲の判定を試せます。</p>
<p><a href="../absolute">same-origin absolute page</a></p>
<p><a href="tel:+81012345678">phone link</a></p>
<p><a href="https://www.google.com/maps/search/?api=1&query=Kyoto" target="_blank">maps target blank</a></p>
''',
      '/app/absolute' => '''
<p>絶対 URL を proxy URL に戻す例です。</p>
<p><a href="$upstreamOrigin/app">back home by absolute upstream url</a></p>
''',
      _ => '''
<p>offline_web_proxy の delegate 推奨アクション API を使った WebView サンプルです。</p>
<ul>
  <li><a href="/app/orders/detail">relative same-origin link</a></li>
  <li><a href="$upstreamOrigin/app/absolute">absolute same-origin link</a></li>
  <li><a href="tel:+81012345678">phone link</a></li>
  <li><a href="https://www.google.com/maps/search/?api=1&query=Tokyo+Station">maps link</a></li>
  <li><a href="https://www.google.com/maps/search/?api=1&query=Osaka+Station" target="_blank">maps target blank</a></li>
</ul>
''',
    };

    return '''
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>$title</title>
    <style>
      body { font-family: sans-serif; margin: 0; padding: 24px; background: #f4f8f8; color: #163133; }
      main { max-width: 720px; margin: 0 auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 30px rgba(10, 108, 116, 0.12); }
      a { color: #0a6c74; display: inline-block; margin: 8px 0; }
      code { background: #e8f3f4; padding: 2px 6px; border-radius: 6px; }
    </style>
  </head>
  <body>
    <main>
      <h1>$title</h1>
      <p>現在の upstream origin: <code>$upstreamOrigin</code></p>
      $body
    </main>
  </body>
</html>
''';
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;

    return Scaffold(
      appBar: AppBar(
        title: const Text('offline_web_proxy demo'),
      ),
      body: SafeArea(
        child: !_isReady || controller == null
            ? Center(
                child: Text(_statusText ?? 'proxy を起動しています...'),
              )
            : Column(
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.all(16),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          'WebView delegate デモ',
                          style: Theme.of(context).textTheme.headlineSmall,
                        ),
                        const SizedBox(height: 8),
                        Text(_statusText ?? ''),
                        const SizedBox(height: 8),
                        Text('current: ${_currentPageUrl ?? '-'}'),
                        const SizedBox(height: 12),
                        Wrap(
                          spacing: 12,
                          runSpacing: 12,
                          children: <Widget>[
                            FilledButton(
                              onPressed: _reloadHome,
                              child: const Text('Home を再読込'),
                            ),
                            OutlinedButton(
                              onPressed: _simulateExternalTarget,
                              child: const Text('電話リンク判定'),
                            ),
                            OutlinedButton(
                              onPressed: _simulateNewWindowTarget,
                              child: const Text('target=_blank 判定'),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                  if (_recoveryNoticeText != null)
                    Container(
                      width: double.infinity,
                      color: const Color(0xFFFFF3CD),
                      padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
                      child: Row(
                        children: <Widget>[
                          Expanded(child: Text(_recoveryNoticeText!)),
                          TextButton(
                            onPressed: _retryRecovery,
                            child: const Text('再試行'),
                          ),
                        ],
                      ),
                    ),
                  Expanded(
                    child: WebViewWidget(controller: controller),
                  ),
                  Container(
                    color: const Color(0xFFF0F7F8),
                    height: 220,
                    padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          'Proxy event log',
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                        const SizedBox(height: 8),
                        Expanded(
                          child: ListView.builder(
                            itemCount: _eventLogs.length,
                            itemBuilder: (BuildContext context, int index) {
                              return Padding(
                                padding: const EdgeInsets.only(bottom: 6),
                                child: Text(
                                  _eventLogs[index],
                                  style: Theme.of(context)
                                      .textTheme
                                      .bodySmall
                                      ?.copyWith(fontFamily: 'monospace'),
                                ),
                              );
                            },
                          ),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
      ),
    );
  }
}
1
likes
0
points
422
downloads

Documentation

Documentation

Publisher

unverified uploader

Weekly Downloads

Offline-capable local proxy server for Flutter WebView. Provides seamless online/offline operation when converting existing web systems to mobile apps.

Repository (GitHub)
View/report issues

Topics

#proxy #offline #webview #flutter #cache

License

unknown (license)

Dependencies

connectivity_plus, crypto, flutter, flutter_secure_storage, hive, hive_flutter, http, path_provider, shelf, shelf_proxy, shelf_router

More

Packages that depend on offline_web_proxy