Photo-Composition-Designer 0.0.7__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.
Files changed (40) hide show
  1. Photo_Composition_Designer/__init__.py +0 -0
  2. Photo_Composition_Designer/__main__.py +8 -0
  3. Photo_Composition_Designer/_version.py +34 -0
  4. Photo_Composition_Designer/cli/__init__.py +0 -0
  5. Photo_Composition_Designer/cli/__main__.py +8 -0
  6. Photo_Composition_Designer/cli/cli.py +106 -0
  7. Photo_Composition_Designer/common/Anniversaries.py +93 -0
  8. Photo_Composition_Designer/common/Locations.py +87 -0
  9. Photo_Composition_Designer/common/MoonPhase.py +85 -0
  10. Photo_Composition_Designer/common/Photo.py +113 -0
  11. Photo_Composition_Designer/common/__init__.py +0 -0
  12. Photo_Composition_Designer/common/logging.py +216 -0
  13. Photo_Composition_Designer/config/__init__.py +0 -0
  14. Photo_Composition_Designer/config/config.py +321 -0
  15. Photo_Composition_Designer/core/__init__.py +0 -0
  16. Photo_Composition_Designer/core/base.py +383 -0
  17. Photo_Composition_Designer/gui/GuiLogWriter.py +79 -0
  18. Photo_Composition_Designer/gui/__init__.py +0 -0
  19. Photo_Composition_Designer/gui/__main__.py +8 -0
  20. Photo_Composition_Designer/gui/gui.py +565 -0
  21. Photo_Composition_Designer/image/CalendarRenderer.py +319 -0
  22. Photo_Composition_Designer/image/CollageRenderer.py +433 -0
  23. Photo_Composition_Designer/image/DescriptionRenderer.py +74 -0
  24. Photo_Composition_Designer/image/MapRenderer.py +101 -0
  25. Photo_Composition_Designer/image/__init__.py +0 -0
  26. Photo_Composition_Designer/tools/DescriptionsFileGenerator.py +44 -0
  27. Photo_Composition_Designer/tools/GeoPlotter.py +211 -0
  28. Photo_Composition_Designer/tools/Helpers.py +18 -0
  29. Photo_Composition_Designer/tools/ImageDistributor.py +153 -0
  30. Photo_Composition_Designer/tools/__init__.py +0 -0
  31. __init__.py +0 -0
  32. firewall_handler.py +198 -0
  33. main.py +146 -0
  34. path_handler.py +10 -0
  35. photo_composition_designer-0.0.7.dist-info/METADATA +205 -0
  36. photo_composition_designer-0.0.7.dist-info/RECORD +40 -0
  37. photo_composition_designer-0.0.7.dist-info/WHEEL +5 -0
  38. photo_composition_designer-0.0.7.dist-info/entry_points.txt +3 -0
  39. photo_composition_designer-0.0.7.dist-info/licenses/LICENSE +24 -0
  40. photo_composition_designer-0.0.7.dist-info/top_level.txt +5 -0
@@ -0,0 +1,211 @@
1
+ import math
2
+ from pathlib import Path
3
+
4
+ import geopandas as gpd
5
+ import matplotlib.colors as mcolors
6
+ import matplotlib.pyplot as plt
7
+ from shapely.geometry import Point
8
+
9
+ from path_handler import get_base_path
10
+
11
+
12
+ class GeoPlotter:
13
+ """
14
+ Class for plotting a map section with optional layers such as federal states or bodies of water.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ minimalExtension=5,
20
+ size=(400, 400),
21
+ background_color="black",
22
+ border_color="white",
23
+ line_width=0.4,
24
+ ):
25
+ base_path = get_base_path() / "res/maps"
26
+
27
+ countries_shp = base_path / "ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp"
28
+ lakes_shp = (
29
+ base_path / "ne_50m_rivers_lake_centerlines_scale_rank/"
30
+ "ne_50m_rivers_lake_centerlines_scale_rank.shp"
31
+ )
32
+
33
+ self.shapefile_path = countries_shp
34
+ self.minimalExtension = minimalExtension
35
+ self.size = size
36
+ self.background_color = self._normalize_color(background_color)
37
+ self.border_color = self._normalize_color(border_color)
38
+ self.line_width = line_width * size[1] / 100
39
+ self.size_marker = 0.25 * size[1] * size[1] / 100
40
+ self.layers = {}
41
+
42
+ # Zusätzliche Layer hinzufügen
43
+ lakes_shp = Path(lakes_shp).resolve()
44
+ self._addLayer("lakes", lakes_shp, color="royalblue", edgecolor="blue", alpha=1.0)
45
+
46
+ @staticmethod
47
+ def _normalize_color(color):
48
+ """
49
+ Normalizes the background color to the range 0-1, if necessary.
50
+ :param color: Color as name, hex value or (R, G, B) tuple in the range 0-255.
51
+ :return: Color in Matplotlib-compatible format.
52
+ """
53
+ if isinstance(color, tuple) and len(color) == 3:
54
+ return tuple(c / 255 for c in color)
55
+ return color
56
+
57
+ @staticmethod
58
+ def _create_geodataframe(coordinates: list[tuple[float, float]]):
59
+ """
60
+ Creates a GeoDataFrame from GPS coordinates.
61
+ :param coordinates: List of (latitude, longitude) tuples.
62
+ :return: GeoDataFrame with points.
63
+ """
64
+
65
+ gdf = gpd.GeoDataFrame(
66
+ {"geometry": [Point(lon, lat) for lat, lon in coordinates]},
67
+ crs="EPSG:4326",
68
+ )
69
+ return gdf
70
+
71
+ def _calculate_bounds(self, geo_df):
72
+ """
73
+ Calculates the boundaries of the map section with a buffer.
74
+ :param geo_df: GeoDataFrame with points.
75
+ :return: Boundaries as (minx, miny, maxx, maxy).
76
+ """
77
+ bounds = geo_df.total_bounds # (minx, miny, maxx, maxy)
78
+
79
+ # Calculate the height of the section based on the buffer
80
+
81
+ height_deg = abs(bounds[3] - bounds[1]) / 2
82
+ if height_deg < self.minimalExtension / 2:
83
+ height_deg = self.minimalExtension / 2
84
+ height_deg += self.minimalExtension / 8 # minimal margin
85
+
86
+ # Calculate the average width (average width of the points in the GeoDataFrame)
87
+ mid_lat = (
88
+ bounds[1] + bounds[3]
89
+ ) / 2 # Average value of the miny and maxy coordinates (latitude)
90
+ mid_lon = (
91
+ bounds[0] + bounds[2]
92
+ ) / 2 # Average value of minx and maxx coordinates (longitude)
93
+
94
+ lat_dis_per_deg = 111.32 # Spacing Latitude circle
95
+ # Calculate the width of the section taking into account the latitude
96
+ lon_dis_per_deg = lat_dis_per_deg * math.cos(
97
+ math.radians(mid_lat)
98
+ ) # Longitude distance in km
99
+ # Calculate the latitude based on the resolution and the actual longitude distance
100
+ width_deg = lat_dis_per_deg * height_deg * self.size[0] / self.size[1] / lon_dis_per_deg
101
+
102
+ return (
103
+ min(bounds[0], mid_lon - width_deg),
104
+ mid_lat - height_deg,
105
+ max(bounds[2], mid_lon + width_deg),
106
+ mid_lat + height_deg,
107
+ )
108
+
109
+ def _addLayer(self, name, shapefile_path, color="blue", edgecolor="black", alpha=0.5):
110
+ """
111
+ Adds a layer such as federal states or bodies of water.
112
+
113
+ :param name: Name of the layer.
114
+ :param shapefile_path: Path to the shapefile of the layer.
115
+ :param color: Fill color of the layer.
116
+ :param edgecolor: Color of the edges.
117
+ :param alpha: Transparency of the layer.
118
+ """
119
+ gdf = gpd.read_file(shapefile_path)
120
+ self.layers[name] = {
121
+ "gdf": gdf,
122
+ "color": color,
123
+ "edgecolor": edgecolor,
124
+ "alpha": alpha,
125
+ }
126
+
127
+ def renderMap(self, coordinates: list[tuple[float, float]]):
128
+ """
129
+ Creates a map section as a plotable object.
130
+ :param coordinates: List of (latitude, longitude) tuples.
131
+ :return: Plottable matplotlib.pyplot object.
132
+ """
133
+ # Shapefile für Ländergrenzen laden
134
+ world = gpd.read_file(self.shapefile_path)
135
+ size_marker = self.size_marker
136
+
137
+ # Kartengrenzen berechnen
138
+ if not coordinates:
139
+ points_gdf = self._create_geodataframe([(51.0504, 13.7373)])
140
+ self.minimalExtension = 25
141
+ bounds = self._calculate_bounds(points_gdf)
142
+ size_marker = 0
143
+ else:
144
+ # GeoDataFrame für GPS-Punkte erstellen
145
+ points_gdf = self._create_geodataframe(coordinates)
146
+ bounds = self._calculate_bounds(points_gdf)
147
+
148
+ # Karte plotten
149
+ fig, ax = plt.subplots(figsize=(self.size[0] / 100, self.size[1] / 100), tight_layout=True)
150
+ fig.patch.set_facecolor(self.background_color)
151
+ ax.set_facecolor(self.background_color)
152
+ plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
153
+
154
+ # Ländergrenzen plotten
155
+ # Shading of the land area for better contrast to the background
156
+ bg_color = (
157
+ mcolors.to_rgb(self.background_color)
158
+ if isinstance(self.background_color, str)
159
+ else self.background_color
160
+ )
161
+ map_land_color = tuple([x - 0.10 if x >= 0.5 else x + 0.10 for x in bg_color])
162
+ world.plot(
163
+ ax=ax,
164
+ color=map_land_color,
165
+ edgecolor=self.border_color,
166
+ linewidth=self.line_width * 1.0,
167
+ )
168
+
169
+ # Zusätzliche Layer plotten - außer im großen Europa-Plot
170
+ if coordinates:
171
+ for layer_name, layer_data in self.layers.items():
172
+ layer_data["gdf"].plot(
173
+ ax=ax,
174
+ markersize=self.size_marker,
175
+ color=layer_data["color"],
176
+ edgecolor=layer_data["edgecolor"],
177
+ alpha=layer_data["alpha"],
178
+ linewidth=self.line_width,
179
+ label=layer_name,
180
+ )
181
+
182
+ points_gdf.plot(ax=ax, marker="o", color="red", edgecolors="red", markersize=size_marker)
183
+
184
+ # Set axes to the calculated limits
185
+ ax.set_xlim(bounds[0], bounds[2])
186
+ ax.set_ylim(bounds[1], bounds[3])
187
+
188
+ # Remove axes and edge
189
+ ax.axis("off")
190
+
191
+ return plt
192
+
193
+
194
+ if __name__ == "__main__":
195
+ output_dir = Path.cwd() # Speichert die Bilder im aktuellen Arbeitsverzeichnis
196
+
197
+ for size in range(100, 900, 200):
198
+ plotter = GeoPlotter(size=(size, size))
199
+
200
+ gps_coords = [
201
+ (51.0504, 13.7373), # Dresden
202
+ (51.3397, 12.3731), # Leipzig
203
+ (50.8278, 12.9214), # Chemnitz
204
+ (51.1079, 17.0441), # Breslau
205
+ (52.5200, 13.5156), # Berlin
206
+ ]
207
+
208
+ map_plt = plotter.renderMap(gps_coords)
209
+
210
+ map_plt.savefig(output_dir / f"map_{size}.jpg", bbox_inches=None)
211
+ plt.close()
@@ -0,0 +1,18 @@
1
+ from PIL import ImageFont
2
+
3
+
4
+ # --- geometry helpers -----------------------------------------------------
5
+ def mm_to_px(mm: float | int, dpi: float | int = 300) -> int:
6
+ """Convert millimeters to pixels based on DPI."""
7
+ return int(round(float(mm) * dpi / 25.4))
8
+
9
+
10
+ # --- font helpers ---------------------------------------------------------
11
+
12
+
13
+ def load_font(name: str = "DejaVuSans.ttf", size: int = 10) -> ImageFont.FreeTypeFont:
14
+ """Load a default TTF font, safely falling back to PIL's default."""
15
+ try:
16
+ return ImageFont.truetype(name, size)
17
+ except Exception:
18
+ return ImageFont.load_default()
@@ -0,0 +1,153 @@
1
+ import random
2
+ from collections import defaultdict, deque
3
+ from datetime import datetime, timedelta
4
+
5
+ from Photo_Composition_Designer.common.Photo import Photo
6
+
7
+
8
+ class ImageDistributor:
9
+ def __init__(self, photos: list[Photo], distributions_count: int):
10
+ self.photos = photos
11
+ self.sorted_photos = sorted(photos, key=lambda photo: photo.get_date() or datetime.min)
12
+
13
+ self.distribution_count = distributions_count
14
+
15
+ def distribute_equally(self) -> list[list[Photo]]:
16
+ """
17
+ Verteilt die Bilder möglichst gleichmäßig auf die gewünschte Anzahl von Gruppen.
18
+ """
19
+ grouped_images = [] # Liste von Listen für die Verteilung
20
+
21
+ # Anzahl der Bilder pro Gruppe berechnen
22
+ images_per_group = len(self.sorted_photos) // self.distribution_count
23
+ extra_images = (
24
+ len(self.sorted_photos) % self.distribution_count
25
+ ) # Falls es nicht exakt aufgeht
26
+
27
+ photo_queue = deque(self.sorted_photos) # Nutzt deque für effiziente Pop-Operationen
28
+
29
+ for i in range(self.distribution_count):
30
+ group_size = images_per_group + (
31
+ 1 if i < extra_images else 0
32
+ ) # Extra-Bilder gleichmäßig verteilen
33
+ grouped_images.append([photo_queue.popleft() for _ in range(group_size) if photo_queue])
34
+
35
+ return grouped_images
36
+
37
+ def distribute_randomly(self, allowed_delta: int = 1) -> list[list[Photo]]:
38
+ """
39
+ Ähnlich wie distribute_equally, aber mit einem gewissen Zufallseffekt,
40
+ sodass die Anzahl der Bilder pro Gruppe leicht variieren kann.
41
+ Die Reihenfolge bleibt sortiert, und ein Zufalls-Seed sorgt für Reproduzierbarkeit.
42
+ """
43
+ random.seed(11) # Fester Seed für reproduzierbare Ergebnisse
44
+ grouped_images = []
45
+ photo_queue = deque(self.sorted_photos) # Nutzt deque für effizientes Entfernen
46
+ remaining_images = len(photo_queue)
47
+ remaining_groups = self.distribution_count
48
+
49
+ for _i in range(self.distribution_count - 1):
50
+ images_per_group = remaining_images // remaining_groups
51
+ group_size = max(
52
+ 1, images_per_group + random.choice(range(-allowed_delta, allowed_delta + 1))
53
+ )
54
+ grouped_images.append(
55
+ [photo_queue.popleft() for _ in range(min(group_size, remaining_images))]
56
+ )
57
+ remaining_images -= len(grouped_images[-1])
58
+ remaining_groups -= 1
59
+
60
+ # Alle verbleibenden Bilder der letzten Gruppe zuweisen
61
+ grouped_images.append(list(photo_queue))
62
+
63
+ return grouped_images
64
+
65
+ def distribute_group_matching_dates(
66
+ self, allowed_over_saturation: int = 2, allowed_under_saturation: int = 1
67
+ ) -> list[list[Photo]]:
68
+ """
69
+ Gruppiert Bilder mit demselben Datum zusammen,
70
+ während eine Über- oder Unterfüllung pro Gruppe erlaubt ist.
71
+ Bilder eines Tages können auf mehrere Gruppen aufgeteilt werden.
72
+ """
73
+ grouped_images = []
74
+ date_groups = defaultdict(list)
75
+
76
+ # Gruppiere Bilder nach ihrem Datum
77
+ for img in self.sorted_photos:
78
+ date_groups[img.get_date()].append(img)
79
+
80
+ sorted_dates = sorted(date_groups.keys())
81
+ remaining_images = sum(len(v) for v in date_groups.values())
82
+ remaining_groups = self.distribution_count
83
+
84
+ while sorted_dates and remaining_groups > 0:
85
+ avg_per_group = remaining_images // remaining_groups
86
+ current_group = []
87
+ current_date = sorted_dates[0]
88
+
89
+ # Füge das erste Bild des Tages hinzu
90
+ current_group.append(date_groups[current_date].pop(0))
91
+ if not date_groups[current_date]:
92
+ sorted_dates.pop(0) # Datum entfernen, wenn keine Bilder mehr vorhanden sind
93
+
94
+ while sorted_dates:
95
+ next_date = sorted_dates[0]
96
+
97
+ if (
98
+ not self.is_same_calendar_week(current_date, next_date)
99
+ and len(current_group) >= avg_per_group - allowed_under_saturation
100
+ ):
101
+ # Dieses Datum gehört nicht zur gleichen Woche & Gruppe ist ausreichend voll
102
+ break
103
+ elif len(current_group) >= avg_per_group + allowed_over_saturation:
104
+ # Gruppe hat das erlaubte Maximum erreicht
105
+ break
106
+ else:
107
+ current_group.append(date_groups[next_date].pop(0))
108
+ if not date_groups[next_date]:
109
+ sorted_dates.pop(0)
110
+
111
+ grouped_images.append(current_group)
112
+ remaining_images -= len(current_group)
113
+ remaining_groups -= 1
114
+
115
+ # Falls noch Bilder übrig sind, der letzten Gruppe zuweisen
116
+ if sorted_dates:
117
+ for remaining_date in sorted_dates:
118
+ grouped_images[-1].extend(date_groups[remaining_date])
119
+
120
+ return grouped_images
121
+
122
+ def get_monday_of_same_week(self, date: datetime) -> datetime:
123
+ return date - timedelta(days=date.weekday())
124
+
125
+ def is_same_calendar_week(self, date1: datetime, date2: datetime) -> bool:
126
+ """
127
+ Prüft, ob zwei Datumswerte in der gleichen Kalenderwoche liegen.
128
+ Berücksichtigt dabei das Jahr und die Kalenderwochen-Nummer.
129
+ """
130
+ return date1.isocalendar()[:2] == date2.isocalendar()[:2]
131
+
132
+ def distribute_by_week(self, start_date: datetime) -> list[list[Photo]]:
133
+ grouped_images = []
134
+
135
+ for week in range(self.distribution_count):
136
+ week_start = start_date + timedelta(weeks=week)
137
+ week_end = week_start + timedelta(days=6)
138
+
139
+ # Fotos auswählen, die in diese Woche passen
140
+ photos_in_week = [
141
+ photo
142
+ for photo in self.sorted_photos
143
+ if week_start.date() <= photo.get_date().date() <= week_end.date()
144
+ ]
145
+
146
+ # Entferne gefilterte Fotos aus der Hauptliste
147
+ self.sorted_photos = [
148
+ photo for photo in self.sorted_photos if photo not in photos_in_week
149
+ ]
150
+
151
+ grouped_images.append(photos_in_week)
152
+
153
+ return grouped_images
File without changes
__init__.py ADDED
File without changes
firewall_handler.py ADDED
@@ -0,0 +1,198 @@
1
+ import os
2
+ import socket
3
+ import subprocess
4
+ import sys
5
+ import traceback
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+
10
+
11
+ class FirewallHandler:
12
+ """Handles firewall detection and configuration for network access."""
13
+
14
+ def __init__(self, logger=None):
15
+ self.logger = logger
16
+ self.test_urls = [
17
+ "http://httpbin.org/get",
18
+ "http://www.google.com",
19
+ "https://httpbin.org/get",
20
+ "https://www.google.com",
21
+ ]
22
+ self.srtm_urls = [
23
+ "https://cloud.sdsc.edu/v1/AUTH_opentopography/Raster/SRTM_GL1/",
24
+ "http://cloud.sdsc.edu/v1/AUTH_opentopography/Raster/SRTM_GL1/",
25
+ ]
26
+
27
+ def check_network_access(self, timeout: int = 5) -> bool:
28
+ """Check if network access is available."""
29
+ self.logger.info("Checking network connectivity...")
30
+
31
+ # First check basic connectivity
32
+ if not self._check_basic_connectivity():
33
+ self.logger.warning("Basic network connectivity failed")
34
+ return False
35
+
36
+ # Then check HTTP access
37
+ if not self._check_http_access(timeout):
38
+ self.logger.warning("HTTP access failed")
39
+ return False
40
+
41
+ # Finally check SRTM-specific URLs
42
+ if not self._check_srtm_access(timeout):
43
+ self.logger.warning("SRTM service access failed")
44
+ return False
45
+
46
+ self.logger.info("Network access verified successfully")
47
+ return True
48
+
49
+ def _check_basic_connectivity(self) -> bool:
50
+ """Check basic network connectivity using socket."""
51
+ try:
52
+ # Try to connect to DNS servers
53
+ for host, port in [("8.8.8.8", 53), ("1.1.1.1", 53)]:
54
+ try:
55
+ with socket.create_connection((host, port), timeout=3):
56
+ return True
57
+ except (TimeoutError, OSError):
58
+ continue
59
+ return False
60
+ except Exception as e:
61
+ self.logger.debug(f"Basic connectivity check failed: {e}")
62
+ return False
63
+
64
+ def _check_http_access(self, timeout: int) -> bool:
65
+ """Check HTTP access to common websites."""
66
+ for url in self.test_urls:
67
+ try:
68
+ with urllib.request.urlopen(url, timeout=timeout) as response:
69
+ if response.status == 200:
70
+ self.logger.debug(f"HTTP access confirmed via {url}")
71
+ return True
72
+ except (TimeoutError, urllib.error.URLError, urllib.error.HTTPError) as e:
73
+ self.logger.debug(f"HTTP access failed for {url}: {e}")
74
+ continue
75
+ return False
76
+
77
+ def _check_srtm_access(self, timeout: int) -> bool:
78
+ """Check access to SRTM-specific URLs."""
79
+ for url in self.srtm_urls:
80
+ try:
81
+ with urllib.request.urlopen(url, timeout=timeout) as response:
82
+ if response.status in [200, 403]: # 403 is expected for directory listing
83
+ self.logger.debug(f"SRTM access confirmed via {url}")
84
+ return True
85
+ except (TimeoutError, urllib.error.URLError, urllib.error.HTTPError) as e:
86
+ self.logger.debug(f"SRTM access failed for {url}: {e}")
87
+ continue
88
+ return False
89
+
90
+ def handle_firewall_issue(self):
91
+ """Handle firewall issues by attempting to create rules and inform user."""
92
+ self.logger.info("Attempting to resolve firewall issues...")
93
+
94
+ # Get current executable path
95
+ exe_path = self._get_executable_path()
96
+ if not exe_path:
97
+ self.logger.error("Could not determine executable path")
98
+ self._show_manual_instructions()
99
+ return
100
+
101
+ # Try to create firewall rules
102
+ if self._is_windows():
103
+ self._handle_windows_firewall(exe_path)
104
+ else:
105
+ self._handle_linux_firewall(exe_path)
106
+
107
+ def _get_executable_path(self) -> str | None:
108
+ """Get the path of the current executable."""
109
+ try:
110
+ if getattr(sys, "frozen", False):
111
+ # PyInstaller executable
112
+ return sys.executable
113
+ else:
114
+ # Python script
115
+ return sys.executable
116
+ except Exception as e:
117
+ self.logger.error(f"Error getting executable path: {e}")
118
+ return None
119
+
120
+ def _is_windows(self) -> bool:
121
+ """Check if running on Windows."""
122
+ return os.name == "nt"
123
+
124
+ def _handle_windows_firewall(self, exe_path: str):
125
+ """Handle Windows firewall configuration."""
126
+ self.logger.info("Attempting to configure Windows Firewall...")
127
+
128
+ app_name = Path(exe_path).stem
129
+
130
+ # Commands to add firewall rules
131
+ commands = [
132
+ # Inbound rule
133
+ [
134
+ "netsh",
135
+ "advfirewall",
136
+ "firewall",
137
+ "add",
138
+ "rule",
139
+ f"name={app_name}_Inbound",
140
+ "dir=in",
141
+ "action=allow",
142
+ f"program={exe_path}",
143
+ "enable=yes",
144
+ ],
145
+ # Outbound rule
146
+ [
147
+ "netsh",
148
+ "advfirewall",
149
+ "firewall",
150
+ "add",
151
+ "rule",
152
+ f"name={app_name}_Outbound",
153
+ "dir=out",
154
+ "action=allow",
155
+ f"program={exe_path}",
156
+ "enable=yes",
157
+ ],
158
+ ]
159
+
160
+ success = False
161
+ for cmd in commands:
162
+ try:
163
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
164
+ self.logger.info(
165
+ f"Firewall rule added successfully: {' '.join(cmd)} result: {result}"
166
+ )
167
+ success = True
168
+ except subprocess.CalledProcessError as e:
169
+ self.logger.warning(f"Failed to add firewall rule: {e}")
170
+ self.logger.debug(f"Command: {' '.join(cmd)}")
171
+ self.logger.debug(f"Error output: {e.stderr}")
172
+ except Exception as e:
173
+ self.logger.error(f"Unexpected error adding firewall rule: {e}")
174
+ self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
175
+
176
+ if success:
177
+ self.logger.info(
178
+ "Windows Firewall rules added successfully. Please restart the application."
179
+ )
180
+ else:
181
+ self.logger.warning("Failed to automatically configure Windows Firewall.")
182
+ self._show_manual_instructions()
183
+
184
+ def _handle_linux_firewall(self, exe_path: str):
185
+ """Handle Linux firewall configuration."""
186
+ self.logger.info("Detected Linux system. Firewall configuration may be needed.")
187
+ self._show_manual_instructions()
188
+
189
+ def _show_manual_instructions(self):
190
+ """Show general manual instructions"""
191
+ self.logger.info(
192
+ "Please check your firewall settings and ensure the application has network access.\n"
193
+ "Refer to the documentation for manual configuration steps."
194
+ "quick solution:"
195
+ "Start your EXE as administrator:"
196
+ "Right-click on EXE → Run as administrator"
197
+ "Or temporarily deactivate the firewall:"
198
+ )