Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,21 @@ def delete_profile(profile_id: str) -> bool:
cursor = conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
conn.commit()
return cursor.rowcount > 0


def reset_profile(profile_id: str) -> dict[str, Any] | None:
"""Re-roll fingerprint_seed and update timestamp. Returns updated profile or None."""
existing = get_profile(profile_id)
if not existing:
return None

new_seed = random.randint(10000, 99999)
now = _now()
with get_db() as conn:
conn.execute(
"UPDATE profiles SET fingerprint_seed = ?, updated_at = ? WHERE id = ?",
(new_seed, now, profile_id),
)
conn.commit()

return get_profile(profile_id)
69 changes: 69 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,75 @@ async def delete_profile(profile_id: str):
return {"ok": True}


@app.post("/api/profiles/{profile_id}/reset", response_model=ProfileResponse)
async def reset_profile(profile_id: str):
"""Reset a profile: stop browser, wipe state files, re-roll fingerprint seed.

Preserves bookmarks, preferences, and all profile settings (name, proxy, tags, etc.).
"""
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")

# Stop browser if running
if profile_id in browser_mgr.running:
await browser_mgr.stop(profile_id)

# Wipe browser state files (cookies, history, cache, etc.)
# Preserve Bookmarks and Preferences (search engine config)
user_data_dir = Path(profile["user_data_dir"])
default_dir = user_data_dir / "Default"
if default_dir.exists():
_STATE_FILES = [
"Cookies", "Cookies-journal",
"History", "History-journal", "History Provider Cache",
"Login Data", "Login Data-journal",
"Web Data", "Web Data-journal",
"Favicons", "Favicons-journal",
"Shortcuts", "Shortcuts-journal",
"Top Sites", "Top Sites-journal",
"Visited Links",
"Network Action Predictor",
"Network Action Predictor-journal",
"TransportSecurity",
"SecurePreferences",
"Current Session", "Current Tabs",
"Last Session", "Last Tabs",
"affiliation_db", "coupon_db",
"DownloadMetadata",
"autofill_regex_whitelist.json",
]
_STATE_DIRS = [
"Cache", "Code Cache", "GPUCache",
"Service Worker", "Service Worker/CacheStorage",
"Local Storage", "Session Storage",
"IndexedDB", "databases",
"blob_storage",
"File System",
"GCM Store",
"Extension Rules", "Extension Scripts", "Extension State",
"Platform Notifications",
]
for fname in _STATE_FILES:
(default_dir / fname).unlink(missing_ok=True)
for dname in _STATE_DIRS:
dirpath = default_dir / dname
if dirpath.exists():
shutil.rmtree(dirpath, ignore_errors=True)

# Re-roll fingerprint seed in DB
updated = db.reset_profile(profile_id)
if not updated:
raise HTTPException(status_code=500, detail="Failed to reset profile")

status = browser_mgr.get_status(profile_id)
updated["status"] = status["status"]
updated["vnc_ws_port"] = status["vnc_ws_port"]
updated["cdp_url"] = status["cdp_url"]
updated["tags"] = [TagResponse(**t) for t in updated.get("tags", [])]
return ProfileResponse(**updated)


# ── Launch / Stop ─────────────────────────────────────────────────────────────


Expand Down
102 changes: 102 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from backend import main
from backend.browser_manager import RunningProfile
from pathlib import Path


# ── Profile CRUD ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -560,3 +561,104 @@ def test_ws_allows_no_origin(app_client: TestClient):
except Exception as exc:
assert "4403" not in str(exc)
main.browser_mgr.running.pop(pid, None)


# ── Profile Reset ────────────────────────────────────────────────────────────


def test_reset_profile_not_found(app_client: TestClient):
resp = app_client.post("/api/profiles/nonexistent/reset")
assert resp.status_code == 404


def test_reset_profile_returns_updated_profile(app_client: TestClient):
create = app_client.post("/api/profiles", json={"name": "ResetTest", "fingerprint_seed": 99999})
pid = create.json()["id"]
old_seed = create.json()["fingerprint_seed"]

resp = app_client.post(f"/api/profiles/{pid}/reset")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "ResetTest"
assert data["fingerprint_seed"] != old_seed
assert data["status"] == "stopped"
assert "id" in data


def test_reset_profile_preserves_tags(app_client: TestClient):
create = app_client.post("/api/profiles", json={
"name": "ResetTags",
"tags": [{"tag": "work", "color": "#ff0000"}],
})
pid = create.json()["id"]

resp = app_client.post(f"/api/profiles/{pid}/reset")
assert resp.status_code == 200
assert len(resp.json()["tags"]) == 1
assert resp.json()["tags"][0]["tag"] == "work"


def test_reset_profile_stops_running(app_client: TestClient):
create = app_client.post("/api/profiles", json={"name": "ResetRunning"})
pid = create.json()["id"]

# Inject mock running profile
mock_running = MagicMock(spec=RunningProfile)
mock_running.display = 100
mock_running.ws_port = 6100
mock_running.cdp_port = 5100
main.browser_mgr.running[pid] = mock_running
main.browser_mgr.stop = AsyncMock()

resp = app_client.post(f"/api/profiles/{pid}/reset")
assert resp.status_code == 200
main.browser_mgr.stop.assert_called_once_with(pid)


def test_reset_profile_wipes_state_files(app_client: TestClient, tmp_path: Path):
create = app_client.post("/api/profiles", json={"name": "ResetWipe"})
pid = create.json()["id"]

# Get the profile's user_data_dir
profile = app_client.get(f"/api/profiles/{pid}").json()
default_dir = Path(profile["user_data_dir"]) / "Default"
default_dir.mkdir(parents=True, exist_ok=True)

# Create fake state files
(default_dir / "Cookies").write_text("fake cookies")
(default_dir / "History").write_text("fake history")
cache_dir = default_dir / "Cache"
cache_dir.mkdir()
(cache_dir / "data").write_text("cached")

# Create preserved files
(default_dir / "Bookmarks").write_text("{}")
(default_dir / "Preferences").write_text("{}")

resp = app_client.post(f"/api/profiles/{pid}/reset")
assert resp.status_code == 200

# State files should be deleted
assert not (default_dir / "Cookies").exists()
assert not (default_dir / "History").exists()
assert not cache_dir.exists()

# Preserved files should still exist
assert (default_dir / "Bookmarks").exists()
assert (default_dir / "Preferences").exists()


def test_reset_profile_keeps_bookmarks(app_client: TestClient):
create = app_client.post("/api/profiles", json={"name": "ResetBM"})
pid = create.json()["id"]

profile = app_client.get(f"/api/profiles/{pid}").json()
default_dir = Path(profile["user_data_dir"]) / "Default"
default_dir.mkdir(parents=True, exist_ok=True)
(default_dir / "Bookmarks").write_text('{"test": true}')
(default_dir / "Preferences").write_text('{"test": true}')

resp = app_client.post(f"/api/profiles/{pid}/reset")
assert resp.status_code == 200
assert (default_dir / "Bookmarks").read_text() == '{"test": true}'
assert (default_dir / "Preferences").read_text() == '{"test": true}'
44 changes: 44 additions & 0 deletions backend/tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,47 @@ def test_delete_profile_cascades_tags(tmp_db: Path):
"SELECT * FROM profile_tags WHERE profile_id = ?", (p["id"],)
).fetchall()
assert len(rows) == 0


# ── reset_profile ────────────────────────────────────────────────────────────


def test_reset_profile_changes_seed(tmp_db: Path):
p = db.create_profile("ResetMe", fingerprint_seed=11111)
result = db.reset_profile(p["id"])
assert result is not None
assert result["fingerprint_seed"] != 11111
assert 10000 <= result["fingerprint_seed"] <= 99999


def test_reset_profile_preserves_name(tmp_db: Path):
p = db.create_profile("KeepName", fingerprint_seed=22222)
result = db.reset_profile(p["id"])
assert result["name"] == "KeepName"


def test_reset_profile_preserves_proxy(tmp_db: Path):
p = db.create_profile("KeepProxy", proxy="http://proxy:8080")
result = db.reset_profile(p["id"])
assert result["proxy"] == "http://proxy:8080"


def test_reset_profile_preserves_tags(tmp_db: Path):
p = db.create_profile("KeepTags", tags=[{"tag": "work", "color": "#ff0000"}])
result = db.reset_profile(p["id"])
assert len(result["tags"]) == 1
assert result["tags"][0]["tag"] == "work"


def test_reset_profile_updates_timestamp(tmp_db: Path):
p = db.create_profile("Timestamp")
old_updated = p["updated_at"]
old_created = p["created_at"]
import time; time.sleep(0.01)
result = db.reset_profile(p["id"])
assert result["updated_at"] > old_updated
assert result["created_at"] == old_created


def test_reset_profile_not_found(tmp_db: Path):
assert db.reset_profile("nonexistent") is None
3 changes: 3 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ export const api = {
deleteProfile: (id: string) =>
request<{ ok: boolean }>(`/api/profiles/${id}`, { method: "DELETE" }),

resetProfile: (id: string) =>
request<Profile>(`/api/profiles/${id}/reset`, { method: "POST" }),

launchProfile: (id: string) =>
request<LaunchResult>(`/api/profiles/${id}/launch`, { method: "POST" }),

Expand Down