Skip to content

Commit 25944be

Browse files
lukischclaude
andcommitted
feat: pCloud-Provider hinzufügen (Windows, virtual mount, 9 Tests)
PCloudProvider erkennt pCloud Drive als virtuellen Laufwerks-Mount (mount_type="virtual") per GetVolumeInformationW-Volume-Label-Scan. Prozesssteuerung via pCloud.exe. Pause-Guard schließt den Provider korrekt aus. 9 neue Tests in test_providers_multi.py, 125/125 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dfbe965 commit 25944be

5 files changed

Lines changed: 219 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
66
## [Unreleased]
77

88
### Hinzugefügt / Added
9+
- **pCloud provider (Windows):** `PCloudProvider` erkennt pCloud Drive als virtuellen
10+
Laufwerks-Mount (`mount_type="virtual"`) per `GetVolumeInformationW`-Volume-Label-Scan
11+
(Label muss "pCloud" enthalten). Prozesssteuerung via `pCloud.exe`; Resume sucht in
12+
`%LOCALAPPDATA%\Programs\pCloud\`, `C:\Program Files\pCloud\` und
13+
`C:\Program Files (x86)\pCloud\`. Da `mount_type="virtual"`, wird der Provider
14+
korrekt vom Pause-Guard ausgeschlossen. 9 neue Tests in `test_providers_multi.py`.
915
- **Box provider (Windows):** root auto-discovery via `~/Box` plus optional `CustomBoxLocation` registry path, process detection via `Box.exe`, and pause/resume support.
1016
- **Nextcloud provider (Windows):** root auto-discovery via `%APPDATA%\\Nextcloud\\nextcloud.cfg` plus default `~/Nextcloud`, process detection via `nextcloud.exe`, and pause/resume support.
1117
- README.md, README.de.md and `llms.txt`: added discovery/search context for

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
- [~] Weitere Cloud-Provider (Box, Nextcloud, pCloud, Synology Drive)
4343
- Box erledigt 2026-06-17
4444
- Nextcloud erledigt 2026-06-16
45-
- Offen: pCloud, Synology Drive
45+
- pCloud erledigt 2026-06-28
46+
- Offen: Synology Drive
4647
- [ ] Konfigurierbares Retry-Verhalten (Exponential Backoff, max Retries)
4748
- [ ] Benachrichtigungen (System-Toast bei Dauerfehler)
4849
- [ ] Web-Dashboard / Remote-Status

TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,5 @@ Beim Port der copy+delete-Logik in den ellmos-filecommander-mcp-Server (TypeScri
5858
- [~] Weitere Provider: Box, Nextcloud, pCloud, Synology Drive
5959
Box erledigt 2026-06-17 (`~/Box` plus `CustomBoxLocation`-Registry-Pfad, `Box.exe`-Prozesssteuerung).
6060
Nextcloud erledigt 2026-06-16 (`nextcloud.cfg`-Root-Erkennung + Prozesssteuerung).
61-
Offen bleiben pCloud und Synology Drive.
61+
pCloud erledigt 2026-06-28 (Volume-Label-Scan via `GetVolumeInformationW`, `virtual` mount, `pCloud.exe`-Prozesssteuerung, 9 Tests).
62+
Offen bleibt Synology Drive.

src/cloudlockfixer/providers.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,56 @@ def resume(self) -> bool:
377377
return False
378378

379379

380+
# ── pCloud ─────────────────────────────────────────────────────────
381+
382+
383+
class PCloudProvider(SyncProvider):
384+
"""pCloud Drive for Windows — virtueller Laufwerks-Mount.
385+
386+
pCloud Drive erscheint unter Windows als Laufwerksbuchstabe, dessen
387+
Volume-Label "pCloud Drive" enthält. Erkennung analog zu Google Drive
388+
per GetVolumeInformationW (kein Subprocess nötig).
389+
"""
390+
391+
name = "pCloud"
392+
mount_type = "virtual"
393+
394+
def _detect_roots(self) -> list[Path]:
395+
if sys.platform != "win32":
396+
return []
397+
roots: list[Path] = []
398+
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
399+
for i, letter in enumerate(string.ascii_uppercase):
400+
if bitmask & (1 << i):
401+
label = _get_volume_label(letter)
402+
if "pCloud" in label:
403+
roots.append(Path(f"{letter}:\\"))
404+
return _dedup_paths(roots)
405+
406+
def is_running(self) -> bool:
407+
return _check_process("pCloud.exe")
408+
409+
def pause(self) -> bool:
410+
return _kill_process("pCloud.exe")
411+
412+
def resume(self) -> bool:
413+
if sys.platform != "win32":
414+
return False
415+
candidates = [
416+
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "pCloud" / "pCloud.exe",
417+
Path(r"C:\Program Files\pCloud\pCloud.exe"),
418+
Path(r"C:\Program Files (x86)\pCloud\pCloud.exe"),
419+
]
420+
for exe in candidates:
421+
if exe.exists():
422+
try:
423+
subprocess.Popen([str(exe)])
424+
return True
425+
except OSError:
426+
continue
427+
return False
428+
429+
380430
# ── iCloud ─────────────────────────────────────────────────────────
381431

382432

@@ -424,7 +474,8 @@ def resume(self) -> bool:
424474

425475
def _discover_providers() -> list[SyncProvider]:
426476
candidates = [OneDriveProvider(), GoogleDriveProvider(),
427-
DropboxProvider(), BoxProvider(), NextcloudProvider(), ICloudProvider()]
477+
DropboxProvider(), BoxProvider(), NextcloudProvider(),
478+
PCloudProvider(), ICloudProvider()]
428479
active: list[SyncProvider] = []
429480
for prov in candidates:
430481
try:

tests/test_providers_multi.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from unittest.mock import patch
55

66
from cloudlockfixer.providers import (
7-
BoxProvider, DropboxProvider, GoogleDriveProvider, ICloudProvider, NextcloudProvider, OneDriveProvider,
7+
BoxProvider, DropboxProvider, GoogleDriveProvider, ICloudProvider, NextcloudProvider,
8+
OneDriveProvider, PCloudProvider,
89
SyncProvider, _discover_providers, _get_providers, _is_subpath,
910
available_providers, provider_for,
1011
)
@@ -71,6 +72,10 @@ def test_icloud_is_folder_type():
7172
assert ICloudProvider().mount_type == "folder"
7273

7374

75+
def test_pcloud_is_virtual_type():
76+
assert PCloudProvider().mount_type == "virtual"
77+
78+
7479
# ── Discovery ──────────────────────────────────────────────────────
7580

7681
def test_discover_includes_provider_with_roots(monkeypatch, tmp_path):
@@ -317,3 +322,154 @@ class _Fake:
317322
assert _check_process("nextcloud.exe") is True, (
318323
"case-insensitive Suche muss Treffer finden wenn Ausgabe 'Nextcloud.exe' enthält"
319324
)
325+
326+
327+
# ── pCloud Provider ────────────────────────────────────────────────
328+
329+
def test_pcloud_detects_volume_with_pcloud_label(monkeypatch):
330+
"""_detect_roots() muss ein Laufwerk zurückgeben, dessen Label 'pCloud' enthält."""
331+
import ctypes as _ctypes
332+
import string
333+
334+
# Simuliere ein einzelnes Laufwerk P: mit Label "pCloud Drive"
335+
def fake_get_logical_drives():
336+
idx = string.ascii_uppercase.index("P")
337+
return 1 << idx
338+
339+
def fake_get_volume_label(letter: str) -> str:
340+
return "pCloud Drive" if letter == "P" else ""
341+
342+
monkeypatch.setattr(
343+
_ctypes.windll.kernel32, "GetLogicalDrives", fake_get_logical_drives,
344+
raising=False,
345+
)
346+
monkeypatch.setattr(
347+
"cloudlockfixer.providers._get_volume_label", fake_get_volume_label
348+
)
349+
import sys
350+
monkeypatch.setattr(sys, "platform", "win32")
351+
352+
prov = PCloudProvider()
353+
roots = prov._detect_roots()
354+
assert any(str(r).startswith("P:") for r in roots), (
355+
f"Laufwerk P: erwartet, erhalten: {roots}"
356+
)
357+
358+
359+
def test_pcloud_no_roots_when_no_pcloud_volume(monkeypatch):
360+
"""_detect_roots() gibt leere Liste zurück wenn kein Volume das Label 'pCloud' trägt."""
361+
monkeypatch.setattr(
362+
"cloudlockfixer.providers._get_volume_label", lambda letter: "Local Disk"
363+
)
364+
import sys
365+
monkeypatch.setattr(sys, "platform", "win32")
366+
367+
prov = PCloudProvider()
368+
# Kein einziges Laufwerk hat das pCloud-Label
369+
roots = prov._detect_roots()
370+
# Da GetLogicalDrives nicht gemockt → kann Systemlaufwerke zurückgeben,
371+
# aber keines davon hat das pCloud-Label → roots muss leer sein.
372+
assert roots == []
373+
374+
375+
def test_pcloud_is_running_detects_process(monkeypatch):
376+
monkeypatch.setattr(
377+
subprocess, "run",
378+
lambda *a, **k: _FakeCompleted("pCloud.exe 4321\n"),
379+
)
380+
assert PCloudProvider().is_running() is True
381+
382+
383+
def test_pcloud_is_running_negative(monkeypatch):
384+
monkeypatch.setattr(
385+
subprocess, "run",
386+
lambda *a, **k: _FakeCompleted("INFO: Keine Aufgaben\n"),
387+
)
388+
assert PCloudProvider().is_running() is False
389+
390+
391+
def test_pcloud_resume_tries_known_paths(monkeypatch, tmp_path):
392+
"""resume() muss den ersten vorhandenen Kandidatenpfad starten."""
393+
import subprocess as sp
394+
395+
fake_exe = tmp_path / "pCloud.exe"
396+
fake_exe.write_text("x", encoding="utf-8")
397+
398+
started: list[str] = []
399+
400+
def fake_popen(cmd):
401+
started.append(cmd[0])
402+
403+
monkeypatch.setattr(sp, "Popen", fake_popen)
404+
405+
import sys
406+
monkeypatch.setattr(sys, "platform", "win32")
407+
408+
# Patch die candidates-Liste via _LOCALAPPDATA-Pfad im Provider
409+
import os
410+
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
411+
412+
# Zieldatei muss exakt am erwarteten Pfad liegen
413+
pcloud_dir = tmp_path / "Programs" / "pCloud"
414+
pcloud_dir.mkdir(parents=True)
415+
pcloud_exe = pcloud_dir / "pCloud.exe"
416+
pcloud_exe.write_text("x", encoding="utf-8")
417+
418+
result = PCloudProvider().resume()
419+
assert result is True
420+
assert len(started) == 1
421+
assert Path(started[0]).name == "pCloud.exe"
422+
423+
424+
def test_pcloud_virtual_mount_skipped_in_pause(tmp_path):
425+
"""pCloud ist ein Virtual-Mount-Provider → wird in _providers_to_pause() übersprungen."""
426+
proot = tmp_path / "pcloud"
427+
proot.mkdir()
428+
vprov = FakeVirtualProvider(roots=[proot], running=True)
429+
task = Task(chain=[Step(op="move", src=str(proot / "a"), arg=str(tmp_path / "b"))])
430+
task.retry_count = 10
431+
432+
with patch("cloudlockfixer.worker.provider_for", return_value=vprov):
433+
result = _providers_to_pause([task], force_pause=True)
434+
435+
assert vprov not in result
436+
437+
438+
def test_pcloud_included_in_discover_when_root_found(monkeypatch, tmp_path):
439+
"""_discover_providers() muss pCloud aufnehmen wenn _roots() nicht leer ist."""
440+
monkeypatch.setattr(PCloudProvider, "_roots", lambda self: [tmp_path / "pcloud"])
441+
providers = _discover_providers()
442+
names = [p.name for p in providers]
443+
assert "pCloud" in names
444+
445+
446+
def test_pcloud_excluded_from_discover_without_roots(monkeypatch):
447+
"""_discover_providers() darf pCloud nicht aufnehmen wenn kein Volume gefunden wird."""
448+
monkeypatch.setattr(PCloudProvider, "_roots", lambda self: [])
449+
providers = _discover_providers()
450+
names = [p.name for p in providers]
451+
assert "pCloud" not in names
452+
453+
454+
def test_provider_for_resolves_pcloud_root(monkeypatch, tmp_path):
455+
"""provider_for() muss eine Datei unter dem pCloud-Root dem pCloud-Provider zuordnen."""
456+
import cloudlockfixer.providers as pmod
457+
458+
pc_root = tmp_path / "pCloud"
459+
pc_root.mkdir()
460+
461+
monkeypatch.setattr(OneDriveProvider, "_roots", lambda self: [])
462+
monkeypatch.setattr(GoogleDriveProvider, "_roots", lambda self: [])
463+
monkeypatch.setattr(DropboxProvider, "_roots", lambda self: [])
464+
monkeypatch.setattr(BoxProvider, "_roots", lambda self: [])
465+
monkeypatch.setattr(NextcloudProvider, "_roots", lambda self: [])
466+
monkeypatch.setattr(PCloudProvider, "_roots", lambda self: [pc_root])
467+
monkeypatch.setattr(ICloudProvider, "_roots", lambda self: [])
468+
469+
old = pmod._PROVIDERS
470+
pmod._PROVIDERS = _discover_providers()
471+
try:
472+
p = provider_for(pc_root / "document.pdf")
473+
assert p is not None and p.name == "pCloud"
474+
finally:
475+
pmod._PROVIDERS = old

0 commit comments

Comments
 (0)