diff --git a/donut-sync/package.json b/donut-sync/package.json index ea9ecff..0fda5ec 100644 --- a/donut-sync/package.json +++ b/donut-sync/package.json @@ -24,6 +24,7 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.13", "@nestjs/platform-express": "^11.1.13", + "jsonwebtoken": "^9.0.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, @@ -33,6 +34,7 @@ "@nestjs/testing": "^11.1.13", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.2.3", "@types/supertest": "^6.0.3", "jest": "^30.2.0", diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index c6fd6a2..d025c53 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -2,14 +2,26 @@ import { type CanActivate, type ExecutionContext, Injectable, + Logger, UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Request } from "express"; +import * as jwt from "jsonwebtoken"; +import type { UserContext } from "./user-context.interface.js"; @Injectable() export class AuthGuard implements CanActivate { - constructor(private configService: ConfigService) {} + private readonly logger = new Logger(AuthGuard.name); + private jwtPublicKey: string | null = null; + + constructor(private configService: ConfigService) { + const publicKey = this.configService.get("SYNC_JWT_PUBLIC_KEY"); + if (publicKey) { + this.jwtPublicKey = publicKey.replace(/\\n/g, "\n"); + this.logger.log("JWT public key configured — cloud auth enabled"); + } + } canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); @@ -22,16 +34,45 @@ export class AuthGuard implements CanActivate { } const token = authHeader.substring(7); + + // Try SYNC_TOKEN first (self-hosted mode) const expectedToken = this.configService.get("SYNC_TOKEN"); - - if (!expectedToken) { - throw new UnauthorizedException("Sync token not configured on server"); + if (expectedToken && token === expectedToken) { + (request as any).user = { + mode: "self-hosted", + prefix: "", + teamPrefix: null, + profileLimit: 0, + } satisfies UserContext; + return true; } - if (token !== expectedToken) { - throw new UnauthorizedException("Invalid sync token"); + // Try JWT verification (cloud mode) + if (this.jwtPublicKey) { + try { + const decoded = jwt.verify(token, this.jwtPublicKey, { + algorithms: ["RS256"], + }) as jwt.JwtPayload; + + (request as any).user = { + mode: "cloud", + prefix: decoded.prefix || `users/${decoded.sub}/`, + teamPrefix: decoded.teamPrefix || null, + profileLimit: decoded.profileLimit || 0, + } satisfies UserContext; + return true; + } catch { + // JWT verification failed — fall through to error + } } - return true; + // If SYNC_TOKEN is configured but didn't match, or JWT failed + if (!expectedToken && !this.jwtPublicKey) { + throw new UnauthorizedException( + "No auth method configured on server (set SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY)", + ); + } + + throw new UnauthorizedException("Invalid sync token or JWT"); } } diff --git a/donut-sync/src/auth/user-context.interface.ts b/donut-sync/src/auth/user-context.interface.ts new file mode 100644 index 0000000..56015b2 --- /dev/null +++ b/donut-sync/src/auth/user-context.interface.ts @@ -0,0 +1,6 @@ +export interface UserContext { + mode: "self-hosted" | "cloud"; + prefix: string; // '' for self-hosted, 'users/{id}/' for cloud + teamPrefix: string | null; // 'teams/{id}/' or null + profileLimit: number; // 0 for unlimited (self-hosted) +} diff --git a/donut-sync/src/sync/sync.controller.ts b/donut-sync/src/sync/sync.controller.ts index 0483efc..76df58c 100644 --- a/donut-sync/src/sync/sync.controller.ts +++ b/donut-sync/src/sync/sync.controller.ts @@ -5,11 +5,14 @@ import { HttpCode, type MessageEvent, Post, + Req, Sse, UseGuards, } from "@nestjs/common"; +import type { Request } from "express"; import { map, type Observable } from "rxjs"; import { AuthGuard } from "../auth/auth.guard.js"; +import type { UserContext } from "../auth/user-context.interface.js"; import type { DeletePrefixRequestDto, DeletePrefixResponseDto, @@ -35,68 +38,86 @@ import { SyncService } from "./sync.service.js"; export class SyncController { constructor(private readonly syncService: SyncService) {} + private getUserContext(req: Request): UserContext { + return (req as any).user as UserContext; + } + @Post("stat") @HttpCode(200) - async stat(@Body() dto: StatRequestDto): Promise { - return this.syncService.stat(dto); + async stat( + @Body() dto: StatRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.stat(dto, this.getUserContext(req)); } @Post("presign-upload") @HttpCode(200) async presignUpload( @Body() dto: PresignUploadRequestDto, + @Req() req: Request, ): Promise { - return this.syncService.presignUpload(dto); + return this.syncService.presignUpload(dto, this.getUserContext(req)); } @Post("presign-download") @HttpCode(200) async presignDownload( @Body() dto: PresignDownloadRequestDto, + @Req() req: Request, ): Promise { - return this.syncService.presignDownload(dto); + return this.syncService.presignDownload(dto, this.getUserContext(req)); } @Post("delete") @HttpCode(200) - async delete(@Body() dto: DeleteRequestDto): Promise { - return this.syncService.delete(dto); + async delete( + @Body() dto: DeleteRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.delete(dto, this.getUserContext(req)); } @Post("list") @HttpCode(200) - async list(@Body() dto: ListRequestDto): Promise { - return this.syncService.list(dto); + async list( + @Body() dto: ListRequestDto, + @Req() req: Request, + ): Promise { + return this.syncService.list(dto, this.getUserContext(req)); } @Post("presign-upload-batch") @HttpCode(200) async presignUploadBatch( @Body() dto: PresignUploadBatchRequestDto, + @Req() req: Request, ): Promise { - return this.syncService.presignUploadBatch(dto); + return this.syncService.presignUploadBatch(dto, this.getUserContext(req)); } @Post("presign-download-batch") @HttpCode(200) async presignDownloadBatch( @Body() dto: PresignDownloadBatchRequestDto, + @Req() req: Request, ): Promise { - return this.syncService.presignDownloadBatch(dto); + return this.syncService.presignDownloadBatch(dto, this.getUserContext(req)); } @Post("delete-prefix") @HttpCode(200) async deletePrefix( @Body() dto: DeletePrefixRequestDto, + @Req() req: Request, ): Promise { - return this.syncService.deletePrefix(dto); + return this.syncService.deletePrefix(dto, this.getUserContext(req)); } @Get("subscribe") @Sse() - subscribe(): Observable { - return this.syncService.subscribe(2000).pipe( + subscribe(@Req() req: Request): Observable { + return this.syncService.subscribe(this.getUserContext(req), 2000).pipe( map((event) => ({ data: event, })), diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index e64731b..a473c7f 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -11,10 +11,16 @@ import { S3Client, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { Injectable, type OnModuleInit } from "@nestjs/common"; +import { + ForbiddenException, + Injectable, + Logger, + type OnModuleInit, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { interval, merge, type Observable, of, Subject } from "rxjs"; import { catchError, filter, map, startWith, switchMap } from "rxjs/operators"; +import type { UserContext } from "../auth/user-context.interface.js"; import type { DeletePrefixRequestDto, DeletePrefixResponseDto, @@ -37,11 +43,13 @@ import type { @Injectable() export class SyncService implements OnModuleInit { + private readonly logger = new Logger(SyncService.name); private s3Client: S3Client; private bucket: string; - private lastKnownState: Map = new Map(); private changeSubject = new Subject(); private s3Ready = false; + private backendInternalUrl: string | undefined; + private backendInternalKey: string | undefined; constructor(private configService: ConfigService) { const endpoint = @@ -65,6 +73,13 @@ export class SyncService implements OnModuleInit { }, forcePathStyle, }); + + this.backendInternalUrl = this.configService.get( + "BACKEND_INTERNAL_URL", + ); + this.backendInternalKey = this.configService.get( + "BACKEND_INTERNAL_KEY", + ); } async onModuleInit() { @@ -124,12 +139,37 @@ export class SyncService implements OnModuleInit { } } - async stat(dto: StatRequestDto): Promise { + /** + * Scope a key to the user's prefix for cloud mode. + * Self-hosted mode passes through unchanged. + */ + private scopeKey(ctx: UserContext, key: string): string { + if (ctx.mode === "self-hosted") return key; + return `${ctx.prefix}${key}`; + } + + /** + * Validate that a key is accessible by the user. + * For cloud mode, key must start with user's prefix or team prefix. + */ + private validateKeyAccess(ctx: UserContext, key: string): void { + if (ctx.mode === "self-hosted") return; + + if (key.startsWith(ctx.prefix)) return; + if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return; + + throw new ForbiddenException("Access denied to this key"); + } + + async stat(dto: StatRequestDto, ctx: UserContext): Promise { + const key = this.scopeKey(ctx, dto.key); + this.validateKeyAccess(ctx, key); + try { const response = await this.s3Client.send( new HeadObjectCommand({ Bucket: this.bucket, - Key: dto.key, + Key: key, }), ); @@ -153,18 +193,32 @@ export class SyncService implements OnModuleInit { async presignUpload( dto: PresignUploadRequestDto, + ctx: UserContext, ): Promise { + const key = this.scopeKey(ctx, dto.key); + this.validateKeyAccess(ctx, key); + + // Check profile limit for cloud users + if (ctx.mode === "cloud" && ctx.profileLimit > 0) { + await this.checkProfileLimit(ctx); + } + const expiresIn = dto.expiresIn || 3600; const expiresAt = new Date(Date.now() + expiresIn * 1000); const command = new PutCmd({ Bucket: this.bucket, - Key: dto.key, + Key: key, ContentType: dto.contentType || "application/octet-stream", }); const url = await getSignedUrl(this.s3Client, command, { expiresIn }); + // Report profile usage after upload presign if key is under profiles/ + if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) { + this.reportProfileUsageAsync(ctx); + } + return { url, expiresAt: expiresAt.toISOString(), @@ -173,13 +227,17 @@ export class SyncService implements OnModuleInit { async presignDownload( dto: PresignDownloadRequestDto, + ctx: UserContext, ): Promise { + const key = this.scopeKey(ctx, dto.key); + this.validateKeyAccess(ctx, key); + const expiresIn = dto.expiresIn || 3600; const expiresAt = new Date(Date.now() + expiresIn * 1000); const command = new GetObjectCommand({ Bucket: this.bucket, - Key: dto.key, + Key: key, }); const url = await getSignedUrl(this.s3Client, command, { expiresIn }); @@ -190,7 +248,13 @@ export class SyncService implements OnModuleInit { }; } - async delete(dto: DeleteRequestDto): Promise { + async delete( + dto: DeleteRequestDto, + ctx: UserContext, + ): Promise { + const key = this.scopeKey(ctx, dto.key); + this.validateKeyAccess(ctx, key); + let deleted = false; let tombstoneCreated = false; @@ -198,7 +262,7 @@ export class SyncService implements OnModuleInit { await this.s3Client.send( new DeleteObjectCommand({ Bucket: this.bucket, - Key: dto.key, + Key: key, }), ); deleted = true; @@ -207,15 +271,16 @@ export class SyncService implements OnModuleInit { } if (dto.tombstoneKey) { + const scopedTombstoneKey = this.scopeKey(ctx, dto.tombstoneKey); const tombstoneData = JSON.stringify({ - id: dto.key, + id: key, deleted_at: dto.deletedAt || new Date().toISOString(), }); await this.s3Client.send( new PutObjectCommand({ Bucket: this.bucket, - Key: dto.tombstoneKey, + Key: scopedTombstoneKey, Body: tombstoneData, ContentType: "application/json", }), @@ -223,24 +288,39 @@ export class SyncService implements OnModuleInit { tombstoneCreated = true; } + // Report profile usage after delete if key is under profiles/ + if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) { + this.reportProfileUsageAsync(ctx); + } + return { deleted, tombstoneCreated }; } - async list(dto: ListRequestDto): Promise { + async list(dto: ListRequestDto, ctx?: UserContext): Promise { + const prefix = ctx ? this.scopeKey(ctx, dto.prefix) : dto.prefix; + const response = await this.s3Client.send( new ListObjectsV2Command({ Bucket: this.bucket, - Prefix: dto.prefix, + Prefix: prefix, MaxKeys: dto.maxKeys || 1000, ContinuationToken: dto.continuationToken, }), ); - const objects = (response.Contents || []).map((obj) => ({ - key: obj.Key || "", - lastModified: obj.LastModified?.toISOString() || "", - size: obj.Size || 0, - })); + const userPrefix = ctx?.prefix || ""; + const objects = (response.Contents || []).map((obj) => { + // Strip user prefix from returned keys so client sees relative keys + let key = obj.Key || ""; + if (userPrefix && key.startsWith(userPrefix)) { + key = key.substring(userPrefix.length); + } + return { + key, + lastModified: obj.LastModified?.toISOString() || "", + size: obj.Size || 0, + }; + }); return { objects, @@ -251,15 +331,24 @@ export class SyncService implements OnModuleInit { async presignUploadBatch( dto: PresignUploadBatchRequestDto, + ctx: UserContext, ): Promise { + // Check profile limit for cloud users + if (ctx.mode === "cloud" && ctx.profileLimit > 0) { + await this.checkProfileLimit(ctx); + } + const expiresIn = dto.expiresIn || 3600; const expiresAt = new Date(Date.now() + expiresIn * 1000); const items = await Promise.all( dto.items.map(async (item) => { + const key = this.scopeKey(ctx, item.key); + this.validateKeyAccess(ctx, key); + const command = new PutCmd({ Bucket: this.bucket, - Key: item.key, + Key: key, ContentType: item.contentType || "application/octet-stream", }); @@ -273,17 +362,29 @@ export class SyncService implements OnModuleInit { }), ); + // Report profile usage if any key is under profiles/ + if ( + ctx.mode === "cloud" && + dto.items.some((item) => item.key.startsWith("profiles/")) + ) { + this.reportProfileUsageAsync(ctx); + } + return { items }; } async presignDownloadBatch( dto: PresignDownloadBatchRequestDto, + ctx: UserContext, ): Promise { const expiresIn = dto.expiresIn || 3600; const expiresAt = new Date(Date.now() + expiresIn * 1000); const items = await Promise.all( - dto.keys.map(async (key) => { + dto.keys.map(async (rawKey) => { + const key = this.scopeKey(ctx, rawKey); + this.validateKeyAccess(ctx, key); + const command = new GetObjectCommand({ Bucket: this.bucket, Key: key, @@ -292,7 +393,7 @@ export class SyncService implements OnModuleInit { const url = await getSignedUrl(this.s3Client, command, { expiresIn }); return { - key, + key: rawKey, url, expiresAt: expiresAt.toISOString(), }; @@ -304,7 +405,9 @@ export class SyncService implements OnModuleInit { async deletePrefix( dto: DeletePrefixRequestDto, + ctx: UserContext, ): Promise { + const prefix = this.scopeKey(ctx, dto.prefix); let deletedCount = 0; let tombstoneCreated = false; let continuationToken: string | undefined; @@ -314,7 +417,7 @@ export class SyncService implements OnModuleInit { const listResponse = await this.s3Client.send( new ListObjectsV2Command({ Bucket: this.bucket, - Prefix: dto.prefix, + Prefix: prefix, MaxKeys: 1000, ContinuationToken: continuationToken, }), @@ -346,6 +449,7 @@ export class SyncService implements OnModuleInit { // Create tombstone if requested if (dto.tombstoneKey && deletedCount > 0) { + const scopedTombstoneKey = this.scopeKey(ctx, dto.tombstoneKey); const tombstoneData = JSON.stringify({ prefix: dto.prefix, deleted_at: dto.deletedAt || new Date().toISOString(), @@ -355,7 +459,7 @@ export class SyncService implements OnModuleInit { await this.s3Client.send( new PutObjectCommand({ Bucket: this.bucket, - Key: dto.tombstoneKey, + Key: scopedTombstoneKey, Body: tombstoneData, ContentType: "application/json", }), @@ -363,11 +467,28 @@ export class SyncService implements OnModuleInit { tombstoneCreated = true; } + // Report profile usage after prefix delete if prefix is under profiles/ + if (ctx.mode === "cloud" && dto.prefix.startsWith("profiles/")) { + this.reportProfileUsageAsync(ctx); + } + return { deletedCount, tombstoneCreated }; } - subscribe(pollIntervalMs = 2000): Observable { - const prefixes = ["profiles/", "proxies/", "groups/", "tombstones/"]; + subscribe( + ctx: UserContext, + pollIntervalMs = 2000, + ): Observable { + const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"]; + + // Scope prefixes for cloud users; self-hosted gets root prefixes + const prefixes = + ctx.mode === "self-hosted" + ? basePrefixes + : basePrefixes.map((p) => `${ctx.prefix}${p}`); + + // Per-connection state (not shared across subscribers) + let lastKnownState = new Map(); const pollChanges$ = interval(pollIntervalMs).pipe( startWith(0), @@ -382,7 +503,7 @@ export class SyncService implements OnModuleInit { const stateKey = `${obj.key}:${obj.lastModified}`; currentState.set(obj.key, stateKey); - const previousStateKey = this.lastKnownState.get(obj.key); + const previousStateKey = lastKnownState.get(obj.key); if (previousStateKey !== stateKey) { events.push({ type: "change", @@ -397,7 +518,7 @@ export class SyncService implements OnModuleInit { } } - for (const [key] of this.lastKnownState) { + for (const [key] of lastKnownState) { if (!currentState.has(key)) { events.push({ type: "delete", @@ -406,7 +527,7 @@ export class SyncService implements OnModuleInit { } } - this.lastKnownState = currentState; + lastKnownState = currentState; return events; }), switchMap((events) => of(...events)), @@ -425,4 +546,97 @@ export class SyncService implements OnModuleInit { emitChange(event: SubscribeEventDto) { this.changeSubject.next(event); } + + /** + * Check if the user has reached their profile limit. + * Counts objects in the profiles/ prefix. + */ + private async checkProfileLimit(ctx: UserContext): Promise { + if (ctx.profileLimit <= 0) return; // 0 = unlimited + + const profilePrefix = `${ctx.prefix}profiles/`; + const result = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: profilePrefix, + MaxKeys: ctx.profileLimit + 1, + }), + ); + + const count = result.Contents?.length || 0; + if (count >= ctx.profileLimit) { + throw new ForbiddenException( + `Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`, + ); + } + } + + /** + * Count the number of profile objects for a user. + */ + private async countProfiles(ctx: UserContext): Promise { + const profilePrefix = `${ctx.prefix}profiles/`; + let count = 0; + let continuationToken: string | undefined; + + do { + const result = await this.s3Client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: profilePrefix, + MaxKeys: 1000, + ContinuationToken: continuationToken, + }), + ); + count += result.Contents?.length || 0; + continuationToken = result.NextContinuationToken; + } while (continuationToken); + + return count; + } + + /** + * Extract user ID from context prefix (e.g. "users/abc-123/" → "abc-123"). + */ + private extractUserId(ctx: UserContext): string | null { + const match = ctx.prefix.match(/^users\/([^/]+)\/$/); + return match ? match[1] : null; + } + + /** + * Fire-and-forget: count profiles and report to backend. + */ + private reportProfileUsageAsync(ctx: UserContext): void { + if (!this.backendInternalUrl || !this.backendInternalKey) return; + + const userId = this.extractUserId(ctx); + if (!userId) return; + + this.countProfiles(ctx) + .then((count) => this.reportProfileUsage(userId, count)) + .catch((err) => + this.logger.warn(`Failed to report profile usage: ${err.message}`), + ); + } + + private async reportProfileUsage( + userId: string, + count: number, + ): Promise { + const url = `${this.backendInternalUrl}/api/auth/internal/profile-usage`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-key": this.backendInternalKey!, + }, + body: JSON.stringify({ userId, count }), + }); + + if (!response.ok) { + this.logger.warn( + `Profile usage report failed: ${response.status} ${response.statusText}`, + ); + } + } } diff --git a/package.json b/package.json index 4e0d5f0..d3df647 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "cargo": "cd src-tauri && cargo", "unused-exports:js": "ts-unused-exports tsconfig.json", "check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands", - "copy-proxy-binary": "cd src-tauri && bash copy-proxy-binary.sh", + "copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs", "prebuild": "pnpm copy-proxy-binary", "pretauri:dev": "pnpm copy-proxy-binary", "precargo": "pnpm copy-proxy-binary" @@ -44,7 +44,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-table": "^8.21.3", - "@tauri-apps/api": "^2.10.1", + "@tauri-apps/api": "~2.9.0", "@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "~2.4.5", @@ -74,7 +74,7 @@ "devDependencies": { "@biomejs/biome": "2.3.15", "@tailwindcss/postcss": "^4.1.18", - "@tauri-apps/cli": "^2.10.0", + "@tauri-apps/cli": "~2.9.0", "@types/color": "^4.2.0", "@types/node": "^25.2.3", "@types/react": "^19.2.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdc38df..b9fab91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tauri-apps/api': - specifier: ^2.10.1 - version: 2.10.1 + specifier: ~2.9.0 + version: 2.9.1 '@tauri-apps/plugin-deep-link': specifier: ^2.4.7 version: 2.4.7 @@ -133,8 +133,8 @@ importers: specifier: ^4.1.18 version: 4.1.18 '@tauri-apps/cli': - specifier: ^2.10.0 - version: 2.10.0 + specifier: ~2.9.0 + version: 2.9.6 '@types/color': specifier: ^4.2.0 version: 4.2.0 @@ -189,6 +189,9 @@ importers: '@nestjs/platform-express': specifier: ^11.1.13 version: 11.1.13(@nestjs/common@11.1.13(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -211,6 +214,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^25.2.3 version: 25.2.3 @@ -2694,79 +2700,82 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} - '@tauri-apps/cli-darwin-arm64@2.10.0': - resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + '@tauri-apps/api@2.9.1': + resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} + + '@tauri-apps/cli-darwin-arm64@2.9.6': + resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.0': - resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + '@tauri-apps/cli-darwin-x64@2.9.6': + resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': - resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': + resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': - resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + '@tauri-apps/cli-linux-arm64-gnu@2.9.6': + resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.10.0': - resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + '@tauri-apps/cli-linux-arm64-musl@2.9.6': + resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': - resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': + resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.10.0': - resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + '@tauri-apps/cli-linux-x64-gnu@2.9.6': + resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.10.0': - resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + '@tauri-apps/cli-linux-x64-musl@2.9.6': + resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': - resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + '@tauri-apps/cli-win32-arm64-msvc@2.9.6': + resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': - resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + '@tauri-apps/cli-win32-ia32-msvc@2.9.6': + resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.0': - resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + '@tauri-apps/cli-win32-x64-msvc@2.9.6': + resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.0': - resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + '@tauri-apps/cli@2.9.6': + resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==} engines: {node: '>= 10'} hasBin: true @@ -2903,9 +2912,15 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -3299,6 +3314,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3646,6 +3664,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -4264,6 +4285,16 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4366,9 +4397,30 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -8370,52 +8422,54 @@ snapshots: '@tauri-apps/api@2.10.1': {} - '@tauri-apps/cli-darwin-arm64@2.10.0': + '@tauri-apps/api@2.9.1': {} + + '@tauri-apps/cli-darwin-arm64@2.9.6': optional: true - '@tauri-apps/cli-darwin-x64@2.10.0': + '@tauri-apps/cli-darwin-x64@2.9.6': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + '@tauri-apps/cli-linux-arm64-gnu@2.9.6': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.0': + '@tauri-apps/cli-linux-arm64-musl@2.9.6': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.0': + '@tauri-apps/cli-linux-x64-gnu@2.9.6': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.0': + '@tauri-apps/cli-linux-x64-musl@2.9.6': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + '@tauri-apps/cli-win32-arm64-msvc@2.9.6': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + '@tauri-apps/cli-win32-ia32-msvc@2.9.6': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.0': + '@tauri-apps/cli-win32-x64-msvc@2.9.6': optional: true - '@tauri-apps/cli@2.10.0': + '@tauri-apps/cli@2.9.6': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.0 - '@tauri-apps/cli-darwin-x64': 2.10.0 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 - '@tauri-apps/cli-linux-arm64-musl': 2.10.0 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-gnu': 2.10.0 - '@tauri-apps/cli-linux-x64-musl': 2.10.0 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 - '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/cli-darwin-arm64': 2.9.6 + '@tauri-apps/cli-darwin-x64': 2.9.6 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6 + '@tauri-apps/cli-linux-arm64-gnu': 2.9.6 + '@tauri-apps/cli-linux-arm64-musl': 2.9.6 + '@tauri-apps/cli-linux-riscv64-gnu': 2.9.6 + '@tauri-apps/cli-linux-x64-gnu': 2.9.6 + '@tauri-apps/cli-linux-x64-musl': 2.9.6 + '@tauri-apps/cli-win32-arm64-msvc': 2.9.6 + '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 + '@tauri-apps/cli-win32-x64-msvc': 2.9.6 '@tauri-apps/plugin-deep-link@2.4.7': dependencies: @@ -8423,19 +8477,19 @@ snapshots: '@tauri-apps/plugin-dialog@2.6.0': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.9.1 '@tauri-apps/plugin-fs@2.4.5': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.9.1 '@tauri-apps/plugin-log@2.8.0': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.9.1 '@tauri-apps/plugin-opener@2.5.3': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.9.1 '@tokenizer/inflate@0.4.1': dependencies: @@ -8573,8 +8627,15 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.2.3 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -8994,6 +9055,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -9278,6 +9341,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.286: {} @@ -10105,6 +10172,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + leven@3.1.0: {} lightningcss-android-arm64@1.30.2: @@ -10185,8 +10276,22 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} + lodash.once@4.1.1: {} + lodash@4.17.23: {} log-symbols@4.1.0: @@ -10821,7 +10926,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -11034,7 +11139,7 @@ snapshots: tauri-plugin-macos-permissions-api@2.3.0: dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.9.1 terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: diff --git a/scripts/sync-test-harness.mjs b/scripts/sync-test-harness.mjs index 43a57f2..4bdb834 100755 --- a/scripts/sync-test-harness.mjs +++ b/scripts/sync-test-harness.mjs @@ -79,13 +79,16 @@ function getMinioUrl() { return "https://dl.min.io/server/minio/release/linux-arm64/minio"; } return "https://dl.min.io/server/minio/release/linux-amd64/minio"; + } else if (platform === "win32") { + return "https://dl.min.io/server/minio/release/windows-amd64/minio.exe"; } throw new Error(`Unsupported platform: ${platform}-${arch}`); } async function ensureMinioBinary() { - const minioBin = path.join(CACHE_DIR, "minio"); + const isWindows = os.platform() === "win32"; + const minioBin = path.join(CACHE_DIR, isWindows ? "minio.exe" : "minio"); if (existsSync(minioBin)) { log("MinIO binary already cached"); @@ -97,7 +100,9 @@ async function ensureMinioBinary() { const url = getMinioUrl(); await downloadFile(url, minioBin); - chmodSync(minioBin, 0o755); + if (!isWindows) { + chmodSync(minioBin, 0o755); + } log("MinIO binary downloaded"); return minioBin; @@ -247,7 +252,16 @@ function cleanup() { for (const proc of processes) { try { - proc.kill("SIGTERM"); + if (os.platform() === "win32") { + // On Windows, SIGTERM is not supported; use taskkill for reliable cleanup + try { + execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: "ignore" }); + } catch { + // Process may already be dead + } + } else { + proc.kill("SIGTERM"); + } } catch { // Already dead } diff --git a/src-tauri/app.manifest b/src-tauri/app.manifest new file mode 100644 index 0000000..a7b4693 --- /dev/null +++ b/src-tauri/app.manifest @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/src-tauri/app.rc b/src-tauri/app.rc new file mode 100644 index 0000000..aaa5324 --- /dev/null +++ b/src-tauri/app.rc @@ -0,0 +1,2 @@ +#include +1 RT_MANIFEST "app.manifest" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f2df4f6..a53ddb6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -56,9 +56,23 @@ fn main() { // Only run tauri_build if all external binaries exist // This allows building donut-proxy sidecar without the other binaries present if external_binaries_exist() { - tauri_build::build() + tauri_build::build(); + + // tauri_build embeds the manifest for bin targets only (cargo:rustc-link-arg-bins). + // Test binaries (including `cargo test --lib`) also need the comctl32 v6 manifest + // or they crash with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139). We embed the + // manifest for all targets, then suppress the duplicate for bins with /MANIFEST:NO + // (tauri_build's resource-embedded manifest still takes effect for bins). + #[cfg(target_os = "windows")] + { + embed_windows_manifest(); + println!("cargo:rustc-link-arg-bins=/MANIFEST:NO"); + } } else { println!("cargo:warning=Skipping tauri_build: external binaries not found. This is expected when building sidecar binaries."); + + #[cfg(target_os = "windows")] + embed_windows_manifest(); } } @@ -117,6 +131,25 @@ fn ensure_dist_folder_exists() { println!("cargo:rerun-if-changed=../dist"); } +#[cfg(target_os = "windows")] +fn embed_windows_manifest() { + use std::path::PathBuf; + + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_path = PathBuf::from(&manifest_dir).join("app.manifest"); + + if !manifest_path.exists() { + println!("cargo:warning=app.manifest not found, skipping manifest embedding"); + return; + } + + // Use the path directly (avoid canonicalize which adds \\?\ prefix that mt.exe rejects) + let manifest_str = manifest_path.to_str().unwrap().replace('/', "\\"); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{manifest_str}"); + println!("cargo:rerun-if-changed=app.manifest"); +} + fn generate_tray_icons() { use resvg::tiny_skia::{Pixmap, Transform}; use resvg::usvg::{Options, Tree}; @@ -168,4 +201,21 @@ fn generate_tray_icons() { .save_png(&output_path) .expect("Failed to save tray icon PNG"); } + + // Generate a full-color icon for Windows tray (no template conversion) + { + let size = 44u32; + let mut pixmap = Pixmap::new(size, size).expect("Failed to create pixmap"); + + let svg_size = tree.size(); + let scale = size as f32 / svg_size.width().max(svg_size.height()); + let transform = Transform::from_scale(scale, scale); + + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + let output_path = icons_dir.join("tray-icon-win-44.png"); + pixmap + .save_png(&output_path) + .expect("Failed to save Windows tray icon PNG"); + } } diff --git a/src-tauri/copy-proxy-binary.mjs b/src-tauri/copy-proxy-binary.mjs new file mode 100644 index 0000000..2b8428a --- /dev/null +++ b/src-tauri/copy-proxy-binary.mjs @@ -0,0 +1,80 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url)); +const PROFILE = process.env.PROFILE || "debug"; + +function getTarget() { + if (process.env.TARGET) return process.env.TARGET; + try { + const output = execSync("rustc -vV", { encoding: "utf-8" }); + const match = output.match(/host:\s*(.+)/); + if (match) return match[1].trim(); + } catch {} + return "unknown"; +} + +function getHostTarget() { + try { + const output = execSync("rustc -vV", { encoding: "utf-8" }); + const match = output.match(/host:\s*(.+)/); + if (match) return match[1].trim(); + } catch {} + return "unknown"; +} + +const TARGET = getTarget(); +const HOST_TARGET = getHostTarget(); +const isWindows = TARGET.includes("windows"); + +// Determine source directory +let srcDir; +if (TARGET === HOST_TARGET || TARGET === "unknown") { + srcDir = join(MANIFEST_DIR, "target", PROFILE === "release" ? "release" : "debug"); +} else { + srcDir = join(MANIFEST_DIR, "target", TARGET, PROFILE === "release" ? "release" : "debug"); +} + +const destDir = join(MANIFEST_DIR, "binaries"); +mkdirSync(destDir, { recursive: true }); + +function copyBinary(baseName) { + const binName = isWindows ? `${baseName}.exe` : baseName; + const source = join(srcDir, binName); + + let destName = `${baseName}-${TARGET}`; + if (isWindows) destName += ".exe"; + const dest = join(destDir, destName); + + if (existsSync(source)) { + copyFileSync(source, dest); + console.log(`Copied ${binName} to ${dest}`); + } else { + console.log(`Warning: Binary not found at ${source}`); + console.log(`Building ${baseName} binary...`); + + const buildArgs = ["build", "--bin", baseName]; + if (PROFILE === "release") buildArgs.push("--release"); + if (TARGET !== "unknown" && TARGET !== HOST_TARGET) { + buildArgs.push("--target", TARGET); + } + + execSync(`cargo ${buildArgs.join(" ")}`, { + cwd: MANIFEST_DIR, + stdio: "inherit", + }); + + if (existsSync(source)) { + copyFileSync(source, dest); + console.log(`Built and copied ${binName} to ${dest}`); + } else { + console.error(`Error: Failed to build ${baseName} binary`); + process.exit(1); + } + } +} + +copyBinary("donut-proxy"); +copyBinary("donut-daemon"); diff --git a/src-tauri/copy-proxy-binary.sh b/src-tauri/copy-proxy-binary.sh index a23f49f..d0d3f25 100755 --- a/src-tauri/copy-proxy-binary.sh +++ b/src-tauri/copy-proxy-binary.sh @@ -1,6 +1,32 @@ #!/bin/bash set -e +# Ensure cargo/rustc are on PATH (pnpm's bash on Windows may not inherit it) +if ! command -v cargo &>/dev/null; then + # Try standard cargo locations + for cargo_dir in \ + "$HOME/.cargo/bin" \ + "/c/Users/$USER/.cargo/bin" \ + "/mnt/c/Users/$USER/.cargo/bin"; do + if [[ -d "$cargo_dir" ]] && [[ -e "$cargo_dir/cargo" || -e "$cargo_dir/cargo.exe" ]]; then + export PATH="$cargo_dir:$PATH" + break + fi + done + # Try USERPROFILE (Windows env var with backslashes) + if ! command -v cargo &>/dev/null && [[ -n "$USERPROFILE" ]]; then + CARGO_DIR="$(cd "$USERPROFILE/.cargo/bin" 2>/dev/null && pwd)" + if [[ -n "$CARGO_DIR" ]]; then + export PATH="$CARGO_DIR:$PATH" + fi + fi + if ! command -v cargo &>/dev/null; then + echo "Error: cargo not found. Please ensure Rust is installed and cargo is on your PATH." + echo " Install Rust: https://rustup.rs" + exit 1 + fi +fi + # Get the target triple from environment or use default TARGET="${TARGET:-$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "unknown")}" MANIFEST_DIR="$(dirname "$0")" diff --git a/src-tauri/icons/tray-icon-win-44.png b/src-tauri/icons/tray-icon-win-44.png new file mode 100644 index 0000000..4dc9f0b Binary files /dev/null and b/src-tauri/icons/tray-icon-win-44.png differ diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs index 1e20c15..dc925b4 100644 --- a/src-tauri/src/bin/donut_daemon.rs +++ b/src-tauri/src/bin/donut_daemon.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use tao::event::{Event, StartCause}; use tao::event_loop::{ControlFlow, EventLoopBuilder}; use tokio::runtime::Runtime; -use tray_icon::TrayIcon; +use tray_icon::{MouseButton, TrayIcon, TrayIconEvent}; use donutbrowser_lib::daemon::{autostart, services, tray}; @@ -162,10 +162,7 @@ fn run_daemon() { } // Prepare tray menu and icon (but don't create the tray icon yet) - // Show "Starting..." state initially let tray_menu = tray::TrayMenu::new(); - tray_menu.update_api_status(None); - tray_menu.update_mcp_status(false); let icon = tray::load_icon(); let menu_channel = MenuEvent::receiver(); @@ -208,8 +205,6 @@ fn run_daemon() { mcp_running, } => { log::info!("[daemon] Services started successfully"); - tray_menu.update_api_status(api_port); - tray_menu.update_mcp_status(mcp_running); // Update state file let mut state = read_state(); @@ -221,16 +216,13 @@ fn run_daemon() { } ServiceStatus::Failed(e) => { log::error!("Failed to start services: {}", e); - // Keep tray icon running, show error state - tray_menu.update_api_status(None); - tray_menu.update_mcp_status(false); } } } // Process menu events while let Ok(event) = menu_channel.try_recv() { - if event.id == tray_menu.open_item.id() || event.id == tray_menu.preferences_item.id() { + if event.id == tray_menu.open_item.id() { tray::open_gui(); } else if event.id == tray_menu.quit_item.id() { log::info!("[daemon] Quit requested"); @@ -238,6 +230,17 @@ fn run_daemon() { } } + // Handle tray icon click (left-click opens the app) + while let Ok(event) = TrayIconEvent::receiver().try_recv() { + if let TrayIconEvent::Click { + button: MouseButton::Left, + .. + } = event + { + tray::open_gui(); + } + } + // Use swap to only run cleanup once if SHOULD_QUIT.swap(false, Ordering::SeqCst) { // Cleanup diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 3980ebb..ee0399e 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -291,19 +291,31 @@ async fn main() { log::error!("Proxy worker starting, looking for config id: {}", id); log::error!("Process PID: {}", std::process::id()); - let config = match get_proxy_config(id) { - Some(config) => { - log::error!( - "Found config: id={}, port={:?}, upstream={}", - config.id, - config.local_port, - config.upstream_url - ); - config - } - None => { - log::error!("Proxy configuration {} not found", id); - process::exit(1); + // Retry config loading to handle file system race condition on Windows + // where the config file may not be immediately visible after being written + let config = { + let mut attempts = 0; + loop { + if let Some(config) = get_proxy_config(id) { + log::error!( + "Found config: id={}, port={:?}, upstream={}", + config.id, + config.local_port, + config.upstream_url + ); + break config; + } + attempts += 1; + if attempts >= 10 { + log::error!( + "Proxy configuration {} not found after {} attempts", + id, + attempts + ); + process::exit(1); + } + log::error!("Config {} not found yet, retrying ({}/10)...", id, attempts); + std::thread::sleep(std::time::Duration::from_millis(50)); } }; diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index cd5c6f8..6919af2 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -546,7 +546,11 @@ mod windows { for entry in entries.flatten() { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "exe") { - let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); if name.starts_with("firefox") || name.starts_with("zen") || name.starts_with("camoufox") @@ -609,7 +613,11 @@ mod windows { for entry in entries.flatten() { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "exe") { - let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); if name.contains("chromium") || name.contains("brave") || name.contains("chrome") @@ -644,7 +652,11 @@ mod windows { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "exe") { - let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); if name.starts_with("firefox") || name.starts_with("zen") || name.starts_with("camoufox") @@ -705,7 +717,11 @@ mod windows { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "exe") { - let name = path.file_stem().unwrap_or_default().to_string_lossy(); + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); if name.contains("chromium") || name.contains("brave") || name.contains("chrome") diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs new file mode 100644 index 0000000..69c63f2 --- /dev/null +++ b/src-tauri/src/cloud_auth.rs @@ -0,0 +1,704 @@ +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, +}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use chrono::Utc; +use lazy_static::lazy_static; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::settings_manager::SettingsManager; +use crate::sync; + +pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com"; +pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudUser { + pub id: String, + pub email: String, + pub plan: String, + #[serde(rename = "planPeriod")] + pub plan_period: String, + #[serde(rename = "subscriptionStatus")] + pub subscription_status: String, + #[serde(rename = "profileLimit")] + pub profile_limit: i64, + #[serde(rename = "cloudProfilesUsed")] + pub cloud_profiles_used: i64, + #[serde(rename = "proxyBandwidthLimitMb")] + pub proxy_bandwidth_limit_mb: i64, + #[serde(rename = "proxyBandwidthUsedMb")] + pub proxy_bandwidth_used_mb: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudAuthState { + pub user: CloudUser, + pub logged_in_at: String, +} + +#[derive(Debug, Deserialize)] +struct OtpRequestResponse { + message: String, +} + +#[derive(Debug, Deserialize)] +struct OtpVerifyResponse { + #[serde(rename = "accessToken")] + access_token: String, + #[serde(rename = "refreshToken")] + refresh_token: String, + user: CloudUser, +} + +#[derive(Debug, Deserialize)] +struct RefreshTokenResponse { + #[serde(rename = "accessToken")] + access_token: String, + #[serde(rename = "refreshToken")] + refresh_token: String, +} + +#[derive(Debug, Deserialize)] +struct SyncTokenResponse { + #[serde(rename = "syncToken")] + sync_token: String, +} + +pub struct CloudAuthManager { + client: Client, + state: Mutex>, +} + +lazy_static! { + pub static ref CLOUD_AUTH: CloudAuthManager = CloudAuthManager::new(); +} + +impl CloudAuthManager { + fn new() -> Self { + let state = Self::load_auth_state_from_disk(); + Self { + client: Client::new(), + state: Mutex::new(state), + } + } + + // --- Settings directory (reuse SettingsManager path) --- + + fn get_settings_dir() -> PathBuf { + SettingsManager::instance().get_settings_dir() + } + + fn get_vault_password() -> String { + env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() + } + + // --- Encrypted file storage (same pattern as settings_manager.rs) --- + + fn encrypt_and_store(file_path: &PathBuf, header: &[u8; 5], data: &str) -> Result<(), String> { + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; + } + + let vault_password = Self::get_vault_password(); + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length".to_string())?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, data.as_bytes()) + .map_err(|e| format!("Encryption failed: {e}"))?; + + let mut file_data = Vec::new(); + file_data.extend_from_slice(header); + file_data.push(2u8); + let salt_str = salt.as_str(); + file_data.push(salt_str.len() as u8); + file_data.extend_from_slice(salt_str.as_bytes()); + file_data.extend_from_slice(&nonce); + file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); + file_data.extend_from_slice(&ciphertext); + + fs::write(file_path, file_data).map_err(|e| format!("Failed to write file: {e}"))?; + Ok(()) + } + + fn decrypt_from_file(file_path: &PathBuf, header: &[u8; 5]) -> Result, String> { + if !file_path.exists() { + return Ok(None); + } + + let file_data = fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?; + + if file_data.len() < 6 || &file_data[0..5] != header { + return Ok(None); + } + + let version = file_data[5]; + if version != 2 { + return Ok(None); + } + + let mut offset = 6; + if offset >= file_data.len() { + return Ok(None); + } + let salt_len = file_data[offset] as usize; + offset += 1; + + if offset + salt_len > file_data.len() { + return Ok(None); + } + let salt_bytes = &file_data[offset..offset + salt_len]; + let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; + let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?; + offset += salt_len; + + if offset + 12 > file_data.len() { + return Ok(None); + } + let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] + .try_into() + .map_err(|_| "Invalid nonce length".to_string())?; + let nonce = Nonce::from(nonce_bytes); + offset += 12; + + if offset + 4 > file_data.len() { + return Ok(None); + } + let ciphertext_len = u32::from_le_bytes([ + file_data[offset], + file_data[offset + 1], + file_data[offset + 2], + file_data[offset + 3], + ]) as usize; + offset += 4; + + if offset + ciphertext_len > file_data.len() { + return Ok(None); + } + let ciphertext = &file_data[offset..offset + ciphertext_len]; + + let vault_password = Self::get_vault_password(); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length".to_string())?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + let plaintext = cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| "Decryption failed".to_string())?; + + match String::from_utf8(plaintext) { + Ok(token) => Ok(Some(token)), + Err(_) => Ok(None), + } + } + + // --- Token storage methods --- + + fn store_access_token(token: &str) -> Result<(), String> { + let path = Self::get_settings_dir().join("cloud_access_token.dat"); + Self::encrypt_and_store(&path, b"DBCAT", token) + } + + fn load_access_token() -> Result, String> { + let path = Self::get_settings_dir().join("cloud_access_token.dat"); + Self::decrypt_from_file(&path, b"DBCAT") + } + + fn store_refresh_token(token: &str) -> Result<(), String> { + let path = Self::get_settings_dir().join("cloud_refresh_token.dat"); + Self::encrypt_and_store(&path, b"DBCRT", token) + } + + fn load_refresh_token() -> Result, String> { + let path = Self::get_settings_dir().join("cloud_refresh_token.dat"); + Self::decrypt_from_file(&path, b"DBCRT") + } + + fn store_cloud_sync_token(token: &str) -> Result<(), String> { + let path = Self::get_settings_dir().join("cloud_sync_token.dat"); + Self::encrypt_and_store(&path, b"DBCST", token) + } + + fn load_cloud_sync_token() -> Result, String> { + let path = Self::get_settings_dir().join("cloud_sync_token.dat"); + Self::decrypt_from_file(&path, b"DBCST") + } + + fn store_auth_state(state: &CloudAuthState) -> Result<(), String> { + let path = Self::get_settings_dir().join("cloud_auth_state.json"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; + } + let json = + serde_json::to_string_pretty(state).map_err(|e| format!("Failed to serialize: {e}"))?; + fs::write(path, json).map_err(|e| format!("Failed to write auth state: {e}"))?; + Ok(()) + } + + fn load_auth_state_from_disk() -> Option { + let path = Self::get_settings_dir().join("cloud_auth_state.json"); + if !path.exists() { + return None; + } + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } + + fn delete_all_cloud_files() { + let dir = Self::get_settings_dir(); + let files = [ + "cloud_access_token.dat", + "cloud_refresh_token.dat", + "cloud_sync_token.dat", + "cloud_auth_state.json", + ]; + for f in &files { + let path = dir.join(f); + if path.exists() { + let _ = fs::remove_file(path); + } + } + } + + // --- JWT expiry check --- + + fn is_jwt_expiring_soon(token: &str) -> bool { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return true; + } + + use base64::{engine::general_purpose, Engine as _}; + let payload = match general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(bytes) => bytes, + Err(_) => { + // Try standard base64 with padding + match general_purpose::STANDARD.decode(parts[1]) { + Ok(bytes) => bytes, + Err(_) => return true, + } + } + }; + + let json: serde_json::Value = match serde_json::from_slice(&payload) { + Ok(v) => v, + Err(_) => return true, + }; + + let exp = match json.get("exp").and_then(|v| v.as_i64()) { + Some(exp) => exp, + None => return true, + }; + + let now = Utc::now().timestamp(); + exp - now < 120 + } + + // --- API methods --- + + pub async fn request_otp(&self, email: &str) -> Result { + let url = format!("{CLOUD_API_URL}/api/auth/otp/request"); + let response = self + .client + .post(&url) + .json(&serde_json::json!({ "email": email })) + .send() + .await + .map_err(|e| format!("Failed to request OTP: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("OTP request failed ({status}): {body}")); + } + + let result: OtpRequestResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + Ok(result.message) + } + + pub async fn verify_otp(&self, email: &str, code: &str) -> Result { + let url = format!("{CLOUD_API_URL}/api/auth/otp/verify"); + let response = self + .client + .post(&url) + .json(&serde_json::json!({ "email": email, "code": code })) + .send() + .await + .map_err(|e| format!("Failed to verify OTP: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("OTP verification failed ({status}): {body}")); + } + + let result: OtpVerifyResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + // Store tokens + Self::store_access_token(&result.access_token)?; + Self::store_refresh_token(&result.refresh_token)?; + + // Build and persist auth state + let auth_state = CloudAuthState { + user: result.user, + logged_in_at: Utc::now().to_rfc3339(), + }; + Self::store_auth_state(&auth_state)?; + + // Update in-memory state + let mut state = self.state.lock().await; + *state = Some(auth_state.clone()); + + Ok(auth_state) + } + + pub async fn refresh_access_token(&self) -> Result<(), String> { + let refresh_token = + Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?; + + let url = format!("{CLOUD_API_URL}/api/auth/token/refresh"); + let response = self + .client + .post(&url) + .json(&serde_json::json!({ "refreshToken": refresh_token })) + .send() + .await + .map_err(|e| format!("Failed to refresh token: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + // Refresh token expired — clear everything + self.clear_auth().await; + let _ = crate::events::emit_empty("cloud-auth-expired"); + return Err("Session expired. Please log in again.".to_string()); + } + let body = response.text().await.unwrap_or_default(); + return Err(format!("Token refresh failed ({status}): {body}")); + } + + let result: RefreshTokenResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + Self::store_access_token(&result.access_token)?; + Self::store_refresh_token(&result.refresh_token)?; + + Ok(()) + } + + pub async fn fetch_profile(&self) -> Result { + let user = self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/auth/me"); + let client = self.client.clone(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch profile: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Profile fetch failed ({status}): {body}")); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse profile: {e}")) + } + }) + .await?; + + // Update cached state + let mut state = self.state.lock().await; + if let Some(auth_state) = state.as_mut() { + auth_state.user = user.clone(); + let _ = Self::store_auth_state(auth_state); + } + + Ok(user) + } + + pub async fn get_or_refresh_sync_token(&self) -> Result, String> { + if !self.is_logged_in().await { + return Ok(None); + } + + // Check cached sync token + if let Ok(Some(token)) = Self::load_cloud_sync_token() { + if !Self::is_jwt_expiring_soon(&token) { + return Ok(Some(token)); + } + } + + // Fetch new sync token + let sync_token = self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/auth/sync-token"); + let client = self.client.clone(); + async move { + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to get sync token: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Sync token request failed ({status}): {body}")); + } + + let result: SyncTokenResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse sync token response: {e}"))?; + + Ok(result.sync_token) + } + }) + .await?; + + Self::store_cloud_sync_token(&sync_token)?; + Ok(Some(sync_token)) + } + + pub async fn logout(&self) -> Result<(), String> { + // Try to call the logout API (best-effort) + if let Ok(Some(access_token)) = Self::load_access_token() { + let refresh_token = Self::load_refresh_token().ok().flatten(); + let url = format!("{CLOUD_API_URL}/api/auth/logout"); + let mut body = serde_json::json!({}); + if let Some(rt) = &refresh_token { + body = serde_json::json!({ "refreshToken": rt }); + } + let _ = self + .client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .json(&body) + .send() + .await; + } + + self.clear_auth().await; + Ok(()) + } + + pub async fn is_logged_in(&self) -> bool { + let state = self.state.lock().await; + state.is_some() + } + + pub async fn has_active_paid_subscription(&self) -> bool { + let state = self.state.lock().await; + match &*state { + Some(auth) => auth.user.plan != "free" && auth.user.subscription_status == "active", + None => false, + } + } + + pub async fn get_user(&self) -> Option { + let state = self.state.lock().await; + state.clone() + } + + async fn clear_auth(&self) { + let mut state = self.state.lock().await; + *state = None; + Self::delete_all_cloud_files(); + } + + /// API call with 401 retry: if first attempt gets 401, refresh access token and retry once. + async fn api_call_with_retry(&self, make_request: F) -> Result + where + F: Fn(String) -> Fut + Send, + Fut: std::future::Future> + Send, + { + let access_token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; + + match make_request(access_token).await { + Ok(result) => Ok(result), + Err(e) if e.contains("(401)") => { + // Try refreshing the access token + self.refresh_access_token().await?; + let new_token = + Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())?; + make_request(new_token).await + } + Err(e) => Err(e), + } + } + + /// Background loop that refreshes the sync token periodically + pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { + loop { + tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes + + if !CLOUD_AUTH.is_logged_in().await { + continue; + } + + match CLOUD_AUTH.get_or_refresh_sync_token().await { + Ok(Some(_)) => { + log::debug!("Cloud sync token refreshed successfully"); + } + Ok(None) => {} + Err(e) => { + log::warn!("Failed to refresh cloud sync token: {e}"); + } + } + + // Also refresh the access token if needed + if let Ok(Some(token)) = Self::load_access_token() { + if Self::is_jwt_expiring_soon(&token) { + if let Err(e) = CLOUD_AUTH.refresh_access_token().await { + log::warn!("Failed to refresh cloud access token: {e}"); + } + } + } + + // Refresh profile data periodically + if let Err(e) = CLOUD_AUTH.fetch_profile().await { + log::debug!("Failed to refresh cloud profile: {e}"); + } + + let _ = &app_handle; // keep app_handle alive + } + } +} + +// --- Tauri commands --- + +#[tauri::command] +pub async fn cloud_request_otp(email: String) -> Result { + CLOUD_AUTH.request_otp(&email).await +} + +#[tauri::command] +pub async fn cloud_verify_otp( + app_handle: tauri::AppHandle, + email: String, + code: String, +) -> Result { + let state = CLOUD_AUTH.verify_otp(&email, &code).await?; + + // Pre-fetch sync token so sync can start immediately + if CLOUD_AUTH.has_active_paid_subscription().await { + if let Err(e) = CLOUD_AUTH.get_or_refresh_sync_token().await { + log::warn!("Failed to pre-fetch sync token after login: {e}"); + } + } + + let _ = &app_handle; + Ok(state) +} + +#[tauri::command] +pub async fn cloud_get_user() -> Result, String> { + Ok(CLOUD_AUTH.get_user().await) +} + +#[tauri::command] +pub async fn cloud_refresh_profile() -> Result { + CLOUD_AUTH.fetch_profile().await +} + +#[tauri::command] +pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { + CLOUD_AUTH.logout().await?; + let _ = &app_handle; + Ok(()) +} + +#[tauri::command] +pub async fn cloud_has_active_subscription() -> Result { + Ok(CLOUD_AUTH.has_active_paid_subscription().await) +} + +#[tauri::command] +pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> { + // Stop existing scheduler + if let Some(scheduler) = sync::get_global_scheduler() { + scheduler.stop(); + } + + // Restart sync pipeline + let app_handle_sync = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let mut subscription_manager = sync::SubscriptionManager::new(); + let work_rx = subscription_manager.take_work_receiver(); + + if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await { + log::warn!("Failed to start sync subscription: {e}"); + return; + } + + if let Some(work_rx) = work_rx { + let scheduler = Arc::new(sync::SyncScheduler::new()); + sync::set_global_scheduler(scheduler.clone()); + + scheduler.sync_all_enabled_profiles(&app_handle_sync).await; + + match sync::SyncEngine::create_from_settings(&app_handle_sync).await { + Ok(engine) => { + if let Err(e) = engine + .check_for_missing_synced_profiles(&app_handle_sync) + .await + { + log::warn!("Failed to check for missing profiles: {}", e); + } + } + Err(e) => { + log::debug!("Sync not configured, skipping missing profile check: {}", e); + } + } + + scheduler + .clone() + .start(app_handle_sync.clone(), work_rx) + .await; + log::info!("Sync scheduler restarted"); + } + }); + + Ok(()) +} diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs index 952b2f3..2d3d5a8 100644 --- a/src-tauri/src/daemon/tray.rs +++ b/src-tauri/src/daemon/tray.rs @@ -1,4 +1,4 @@ -use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use muda::{Menu, MenuItem, PredefinedMenuItem}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; @@ -6,8 +6,11 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; static GUI_RUNNING: AtomicBool = AtomicBool::new(false); pub fn load_icon() -> Icon { - // Use the generated template icon (44x44 for retina, macOS standard menu bar size) - // This is the donut logo converted to template format (black with alpha) + // On Windows, use the full-color icon so it renders well on dark taskbars. + // On macOS/Linux, use the template icon (black with alpha) for system light/dark handling. + #[cfg(target_os = "windows")] + let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png"); + #[cfg(not(target_os = "windows"))] let icon_bytes = include_bytes!("../../icons/tray-icon-44.png"); let image = image::load_from_memory(icon_bytes) @@ -23,10 +26,6 @@ pub fn load_icon() -> Icon { pub struct TrayMenu { pub menu: Menu, pub open_item: MenuItem, - pub running_profiles_submenu: Submenu, - pub api_status_item: MenuItem, - pub mcp_status_item: MenuItem, - pub preferences_item: MenuItem, pub quit_item: MenuItem, } @@ -41,53 +40,19 @@ impl TrayMenu { let menu = Menu::new(); let open_item = MenuItem::new("Open Donut Browser", true, None); - let running_profiles_submenu = Submenu::new("Running Profiles", true); - let no_profiles_item = MenuItem::new("No running profiles", false, None); - running_profiles_submenu.append(&no_profiles_item).unwrap(); - - let separator1 = PredefinedMenuItem::separator(); - let api_status_item = MenuItem::new("API: Starting...", false, None); - let mcp_status_item = MenuItem::new("MCP: Starting...", false, None); - let separator2 = PredefinedMenuItem::separator(); - let preferences_item = MenuItem::new("Preferences...", true, None); + let separator = PredefinedMenuItem::separator(); let quit_item = MenuItem::new("Quit Donut Browser", true, None); menu.append(&open_item).unwrap(); - menu.append(&running_profiles_submenu).unwrap(); - menu.append(&separator1).unwrap(); - menu.append(&api_status_item).unwrap(); - menu.append(&mcp_status_item).unwrap(); - menu.append(&separator2).unwrap(); - menu.append(&preferences_item).unwrap(); + menu.append(&separator).unwrap(); menu.append(&quit_item).unwrap(); Self { menu, open_item, - running_profiles_submenu, - api_status_item, - mcp_status_item, - preferences_item, quit_item, } } - - pub fn update_api_status(&self, port: Option) { - let text = match port { - Some(p) => format!("API: Running on :{}", p), - None => "API: Stopped".to_string(), - }; - self.api_status_item.set_text(&text); - } - - pub fn update_mcp_status(&self, running: bool) { - let text = if running { - "MCP: Running" - } else { - "MCP: Stopped" - }; - self.mcp_status_item.set_text(text); - } } pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon { @@ -121,6 +86,17 @@ pub fn open_gui() { { use std::path::PathBuf; + // In dev mode, find the main exe next to the daemon binary + if let Ok(current_exe) = std::env::current_exe() { + if let Some(exe_dir) = current_exe.parent() { + let app_path = exe_dir.join("donutbrowser.exe"); + if app_path.exists() { + let _ = Command::new(app_path).spawn(); + return; + } + } + } + let paths = [ dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")), Some(PathBuf::from( diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index 5a64066..2a46018 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -1191,6 +1191,64 @@ mod tests { } } +#[tauri::command] +pub async fn ensure_active_browsers_downloaded( + app_handle: tauri::AppHandle, +) -> Result, String> { + let registry = DownloadedBrowsersRegistry::instance(); + let version_manager = crate::browser_version_manager::BrowserVersionManager::instance(); + let mut downloaded = Vec::new(); + + for browser in &["wayfern", "camoufox"] { + // Check if any version is already downloaded + let existing = registry.get_downloaded_versions(browser); + if !existing.is_empty() { + log::debug!( + "Skipping {browser}: already have {} version(s) downloaded", + existing.len() + ); + continue; + } + + // Get the latest release type for this browser + let release_types = match version_manager.get_browser_release_types(browser).await { + Ok(rt) => rt, + Err(e) => { + log::warn!("Failed to get release types for {browser}: {e}"); + continue; + } + }; + + // Use stable version (the only release type for these browsers) + let version = match release_types.stable { + Some(v) => v, + None => { + log::debug!("No stable version available for {browser} on this platform, skipping"); + continue; + } + }; + + log::info!("Auto-downloading {browser} {version} (no versions found locally)"); + match crate::downloader::download_browser( + app_handle.clone(), + browser.to_string(), + version.clone(), + ) + .await + { + Ok(_) => { + downloaded.push(format!("{browser} {version}")); + log::info!("Successfully auto-downloaded {browser} {version}"); + } + Err(e) => { + log::warn!("Failed to auto-download {browser} {version}: {e}"); + } + } + } + + Ok(downloaded) +} + #[tauri::command] pub fn get_downloaded_browser_versions(browser_str: String) -> Result, String> { let registry = DownloadedBrowsersRegistry::instance(); diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index 55b8a4a..b15bebc 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -870,6 +870,8 @@ impl Extractor { "chromium.exe", "zen.exe", "brave.exe", + "camoufox.exe", + "wayfern.exe", ]; // First try priority executable names @@ -938,6 +940,8 @@ impl Extractor { || file_name.contains("zen") || file_name.contains("brave") || file_name.contains("browser") + || file_name.contains("camoufox") + || file_name.contains("wayfern") { return Ok(path); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3f8744..af56c65 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ pub mod traffic_stats; mod wayfern_manager; mod wayfern_terms; // mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme +pub mod cloud_auth; mod commercial_license; mod cookie_manager; pub mod daemon; @@ -66,7 +67,8 @@ use browser_version_manager::{ }; use downloaded_browsers_registry::{ - check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions, + check_missing_binaries, ensure_active_browsers_downloaded, ensure_all_binaries_exist, + get_downloaded_browser_versions, }; use downloader::{cancel_download, download_browser}; @@ -654,6 +656,9 @@ pub fn run() { .focused(true) .visible(true); + #[cfg(target_os = "windows")] + let win_builder = win_builder.decorations(false); + #[allow(unused_variables)] let window = win_builder.build().unwrap(); @@ -1084,6 +1089,21 @@ pub fn run() { } }); + // Start cloud auth background refresh loop + let app_handle_cloud = app.handle().clone(); + tauri::async_runtime::spawn(async move { + // On startup, refresh access token + sync token if cloud auth is active + if cloud_auth::CLOUD_AUTH.is_logged_in().await { + if let Err(e) = cloud_auth::CLOUD_AUTH.refresh_access_token().await { + log::warn!("Failed to refresh cloud access token on startup: {e}"); + } + if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { + log::warn!("Failed to refresh cloud sync token on startup: {e}"); + } + } + cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -1135,6 +1155,7 @@ pub fn run() { check_missing_binaries, check_missing_geoip_database, ensure_all_binaries_exist, + ensure_active_browsers_downloaded, create_stored_proxy, get_stored_proxies, update_stored_proxy, @@ -1190,7 +1211,14 @@ pub fn run() { connect_vpn, disconnect_vpn, get_vpn_status, - list_active_vpn_connections + list_active_vpn_connections, + // Cloud auth commands + cloud_auth::cloud_request_otp, + cloud_auth::cloud_verify_otp, + cloud_auth::cloud_get_user, + cloud_auth::cloud_refresh_profile, + cloud_auth::cloud_logout, + cloud_auth::restart_sync_service ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -1340,6 +1368,8 @@ mod tests { // Remove trailing comma and whitespace let command = line.trim_end_matches(',').trim(); if !command.is_empty() { + // Strip module prefix (e.g., "cloud_auth::cloud_request_otp" -> "cloud_request_otp") + let command = command.rsplit("::").next().unwrap_or(command); commands.push(command.to_string()); } } diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 11e7e0e..8040422 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -1415,11 +1415,16 @@ mod tests { .parent() .unwrap() .to_path_buf(); + let proxy_binary_name = if cfg!(windows) { + "donut-proxy.exe" + } else { + "donut-proxy" + }; let proxy_binary = project_root .join("src-tauri") .join("target") .join("debug") - .join("donut-proxy"); + .join(proxy_binary_name); // Check if binary already exists if proxy_binary.exists() { diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 328f90d..6640184 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -60,7 +60,7 @@ pub async fn start_proxy_process_with_profile( cmd.stdout(Stdio::null()); // Always log to file for diagnostics (both debug and release builds) - let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id)); + let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id)); if let Ok(file) = std::fs::File::create(&log_path) { log::info!("Proxy worker stderr will be logged to: {:?}", log_path); cmd.stderr(Stdio::from(file)); @@ -105,13 +105,28 @@ pub async fn start_proxy_process_with_profile( #[cfg(windows)] { + use std::os::windows::io::AsRawHandle; use std::os::windows::process::CommandExt; use std::process::Command as StdCommand; - use windows::Win32::Foundation::CloseHandle; + use windows::Win32::Foundation::{CloseHandle, SetHandleInformation, HANDLE, HANDLE_FLAGS}; use windows::Win32::System::Threading::{ OpenProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, PROCESS_SET_INFORMATION, }; + // Mark current stdout/stderr as non-inheritable so the spawned worker process + // does not inherit pipe handles from our parent (prevents blocking when parent exits). + let stdout_handle = std::io::stdout().as_raw_handle(); + let stderr_handle = std::io::stderr().as_raw_handle(); + const HANDLE_FLAG_INHERIT: u32 = 0x00000001; + unsafe { + if !stdout_handle.is_null() { + let _ = SetHandleInformation(HANDLE(stdout_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0)); + } + if !stderr_handle.is_null() { + let _ = SetHandleInformation(HANDLE(stderr_handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0)); + } + } + let mut cmd = StdCommand::new(&exe); cmd.arg("proxy-worker"); cmd.arg("start"); @@ -120,11 +135,20 @@ pub async fn start_proxy_process_with_profile( cmd.stdin(Stdio::null()); cmd.stdout(Stdio::null()); - cmd.stderr(Stdio::null()); - // On Windows, use CREATE_NEW_PROCESS_GROUP flag for proper detachment + // Log to file for diagnostics (matching Unix behavior) + let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id)); + if let Ok(file) = std::fs::File::create(&log_path) { + log::info!("Proxy worker stderr will be logged to: {:?}", log_path); + cmd.stderr(Stdio::from(file)); + } else { + cmd.stderr(Stdio::null()); + } + + // On Windows, use DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP for proper detachment. + const DETACHED_PROCESS: u32 = 0x00000008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); let child = cmd.spawn()?; let pid = child.id(); diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index f923b7c..d7eb2e7 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -870,6 +870,19 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul #[tauri::command] pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result { + // Cloud auth takes priority over self-hosted settings + if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + let sync_token = crate::cloud_auth::CLOUD_AUTH + .get_or_refresh_sync_token() + .await + .map_err(|e| format!("Failed to get cloud sync token: {e}"))?; + return Ok(SyncSettings { + sync_server_url: Some(crate::cloud_auth::CLOUD_SYNC_URL.to_string()), + sync_token, + }); + } + + // Fall back to self-hosted settings let manager = SettingsManager::instance(); let mut sync_settings = manager .get_sync_settings() diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 51abe29..74fc505 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -24,6 +24,18 @@ impl SyncEngine { } pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result { + // Cloud auth takes priority + if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string(); + let token = crate::cloud_auth::CLOUD_AUTH + .get_or_refresh_sync_token() + .await + .map_err(|e| format!("Failed to get cloud sync token: {e}"))? + .ok_or_else(|| "Cloud sync token not available".to_string())?; + return Ok(Self::new(url, token)); + } + + // Fall back to self-hosted settings let manager = SettingsManager::instance(); let settings = manager .load_settings() diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 3b1189a..33af8cf 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -2,7 +2,6 @@ use super::engine::SyncEngine; use super::subscription::SyncWorkItem; use crate::events; use crate::profile::ProfileManager; -use once_cell::sync::OnceCell; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -11,14 +10,16 @@ use tokio::sync::mpsc; use tokio::sync::Mutex; use tokio::time::sleep; -static GLOBAL_SCHEDULER: OnceCell> = OnceCell::new(); +static GLOBAL_SCHEDULER: std::sync::Mutex>> = std::sync::Mutex::new(None); pub fn get_global_scheduler() -> Option> { - GLOBAL_SCHEDULER.get().cloned() + GLOBAL_SCHEDULER.lock().ok().and_then(|g| g.clone()) } pub fn set_global_scheduler(scheduler: Arc) { - let _ = GLOBAL_SCHEDULER.set(scheduler); + if let Ok(mut g) = GLOBAL_SCHEDULER.lock() { + *g = Some(scheduler); + } } #[derive(Debug, Clone)] diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index 654abe5..088c118 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -53,6 +53,20 @@ impl SyncSubscription { app_handle: &tauri::AppHandle, work_tx: mpsc::UnboundedSender, ) -> Result, String> { + // Cloud auth takes priority + if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string(); + let token = crate::cloud_auth::CLOUD_AUTH + .get_or_refresh_sync_token() + .await + .map_err(|e| format!("Failed to get cloud sync token: {e}"))?; + let Some(token) = token else { + return Ok(None); + }; + return Ok(Some(Self::new(url, token, work_tx))); + } + + // Fall back to self-hosted settings let manager = SettingsManager::instance(); let settings = manager .load_settings() diff --git a/src-tauri/src/version_updater.rs b/src-tauri/src/version_updater.rs index ac7a52d..ff943f7 100644 --- a/src-tauri/src/version_updater.rs +++ b/src-tauri/src/version_updater.rs @@ -265,6 +265,23 @@ impl VersionUpdater { app_handle: &tauri::AppHandle, ) -> Result, Box> { let supported_browsers = self.browser_version_manager.get_supported_browsers(); + + // Only fetch versions for active browsers (wayfern, camoufox) plus any + // deprecated browsers that still have existing profiles + let active_browsers = ["wayfern", "camoufox"]; + let browsers_with_profiles: std::collections::HashSet = + crate::profile::ProfileManager::instance() + .list_profiles() + .unwrap_or_default() + .iter() + .map(|p| p.browser.clone()) + .collect(); + + let supported_browsers: Vec = supported_browsers + .into_iter() + .filter(|b| active_browsers.contains(&b.as_str()) || browsers_with_profiles.contains(b)) + .collect(); + let total_browsers = supported_browsers.len(); let mut results = Vec::new(); let mut total_new_versions = 0; diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index 1b16c11..4469127 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -16,11 +16,16 @@ async fn setup_test() -> Result { + console.error("Failed to auto-download browsers:", err); + }); + } + return () => { clearInterval(updateInterval); if (cleanup) { @@ -766,7 +779,7 @@ export default function Home() { } }, [profiles]); - // Show warning for non-wayfern/camoufox profiles (support ending March 1, 2026) + // Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026) useEffect(() => { if (profiles.length === 0) return; @@ -783,7 +796,7 @@ export default function Home() { id: "browser-support-ending-warning", type: "error", title: "Browser support ending soon", - description: `Support for the following profiles will be removed on March 1, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`, + description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`, duration: 15000, action: { label: "Learn more", @@ -917,6 +930,7 @@ export default function Home() { }} onCreateProfile={handleCreateProfile} selectedGroupId={selectedGroupId} + crossOsUnlocked={crossOsUnlocked} /> Promise; isRunning?: boolean; + crossOsUnlocked?: boolean; } export function CamoufoxConfigDialog({ @@ -48,6 +49,7 @@ export function CamoufoxConfigDialog({ onSave, onSaveWayfern, isRunning = false, + crossOsUnlocked = false, }: CamoufoxConfigDialogProps) { // Use union type to support both Camoufox and Wayfern configs const [config, setConfig] = useState(() => ({ @@ -160,6 +162,7 @@ export function CamoufoxConfigDialog({ onConfigChange={updateConfig} forceAdvanced={true} readOnly={isRunning} + crossOsUnlocked={crossOsUnlocked} /> ) : ( )} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 985ee78..ad8020c 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -71,6 +71,7 @@ interface CreateProfileDialogProps { groupId?: string; }) => Promise; selectedGroupId?: string; + crossOsUnlocked?: boolean; } interface BrowserOption { @@ -106,6 +107,7 @@ export function CreateProfileDialog({ onClose, onCreateProfile, selectedGroupId, + crossOsUnlocked = false, }: CreateProfileDialogProps) { const [profileName, setProfileName] = useState(""); const [currentStep, setCurrentStep] = useState< @@ -677,6 +679,16 @@ export function CreateProfileDialog({ )} + {!isLoadingReleaseTypes && + !releaseTypesError && + !getBestAvailableVersion("wayfern") && ( +
+

+ Wayfern is not available on your platform + yet. +

+
+ )} {!isLoadingReleaseTypes && !releaseTypesError && !isBrowserCurrentlyDownloading("wayfern") && @@ -732,6 +744,7 @@ export function CreateProfileDialog({ config={wayfernConfig} onConfigChange={updateWayfernConfig} isCreating + crossOsUnlocked={crossOsUnlocked} /> ) : selectedBrowser === "camoufox" ? ( @@ -763,6 +776,16 @@ export function CreateProfileDialog({ )} + {!isLoadingReleaseTypes && + !releaseTypesError && + !getBestAvailableVersion("camoufox") && ( +
+

+ Camoufox is not available on your platform + yet. +

+
+ )} {!isLoadingReleaseTypes && !releaseTypesError && !isBrowserCurrentlyDownloading("camoufox") && @@ -819,6 +842,7 @@ export function CreateProfileDialog({ onConfigChange={updateCamoufoxConfig} isCreating browserType="camoufox" + crossOsUnlocked={crossOsUnlocked} /> ) : ( diff --git a/src/components/shared-camoufox-config-form.tsx b/src/components/shared-camoufox-config-form.tsx index 8c18118..6be5577 100644 --- a/src/components/shared-camoufox-config-form.tsx +++ b/src/components/shared-camoufox-config-form.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuLock } from "react-icons/lu"; import MultipleSelector, { type Option } from "@/components/multiple-selector"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; @@ -29,6 +31,7 @@ interface SharedCamoufoxConfigFormProps { forceAdvanced?: boolean; // Force advanced mode (for editing) readOnly?: boolean; // Flag to indicate if the form should be read-only browserType?: "camoufox" | "wayfern"; // Browser type to customize form options + crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature) } // Determine if fingerprint editing should be disabled @@ -118,7 +121,9 @@ export function SharedCamoufoxConfigForm({ forceAdvanced = false, readOnly = false, browserType = "camoufox", + crossOsUnlocked = false, }: SharedCamoufoxConfigFormProps) { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState( forceAdvanced ? "manual" : "automatic", ); @@ -128,7 +133,6 @@ export function SharedCamoufoxConfigForm({ // Get selected OS (defaults to current OS) const selectedOS = config.os || currentOS; - const isOSDifferent = selectedOS !== currentOS; // Set screen resolution to user's screen size when creating a new profile useEffect(() => { @@ -227,18 +231,25 @@ export function SharedCamoufoxConfigForm({ - {osLabels.windows} - {osLabels.macos} - {osLabels.linux} + {(["windows", "macos", "linux"] as CamoufoxOS[]).map((os) => { + const isDisabled = os !== currentOS && !crossOsUnlocked; + return ( + + + {osLabels[os]} + {isDisabled && ( + + )} + + + ); + })} - {isOSDifferent && ( - - - ⚠️ Warning: Selecting an OS different from your current system ( - {osLabels[currentOS]}) increases the risk of detection. Websites - can detect mismatches between your fingerprint and actual system - behavior. + {selectedOS !== currentOS && crossOsUnlocked && ( + + + {t("fingerprint.crossOsWarning")} )} @@ -994,19 +1005,27 @@ export function SharedCamoufoxConfigForm({ - {osLabels.windows} - {osLabels.macos} - {osLabels.linux} + {(["windows", "macos", "linux"] as CamoufoxOS[]).map((os) => { + const isDisabled = os !== currentOS && !crossOsUnlocked; + return ( + + + {osLabels[os]} + {isDisabled && ( + + )} + + + ); + })} - {isOSDifferent && ( - - - ⚠️ Warning: Selecting an OS different from your current - system ({osLabels[currentOS]}) increases the risk of - detection. Websites with advanced protections can detect - mismatches between your fingerprint and actual system - behavior. + {selectedOS !== currentOS && crossOsUnlocked && ( + + + Cross-OS fingerprinting has limitations. System-level APIs + may still reflect your actual operating system, and some + features may have degraded performance. )} diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index a9e6c4c..32fc527 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; import { Button } from "@/components/ui/button"; @@ -15,11 +16,13 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { SyncSettings } from "@/types"; @@ -29,6 +32,9 @@ interface SyncConfigDialogProps { } export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { + const { t } = useTranslation(); + + // Self-hosted state const [serverUrl, setServerUrl] = useState(""); const [token, setToken] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -36,6 +42,24 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { const [isTesting, setIsTesting] = useState(false); const [showToken, setShowToken] = useState(false); + // Cloud auth state + const { + user, + isLoggedIn, + isLoading: isCloudLoading, + requestOtp, + verifyOtp, + logout, + } = useCloudAuth(); + const [email, setEmail] = useState(""); + const [otpCode, setOtpCode] = useState(""); + const [codeSent, setCodeSent] = useState(false); + const [isSendingCode, setIsSendingCode] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + // Default to self-hosted tab if self-hosted is configured and not cloud-logged-in + const [activeTab, setActiveTab] = useState("cloud"); + const loadSettings = useCallback(async () => { setIsLoading(true); try { @@ -52,9 +76,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { useEffect(() => { if (isOpen) { void loadSettings(); + setCodeSent(false); + setOtpCode(""); + setEmail(""); } }, [isOpen, loadSettings]); + // If self-hosted is configured and not cloud-logged-in, default to self-hosted tab + useEffect(() => { + if (!isCloudLoading && !isLoggedIn && serverUrl && token) { + setActiveTab("self-hosted"); + } + }, [isCloudLoading, isLoggedIn, serverUrl, token]); + const handleTestConnection = useCallback(async () => { if (!serverUrl) { showErrorToast("Please enter a server URL"); @@ -112,102 +146,283 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } }, []); + const handleSendCode = useCallback(async () => { + if (!email) return; + setIsSendingCode(true); + try { + await requestOtp(email); + setCodeSent(true); + showSuccessToast(t("sync.cloud.codeSent")); + } catch (error) { + console.error("Failed to send OTP:", error); + showErrorToast(String(error)); + } finally { + setIsSendingCode(false); + } + }, [email, requestOtp, t]); + + const handleVerifyOtp = useCallback(async () => { + if (!email || !otpCode) return; + setIsVerifying(true); + try { + await verifyOtp(email, otpCode); + showSuccessToast(t("sync.cloud.loginSuccess")); + // Restart sync service with cloud credentials + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + } catch (error) { + console.error("OTP verification failed:", error); + showErrorToast(String(error)); + } finally { + setIsVerifying(false); + } + }, [email, otpCode, verifyOtp, t]); + + const handleCloudLogout = useCallback(async () => { + try { + await logout(); + showSuccessToast(t("sync.cloud.logoutSuccess")); + // Restart sync service (will fall back to self-hosted or stop) + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + } catch (error) { + console.error("Failed to logout:", error); + showErrorToast(String(error)); + } + }, [logout, t]); + const isConnected = Boolean(serverUrl && token); return ( - Sync Service - - Configure connection to a sync server to synchronize your profiles - across devices. - + {t("sync.title")} + {t("sync.description")} - {isLoading ? ( -
-
-
- ) : ( -
-
- - setServerUrl(e.target.value)} - /> -
+ + + + {t("sync.cloud.tabLabel")} + + + {t("sync.cloud.selfHostedTabLabel")} + + -
- -
- setToken(e.target.value)} - className="pr-10" - /> - - - - - - {showToken ? "Hide token" : "Show token"} - - + + {isCloudLoading ? ( +
+
-
+ ) : isLoggedIn && user ? ( +
+
+
+ {t("sync.cloud.connected")} +
- {isConnected && ( -
-
- Connected +
+
+ + {t("sync.cloud.email")} + + {user.email} +
+
+ + {t("sync.cloud.plan")} + + + {user.plan} + {user.planPeriod ? ` (${user.planPeriod})` : ""} + +
+
+ + {t("sync.cloud.profiles")} + + + {t("sync.cloud.profileUsage", { + used: user.cloudProfilesUsed, + limit: user.profileLimit, + })} + +
+
+ +
+ + +
+
+ ) : ( +
+
+ +
+ setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !codeSent) { + void handleSendCode(); + } + }} + /> + + {t("sync.cloud.sendCode")} + +
+
+ + {codeSent && ( +
+ + setOtpCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleVerifyOtp(); + } + }} + /> + + {isVerifying + ? t("sync.cloud.loggingIn") + : t("sync.cloud.verifyAndLogin")} + +
+ )}
)} -
- )} + - - {isConnected && ( - - )} - - - Save - - + + {isLoading ? ( +
+
+
+ ) : ( +
+
+ + setServerUrl(e.target.value)} + /> +
+ +
+ +
+ setToken(e.target.value)} + className="pr-10" + /> + + + + + + {showToken ? "Hide token" : "Show token"} + + +
+
+ + {isConnected && ( +
+
+ {t("sync.status.connected")} +
+ )} +
+ )} + + + {isConnected && ( + + )} + + + Save + + + +
); diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index e6816f4..6a5a14f 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { LuLock } from "react-icons/lu"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; @@ -28,6 +29,7 @@ interface WayfernConfigFormProps { isCreating?: boolean; forceAdvanced?: boolean; readOnly?: boolean; + crossOsUnlocked?: boolean; } const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => { @@ -57,7 +59,9 @@ export function WayfernConfigForm({ isCreating = false, forceAdvanced = false, readOnly = false, + crossOsUnlocked = false, }: WayfernConfigFormProps) { + const { t } = useTranslation(); const [activeTab, setActiveTab] = useState( forceAdvanced ? "manual" : "automatic", ); @@ -157,7 +161,7 @@ export function WayfernConfigForm({ {( ["windows", "macos", "linux", "android", "ios"] as WayfernOS[] ).map((os) => { - const isDisabled = os !== currentOS; + const isDisabled = os !== currentOS && !crossOsUnlocked; return ( @@ -171,6 +175,13 @@ export function WayfernConfigForm({ })} + {selectedOS !== currentOS && crossOsUnlocked && ( + + + {t("fingerprint.crossOsWarning")} + + + )} {/* Randomize Fingerprint Option */} @@ -943,7 +954,7 @@ export function WayfernConfigForm({ "ios", ] as WayfernOS[] ).map((os) => { - const isDisabled = os !== currentOS; + const isDisabled = os !== currentOS && !crossOsUnlocked; return ( @@ -957,6 +968,15 @@ export function WayfernConfigForm({ })} + {selectedOS !== currentOS && crossOsUnlocked && ( + + + Cross-OS fingerprinting has limitations. System-level APIs + may still reflect your actual operating system, and some + features may have degraded performance. + + + )} {/* Randomize Fingerprint Option */} diff --git a/src/components/window-drag-area.tsx b/src/components/window-drag-area.tsx index f421890..ea96113 100644 --- a/src/components/window-drag-area.tsx +++ b/src/components/window-drag-area.tsx @@ -3,17 +3,20 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { useEffect, useState } from "react"; +type Platform = "macos" | "windows" | "linux"; + +function detectPlatform(): Platform { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes("mac")) return "macos"; + if (userAgent.includes("win")) return "windows"; + return "linux"; +} + export function WindowDragArea() { - const [isMacOS, setIsMacOS] = useState(false); + const [platform, setPlatform] = useState(null); useEffect(() => { - // Check if we're on macOS using user agent detection - const checkPlatform = () => { - const userAgent = navigator.userAgent.toLowerCase(); - setIsMacOS(userAgent.includes("mac")); - }; - - checkPlatform(); + setPlatform(detectPlatform()); }, []); const handlePointerDown = (e: React.PointerEvent) => { @@ -33,21 +36,97 @@ export function WindowDragArea() { void startDrag(); }; - // Only render on macOS and when no dialogs are open - if (!isMacOS) { + // Linux: system decorations handle everything + if (!platform || platform === "linux") { return null; } + // macOS: transparent drag area overlay + if (platform === "macos") { + return ( + + + + ); } diff --git a/src/hooks/use-cloud-auth.ts b/src/hooks/use-cloud-auth.ts new file mode 100644 index 0000000..ee6eccd --- /dev/null +++ b/src/hooks/use-cloud-auth.ts @@ -0,0 +1,86 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { CloudAuthState, CloudUser } from "@/types"; + +interface UseCloudAuthReturn { + user: CloudUser | null; + isLoggedIn: boolean; + isLoading: boolean; + requestOtp: (email: string) => Promise; + verifyOtp: (email: string, code: string) => Promise; + logout: () => Promise; + refreshProfile: () => Promise; +} + +export function useCloudAuth(): UseCloudAuthReturn { + const [authState, setAuthState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const loadUser = useCallback(async () => { + try { + const state = await invoke("cloud_get_user"); + setAuthState(state); + } catch (error) { + console.error("Failed to load cloud auth state:", error); + setAuthState(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadUser(); + + const unlistenPromise = listen("cloud-auth-expired", () => { + setAuthState(null); + }); + + return () => { + void unlistenPromise.then((unlisten) => { + unlisten(); + }); + }; + }, [loadUser]); + + const requestOtp = useCallback(async (email: string): Promise => { + return invoke("cloud_request_otp", { email }); + }, []); + + const verifyOtp = useCallback( + async (email: string, code: string): Promise => { + const state = await invoke("cloud_verify_otp", { + email, + code, + }); + setAuthState(state); + return state; + }, + [], + ); + + const logout = useCallback(async () => { + await invoke("cloud_logout"); + setAuthState(null); + }, []); + + const refreshProfile = useCallback(async (): Promise => { + const user = await invoke("cloud_refresh_profile"); + setAuthState((prev) => + prev + ? { ...prev, user } + : { user, logged_in_at: new Date().toISOString() }, + ); + return user; + }, []); + + return { + user: authState?.user ?? null, + isLoggedIn: authState !== null, + isLoading, + requestOtp, + verifyOtp, + logout, + refreshProfile, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f3c2735..381c07b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -274,7 +274,28 @@ "syncing": "Syncing...", "error": "Sync Error" }, - "description": "Connect to a sync server to synchronize your profiles, proxies, and groups across devices." + "description": "Connect to a sync server to synchronize your profiles, proxies, and groups across devices.", + "cloud": { + "tabLabel": "Cloud", + "selfHostedTabLabel": "Self-Hosted", + "email": "Email", + "emailPlaceholder": "you@example.com", + "sendCode": "Send Code", + "codeSent": "Code sent! Check your email.", + "verificationCode": "Verification Code", + "codePlaceholder": "Enter 6-digit code", + "verifyAndLogin": "Verify & Log In", + "loggingIn": "Logging in...", + "connected": "Connected", + "plan": "Plan", + "profiles": "Profiles", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "Manage Account", + "logout": "Log Out", + "logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.", + "loginSuccess": "Successfully logged in!", + "logoutSuccess": "Successfully logged out." + } }, "integrations": { "title": "Integrations", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "Spoofing a different operating system is harder — system-level APIs are more difficult to mask, making it easier for websites to detect inconsistencies. No anti-detect browser can perfectly spoof every detail across operating systems." } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 436a61a..f831e8a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -274,7 +274,28 @@ "syncing": "Sincronizando...", "error": "Error de Sincronización" }, - "description": "Conéctate a un servidor de sincronización para sincronizar tus perfiles, proxies y grupos entre dispositivos." + "description": "Conéctate a un servidor de sincronización para sincronizar tus perfiles, proxies y grupos entre dispositivos.", + "cloud": { + "tabLabel": "Nube", + "selfHostedTabLabel": "Autoalojado", + "email": "Correo electrónico", + "emailPlaceholder": "tu@ejemplo.com", + "sendCode": "Enviar Código", + "codeSent": "¡Código enviado! Revisa tu correo electrónico.", + "verificationCode": "Código de Verificación", + "codePlaceholder": "Ingresa el código de 6 dígitos", + "verifyAndLogin": "Verificar e Iniciar Sesión", + "loggingIn": "Iniciando sesión...", + "connected": "Conectado", + "plan": "Plan", + "profiles": "Perfiles", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "Administrar Cuenta", + "logout": "Cerrar Sesión", + "logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.", + "loginSuccess": "¡Sesión iniciada exitosamente!", + "logoutSuccess": "Sesión cerrada exitosamente." + } }, "integrations": { "title": "Integraciones", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "Suplantar un sistema operativo diferente es más difícil: las API a nivel de sistema son más difíciles de enmascarar, lo que facilita que los sitios web detecten inconsistencias. Ningún navegador antidetección puede suplantar perfectamente cada detalle entre sistemas operativos." } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 8bc6332..8de222a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -274,7 +274,28 @@ "syncing": "Synchronisation...", "error": "Erreur de synchronisation" }, - "description": "Connectez-vous à un serveur de synchronisation pour synchroniser vos profils, proxies et groupes entre appareils." + "description": "Connectez-vous à un serveur de synchronisation pour synchroniser vos profils, proxies et groupes entre appareils.", + "cloud": { + "tabLabel": "Cloud", + "selfHostedTabLabel": "Auto-hébergé", + "email": "E-mail", + "emailPlaceholder": "vous@exemple.com", + "sendCode": "Envoyer le Code", + "codeSent": "Code envoyé ! Vérifiez votre e-mail.", + "verificationCode": "Code de Vérification", + "codePlaceholder": "Entrez le code à 6 chiffres", + "verifyAndLogin": "Vérifier et Se Connecter", + "loggingIn": "Connexion en cours...", + "connected": "Connecté", + "plan": "Forfait", + "profiles": "Profils", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "Gérer le Compte", + "logout": "Se Déconnecter", + "logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.", + "loginSuccess": "Connexion réussie !", + "logoutSuccess": "Déconnexion réussie." + } }, "integrations": { "title": "Intégrations", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "Usurper un système d'exploitation différent est plus difficile : les API au niveau du système sont plus difficiles à masquer, ce qui permet aux sites web de détecter plus facilement les incohérences. Aucun navigateur anti-détection ne peut parfaitement usurper chaque détail d'un système d'exploitation à l'autre." } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 36f2efc..6d465f6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -274,7 +274,28 @@ "syncing": "同期中...", "error": "同期エラー" }, - "description": "同期サーバーに接続して、デバイス間でプロファイル、プロキシ、グループを同期します。" + "description": "同期サーバーに接続して、デバイス間でプロファイル、プロキシ、グループを同期します。", + "cloud": { + "tabLabel": "クラウド", + "selfHostedTabLabel": "セルフホスト", + "email": "メールアドレス", + "emailPlaceholder": "you@example.com", + "sendCode": "コードを送信", + "codeSent": "コードを送信しました!メールを確認してください。", + "verificationCode": "認証コード", + "codePlaceholder": "6桁のコードを入力", + "verifyAndLogin": "認証してログイン", + "loggingIn": "ログイン中...", + "connected": "接続済み", + "plan": "プラン", + "profiles": "プロファイル", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "アカウント管理", + "logout": "ログアウト", + "logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。", + "loginSuccess": "ログインに成功しました!", + "logoutSuccess": "ログアウトしました。" + } }, "integrations": { "title": "統合", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "異なるオペレーティングシステムの偽装はより困難です。システムレベルのAPIはマスクしにくく、ウェブサイトが矛盾を検出しやすくなります。どのアンチディテクトブラウザも、異なるOS間のすべての詳細を完璧に偽装することはできません。" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 94b45e1..f62ffa1 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -274,7 +274,28 @@ "syncing": "Sincronizando...", "error": "Erro de Sincronização" }, - "description": "Conecte-se a um servidor de sincronização para sincronizar seus perfis, proxies e grupos entre dispositivos." + "description": "Conecte-se a um servidor de sincronização para sincronizar seus perfis, proxies e grupos entre dispositivos.", + "cloud": { + "tabLabel": "Nuvem", + "selfHostedTabLabel": "Auto-hospedado", + "email": "E-mail", + "emailPlaceholder": "voce@exemplo.com", + "sendCode": "Enviar Código", + "codeSent": "Código enviado! Verifique seu e-mail.", + "verificationCode": "Código de Verificação", + "codePlaceholder": "Digite o código de 6 dígitos", + "verifyAndLogin": "Verificar e Entrar", + "loggingIn": "Entrando...", + "connected": "Conectado", + "plan": "Plano", + "profiles": "Perfis", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "Gerenciar Conta", + "logout": "Sair", + "logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.", + "loginSuccess": "Login realizado com sucesso!", + "logoutSuccess": "Logout realizado com sucesso." + } }, "integrations": { "title": "Integrações", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "Falsificar um sistema operacional diferente é mais difícil: as APIs de nível de sistema são mais difíceis de mascarar, facilitando a detecção de inconsistências pelos sites. Nenhum navegador antidetecção consegue falsificar perfeitamente todos os detalhes entre sistemas operacionais." } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 64a04c9..d07f052 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -274,7 +274,28 @@ "syncing": "Синхронизация...", "error": "Ошибка синхронизации" }, - "description": "Подключитесь к серверу синхронизации для синхронизации профилей, прокси и групп между устройствами." + "description": "Подключитесь к серверу синхронизации для синхронизации профилей, прокси и групп между устройствами.", + "cloud": { + "tabLabel": "Облако", + "selfHostedTabLabel": "Свой сервер", + "email": "Электронная почта", + "emailPlaceholder": "вы@пример.com", + "sendCode": "Отправить код", + "codeSent": "Код отправлен! Проверьте вашу почту.", + "verificationCode": "Код подтверждения", + "codePlaceholder": "Введите 6-значный код", + "verifyAndLogin": "Подтвердить и Войти", + "loggingIn": "Вход...", + "connected": "Подключено", + "plan": "Тариф", + "profiles": "Профили", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "Управление аккаунтом", + "logout": "Выйти", + "logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.", + "loginSuccess": "Вход выполнен успешно!", + "logoutSuccess": "Выход выполнен успешно." + } }, "integrations": { "title": "Интеграции", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "Подмена другой операционной системы сложнее — системные API труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы." } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c287789..976e950 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -274,7 +274,28 @@ "syncing": "同步中...", "error": "同步错误" }, - "description": "连接到同步服务器以在设备之间同步您的配置文件、代理和分组。" + "description": "连接到同步服务器以在设备之间同步您的配置文件、代理和分组。", + "cloud": { + "tabLabel": "云端", + "selfHostedTabLabel": "自托管", + "email": "电子邮件", + "emailPlaceholder": "you@example.com", + "sendCode": "发送验证码", + "codeSent": "验证码已发送!请检查您的邮箱。", + "verificationCode": "验证码", + "codePlaceholder": "输入6位验证码", + "verifyAndLogin": "验证并登录", + "loggingIn": "登录中...", + "connected": "已连接", + "plan": "套餐", + "profiles": "配置文件", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "管理账户", + "logout": "退出登录", + "logoutConfirm": "您确定要退出登录吗?云同步将会停止。", + "loginSuccess": "登录成功!", + "logoutSuccess": "已成功退出登录。" + } }, "integrations": { "title": "集成", @@ -457,5 +478,8 @@ "zen": "Zen Browser", "camoufox": "Camoufox", "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "伪装不同的操作系统更加困难——系统级API更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。" } } diff --git a/src/types.ts b/src/types.ts index 916adde..52c2050 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,23 @@ export interface SyncSettings { sync_token?: string; } +export interface CloudUser { + id: string; + email: string; + plan: string; + planPeriod: string; + subscriptionStatus: string; + profileLimit: number; + cloudProfilesUsed: number; + proxyBandwidthLimitMb: number; + proxyBandwidthUsedMb: number; +} + +export interface CloudAuthState { + user: CloudUser; + logged_in_at: string; +} + export interface ProfileSyncStatusEvent { profile_id: string; status: "disabled" | "syncing" | "synced" | "error" | "pending";