feat: windows support

This commit is contained in:
zhom
2026-02-15 11:48:59 +04:00
parent dd5afac951
commit 63453331ff
46 changed files with 2445 additions and 328 deletions
+2
View File
@@ -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",
+48 -7
View File
@@ -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)
}
+34 -13
View File
@@ -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,
})),
+241 -27
View File
@@ -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
View File
@@ -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",
+162 -57
View File
@@ -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:
+17 -3
View File
@@ -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
}
+15
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
#include <winuser.h>
1 RT_MANIFEST "app.manifest"
+51 -1
View File
@@ -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");
}
}
+80
View File
@@ -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");
+26
View File
@@ -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

+13 -10
View File
@@ -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
+25 -13
View File
@@ -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));
}
};
+20 -4
View File
@@ -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")
+704
View File
@@ -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(())
}
+19 -43
View File
@@ -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();
+4
View File
@@ -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
View File
@@ -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());
}
}
+6 -1
View File
@@ -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() {
+29 -5
View File
@@ -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();
+13
View File
@@ -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()
+12
View File
@@ -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()
+5 -4
View File
@@ -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)]
+14
View File
@@ -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()
+17
View File
@@ -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;
+6 -1
View File
@@ -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
View File
@@ -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>
+24
View File
@@ -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>
) : (
+41 -22
View File
@@ -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>
)}
+297 -82
View File
@@ -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>
);
+22 -2
View File
@@ -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 */}
+98 -19
View File
@@ -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>
);
}
+86
View File
@@ -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,
};
}
+25 -1
View File
@@ -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."
}
}
+25 -1
View File
@@ -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."
}
}
+25 -1
View File
@@ -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."
}
}
+25 -1
View File
@@ -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間のすべての詳細を完璧に偽装することはできません。"
}
}
+25 -1
View File
@@ -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."
}
}
+25 -1
View File
@@ -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 труднее замаскировать, что упрощает обнаружение несоответствий веб-сайтами. Ни один антидетект-браузер не может идеально подменить все детали при смене операционной системы."
}
}
+25 -1
View File
@@ -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更难以掩盖,使网站更容易检测到不一致之处。没有任何反检测浏览器能够完美伪装跨操作系统的所有细节。"
}
}
+17
View File
@@ -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";