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 +146 -0
- actions_classic.py +173 -0
- actions_classic_extra.py +187 -0
- actions_clone.py +166 -0
- actions_clone_loader.py +103 -0
- actions_common.py +184 -0
- actions_help.py +124 -0
- actions_remove.py +57 -0
- actions_show.py +48 -0
- actions_verify.py +177 -0
- actions_windows.py +70 -0
- backends/__init__.py +5 -0
- backends/base.py +74 -0
- backends/linux_backend.py +194 -0
- backends/linux_classic.py +116 -0
- backends/linux_common.py +101 -0
- backends/offline_windows.py +196 -0
- backends/offline_windows_classic.py +63 -0
- backends/windows_backend.py +191 -0
- backends/windows_common.py +182 -0
- btkey_sync-0.2.0.dist-info/METADATA +141 -0
- btkey_sync-0.2.0.dist-info/RECORD +36 -0
- btkey_sync-0.2.0.dist-info/WHEEL +5 -0
- btkey_sync-0.2.0.dist-info/entry_points.txt +2 -0
- btkey_sync-0.2.0.dist-info/top_level.txt +19 -0
- cli.py +157 -0
- exporters/__init__.py +7 -0
- exporters/classic_exporter.py +59 -0
- exporters/reg_exporter.py +58 -0
- importers/__init__.py +7 -0
- importers/classic_importer.py +37 -0
- importers/reg_importer.py +21 -0
- models.py +187 -0
- platform_detect.py +106 -0
- storage.py +176 -0
- tui_helpers.py +149 -0
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)
|
actions_classic_extra.py
ADDED
|
@@ -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)
|