lanscape 1.3.5a1__py3-none-any.whl → 1.3.5a2__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.
@@ -1,14 +1,26 @@
1
+ """
2
+ Configuration module for network scanning operations.
3
+ Provides classes and utilities to configure different types of network scans
4
+ including ping scans, ARP scans, and port scanning.
5
+ """
6
+
1
7
  from typing import List, Dict
2
8
  import ipaddress
3
- from pydantic import BaseModel, Field
4
9
  from enum import Enum
5
10
 
11
+ from pydantic import BaseModel, Field
6
12
 
7
13
  from lanscape.libraries.port_manager import PortManager
8
14
  from lanscape.libraries.ip_parser import parse_ip_input
9
15
 
10
16
 
11
17
  class PingConfig(BaseModel):
18
+ """
19
+ Configuration settings for ICMP ping-based network scanning.
20
+
21
+ Controls parameters such as the number of ping attempts, count per ping,
22
+ timeout values, and retry delays to optimize ping scanning behavior.
23
+ """
12
24
  attempts: int = 2
13
25
  ping_count: int = 1
14
26
  timeout: float = 1.0
@@ -16,9 +28,24 @@ class PingConfig(BaseModel):
16
28
 
17
29
  @classmethod
18
30
  def from_dict(cls, data: dict) -> 'PingConfig':
31
+ """
32
+ Create a PingConfig instance from a dictionary.
33
+
34
+ Args:
35
+ data: Dictionary containing PingConfig parameters
36
+
37
+ Returns:
38
+ A new PingConfig instance with the provided settings
39
+ """
19
40
  return cls.model_validate(data)
20
41
 
21
42
  def to_dict(self) -> dict:
43
+ """
44
+ Convert the PingConfig instance to a dictionary.
45
+
46
+ Returns:
47
+ Dictionary representation of the PingConfig
48
+ """
22
49
  return self.model_dump()
23
50
 
24
51
  def __str__(self):
@@ -39,9 +66,24 @@ class ArpConfig(BaseModel):
39
66
 
40
67
  @classmethod
41
68
  def from_dict(cls, data: dict) -> 'ArpConfig':
69
+ """
70
+ Create an ArpConfig instance from a dictionary.
71
+
72
+ Args:
73
+ data: Dictionary containing ArpConfig parameters
74
+
75
+ Returns:
76
+ A new ArpConfig instance with the provided settings
77
+ """
42
78
  return cls.model_validate(data)
43
79
 
44
80
  def to_dict(self) -> dict:
81
+ """
82
+ Convert the ArpConfig instance to a dictionary.
83
+
84
+ Returns:
85
+ Dictionary representation of the ArpConfig
86
+ """
45
87
  return self.model_dump()
46
88
 
47
89
  def __str__(self):
@@ -49,12 +91,25 @@ class ArpConfig(BaseModel):
49
91
 
50
92
 
51
93
  class ScanType(Enum):
94
+ """
95
+ Enumeration of supported network scan types.
96
+
97
+ PING: Uses ICMP echo requests to determine if hosts are alive
98
+ ARP: Uses Address Resolution Protocol to discover hosts on the local network
99
+ BOTH: Uses both PING and ARP methods for maximum coverage
100
+ """
52
101
  PING = 'ping'
53
102
  ARP = 'arp'
54
103
  BOTH = 'both'
55
104
 
56
105
 
57
106
  class ScanConfig(BaseModel):
107
+ """
108
+ Main configuration class for network scanning operations.
109
+
110
+ Contains settings for subnet targets, port ranges, thread counts,
111
+ scan tasks to perform, and configurations for different scan methods.
112
+ """
58
113
  subnet: str
59
114
  port_list: str
60
115
  t_multiplier: float = 1.0
@@ -71,11 +126,31 @@ class ScanConfig(BaseModel):
71
126
  ping_config: PingConfig = Field(default_factory=PingConfig)
72
127
  arp_config: ArpConfig = Field(default_factory=ArpConfig)
73
128
 
74
- def t_cnt(self, id: str) -> int:
75
- return int(int(getattr(self, f't_cnt_{id}')) * float(self.t_multiplier))
129
+ def t_cnt(self, thread_id: str) -> int:
130
+ """
131
+ Calculate thread count for a specific operation based on multiplier.
132
+
133
+ Args:
134
+ thread_id: String identifier for the thread type (e.g., 'port_scan')
135
+
136
+ Returns:
137
+ Calculated thread count for the specified operation
138
+ """
139
+ return int(int(getattr(self, f't_cnt_{thread_id}')) * float(self.t_multiplier))
76
140
 
77
141
  @classmethod
78
142
  def from_dict(cls, data: dict) -> 'ScanConfig':
143
+ """
144
+ Create a ScanConfig instance from a dictionary.
145
+
146
+ Handles special cases like converting string enum values to proper Enum types.
147
+
148
+ Args:
149
+ data: Dictionary containing ScanConfig parameters
150
+
151
+ Returns:
152
+ A new ScanConfig instance with the provided settings
153
+ """
79
154
  # Handle special cases before validation
80
155
  if isinstance(data.get('lookup_type'), str):
81
156
  data['lookup_type'] = ScanType[data['lookup_type'].upper()]
@@ -83,14 +158,34 @@ class ScanConfig(BaseModel):
83
158
  return cls.model_validate(data)
84
159
 
85
160
  def to_dict(self) -> dict:
161
+ """
162
+ Convert the ScanConfig instance to a dictionary.
163
+
164
+ Handles special cases like converting Enum values to strings.
165
+
166
+ Returns:
167
+ Dictionary representation of the ScanConfig
168
+ """
86
169
  dump = self.model_dump()
87
170
  dump['lookup_type'] = self.lookup_type.value
88
171
  return dump
89
172
 
90
173
  def get_ports(self) -> List[int]:
174
+ """
175
+ Get the list of ports to scan based on the configured port list name.
176
+
177
+ Returns:
178
+ List of port numbers to scan
179
+ """
91
180
  return PortManager().get_port_list(self.port_list).keys()
92
181
 
93
182
  def parse_subnet(self) -> List[ipaddress.IPv4Network]:
183
+ """
184
+ Parse the configured subnet string into IPv4Network objects.
185
+
186
+ Returns:
187
+ List of IPv4Network objects representing the target networks
188
+ """
94
189
  return parse_ip_input(self.subnet)
95
190
 
96
191
  def __str__(self):
@@ -1,7 +1,8 @@
1
+ """Service scanning module for identifying services running on network ports."""
1
2
  import asyncio
2
3
  import logging
3
4
  import traceback
4
- from .app_scope import ResourceManager
5
+ from lanscape.libraries.app_scope import ResourceManager
5
6
 
6
7
  log = logging.getLogger('ServiceScan')
7
8
  SERVICES = ResourceManager('services').get_jsonc('definitions.jsonc')
@@ -24,8 +25,7 @@ def scan_service(ip: str, port: int, timeout=10) -> str:
24
25
  reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=5)
25
26
 
26
27
  # 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")
28
+ probe = f"GET / HTTP/1.1\r\nHost: {ip}\r\n\r\n".encode("utf-8")
29
29
  writer.write(probe)
30
30
  await writer.drain()
31
31
 
@@ -1,5 +1,10 @@
1
- from .scan_config import ScanConfig
2
- from .decorators import job_tracker, terminator, JobStatsMixin
1
+ """
2
+ Network subnet scanning module for LANscape.
3
+ Provides classes for performing network discovery, device scanning, and port scanning.
4
+ Handles scan management, result tracking, and scan termination.
5
+ """
6
+
7
+ # Standard library imports
3
8
  import os
4
9
  import json
5
10
  import uuid
@@ -7,33 +12,49 @@ import logging
7
12
  import ipaddress
8
13
  import traceback
9
14
  import threading
10
- from time import time
11
- from time import sleep
15
+ from time import time, sleep
12
16
  from typing import List, Union
13
- from tabulate import tabulate
14
17
  from concurrent.futures import ThreadPoolExecutor, as_completed
15
18
 
16
- from .net_tools import Device, is_arp_supported
17
- from .errors import SubnetScanTerminationFailure
19
+ # Third-party imports
20
+ from tabulate import tabulate
21
+
22
+ # Local imports
23
+ from lanscape.libraries.scan_config import ScanConfig
24
+ from lanscape.libraries.decorators import job_tracker, terminator, JobStatsMixin
25
+ from lanscape.libraries.net_tools import Device, is_arp_supported
26
+ from lanscape.libraries.errors import SubnetScanTerminationFailure
18
27
 
19
28
 
20
29
  class SubnetScanner(JobStatsMixin):
30
+ """
31
+ Scans a subnet for devices and open ports.
32
+
33
+ Manages the scanning process including device discovery and port scanning.
34
+ Tracks scan progress and provides mechanisms for controlled termination.
35
+ """
36
+
21
37
  def __init__(
22
38
  self,
23
39
  config: ScanConfig
24
40
  ):
41
+ # Config and network properties
25
42
  self.cfg = config
26
43
  self.subnet = config.parse_subnet()
27
44
  self.ports: List[int] = config.get_ports()
28
- self.running = False
29
45
  self.subnet_str = config.subnet
30
46
 
47
+ # Status properties
48
+ self.running = False
31
49
  self.uid = str(uuid.uuid4())
32
50
  self.results = ScannerResults(self)
33
51
  self.log: logging.Logger = logging.getLogger('SubnetScanner')
52
+
53
+ # Initial logging
34
54
  if not is_arp_supported():
35
55
  self.log.warning(
36
- 'ARP is not supported with the active runtime context. Device discovery will be limited to ping responses.')
56
+ 'ARP is not supported with the active runtime context. '
57
+ 'Device discovery will be limited to ping responses.')
37
58
  self.log.debug(f'Instantiated with uid: {self.uid}')
38
59
  self.log.debug(
39
60
  f'Port Count: {len(self.ports)} | Device Count: {len(self.subnet)}')
@@ -68,16 +89,36 @@ class SubnetScanner(JobStatsMixin):
68
89
  return self.results
69
90
 
70
91
  def terminate(self):
92
+ """
93
+ Terminate the scan operation.
94
+
95
+ Attempts a graceful shutdown of all scan operations and waits for running
96
+ tasks to complete. Raises an exception if termination takes too long.
97
+
98
+ Returns:
99
+ bool: True if terminated successfully
100
+
101
+ Raises:
102
+ SubnetScanTerminationFailure: If the scan cannot be terminated within the timeout
103
+ """
71
104
  self.running = False
72
105
  self._set_stage('terminating')
73
- for i in range(20):
74
- if not len(self.job_stats.running.keys()):
106
+ for _ in range(20):
107
+ if not self.job_stats.running:
75
108
  self._set_stage('terminated')
76
109
  return True
77
110
  sleep(.5)
78
111
  raise SubnetScanTerminationFailure(self.job_stats.running)
79
112
 
80
113
  def calc_percent_complete(self) -> int: # 0 - 100
114
+ """
115
+ Calculate the percentage completion of the scan.
116
+
117
+ Uses scan statistics and job timing information to estimate progress.
118
+
119
+ Returns:
120
+ int: Completion percentage (0-100)
121
+ """
81
122
  if not self.running:
82
123
  return 100
83
124
 
@@ -85,7 +126,7 @@ class SubnetScanner(JobStatsMixin):
85
126
  avg_host_detail_sec = self.job_stats.timing.get(
86
127
  '_get_host_details', 4.5)
87
128
  # assume 10% alive percentage if the scan just started
88
- if len(self.results.devices) and (self.results.devices_scanned):
129
+ if self.results.devices and self.results.devices_scanned:
89
130
  est_subnet_alive_percent = (
90
131
  # avoid div 0
91
132
  len(self.results.devices)) / (self.results.devices_scanned)
@@ -213,38 +254,65 @@ class SubnetScanner(JobStatsMixin):
213
254
 
214
255
 
215
256
  class ScannerResults:
257
+ """
258
+ Stores and manages the results of a subnet scan.
259
+
260
+ Tracks devices found, scan statistics, and provides export functionality
261
+ for scan results. Also handles runtime calculation and progress tracking.
262
+ """
263
+
216
264
  def __init__(self, scan: SubnetScanner):
265
+ # Parent reference and identifiers
217
266
  self.scan = scan
218
267
  self.port_list: str = scan.cfg.port_list
219
268
  self.subnet: str = scan.subnet_str
220
269
  self.uid = scan.uid
221
270
 
271
+ # Scan statistics
222
272
  self.devices_total: int = len(list(scan.subnet))
223
273
  self.devices_scanned: int = 0
224
274
  self.devices: List[Device] = []
275
+ self.devices_alive = 0
225
276
 
277
+ # Status tracking
226
278
  self.errors: List[str] = []
227
279
  self.running: bool = False
228
280
  self.start_time: float = time()
229
281
  self.end_time: int = None
230
282
  self.stage = 'instantiated'
283
+ self.run_time = 0
231
284
 
285
+ # Logging
232
286
  self.log = logging.getLogger('ScannerResults')
233
287
  self.log.debug(f'Instantiated Logger For Scan: {self.scan.uid}')
234
288
 
235
289
  def scanned(self):
290
+ """
291
+ Increment the count of scanned devices.
292
+ """
236
293
  self.devices_scanned += 1
237
294
 
238
295
  def get_runtime(self):
296
+ """
297
+ Calculate the runtime of the scan in seconds.
298
+
299
+ Returns:
300
+ int: Runtime in seconds
301
+ """
239
302
  if self.scan.running:
240
303
  return int(time() - self.start_time)
241
304
  return int(self.end_time - self.start_time)
242
305
 
243
306
  def export(self, out_type=dict) -> Union[str, dict]:
244
307
  """
245
- Returns json representation of the scan
246
- """
308
+ Export scan results in the specified format.
247
309
 
310
+ Args:
311
+ out_type: The output type (dict or str)
312
+
313
+ Returns:
314
+ Union[str, dict]: Scan results in the specified format
315
+ """
248
316
  self.running = self.scan.running
249
317
  self.run_time = int(round(time() - self.start_time, 0))
250
318
  self.devices_alive = len(self.devices)
@@ -255,9 +323,9 @@ class ScannerResults:
255
323
  out['cfg'] = vars(self.scan.cfg)
256
324
 
257
325
  devices: List[Device] = out.pop('devices')
258
- sortedDevices = sorted(
326
+ sorted_devices = sorted(
259
327
  devices, key=lambda obj: ipaddress.IPv4Address(obj.ip))
260
- out['devices'] = [device.dict() for device in sortedDevices]
328
+ out['devices'] = [device.dict() for device in sorted_devices]
261
329
 
262
330
  if out_type == str:
263
331
  return json.dumps(out, default=str, indent=2)
@@ -304,6 +372,15 @@ class ScanManager:
304
372
  self.log = logging.getLogger('ScanManager')
305
373
 
306
374
  def new_scan(self, config: ScanConfig) -> SubnetScanner:
375
+ """
376
+ Create and start a new scan with the given configuration.
377
+
378
+ Args:
379
+ config: The scan configuration
380
+
381
+ Returns:
382
+ SubnetScanner: The newly created scan instance
383
+ """
307
384
  scan = SubnetScanner(config)
308
385
  self._start(scan)
309
386
  self.log.info(f'Scan started - {config}')
@@ -317,6 +394,7 @@ class ScanManager:
317
394
  for scan in self.scans:
318
395
  if scan.uid == scan_id:
319
396
  return scan
397
+ return None # Explicitly return None for consistency
320
398
 
321
399
  def terminate_scans(self):
322
400
  """
@@ -327,12 +405,22 @@ class ScanManager:
327
405
  scan.terminate()
328
406
 
329
407
  def wait_until_complete(self, scan_id: str) -> SubnetScanner:
408
+ """Wait for a scan to complete."""
330
409
  scan = self.get_scan(scan_id)
331
410
  while scan.running:
332
411
  sleep(.5)
333
412
  return scan
334
413
 
335
414
  def _start(self, scan: SubnetScanner):
415
+ """
416
+ Start a scan in a separate thread.
417
+
418
+ Args:
419
+ scan: The scan to start
420
+
421
+ Returns:
422
+ Thread: The thread running the scan
423
+ """
336
424
  t = threading.Thread(target=scan.start)
337
425
  t.start()
338
426
  return t
@@ -1,9 +1,16 @@
1
+ """
2
+ Version management module for LANscape.
3
+ Handles version checking, update detection, and retrieving package information
4
+ from both local installation and PyPI repository.
5
+ """
6
+
1
7
  import logging
2
- import requests
3
8
  import traceback
4
9
  from importlib.metadata import version, PackageNotFoundError
5
10
  from random import randint
6
11
 
12
+ import requests
13
+
7
14
  from .app_scope import is_local_run
8
15
 
9
16
  log = logging.getLogger('VersionManager')
@@ -11,10 +18,23 @@ log = logging.getLogger('VersionManager')
11
18
  PACKAGE = 'lanscape'
12
19
  LOCAL_VERSION = '0.0.0'
13
20
 
14
- latest = None # used to 'remember' pypi version each runtime
21
+ # Used to cache PyPI version during runtime
22
+ LATEST_VERSION = None
15
23
 
16
24
 
17
25
  def is_update_available(package=PACKAGE) -> bool:
26
+ """
27
+ Check if an update is available for the package.
28
+
29
+ Compares the installed version with the latest version available on PyPI.
30
+ Ignores pre-release versions (alpha/beta) and local development installs.
31
+
32
+ Args:
33
+ package: The package name to check for updates
34
+
35
+ Returns:
36
+ Boolean indicating if an update is available
37
+ """
18
38
  installed = get_installed_version(package)
19
39
  available = lookup_latest_version(package)
20
40
 
@@ -30,23 +50,46 @@ def is_update_available(package=PACKAGE) -> bool:
30
50
 
31
51
 
32
52
  def lookup_latest_version(package=PACKAGE):
53
+ """
54
+ Retrieve the latest version of the package from PyPI.
55
+
56
+ Caches the result for subsequent calls during the same runtime.
57
+
58
+ Args:
59
+ package: The package name to lookup
60
+
61
+ Returns:
62
+ The latest version string from PyPI or None if retrieval fails
63
+ """
33
64
  # Fetch the latest version from PyPI
34
- global latest
35
- if not latest:
65
+ global LATEST_VERSION # pylint: disable=global-statement
66
+ if not LATEST_VERSION:
36
67
  no_cache = f'?cachebust={randint(0, 6969)}'
37
68
  url = f"https://pypi.org/pypi/{package}/json{no_cache}"
38
69
  try:
39
70
  response = requests.get(url, timeout=5)
40
71
  response.raise_for_status() # Raise an exception for HTTP errors
41
- latest = response.json()['info']['version']
42
- log.debug(f'Latest pypi version: {latest}')
72
+ LATEST_VERSION = response.json()['info']['version']
73
+ log.debug(f'Latest pypi version: {LATEST_VERSION}')
43
74
  except BaseException:
44
75
  log.debug(traceback.format_exc())
45
76
  log.warning('Unable to fetch package version from PyPi')
46
- return latest
77
+ return LATEST_VERSION
47
78
 
48
79
 
49
80
  def get_installed_version(package=PACKAGE):
81
+ """
82
+ Get the installed version of the package.
83
+
84
+ Returns the current installed version or a default local version
85
+ if running in development mode or if the package is not found.
86
+
87
+ Args:
88
+ package: The package name to check
89
+
90
+ Returns:
91
+ The installed version string or LOCAL_VERSION for local development
92
+ """
50
93
  if not is_local_run():
51
94
  try:
52
95
  return version(package)
@@ -37,7 +37,7 @@ def open_webapp(url: str) -> bool:
37
37
 
38
38
  if time.time() - start < 2:
39
39
  log.debug(
40
- f'Unable to hook into closure of UI, listening for flask shutdown')
40
+ 'Unable to hook into closure of UI, listening for flask shutdown')
41
41
  return False
42
42
  return True
43
43
 
@@ -49,70 +49,26 @@ def open_webapp(url: str) -> bool:
49
49
  success = webbrowser.open(url)
50
50
  log.debug(f'Opened {url} in browser tab: {success}')
51
51
  if not success:
52
- raise RuntimeError('Unknown error while opening browser tab')
53
- except Exception as e:
52
+ # pylint: disable=raise-missing-from
53
+ raise RuntimeError(
54
+ 'Unknown error while opening browser tab') from e
55
+ except Exception as e2:
54
56
  log.warning(
55
- f'Exhausted all options to open browser, you need to open manually')
56
- log.debug(f'As tab error: {e}')
57
+ 'Exhausted all options to open browser, you need to open manually')
58
+ log.debug(f'As tab error: {e2}')
57
59
  log.info(f'LANScape UI is running on {url}')
58
60
  return False
59
61
 
60
62
 
61
63
  def get_default_browser_executable() -> Optional[str]:
64
+ """Platform-agnostic method to get the default browser executable path."""
62
65
  if sys.platform.startswith("win"):
63
66
  return windows_get_browser_from_registry()
64
67
 
68
+ if sys.platform.startswith("linux"):
69
+ return linux_get_browser_executable()
65
70
 
66
- elif sys.platform.startswith("linux"):
67
- # First, find the .desktop file name
68
- desktop_file = None
69
- try:
70
- # Try xdg-mime
71
- p = subprocess.run(
72
- ["xdg-mime", "query", "default", "x-scheme-handler/http"],
73
- capture_output=True, text=True,
74
- check=True
75
- )
76
- desktop_file = p.stdout.strip()
77
- except subprocess.CalledProcessError:
78
- pass
79
-
80
- if not desktop_file:
81
- # Fallback to xdg-settings
82
- try:
83
- p = subprocess.run(
84
- ["xdg-settings", "get", "default-web-browser"],
85
- capture_output=True, text=True,
86
- check=True
87
- )
88
- desktop_file = p.stdout.strip()
89
- except subprocess.CalledProcessError:
90
- pass
91
-
92
- # Final fallback: BROWSER environment variable
93
- if not desktop_file:
94
- return os.environ.get("BROWSER")
95
-
96
- # Look for that .desktop file in standard locations
97
- search_paths = [
98
- os.path.expanduser("~/.local/share/applications"),
99
- "/usr/local/share/applications",
100
- "/usr/share/applications",
101
- ]
102
- for path in search_paths:
103
- full_path = os.path.join(path, desktop_file)
104
- if os.path.isfile(full_path):
105
- with open(full_path, encoding="utf-8", errors="ignore") as f:
106
- for line in f:
107
- if line.startswith("Exec="):
108
- exec_cmd = line[len("Exec="):].strip()
109
- # strip arguments like “%u”, “--flag”, etc.
110
- exec_cmd = exec_cmd.split()[0]
111
- exec_cmd = exec_cmd.split("%")[0]
112
- return exec_cmd
113
- return None
114
-
115
- elif sys.platform.startswith("darwin"):
71
+ if sys.platform.startswith("darwin"):
116
72
  # macOS: try to find Chrome first for app mode support, fallback to default
117
73
  try:
118
74
  p = subprocess.run(
@@ -128,14 +84,75 @@ def get_default_browser_executable() -> Optional[str]:
128
84
  # Fallback to system default
129
85
  return "/usr/bin/open"
130
86
 
131
- else:
132
- raise NotImplementedError(f"Unsupported platform: {sys.platform!r}")
87
+ # Unsupported platform
88
+ return None
89
+
90
+
91
+ def linux_get_browser_executable() -> Optional[str]:
92
+ """Get the default web browser executable path on Linux."""
93
+ # First, find the .desktop file name
94
+ desktop_file = None
95
+ try:
96
+ # Try xdg-mime
97
+ p = subprocess.run(
98
+ ["xdg-mime", "query", "default", "x-scheme-handler/http"],
99
+ capture_output=True, text=True,
100
+ check=True
101
+ )
102
+ desktop_file = p.stdout.strip()
103
+ except subprocess.CalledProcessError:
104
+ pass
105
+
106
+ if not desktop_file:
107
+ # Fallback to xdg-settings
108
+ try:
109
+ p = subprocess.run(
110
+ ["xdg-settings", "get", "default-web-browser"],
111
+ capture_output=True, text=True,
112
+ check=True
113
+ )
114
+ desktop_file = p.stdout.strip()
115
+ except subprocess.CalledProcessError:
116
+ pass
117
+
118
+ # Final fallback: BROWSER environment variable
119
+ if not desktop_file:
120
+ return os.environ.get("BROWSER")
121
+
122
+ # Look for that .desktop file in standard locations
123
+ search_paths = [
124
+ os.path.expanduser("~/.local/share/applications"),
125
+ "/usr/local/share/applications",
126
+ "/usr/share/applications",
127
+ ]
128
+
129
+ exec_cmd = None
130
+ for path in search_paths:
131
+ full_path = os.path.join(path, desktop_file)
132
+ if os.path.isfile(full_path):
133
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
134
+ for line in f:
135
+ if line.startswith("Exec="):
136
+ exec_cmd = line[len("Exec="):].strip()
137
+ # strip arguments like "%u", "--flag", etc.
138
+ exec_cmd = exec_cmd.split()[0]
139
+ exec_cmd = exec_cmd.split("%")[0]
140
+ return exec_cmd
141
+
142
+ return exec_cmd
133
143
 
134
144
 
135
145
  def windows_get_browser_from_registry() -> Optional[str]:
136
146
  """Get the default web browser executable path on Windows."""
147
+ # Import winreg only on Windows platforms
148
+ if not sys.platform.startswith("win"):
149
+ return None
137
150
 
138
- import winreg # pylint: disable=import-outside-toplevel
151
+ try:
152
+ import winreg # pylint: disable=import-outside-toplevel
153
+ except ImportError:
154
+ log.debug("winreg module not available")
155
+ return None
139
156
 
140
157
  def get_reg(base, path, key=None):
141
158
  """Helper function to read a registry key."""