diff --git a/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py b/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py new file mode 100644 index 00000000..bda7fe6d --- /dev/null +++ b/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py @@ -0,0 +1,74 @@ +"""add desktop phase 2 notification and client metadata state + +Revision ID: 20260630_02 +Revises: 20260630_01 +Create Date: 2026-06-30 21:30:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "20260630_02" +down_revision: Union[str, None] = "20260630_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True)) + op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True)) + op.create_index( + "ix_security_log_client_created", + "security_access_logs", + ["client_type", "client_version", "created_at"], + ) + + op.create_table( + "desktop_notification_subscriptions", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + op.create_table( + "desktop_notification_deliveries", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "distribution_id", + name="uq_desktop_notification_user_distribution", + ), + ) + op.create_index( + "ix_desktop_notification_claim", + "desktop_notification_deliveries", + ["user_id", "delivered_at", "claimed_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries") + op.drop_table("desktop_notification_deliveries") + op.drop_table("desktop_notification_subscriptions") + op.drop_index("ix_security_log_client_created", table_name="security_access_logs") + for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"): + op.drop_column("security_access_logs", column) diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index ce1d393e..05e98136 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -346,13 +346,10 @@ async def preview_attachment( async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID): - token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.lower().startswith("bearer "): token = auth_header.split(" ", 1)[1] - if not token: - token = request.query_params.get("token") - if not token: + else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") payload = decode_token(token) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) diff --git a/backend/app/api/v1/desktop_notifications.py b/backend/app/api/v1/desktop_notifications.py new file mode 100644 index 00000000..b1940e84 --- /dev/null +++ b/backend/app/api/v1/desktop_notifications.py @@ -0,0 +1,86 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.schemas.notification import ( + DesktopNotificationAckRequest, + DesktopNotificationClaimRequest, + DesktopNotificationClaimResponse, + DesktopNotificationSubscriptionRead, + DesktopNotificationSubscriptionUpdate, +) +from app.services import desktop_notification_service + +router = APIRouter() + + +@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead) +async def read_subscription( + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationSubscriptionRead: + subscription = await desktop_notification_service.get_subscription(db, current_user.id) + return DesktopNotificationSubscriptionRead( + enabled=bool(subscription and subscription.enabled), + enabled_at=subscription.enabled_at if subscription else None, + ) + + +@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead) +async def update_subscription( + payload: DesktopNotificationSubscriptionUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationSubscriptionRead: + subscription = await desktop_notification_service.set_subscription( + db, current_user.id, payload.enabled + ) + return DesktopNotificationSubscriptionRead( + enabled=subscription.enabled, + enabled_at=subscription.enabled_at, + ) + + +@router.post("/claim", response_model=DesktopNotificationClaimResponse) +async def claim_notifications( + payload: DesktopNotificationClaimRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationClaimResponse: + if payload.limit < 1 or payload.limit > 50: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间") + claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications( + db, current_user.id, payload.limit + ) + return DesktopNotificationClaimResponse( + claim_token=claim_token, + lease_expires_at=lease_expires_at, + items=items, + ) + + +@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT) +async def acknowledge_notifications( + payload: DesktopNotificationAckRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await desktop_notification_service.acknowledge_notifications( + db, + current_user.id, + payload.claim_token, + payload.delivered_ids, + ) + + +@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT) +async def mark_notification_read( + distribution_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await desktop_notification_service.mark_notification_read( + db, current_user.id, distribution_id + ) diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index 29a2d667..6e78242b 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, desc from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, is_system_admin +from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids from app.core.permission_monitor import ( CACHE_HIT_RATE_HEALTH_MIN_ACCESSES, CACHE_METRICS_WINDOW_SECONDS, @@ -43,6 +43,11 @@ class MonitoringScope: async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: if is_system_admin(current_user): return MonitoringScope(is_admin=True, study_ids=set()) + user_id = getattr(current_user, "id", None) + if user_id: + study_ids = await list_active_pm_study_ids(db, user_id) + if study_ids: + return MonitoringScope(is_admin=False, study_ids=study_ids) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") @@ -479,6 +484,8 @@ async def get_security_access_logs( _=Depends(get_current_user), status_min: Optional[int] = Query(None, ge=100, le=599), auth_status: Optional[str] = Query(None), + client_type: Optional[str] = Query(None), + client_version: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ) -> dict: @@ -486,11 +493,17 @@ async def get_security_access_logs( scope = await resolve_monitoring_scope(db, _) if not scope.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + client_type = client_type if isinstance(client_type, str) else None + client_version = client_version if isinstance(client_version, str) else None conditions = [] if status_min is not None: conditions.append(SecurityAccessLog.status_code >= status_min) if auth_status: conditions.append(SecurityAccessLog.auth_status == auth_status) + if client_type: + conditions.append(SecurityAccessLog.client_type == client_type) + if client_version: + conditions.append(SecurityAccessLog.client_version == client_version) query = select(SecurityAccessLog) count_query = select(func.count()).select_from(SecurityAccessLog) @@ -545,6 +558,11 @@ async def get_security_access_logs( "ip_city": ip_location.city, "ip_isp": ip_location.isp, "user_agent": log.user_agent, + "client_type": log.client_type, + "client_version": log.client_version, + "client_platform": log.client_platform, + "build_channel": log.build_channel, + "build_commit": log.build_commit, "auth_status": log.auth_status, "user_identifier": log.user_identifier, "account_label": _security_account_label(log.auth_status, log.user_identifier, user_names), diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index d4db06b1..5db13f67 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles +from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles api_router = APIRouter() @@ -10,6 +10,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"]) api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"]) +api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) api_router.include_router(api_permissions.router, tags=["api-permissions"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d19e199d..4a565b7d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,10 @@ class Settings(BaseSettings): LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000 SETTINGS_ENCRYPTION_KEY: Optional[str] = None FRONTEND_PUBLIC_URL: str = "http://localhost:8888" + CORS_ALLOWED_ORIGINS: str = ( + "http://localhost:8888,http://localhost:5173," + "tauri://localhost,http://tauri.localhost" + ) IP2REGION_XDB_PATH: Optional[str] = None IP2REGION_IPV6_XDB_PATH: Optional[str] = None @@ -36,3 +40,11 @@ def get_settings() -> Settings: settings = get_settings() + + +def get_cors_allowed_origins() -> list[str]: + return [ + origin.strip() + for origin in settings.CORS_ALLOWED_ORIGINS.split(",") + if origin.strip() + ] diff --git a/backend/app/db/base.py b/backend/app/db/base.py index d54c1982..dac30b5b 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -42,4 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401 from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401 from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401 from app.models.security_access_log import SecurityAccessLog # noqa: F401 +from app.models.desktop_notification import ( # noqa: F401 + DesktopNotificationDelivery, + DesktopNotificationSubscription, +) from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401 diff --git a/backend/app/main.py b/backend/app/main.py index 57fcd1bc..9ef74f6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from app.api.v1.router import api_router -from app.core.config import settings +from app.core.config import get_cors_allowed_origins, settings from app.core.exceptions import register_exception_handlers from app.core.login_crypto import validate_login_crypto_configuration from app.crud.user import ensure_admin_exists @@ -116,10 +116,19 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=get_cors_allowed_origins(), allow_credentials=True, allow_methods=["*"], - allow_headers=["*"], + allow_headers=[ + "Accept", + "Authorization", + "Content-Type", + "X-CTMS-Client-Type", + "X-CTMS-Client-Version", + "X-CTMS-Client-Platform", + "X-CTMS-Build-Channel", + "X-CTMS-Build-Commit", + ], ) @app.middleware("http") @@ -269,6 +278,11 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), "client_ip": _resolve_client_ip(request), "user_agent": request.headers.get("user-agent"), + "client_type": request.headers.get("x-ctms-client-type"), + "client_version": request.headers.get("x-ctms-client-version"), + "client_platform": request.headers.get("x-ctms-client-platform"), + "build_channel": request.headers.get("x-ctms-build-channel"), + "build_commit": request.headers.get("x-ctms-build-commit"), "auth_status": auth_status, "user_identifier": user_identifier, } diff --git a/backend/app/models/desktop_notification.py b/backend/app/models/desktop_notification.py new file mode 100644 index 00000000..28e083fd --- /dev/null +++ b/backend/app/models/desktop_notification.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class DesktopNotificationSubscription(Base): + __tablename__ = "desktop_notification_subscriptions" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class DesktopNotificationDelivery(Base): + __tablename__ = "desktop_notification_deliveries" + __table_args__ = ( + UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"), + Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + distribution_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False + ) + claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/security_access_log.py b/backend/app/models/security_access_log.py index 4de4dc6d..c52217ee 100644 --- a/backend/app/models/security_access_log.py +++ b/backend/app/models/security_access_log.py @@ -30,6 +30,11 @@ class SecurityAccessLog(Base): elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False) client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) auth_status: Mapped[str] = mapped_column(String(30), nullable=False) user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index af6b8912..45f52062 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -16,5 +16,33 @@ class NotificationItem(BaseModel): change_summary: str | None = None effective_at: datetime | None = None created_at: datetime + study_id: uuid.UUID | None = None + study_name: str | None = None + delivered_at: datetime | None = None + read_at: datetime | None = None model_config = ConfigDict(from_attributes=True) + + +class DesktopNotificationSubscriptionRead(BaseModel): + enabled: bool + enabled_at: datetime | None = None + + +class DesktopNotificationSubscriptionUpdate(BaseModel): + enabled: bool + + +class DesktopNotificationClaimRequest(BaseModel): + limit: int = 20 + + +class DesktopNotificationClaimResponse(BaseModel): + claim_token: uuid.UUID | None = None + lease_expires_at: datetime | None = None + items: list[NotificationItem] + + +class DesktopNotificationAckRequest(BaseModel): + claim_token: uuid.UUID + delivered_ids: list[uuid.UUID] diff --git a/backend/app/services/desktop_notification_service.py b/backend/app/services/desktop_notification_service.py new file mode 100644 index 00000000..3171178e --- /dev/null +++ b/backend/app/services/desktop_notification_service.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, exists, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.desktop_notification import ( + DesktopNotificationDelivery, + DesktopNotificationSubscription, +) +from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType +from app.models.document import Document +from app.models.document_version import DocumentVersion +from app.models.study import Study +from app.models.study_member import StudyMember +from app.schemas.notification import NotificationItem + +CLAIM_LEASE = timedelta(minutes=5) + + +async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None: + return await db.get(DesktopNotificationSubscription, user_id) + + +async def set_subscription( + db: AsyncSession, + user_id: uuid.UUID, + enabled: bool, +) -> DesktopNotificationSubscription: + subscription = await get_subscription(db, user_id) + now = datetime.now(timezone.utc) + if subscription is None: + subscription = DesktopNotificationSubscription( + user_id=user_id, + enabled=enabled, + enabled_at=now if enabled else None, + ) + db.add(subscription) + elif subscription.enabled != enabled: + subscription.enabled = enabled + subscription.enabled_at = now if enabled else None + await db.commit() + await db.refresh(subscription) + return subscription + + +def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime): + role_target_exists = exists( + select(StudyMember.id).where( + StudyMember.study_id == Document.trial_id, + StudyMember.user_id == user_id, + StudyMember.is_active.is_(True), + StudyMember.role_in_study == Distribution.target_id, + ) + ) + target_matches = or_( + and_( + Distribution.target_type == DistributionTargetType.USER, + Distribution.target_id == str(user_id), + ), + and_( + Distribution.target_type == DistributionTargetType.ROLE, + role_target_exists, + ), + ) + return ( + select( + Distribution, + Document, + DocumentVersion, + Study, + DesktopNotificationDelivery, + ) + .join(Document, Distribution.document_id == Document.id) + .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) + .join(Study, Document.trial_id == Study.id) + .outerjoin( + DesktopNotificationDelivery, + and_( + DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.user_id == user_id, + ), + ) + .where( + Distribution.status == DistributionStatus.ACTIVE, + Distribution.created_at >= enabled_at, + target_matches, + or_( + DesktopNotificationDelivery.id.is_(None), + and_( + DesktopNotificationDelivery.delivered_at.is_(None), + or_( + DesktopNotificationDelivery.claimed_at.is_(None), + DesktopNotificationDelivery.claimed_at < lease_cutoff, + ), + ), + ), + ) + .order_by(Distribution.created_at.asc()) + .with_for_update(of=Distribution, skip_locked=True) + ) + + +async def claim_notifications( + db: AsyncSession, + user_id: uuid.UUID, + limit: int, +) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]: + subscription = await get_subscription(db, user_id) + if not subscription or not subscription.enabled or not subscription.enabled_at: + return None, None, [] + + now = datetime.now(timezone.utc) + token = uuid.uuid4() + rows = ( + await db.execute( + _eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50))) + ) + ).all() + items: list[NotificationItem] = [] + for distribution, document, version, study, delivery in rows: + if delivery is None: + delivery = DesktopNotificationDelivery( + user_id=user_id, + distribution_id=distribution.id, + ) + db.add(delivery) + delivery.claim_token = token + delivery.claimed_at = now + items.append( + NotificationItem( + id=distribution.id, + document_id=document.id, + version_id=version.id, + document_title=document.title, + document_no=document.doc_no, + version_no=version.version_no, + change_summary=version.change_summary, + effective_at=version.effective_at, + created_at=distribution.created_at, + study_id=study.id, + study_name=study.name, + delivered_at=delivery.delivered_at, + read_at=delivery.read_at, + ) + ) + await db.commit() + if not items: + return None, None, [] + return token, now + CLAIM_LEASE, items + + +async def acknowledge_notifications( + db: AsyncSession, + user_id: uuid.UUID, + claim_token: uuid.UUID, + delivered_ids: list[uuid.UUID], +) -> None: + if delivered_ids: + await db.execute( + update(DesktopNotificationDelivery) + .where( + DesktopNotificationDelivery.user_id == user_id, + DesktopNotificationDelivery.claim_token == claim_token, + DesktopNotificationDelivery.distribution_id.in_(delivered_ids), + ) + .values(delivered_at=datetime.now(timezone.utc)) + ) + await db.commit() + + +async def mark_notification_read( + db: AsyncSession, + user_id: uuid.UUID, + distribution_id: uuid.UUID, +) -> None: + delivery = ( + await db.execute( + select(DesktopNotificationDelivery).where( + DesktopNotificationDelivery.user_id == user_id, + DesktopNotificationDelivery.distribution_id == distribution_id, + ) + ) + ).scalar_one_or_none() + if delivery is None: + delivery = DesktopNotificationDelivery( + user_id=user_id, + distribution_id=distribution_id, + ) + db.add(delivery) + delivery.read_at = datetime.now(timezone.utc) + await db.commit() diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 8c459631..0d7fd6d9 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -10,7 +10,7 @@ from typing import Iterable import aiofiles from fastapi import HTTPException, UploadFile, status from fastapi.responses import FileResponse -from sqlalchemy import delete as sa_delete, or_, select, update as sa_update +from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope @@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType from app.models.document import Document, DocumentScopeType, DocumentStatus from app.models.document_version import DocumentVersion, DocumentVersionStatus +from app.models.desktop_notification import DesktopNotificationDelivery +from app.models.study import Study from app.schemas.acknowledgement import AcknowledgementCreate from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate @@ -778,9 +780,20 @@ async def list_distribution_notifications( DocumentVersion.version_no, DocumentVersion.change_summary, DocumentVersion.effective_at, + Study.name.label("study_name"), + DesktopNotificationDelivery.delivered_at, + DesktopNotificationDelivery.read_at, ) .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) .join(Document, Distribution.document_id == Document.id) + .join(Study, Document.trial_id == Study.id) + .outerjoin( + DesktopNotificationDelivery, + and_( + DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.user_id == current_user.id, + ), + ) .where( Document.trial_id == study_id, Distribution.status == DistributionStatus.ACTIVE, @@ -804,6 +817,10 @@ async def list_distribution_notifications( change_summary=row.change_summary, effective_at=row.effective_at, created_at=row.created_at, + study_id=study_id, + study_name=row.study_name, + delivered_at=row.delivered_at, + read_at=row.read_at, ) ) return items diff --git a/backend/app/services/security_access_log_writer.py b/backend/app/services/security_access_log_writer.py index 23354645..da25c487 100644 --- a/backend/app/services/security_access_log_writer.py +++ b/backend/app/services/security_access_log_writer.py @@ -76,6 +76,11 @@ class SecurityAccessLogWriter: elapsed_ms=entry["elapsed_ms"], client_ip=entry.get("client_ip"), user_agent=entry.get("user_agent"), + client_type=entry.get("client_type"), + client_version=entry.get("client_version"), + client_platform=entry.get("client_platform"), + build_channel=entry.get("build_channel"), + build_commit=entry.get("build_commit"), auth_status=entry["auth_status"], user_identifier=entry.get("user_identifier"), created_at=entry.get("created_at", datetime.now(timezone.utc)), diff --git a/docs/audits/storage-persistence-audit.md b/docs/audits/storage-persistence-audit.md index 7b3ea913..e0d39122 100644 --- a/docs/audits/storage-persistence-audit.md +++ b/docs/audits/storage-persistence-audit.md @@ -2,11 +2,14 @@ 状态: `snapshot` 适用范围: `storage-persistence` -最后更新: `2026-02-27` +最后更新: `2026-06-30` - 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src` -- 总发现数: `46` -- 高风险: `4` / 中风险: `0` / 低风险: `42` +- 总发现数: `36` +- 高风险: `4` / 中风险: `0` / 低风险: `32` + +> 2026-06-30 第二阶段更新:认证 token 已迁移到 `frontend/src/runtime/secureSessionStorage.ts`; +> 业务入口不再直接从 `localStorage` 读取 `ctms_token`,附件下载不再使用 query-token。 ## 明细 @@ -18,13 +21,6 @@ | high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | | low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | -| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | @@ -49,9 +45,6 @@ | low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 | -| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | diff --git a/docs/desktop-phase-2-design.md b/docs/desktop-phase-2-design.md new file mode 100644 index 00000000..02b318d5 --- /dev/null +++ b/docs/desktop-phase-2-design.md @@ -0,0 +1,160 @@ +# CTMS 桌面端第二阶段执行设计 + +## 范围与边界 + +第二阶段基于已合入 `dev` 的 Tauri 基线推进。每个评审单元从最新 `dev` 创建短期 `codex/*` 分支,合入后删除,不建立长期桌面主线。 + +本阶段只增强在线桌面客户端能力: + +- 安全运行时与诊断。 +- 原生文件选择、保存、打开。 +- 服务端持久系统通知。 +- 签名更新、发布流水线与 Windows 打包准备。 + +本阶段明确不实现离线登录、离线缓存、本地业务数据库、本地业务队列、后台业务同步或本地权限裁决。所有业务数据、权限、审计和认证判定仍以 FastAPI 后端为准。 + +## 运行时边界 + +共享业务模块只能通过 `frontend/src/runtime/` 使用平台能力。非 runtime 模块不得直接导入 Tauri API。 + +`ClientRuntime` 第二阶段能力包括: + +- `secureSessionStorage` +- `files` +- `notifications` +- `updates` +- `capabilities.secureSessionStorage/nativeFiles/systemNotifications/automaticUpdates` + +能力标志只在对应运行时可用或初始化成功后开启。Web 端继续使用浏览器能力,不申请系统通知权限,不启动桌面通知轮询,也不触发 updater。 + +## 安全会话存储 + +桌面端 token 存入系统凭据库: + +- macOS:Keychain。 +- Windows:Credential Manager。 + +Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。 + +应用挂载前异步初始化 token: + +1. Web 端继续读取 `localStorage.ctms_token`。 +2. 桌面端先删除 legacy `localStorage.ctms_token`。 +3. 若 legacy token 仍有效,则迁移到系统凭据库。 +4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。 + +登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。 + +## 原生文件能力 + +公共接口固定为: + +```ts +pickFiles(options): Promise +saveFile({ suggestedName, mimeType, data }): Promise<"saved" | "cancelled"> +openFile({ suggestedName, mimeType, data }): Promise +``` + +桌面端使用 Tauri dialog、fs、opener 插件。权限范围只覆盖用户当次选择的文件路径和 `$TEMP/ctms-desktop/**`,不启用 persisted-scope,不开放目录遍历或 shell。外部打开文件时写入随机临时目录,并使用净化后的文件名;启动和退出时清理临时目录,用户主动保存的文件不自动删除。 + +下载与预览统一先通过 Axios Bearer 请求取得 Blob,再交给文件适配器。附件全局下载接口只接受 `Authorization` header,不再接受 `?token=`。 + +## 系统通知 + +通知订阅由用户在个人设置中主动开启。开启时才请求 OS 权限并创建服务端订阅;`enabled_at` 设为当前时间,不补发历史分发记录。关闭后客户端停止领取通知,重新开启时重新设定起点。 + +后端新增: + +- `desktop_notification_subscriptions` +- `desktop_notification_deliveries` + +API: + +- `GET/PUT /api/v1/desktop-notifications/subscription` +- `POST /api/v1/desktop-notifications/claim` +- `POST /api/v1/desktop-notifications/ack` +- `POST /api/v1/desktop-notifications/{distribution_id}/read` + +claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。 + +系统通知正文只显示通用内容: + +- 标题:`CTMS 文件更新` +- 正文:`有新的文件版本待查看` + +项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。 + +## 诊断元数据与 CORS + +Axios 请求附加: + +- `X-CTMS-Client-Type` +- `X-CTMS-Client-Version` +- `X-CTMS-Client-Platform` +- `X-CTMS-Build-Channel` +- `X-CTMS-Build-Commit` + +后端安全访问日志保存这些 nullable 字段,并支持按客户端类型/版本筛选。这些字段只用于诊断与排障,不参与授权。 + +CORS origin 由环境变量白名单控制,并显式允许桌面 origin、开发 origin 和上述请求头。 + +Tauri CSP 禁止远程脚本和 shell 入口,仅允许 HTTPS API、本地开发地址、blob 预览与必要资源。 + +## 单实例 + +单实例插件必须最先注册。重复启动时只恢复、显示并聚焦主窗口,不处理命令行参数、深链或业务动作。 + +## 自动更新与发布 + +正式 release 构建启用 updater。客户端从当前 CTMS origin 派生固定清单路径: + +```text +/desktop-updates/stable/latest.json +``` + +生产只允许 HTTPS;本地测试只允许 localhost HTTP。 + +更新检查策略: + +- 启动延迟 30 秒检查。 +- 之后每 6 小时检查。 +- 发现更新时展示版本和发布说明。 +- 用户确认后下载、验签、安装并重启。 +- 用户选择“稍后”后,同版本 24 小时内不再提示。 +- 不做静默安装,不中断正在录入的业务流程。 + +Tauri 配置必须嵌入 updater 公钥并生成 updater artifacts。生产私钥只能存放在组织密钥库或 CI secret,不进仓库。macOS 更新制品为 `.app.tar.gz` 与 `.sig`。 + +release tag 流水线应从同一提交构建 Web 与桌面端: + +1. 校验 `frontend/package.json`、Tauri 配置、Cargo manifest/lock 版本一致。 +2. 构建 macOS Universal。 +3. 完成 Apple 签名和公证。 +4. 使用 updater 私钥签名更新包。 +5. 生成 DMG、更新包、签名、`latest.json` 与校验清单。 +6. 先上传不可变制品,最后原子替换 `latest.json`。 + +`latest.json` 同时提供 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 Universal 更新制品。 + +## Windows 准备 + +第二阶段只验证 Windows x64 NSIS 构建兼容,不发布正式 Windows 安装包。 + +验证范围: + +- Windows Credential Manager。 +- 路径净化与临时目录限制。 +- 系统通知编译兼容。 +- updater 编译兼容。 +- WebView2 前置条件。 +- 用户级安装假设。 +- 后续代码签名要求。 + +## 验收重点 + +- `localStorage`、URL、日志和系统通知正文不包含 token。 +- 附件下载不再接受 query-token。 +- 业务模块不直接导入 Tauri API。 +- Web 构建、类型检查和单测不回归。 +- macOS `.app` 构建可重复。 +- Keychain 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 60302600..515d60d3 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -67,6 +67,8 @@ 第二阶段在不改变在线优先产品边界的前提下,增加原生桌面能力。 +详细方案见 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。 + 范围: - 在附件等场景中引入原生文件选择、下载、打开能力。 @@ -124,9 +126,10 @@ 桌面端工作在以下位置开发: - Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` -- 分支:`codex/ctms-desktop` +- 短期分支:`codex/<任务名称>`,必须从最新 `dev` 创建 -除非发布计划另有说明,桌面端分支应持续与 `dev` 对齐。 +`codex/ctms-desktop` 仅是第一阶段 Tauri 基线临时集成分支。基线合入 +`dev` 后,第二阶段工作不得继续把它作为长期桌面主线。 ## 每次开发前必须执行的检查 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 88e90e30..26473095 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -26,11 +26,15 @@ The runtime contract currently provides: - app version, source commit, build channel, and client type metadata - explicit capability flags - Desktop server address configuration +- secure session storage +- file picker/save/open adapters +- desktop system notification adapters +- desktop updater adapters Business modules must use this public runtime entry point. They must not inspect Tauri globals or import Tauri packages directly. Native files, notifications, -secure session storage, and automatic updates remain disabled until their -second-phase adapters are implemented and reviewed. +secure session storage, and automatic updates must remain behind +`frontend/src/runtime/` and explicit capability flags. ## Version Change @@ -71,6 +75,51 @@ VITE_BUILD_COMMIT="$(git rev-parse HEAD)" These values support diagnostics but do not replace the semantic version. +## Desktop Updater + +Formal Desktop release builds must use Tauri updater signatures. The application +embeds the updater public key in `frontend/src-tauri/tauri.conf.json`; the +private key must live only in the organization key vault or CI secret store. + +Runtime update checks derive the feed from the currently configured CTMS origin: + +```text +/desktop-updates/stable/latest.json +``` + +Production update feeds must use HTTPS. Local update testing may use HTTP only +for `localhost`, `127.0.0.1`, or `::1`. + +The release pipeline must: + +1. build from the accepted release tag and commit; +2. build macOS Universal desktop artifacts; +3. sign and notarize the macOS app; +4. produce updater artifacts and `.sig` files with the updater private key; +5. generate `latest.json` and a checksum manifest; +6. upload immutable artifacts first; +7. atomically replace `latest.json` last. + +For Universal macOS artifacts, `latest.json` must provide both +`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal +update package. + +## Windows Build Readiness + +Second-phase Windows work is limited to CI compatibility validation. A +`windows-latest` x64 NSIS build may be produced for verification, but it is not +a formal deliverable until Windows code signing and release support are +approved. + +Windows validation must cover: + +- WebView2 runtime prerequisite behavior; +- user-level installer assumptions; +- Windows Credential Manager session storage; +- path handling and temporary file cleanup; +- notification and updater compilation; +- future code-signing requirements. + ## Required Checks ```bash @@ -81,9 +130,12 @@ npm run runtime:check npm run type-check npm run test:unit npm run build +export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" npm run desktop:build -- --bundles app ``` The Desktop build must run on macOS for the current first-phase target. A signed or notarized public release additionally requires the Apple credentials defined -by the release owner; an unsigned internal build is not a formal distribution. +by the release owner. A formal second-phase desktop release also requires the +updater signing key; unsigned internal builds are not formal distributions. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e6947fde..a48a90f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,12 @@ "name": "ctms-frontend", "version": "0.1.0", "dependencies": { + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.3.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-updater": "^2.9.0", "axios": "^1.6.8", "date-fns": "^3.6.0", "echarts": "^6.0.0", @@ -1355,6 +1361,16 @@ "win32" ] }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, "node_modules/@tauri-apps/cli": { "version": "2.11.4", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", @@ -1587,6 +1603,51 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 307f89d8..ea431c73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,12 @@ "ui:contract": "node scripts/verify-ui-contract.mjs" }, "dependencies": { + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.3.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-updater": "^2.9.0", "axios": "^1.6.8", "date-fns": "^3.6.0", "echarts": "^6.0.0", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index a1b4cffc..5b6f3ac6 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +58,157 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "atk" version = "0.18.2" @@ -133,6 +295,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -142,6 +313,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -257,6 +441,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.65" @@ -312,6 +505,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -322,6 +525,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -442,9 +654,20 @@ dependencies = [ name = "ctms-desktop" version = "0.1.0" dependencies = [ + "keyring", + "serde", + "serde_json", + "sha2", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-updater", "time", + "url", ] [[package]] @@ -519,6 +742,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -548,6 +782,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -703,6 +938,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -720,6 +982,37 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -745,6 +1038,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -841,6 +1144,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1195,12 +1511,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1270,6 +1610,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1468,12 +1823,41 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1519,6 +1903,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1591,6 +2005,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fef88805a7ddbc8f9cf52bfa8dba90b3f80dff23a1e1533cd4f1d8e8d448fc8" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1649,6 +2084,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1670,6 +2111,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -1702,6 +2157,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1774,12 +2235,90 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1934,6 +2473,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -1949,6 +2489,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2012,12 +2564,53 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2043,6 +2636,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2131,6 +2730,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2145,7 +2755,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2176,6 +2786,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2191,6 +2815,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2259,6 +2892,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2289,6 +2931,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2378,15 +3049,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2398,6 +3074,44 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2413,6 +3127,92 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2428,6 +3228,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2485,6 +3294,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -2688,12 +3539,38 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -2806,6 +3683,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -2889,7 +3772,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -2922,6 +3805,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -2945,7 +3839,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3041,6 +3935,153 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3051,7 +4092,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3074,7 +4115,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3141,6 +4182,31 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3246,6 +4312,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3431,9 +4507,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3483,6 +4571,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -3536,6 +4635,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3785,6 +4890,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -3960,6 +5074,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -4015,6 +5142,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4024,6 +5160,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4057,13 +5202,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4094,6 +5256,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4106,6 +5274,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4118,12 +5292,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4136,6 +5322,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4148,6 +5340,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4160,6 +5358,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4172,6 +5376,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4236,7 +5446,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4283,6 +5493,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4306,6 +5526,98 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4327,6 +5639,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4360,8 +5678,60 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index c1c656c8..2e8e39b2 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,23 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" +tauri-plugin-notification = "2" +tauri-plugin-opener = "2" +tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] } +url = "2" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] } + +[target.'cfg(windows)'.dependencies] +keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] } [features] default = ["custom-protocol"] diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index baa429b8..08b32614 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -3,5 +3,25 @@ "identifier": "default", "description": "Default capability for the CTMS desktop window.", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "dialog:allow-open", + "dialog:allow-save", + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-remove", + { + "identifier": "fs:scope", + "allow": [ + { "path": "$TEMP/ctms-desktop/**" } + ] + }, + "notification:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$TEMP/ctms-desktop/**" } + ] + } + ] } diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs new file mode 100644 index 00000000..e48a0dc0 --- /dev/null +++ b/frontend/src-tauri/src/credentials.rs @@ -0,0 +1,114 @@ +use sha2::{Digest, Sha256}; +use url::Url; + +const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session"; + +fn credential_account(server_origin: &str) -> Result { + let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?; + let is_https = parsed.scheme() == "https"; + let is_local_http = parsed.scheme() == "http" + && matches!( + parsed.host_str(), + Some("localhost") | Some("127.0.0.1") | Some("::1") + ); + if !is_https && !is_local_http { + return Err("非本地服务必须使用 HTTPS".to_string()); + } + if parsed.username() != "" || parsed.password().is_some() { + return Err("服务器地址不能包含凭据".to_string()); + } + let origin = parsed.origin().ascii_serialization(); + let digest = Sha256::digest(origin.as_bytes()); + Ok(format!("{digest:x}")) +} + +#[cfg(any(target_os = "macos", windows))] +fn get_entry(server_origin: &str) -> Result { + let account = credential_account(server_origin)?; + keyring::Entry::new(CREDENTIAL_SERVICE, &account) + .map_err(|error| format!("无法访问系统凭据库:{error}")) +} + +#[tauri::command] +pub async fn credential_get(server_origin: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(&server_origin)?; + return match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(format!("读取系统凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("读取系统凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> { + if token.trim().is_empty() { + return Err("拒绝保存空凭据".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + return get_entry(&server_origin)? + .set_password(&token) + .map_err(|error| format!("保存系统凭据失败:{error}")); + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("保存系统凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn credential_delete(server_origin: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(&server_origin)?; + return match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(format!("删除系统凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("删除系统凭据任务失败:{error}"))? +} + +#[cfg(test)] +mod tests { + use super::credential_account; + + #[test] + fn account_is_stable_for_same_origin() { + assert_eq!( + credential_account("https://ctms.example.com/path").unwrap(), + credential_account("https://ctms.example.com/other").unwrap() + ); + } + + #[test] + fn rejects_insecure_remote_origin() { + assert!(credential_account("http://ctms.example.com").is_err()); + assert!(credential_account("http://localhost:8000").is_ok()); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 9c6894c0..93d330b7 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,5 +1,30 @@ +mod credentials; +mod updates; + +use tauri::Manager; + pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .manage(updates::PendingUpdate::default()) + .invoke_handler(tauri::generate_handler![ + credentials::credential_get, + credentials::credential_set, + credentials::credential_delete, + updates::desktop_update_check, + updates::desktop_update_install, + ]) .run(tauri::generate_context!()) .expect("error while running CTMS desktop application"); } diff --git a/frontend/src-tauri/src/updates.rs b/frontend/src-tauri/src/updates.rs new file mode 100644 index 00000000..ce9302b7 --- /dev/null +++ b/frontend/src-tauri/src/updates.rs @@ -0,0 +1,116 @@ +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, State}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use url::Url; + +#[derive(Default)] +pub struct PendingUpdate(pub Mutex>); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopUpdateMetadata { + version: String, + current_version: String, + notes: Option, + date: Option, +} + +fn normalized_origin(raw: &str) -> Result { + let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?; + let scheme = parsed.scheme(); + if parsed.username() != "" || parsed.password().is_some() { + return Err("server origin must not include credentials".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "server origin must include a host".to_string())?; + let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1"); + if scheme != "https" && !(scheme == "http" && localhost) { + return Err("desktop updates require HTTPS outside localhost".to_string()); + } + let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default(); + Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string()) +} + +fn update_endpoint(server_origin: &str) -> Result { + let origin = normalized_origin(server_origin)?; + origin + .join("desktop-updates/stable/latest.json") + .map_err(|_| "invalid update endpoint".to_string()) +} + +#[tauri::command] +pub async fn desktop_update_check( + app: AppHandle, + pending_update: State<'_, PendingUpdate>, + server_origin: String, +) -> Result, String> { + let endpoint = update_endpoint(&server_origin)?; + let update = app + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|err| err.to_string())? + .build() + .map_err(|err| err.to_string())? + .check() + .await + .map_err(|err| err.to_string())?; + + let metadata = update.as_ref().map(|update| DesktopUpdateMetadata { + version: update.version.clone(), + current_version: update.current_version.clone(), + notes: update.body.clone(), + date: update.date.map(|value| value.to_string()), + }); + + let mut guard = pending_update + .0 + .lock() + .map_err(|_| "pending update lock poisoned".to_string())?; + *guard = update; + Ok(metadata) +} + +#[tauri::command] +pub async fn desktop_update_install( + app: AppHandle, + pending_update: State<'_, PendingUpdate>, +) -> Result<(), String> { + let update = { + let mut guard = pending_update + .0 + .lock() + .map_err(|_| "pending update lock poisoned".to_string())?; + guard + .take() + .ok_or_else(|| "there is no pending update".to_string())? + }; + + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|err| err.to_string())?; + app.restart(); +} + +#[cfg(test)] +mod tests { + use super::update_endpoint; + + #[test] + fn update_endpoint_uses_fixed_https_path() { + let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap(); + assert_eq!( + endpoint.as_str(), + "https://ctms.example.com/desktop-updates/stable/latest.json" + ); + } + + #[test] + fn update_endpoint_allows_localhost_http_only() { + assert!(update_endpoint("http://localhost:8888").is_ok()); + assert!(update_endpoint("http://ctms.example.com").is_err()); + } +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 9549ded5..238281de 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -21,11 +21,17 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'" } }, "bundle": { "active": true, - "targets": ["app", "dmg"] + "targets": ["app", "dmg"], + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo=" + } } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c3dcc97f..76eb2b8a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,9 +8,13 @@