btkey-sync 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
actions.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from exporters import export_bond_key
4
+ from importers import load_bond_from_reg_file
5
+ from platform_detect import OSKind
6
+ from storage import ensure_exports_dir
7
+ import tui_helpers as tui
8
+ import actions_common as common
9
+ from actions_windows import run_offline_windows_export
10
+ from actions_verify import verify_and_connect, run_verify_flow # noqa: F401
11
+ from actions_remove import run_remove_flow # noqa: F401
12
+
13
+
14
+ def _ensure_device_name(bond) -> None:
15
+ """If bond has no name, prompt the user to enter one interactively."""
16
+ if not bond.device_name:
17
+ name = tui.ask("Device has no name — enter a label (Enter to skip)", default="")
18
+ if name.strip():
19
+ bond.device_name = name.strip()
20
+
21
+
22
+ def run_export_flow() -> None:
23
+ tui.header("Select & Extract — Step 1: System detection")
24
+ env, backend = common.detect_and_validate()
25
+ tui.header("Select & Extract — Step 2: Choose extraction source")
26
+ source_os, win_mount = common.prompt_source_os(env.os_kind)
27
+ if source_os == OSKind.WINDOWS and env.os_kind == OSKind.LINUX:
28
+ run_offline_windows_export(win_mount, export_bond_key, ensure_exports_dir)
29
+ return
30
+ tui.header("Select & Extract — Step 3: Choose a device")
31
+ with tui.Spinner("Scanning bonded BLE devices…"):
32
+ devices = backend.list_devices()
33
+ chosen = common.prompt_select_device(devices) if devices else None
34
+ if not chosen:
35
+ tui.info("No devices or cancelled.")
36
+ return
37
+ tui.header("Select & Extract — Step 4: Extract and save")
38
+ with tui.Spinner(f"Extracting bonding for {chosen.device_mac}…"):
39
+ bond = backend.extract_bond_key(chosen)
40
+ bond.source_os = source_os.value
41
+ common.print_bond_summary(bond)
42
+ _ensure_device_name(bond)
43
+ with tui.Spinner("Writing export files…"):
44
+ exports_dir = ensure_exports_dir()
45
+ reg_path = export_bond_key(bond)
46
+ print()
47
+ tui.ok(f"Exports folder : {tui.dim(str(exports_dir))}")
48
+ tui.ok(f"File generated : {tui.bold(reg_path.name)}")
49
+ tui.ok(f"Metadata (JSON): {tui.bold(reg_path.with_suffix('.json').name)}")
50
+
51
+
52
+ def _pick_adapter(backend, default_adapter: str | None = None) -> str | None:
53
+ adapters = backend._list_adapters()
54
+ if not adapters:
55
+ tui.err("No Bluetooth adapters found in /var/lib/bluetooth.")
56
+ return None
57
+ if len(adapters) == 1:
58
+ return adapters[0]
59
+ print(f"\n {tui.bold('Multiple Bluetooth adapters found on this system:')}\n")
60
+ for i, a in enumerate(adapters, 1):
61
+ mark = tui.green(" (matches file)") if a == default_adapter else ""
62
+ print(f" {tui.cyan(f'[{i}]')} {a}{mark}")
63
+ print()
64
+ while True:
65
+ raw = tui.ask("Adapter index")
66
+ if raw.isdigit():
67
+ idx = int(raw)
68
+ if 1 <= idx <= len(adapters):
69
+ return adapters[idx - 1]
70
+ tui.warn("Invalid index, try again.")
71
+
72
+
73
+ def run_import_flow(reg_file_arg: str | None = None) -> None:
74
+ tui.header("Import & Reconnect — Step 1: System detection")
75
+ env, backend = common.detect_and_validate()
76
+ if env.os_kind != OSKind.LINUX:
77
+ tui.err("Import is only supported when running on Linux (target system).")
78
+ return
79
+ tui.header("Import & Reconnect — Step 2: Select file")
80
+ reg_path = Path(reg_file_arg) if reg_file_arg else common.prompt_select_export_file()
81
+ if not reg_path or not reg_path.exists():
82
+ tui.err(f"File not found: {reg_path}")
83
+ return
84
+ tui.ok(f"File: {tui.bold(reg_path.name)}")
85
+ tui.header("Import & Reconnect — Step 3: Inspect key")
86
+ try:
87
+ bond = load_bond_from_reg_file(reg_path)
88
+ except Exception as e:
89
+ tui.err(f"Failed to parse {reg_path.name}: {e}")
90
+ return
91
+ common.print_bond_summary(bond)
92
+ tui.header("Import & Reconnect — Step 4: Map local device")
93
+ chosen_adapter = _pick_adapter(backend, default_adapter=bond.adapter_mac)
94
+ if not chosen_adapter:
95
+ return
96
+ bond.adapter_mac = chosen_adapter
97
+ with tui.Spinner("Scanning existing devices in BlueZ…"):
98
+ existing = backend.list_devices()
99
+ local_match = next((d for d in existing if d.device_mac.upper() == bond.device_mac.upper()), None)
100
+ ltk_match = None
101
+ if not local_match:
102
+ for d in existing:
103
+ if d.has_ltk:
104
+ try:
105
+ local_bond = backend.extract_bond_key(d)
106
+ if (local_bond.ltk_hex.upper() == bond.ltk_hex.upper() or
107
+ (bond.irk_hex and local_bond.irk_hex and
108
+ local_bond.irk_hex.upper() == bond.irk_hex.upper())):
109
+ ltk_match = d
110
+ if not bond.device_name and local_bond.device_name:
111
+ bond.device_name = local_bond.device_name
112
+ break
113
+ except Exception:
114
+ pass
115
+ source_device_mac = None
116
+ if ltk_match:
117
+ tui.info(f"Matched physical device under a different MAC address: {ltk_match.device_mac}")
118
+ q = f"Migrate profile/cache from {ltk_match.device_mac} to {bond.device_mac}? [Y/n]"
119
+ if tui.ask(q, default="Y").upper() == "Y":
120
+ source_device_mac = ltk_match.device_mac
121
+ elif local_match:
122
+ tui.info("Device found locally but has no LTK.")
123
+ else:
124
+ tui.info("Device not paired locally yet.")
125
+
126
+ print()
127
+ target_mac = tui.ask("Destination MAC (Enter to keep original)", default=bond.device_mac)
128
+ target_mac = target_mac if target_mac != bond.device_mac else None
129
+
130
+ tui.header("Import — Step 5: Write and restart Bluetooth")
131
+ with tui.Spinner("Writing bonding to disk…"):
132
+ written_path = backend.import_bond_key(
133
+ bond, target_device_mac=target_mac, source_device_mac=source_device_mac
134
+ )
135
+ tui.ok(f"Written to: {tui.dim(str(written_path))}")
136
+ tui.info("Restarting Bluetooth service to apply changes. This may take a moment…")
137
+ with tui.Spinner("Restarting Bluetooth service…"):
138
+ backend.restart_bluetooth_stack()
139
+ tui.ok("Bluetooth service restarted.")
140
+ verify_and_connect(backend, target_mac or bond.device_mac)
141
+
142
+
143
+ def run_clone_flow() -> None:
144
+ """Delegates to unified clone flow for BLE, Classic, and Dual-Mode."""
145
+ from actions_clone import run_unified_clone_flow
146
+ run_unified_clone_flow()
actions_classic.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ actions_classic.py
3
+
4
+ Top-level orchestration for Classic (BR/EDR) export and import flows.
5
+
6
+ Implements RC5, RC6, RC7, RC9, RC11 from CLASSIC_SUPPORT.agent.md.
7
+ Each step of the bonding lifecycle is sequenced explicitly here — the backend
8
+ exposes discrete methods (RC9) and this layer calls them in order.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import tui_helpers as tui
16
+ import actions_common as common
17
+ from exporters.classic_exporter import export_classic_bond
18
+ from importers.classic_importer import load_classic_bond_from_reg_file
19
+ from platform_detect import OSKind
20
+ from actions_verify import verify_and_connect
21
+
22
+
23
+ def _filter_classic_devices(backend, devices):
24
+ """Return only devices whose info file has a [LinkKey] section."""
25
+ import configparser
26
+ classic = []
27
+ for d in devices:
28
+ info_path = Path(d.raw_source_path)
29
+ if not info_path.exists():
30
+ continue
31
+ cfg = configparser.ConfigParser()
32
+ try:
33
+ cfg.read(info_path)
34
+ if "LinkKey" in cfg:
35
+ classic.append(d)
36
+ except Exception:
37
+ pass
38
+ return classic
39
+
40
+
41
+ def run_classic_export_flow() -> None:
42
+ """
43
+ Full Classic export flow:
44
+ pre-flight → pick device → extract bond → write .reg + .json.
45
+ """
46
+ tui.header("Classic Export — Step 1: System detection")
47
+ env, backend = common.detect_and_validate()
48
+
49
+ tui.header("Classic Export — Pre-flight Check")
50
+ if not tui.warn_classic_preflight():
51
+ tui.info("Aborted — pre-flight not acknowledged.")
52
+ return
53
+
54
+ tui.header("Classic Export — Step 2: Choose device")
55
+ with tui.Spinner("Scanning bonded devices…"):
56
+ all_devices = backend.list_devices()
57
+
58
+ classic_devices = _filter_classic_devices(backend, all_devices)
59
+ if not classic_devices:
60
+ tui.warn("No Classic (BR/EDR) bonded devices found.")
61
+ tui.info("Classic devices have a [LinkKey] section in their BlueZ info file.")
62
+ return
63
+
64
+ chosen = common.prompt_select_device(classic_devices)
65
+ if not chosen:
66
+ tui.info("Cancelled.")
67
+ return
68
+
69
+ tui.header("Classic Export — Step 3: Extract and save")
70
+ with tui.Spinner(f"Extracting Classic bond for {chosen.device_mac}…"):
71
+ bond = backend.extract_classic_bond(chosen)
72
+
73
+ tui.info(bond.summary())
74
+
75
+ with tui.Spinner("Writing export files…"):
76
+ reg_path = export_classic_bond(bond)
77
+
78
+ tui.ok(f"Exported to: {tui.bold(reg_path.name)}")
79
+ tui.warn("Keep the device powered ON. Do NOT power-cycle it before importing.")
80
+
81
+
82
+ def run_classic_import_flow(reg_file_arg: str | None = None) -> None:
83
+ """
84
+ Full Classic import flow (RC5, RC9, RC11):
85
+ pre-flight → load .reg → check active link → confirm remove →
86
+ stop BT → remove → write info → set perms → start BT → post-import warnings.
87
+ """
88
+ tui.header("Classic Import — Step 1: System detection")
89
+ env, backend = common.detect_and_validate()
90
+
91
+ if env.os_kind != OSKind.LINUX:
92
+ tui.err("Classic import (write destination) is only supported on Linux.")
93
+ return
94
+
95
+ tui.header("Classic Import — Pre-flight Check")
96
+ if not tui.warn_classic_preflight():
97
+ tui.info("Aborted — pre-flight not acknowledged.")
98
+ return
99
+
100
+ tui.header("Classic Import — Step 2: Load bond key")
101
+ reg_path = Path(reg_file_arg) if reg_file_arg else common.prompt_select_export_file()
102
+ if not reg_path:
103
+ tui.info("Cancelled.")
104
+ return
105
+ if not reg_path.exists():
106
+ tui.err(f"File not found: {reg_path}")
107
+ return
108
+
109
+ with tui.Spinner(f"Parsing {reg_path.name}…"):
110
+ bond = load_classic_bond_from_reg_file(reg_path)
111
+
112
+ tui.info(bond.summary())
113
+
114
+ if not bond.device_name:
115
+ name = tui.ask("Device has no name — enter a label (Enter to skip)", default="")
116
+ if name.strip():
117
+ bond.device_name = name.strip()
118
+
119
+ tui.header("Classic Import — Step 3: Pre-write checks")
120
+
121
+ # RC11: check for active connection before touching bonding state
122
+ with tui.Spinner(f"Checking if {bond.device_mac} is currently connected…"):
123
+ is_connected = backend.check_active_link(bond.device_mac)
124
+
125
+ if is_connected:
126
+ tui.warn(f"Device {bond.device_mac} is currently connected!")
127
+ tui.info("Writing bonding state over an active connection may leave BlueZ inconsistent.")
128
+ confirm = tui.ask("Proceed anyway? [y/N]", default="N")
129
+ if confirm.strip().lower() != "y":
130
+ tui.info("Aborted. Disconnect the device first, then re-run.")
131
+ return
132
+
133
+ # RC5: remove existing bonding folder with explicit user confirmation
134
+ target_dir = (backend.bluetooth_dir / bond.adapter_mac / bond.device_mac)
135
+ if target_dir.exists():
136
+ tui.warn(f"Existing bonding folder found: {target_dir}")
137
+ tui.info("It must be removed before writing the new Classic bond (RC5).")
138
+ tui.info("A timestamped backup will be kept automatically.")
139
+ confirm_rm = tui.ask("Remove existing bonding? [y/N]", default="N")
140
+ if confirm_rm.strip().lower() != "y":
141
+ tui.info("Aborted. Existing bonding preserved.")
142
+ return
143
+
144
+ tui.header("Classic Import — Step 4: Write bond to disk")
145
+
146
+ with tui.Spinner("Stopping Bluetooth service…"):
147
+ backend.stop_bluetooth()
148
+ tui.ok("Bluetooth service stopped.")
149
+
150
+ if target_dir.exists():
151
+ with tui.Spinner(f"Removing {bond.device_mac} bonding folder…"):
152
+ backend.remove_bond_key(bond.adapter_mac, bond.device_mac)
153
+ tui.ok("Old bonding removed (backup kept).")
154
+
155
+ with tui.Spinner("Disabling discovery scan if active…"):
156
+ disabled = backend.disable_discovery_if_active()
157
+ if disabled:
158
+ tui.info("Discovery scan disabled.")
159
+
160
+ with tui.Spinner(f"Writing Classic info for {bond.device_mac}…"):
161
+ write_dir = backend.bluetooth_dir / bond.adapter_mac / bond.device_mac
162
+ info_path = backend.write_classic_info(bond, write_dir)
163
+ backend.set_info_permissions(info_path)
164
+ tui.ok(f"Written to: {tui.dim(str(info_path))}")
165
+
166
+ with tui.Spinner("Starting Bluetooth service…"):
167
+ backend.start_bluetooth()
168
+ tui.ok("Bluetooth service started.")
169
+
170
+ # RC7: mandatory post-import instructions
171
+ tui.warn_classic_post_import(bond.device_mac)
172
+
173
+ verify_and_connect(backend, bond.device_mac)
@@ -0,0 +1,187 @@
1
+ """
2
+ actions_classic_extra.py
3
+
4
+ Classic (BR/EDR) Bluetooth verification and cloning workflows.
5
+ Kept in a separate file to adhere to the 200-line limit (AGENTS.md R14).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from platform_detect import OSKind
12
+ import tui_helpers as tui
13
+ import actions_common as common
14
+ from models import LinkKeyBond
15
+ from actions_verify import verify_and_connect
16
+ from exporters.classic_exporter import export_classic_bond
17
+ from backends.offline_windows_classic import extract_classic_bt_keys_from_hive
18
+
19
+
20
+ def run_classic_verify_flow() -> None:
21
+ """Choose a bonded Classic device and attempt connection."""
22
+ tui.header("Verify & Connect (Classic) — Step 1: System detection")
23
+ env, backend = common.detect_and_validate()
24
+ if env.os_kind != OSKind.LINUX:
25
+ tui.err("Verify & Connect is only supported on Linux.")
26
+ return
27
+
28
+ tui.header("Verify & Connect (Classic) — Step 2: Choose device")
29
+ with tui.Spinner("Scanning bonded Classic devices…"):
30
+ all_devices = backend.list_devices()
31
+
32
+ from actions_classic import _filter_classic_devices
33
+ classic_devices = _filter_classic_devices(backend, all_devices)
34
+ if not classic_devices:
35
+ tui.warn("No Classic (BR/EDR) bonded devices found.")
36
+ return
37
+
38
+ chosen = common.prompt_select_device(classic_devices)
39
+ if not chosen:
40
+ tui.info("No device selected.")
41
+ return
42
+
43
+ verify_and_connect(backend, chosen.device_mac)
44
+
45
+
46
+ def _load_classic_hive(win_mount: Path | None):
47
+ """Locate hive, check reged, return Classic bonds list or None."""
48
+ from backends.offline_windows import reged_available
49
+ from actions_common import find_windows_system_hive
50
+ if not reged_available():
51
+ tui.err("reged not found. Install chntpw (e.g. `sudo apt install chntpw`).")
52
+ return None
53
+ hive = find_windows_system_hive(win_mount)
54
+ if not hive:
55
+ tui.err("Could not locate the Windows SYSTEM hive on the selected partition.")
56
+ return None
57
+ tui.ok(f"Hive: {tui.dim(str(hive))}")
58
+ with tui.Spinner("Reading Windows registry hive for Classic bonds…"):
59
+ bonds = extract_classic_bt_keys_from_hive(hive)
60
+ if not bonds:
61
+ tui.warn("No Classic (BR/EDR) bonding keys found in the Windows hive.")
62
+ return None
63
+ return bonds
64
+
65
+
66
+ def _choose_classic_bond(bonds) -> LinkKeyBond | None:
67
+ """Present a list and return the chosen LinkKeyBond."""
68
+ for i, b in enumerate(bonds, 1):
69
+ name = b.device_name or "Unknown Device"
70
+ print(f" [{i}] {tui.bold(name)} {tui.dim(b.device_mac)}")
71
+ raw = tui.ask("→ Device number [q to cancel]", default="q")
72
+ if raw.lower() == "q" or not raw.isdigit() or not (1 <= int(raw) <= len(bonds)):
73
+ tui.info("Cancelled.")
74
+ return None
75
+ return bonds[int(raw) - 1]
76
+
77
+
78
+ def _pick_source_classic_bond(source_os: OSKind, win_mount: Path | None, env, backend) -> LinkKeyBond | None:
79
+ """Extract a Classic bond from the chosen source (local Linux or Windows partition)."""
80
+ if source_os == OSKind.WINDOWS and env.os_kind == OSKind.LINUX:
81
+ bonds = _load_classic_hive(win_mount)
82
+ if not bonds:
83
+ return None
84
+ return _choose_classic_bond(bonds)
85
+
86
+ with tui.Spinner("Scanning bonded Classic devices…"):
87
+ all_devices = backend.list_devices()
88
+ from actions_classic import _filter_classic_devices
89
+ classic_devices = _filter_classic_devices(backend, all_devices)
90
+ if not classic_devices:
91
+ tui.warn("No bonded Classic devices found.")
92
+ return None
93
+ source = common.prompt_select_device(classic_devices)
94
+ if source is None:
95
+ tui.info("Cancelled.")
96
+ return None
97
+ with tui.Spinner(f"Extracting Classic bond for {source.device_mac}…"):
98
+ bond = backend.extract_classic_bond(source)
99
+ bond.source_os = source_os.value
100
+ return bond
101
+
102
+
103
+ def run_classic_clone_flow() -> None:
104
+ """Directly copy/clone a Classic bonding to the destination on Linux."""
105
+ tui.header("Classic Clone — Step 1: System detection")
106
+ env, backend = common.detect_and_validate()
107
+ if env.os_kind != OSKind.LINUX:
108
+ tui.err("Clone (write destination) is only supported on Linux.")
109
+ return
110
+
111
+ # RC6: Classic pre-flight safety warning
112
+ tui.header("Classic Clone — Pre-flight Check")
113
+ if not tui.warn_classic_preflight():
114
+ tui.info("Aborted — pre-flight not acknowledged.")
115
+ return
116
+
117
+ tui.header("Classic Clone — Step 2: Choose source")
118
+ source_os, win_mount = common.prompt_source_os(env.os_kind)
119
+ bond = _pick_source_classic_bond(source_os, win_mount, env, backend)
120
+ if bond is None:
121
+ return
122
+
123
+ tui.info(bond.summary())
124
+ if not bond.device_name:
125
+ name = tui.ask("Device has no name — enter a label (Enter to skip)", default="")
126
+ if name.strip():
127
+ bond.device_name = name.strip()
128
+
129
+ tui.header("Classic Clone — Step 3: Destination MAC")
130
+ target_mac = tui.ask("Destination MAC (Enter to keep original)", default=bond.device_mac)
131
+
132
+ tui.header("Classic Clone — Step 4: Pre-write checks")
133
+ # RC11: Check for active connection
134
+ with tui.Spinner(f"Checking if {target_mac} is currently connected…"):
135
+ is_connected = backend.check_active_link(target_mac)
136
+ if is_connected:
137
+ tui.warn(f"Device {target_mac} is currently connected!")
138
+ confirm = tui.ask("Proceed anyway? [y/N]", default="N")
139
+ if confirm.strip().lower() != "y":
140
+ tui.info("Aborted.")
141
+ return
142
+
143
+ # RC5: confirmation before overwriting
144
+ target_dir = backend.bluetooth_dir / bond.adapter_mac / target_mac
145
+ if target_dir.exists():
146
+ tui.warn(f"Existing bonding folder found: {target_dir}")
147
+ tui.info("It must be removed before writing (RC5). A backup will be kept.")
148
+ if tui.ask("Remove existing bonding? [y/N]", default="N").strip().lower() != "y":
149
+ tui.info("Aborted.")
150
+ return
151
+
152
+ tui.header("Classic Clone — Step 5: Write and restart Bluetooth")
153
+ with tui.Spinner("Stopping Bluetooth service…"):
154
+ backend.stop_bluetooth()
155
+
156
+ if target_dir.exists():
157
+ with tui.Spinner(f"Removing {target_mac} bonding folder…"):
158
+ backend.remove_bond_key(bond.adapter_mac, target_mac)
159
+
160
+ with tui.Spinner("Disabling discovery scan if active…"):
161
+ backend.disable_discovery_if_active()
162
+
163
+ with tui.Spinner(f"Writing Classic info for {target_mac}…"):
164
+ write_dir = backend.bluetooth_dir / bond.adapter_mac / target_mac
165
+ # Clone with target mac
166
+ bond_clone = LinkKeyBond(
167
+ adapter_mac=bond.adapter_mac,
168
+ device_mac=target_mac,
169
+ link_key_hex=bond.link_key_hex,
170
+ key_type=bond.key_type,
171
+ pin_length=bond.pin_length,
172
+ device_class=bond.device_class,
173
+ device_name=bond.device_name,
174
+ device_id=bond.device_id,
175
+ service_uuids=bond.service_uuids,
176
+ source_os=bond.source_os,
177
+ )
178
+ info_path = backend.write_classic_info(bond_clone, write_dir)
179
+ backend.set_info_permissions(info_path)
180
+
181
+ with tui.Spinner("Starting Bluetooth service…"):
182
+ backend.start_bluetooth()
183
+
184
+ # RC7: Post-import instructions
185
+ tui.warn_classic_post_import(target_mac)
186
+
187
+ verify_and_connect(backend, target_mac)
actions_clone.py ADDED
@@ -0,0 +1,166 @@
1
+ """
2
+ actions_clone.py
3
+
4
+ Unified, simplified cloning workflow for BLE, Classic, and Dual-Mode devices.
5
+ Maintains 200-line file limit (AGENTS.md R14).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from platform_detect import OSKind
12
+ import tui_helpers as tui
13
+ import actions_common as common
14
+ from backends import LinuxBluetoothBackend
15
+ from models import BondKey, LinkKeyBond
16
+ from actions_verify import force_push_sync, verify_and_connect
17
+ from actions_clone_loader import UnifiedDevice, load_windows_devices, load_linux_devices
18
+
19
+
20
+ def _resolve_target_adapter(backend: LinuxBluetoothBackend, src_adapter_mac: str) -> str | None:
21
+ """Finds or prompts for the local Linux Bluetooth adapter destination."""
22
+ local_adapters = backend._list_adapters()
23
+ if not local_adapters:
24
+ tui.err("No local Bluetooth adapters found under /var/lib/bluetooth.")
25
+ return None
26
+
27
+ if src_adapter_mac in local_adapters:
28
+ return src_adapter_mac
29
+ if len(local_adapters) == 1:
30
+ target = local_adapters[0]
31
+ if target.upper() != src_adapter_mac.upper():
32
+ tui.info(f"Using local adapter: {tui.bold(target)} {tui.dim(f'(source was {src_adapter_mac})')}")
33
+ return target
34
+
35
+ print(f"\n {tui.bold('Select local destination adapter:')}")
36
+ for i, ad in enumerate(local_adapters, 1):
37
+ print(f" [{i}] {ad}")
38
+ choice = tui.ask("Adapter number", default="1")
39
+ if choice.isdigit() and 1 <= int(choice) <= len(local_adapters):
40
+ return local_adapters[int(choice) - 1]
41
+ return local_adapters[0]
42
+
43
+
44
+ def _write_cloned_keys(backend: LinuxBluetoothBackend, chosen_dev: UnifiedDevice, target_mac: str, target_adapter: str, target_dir: Path) -> None:
45
+ """Writes Classic and/or BLE keys to BlueZ directory for cloned device."""
46
+ if chosen_dev.classic_bond:
47
+ cb = chosen_dev.classic_bond
48
+ bond_clone = LinkKeyBond(
49
+ adapter_mac=target_adapter,
50
+ device_mac=target_mac,
51
+ link_key_hex=cb.link_key_hex,
52
+ key_type=cb.key_type,
53
+ pin_length=cb.pin_length,
54
+ device_class=cb.device_class,
55
+ device_name=chosen_dev.name,
56
+ device_id=cb.device_id,
57
+ service_uuids=cb.service_uuids,
58
+ source_os=cb.source_os,
59
+ )
60
+ backend.write_classic_info(bond_clone, target_dir)
61
+
62
+ if chosen_dev.ble_bond:
63
+ bb = chosen_dev.ble_bond
64
+ bond_clone = BondKey(
65
+ adapter_mac=target_adapter,
66
+ device_mac=target_mac,
67
+ ltk_hex=bb.ltk_hex,
68
+ ediv=bb.ediv,
69
+ erand=bb.erand,
70
+ auth_req=bb.auth_req,
71
+ device_name=chosen_dev.name,
72
+ source_os=bb.source_os,
73
+ irk_hex=bb.irk_hex,
74
+ address_type=bb.address_type,
75
+ is_le=bb.is_le,
76
+ )
77
+ backend.import_bond_key(bond_clone, target_device_mac=target_mac)
78
+
79
+ backend.set_info_permissions(target_dir / "info")
80
+
81
+
82
+ def run_unified_clone_flow() -> None:
83
+ """Clones a device from source (Windows/Linux) automatically handling BLE/Classic/Both."""
84
+ tui.header("Clone Device — Step 1: System detection")
85
+ env, backend = common.detect_and_validate()
86
+ if env.os_kind != OSKind.LINUX or not isinstance(backend, LinuxBluetoothBackend):
87
+ tui.err("Cloning is only supported on Linux.")
88
+ return
89
+
90
+ tui.header("Clone Device — Step 2: Choose source")
91
+ source_os, win_mount = common.prompt_source_os(env.os_kind)
92
+ devices = load_windows_devices(win_mount) if source_os == OSKind.WINDOWS else load_linux_devices(backend)
93
+
94
+ if not devices:
95
+ tui.warn("No bonded devices found on the source.")
96
+ return
97
+
98
+ tui.header("Clone Device — Step 3: Choose device")
99
+ for i, dev in enumerate(devices, 1):
100
+ print(f" [{i}] {dev.summary()}")
101
+ print()
102
+
103
+ choice = tui.ask("Device number [q to cancel]", default="q")
104
+ if choice.lower() == "q" or not choice.isdigit() or not (1 <= int(choice) <= len(devices)):
105
+ tui.info("Cancelled.")
106
+ return
107
+
108
+ chosen_dev = devices[int(choice) - 1]
109
+
110
+ if chosen_dev.classic_bond:
111
+ tui.header("Classic/Dual-Mode Pre-flight Warning")
112
+ tui.warn("This device contains Bluetooth Classic bonding keys.")
113
+ tui.warn("Classic/Dual-Mode devices usually support only ONE active bonding slot.")
114
+ tui.info("Make sure the device is powered on, and do NOT put it in pairing mode during/after clone.")
115
+ if tui.ask("Type 'yes' to proceed", default="").lower() != "yes":
116
+ tui.info("Aborted.")
117
+ return
118
+
119
+ tui.header("Clone Device — Step 4: Destination MAC & Adapter")
120
+ target_mac = tui.ask("Destination MAC (Enter to keep original)", default=chosen_dev.mac)
121
+
122
+ bond = chosen_dev.ble_bond or chosen_dev.classic_bond
123
+ if not bond:
124
+ tui.err("No bonding keys found for this device.")
125
+ return
126
+
127
+ src_adapter = bond.adapter_mac
128
+ target_adapter = _resolve_target_adapter(backend, src_adapter)
129
+ if not target_adapter:
130
+ return
131
+
132
+ with tui.Spinner(f"Checking if {target_mac} is currently connected…"):
133
+ is_connected = backend.check_active_link(target_mac)
134
+ if is_connected:
135
+ tui.warn(f"Device {target_mac} is currently connected!")
136
+ if tui.ask("Proceed anyway? [y/N]", default="N").strip().lower() != "y":
137
+ tui.info("Aborted.")
138
+ return
139
+
140
+ target_dir = backend.bluetooth_dir / target_adapter / target_mac
141
+ if target_dir.exists():
142
+ tui.warn(f"Existing bonding folder found: {target_dir}")
143
+ if tui.ask("Over-write existing bonding? [y/N]", default="N").strip().lower() != "y":
144
+ tui.info("Aborted.")
145
+ return
146
+
147
+ tui.header("Clone Device — Step 5: Write and restart Bluetooth")
148
+ with tui.Spinner("Stopping Bluetooth service…"):
149
+ backend.stop_bluetooth()
150
+
151
+ if target_dir.exists():
152
+ backend.remove_bond_key(target_adapter, target_mac)
153
+
154
+ backend.disable_discovery_if_active()
155
+
156
+ with tui.Spinner(f"Writing bonding info for {target_mac}…"):
157
+ _write_cloned_keys(backend, chosen_dev, target_mac, target_adapter, target_dir)
158
+
159
+ with tui.Spinner("Starting Bluetooth service…"):
160
+ backend.start_bluetooth()
161
+
162
+ tui.ok("Bonding successfully cloned!")
163
+ if chosen_dev.classic_bond:
164
+ tui.warn_classic_post_import(target_mac)
165
+
166
+ force_push_sync(backend, target_mac)