From b2c475d015cc76a9e7b2402ef42171b18cdd20f8 Mon Sep 17 00:00:00 2001
From: "Silas, Mariusz Grzybacz" <silas@qnapclub.pl>
Date: Sat, 3 Oct 2026 05:53:28 +0000
Subject: [PATCH] feat(ui): grey out BitTorrent settings while Rtorrent16 takes
 the torrents

Rtorrent16 local change, not for upstream. The Rtorrent16-Gopeed add-on's
shim hands torrents and magnets to rtorrent16 while that package is on the
NAS, and Gopeed's own BitTorrent settings then have no effect. The shim says
so in GET /api/v1/config as extra.rt16.rtorrent = true and drops the key
again from every write, so Gopeed never stores it.

- ExtraConfig.rt16Rtorrent reads extra.rt16.rtorrent. A missing key, or
  anything but true, is false, so the strict config parse never fails on
  it. toJson writes it back in the same shape, so the copies the settings
  page makes through toJson keep it.
- Rt16BtLock (shared/widgets) keeps a section on the page, greyed out and
  closed to pointer and keyboard, under the line "Rtorrent16 enabled". The
  line is the same in every language and is not in the l10n catalogue.
- Settings: the BitTorrent block and the two autoTorrent switches (the shim
  sets autoTorrent itself) are locked while the flag is true.
- Create task: the BitTorrent tab's trackers (the shim does not pass them
  to rtorrent16) and the HTTP tab's autoTorrent switches are locked too.
- A settings save takes the flag from the server's answer of that moment.
- Without the flag nothing changes.
---
 .../lib/api/model/downloader_config.dart      |  12 ++
 .../lib/api/model/downloader_config.g.dart    |   2 +
 .../application/settings_controller.dart      |   4 +-
 .../presentation/pages/settings_page.dart     | 144 ++++++++------
 .../pages/create_task_window_page.dart        |  78 +++++---
 .../lib/shared/widgets/rt16_bt_lock.dart      |  53 +++++
 ui/flutter/test/rt16/rt16_bt_lock_test.dart   | 184 ++++++++++++++++++
 7 files changed, 381 insertions(+), 96 deletions(-)
 create mode 100644 ui/flutter/lib/shared/widgets/rt16_bt_lock.dart
 create mode 100644 ui/flutter/test/rt16/rt16_bt_lock_test.dart

diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart
index 06a962d..9b5ef45 100644
--- a/ui/flutter/lib/api/model/downloader_config.dart
+++ b/ui/flutter/lib/api/model/downloader_config.dart
@@ -158,6 +158,13 @@ class ExtraConfig {
   ExtraConfigBt bt = ExtraConfigBt();
   ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
 
+  /// Rtorrent16: true while the add-on's shim hands torrents and magnets to rtorrent16. The shim adds it to
+  /// GET /api/v1/config as extra.rt16.rtorrent and strips it from every write, so Gopeed never stores it. A
+  /// missing key, or anything but true, reads as false. toJson writes it back in the same shape, so a copy
+  /// made through toJson and fromJson keeps it.
+  @JsonKey(name: 'rt16', fromJson: rt16RtorrentFromJson, toJson: rt16RtorrentToJson, includeIfNull: false)
+  bool rt16Rtorrent;
+
   ExtraConfig({
     this.themeMode = '',
     this.themeColor = 'green',
@@ -176,6 +183,7 @@ class ExtraConfig {
     this.analyticsEnabled = true,
     this.analyticsClientId = '',
     this.downloadCategories = const [],
+    this.rt16Rtorrent = false,
   }) : windowState = windowState ?? WindowStateConfig();
 
   factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -184,6 +192,10 @@ class ExtraConfig {
   Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
 }
 
+bool rt16RtorrentFromJson(Object? json) => json is Map && json['rtorrent'] == true;
+
+Map<String, dynamic>? rt16RtorrentToJson(bool rtorrent) => rtorrent ? {'rtorrent': true} : null;
+
 @JsonSerializable()
 class WindowStateConfig {
   bool isMaximized;
diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart
index c354d78..c27c968 100644
--- a/ui/flutter/lib/api/model/downloader_config.g.dart
+++ b/ui/flutter/lib/api/model/downloader_config.g.dart
@@ -154,6 +154,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
                 ?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
                 .toList() ??
             const [],
+        rt16Rtorrent: json['rt16'] == null ? false : rt16RtorrentFromJson(json['rt16']),
       )
       ..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
       ..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map<String, dynamic>?);
@@ -179,6 +180,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
   'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
   'bt': instance.bt.toJson(),
   'githubMirror': instance.githubMirror.toJson(),
+  'rt16': ?rt16RtorrentToJson(instance.rt16Rtorrent),
 };
 
 WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart
index 0af7e1b..146590c 100644
--- a/ui/flutter/lib/features/settings/application/settings_controller.dart
+++ b/ui/flutter/lib/features/settings/application/settings_controller.dart
@@ -39,7 +39,9 @@ class SettingsController extends AsyncNotifier<SettingsState> {
       ..bookmarks = latest.extra.bookmarks
       ..createHistory = latest.extra.createHistory
       ..runAsMenubarApp = latest.extra.runAsMenubarApp
-      ..analyticsClientId = latest.extra.analyticsClientId;
+      ..analyticsClientId = latest.extra.analyticsClientId
+      // Rtorrent16: the server's answer of now, so the BitTorrent lock follows rtorrent16 on every save
+      ..rt16Rtorrent = latest.extra.rt16Rtorrent;
     await ref.read(gopeedServiceProvider).putConfig(config);
     ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(config);
     state = AsyncValue.data(SettingsState(config: config));
diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
index 95cdbd1..2d378e4 100644
--- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
+++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
@@ -28,6 +28,7 @@ import '../../../../shared/widgets/app_text_field.dart';
 import '../../../../shared/widgets/app_tooltip.dart';
 import '../../../../shared/widgets/app_toast.dart';
 import '../../../../shared/widgets/responsive_menu_layout.dart';
+import '../../../../shared/widgets/rt16_bt_lock.dart';
 import '../../../../l10n/l10n.dart';
 import '../../../../util/log_util.dart';
 import '../../../../util/package_info.dart';
@@ -504,19 +505,27 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
                       onChanged: (value) => _mutateConfig((next) => next.autoStartTasks = value),
                     ),
                   ),
-                  SettingsItem(
-                    title: context.l10n.autoTorrentEnable,
-                    child: shad.Switch(
-                      value: config.autoTorrent.enable,
-                      onChanged: (value) => _mutateConfig((next) => next.autoTorrent.enable = value),
+                  // Rtorrent16: the shim sets autoTorrent itself while rtorrent16 takes the torrents
+                  Rt16BtLock(
+                    key: const ValueKey('rt16-auto-torrent-lock'),
+                    locked: config.extra.rt16Rtorrent,
+                    child: SettingsItem(
+                      title: context.l10n.autoTorrentEnable,
+                      child: shad.Switch(
+                        value: config.autoTorrent.enable,
+                        onChanged: (value) => _mutateConfig((next) => next.autoTorrent.enable = value),
+                      ),
                     ),
                   ),
                   if (config.autoTorrent.enable)
-                    SettingsItem(
-                      title: context.l10n.autoTorrentDeleteAfterDownload,
-                      child: shad.Switch(
-                        value: config.autoTorrent.deleteAfterDownload,
-                        onChanged: (value) => _mutateConfig((next) => next.autoTorrent.deleteAfterDownload = value),
+                    Rt16BtLock(
+                      locked: config.extra.rt16Rtorrent,
+                      child: SettingsItem(
+                        title: context.l10n.autoTorrentDeleteAfterDownload,
+                        child: shad.Switch(
+                          value: config.autoTorrent.deleteAfterDownload,
+                          onChanged: (value) => _mutateConfig((next) => next.autoTorrent.deleteAfterDownload = value),
+                        ),
                       ),
                     ),
                   SettingsItem(
@@ -612,71 +621,78 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
               ),
             ),
             _SettingsBlock(
+              key: const ValueKey('settings-bt-block'),
               title: 'BitTorrent',
-              child: _SettingsGroup(
-                children: [
-                  if (Util.isWindows())
-                    SettingsItem(
-                      title: context.l10n.setAsDefaultBtClient,
-                      child: shad.Switch(
-                        value: config.extra.defaultBtClient,
-                        onChanged: (value) => unawaited(_setDefaultBtClient(value)),
+              // Rtorrent16: greyed out, not hidden, while rtorrent16 takes the torrents and magnets
+              child: Rt16BtLock(
+                key: const ValueKey('rt16-bt-lock'),
+                locked: config.extra.rt16Rtorrent,
+                child: _SettingsGroup(
+                  children: [
+                    if (Util.isWindows())
+                      SettingsItem(
+                        title: context.l10n.setAsDefaultBtClient,
+                        child: shad.Switch(
+                          value: config.extra.defaultBtClient,
+                          onChanged: (value) => unawaited(_setDefaultBtClient(value)),
+                        ),
                       ),
-                    ),
-                  SettingsItem(
-                    title: context.l10n.listenPort,
-                    child: _NumberSettingControl(
-                      fieldKey: const ValueKey('bt-listen-port-input'),
-                      controller: _btListenPortController,
-                      min: 0,
-                      max: 65535,
-                    ),
-                  ),
-                  SettingsItem(
-                    title: context.l10n.seedKeep,
-                    child: shad.Switch(
-                      value: config.protocolConfig.bt.seedKeep,
-                      onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.seedKeep = value),
-                    ),
-                  ),
-                  if (!config.protocolConfig.bt.seedKeep) ...[
                     SettingsItem(
-                      title: context.l10n.seedRatio,
+                      title: context.l10n.listenPort,
                       child: _NumberSettingControl(
-                        fieldKey: const ValueKey('bt-seed-ratio-input'),
-                        controller: _btSeedRatioController,
+                        fieldKey: const ValueKey('bt-listen-port-input'),
+                        controller: _btListenPortController,
                         min: 0,
-                        step: 0.1,
-                        decimalPlaces: 2,
+                        max: 65535,
                       ),
                     ),
                     SettingsItem(
-                      title: context.l10n.seedTime,
-                      child: _NumberSettingControl(
-                        fieldKey: const ValueKey('bt-seed-time-input'),
-                        controller: _btSeedTimeController,
-                        min: 0,
-                        max: 100000000,
+                      title: context.l10n.seedKeep,
+                      child: shad.Switch(
+                        value: config.protocolConfig.bt.seedKeep,
+                        onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.seedKeep = value),
                       ),
                     ),
-                  ],
-                  SettingsItem(
-                    title: context.l10n.subscribeTracker,
-                    child: _TrackerSubscriptionsControl(
-                      selected: config.extra.bt.trackerSubscribeUrls,
-                      autoUpdate: config.extra.bt.autoUpdateTrackers,
-                      lastUpdated: config.extra.bt.lastTrackerUpdateTime,
-                      onChanged: (urls) => _mutateConfig((next) => next.extra.bt.trackerSubscribeUrls = urls),
-                      onAutoUpdateChanged: (value) => _mutateConfig((next) => next.extra.bt.autoUpdateTrackers = value),
-                      onUpdate: _updateTrackers,
+                    if (!config.protocolConfig.bt.seedKeep) ...[
+                      SettingsItem(
+                        title: context.l10n.seedRatio,
+                        child: _NumberSettingControl(
+                          fieldKey: const ValueKey('bt-seed-ratio-input'),
+                          controller: _btSeedRatioController,
+                          min: 0,
+                          step: 0.1,
+                          decimalPlaces: 2,
+                        ),
+                      ),
+                      SettingsItem(
+                        title: context.l10n.seedTime,
+                        child: _NumberSettingControl(
+                          fieldKey: const ValueKey('bt-seed-time-input'),
+                          controller: _btSeedTimeController,
+                          min: 0,
+                          max: 100000000,
+                        ),
+                      ),
+                    ],
+                    SettingsItem(
+                      title: context.l10n.subscribeTracker,
+                      child: _TrackerSubscriptionsControl(
+                        selected: config.extra.bt.trackerSubscribeUrls,
+                        autoUpdate: config.extra.bt.autoUpdateTrackers,
+                        lastUpdated: config.extra.bt.lastTrackerUpdateTime,
+                        onChanged: (urls) => _mutateConfig((next) => next.extra.bt.trackerSubscribeUrls = urls),
+                        onAutoUpdateChanged: (value) =>
+                            _mutateConfig((next) => next.extra.bt.autoUpdateTrackers = value),
+                        onUpdate: _updateTrackers,
+                      ),
                     ),
-                  ),
-                  SettingsItem(
-                    title: context.l10n.addTracker,
-                    subtitle: context.l10n.onePerLine,
-                    child: _TextSettingControl(controller: _customTrackersController, minLines: 7),
-                  ),
-                ],
+                    SettingsItem(
+                      title: context.l10n.addTracker,
+                      subtitle: context.l10n.onePerLine,
+                      child: _TextSettingControl(controller: _customTrackersController, minLines: 7),
+                    ),
+                  ],
+                ),
               ),
             ),
             _SettingsBlock(
diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart
index 554b07f..a73dd81 100644
--- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart
+++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart
@@ -36,6 +36,7 @@ import '../../../../shared/widgets/app_path_picker_field.dart';
 import '../../../../shared/widgets/app_text_field.dart';
 import '../../../../shared/widgets/app_tooltip.dart';
 import '../../../../shared/widgets/app_toast.dart';
+import '../../../../shared/widgets/rt16_bt_lock.dart';
 import '../../../../l10n/l10n.dart';
 import '../../../../util/util.dart';
 import '../../application/pending_create_task.dart';
@@ -78,6 +79,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
   // null follows the global setting (_globalAdaptive) until the user toggles it
   bool? _adaptive;
   bool _globalAdaptive = false;
+  // Rtorrent16: the server's extra.rt16.rtorrent, true while the shim hands torrents and magnets to rtorrent16
+  bool _rt16Rtorrent = false;
   bool? _autoTorrent;
   bool? _deleteTorrentAfterDownload;
   bool? _autoExtract;
@@ -668,24 +671,31 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
                                 ),
                               ),
                               const SizedBox(height: 14),
-                              AppFormRow(
-                                direction: stacked ? Axis.vertical : Axis.horizontal,
-                                label: context.l10n.autoTorrentEnable,
-                                child: _OptionSwitch(
-                                  key: const ValueKey('create-task-auto-torrent'),
-                                  value: _autoTorrent ?? false,
-                                  onChanged: (value) => setState(() => _autoTorrent = value),
+                              // Rtorrent16: a downloaded .torrent goes to rtorrent16 through the shim's hook
+                              Rt16BtLock(
+                                locked: _rt16Rtorrent,
+                                child: AppFormRow(
+                                  direction: stacked ? Axis.vertical : Axis.horizontal,
+                                  label: context.l10n.autoTorrentEnable,
+                                  child: _OptionSwitch(
+                                    key: const ValueKey('create-task-auto-torrent'),
+                                    value: _autoTorrent ?? false,
+                                    onChanged: (value) => setState(() => _autoTorrent = value),
+                                  ),
                                 ),
                               ),
                               if (_autoTorrent == true) ...[
                                 const SizedBox(height: 12),
-                                AppFormRow(
-                                  direction: stacked ? Axis.vertical : Axis.horizontal,
-                                  label: context.l10n.autoTorrentDeleteAfterDownload,
-                                  child: _OptionSwitch(
-                                    key: const ValueKey('create-task-delete-torrent'),
-                                    value: _deleteTorrentAfterDownload ?? false,
-                                    onChanged: (value) => setState(() => _deleteTorrentAfterDownload = value),
+                                Rt16BtLock(
+                                  locked: _rt16Rtorrent,
+                                  child: AppFormRow(
+                                    direction: stacked ? Axis.vertical : Axis.horizontal,
+                                    label: context.l10n.autoTorrentDeleteAfterDownload,
+                                    child: _OptionSwitch(
+                                      key: const ValueKey('create-task-delete-torrent'),
+                                      value: _deleteTorrentAfterDownload ?? false,
+                                      onChanged: (value) => setState(() => _deleteTorrentAfterDownload = value),
+                                    ),
                                   ),
                                 ),
                               ],
@@ -723,23 +733,28 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
                                 ),
                               ],
                             ] else ...[
-                              AppFormRow(
-                                direction: stacked ? Axis.vertical : Axis.horizontal,
-                                label: context.l10n.trackers,
-                                child: SizedBox(
-                                  height: 96,
-                                  child: AppTextField(
-                                    controller: _trackersController,
-                                    hintText: context.l10n.oneTrackerPerLine,
-                                    keyboardType: TextInputType.multiline,
-                                    textInputAction: TextInputAction.newline,
-                                    maxLines: null,
-                                    minLines: null,
-                                    expands: true,
-                                    textAlignVertical: TextAlignVertical.top,
-                                    filled: true,
-                                    border: Border.all(color: palette.border),
-                                    borderRadius: BorderRadius.circular(4),
+                              // Rtorrent16: the shim does not pass these trackers on to rtorrent16
+                              Rt16BtLock(
+                                key: const ValueKey('create-task-rt16-bt-lock'),
+                                locked: _rt16Rtorrent,
+                                child: AppFormRow(
+                                  direction: stacked ? Axis.vertical : Axis.horizontal,
+                                  label: context.l10n.trackers,
+                                  child: SizedBox(
+                                    height: 96,
+                                    child: AppTextField(
+                                      controller: _trackersController,
+                                      hintText: context.l10n.oneTrackerPerLine,
+                                      keyboardType: TextInputType.multiline,
+                                      textInputAction: TextInputAction.newline,
+                                      maxLines: null,
+                                      minLines: null,
+                                      expands: true,
+                                      textAlignVertical: TextAlignVertical.top,
+                                      filled: true,
+                                      border: Border.all(color: palette.border),
+                                      borderRadius: BorderRadius.circular(4),
+                                    ),
                                   ),
                                 ),
                               ),
@@ -821,6 +836,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
           _asDefaultPath = false;
         }
         _globalAdaptive = config.protocolConfig.http.adaptive;
+        _rt16Rtorrent = config.extra.rt16Rtorrent;
         final connections = config.protocolConfig.http.connections;
         if (connections > 0 && (_connectionsController.text.isEmpty || _connectionsController.text == '16')) {
           _connectionsController.text = connections.toString();
diff --git a/ui/flutter/lib/shared/widgets/rt16_bt_lock.dart b/ui/flutter/lib/shared/widgets/rt16_bt_lock.dart
new file mode 100644
index 0000000..1f09ae3
--- /dev/null
+++ b/ui/flutter/lib/shared/widgets/rt16_bt_lock.dart
@@ -0,0 +1,53 @@
+import 'package:flutter/material.dart' show Icons;
+import 'package:flutter/widgets.dart';
+
+import '../theme/app_palette.dart';
+
+/// The line over Gopeed's BitTorrent settings while Rtorrent16 takes the torrents. It is the add-on owner's
+/// wording and the same in every language, so it is a literal and not in the l10n catalogue.
+const rt16BtLockLabel = 'Rtorrent16 enabled';
+
+/// Rtorrent16: while the add-on's shim hands torrents and magnets to rtorrent16, Gopeed's own BitTorrent
+/// settings have no effect. With [locked] the [child] stays on the page, greyed out, and takes no pointer or
+/// keyboard input, under a line that says why. Without it the child is returned as it is.
+class Rt16BtLock extends StatelessWidget {
+  const Rt16BtLock({super.key, required this.locked, required this.child});
+
+  final bool locked;
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    if (!locked) {
+      return child;
+    }
+    final palette = AppPalette.of(context);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        Padding(
+          padding: const EdgeInsets.only(left: 2, bottom: 8),
+          child: Row(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Icon(Icons.lock_outline, size: 14, color: palette.textPrimary),
+              const SizedBox(width: 6),
+              Text(
+                rt16BtLockLabel,
+                key: const ValueKey('rt16-bt-lock-label'),
+                style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600),
+              ),
+            ],
+          ),
+        ),
+        Semantics(
+          enabled: false,
+          child: ExcludeFocus(
+            child: AbsorbPointer(child: Opacity(opacity: 0.45, child: child)),
+          ),
+        ),
+      ],
+    );
+  }
+}
diff --git a/ui/flutter/test/rt16/rt16_bt_lock_test.dart b/ui/flutter/test/rt16/rt16_bt_lock_test.dart
new file mode 100644
index 0000000..ce16819
--- /dev/null
+++ b/ui/flutter/test/rt16/rt16_bt_lock_test.dart
@@ -0,0 +1,184 @@
+// Rtorrent16's local patch: Gopeed's BitTorrent settings greyed out while the add-on's shim hands torrents
+// and magnets to rtorrent16. The shim says so in GET /api/v1/config as extra.rt16.rtorrent.
+import 'dart:convert';
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gopeed/api/model/downloader_config.dart';
+import 'package:gopeed/app/application/app_runtime_controller.dart';
+import 'package:gopeed/core/capabilities/app_capabilities.dart';
+import 'package:gopeed/core/capabilities/capability_rpc.dart';
+import 'package:gopeed/core/capabilities/gopeed_capability.dart';
+import 'package:gopeed/core/common/api_server_state.dart';
+import 'package:gopeed/core/common/start_config.dart';
+import 'package:gopeed/features/settings/application/settings_controller.dart';
+import 'package:gopeed/shared/theme/app_component_themes.dart';
+import 'package:gopeed/shared/theme/app_theme.dart';
+import 'package:gopeed/shared/widgets/rt16_bt_lock.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
+
+// GET /api/v1/config through the shim with the bridge on, from the real Gopeed 2.0.0-beta.3+rt16 binary and
+// the shim of 2026-10-03 (paths shortened).
+const _shimAnswer =
+    '{"code":0,"data":{"api":null,"archive":{"autoExtract":false,"deleteAfterExtract":false},'
+    '"autoDeleteMissingFileTasks":false,"autoStartTasks":false,"autoTorrent":{"deleteAfterDownload":false,"enable":false},'
+    '"downloadDir":"/share/Download/gopeed","extra":{"analyticsEnabled":false,"bt":{"autoUpdateTrackers":true,'
+    '"customTrackers":[],"subscribeTrackers":[],"trackerSubscribeUrls":[]},"rt16":{"rtorrent":true}},"maxRunning":5,'
+    '"protocolConfig":{"bt":{"listenPort":0,"seedKeep":false,"seedRatio":1,"seedTime":7200,"trackers":[]},'
+    '"ed2k":{"listenPort":0,"nodesDat":"https://upd.emule-security.org/nodes.dat","serverAddr":"45.82.80.155:5687",'
+    '"serverMet":"ed2k://|serverlist|http://upd.emule-security.org/server.met|/","udpPort":0},"ftp":{"connections":4},'
+    '"hls":{"maxRetries":5,"prefetchContentLength":true,"segmentConnections":8,"timeoutSeconds":30},'
+    '"http":{"adaptive":false,"connections":16,"useServerCtime":false,"userAgent":"Mozilla/5.0"}},'
+    '"proxy":{"enable":false,"host":"","pwd":"","scheme":"","system":false,"usr":""},'
+    '"script":{"enable":true,"paths":["/share/CACHEDEV1_DATA/.qpkg/Rtorrent16-Gopeed/bin/rt16-gopeed"]},'
+    '"webhook":{"enable":false,"urls":null}},"msg":""}';
+
+DownloaderConfig _parse(Map<String, dynamic> data) => DownloaderConfig.fromJson(data);
+
+Map<String, dynamic> _data() => (jsonDecode(_shimAnswer) as Map<String, dynamic>)['data'] as Map<String, dynamic>;
+
+void main() {
+  group('extra.rt16 in the configuration', () {
+    test('the shim\'s real answer parses, flag and all, and keeps maxRunning', () {
+      final config = _parse(_data());
+      expect(config.extra.rt16Rtorrent, isTrue);
+      expect(config.maxRunning, 5);
+      expect(config.extra.analyticsEnabled, isFalse);
+    });
+
+    test('without the key (bridge off) it parses to false and does not throw', () {
+      final data = _data();
+      (data['extra'] as Map<String, dynamic>).remove('rt16');
+      final config = _parse(data);
+      expect(config.extra.rt16Rtorrent, isFalse);
+      expect(config.maxRunning, 5);
+    });
+
+    test('anything but rtorrent:true reads as false, and other unknown keys in extra are ignored', () {
+      for (final odd in <Object?>[
+        null,
+        true,
+        'yes',
+        <String, dynamic>{},
+        {'rtorrent': 'true'},
+        {'rtorrent': 1},
+      ]) {
+        final data = _data();
+        (data['extra'] as Map<String, dynamic>)
+          ..['rt16'] = odd
+          ..['somethingNew'] = {'a': 1};
+        expect(_parse(data).extra.rt16Rtorrent, isFalse, reason: 'rt16 = $odd');
+      }
+    });
+
+    test('a copy through toJson keeps the flag, and false writes no key at all', () {
+      final on = _parse(_data());
+      final json = on.toJson();
+      expect((json['extra'] as Map<String, dynamic>)['rt16'], {'rtorrent': true});
+      expect(
+        DownloaderConfig.fromJson(jsonDecode(jsonEncode(json)) as Map<String, dynamic>).extra.rt16Rtorrent,
+        isTrue,
+      );
+      final off = DownloaderConfig();
+      expect((off.toJson()['extra'] as Map<String, dynamic>).containsKey('rt16'), isFalse);
+    });
+  });
+
+  group('Rt16BtLock', () {
+    Future<List<bool>> pumpLock(WidgetTester tester, {required bool locked}) async {
+      final changes = <bool>[];
+      await tester.pumpWidget(
+        shad.ShadcnApp(
+          theme: AppTheme.dark(),
+          materialTheme: AppTheme.materialDark(),
+          home: AppComponentThemes(
+            child: Rt16BtLock(
+              key: const ValueKey('lock'),
+              locked: locked,
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  const Text('Listen port'),
+                  shad.Switch(key: const ValueKey('bt-switch'), value: false, onChanged: changes.add),
+                ],
+              ),
+            ),
+          ),
+        ),
+      );
+      await tester.pump();
+      return changes;
+    }
+
+    testWidgets('locked: the label is shown, the section stays visible and greyed, and takes no input', (tester) async {
+      final changes = await pumpLock(tester, locked: true);
+      expect(find.text('Rtorrent16 enabled'), findsOneWidget);
+      expect(find.text('Listen port'), findsOneWidget);
+      expect(find.byKey(const ValueKey('bt-switch')), findsOneWidget);
+      final opacity = tester.widget<Opacity>(
+        find.ancestor(of: find.byKey(const ValueKey('bt-switch')), matching: find.byType(Opacity)).first,
+      );
+      expect(opacity.opacity, lessThan(1));
+      expect(
+        find.ancestor(of: find.byKey(const ValueKey('bt-switch')), matching: find.byType(ExcludeFocus)),
+        findsWidgets,
+      );
+      await tester.tap(find.byKey(const ValueKey('bt-switch')), warnIfMissed: false);
+      await tester.pump();
+      expect(changes, isEmpty);
+    });
+
+    testWidgets('unlocked: no label, and the section works as before', (tester) async {
+      final changes = await pumpLock(tester, locked: false);
+      expect(find.text('Rtorrent16 enabled'), findsNothing);
+      expect(find.byType(Opacity), findsNothing);
+      await tester.tap(find.byKey(const ValueKey('bt-switch')));
+      await tester.pump();
+      expect(changes, [true]);
+    });
+  });
+
+  test('a settings save takes the flag from the server\'s answer of now', () async {
+    var rtorrent = true;
+    DownloaderConfig? saved;
+    final registry = CapabilityRegistry(createAppCapabilityCodecs())
+      ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig()..extra.rt16Rtorrent = rtorrent)
+      ..bind(GopeedMethods.putConfig, (config) {
+        saved = DownloaderConfig.fromJson(config.toJson());
+        return const RpcUnit();
+      });
+    final container = ProviderContainer(
+      overrides: [
+        appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
+        appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
+      ],
+    );
+    addTearDown(container.dispose);
+
+    final controller = container.read(settingsControllerProvider.notifier);
+    expect((await container.read(settingsControllerProvider.future)).config.extra.rt16Rtorrent, isTrue);
+    rtorrent = false;
+    await controller.save(DownloaderConfig(downloadDir: 'E:/New')..extra.rt16Rtorrent = true);
+    expect(saved?.downloadDir, 'E:/New');
+    expect(container.read(settingsControllerProvider).value?.config.extra.rt16Rtorrent, isFalse);
+  });
+}
+
+class _TestRuntimeController extends AppRuntimeController {
+  @override
+  Future<AppRuntimeState> build() async => AppRuntimeState(
+    startConfig: StartConfig(),
+    apiServerState: const ApiServerState(
+      enabled: false,
+      mcpEnabled: false,
+      running: false,
+      network: '',
+      address: '',
+      runningPort: 0,
+      pendingApply: false,
+      lastError: '',
+    ),
+    downloaderConfig: DownloaderConfig(),
+  );
+}
-- 
2.55.0

