Skip to content

Commit 2b446d8

Browse files
committed
feat: implemente auto updater
1 parent 61ef0f0 commit 2b446d8

18 files changed

Lines changed: 621 additions & 26 deletions

.github/workflows/desktop-release.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,45 @@ jobs:
6464
- name: Install frontend dependencies
6565
run: npm ci
6666

67+
# Version unique par run CI (semver patch = run_number) pour futur updater / traçabilité des builds.
68+
- name: Set desktop version for this release
69+
shell: bash
70+
working-directory: web
71+
env:
72+
RELEASE_VERSION: 0.1.${{ github.run_number }}
73+
run: |
74+
python3 << 'PY'
75+
import json, os, re
76+
version = os.environ["RELEASE_VERSION"]
77+
os.chdir("src-tauri")
78+
with open("Cargo.toml", encoding="utf-8") as f:
79+
lines = f.read().splitlines(keepends=True)
80+
in_package = False
81+
out = []
82+
for line in lines:
83+
s = line.strip()
84+
if s == "[package]":
85+
in_package = True
86+
elif s.startswith("[") and s != "[package]":
87+
in_package = False
88+
if in_package and re.match(r"^version\s*=\s*", line):
89+
line = f'version = "{version}"\n'
90+
out.append(line)
91+
with open("Cargo.toml", "w", encoding="utf-8") as f:
92+
f.writelines(out)
93+
with open("tauri.conf.json", encoding="utf-8") as f:
94+
conf = json.load(f)
95+
conf["version"] = version
96+
with open("tauri.conf.json", "w", encoding="utf-8") as f:
97+
json.dump(conf, f, indent=2, ensure_ascii=False)
98+
f.write("\n")
99+
print(f"Desktop version set to {version}")
100+
PY
101+
102+
- name: Sync Cargo.lock for new desktop version
103+
working-directory: web/src-tauri
104+
run: cargo update -p goupixdex-desktop
105+
67106
- name: Build and publish desktop artifacts
68107
uses: tauri-apps/tauri-action@v0
69108
env:
@@ -78,6 +117,8 @@ jobs:
78117
Build **production** Windows / macOS (Rust `--release`, pas de console cmd au lancement sur Windows).
79118
80119
Installeurs : voir les assets ci-dessous (.exe, .msi, .dmg).
120+
121+
**Version appli :** `0.1.${{ github.run_number }}` (semver patch = numéro de run GitHub Actions, unique à chaque build sur `main`).
81122
releaseDraft: false
82123
prerelease: false
83124
args: ${{ matrix.args }}

api/desktop_vinted_server.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""
2+
Worker HTTP local (127.0.0.1) : publication Vinted / nodriver sur le PC utilisateur.
3+
4+
Les métadonnées et le JWT sont lus sur l’API distante ; Chrome et nodriver tournent ici.
5+
6+
Lancer depuis le dossier ``api/`` (venv activé) ::
7+
8+
python desktop_vinted_server.py
9+
10+
Variables utiles : ``GOUPIX_VINTED_LOCAL_PORT`` (défaut 18766), ``GOUPIX_REMOTE_API`` (URL API si
11+
le client n’envoie pas ``X-Goupix-Remote-Api``).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import asyncio
17+
import json
18+
import logging
19+
import os
20+
import sys
21+
import uuid
22+
from typing import Annotated
23+
24+
import httpx
25+
import uvicorn
26+
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query, status
27+
from fastapi.middleware.cors import CORSMiddleware
28+
from fastapi.responses import StreamingResponse
29+
30+
from core.deps import get_bearer_or_query_token
31+
from core.win32_asyncio import ensure_proactor_event_loop
32+
from schemas.articles import VintedBatchStartBody
33+
from services import vinted_batch_progress as vinted_batch_hub
34+
from services import vinted_progress as vinted_progress_hub
35+
from services.desktop_vinted_runner import run_desktop_vinted_batch_job, run_desktop_vinted_publish_job
36+
37+
ensure_proactor_event_loop()
38+
39+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
40+
logger = logging.getLogger("goupixdex.vinted_local")
41+
42+
try:
43+
from dotenv import load_dotenv
44+
45+
load_dotenv()
46+
except ImportError:
47+
pass
48+
49+
50+
def get_remote_base_flexible(
51+
x_goupix_remote_api: Annotated[str | None, Header(alias="X-Goupix-Remote-Api")] = None,
52+
remote_api: Annotated[str | None, Query(description="URL API (SSE / EventSource)")] = None,
53+
) -> str:
54+
for cand in (x_goupix_remote_api, remote_api, os.environ.get("GOUPIX_REMOTE_API", "")):
55+
if cand and str(cand).strip():
56+
return str(cand).strip().rstrip("/")
57+
raise HTTPException(
58+
status_code=status.HTTP_400_BAD_REQUEST,
59+
detail=(
60+
"URL API distante requise (header X-Goupix-Remote-Api, query remote_api ou GOUPIX_REMOTE_API)."
61+
),
62+
)
63+
64+
65+
async def get_user_id_introspected(
66+
raw_token: Annotated[str, Depends(get_bearer_or_query_token)],
67+
remote: Annotated[str, Depends(get_remote_base_flexible)],
68+
) -> int:
69+
"""Valide le JWT via l’API distante (pas besoin du secret JWT en local)."""
70+
async with httpx.AsyncClient(timeout=30.0) as client:
71+
r = await client.get(
72+
f"{remote}/users/me",
73+
headers={"Authorization": f"Bearer {raw_token}", "Accept": "application/json"},
74+
)
75+
if r.status_code == status.HTTP_401_UNAUTHORIZED:
76+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
77+
if not r.is_success:
78+
raise HTTPException(
79+
status_code=status.HTTP_502_BAD_GATEWAY,
80+
detail="Impossible de joindre l’API distante pour valider la session.",
81+
)
82+
return int(r.json()["id"])
83+
84+
85+
router = APIRouter(prefix="/articles", tags=["articles-vinted-local"])
86+
87+
88+
@router.post("/{article_id}/publish-vinted")
89+
async def publish_vinted_for_article(
90+
article_id: int,
91+
user_id: Annotated[int, Depends(get_user_id_introspected)],
92+
raw_token: Annotated[str, Depends(get_bearer_or_query_token)],
93+
remote: Annotated[str, Depends(get_remote_base_flexible)],
94+
) -> dict[str, object]:
95+
vinted_progress_hub.register(article_id)
96+
asyncio.create_task(run_desktop_vinted_publish_job(article_id, user_id, raw_token, remote))
97+
return {
98+
"vinted": {
99+
"status": "running",
100+
"stream_path": f"/articles/{article_id}/vinted-progress",
101+
},
102+
}
103+
104+
105+
@router.get("/{article_id}/vinted-progress")
106+
async def vinted_progress_stream(
107+
article_id: int,
108+
_: Annotated[int, Depends(get_user_id_introspected)],
109+
) -> StreamingResponse:
110+
async def generate():
111+
async for ev in vinted_progress_hub.event_stream(article_id):
112+
yield f"data: {json.dumps(ev, default=str)}\n\n"
113+
114+
return StreamingResponse(
115+
generate(),
116+
media_type="text/event-stream",
117+
headers={
118+
"Cache-Control": "no-cache",
119+
"Connection": "keep-alive",
120+
"X-Accel-Buffering": "no",
121+
},
122+
)
123+
124+
125+
@router.get("/vinted-batch/active")
126+
async def vinted_batch_active(
127+
user_id: Annotated[int, Depends(get_user_id_introspected)],
128+
) -> dict[str, object]:
129+
jid = vinted_batch_hub.get_active_job_id(user_id)
130+
return {
131+
"job_id": jid,
132+
"stream_path": f"/articles/vinted-batch/{jid}/stream" if jid else None,
133+
}
134+
135+
136+
@router.get("/vinted-batch/{job_id}/stream")
137+
async def vinted_batch_stream(
138+
job_id: str,
139+
user_id: Annotated[int, Depends(get_user_id_introspected)],
140+
) -> StreamingResponse:
141+
owner = vinted_batch_hub.get_job_user_id(job_id)
142+
if owner is None:
143+
raise HTTPException(status_code=404, detail="Job introuvable ou expiré.")
144+
if owner != user_id:
145+
raise HTTPException(status_code=403, detail="Accès refusé à ce job.")
146+
147+
async def generate():
148+
async for ev in vinted_batch_hub.event_stream(job_id):
149+
yield f"data: {json.dumps(ev, default=str)}\n\n"
150+
151+
return StreamingResponse(
152+
generate(),
153+
media_type="text/event-stream",
154+
headers={
155+
"Cache-Control": "no-cache",
156+
"Connection": "keep-alive",
157+
"X-Accel-Buffering": "no",
158+
},
159+
)
160+
161+
162+
@router.post("/vinted-batch", status_code=status.HTTP_202_ACCEPTED)
163+
async def start_vinted_batch(
164+
body: VintedBatchStartBody,
165+
user_id: Annotated[int, Depends(get_user_id_introspected)],
166+
raw_token: Annotated[str, Depends(get_bearer_or_query_token)],
167+
remote: Annotated[str, Depends(get_remote_base_flexible)],
168+
) -> dict[str, object]:
169+
unique_ids = list(dict.fromkeys(body.article_ids))
170+
job_id = str(uuid.uuid4())
171+
if not vinted_batch_hub.try_register_job(job_id, user_id):
172+
raise HTTPException(
173+
status_code=409,
174+
detail="Une publication Vinted groupée est déjà en cours pour ce compte.",
175+
)
176+
asyncio.create_task(
177+
run_desktop_vinted_batch_job(job_id, user_id, unique_ids, raw_token, remote),
178+
)
179+
return {
180+
"job_id": job_id,
181+
"stream_path": f"/articles/vinted-batch/{job_id}/stream",
182+
}
183+
184+
185+
app = FastAPI(title="GoupixDex Vinted local", version="1.0.0")
186+
app.add_middleware(
187+
CORSMiddleware,
188+
allow_origins=["*"],
189+
allow_credentials=True,
190+
allow_methods=["*"],
191+
allow_headers=["*"],
192+
)
193+
app.include_router(router)
194+
195+
196+
@app.get("/health")
197+
def health() -> dict[str, str]:
198+
return {"status": "ok", "service": "goupixdex-vinted-local"}
199+
200+
201+
if __name__ == "__main__":
202+
from core.nodriver_uvicorn_loop import UVICORN_WINDOWS_NODRIVER_LOOP
203+
204+
port = int(os.environ.get("GOUPIX_VINTED_LOCAL_PORT", "18766"))
205+
loop = UVICORN_WINDOWS_NODRIVER_LOOP if sys.platform == "win32" else "auto"
206+
uvicorn.run(app, host="127.0.0.1", port=port, loop=loop, log_level="info")

api/routes/articles.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from decimal import Decimal
99
from typing import Annotated, Any
1010

11-
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status
11+
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status
1212
from fastapi.responses import StreamingResponse
1313
from sqlalchemy.orm import Session
1414

@@ -227,8 +227,25 @@ def publish_vinted_for_article(
227227
}
228228

229229

230+
@router.post("/{article_id}/confirm-vinted-publish", status_code=status.HTTP_200_OK)
231+
def confirm_vinted_publish(
232+
article_id: int,
233+
db: Annotated[Session, Depends(get_db)],
234+
user: Annotated[User, Depends(get_current_user)],
235+
) -> dict[str, bool]:
236+
"""
237+
Marque l'article comme publié sur Vinted après succès du worker desktop local.
238+
"""
239+
article = article_service.get_article(db, article_id, user.id)
240+
if article is None:
241+
raise HTTPException(status_code=404, detail="Article not found")
242+
article_service.mark_article_published_on_vinted(article_id, user.id)
243+
return {"ok": True}
244+
245+
230246
@router.post("", status_code=status.HTTP_201_CREATED)
231247
async def create_article(
248+
request: Request,
232249
background_tasks: BackgroundTasks,
233250
db: Annotated[Session, Depends(get_db)],
234251
user: Annotated[User, Depends(get_current_user)],
@@ -283,15 +300,23 @@ async def create_article(
283300
db.commit()
284301
db.refresh(article)
285302

286-
if _form_bool(publish_to_vinted):
303+
vinted_local_desktop = request.headers.get("x-goupix-vinted-target", "").strip().lower() == "local"
304+
305+
if _form_bool(publish_to_vinted) and vinted_local_desktop:
306+
vinted_result = {
307+
"status": "pending",
308+
"stream_path": f"/articles/{article.id}/vinted-progress",
309+
"desktop_local": True,
310+
}
311+
elif _form_bool(publish_to_vinted):
287312
vinted_progress_hub.register(article.id)
288313
background_tasks.add_task(
289314
run_vinted_publish_job,
290315
article.id,
291316
user.id,
292317
stored_sources,
293318
)
294-
vinted_result: dict[str, Any] = {
319+
vinted_result = {
295320
"status": "running",
296321
"stream_path": f"/articles/{article.id}/vinted-progress",
297322
}

api/routes/users.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from core.database import get_db
1111
from core.deps import get_current_user
1212
from models.user import User
13-
from schemas.users import UserCreate, UserResponse, UserUpdate
13+
from core.security import decrypt_vinted_credential
14+
from schemas.users import UserCreate, UserResponse, UserUpdate, VintedDecryptedResponse
1415
from services import auth_service
1516

1617
router = APIRouter(prefix="/users", tags=["users"])
@@ -58,6 +59,16 @@ def me(current: Annotated[User, Depends(get_current_user)]) -> UserResponse:
5859
return _serialize(current)
5960

6061

62+
@router.get("/me/vinted-decrypted", response_model=VintedDecryptedResponse)
63+
def me_vinted_decrypted(current: Annotated[User, Depends(get_current_user)]) -> VintedDecryptedResponse:
64+
"""Expose les identifiants Vinted en clair pour le worker Python local (app desktop)."""
65+
plain = decrypt_vinted_credential(current.vinted_password)
66+
return VintedDecryptedResponse(
67+
vinted_email=current.vinted_email,
68+
vinted_password=plain,
69+
)
70+
71+
6172
@router.put("/{user_id}", response_model=UserResponse)
6273
def update_user(
6374
user_id: int,

api/schemas/users.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@ class UserResponse(BaseModel):
2222
created_at: str
2323

2424
model_config = {"from_attributes": False}
25+
26+
27+
class VintedDecryptedResponse(BaseModel):
28+
"""
29+
Mot de passe Vinted en clair — réservé au worker desktop local (HTTPS + JWT).
30+
"""
31+
32+
vinted_email: str | None
33+
vinted_password: str | None

api/services/desktop_stubs.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Objets Article / User non persistés pour réutiliser la logique Vinted sans DB locale."""
2+
3+
from __future__ import annotations
4+
5+
from decimal import Decimal
6+
from typing import Any
7+
8+
from models.article import Article
9+
from models.user import User
10+
11+
12+
def article_from_api_dict(d: dict[str, Any]) -> Article:
13+
a = Article()
14+
a.id = d["id"]
15+
a.user_id = d["user_id"]
16+
a.title = d["title"]
17+
a.description = d["description"]
18+
a.pokemon_name = d.get("pokemon_name")
19+
a.set_code = d.get("set_code")
20+
a.card_number = d.get("card_number")
21+
a.condition = d.get("condition") or "Near Mint"
22+
a.purchase_price = Decimal(str(d["purchase_price"]))
23+
sp = d.get("sell_price")
24+
a.sell_price = Decimal(str(sp)) if sp is not None else None
25+
a.is_sold = bool(d.get("is_sold", False))
26+
return a
27+
28+
29+
def user_stub(user_id: int, email: str, vinted_email: str | None) -> User:
30+
u = User()
31+
u.id = user_id
32+
u.email = email
33+
u.vinted_email = vinted_email
34+
u.vinted_password = None
35+
return u

0 commit comments

Comments
 (0)