Skip to content

Commit 39459f3

Browse files
lukischclaude
andcommitted
feat: cwd-aware launch, neues App-Logo, ROADMAP
- open_file startet Tools mit Arbeitsverzeichnis = Ordner der Ziel-Datei (os.startfile cwd / ShellExecuteW lpDirectory) -> behebt Apps, die Ressourcen relativ zur cwd laden - neues App-Icon/Logo (icon.ico, DesktopIcon.ico, store_assets, web_companion) - ROADMAP.md: universeller Ablagelayer (Dateien/Ordner/Dokumente) + Project-Boards - Test fuer cwd-Launch (tests/test_open_file_cwd.py) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V5RVPpPGT4fb2idwJrttoJ
1 parent b7ef5a3 commit 39459f3

18 files changed

Lines changed: 534 additions & 7 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.
88
- Icon/EXE/START-Health-Check 2026-06-19: vorhandenes `icon.ico` weiterverwendet, `START.bat` auf EXE-first umgestellt, `build_exe.bat` auf lokalen Buildpfad `C:\_Local_DEV\codex_build\softwarecenter` mit Build-Exclude-Scanner aktualisiert und `SoftwareCenter.exe` neu gebaut.
99
- EXE neu gebaut 2026-06-01 (PyInstaller, `SoftwareCenter.spec`); 9/9 Tests grün, Smoke-Test bestanden. Vorherige EXE: 2026-05-01.
1010

11+
### Added
12+
- Neues App-Icon im Superman-Wappen-Stil: rotes Schild mit fettem, leicht schrägem weißem „SC" und weißem Rand. Reproduzierbar über `generate_icon.py`; ersetzt `icon.ico`, `DesktopIcon.ico`, die Windows-Store-Logos (`store_assets/*.png`) sowie die PWA-Icons und das `icon.svg` des Web-Companions (maskable-/apple-touch-Varianten mit rotem Vollhintergrund).
13+
- Kontextmenü „Senden an" (verschieben) und „Duplizieren auf" (kopieren): Einträge lassen sich per Rechtsklick auf ein anderes Board (Tab) verschieben oder zusätzlich dort anzeigen. Submenüs listen alle Boards außer dem aktuellen und erscheinen nur bei vorhandener Auswahl und mindestens einem weiteren Board. Label, Typ und Notizen bleiben erhalten, bereits vorhandene Pfade werden im Ziel-Board nicht dupliziert. Tests in `tests/test_features_board_transfer.py`.
14+
1115
### Changed
1216
- Windows-`.lnk`-Dateien, die auf eine `.exe` oder einen Ordner zeigen, werden beim Hinzufügen jetzt als Originalziel gespeichert und angezeigt; direkte Ordner-Drops werden ebenfalls unterstützt, nicht auflösbare Links bleiben kompatibel als `.lnk`-Eintrag erhalten.
1317
- Web Companion: Service-Worker-Cache auf v3 angehoben und Offline-Fetch-Fehler liefern jetzt einen HTTP-503-Fallback statt unkontrolliert zu scheitern.

DesktopIcon.ico

11.9 KB
Binary file not shown.

ROADMAP.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SoftwareCenter — ROADMAP
2+
3+
> Strategische Ideen und geplante Erweiterungen. Status-Schnappschuss; abgeschlossene Punkte wandern ins CHANGELOG.
4+
5+
## Vision: Universeller Ordnungslayer außerhalb des Dateisystems
6+
7+
SoftwareCenter soll **nicht nur App-Verknüpfungen** aufnehmen, sondern **alles, was man ablegen will** — Verknüpfungen, Dokumente, Ordner, Dateien, URLs. Der Nutzer ordnet Dinge frei in Tabs/Boards, **unabhängig davon, wo sie physisch im Dateisystem liegen**. Damit wird SoftwareCenter zu einem zweiten Ordnungslayer **über** dem Dateisystem.
8+
9+
- **Bereits unterstützt (technisch):** Drag & Drop von Dateien, Ordnern, `.lnk`-Verknüpfungen, `.url`, Skripten und Dokumenten. Intern unterscheidet die Engine schon die Typen `file`, `directory`, `windows_shortcut`, `url`, `script`, `mac_app`, `linux_desktop` (siehe `detect_entry_kind` / `is_supported_launch_target`).
10+
- **Auszubauen:**
11+
- Klar kommunizieren, dass **beliebige** Dateien/Ordner/Dokumente ablegbar sind (nicht nur EXEs) — UI-Hinweis, Doku, Store-Listing.
12+
- Komfort für Nicht-App-Einträge: Kontextmenü „Im Explorer anzeigen", optionale Vorschau/Notiz, Sortierung.
13+
- Robustes Verhalten bei fehlenden Zielen (Eintrag bleibt sichtbar, klare Warnung).
14+
15+
## Idee: Zwillings-Produkt „Project-Boards"
16+
17+
Eine **identische Variante** des SoftwareCenter unter eigenem Namen **„Project-Boards"** herausbringen — positioniert als **eigenständiges, zweites Ordnungslayer außerhalb des Dateisystems** (Projekt-/Themen-Boards statt App-Launcher).
18+
19+
- **Gleiche Code-Basis, andere Positionierung/Branding:** SoftwareCenter = Fokus App-Launcher; Project-Boards = allgemeines Projekt-/Ordnungs-Board (Verknüpfungen, Dokumente, Ordner, Notizen je Projekt/Thema in eigenen Boards/Tabs).
20+
- **Umsetzung:**
21+
- Identische Engine; separater Name, Icon, Store-Listing.
22+
- **Eigener QSettings-Namespace** (z. B. `QSettings("LukasGeiger", "ProjectBoards")`), damit beide Programme **parallel** mit getrennten Profilen laufen.
23+
- Gemeinsamer Kern als Modul, dünne App-Wrapper je Produkt — vermeidet Code-Duplikation.
24+
- Profil-Austauschformat (`softwarecenter-profile-v1.json`) für beide nutzbar.
25+
26+
## Offene technische Nachsorge (aus Build-/Reparaturlauf 2026-06-27)
27+
28+
- `build_exe.bat` nutzt feste Spec-Excludes statt `build_exclude_scanner.py` (Abweichung von BUILD-VERFAHREN §3/§4) — bei Gelegenheit angleichen.
29+
- onedir-Builds erzeugen große `_internal/`-Strukturen in OneDrive (Sync-Last) — für Releases onefile bevorzugen, wo sinnvoll.

SoftwareCenter.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,40 @@ def validate_profile_payload(payload: dict) -> list[dict]:
299299
raise ValueError("Das Profil enthält keine lesbaren Tabs.")
300300
return tabs
301301

302+
def _startfile_in_dir(path: str, workdir: str | None) -> None:
303+
"""Startet eine Datei unter Windows mit gesetztem Arbeitsverzeichnis.
304+
305+
Viele gebuendelte Apps suchen Ressourcen/Module relativ zum Arbeitsverzeichnis
306+
(cwd). Eine Desktop-Verknuepfung setzt dafuer "Ausfuehren in:"; os.startfile()
307+
erbt dagegen die cwd des SoftwareCenter-Prozesses, was solche Apps brechen kann.
308+
Daher hier explizit das Verzeichnis der Ziel-Datei als cwd setzen.
309+
"""
310+
# Python 3.13+: os.startfile akzeptiert cwd direkt.
311+
try:
312+
os.startfile(path, cwd=workdir) # type: ignore[call-arg]
313+
return
314+
except TypeError:
315+
pass # aelteres Python ohne cwd-Parameter -> ShellExecuteW
316+
except OSError:
317+
raise
318+
import ctypes
319+
# ShellExecuteW(hwnd, lpVerb, lpFile, lpParameters, lpDirectory, nShowCmd)
320+
# lpDirectory = Arbeitsverzeichnis (wie "Ausfuehren in" einer Verknuepfung).
321+
rc = ctypes.windll.shell32.ShellExecuteW(None, None, path, None, workdir, 1)
322+
if rc <= 32: # ShellExecute meldet Fehler als Wert <= 32
323+
os.startfile(path) # letzter Fallback ohne Arbeitsverzeichnis
324+
325+
302326
def open_file(path: str) -> None:
303327
if not os.path.exists(path):
304328
QMessageBox.warning(None, "Datei nicht gefunden", f"Pfad existiert nicht:\n{path}")
305329
return
306330
try:
307331
if sys.platform.startswith("win"):
308-
os.startfile(path) # type: ignore[attr-defined]
332+
# Windows: Arbeitsverzeichnis = Ordner der Ziel-Datei (wie "Ausfuehren in"
333+
# einer Verknuepfung), damit Apps ihre Ressourcen relativ zur cwd finden.
334+
workdir = os.path.dirname(os.path.abspath(path)) or None
335+
_startfile_in_dir(path, workdir)
309336
elif sys.platform == "darwin":
310337
subprocess.Popen(["open", path])
311338
else:
@@ -324,6 +351,9 @@ def open_file(path: str) -> None:
324351

325352
class SoftwareListWidget(QListWidget):
326353
requestDelete = Signal(list)
354+
# Transfer zwischen Boards (Tabs): jeweils (entries: list[dict], target_index: int)
355+
requestMoveToBoard = Signal(list, int)
356+
requestCopyToBoard = Signal(list, int)
327357
def __init__(self, parent=None):
328358
super().__init__(parent)
329359
self.setAcceptDrops(True)
@@ -332,6 +362,10 @@ def __init__(self, parent=None):
332362
self.customContextMenuRequested.connect(self._on_context_menu)
333363
self.itemActivated.connect(self._on_item_activated)
334364
self._icon_provider = QFileIconProvider()
365+
# Callable -> list[(index, name)] der ANDEREN Boards (ohne dieses).
366+
# Wird von MainWindow.add_new_tab gesetzt; ohne Provider bleiben die
367+
# Transfer-Submenues ausgeblendet (z. B. bei isolierter Nutzung in Tests).
368+
self.board_provider = None
335369
self.configure_as_tiles()
336370

337371
def configure_as_tiles(self):
@@ -384,8 +418,24 @@ def _on_context_menu(self, pos):
384418
menu = QMenu(self)
385419
act_open = menu.addAction("Öffnen/Starten")
386420
act_del = menu.addAction("Löschen")
421+
422+
# "Senden an" (verschieben) / "Duplizieren auf" (kopieren) nur anbieten,
423+
# wenn etwas ausgewählt ist UND mindestens ein anderes Board existiert.
424+
move_actions: dict = {}
425+
copy_actions: dict = {}
426+
boards = self.board_provider() if callable(self.board_provider) else []
427+
if self.selectedItems() and boards:
428+
menu.addSeparator()
429+
move_menu = menu.addMenu("Senden an")
430+
copy_menu = menu.addMenu("Duplizieren auf")
431+
for index, name in boards:
432+
move_actions[move_menu.addAction(name)] = index
433+
copy_actions[copy_menu.addAction(name)] = index
434+
387435
global_pos = self.viewport().mapToGlobal(pos)
388436
action = menu.exec(global_pos)
437+
if action is None:
438+
return
389439
if action == act_open:
390440
for item in self.selectedItems():
391441
path = item.data(Qt.ItemDataRole.UserRole)
@@ -394,6 +444,38 @@ def _on_context_menu(self, pos):
394444
paths = [it.data(Qt.ItemDataRole.UserRole) for it in self.selectedItems()]
395445
if paths:
396446
self.requestDelete.emit(paths)
447+
elif action in move_actions:
448+
entries = self._selected_entries()
449+
if entries:
450+
self.requestMoveToBoard.emit(entries, move_actions[action])
451+
elif action in copy_actions:
452+
entries = self._selected_entries()
453+
if entries:
454+
self.requestCopyToBoard.emit(entries, copy_actions[action])
455+
456+
def _selected_entries(self) -> list[dict]:
457+
"""Vollständige Eintrags-Dicts (Pfad, Label, Typ, Notizen) der Auswahl.
458+
459+
Spiegelt den Fallback aus get_all_entries(), falls ein Item kein
460+
ENTRY_METADATA_ROLE-Dict trägt (z. B. Alt-Daten)."""
461+
entries = []
462+
for item in self.selectedItems():
463+
metadata = item.data(ENTRY_METADATA_ROLE)
464+
if isinstance(metadata, dict):
465+
entries.append(normalize_entry(metadata))
466+
else:
467+
path = item.data(Qt.ItemDataRole.UserRole)
468+
entries.append(
469+
normalize_entry(
470+
{
471+
"path": path,
472+
"label": item.text(),
473+
"kind": detect_entry_kind(path),
474+
"notes": None,
475+
}
476+
)
477+
)
478+
return [entry for entry in entries if entry]
397479

398480
def _on_item_activated(self, item: QListWidgetItem):
399481
path = item.data(Qt.ItemDataRole.UserRole)
@@ -603,10 +685,50 @@ def add_new_tab(self, name: str | None = None, view_mode="tiles", paths=None, en
603685
page.list.set_all_entries(entries)
604686
elif paths:
605687
page.list.set_all_paths(paths)
688+
# Board-Transfer verdrahten: Provider liefert zur Menü-Zeit die anderen
689+
# Boards, die Signale lösen Verschieben/Duplizieren aus.
690+
page.list.board_provider = self._board_provider_for(page)
691+
page.list.requestMoveToBoard.connect(
692+
lambda transfer, target_index, source=page: self.move_entries_to_board(source, transfer, target_index)
693+
)
694+
page.list.requestCopyToBoard.connect(self.copy_entries_to_board)
606695
idx = self.tabs.addTab(page, name or "Neuer Tab")
607696
self._update_tab_closable_state()
608697
self.tabs.setCurrentIndex(idx)
609698

699+
def _board_provider_for(self, page: "TabPage"):
700+
"""Closure: liefert zur Aufrufzeit (index, name) aller Boards außer `page`.
701+
702+
Dynamisch, damit verschobene/umbenannte/geschlossene Tabs korrekt
703+
berücksichtigt werden."""
704+
def provider():
705+
result = []
706+
for i in range(self.tabs.count()):
707+
if self.tabs.widget(i) is page:
708+
continue
709+
result.append((i, self.tabs.tabText(i)))
710+
return result
711+
return provider
712+
713+
def move_entries_to_board(self, source_page: "TabPage", entries: list, target_index: int):
714+
target_page = self.tabs.widget(target_index)
715+
if not isinstance(target_page, TabPage) or target_page is source_page:
716+
return
717+
if not entries:
718+
return
719+
target_page.list.add_entries(entries)
720+
source_page.list.remove_paths([e["path"] for e in entries if isinstance(e, dict) and e.get("path")])
721+
self.save_settings()
722+
723+
def copy_entries_to_board(self, entries: list, target_index: int):
724+
target_page = self.tabs.widget(target_index)
725+
if not isinstance(target_page, TabPage):
726+
return
727+
if not entries:
728+
return
729+
target_page.list.add_entries(entries)
730+
self.save_settings()
731+
610732
def export_profile(self):
611733
default_name = os.path.join(os.path.expanduser("~"), f"{PROFILE_FORMAT}.json")
612734
target, _selected_filter = QFileDialog.getSaveFileName(

generate_icon.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# -*- coding: utf-8 -*-
2+
"""Erzeugt das SoftwareCenter-Icon im Superman-Wappen-Stil.
3+
4+
Motiv: rotes Wappen-Schild mit fettem, leicht schraegem weissem "SC".
5+
Erzeugt reproduzierbar alle App-, Store- und PWA-Assets aus einem Master.
6+
7+
Aufruf:
8+
PYTHONIOENCODING=utf-8 python generate_icon.py # schreibt alle Assets
9+
PYTHONIOENCODING=utf-8 python generate_icon.py --preview out.png # nur Vorschau
10+
11+
Abhaengigkeit: Pillow. Font: Arial Black (C:\\Windows\\Fonts\\ariblk.ttf).
12+
"""
13+
import os
14+
import sys
15+
16+
from PIL import Image, ImageDraw, ImageFont
17+
18+
HERE = os.path.dirname(os.path.abspath(__file__))
19+
20+
SS = 4 # Supersampling
21+
BASE = 1024
22+
N = BASE * SS
23+
24+
RED = (206, 27, 35, 255) # Superman-Rot
25+
WHITE = (255, 255, 255, 255)
26+
FONT_PATH = r"C:\Windows\Fonts\ariblk.ttf"
27+
28+
29+
def _quad(p0, p1, p2, steps):
30+
out = []
31+
for i in range(steps + 1):
32+
t = i / steps
33+
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
34+
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
35+
out.append((x, y))
36+
return out
37+
38+
39+
def _cubic(p0, p1, p2, p3, steps):
40+
out = []
41+
for i in range(steps + 1):
42+
t = i / steps
43+
mt = 1 - t
44+
x = mt ** 3 * p0[0] + 3 * mt ** 2 * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0]
45+
y = mt ** 3 * p0[1] + 3 * mt ** 2 * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1]
46+
out.append((x, y))
47+
return out
48+
49+
50+
def _shield_points(n):
51+
"""Wappen-Schild als Polygonpunkte, normiert auf Kantenlaenge n."""
52+
def S(x, y):
53+
return (x * n, y * n)
54+
55+
tl, tr = (0.13, 0.16), (0.87, 0.16)
56+
top_ctrl = (0.50, 0.085)
57+
tip = (0.50, 0.95)
58+
r_c1, r_c2 = (1.00, 0.33), (0.74, 0.74)
59+
l_c2, l_c1 = (0.26, 0.74), (0.00, 0.33)
60+
61+
pts = _quad(tl, top_ctrl, tr, 120)
62+
pts += _cubic(tr, r_c1, r_c2, tip, 200)[1:]
63+
pts += _cubic(tip, l_c2, l_c1, tl, 200)[1:]
64+
return [S(x, y) for (x, y) in pts]
65+
66+
67+
def _draw_sc(canvas, n, font_frac=0.30, y_center=0.40):
68+
"""Rendert ein leicht schraeges weisses 'SC' mittig auf canvas (Kantenlaenge n)."""
69+
font = ImageFont.truetype(FONT_PATH, int(font_frac * n))
70+
layer = Image.new("RGBA", (n, n), (0, 0, 0, 0))
71+
d = ImageDraw.Draw(layer)
72+
bbox = d.textbbox((0, 0), "SC", font=font)
73+
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
74+
tx = (n - tw) / 2 - bbox[0]
75+
ty = (y_center * n) - th / 2 - bbox[1]
76+
d.text((tx, ty), "SC", font=font, fill=WHITE)
77+
shear = -0.16
78+
layer = layer.transform(
79+
(n, n), Image.AFFINE, (1, shear, -shear * n * 0.5, 0, 1, 0),
80+
resample=Image.BICUBIC,
81+
)
82+
return Image.alpha_composite(canvas, layer)
83+
84+
85+
def shield_master():
86+
"""Transparentes Schild-Master (N x N): weisser Rand, rote Flaeche, weisses SC."""
87+
img = Image.new("RGBA", (N, N), (0, 0, 0, 0))
88+
d = ImageDraw.Draw(img)
89+
poly = _shield_points(N)
90+
d.polygon(poly, fill=WHITE) # weisser Aussenrand
91+
c = N / 2
92+
inner = [(c + (x - c) * 0.93, c + (y - c) * 0.93) for (x, y) in poly]
93+
d.polygon(inner, fill=RED) # rote Innenflaeche
94+
return _draw_sc(img, N)
95+
96+
97+
def filled_master():
98+
"""Rotes Vollbild-Master (N x N) mit weissem SC — fuer maskable/apple-touch."""
99+
img = Image.new("RGBA", (N, N), RED)
100+
return _draw_sc(img, N, font_frac=0.42, y_center=0.50)
101+
102+
103+
def _save_png(master, size, path, canvas=None):
104+
out = master.resize((size, size), Image.LANCZOS)
105+
if canvas: # in groesseren/anders geformten Rahmen zentrieren
106+
bg = Image.new("RGBA", canvas, (0, 0, 0, 0))
107+
bg.alpha_composite(out, ((canvas[0] - size) // 2, (canvas[1] - size) // 2))
108+
out = bg
109+
out.save(path)
110+
print("PNG :", os.path.relpath(path, HERE))
111+
112+
113+
def _save_ico(master, path):
114+
sizes = [256, 128, 64, 48, 32, 16]
115+
imgs = [master.resize((s, s), Image.LANCZOS) for s in sizes]
116+
imgs[0].save(path, format="ICO", sizes=[(s, s) for s in sizes], append_images=imgs[1:])
117+
print("ICO :", os.path.relpath(path, HERE))
118+
119+
120+
SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
121+
<path d="M 33.3 41 Q 128 21.8 222.7 41 C 256 84.5 189.4 189.4 128 243.2 C 66.6 189.4 0 84.5 33.3 41 Z"
122+
fill="#ce1b23" stroke="#ffffff" stroke-width="6" stroke-linejoin="round"/>
123+
<g transform="translate(128,118)">
124+
<text transform="skewX(-9)" text-anchor="middle"
125+
font-family="'Arial Black','Arial',sans-serif" font-weight="900"
126+
font-size="92" fill="#ffffff">SC</text>
127+
</g>
128+
</svg>
129+
"""
130+
131+
132+
def main():
133+
if len(sys.argv) > 2 and sys.argv[1] == "--preview":
134+
shield_master().resize((512, 512), Image.LANCZOS).save(sys.argv[2])
135+
print("Vorschau:", sys.argv[2])
136+
return
137+
138+
shield = shield_master()
139+
filled = filled_master()
140+
141+
# App-Icons
142+
_save_ico(shield, os.path.join(HERE, "icon.ico"))
143+
_save_ico(shield, os.path.join(HERE, "DesktopIcon.ico"))
144+
145+
# Windows-Store-Assets (transparent)
146+
store = os.path.join(HERE, "store_assets")
147+
_save_png(shield, 44, os.path.join(store, "Square44x44Logo.png"))
148+
_save_png(shield, 150, os.path.join(store, "Square150x150Logo.png"))
149+
_save_png(shield, 310, os.path.join(store, "Square310x310Logo.png"))
150+
_save_png(shield, 150, os.path.join(store, "Wide310x150Logo.png"), canvas=(310, 150))
151+
152+
# PWA-Icons
153+
icons = os.path.join(HERE, "web_companion", "icons")
154+
_save_png(shield, 192, os.path.join(icons, "Icon-192.png"))
155+
_save_png(shield, 512, os.path.join(icons, "Icon-512.png"))
156+
_save_png(filled, 192, os.path.join(icons, "Icon-maskable-192.png"))
157+
_save_png(filled, 512, os.path.join(icons, "Icon-maskable-512.png"))
158+
_save_png(filled, 180, os.path.join(icons, "apple-touch-icon-180.png"))
159+
160+
# Skalierbares SVG fuer den Web-Companion
161+
svg_path = os.path.join(HERE, "web_companion", "icon.svg")
162+
with open(svg_path, "w", encoding="utf-8") as fh:
163+
fh.write(SVG)
164+
print("SVG :", os.path.relpath(svg_path, HERE))
165+
166+
167+
if __name__ == "__main__":
168+
main()

icon.ico

-76.5 KB
Binary file not shown.

store_assets/Square150x150Logo.png

-8.49 KB
Loading

store_assets/Square310x310Logo.png

-37.2 KB
Loading

store_assets/Square44x44Logo.png

83 Bytes
Loading

store_assets/Wide310x150Logo.png

-9.19 KB
Loading

0 commit comments

Comments
 (0)