mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-02 00:25:11 +02:00
feat: windows support
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string>("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<Request>();
|
||||
@@ -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<string>("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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<StatResponseDto> {
|
||||
return this.syncService.stat(dto);
|
||||
async stat(
|
||||
@Body() dto: StatRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<StatResponseDto> {
|
||||
return this.syncService.stat(dto, this.getUserContext(req));
|
||||
}
|
||||
|
||||
@Post("presign-upload")
|
||||
@HttpCode(200)
|
||||
async presignUpload(
|
||||
@Body() dto: PresignUploadRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<PresignUploadResponseDto> {
|
||||
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<PresignDownloadResponseDto> {
|
||||
return this.syncService.presignDownload(dto);
|
||||
return this.syncService.presignDownload(dto, this.getUserContext(req));
|
||||
}
|
||||
|
||||
@Post("delete")
|
||||
@HttpCode(200)
|
||||
async delete(@Body() dto: DeleteRequestDto): Promise<DeleteResponseDto> {
|
||||
return this.syncService.delete(dto);
|
||||
async delete(
|
||||
@Body() dto: DeleteRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<DeleteResponseDto> {
|
||||
return this.syncService.delete(dto, this.getUserContext(req));
|
||||
}
|
||||
|
||||
@Post("list")
|
||||
@HttpCode(200)
|
||||
async list(@Body() dto: ListRequestDto): Promise<ListResponseDto> {
|
||||
return this.syncService.list(dto);
|
||||
async list(
|
||||
@Body() dto: ListRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<ListResponseDto> {
|
||||
return this.syncService.list(dto, this.getUserContext(req));
|
||||
}
|
||||
|
||||
@Post("presign-upload-batch")
|
||||
@HttpCode(200)
|
||||
async presignUploadBatch(
|
||||
@Body() dto: PresignUploadBatchRequestDto,
|
||||
@Req() req: Request,
|
||||
): Promise<PresignUploadBatchResponseDto> {
|
||||
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<PresignDownloadBatchResponseDto> {
|
||||
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<DeletePrefixResponseDto> {
|
||||
return this.syncService.deletePrefix(dto);
|
||||
return this.syncService.deletePrefix(dto, this.getUserContext(req));
|
||||
}
|
||||
|
||||
@Get("subscribe")
|
||||
@Sse()
|
||||
subscribe(): Observable<MessageEvent> {
|
||||
return this.syncService.subscribe(2000).pipe(
|
||||
subscribe(@Req() req: Request): Observable<MessageEvent> {
|
||||
return this.syncService.subscribe(this.getUserContext(req), 2000).pipe(
|
||||
map((event) => ({
|
||||
data: event,
|
||||
})),
|
||||
|
||||
@@ -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<string, string> = new Map();
|
||||
private changeSubject = new Subject<SubscribeEventDto>();
|
||||
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<string>(
|
||||
"BACKEND_INTERNAL_URL",
|
||||
);
|
||||
this.backendInternalKey = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_KEY",
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -124,12 +139,37 @@ export class SyncService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
async stat(dto: StatRequestDto): Promise<StatResponseDto> {
|
||||
/**
|
||||
* 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<StatResponseDto> {
|
||||
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<PresignUploadResponseDto> {
|
||||
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<PresignDownloadResponseDto> {
|
||||
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<DeleteResponseDto> {
|
||||
async delete(
|
||||
dto: DeleteRequestDto,
|
||||
ctx: UserContext,
|
||||
): Promise<DeleteResponseDto> {
|
||||
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<ListResponseDto> {
|
||||
async list(dto: ListRequestDto, ctx?: UserContext): Promise<ListResponseDto> {
|
||||
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<PresignUploadBatchResponseDto> {
|
||||
// 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<PresignDownloadBatchResponseDto> {
|
||||
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<DeletePrefixResponseDto> {
|
||||
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<SubscribeEventDto> {
|
||||
const prefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
|
||||
subscribe(
|
||||
ctx: UserContext,
|
||||
pollIntervalMs = 2000,
|
||||
): Observable<SubscribeEventDto> {
|
||||
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<string, string>();
|
||||
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
Generated
+162
-57
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
@@ -0,0 +1,2 @@
|
||||
#include <winuser.h>
|
||||
1 RT_MANIFEST "app.manifest"
|
||||
+51
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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")"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<Option<CloudAuthState>>,
|
||||
}
|
||||
|
||||
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::<Aes256Gcm>::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<Option<String>, 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::<Aes256Gcm>::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<Option<String>, 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<Option<String>, 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<Option<String>, 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<CloudAuthState> {
|
||||
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<String, String> {
|
||||
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<CloudAuthState, String> {
|
||||
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<CloudUser, String> {
|
||||
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::<CloudUser>()
|
||||
.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<Option<String>, 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<CloudAuthState> {
|
||||
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<F, Fut, T>(&self, make_request: F) -> Result<T, String>
|
||||
where
|
||||
F: Fn(String) -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<T, String>> + 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<String, String> {
|
||||
CLOUD_AUTH.request_otp(&email).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_verify_otp(
|
||||
app_handle: tauri::AppHandle,
|
||||
email: String,
|
||||
code: String,
|
||||
) -> Result<CloudAuthState, String> {
|
||||
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<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
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<bool, String> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<u16>) {
|
||||
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(
|
||||
|
||||
@@ -1191,6 +1191,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ensure_active_browsers_downloaded(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<String>, 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<Vec<String>, String> {
|
||||
let registry = DownloadedBrowsersRegistry::instance();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+32
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<SyncSettings, String> {
|
||||
// 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()
|
||||
|
||||
@@ -24,6 +24,18 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result<Self, 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}"))?
|
||||
.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()
|
||||
|
||||
@@ -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<Arc<SyncScheduler>> = OnceCell::new();
|
||||
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
|
||||
|
||||
pub fn get_global_scheduler() -> Option<Arc<SyncScheduler>> {
|
||||
GLOBAL_SCHEDULER.get().cloned()
|
||||
GLOBAL_SCHEDULER.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
pub fn set_global_scheduler(scheduler: Arc<SyncScheduler>) {
|
||||
let _ = GLOBAL_SCHEDULER.set(scheduler);
|
||||
if let Ok(mut g) = GLOBAL_SCHEDULER.lock() {
|
||||
*g = Some(scheduler);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -53,6 +53,20 @@ impl SyncSubscription {
|
||||
app_handle: &tauri::AppHandle,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Result<Option<Self>, 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()
|
||||
|
||||
@@ -265,6 +265,23 @@ impl VersionUpdater {
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<String> =
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|p| p.browser.clone())
|
||||
.collect();
|
||||
|
||||
let supported_browsers: Vec<String> = 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;
|
||||
|
||||
@@ -16,11 +16,16 @@ async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error +
|
||||
.to_path_buf();
|
||||
|
||||
// Build donut-proxy binary if it doesn't exist
|
||||
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);
|
||||
|
||||
if !proxy_binary.exists() {
|
||||
println!("Building donut-proxy binary for integration tests...");
|
||||
|
||||
+17
-2
@@ -26,6 +26,7 @@ import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
@@ -88,6 +89,11 @@ export default function Home() {
|
||||
checkTrialStatus,
|
||||
} = useCommercialTrial();
|
||||
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked =
|
||||
cloudUser?.plan !== "free" && cloudUser?.subscriptionStatus === "active";
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -719,6 +725,13 @@ export default function Home() {
|
||||
void checkMissingBinaries();
|
||||
}
|
||||
|
||||
// Proactively download Wayfern and Camoufox if not already available
|
||||
if (!profilesLoading) {
|
||||
void invoke("ensure_active_browsers_downloaded").catch((err: unknown) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
@@ -988,6 +1002,7 @@ export default function Home() {
|
||||
? runningProfiles.has(currentProfileForCamoufoxConfig.id)
|
||||
: false
|
||||
}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<GroupManagementDialog
|
||||
|
||||
@@ -39,6 +39,7 @@ interface CamoufoxConfigDialogProps {
|
||||
config: CamoufoxConfig,
|
||||
) => Promise<void>;
|
||||
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<CamoufoxConfig | WayfernConfig>(() => ({
|
||||
@@ -160,6 +162,7 @@ export function CamoufoxConfigDialog({
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
<SharedCamoufoxConfigForm
|
||||
@@ -168,6 +171,7 @@ export function CamoufoxConfigDialog({
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ interface CreateProfileDialogProps {
|
||||
groupId?: string;
|
||||
}) => Promise<void>;
|
||||
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({
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
|
||||
<p className="text-sm text-yellow-500">
|
||||
Wayfern is not available on your platform
|
||||
yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
@@ -732,6 +744,7 @@ export function CreateProfileDialog({
|
||||
config={wayfernConfig}
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
@@ -763,6 +776,16 @@ export function CreateProfileDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
|
||||
<p className="text-sm text-yellow-500">
|
||||
Camoufox is not available on your platform
|
||||
yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
@@ -819,6 +842,7 @@ export function CreateProfileDialog({
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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({
|
||||
<SelectValue placeholder="Select operating system" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="windows">{osLabels.windows}</SelectItem>
|
||||
<SelectItem value="macos">{osLabels.macos}</SelectItem>
|
||||
<SelectItem value="linux">{osLabels.linux}</SelectItem>
|
||||
{(["windows", "macos", "linux"] as CamoufoxOS[]).map((os) => {
|
||||
const isDisabled = os !== currentOS && !crossOsUnlocked;
|
||||
return (
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isOSDifferent && (
|
||||
<Alert className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠️ 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 && (
|
||||
<Alert className="mt-2">
|
||||
<AlertDescription>
|
||||
{t("fingerprint.crossOsWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -994,19 +1005,27 @@ export function SharedCamoufoxConfigForm({
|
||||
<SelectValue placeholder="Select operating system" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="windows">{osLabels.windows}</SelectItem>
|
||||
<SelectItem value="macos">{osLabels.macos}</SelectItem>
|
||||
<SelectItem value="linux">{osLabels.linux}</SelectItem>
|
||||
{(["windows", "macos", "linux"] as CamoufoxOS[]).map((os) => {
|
||||
const isDisabled = os !== currentOS && !crossOsUnlocked;
|
||||
return (
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isOSDifferent && (
|
||||
<Alert className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠️ 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 && (
|
||||
<Alert className="mt-2">
|
||||
<AlertDescription>
|
||||
Cross-OS fingerprinting has limitations. System-level APIs
|
||||
may still reflect your actual operating system, and some
|
||||
features may have degraded performance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -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<string>("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 (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure connection to a sync server to synchronize your profiles
|
||||
across devices.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("sync.title")}</DialogTitle>
|
||||
<DialogDescription>{t("sync.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-server-url">Server URL</Label>
|
||||
<Input
|
||||
id="sync-server-url"
|
||||
placeholder="https://sync.example.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="cloud" className="flex-1">
|
||||
{t("sync.cloud.tabLabel")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="self-hosted" className="flex-1">
|
||||
{t("sync.cloud.selfHostedTabLabel")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-token">Access Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="sync-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="Enter your sync token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
) : (
|
||||
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? "Hide token" : "Show token"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TabsContent value="cloud">
|
||||
{isCloudLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
) : isLoggedIn && user ? (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{t("sync.cloud.connected")}
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
Connected
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.cloud.email")}
|
||||
</span>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.cloud.plan")}
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
{user.plan}
|
||||
{user.planPeriod ? ` (${user.planPeriod})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.cloud.profiles")}
|
||||
</span>
|
||||
<span>
|
||||
{t("sync.cloud.profileUsage", {
|
||||
used: user.cloudProfilesUsed,
|
||||
limit: user.profileLimit,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" asChild>
|
||||
<a
|
||||
href="https://donutbrowser.com/account"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("sync.cloud.manageAccount")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleCloudLogout}
|
||||
>
|
||||
{t("sync.cloud.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloud-email">{t("sync.cloud.email")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cloud-email"
|
||||
type="email"
|
||||
placeholder={t("sync.cloud.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !codeSent) {
|
||||
void handleSendCode();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleSendCode}
|
||||
isLoading={isSendingCode}
|
||||
disabled={!email || codeSent}
|
||||
variant="outline"
|
||||
>
|
||||
{t("sync.cloud.sendCode")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{codeSent && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cloud-otp">
|
||||
{t("sync.cloud.verificationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cloud-otp"
|
||||
placeholder={t("sync.cloud.codePlaceholder")}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleVerifyOtp();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<LoadingButton
|
||||
onClick={handleVerifyOtp}
|
||||
isLoading={isVerifying}
|
||||
disabled={!otpCode}
|
||||
className="w-full"
|
||||
>
|
||||
{isVerifying
|
||||
? t("sync.cloud.loggingIn")
|
||||
: t("sync.cloud.verifyAndLogin")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !serverUrl}
|
||||
>
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
disabled={!serverUrl || !token}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
<TabsContent value="self-hosted">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-server-url">{t("sync.serverUrl")}</Label>
|
||||
<Input
|
||||
id="sync-server-url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-token">{t("sync.token")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="sync-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
) : (
|
||||
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? "Hide token" : "Show token"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{t("sync.status.connected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !serverUrl}
|
||||
>
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
disabled={!serverUrl || !token}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -171,6 +175,13 @@ export function WayfernConfigForm({
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedOS !== currentOS && crossOsUnlocked && (
|
||||
<Alert className="mt-2">
|
||||
<AlertDescription>
|
||||
{t("fingerprint.crossOsWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -957,6 +968,15 @@ export function WayfernConfigForm({
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedOS !== currentOS && crossOsUnlocked && (
|
||||
<Alert className="mt-2">
|
||||
<AlertDescription>
|
||||
Cross-OS fingerprinting has limitations. System-level APIs
|
||||
may still reflect your actual operating system, and some
|
||||
features may have degraded performance.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Randomize Fingerprint Option */}
|
||||
|
||||
@@ -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<Platform | null>(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 (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Windows: custom title bar with drag area + minimize/close buttons
|
||||
const handleMinimize = async () => {
|
||||
try {
|
||||
await getCurrentWindow().minimize();
|
||||
} catch (error) {
|
||||
console.error("Failed to minimize window:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
try {
|
||||
await getCurrentWindow().close();
|
||||
} catch (error) {
|
||||
console.error("Failed to close window:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
|
||||
<div
|
||||
className="fixed top-0 right-0 left-0 h-10 z-[999999] flex items-center select-none"
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* Draggable area */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 h-full bg-transparent border-0 cursor-default"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
{/* Window control buttons */}
|
||||
<div className="flex items-center h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMinimize}
|
||||
className="flex items-center justify-center w-12 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center w-12 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label="Close"
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
verifyOtp: (email: string, code: string) => Promise<CloudAuthState>;
|
||||
logout: () => Promise<void>;
|
||||
refreshProfile: () => Promise<CloudUser>;
|
||||
}
|
||||
|
||||
export function useCloudAuth(): UseCloudAuthReturn {
|
||||
const [authState, setAuthState] = useState<CloudAuthState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
try {
|
||||
const state = await invoke<CloudAuthState | null>("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<string> => {
|
||||
return invoke<string>("cloud_request_otp", { email });
|
||||
}, []);
|
||||
|
||||
const verifyOtp = useCallback(
|
||||
async (email: string, code: string): Promise<CloudAuthState> => {
|
||||
const state = await invoke<CloudAuthState>("cloud_verify_otp", {
|
||||
email,
|
||||
code,
|
||||
});
|
||||
setAuthState(state);
|
||||
return state;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await invoke("cloud_logout");
|
||||
setAuthState(null);
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async (): Promise<CloudUser> => {
|
||||
const user = await invoke<CloudUser>("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,
|
||||
};
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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間のすべての詳細を完璧に偽装することはできません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user