lanscape 1.3.2a6__py3-none-any.whl → 1.3.2a9__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.

Potentially problematic release.


This version of lanscape might be problematic. Click here for more details.

Files changed (33) hide show
  1. lanscape/__init__.py +3 -3
  2. lanscape/__main__.py +1 -1
  3. lanscape/libraries/__init__.py +0 -0
  4. lanscape/libraries/app_scope.py +75 -0
  5. lanscape/libraries/decorators.py +153 -0
  6. lanscape/libraries/errors.py +32 -0
  7. lanscape/libraries/ip_parser.py +69 -0
  8. lanscape/libraries/logger.py +45 -0
  9. lanscape/libraries/mac_lookup.py +102 -0
  10. lanscape/libraries/net_tools.py +516 -0
  11. lanscape/libraries/port_manager.py +67 -0
  12. lanscape/libraries/runtime_args.py +54 -0
  13. lanscape/libraries/scan_config.py +97 -0
  14. lanscape/libraries/service_scan.py +50 -0
  15. lanscape/libraries/subnet_scan.py +338 -0
  16. lanscape/libraries/version_manager.py +56 -0
  17. lanscape/libraries/web_browser.py +142 -0
  18. lanscape/resources/mac_addresses/convert_csv.py +30 -0
  19. lanscape/resources/ports/convert_csv.py +30 -0
  20. lanscape/ui/app.py +128 -0
  21. lanscape/ui/blueprints/__init__.py +7 -0
  22. lanscape/ui/blueprints/api/__init__.py +3 -0
  23. lanscape/ui/blueprints/api/port.py +33 -0
  24. lanscape/ui/blueprints/api/scan.py +75 -0
  25. lanscape/ui/blueprints/api/tools.py +36 -0
  26. lanscape/ui/blueprints/web/__init__.py +3 -0
  27. lanscape/ui/blueprints/web/routes.py +78 -0
  28. lanscape/ui/main.py +137 -0
  29. {lanscape-1.3.2a6.dist-info → lanscape-1.3.2a9.dist-info}/METADATA +1 -1
  30. {lanscape-1.3.2a6.dist-info → lanscape-1.3.2a9.dist-info}/RECORD +33 -7
  31. {lanscape-1.3.2a6.dist-info → lanscape-1.3.2a9.dist-info}/WHEEL +0 -0
  32. {lanscape-1.3.2a6.dist-info → lanscape-1.3.2a9.dist-info}/licenses/LICENSE +0 -0
  33. {lanscape-1.3.2a6.dist-info → lanscape-1.3.2a9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,97 @@
1
+ from typing import List
2
+ import ipaddress
3
+ from pydantic import BaseModel, Field
4
+ from enum import Enum
5
+
6
+ from .port_manager import PortManager
7
+ from .ip_parser import parse_ip_input
8
+
9
+
10
+ class PingConfig(BaseModel):
11
+ attempts: int = 1
12
+ ping_count: int = 2
13
+ timeout: float = 2.0
14
+ retry_delay: float = 0.5
15
+
16
+ @classmethod
17
+ def from_dict(cls, data: dict) -> 'PingConfig':
18
+ return cls.model_validate(data)
19
+
20
+ def to_dict(self) -> dict:
21
+ return self.model_dump()
22
+
23
+ def __str__(self):
24
+ return (
25
+ f"PingCfg(attempts={self.attempts}, "
26
+ f"ping_count={self.ping_count}, "
27
+ f"timeout={self.timeout}, "
28
+ f"retry_delay={self.retry_delay})"
29
+ )
30
+
31
+
32
+ class ArpConfig(BaseModel):
33
+ """
34
+ Configuration for ARP scanning.
35
+ """
36
+ attempts: int = 1
37
+ timeout: float = 2.0
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: dict) -> 'ArpConfig':
41
+ return cls.model_validate(data)
42
+
43
+ def to_dict(self) -> dict:
44
+ return self.model_dump()
45
+
46
+ def __str__(self):
47
+ return f'ArpCfg(timeout={self.timeout}, attempts={self.attempts})'
48
+
49
+
50
+ class ScanType(Enum):
51
+ PING = 'ping'
52
+ ARP = 'arp'
53
+ BOTH = 'both'
54
+
55
+
56
+ class ScanConfig(BaseModel):
57
+ subnet: str
58
+ port_list: str
59
+ t_multiplier: float = 1.0
60
+ t_cnt_port_scan: int = 10
61
+ t_cnt_port_test: int = 128
62
+ t_cnt_isalive: int = 256
63
+
64
+ task_scan_ports: bool = True
65
+ # below wont run if above false
66
+ task_scan_port_services: bool = False # disabling until more stable
67
+
68
+ lookup_type: ScanType = ScanType.BOTH
69
+
70
+ ping_config: PingConfig = Field(default_factory=PingConfig)
71
+ arp_config: ArpConfig = Field(default_factory=ArpConfig)
72
+
73
+ def t_cnt(self, id: str) -> int:
74
+ return int(int(getattr(self, f't_cnt_{id}')) * float(self.t_multiplier))
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict) -> 'ScanConfig':
78
+ # Handle special cases before validation
79
+ if isinstance(data.get('lookup_type'), str):
80
+ data['lookup_type'] = ScanType[data['lookup_type'].upper()]
81
+
82
+ return cls.model_validate(data)
83
+
84
+ def to_dict(self) -> dict:
85
+ return self.model_dump()
86
+
87
+ def get_ports(self) -> List[int]:
88
+ return PortManager().get_port_list(self.port_list).keys()
89
+
90
+ def parse_subnet(self) -> List[ipaddress.IPv4Network]:
91
+ return parse_ip_input(self.subnet)
92
+
93
+ def __str__(self):
94
+ a = f'subnet={self.subnet}'
95
+ b = f'ports={self.port_list}'
96
+ c = f'scan_type={self.lookup_type.value}'
97
+ return f'ScanConfig({a}, {b}, {c})'
@@ -0,0 +1,50 @@
1
+ import asyncio
2
+ import logging
3
+ import traceback
4
+ from .app_scope import ResourceManager
5
+
6
+ log = logging.getLogger('ServiceScan')
7
+ SERVICES = ResourceManager('services').get_jsonc('definitions.jsonc')
8
+
9
+ # skip printer ports because they cause blank pages to be printed
10
+ PRINTER_PORTS = [9100, 631]
11
+
12
+
13
+ def scan_service(ip: str, port: int, timeout=10) -> str:
14
+ """
15
+ Synchronous function that attempts to identify the service running on a given port.
16
+ """
17
+
18
+ async def _async_scan_service(ip: str, port: int, timeout) -> str:
19
+ if port in PRINTER_PORTS:
20
+ return "Printer"
21
+
22
+ try:
23
+ # Add a timeout to prevent hanging
24
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=5)
25
+
26
+ # Send a probe appropriate for common services
27
+ probe = "GET / HTTP/1.1\r\nHost: {}\r\n\r\n".format(
28
+ ip).encode("utf-8")
29
+ writer.write(probe)
30
+ await writer.drain()
31
+
32
+ # Receive the response with a timeout
33
+ response = await asyncio.wait_for(reader.read(1024), timeout=timeout)
34
+ writer.close()
35
+ await writer.wait_closed()
36
+
37
+ # Analyze the response to identify the service
38
+ response_str = response.decode("utf-8", errors="ignore")
39
+ for service, hints in SERVICES.items():
40
+ if any(hint.lower() in response_str.lower() for hint in hints):
41
+ return service
42
+ except asyncio.TimeoutError:
43
+ log.warning(f"Timeout scanning {ip}:{port}")
44
+ except Exception as e:
45
+ log.error(f"Error scanning {ip}:{port}: {str(e)}")
46
+ log.debug(traceback.format_exc())
47
+ return "Unknown"
48
+
49
+ # Use asyncio.run to execute the asynchronous logic synchronously
50
+ return asyncio.run(_async_scan_service(ip, port, timeout=timeout))
@@ -0,0 +1,338 @@
1
+ from .scan_config import ScanConfig
2
+ from .decorators import job_tracker, terminator, JobStatsMixin
3
+ import os
4
+ import json
5
+ import uuid
6
+ import logging
7
+ import ipaddress
8
+ import traceback
9
+ import threading
10
+ from time import time
11
+ from time import sleep
12
+ from typing import List, Union
13
+ from tabulate import tabulate
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+
16
+ from .net_tools import Device, is_arp_supported
17
+ from .errors import SubnetScanTerminationFailure
18
+
19
+
20
+ class SubnetScanner(JobStatsMixin):
21
+ def __init__(
22
+ self,
23
+ config: ScanConfig
24
+ ):
25
+ self.cfg = config
26
+ self.subnet = config.parse_subnet()
27
+ self.ports: List[int] = config.get_ports()
28
+ self.running = False
29
+ self.subnet_str = config.subnet
30
+
31
+ self.uid = str(uuid.uuid4())
32
+ self.results = ScannerResults(self)
33
+ self.log: logging.Logger = logging.getLogger('SubnetScanner')
34
+ if not is_arp_supported():
35
+ self.log.warning(
36
+ 'ARP is not supported with the active runtime context. Device discovery will be limited to ping responses.')
37
+ self.log.debug(f'Instantiated with uid: {self.uid}')
38
+ self.log.debug(
39
+ f'Port Count: {len(self.ports)} | Device Count: {len(self.subnet)}')
40
+
41
+ def start(self):
42
+ """
43
+ Scan the subnet for devices and open ports.
44
+ """
45
+ self._set_stage('scanning devices')
46
+ self.running = True
47
+ with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('isalive')) as executor:
48
+ futures = {executor.submit(self._get_host_details, str(
49
+ ip)): str(ip) for ip in self.subnet}
50
+ for future in as_completed(futures):
51
+ ip = futures[future]
52
+ try:
53
+ future.result()
54
+ except Exception as e:
55
+ self.log.error(
56
+ f'[{ip}] scan failed. details below:\n{traceback.format_exc()}')
57
+ self.results.errors.append({
58
+ 'basic': f"Error scanning IP {ip}: {e}",
59
+ 'traceback': traceback.format_exc(),
60
+ })
61
+
62
+ self._set_stage('testing ports')
63
+ if self.cfg.task_scan_ports:
64
+ self._scan_network_ports()
65
+ self.running = False
66
+ self._set_stage('complete')
67
+
68
+ return self.results
69
+
70
+ def terminate(self):
71
+ self.running = False
72
+ self._set_stage('terminating')
73
+ for i in range(20):
74
+ if not len(self.job_stats.running.keys()):
75
+ self._set_stage('terminated')
76
+ return True
77
+ sleep(.5)
78
+ raise SubnetScanTerminationFailure(self.job_stats.running)
79
+
80
+ def calc_percent_complete(self) -> int: # 0 - 100
81
+ if not self.running:
82
+ return 100
83
+
84
+ # --- Host discovery (isalive) calculations ---
85
+ avg_host_detail_sec = self.job_stats.timing.get(
86
+ '_get_host_details', 4.5)
87
+ # assume 10% alive percentage if the scan just started
88
+ if len(self.results.devices) and (self.results.devices_scanned):
89
+ est_subnet_alive_percent = (
90
+ # avoid div 0
91
+ len(self.results.devices)) / (self.results.devices_scanned)
92
+ else:
93
+ est_subnet_alive_percent = .1
94
+ est_subnet_devices = est_subnet_alive_percent * self.results.devices_total
95
+
96
+ remaining_isalive_sec = (
97
+ self.results.devices_total - self.results.devices_scanned) * avg_host_detail_sec
98
+ total_isalive_sec = self.results.devices_total * avg_host_detail_sec
99
+
100
+ isalive_multiplier = self.cfg.t_cnt('isalive')
101
+
102
+ # --- Port scanning calculations ---
103
+ device_ports_scanned = self.job_stats.finished.get('_test_port', 0)
104
+ # remediate initial inaccurate results because open ports reurn quickly
105
+ avg_port_test_sec = self.job_stats.timing.get(
106
+ '_test_port', 1) if device_ports_scanned > 20 else 1
107
+
108
+ device_ports_unscanned = max(
109
+ 0, (est_subnet_devices * len(self.ports)) - device_ports_scanned)
110
+
111
+ remaining_port_test_sec = device_ports_unscanned * avg_port_test_sec
112
+ total_port_test_sec = est_subnet_devices * \
113
+ len(self.ports) * avg_port_test_sec
114
+
115
+ port_test_multiplier = self.cfg.t_cnt(
116
+ 'port_scan') * self.cfg.t_cnt('port_test')
117
+
118
+ # --- Overall progress ---
119
+ est_total_time = (total_isalive_sec / isalive_multiplier) + \
120
+ (total_port_test_sec / port_test_multiplier)
121
+ est_remaining_time = (remaining_isalive_sec / isalive_multiplier) + \
122
+ (remaining_port_test_sec / port_test_multiplier)
123
+
124
+ return int(abs((1 - (est_remaining_time / est_total_time)) * 100))
125
+
126
+ def debug_active_scan(self, sleep_sec=1):
127
+ """
128
+ Run this after running scan_subnet_threaded
129
+ to see the progress of the scan
130
+ """
131
+ while self.running:
132
+ percent = self.calc_percent_complete()
133
+ t_elapsed = time() - self.results.start_time
134
+ t_remain = int((100 - percent) * (t_elapsed / percent)
135
+ ) if percent else '∞'
136
+ buffer = f'{self.uid} - {self.subnet_str}\n'
137
+ buffer += f'Elapsed: {int(t_elapsed)} sec - Remain: {t_remain} sec\n'
138
+ buffer += f'Scanned: {self.results.devices_scanned}/{self.results.devices_total}'
139
+ buffer += f' - {percent}%\n'
140
+ buffer += str(self.job_stats)
141
+ os.system('cls' if os.name == 'nt' else 'clear')
142
+ print(buffer)
143
+ sleep(sleep_sec)
144
+
145
+ @terminator
146
+ @job_tracker
147
+ def _get_host_details(self, host: str):
148
+ """
149
+ Get the MAC address and open ports of the given host.
150
+ """
151
+ device = Device(host)
152
+ device.alive = self._ping(device)
153
+ self.results.scanned()
154
+ if not device.alive:
155
+ return None
156
+ self.log.debug(f'[{host}] is alive, getting metadata')
157
+ device.get_metadata()
158
+ self.results.devices.append(device)
159
+ return True
160
+
161
+ @terminator
162
+ def _scan_network_ports(self):
163
+ with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_scan')) as executor:
164
+ futures = {executor.submit(
165
+ self._scan_ports, device): device for device in self.results.devices}
166
+ for future in futures:
167
+ future.result()
168
+
169
+ @terminator
170
+ @job_tracker
171
+ def _scan_ports(self, device: Device):
172
+ self.log.debug(f'[{device.ip}] Initiating port scan')
173
+ device.stage = 'scanning'
174
+ with ThreadPoolExecutor(max_workers=self.cfg.t_cnt('port_test')) as executor:
175
+ futures = {executor.submit(self._test_port, device, int(
176
+ port)): port for port in self.ports}
177
+ for future in futures:
178
+ future.result()
179
+ self.log.debug(f'[{device.ip}] Completed port scan')
180
+ device.stage = 'complete'
181
+
182
+ @terminator
183
+ @job_tracker
184
+ def _test_port(self, host: Device, port: int):
185
+ """
186
+ Test if a port is open on a given host.
187
+ If port open, determine service.
188
+ Device class handles tracking open ports.
189
+ """
190
+ is_alive = host.test_port(port)
191
+ if is_alive and self.cfg.task_scan_port_services:
192
+ host.scan_service(port)
193
+ return is_alive
194
+
195
+ @terminator
196
+ @job_tracker
197
+ def _ping(self, host: Device):
198
+ """
199
+ Ping the given host and return True if it's reachable, False otherwise.
200
+ """
201
+ return host.is_alive(
202
+ host.ip,
203
+ scan_type=self.cfg.lookup_type,
204
+ ping_config=self.cfg.ping_config,
205
+ arp_config=self.cfg.arp_config
206
+ )
207
+
208
+ def _set_stage(self, stage):
209
+ self.log.debug(f'[{self.uid}] Moving to Stage: {stage}')
210
+ self.results.stage = stage
211
+ if not self.running:
212
+ self.results.end_time = time()
213
+
214
+
215
+ class ScannerResults:
216
+ def __init__(self, scan: SubnetScanner):
217
+ self.scan = scan
218
+ self.port_list: str = scan.cfg.port_list
219
+ self.subnet: str = scan.subnet_str
220
+ self.uid = scan.uid
221
+
222
+ self.devices_total: int = len(list(scan.subnet))
223
+ self.devices_scanned: int = 0
224
+ self.devices: List[Device] = []
225
+
226
+ self.errors: List[str] = []
227
+ self.running: bool = False
228
+ self.start_time: float = time()
229
+ self.end_time: int = None
230
+ self.stage = 'instantiated'
231
+
232
+ self.log = logging.getLogger('ScannerResults')
233
+ self.log.debug(f'Instantiated Logger For Scan: {self.scan.uid}')
234
+
235
+ def scanned(self):
236
+ self.devices_scanned += 1
237
+
238
+ def get_runtime(self):
239
+ if self.scan.running:
240
+ return int(time() - self.start_time)
241
+ return int(self.end_time - self.start_time)
242
+
243
+ def export(self, out_type=dict) -> Union[str, dict]:
244
+ """
245
+ Returns json representation of the scan
246
+ """
247
+
248
+ self.running = self.scan.running
249
+ self.run_time = int(round(time() - self.start_time, 0))
250
+ self.devices_alive = len(self.devices)
251
+
252
+ out = vars(self).copy()
253
+ out.pop('scan')
254
+ out.pop('log')
255
+ out['cfg'] = vars(self.scan.cfg)
256
+
257
+ devices: List[Device] = out.pop('devices')
258
+ sortedDevices = sorted(
259
+ devices, key=lambda obj: ipaddress.IPv4Address(obj.ip))
260
+ out['devices'] = [device.dict() for device in sortedDevices]
261
+
262
+ if out_type == str:
263
+ return json.dumps(out, default=str, indent=2)
264
+ # otherwise return dict
265
+ return out
266
+
267
+ def __str__(self):
268
+ # Prepare data for tabulate
269
+ data = [
270
+ [device.ip, device.hostname, device.get_mac(
271
+ ), ", ".join(map(str, device.ports))]
272
+ for device in self.devices
273
+ ]
274
+
275
+ # Create headers for the table
276
+ headers = ["IP", "Host", "MAC", "Ports"]
277
+
278
+ # Generate the table using tabulate
279
+ table = tabulate(data, headers=headers, tablefmt="grid")
280
+
281
+ # Format and return the complete buffer with table output
282
+ buffer = f"Scan Results - {self.scan.subnet_str} - {self.uid}\n"
283
+ buffer += "---------------------------------------------\n\n"
284
+ buffer += table
285
+ return buffer
286
+
287
+
288
+ class ScanManager:
289
+ """
290
+ Maintain active and completed scans in memory for
291
+ future reference. Singleton implementation.
292
+ """
293
+ _instance = None
294
+
295
+ def __new__(cls, *args, **kwargs):
296
+ if not cls._instance:
297
+ cls._instance = super(ScanManager, cls).__new__(
298
+ cls, *args, **kwargs)
299
+ return cls._instance
300
+
301
+ def __init__(self):
302
+ if not hasattr(self, 'scans'): # Prevent reinitialization
303
+ self.scans: List[SubnetScanner] = []
304
+ self.log = logging.getLogger('ScanManager')
305
+
306
+ def new_scan(self, config: ScanConfig) -> SubnetScanner:
307
+ scan = SubnetScanner(config)
308
+ self._start(scan)
309
+ self.log.info(f'Scan started - {config}')
310
+ self.scans.append(scan)
311
+ return scan
312
+
313
+ def get_scan(self, scan_id: str) -> SubnetScanner:
314
+ """
315
+ Get scan by scan.uid
316
+ """
317
+ for scan in self.scans:
318
+ if scan.uid == scan_id:
319
+ return scan
320
+
321
+ def terminate_scans(self):
322
+ """
323
+ Terminate all active scans
324
+ """
325
+ for scan in self.scans:
326
+ if scan.running:
327
+ scan.terminate()
328
+
329
+ def wait_until_complete(self, scan_id: str) -> SubnetScanner:
330
+ scan = self.get_scan(scan_id)
331
+ while scan.running:
332
+ sleep(.5)
333
+ return scan
334
+
335
+ def _start(self, scan: SubnetScanner):
336
+ t = threading.Thread(target=scan.start)
337
+ t.start()
338
+ return t
@@ -0,0 +1,56 @@
1
+ import logging
2
+ import requests
3
+ import traceback
4
+ from importlib.metadata import version, PackageNotFoundError
5
+ from random import randint
6
+
7
+ from .app_scope import is_local_run
8
+
9
+ log = logging.getLogger('VersionManager')
10
+
11
+ PACKAGE = 'lanscape'
12
+ LOCAL_VERSION = '0.0.0'
13
+
14
+ latest = None # used to 'remember' pypi version each runtime
15
+
16
+
17
+ def is_update_available(package=PACKAGE) -> bool:
18
+ installed = get_installed_version(package)
19
+ available = lookup_latest_version(package)
20
+
21
+ is_update_exempt = (
22
+ 'a' in installed, 'b' in installed, # pre-release
23
+ installed == LOCAL_VERSION
24
+ )
25
+ if any(is_update_exempt):
26
+ return False
27
+
28
+ log.debug(f'Installed: {installed} | Available: {available}')
29
+ return installed != available
30
+
31
+
32
+ def lookup_latest_version(package=PACKAGE):
33
+ # Fetch the latest version from PyPI
34
+ global latest
35
+ if not latest:
36
+ no_cache = f'?cachebust={randint(0, 6969)}'
37
+ url = f"https://pypi.org/pypi/{package}/json{no_cache}"
38
+ try:
39
+ response = requests.get(url, timeout=5)
40
+ response.raise_for_status() # Raise an exception for HTTP errors
41
+ latest = response.json()['info']['version']
42
+ log.debug(f'Latest pypi version: {latest}')
43
+ except BaseException:
44
+ log.debug(traceback.format_exc())
45
+ log.warning('Unable to fetch package version from PyPi')
46
+ return latest
47
+
48
+
49
+ def get_installed_version(package=PACKAGE):
50
+ if not is_local_run():
51
+ try:
52
+ return version(package)
53
+ except PackageNotFoundError:
54
+ log.debug(traceback.format_exc())
55
+ log.warning(f'Cannot find {package} installation')
56
+ return LOCAL_VERSION
@@ -0,0 +1,142 @@
1
+ """
2
+ Get the executable path of the system’s default web browser.
3
+
4
+ Supports:
5
+ - Windows (reads from the registry)
6
+ - Linux (uses xdg-mime / xdg-settings + .desktop file parsing)
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import subprocess
12
+ import webbrowser
13
+ import logging
14
+ import re
15
+ import time
16
+ from typing import Optional
17
+
18
+ log = logging.getLogger('WebBrowser')
19
+
20
+
21
+ def open_webapp(url: str) -> bool:
22
+ """
23
+ will try to open the web page as an app
24
+ on failure, will open as a tab in default browser
25
+
26
+ returns:
27
+ """
28
+ start = time.time()
29
+ try:
30
+ exe = get_default_browser_executable()
31
+ if not exe:
32
+ raise RuntimeError('Unable to find browser binary')
33
+ log.debug(f'Opening {url} with {exe}')
34
+
35
+ cmd = f'"{exe}" --app="{url}"'
36
+ subprocess.run(cmd, check=True, shell=True)
37
+
38
+ if time.time() - start < 2:
39
+ log.debug(
40
+ f'Unable to hook into closure of UI, listening for flask shutdown')
41
+ return False
42
+ return True
43
+
44
+ except Exception as e:
45
+ log.warning(
46
+ 'Failed to open webpage as app, falling back to browser tab')
47
+ log.debug(f'As app error: {e}')
48
+ try:
49
+ success = webbrowser.open(url)
50
+ log.debug(f'Opened {url} in browser tab: {success}')
51
+ if not success:
52
+ raise RuntimeError('Unknown error while opening browser tab')
53
+ except Exception as e:
54
+ log.warning(
55
+ f'Exhausted all options to open browser, you need to open manually')
56
+ log.debug(f'As tab error: {e}')
57
+ log.info(f'LANScape UI is running on {url}')
58
+ return False
59
+
60
+
61
+ def get_default_browser_executable() -> Optional[str]:
62
+ if sys.platform.startswith("win"):
63
+ try:
64
+ import winreg
65
+ # On Windows the HKEY_CLASSES_ROOT\http\shell\open\command key
66
+ # holds the command for opening HTTP URLs.
67
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"http\shell\open\command") as key:
68
+ cmd, _ = winreg.QueryValueEx(key, None)
69
+ except Exception:
70
+ return None
71
+
72
+ # cmd usually looks like: '"C:\\Program Files\\Foo\\foo.exe" %1'
73
+ m = re.match(r'\"?(.+?\.exe)\"?', cmd)
74
+ return m.group(1) if m else None
75
+
76
+ elif sys.platform.startswith("linux"):
77
+ # First, find the .desktop file name
78
+ desktop_file = None
79
+ try:
80
+ # Try xdg-mime
81
+ p = subprocess.run(
82
+ ["xdg-mime", "query", "default", "x-scheme-handler/http"],
83
+ capture_output=True, text=True,
84
+ check=True
85
+ )
86
+ desktop_file = p.stdout.strip()
87
+ except subprocess.CalledProcessError:
88
+ pass
89
+
90
+ if not desktop_file:
91
+ # Fallback to xdg-settings
92
+ try:
93
+ p = subprocess.run(
94
+ ["xdg-settings", "get", "default-web-browser"],
95
+ capture_output=True, text=True,
96
+ check=True
97
+ )
98
+ desktop_file = p.stdout.strip()
99
+ except subprocess.CalledProcessError:
100
+ pass
101
+
102
+ # Final fallback: BROWSER environment variable
103
+ if not desktop_file:
104
+ return os.environ.get("BROWSER")
105
+
106
+ # Look for that .desktop file in standard locations
107
+ search_paths = [
108
+ os.path.expanduser("~/.local/share/applications"),
109
+ "/usr/local/share/applications",
110
+ "/usr/share/applications",
111
+ ]
112
+ for path in search_paths:
113
+ full_path = os.path.join(path, desktop_file)
114
+ if os.path.isfile(full_path):
115
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
116
+ for line in f:
117
+ if line.startswith("Exec="):
118
+ exec_cmd = line[len("Exec="):].strip()
119
+ # strip arguments like “%u”, “--flag”, etc.
120
+ exec_cmd = exec_cmd.split()[0]
121
+ exec_cmd = exec_cmd.split("%")[0]
122
+ return exec_cmd
123
+ return None
124
+
125
+ elif sys.platform.startswith("darwin"):
126
+ # macOS: try to find Chrome first for app mode support, fallback to default
127
+ try:
128
+ p = subprocess.run(
129
+ ["mdfind", "kMDItemCFBundleIdentifier == 'com.google.Chrome'"],
130
+ capture_output=True, text=True, check=True
131
+ )
132
+ chrome_paths = p.stdout.strip().split('\n')
133
+ if chrome_paths and chrome_paths[0]:
134
+ return f"{chrome_paths[0]}/Contents/MacOS/Google Chrome"
135
+ except subprocess.CalledProcessError:
136
+ pass
137
+
138
+ # Fallback to system default
139
+ return "/usr/bin/open"
140
+
141
+ else:
142
+ raise NotImplementedError(f"Unsupported platform: {sys.platform!r}")